From ca1d913d06ba93fcd31ea1a47d6c487dc9421d84 Mon Sep 17 00:00:00 2001 From: erangi-ar Date: Mon, 19 Jan 2026 11:02:42 +0530 Subject: [PATCH 001/105] helm charts for byk and rag components --- kubernetes/CONTAINER_REGISTRY_SETUP.md | 35 ++++ kubernetes/Chart.lock | 75 +++++++ kubernetes/Chart.yaml | 80 ++++++++ .../charts/Authentication-Layer/Chart.yaml | 6 + .../deployment-byk-authentication-layer.yaml | 34 ++++ .../ingress-byk-authentication-layer.yaml | 29 +++ .../service-byk-authentication-layer.yaml | 17 ++ .../charts/Authentication-Layer/values.yaml | 35 ++++ kubernetes/charts/ClickHouse/Chart.yaml | 6 + .../templates/deployment-byk-clickhouse.yaml | 86 ++++++++ .../ClickHouse/templates/pvc-clickhouse.yaml | 37 ++++ .../templates/service-byk-clickhouse.yaml | 22 +++ kubernetes/charts/ClickHouse/values.yaml | 60 ++++++ kubernetes/charts/CronManager/Chart.yaml | 6 + .../configmap-cronmanager-config.yaml | 15 ++ .../templates/deployment-byk-cronmanager.yaml | 103 ++++++++++ .../templates/pvc-cronmanager.yaml | 17 ++ .../templates/service-byk-cronmanager.yaml | 17 ++ kubernetes/charts/CronManager/values.yaml | 43 ++++ kubernetes/charts/DataMapper/Chart.yaml | 6 + .../templates/deployment-byk-data-mapper.yaml | 63 ++++++ .../templates/service-byk-data-mapper.yaml | 17 ++ kubernetes/charts/DataMapper/values.yaml | 31 +++ kubernetes/charts/GUI/Chart.yaml | 6 + .../GUI/templates/deployment-byk-gui.yaml | 87 ++++++++ .../charts/GUI/templates/ingress-byk-gui.yaml | 20 ++ .../charts/GUI/templates/service-byk-gui.yaml | 15 ++ kubernetes/charts/GUI/values.yaml | 62 ++++++ kubernetes/charts/Grafana/Chart.yaml | 6 + .../grafana-dashboard-deployment.json | 167 ++++++++++++++++ .../templates/configmap-dashboards.yaml | 11 ++ .../Grafana/templates/configmap-grafana.yaml | 39 ++++ .../Grafana/templates/deployment-grafana.yaml | 73 +++++++ .../charts/Grafana/templates/pvc-grafana.yaml | 17 ++ .../Grafana/templates/service-grafana.yaml | 15 ++ kubernetes/charts/Grafana/values.yaml | 62 ++++++ .../LLM-Orchestration-Service/Chart.yaml | 6 + .../deployment-byk-llm-orchestration.yaml | 166 ++++++++++++++++ .../templates/pvc-volumes.yaml | 61 ++++++ .../service-byk-llm-orchestration.yaml | 18 ++ .../LLM-Orchestration-Service/values.yaml | 86 ++++++++ kubernetes/charts/Langfuse-Web/Chart.yaml | 6 + .../deployment-byk-langfuse-web.yaml | 59 ++++++ .../templates/service-byk-langfuse-web.yaml | 18 ++ kubernetes/charts/Langfuse-Web/values.yaml | 104 ++++++++++ kubernetes/charts/Langfuse-Worker/Chart.yaml | 6 + .../deployment-byk-langfuse-worker.yaml | 59 ++++++ .../service-byk-langfuse-worker.yaml | 18 ++ kubernetes/charts/Langfuse-Worker/values.yaml | 91 +++++++++ kubernetes/charts/Liquibase/Chart.yaml | 6 + .../Liquibase/templates/liquibase-job.yaml | 60 ++++++ kubernetes/charts/Liquibase/values.yaml | 21 ++ kubernetes/charts/Loki/Chart.yaml | 6 + .../charts/Loki/templates/configmap-loki.yaml | 9 + .../Loki/templates/deployment-loki.yaml | 43 ++++ .../charts/Loki/templates/pvc-loki.yaml | 17 ++ .../charts/Loki/templates/service-loki.yaml | 17 ++ kubernetes/charts/Loki/values.yaml | 85 ++++++++ kubernetes/charts/Qdrant/Chart.yaml | 6 + .../Qdrant/templates/service-byk-qdrant.yaml | 31 +++ .../templates/statefulset-byk-qdrant.yaml | 82 ++++++++ kubernetes/charts/Qdrant/values.yaml | 50 +++++ kubernetes/charts/Redis/Chart.yaml | 6 + .../Redis/templates/deployment-byk-redis.yaml | 68 +++++++ .../Redis/templates/service-byk-redis.yaml | 18 ++ kubernetes/charts/Redis/values.yaml | 40 ++++ kubernetes/charts/Resql/Chart.yaml | 6 + .../Resql/templates/deployment-byk-resql.yaml | 68 +++++++ .../Resql/templates/service-byk-resql.yaml | 14 ++ kubernetes/charts/Resql/values.yaml | 32 +++ kubernetes/charts/Ruuter-Private/Chart.yaml | 6 + .../configmap-byk-ruuter-private.yaml | 19 ++ .../deployment-byk-ruuter-private.yaml | 88 +++++++++ .../templates/ingress-ruuter-private.yaml | 46 +++++ .../templates/service-byk-ruuter-private.yaml | 17 ++ kubernetes/charts/Ruuter-Private/values.yaml | 56 ++++++ kubernetes/charts/Ruuter-Public/Chart.yaml | 6 + .../configmap-byk-ruuter-public.yaml | 19 ++ .../deployment-byk-ruuter-public.yaml | 87 ++++++++ .../templates/ingress-ruuter-public.yaml | 45 +++++ .../templates/service-byk-ruuter-public.yaml | 18 ++ kubernetes/charts/Ruuter-Public/values.yaml | 51 +++++ kubernetes/charts/S3-Ferry/Chart.yaml | 6 + .../S3-Ferry/templates/configmap-s3.yaml | 10 + .../S3-Ferry/templates/deployment-s3.yaml | 50 +++++ .../charts/S3-Ferry/templates/pvc-s3.yaml | 36 ++++ .../charts/S3-Ferry/templates/service-s3.yaml | 15 ++ kubernetes/charts/S3-Ferry/values.yaml | 61 ++++++ kubernetes/charts/TIM-database/Chart.yaml | 6 + .../templates/deployment-byk-timdb.yaml | 42 ++++ .../TIM-database/templates/pvc-byk-timdb.yaml | 19 ++ .../templates/secret-byk-timdb.yaml | 9 + .../templates/service-byk-timdb.yaml | 15 ++ kubernetes/charts/TIM-database/values.yaml | 29 +++ kubernetes/charts/TIM/Chart.yaml | 6 + .../TIM/templates/configmap-byk-tim.yaml | 56 ++++++ .../TIM/templates/deployment-byk-tim.yaml | 46 +++++ kubernetes/charts/TIM/templates/ingress.yaml | 30 +++ .../charts/TIM/templates/secret-byk-tim.yaml | 9 + .../charts/TIM/templates/service-byk-tim.yaml | 15 ++ kubernetes/charts/TIM/values.yaml | 45 +++++ kubernetes/charts/Vault-Agent-LLM/Chart.yaml | 7 + .../Vault-Agent-LLM/templates/configmap.yaml | 48 +++++ .../Vault-Agent-LLM/templates/deployment.yaml | 101 ++++++++++ kubernetes/charts/Vault-Agent-LLM/values.yaml | 101 ++++++++++ kubernetes/charts/Vault-Init/Chart.yaml | 6 + .../Vault-Init/templates/configmap.yaml | 186 ++++++++++++++++++ .../charts/Vault-Init/templates/job.yaml | 93 +++++++++ .../charts/Vault-Init/templates/pvc.yaml | 37 ++++ kubernetes/charts/Vault-Init/values.yaml | 60 ++++++ kubernetes/charts/Vault/Chart.yaml | 6 + .../charts/Vault/templates/configmap.yaml | 66 +++++++ .../Vault/templates/service-byk-vault.yaml | 23 +++ .../templates/statefulset-byk-vault.yaml | 122 ++++++++++++ kubernetes/charts/Vault/values.yaml | 71 +++++++ kubernetes/charts/database/Chart.lock | 6 + kubernetes/charts/database/Chart.yaml | 12 ++ kubernetes/charts/database/values.yaml | 14 ++ kubernetes/charts/minio/Chart.yaml | 6 + .../minio/templates/deployment-minio.yaml | 65 ++++++ .../charts/minio/templates/ingress-minio.yaml | 37 ++++ .../minio/templates/pvc-minio-data.yaml | 10 + .../charts/minio/templates/service-minio.yaml | 16 ++ kubernetes/charts/minio/values.yaml | 41 ++++ kubernetes/dashboard-admin.yaml | 18 ++ kubernetes/values.yaml | 78 ++++++++ 126 files changed, 4996 insertions(+) create mode 100644 kubernetes/CONTAINER_REGISTRY_SETUP.md create mode 100644 kubernetes/Chart.lock create mode 100644 kubernetes/Chart.yaml create mode 100644 kubernetes/charts/Authentication-Layer/Chart.yaml create mode 100644 kubernetes/charts/Authentication-Layer/templates/deployment-byk-authentication-layer.yaml create mode 100644 kubernetes/charts/Authentication-Layer/templates/ingress-byk-authentication-layer.yaml create mode 100644 kubernetes/charts/Authentication-Layer/templates/service-byk-authentication-layer.yaml create mode 100644 kubernetes/charts/Authentication-Layer/values.yaml create mode 100644 kubernetes/charts/ClickHouse/Chart.yaml create mode 100644 kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml create mode 100644 kubernetes/charts/ClickHouse/templates/pvc-clickhouse.yaml create mode 100644 kubernetes/charts/ClickHouse/templates/service-byk-clickhouse.yaml create mode 100644 kubernetes/charts/ClickHouse/values.yaml create mode 100644 kubernetes/charts/CronManager/Chart.yaml create mode 100644 kubernetes/charts/CronManager/templates/configmap-cronmanager-config.yaml create mode 100644 kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml create mode 100644 kubernetes/charts/CronManager/templates/pvc-cronmanager.yaml create mode 100644 kubernetes/charts/CronManager/templates/service-byk-cronmanager.yaml create mode 100644 kubernetes/charts/CronManager/values.yaml create mode 100644 kubernetes/charts/DataMapper/Chart.yaml create mode 100644 kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml create mode 100644 kubernetes/charts/DataMapper/templates/service-byk-data-mapper.yaml create mode 100644 kubernetes/charts/DataMapper/values.yaml create mode 100644 kubernetes/charts/GUI/Chart.yaml create mode 100644 kubernetes/charts/GUI/templates/deployment-byk-gui.yaml create mode 100644 kubernetes/charts/GUI/templates/ingress-byk-gui.yaml create mode 100644 kubernetes/charts/GUI/templates/service-byk-gui.yaml create mode 100644 kubernetes/charts/GUI/values.yaml create mode 100644 kubernetes/charts/Grafana/Chart.yaml create mode 100644 kubernetes/charts/Grafana/dashboards/grafana-dashboard-deployment.json create mode 100644 kubernetes/charts/Grafana/templates/configmap-dashboards.yaml create mode 100644 kubernetes/charts/Grafana/templates/configmap-grafana.yaml create mode 100644 kubernetes/charts/Grafana/templates/deployment-grafana.yaml create mode 100644 kubernetes/charts/Grafana/templates/pvc-grafana.yaml create mode 100644 kubernetes/charts/Grafana/templates/service-grafana.yaml create mode 100644 kubernetes/charts/Grafana/values.yaml create mode 100644 kubernetes/charts/LLM-Orchestration-Service/Chart.yaml create mode 100644 kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml create mode 100644 kubernetes/charts/LLM-Orchestration-Service/templates/pvc-volumes.yaml create mode 100644 kubernetes/charts/LLM-Orchestration-Service/templates/service-byk-llm-orchestration.yaml create mode 100644 kubernetes/charts/LLM-Orchestration-Service/values.yaml create mode 100644 kubernetes/charts/Langfuse-Web/Chart.yaml create mode 100644 kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml create mode 100644 kubernetes/charts/Langfuse-Web/templates/service-byk-langfuse-web.yaml create mode 100644 kubernetes/charts/Langfuse-Web/values.yaml create mode 100644 kubernetes/charts/Langfuse-Worker/Chart.yaml create mode 100644 kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml create mode 100644 kubernetes/charts/Langfuse-Worker/templates/service-byk-langfuse-worker.yaml create mode 100644 kubernetes/charts/Langfuse-Worker/values.yaml create mode 100644 kubernetes/charts/Liquibase/Chart.yaml create mode 100644 kubernetes/charts/Liquibase/templates/liquibase-job.yaml create mode 100644 kubernetes/charts/Liquibase/values.yaml create mode 100644 kubernetes/charts/Loki/Chart.yaml create mode 100644 kubernetes/charts/Loki/templates/configmap-loki.yaml create mode 100644 kubernetes/charts/Loki/templates/deployment-loki.yaml create mode 100644 kubernetes/charts/Loki/templates/pvc-loki.yaml create mode 100644 kubernetes/charts/Loki/templates/service-loki.yaml create mode 100644 kubernetes/charts/Loki/values.yaml create mode 100644 kubernetes/charts/Qdrant/Chart.yaml create mode 100644 kubernetes/charts/Qdrant/templates/service-byk-qdrant.yaml create mode 100644 kubernetes/charts/Qdrant/templates/statefulset-byk-qdrant.yaml create mode 100644 kubernetes/charts/Qdrant/values.yaml create mode 100644 kubernetes/charts/Redis/Chart.yaml create mode 100644 kubernetes/charts/Redis/templates/deployment-byk-redis.yaml create mode 100644 kubernetes/charts/Redis/templates/service-byk-redis.yaml create mode 100644 kubernetes/charts/Redis/values.yaml create mode 100644 kubernetes/charts/Resql/Chart.yaml create mode 100644 kubernetes/charts/Resql/templates/deployment-byk-resql.yaml create mode 100644 kubernetes/charts/Resql/templates/service-byk-resql.yaml create mode 100644 kubernetes/charts/Resql/values.yaml create mode 100644 kubernetes/charts/Ruuter-Private/Chart.yaml create mode 100644 kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml create mode 100644 kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml create mode 100644 kubernetes/charts/Ruuter-Private/templates/ingress-ruuter-private.yaml create mode 100644 kubernetes/charts/Ruuter-Private/templates/service-byk-ruuter-private.yaml create mode 100644 kubernetes/charts/Ruuter-Private/values.yaml create mode 100644 kubernetes/charts/Ruuter-Public/Chart.yaml create mode 100644 kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml create mode 100644 kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml create mode 100644 kubernetes/charts/Ruuter-Public/templates/ingress-ruuter-public.yaml create mode 100644 kubernetes/charts/Ruuter-Public/templates/service-byk-ruuter-public.yaml create mode 100644 kubernetes/charts/Ruuter-Public/values.yaml create mode 100644 kubernetes/charts/S3-Ferry/Chart.yaml create mode 100644 kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml create mode 100644 kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml create mode 100644 kubernetes/charts/S3-Ferry/templates/pvc-s3.yaml create mode 100644 kubernetes/charts/S3-Ferry/templates/service-s3.yaml create mode 100644 kubernetes/charts/S3-Ferry/values.yaml create mode 100644 kubernetes/charts/TIM-database/Chart.yaml create mode 100644 kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml create mode 100644 kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml create mode 100644 kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml create mode 100644 kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml create mode 100644 kubernetes/charts/TIM-database/values.yaml create mode 100644 kubernetes/charts/TIM/Chart.yaml create mode 100644 kubernetes/charts/TIM/templates/configmap-byk-tim.yaml create mode 100644 kubernetes/charts/TIM/templates/deployment-byk-tim.yaml create mode 100644 kubernetes/charts/TIM/templates/ingress.yaml create mode 100644 kubernetes/charts/TIM/templates/secret-byk-tim.yaml create mode 100644 kubernetes/charts/TIM/templates/service-byk-tim.yaml create mode 100644 kubernetes/charts/TIM/values.yaml create mode 100644 kubernetes/charts/Vault-Agent-LLM/Chart.yaml create mode 100644 kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml create mode 100644 kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml create mode 100644 kubernetes/charts/Vault-Agent-LLM/values.yaml create mode 100644 kubernetes/charts/Vault-Init/Chart.yaml create mode 100644 kubernetes/charts/Vault-Init/templates/configmap.yaml create mode 100644 kubernetes/charts/Vault-Init/templates/job.yaml create mode 100644 kubernetes/charts/Vault-Init/templates/pvc.yaml create mode 100644 kubernetes/charts/Vault-Init/values.yaml create mode 100644 kubernetes/charts/Vault/Chart.yaml create mode 100644 kubernetes/charts/Vault/templates/configmap.yaml create mode 100644 kubernetes/charts/Vault/templates/service-byk-vault.yaml create mode 100644 kubernetes/charts/Vault/templates/statefulset-byk-vault.yaml create mode 100644 kubernetes/charts/Vault/values.yaml create mode 100644 kubernetes/charts/database/Chart.lock create mode 100644 kubernetes/charts/database/Chart.yaml create mode 100644 kubernetes/charts/database/values.yaml create mode 100644 kubernetes/charts/minio/Chart.yaml create mode 100644 kubernetes/charts/minio/templates/deployment-minio.yaml create mode 100644 kubernetes/charts/minio/templates/ingress-minio.yaml create mode 100644 kubernetes/charts/minio/templates/pvc-minio-data.yaml create mode 100644 kubernetes/charts/minio/templates/service-minio.yaml create mode 100644 kubernetes/charts/minio/values.yaml create mode 100644 kubernetes/dashboard-admin.yaml create mode 100644 kubernetes/values.yaml 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..aa9d953c --- /dev/null +++ b/kubernetes/Chart.lock @@ -0,0 +1,75 @@ +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-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 +digest: sha256:ebf9bd6c7a999f2ab58598fdfff371579d6c7ca17d35e87fc8200668c2ae493e +generated: "2025-12-02T13:11:13.8392479+05:30" diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml new file mode 100644 index 00000000..698d4b9e --- /dev/null +++ b/kubernetes/Chart.yaml @@ -0,0 +1,80 @@ +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" + - name: TIM-database + version: 0.1.0 + repository: "file://./charts/TIM-database" + - name: resql + version: 0.1.0 + repository: "file://./charts/Resql" + - name: ruuter-public + version: 0.1.0 + repository: "file://./charts/Ruuter-Public" + - name: ruuter-private + version: 0.1.0 + repository: "file://./charts/Ruuter-Private" + - name: data-mapper + version: 0.1.0 + repository: "file://./charts/DataMapper" + - name: TIM + version: 0.1.0 + repository: "file://./charts/TIM" + - name: Authentication-Layer + version: 0.1.0 + repository: "file://./charts/Authentication-Layer" + - name: CronManager + version: 0.1.0 + repository: "file://./charts/CronManager" + - name: GUI + version: 0.1.0 + repository: "file://./charts/GUI" + - name: Loki + version: 0.1.0 + repository: "file://./charts/Loki" + - name: Grafana + version: 0.1.0 + repository: "file://./charts/Grafana" + - name: S3-Ferry + version: 0.1.0 + repository: "file://./charts/S3-Ferry" + - name: minio + version: 0.1.0 + repository: "file://./charts/minio" + - name: Redis + version: 0.1.0 + repository: "file://./charts/Redis" + - name: Qdrant + version: 0.1.0 + repository: "file://./charts/Qdrant" + - name: ClickHouse + version: 0.1.0 + repository: "file://./charts/ClickHouse" + - name: Langfuse-Web + version: 0.1.0 + repository: "file://./charts/Langfuse-Web" + - name: Langfuse-Worker + version: 0.1.0 + repository: "file://./charts/Langfuse-Worker" + - name: Vault + version: 0.1.0 + repository: "file://./charts/Vault" + - name: Vault-Init + version: 0.1.0 + repository: "file://./charts/Vault-Init" + - name: Vault-Agent-LLM + version: 0.1.0 + repository: "file://./charts/Vault-Agent-LLM" + - name: LLM-Orchestration-Service + version: 0.1.0 + repository: "file://./charts/LLM-Orchestration-Service" + - name: Liquibase + version: 0.1.0 + repository: "file://./charts/Liquibase" + 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..1deb38f2 --- /dev/null +++ b/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml @@ -0,0 +1,86 @@ +{{- 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 + env: + - name: CLICKHOUSE_DB + value: "{{ .Values.env.CLICKHOUSE_DB }}" + - name: CLICKHOUSE_USER + value: "{{ .Values.env.CLICKHOUSE_USER }}" + - name: CLICKHOUSE_PASSWORD + value: "{{ .Values.env.CLICKHOUSE_PASSWORD }}" + {{- 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/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..7d181c4a --- /dev/null +++ b/kubernetes/charts/ClickHouse/values.yaml @@ -0,0 +1,60 @@ +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" + CLICKHOUSE_USER: "clickhouse" + CLICKHOUSE_PASSWORD: "changeme" + +# 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..0fbfc953 --- /dev/null +++ b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml @@ -0,0 +1,103 @@ +{{- 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: + 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 + + 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/ && + + # Set execute permissions on all shell scripts + chmod +x /app/scripts/*.sh && + echo "Scripts copied and permissions set successfully" + + containers: + - 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 }} + - name: VAULT_ADDR + value: {{ .Values.cronmanager.environment.VAULT_ADDR | quote }} + - 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" + +{{- 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..21c24d4d --- /dev/null +++ b/kubernetes/charts/CronManager/values.yaml @@ -0,0 +1,43 @@ +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: "1Gi" + cpu: "500m" + limits: + memory: "4Gi" + cpu: "2000m" \ 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..09f23652 --- /dev/null +++ b/kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml @@ -0,0 +1,63 @@ +{{- 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: + 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..220526ca --- /dev/null +++ b/kubernetes/charts/DataMapper/values.yaml @@ -0,0 +1,31 @@ +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 + 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/deployment-byk-gui.yaml b/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml new file mode 100644 index 00000000..081b1842 --- /dev/null +++ b/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml @@ -0,0 +1,87 @@ +{{- 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: + - 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 }} + + # Vite development server configuration + - name: VITE_HOST + value: {{ .Values.gui.vite.host | quote }} + - name: VITE_ALLOWED_HOSTS + value: {{ .Values.gui.vite.allowedHosts | quote }} + + 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 + + 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..72b429f9 --- /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 +spec: + rules: + - host: localhost + http: + paths: + - path: /rag-search + pathType: Prefix + backend: + service: + name: gui + port: + number: 3003 + \ No newline at end of file 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..192a9e52 --- /dev/null +++ b/kubernetes/charts/GUI/values.yaml @@ -0,0 +1,62 @@ +gui: + enabled: true + release_name: gui + image: + repository: "ghcr.io/buerokratt/rag-gui" # Update with actual GUI image repository + tag: latest + pullPolicy: IfNotPresent + + # React application configuration + nodeEnv: production + port: 3001 + debugEnabled: true + enableHiddenFeatures: false + + #service URLs + services: + ruuterPublic: "http://ruuter-public:8086" + ruuterPrivate: "http://localhost:8088" + authenticationLayer: "http://authentication-layer:3004" + 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:8086 https://ruuter-public:8086 http://ruuter-private:8088 https://ruuter-private:8088 http://authentication-layer:3004 https://authentication-layer:3004 http://notifications-node:4040 https://notifications-node:4040 http://dataset-gen-service:8000 https://dataset-gen-service:8000 http://localhost:* https://localhost:* http://global-classifier.local https://global-classifier.local ws://global-classifier.local wss://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" + + resources: + limits: + cpu: 500m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + + replicas: 1 + + service: + type: ClusterIP + port: 3001 + targetPort: 3001 + + # 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..844aa70a --- /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 }}: | +{{ $content | 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..4b6fef87 --- /dev/null +++ b/kubernetes/charts/Grafana/templates/deployment-grafana.yaml @@ -0,0 +1,73 @@ +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 + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- 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/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..805b8404 --- /dev/null +++ b/kubernetes/charts/Grafana/values.yaml @@ -0,0 +1,62 @@ +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: + user: admin + password: admin + +# 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 +env: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: admin123 + GF_USERS_ALLOW_SIGN_UP: "false" 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..4a6013f3 --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml @@ -0,0 +1,166 @@ +{{- 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: + - 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.vault.enabled }} + - name: VAULT_ADDR + value: "{{ .Values.vault.addr }}" + - name: VAULT_TOKEN + value: "{{ .Values.vault.tokenPath }}" + {{- 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 }} + # Vault token (from agent) + {{- if and .Values.vault.enabled .Values.volumes.vaultToken.enabled }} + - name: vault-token + mountPath: {{ .Values.volumes.vaultToken.mountPath }} + readOnly: true + {{- 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 }} + # Vault token (shared PVC with vault-agent-llm) + {{- if and .Values.vault.enabled .Values.volumes.vaultToken.enabled }} + - name: vault-token + persistentVolumeClaim: + claimName: vault-agent-token + {{- 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..b64723ef --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/values.yaml @@ -0,0 +1,86 @@ +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: "" + + # Vault agent token volume (emptyDir - managed by sidecar) + vaultToken: + enabled: true + mountPath: "/agent/out" + +# 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 + +env: + ENVIRONMENT: "production" + +vault: + enabled: true + addr: "http://vault:8200" + tokenPath: "/agent/out/token" + +resources: + requests: + memory: "512Mi" + cpu: "200m" + limits: + memory: "2Gi" + cpu: "1000m" + +healthcheck: + enabled: false + initialDelaySeconds: 40 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + successThreshold: 1 + # LLM orchestration health endpoint + httpPath: "/health" + # Additional readiness checks + readinessPath: "/ready" 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..59cff5c1 --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml @@ -0,0 +1,59 @@ +{{- 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: + 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.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-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..a85b0eae --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/values.yaml @@ -0,0 +1,104 @@ +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: + # Database configuration + NEXTAUTH_URL: "http://localhost:3000" + DATABASE_URL: "postgresql://postgres:dbadmin@rag_search_db:5432/rag-search" + SALT: "changeme" + ENCRYPTION_KEY: "changeme" + NEXTAUTH_SECRET: "changeme" + TELEMETRY_ENABLED: "true" + LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" + + # ClickHouse configuration + CLICKHOUSE_MIGRATION_URL: "clickhouse://clickhouse:9000" + CLICKHOUSE_URL: "http://clickhouse:8123" + CLICKHOUSE_USER: "default" + CLICKHOUSE_PASSWORD: "clickhouse" + CLICKHOUSE_CLUSTER_ENABLED: "false" + + # S3/MinIO configuration + LANGFUSE_USE_AZURE_BLOB: "false" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "rag-search" + LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto" + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "changeme" + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "changeme" + 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_ACCESS_KEY_ID: "changeme" + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "changeme" + 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_ACCESS_KEY_ID: "changeme" + LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: "changeme" + LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" + LANGFUSE_INGESTION_QUEUE_DELAY_MS: "" + LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: "" + + # Redis configuration + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_AUTH: "myredissecret" + 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: "" + +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..3a82d36c --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml @@ -0,0 +1,59 @@ +{{- 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 + 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.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/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..61e5cb0c --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/values.yaml @@ -0,0 +1,91 @@ +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: + # Database configuration + NEXTAUTH_URL: "http://localhost:3000" + DATABASE_URL: "postgresql://postgres:dbadmin@rag_search_db:5432/rag-search" + SALT: "changeme" + ENCRYPTION_KEY: "changeme" + TELEMETRY_ENABLED: "true" + LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" + + # ClickHouse configuration + CLICKHOUSE_MIGRATION_URL: "clickhouse://clickhouse:9000" + CLICKHOUSE_URL: "http://clickhouse:8123" + CLICKHOUSE_USER: "default" + CLICKHOUSE_PASSWORD: "clickhouse" + CLICKHOUSE_CLUSTER_ENABLED: "false" + + # S3/MinIO configuration + LANGFUSE_USE_AZURE_BLOB: "false" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "rag-search" + LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto" + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "changeme" + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "changeme" + 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_ACCESS_KEY_ID: "changeme" + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "changeme" + 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_ACCESS_KEY_ID: "changeme" + LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: "changeme" + LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" + LANGFUSE_INGESTION_QUEUE_DELAY_MS: "" + LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: "" + + # Redis configuration + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_AUTH: "myredissecret" + REDIS_TLS_ENABLED: "false" + REDIS_TLS_CA: "" + REDIS_TLS_CERT: "" + REDIS_TLS_KEY: "" + + # Email configuration + EMAIL_FROM_ADDRESS: "" + SMTP_CONNECTION_URL: "" + +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..d9e54cee --- /dev/null +++ b/kubernetes/charts/Liquibase/templates/liquibase-job.yaml @@ -0,0 +1,60 @@ +{{- if .Values.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + backoffLimit: {{ .Values.backoffLimit }} + template: + metadata: + 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/* /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 }} + + + 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 \ + --changeLogFile=/master.yml \ + --url=jdbc:postgresql://rag-search-db:5432/rag-search \ + --username=postgres \ + --password=dbadmin \ + update + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Liquibase/values.yaml b/kubernetes/charts/Liquibase/values.yaml new file mode 100644 index 00000000..0d538422 --- /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: POSTGRES_USER + value: "postgres" + - name: POSTGRES_PASSWORD + value: "dbadmin" + - name: LIQUIBASE_URL + value: "jdbc:postgresql://rag-search-db:5432/rag-search" + - name: LIQUIBASE_CHANGELOG_FILE + value: /master.yml + + +pullPolicy: IfNotPresent \ 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..b67ab1d2 --- /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 }} + env: + {{- if .Values.auth.enabled }} + - name: REDIS_PASSWORD + value: "{{ .Values.auth.password }}" + {{- 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/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..01018687 --- /dev/null +++ b/kubernetes/charts/Redis/values.yaml @@ -0,0 +1,40 @@ +replicas: 1 +enabled: true + +images: + redis: + registry: "docker.io" + repository: "redis" + tag: "7" + +release_name: "redis" + +service: + type: ClusterIP + port: 6379 + +auth: + enabled: true + password: "myredissecret" + +env: + REDIS_PASSWORD: "myredissecret" + +# 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..edc52930 --- /dev/null +++ b/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml @@ -0,0 +1,68 @@ +{{- 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: + 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 }}" + - name: SQLMS_DATASOURCES_0_PASSWORD + value: "{{ .Values.env.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/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..470b7a0d --- /dev/null +++ b/kubernetes/charts/Resql/values.yaml @@ -0,0 +1,32 @@ +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/rag-search" + SQLMS_DATASOURCES_0_USERNAME: "postgres" + SQLMS_DATASOURCES_0_PASSWORD: "dbadmin" + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_BOOT: "INFO" + JAVA_OPTS: "-Xms1g -Xmx3g" + +resources: + requests: + memory: "1000Mi" + cpu: "50m" + limits: + memory: "4Gi" + cpu: "50m" + +pullPolicy: IfNotPresent 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..9a20ec2d --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml @@ -0,0 +1,19 @@ +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 + DB_PASSWORD=dbadmin \ 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..c2082f6d --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml @@ -0,0 +1,88 @@ +{{- 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: + 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 }}" + + + - 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/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..a6a6d64d --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/values.yaml @@ -0,0 +1,56 @@ +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 + + 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..354b6f2a --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml @@ -0,0 +1,19 @@ +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 + DB_PASSWORD=dbadmin \ 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..ebf2c2f8 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml @@ -0,0 +1,87 @@ +{{- 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: + 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" + + - 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/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..635d51c9 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/values.yaml @@ -0,0 +1,51 @@ +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 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/configmap-s3.yaml b/kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml new file mode 100644 index 00000000..5a80ebe0 --- /dev/null +++ b/kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.release_name }}-config + labels: + app: {{ .Values.release_name }} +data: + {{- range $key, $value := .Values.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} \ 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..16678196 --- /dev/null +++ b/kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml @@ -0,0 +1,50 @@ +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 + envFrom: + - configMapRef: + name: {{ .Values.release_name }}-config + 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/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..69c03c5f --- /dev/null +++ b/kubernetes/charts/S3-Ferry/values.yaml @@ -0,0 +1,61 @@ +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 +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_SECRET_ACCESS_KEY: "changeme" + S3_ACCESS_KEY_ID: "changeme" + S3_HEALTH_ENDPOINT: "http://minio:9000/minio/health/live" + MINIO_BROWSER_REDIRECT_URL: "http://localhost:9091" + GF_SECURITY_ADMIN_USER: "admin" + GF_SECURITY_ADMIN_PASSWORD: "admin123" + GF_USERS_ALLOW_SIGN_UP: "false" + PORT: "3000" + + + diff --git a/kubernetes/charts/TIM-database/Chart.yaml b/kubernetes/charts/TIM-database/Chart.yaml new file mode 100644 index 00000000..2bcdf24d --- /dev/null +++ b/kubernetes/charts/TIM-database/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: TIM-database +description: TIM postgresql database +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml new file mode 100644 index 00000000..7e8fa6ba --- /dev/null +++ b/kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml @@ -0,0 +1,42 @@ +{{- if .Values.timPostgresql.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} + labels: + app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} +spec: + replicas: {{ .Values.timPostgresql.replicaCount | default 1 }} + selector: + matchLabels: + app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} + template: + metadata: + labels: + app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} + spec: + containers: + - name: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} + image: "{{ .Values.timPostgresql.image.repository }}:{{ .Values.timPostgresql.image.tag }}" + imagePullPolicy: {{ .Values.timPostgresql.image.pullPolicy }} + env: + {{- range .Values.timPostgresql.env }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.timPostgresql.secret.name }} + key: POSTGRES_PASSWORD + ports: + - containerPort: {{ .Values.timPostgresql.service.port }} + resources: + {{- toYaml .Values.timPostgresql.resources | nindent 12 }} + volumes: + {{- if .Values.timPostgresql.persistence.enabled }} + - name: postgres-storage + persistentVolumeClaim: + claimName: {{ .Values.timPostgresql.persistence.existingClaim | default (printf "%s-pvc" (.Values.timPostgresql.nameOverride | default "tim-postgresql")) }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml new file mode 100644 index 00000000..6dfdc707 --- /dev/null +++ b/kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.timPostgresql.enabled .Values.timPostgresql.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.timPostgresql.persistence.existingClaim | default (printf "%s-pvc" (.Values.timPostgresql.nameOverride | default "tim-postgresql")) }} + labels: + app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} +spec: + accessModes: + {{- range .Values.timPostgresql.persistence.accessModes }} + - {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.timPostgresql.persistence.size }} + {{- if .Values.timPostgresql.persistence.storageClass }} + storageClassName: {{ .Values.timPostgresql.persistence.storageClass }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml new file mode 100644 index 00000000..30b97aea --- /dev/null +++ b/kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml @@ -0,0 +1,9 @@ +{{- if .Values.timPostgresql.secret.create }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Values.timPostgresql.secret.name }} +type: Opaque +data: + POSTGRES_PASSWORD: {{ .Values.timPostgresql.secret.keys.POSTGRES_PASSWORD | b64enc | quote }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml new file mode 100644 index 00000000..686d20ce --- /dev/null +++ b/kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml @@ -0,0 +1,15 @@ +{{- if .Values.timPostgresql.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.timPostgresql.nameOverride }} +spec: + type: {{ .Values.timPostgresql.service.type | default "ClusterIP" }} + selector: + app: {{ .Values.timPostgresql.nameOverride }} + ports: + - name: postgres + port: {{ .Values.timPostgresql.service.port }} + targetPort: {{ .Values.timPostgresql.service.port }} + nodePort: {{- if eq .Values.timPostgresql.service.type "NodePort" }} {{ .Values.timPostgresql.service.externalPort }} {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/values.yaml b/kubernetes/charts/TIM-database/values.yaml new file mode 100644 index 00000000..2e9ce2b7 --- /dev/null +++ b/kubernetes/charts/TIM-database/values.yaml @@ -0,0 +1,29 @@ +timPostgresql: + enabled: true + nameOverride: tim-postgresql + image: + repository: postgres + tag: "14.1" + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 5432 + externalPort: 9876 + env: + - name: POSTGRES_USER + value: "tim" + - name: POSTGRES_DB + value: "tim" + - name: POSTGRES_HOST_AUTH_METHOD + value: "trust" + secret: + create: true + name: tim-postgres-secret + keys: + POSTGRES_PASSWORD: "Ab123" + persistence: + enabled: true + size: 1Gi + storageClass: "" + accessModes: ["ReadWriteOnce"] + existingClaim: "" 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..0692c868 --- /dev/null +++ b/kubernetes/charts/TIM/templates/secret-byk-tim.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: tim-env-secret +type: Opaque +data: + oauth2_client_secret: "{{ .Values.tim.config.oauth2_client_secret | b64enc }}" + jwt_integration_key_store_password: "{{ .Values.tim.config.jwt_keystore_password | b64enc }}" + POSTGRES_PASSWORD: "{{ "dbadmin" | b64enc }}" 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..a057edcd --- /dev/null +++ b/kubernetes/charts/TIM/values.yaml @@ -0,0 +1,45 @@ +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_keystore_password: "defaultpassword" + 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_secret: "my-secret-value" + 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-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..17d90c05 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml @@ -0,0 +1,48 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.release_name }}-config + labels: + app: {{ .Values.release_name }} + component: vault-agent-llm +data: + agent.hcl: | + # Vault agent configuration for LLM Orchestration Service + + vault { + address = "http://vault:8200" + } + + pid_file = "/agent/out/pidfile" + + auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/agent/credentials/role_id" + secret_id_file_path = "/agent/credentials/secret_id" + remove_secret_id_file_after_reading = false + } + } + + sink "file" { + config = { + path = "/agent/out/token" + } + } + } + + cache { + default_lease_duration = "1h" + } + + listener "tcp" { + address = "127.0.0.1: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..ebd785c6 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml @@ -0,0 +1,101 @@ +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 \ 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..29b630f9 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/values.yaml @@ -0,0 +1,101 @@ +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 + +# Deployment configuration +deployment: + replicas: 1 + # Use Deployment for consistent agent behavior + 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: "100m" + 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..c0d55b4b --- /dev/null +++ b/kubernetes/charts/Vault-Init/templates/configmap.yaml @@ -0,0 +1,186 @@ +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 policy + echo "Creating llm-orchestration policy..." + POLICY='path "secret/metadata/llm/*" { capabilities = ["list", "delete"] } + path "secret/data/llm/*" { capabilities = ["create", "read", "update", "delete"] } + path "auth/token/lookup-self" { capabilities = ["read"] } + path "secret/metadata/embeddings/*" { capabilities = ["list", "delete"] } + path "secret/data/embeddings/*" { capabilities = ["create", "read", "update", "delete"] }' + + POLICY_JSON=$(echo "$POLICY" | jq -Rs '{"policy":.}') + wget -q -O- --post-data="$POLICY_JSON" \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/policies/acl/llm-orchestration" >/dev/null + + # Create AppRole + echo "Creating llm-orchestration-service AppRole..." + wget -q -O- --post-data='{"token_policies":["llm-orchestration"],"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 role_id + echo "Getting role_id..." + 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 "$ROLE_ID" > /agent/credentials/role_id + + # Generate secret_id + echo "Generating secret_id..." + 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 "$SECRET_ID" > /agent/credentials/secret_id + + chmod 644 /agent/credentials/role_id /agent/credentials/secret_id + + # 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" + + # 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 + + # Regenerate secret_id after unseal + echo "Regenerating secret_id..." + 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 "$SECRET_ID" > /agent/credentials/secret_id + chmod 644 /agent/credentials/secret_id + + # Ensure role_id exists + if [ ! -f /agent/credentials/role_id ]; then + echo "Copying role_id..." + mkdir -p /agent/credentials + 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 "$ROLE_ID" > /agent/credentials/role_id + chmod 644 /agent/credentials/role_id + fi + else + echo "Vault is unsealed. No action needed." + 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..96f2ada5 --- /dev/null +++ b/kubernetes/charts/Vault-Init/templates/job.yaml @@ -0,0 +1,93 @@ +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.volumes.agentToken.enabled }} + - name: vault-agent-token + persistentVolumeClaim: + claimName: vault-agent-token + {{- 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: + - | + # Install dependencies and setup permissions + apk add --no-cache curl jq + mkdir -p /agent/credentials /agent/out + chmod -R 755 /agent/credentials + chmod -R 770 /agent/out + echo "Permissions set successfully" + + # Run the init script + /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.volumes.agentToken.enabled }} + - name: vault-agent-token + mountPath: {{ .Values.volumes.agentToken.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..7084dc3c --- /dev/null +++ b/kubernetes/charts/Vault-Init/templates/pvc.yaml @@ -0,0 +1,37 @@ +{{- if .Values.volumes.agentCredentials.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-agent-creds + labels: + app: {{ .Values.release_name }} + component: vault-init +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: {{ .Values.volumes.agentCredentials.size }} + {{- if .Values.volumes.agentCredentials.storageClass }} + storageClassName: {{ .Values.volumes.agentCredentials.storageClass }} + {{- end }} +--- +{{- end }} +{{- if .Values.volumes.agentToken.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-agent-token + labels: + app: {{ .Values.release_name }} + component: vault-init +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: {{ .Values.volumes.agentToken.size }} + {{- if .Values.volumes.agentToken.storageClass }} + storageClassName: {{ .Values.volumes.agentToken.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..207da592 --- /dev/null +++ b/kubernetes/charts/Vault-Init/values.yaml @@ -0,0 +1,60 @@ +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: "ReadWriteMany" + storageClass: "" + + agentToken: + enabled: true + mountPath: "/agent/out" + size: "100Mi" + accessMode: "ReadWriteMany" + storageClass: "" + +# Init script configuration +initScript: + enabled: true + filename: "vault-init.sh" + +pullPolicy: IfNotPresent + +resources: + requests: + memory: "128Mi" + cpu: "100m" + 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..253aa938 --- /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: ReadWriteMany + 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: "256Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "500m" + +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..2facc943 --- /dev/null +++ b/kubernetes/charts/database/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: database +description: PostgreSQL databases for RAG +type: application +version: 0.1.0 + +dependencies: + - name: postgresql + version: 12.2.6 + repository: https://charts.bitnami.com/bitnami + alias: rag-search-db + \ No newline at end of file diff --git a/kubernetes/charts/database/values.yaml b/kubernetes/charts/database/values.yaml new file mode 100644 index 00000000..d7841e56 --- /dev/null +++ b/kubernetes/charts/database/values.yaml @@ -0,0 +1,14 @@ +rag-search-db: + fullnameOverride: rag-search-db + image: + tag: latest + auth: + postgresPassword: dbadmin + username: postgres + password: dbadmin + database: rag-search + primary: + persistence: + enabled: true + size: 8Gi + 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..2012d5bf --- /dev/null +++ b/kubernetes/charts/minio/templates/deployment-minio.yaml @@ -0,0 +1,65 @@ +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 + env: +{{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: "{{ $value }}" +{{- 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/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..5919e678 --- /dev/null +++ b/kubernetes/charts/minio/values.yaml @@ -0,0 +1,41 @@ +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_ROOT_USER: "minioadmin" + MINIO_ROOT_PASSWORD: "minioadmin" + MINIO_BROWSER_REDIRECT_URL: "http://localhost:9001" + +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..92c78531 --- /dev/null +++ b/kubernetes/values.yaml @@ -0,0 +1,78 @@ +# Global configuration for RAG Module +global: + domain: "rag-module.local" + namespace: "rag-module" + storageClass: "standard" + +# Individual service configurations +database: + enabled: true + +TIM-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: true + +Langfuse-Web: + enabled: true + +Langfuse-Worker: + enabled: true + +Vault: + enabled: true + +Vault-Init: + enabled: true + +Vault-Agent-LLM: + enabled: true + +LLM-Orchestration-Service: + enabled: true + +Liquibase: + enabled: true From 3becd2ad8d4afed22f65905bae9c83d3e63ce010 Mon Sep 17 00:00:00 2001 From: erangi-ar Date: Mon, 19 Jan 2026 11:05:43 +0530 Subject: [PATCH 002/105] Remove outdated Vite configuration files and associated plugins --- ....timestamp-1767946562610-7e8d2a8c1f401.mjs | 70 ----------------- ....timestamp-1767946574215-f7ac6ce2fedaa.mjs | 70 ----------------- ....timestamp-1768278822370-7924bd5f138d9.mjs | 77 ------------------- ....timestamp-1768278833602-e10c19bbae925.mjs | 77 ------------------- 4 files changed, 294 deletions(-) delete mode 100644 GUI/vite.config.ts.timestamp-1767946562610-7e8d2a8c1f401.mjs delete mode 100644 GUI/vite.config.ts.timestamp-1767946574215-f7ac6ce2fedaa.mjs delete mode 100644 GUI/vite.config.ts.timestamp-1768278822370-7924bd5f138d9.mjs delete mode 100644 GUI/vite.config.ts.timestamp-1768278833602-e10c19bbae925.mjs diff --git a/GUI/vite.config.ts.timestamp-1767946562610-7e8d2a8c1f401.mjs b/GUI/vite.config.ts.timestamp-1767946562610-7e8d2a8c1f401.mjs deleted file mode 100644 index b770c4c5..00000000 --- a/GUI/vite.config.ts.timestamp-1767946562610-7e8d2a8c1f401.mjs +++ /dev/null @@ -1,70 +0,0 @@ -// 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"] - }, - 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+IHtcbiAgICAgICAgaWYoIWlkLmVuZHNXaXRoKCcvbWVudS1zdHJ1Y3R1cmUuanNvbicpKVxuICAgICAgICAgIHJldHVybiBzdHI7XG4gICAgICAgIHJldHVybiByZW1vdmVIaWRkZW5NZW51SXRlbXMoc3RyKTtcbiAgICAgIH0sXG4gICAgfSxcbiAgXSxcbiAgYmFzZTogJy9yYWctc2VhcmNoJyxcbiAgYnVpbGQ6IHtcbiAgICBvdXREaXI6ICcuL2J1aWxkJyxcbiAgICB0YXJnZXQ6ICdlczIwMTUnLFxuICAgIGVtcHR5T3V0RGlyOiB0cnVlLFxuICB9LFxuICBzZXJ2ZXI6IHtcbiAgICBoZWFkZXJzOiB7XG4gICAgICAuLi4ocHJvY2Vzcy5lbnYuUkVBQ1RfQVBQX0NTUCAmJiB7XG4gICAgICAgICdDb250ZW50LVNlY3VyaXR5LVBvbGljeSc6IHByb2Nlc3MuZW52LlJFQUNUX0FQUF9DU1AsXG4gICAgICB9KSxcbiAgICB9LFxuICAgIGFsbG93ZWRIb3N0czogWydlc3QtcmFnLXJ0Yy5yb290Y29kZS5zb2Z0d2FyZScsICdsb2NhbGhvc3QnLCAnMTI3LjAuMC4xJ10sXG5cbiAgfSxcbiAgcmVzb2x2ZToge1xuICAgIGFsaWFzOiB7XG4gICAgICAnfkBmb250c291cmNlJzogcGF0aC5yZXNvbHZlKF9fZGlybmFtZSwgJ25vZGVfbW9kdWxlcy9AZm9udHNvdXJjZScpLFxuICAgICAgJ0AnOiBgJHtwYXRoLnJlc29sdmUoX19kaXJuYW1lLCAnLi9zcmMnKX1gLFxuICAgIH0sXG4gIH0sXG59KTtcbiIsICJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiL2FwcFwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL2FwcC92aXRlUGx1Z2luLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9hcHAvdml0ZVBsdWdpbi5qc1wiO2V4cG9ydCBmdW5jdGlvbiByZW1vdmVIaWRkZW5NZW51SXRlbXMoc3RyKSB7XG4gIGNvbnN0IGJhZEpzb24gPSBzdHIucmVwbGFjZSgnZXhwb3J0IGRlZmF1bHQgWycsICdbJykucmVwbGFjZSgnXTsnLCAnXScpO1xuICBjb25zdCBjb3JyZWN0SnNvbiA9IGJhZEpzb24ucmVwbGFjZSgvKFsnXCJdKT8oW2EtejAtOUEtWl9dKykoWydcIl0pPzovZywgJ1wiJDJcIjogJyk7XG5cbiBjb25zdCBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCA9IFxuICAgIHByb2Nlc3MuZW52LlJFQUNUX0FQUF9FTkFCTEVfSElEREVOX0ZFQVRVUkVTPy50b0xvd2VyQ2FzZSgpLnRyaW0oKSA9PT0gJ3RydWUnIHx8XG4gICAgcHJvY2Vzcy5lbnYuUkVBQ1RfQVBQX0VOQUJMRV9ISURERU5fRkVBVFVSRVM/LnRvTG93ZXJDYXNlKCkudHJpbSgpID09PSAnMSc7XG5cbiAgY29uc3QganNvbiA9IHJlbW92ZUhpZGRlbihKU09OLnBhcnNlKGNvcnJlY3RKc29uKSwgaXNIaWRkZW5GZWF0dXJlc0VuYWJsZWQpO1xuICBcbiAgY29uc3QgdXBkYXRlZEpzb24gPSBKU09OLnN0cmluZ2lmeShqc29uKTtcblxuICByZXR1cm4gJ2V4cG9ydCBkZWZhdWx0ICcgKyB1cGRhdGVkSnNvbiArICc7J1xufVxuXG5mdW5jdGlvbiByZW1vdmVIaWRkZW4obWVudUl0ZW1zLCBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCkge1xuICBpZighbWVudUl0ZW1zKSByZXR1cm4gbWVudUl0ZW1zO1xuICBjb25zdCBhcnIgPSBtZW51SXRlbXNcbiAgICA/LmZpbHRlcih4ID0+ICF4LmhpZGRlbilcbiAgICA/LmZpbHRlcih4ID0+IGlzSGlkZGVuRmVhdHVyZXNFbmFibGVkIHx8IHguaGlkZGVuTW9kZSAhPT0gXCJwcm9kdWN0aW9uXCIpO1xuICBmb3IgKGNvbnN0IGEgb2YgYXJyKSB7XG4gICAgYS5jaGlsZHJlbiA9IHJlbW92ZUhpZGRlbihhLmNoaWxkcmVuLCBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCk7XG4gIH1cbiAgcmV0dXJuIGFycjtcbn1cbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBOEwsU0FBUyxvQkFBb0I7QUFDM04sT0FBTyxXQUFXO0FBQ2xCLE9BQU8sbUJBQW1CO0FBQzFCLE9BQU8sVUFBVTtBQUNqQixPQUFPLFVBQVU7OztBQ0prTCxTQUFTLHNCQUFzQixLQUFLO0FBQXZPO0FBQ0UsUUFBTSxVQUFVLElBQUksUUFBUSxvQkFBb0IsR0FBRyxFQUFFLFFBQVEsTUFBTSxHQUFHO0FBQ3RFLFFBQU0sY0FBYyxRQUFRLFFBQVEsbUNBQW1DLFFBQVE7QUFFaEYsUUFBTSw0QkFDSCxhQUFRLElBQUkscUNBQVosbUJBQThDLGNBQWMsWUFBVyxZQUN2RSxhQUFRLElBQUkscUNBQVosbUJBQThDLGNBQWMsWUFBVztBQUV6RSxRQUFNLE9BQU8sYUFBYSxLQUFLLE1BQU0sV0FBVyxHQUFHLHVCQUF1QjtBQUUxRSxRQUFNLGNBQWMsS0FBSyxVQUFVLElBQUk7QUFFdkMsU0FBTyxvQkFBb0IsY0FBYztBQUMzQztBQUVBLFNBQVMsYUFBYSxXQUFXLHlCQUF5QjtBQWYxRDtBQWdCRSxNQUFHLENBQUM7QUFBVyxXQUFPO0FBQ3RCLFFBQU0sT0FBTSw0Q0FDUixPQUFPLE9BQUssQ0FBQyxFQUFFLFlBRFAsbUJBRVIsT0FBTyxPQUFLLDJCQUEyQixFQUFFLGVBQWU7QUFDNUQsYUFBVyxLQUFLLEtBQUs7QUFDbkIsTUFBRSxXQUFXLGFBQWEsRUFBRSxVQUFVLHVCQUF1QjtBQUFBLEVBQy9EO0FBQ0EsU0FBTztBQUNUOzs7QUR4QkEsSUFBTSxtQ0FBbUM7QUFRekMsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsV0FBVztBQUFBLEVBQ1gsU0FBUztBQUFBLElBQ1AsTUFBTTtBQUFBLElBQ04sY0FBYztBQUFBLElBQ2QsS0FBSztBQUFBLElBQ0w7QUFBQSxNQUNFLE1BQU07QUFBQSxNQUNOLFdBQVcsQ0FBQyxLQUFLLE9BQU87QUFDdEIsWUFBRyxDQUFDLEdBQUcsU0FBUyxzQkFBc0I7QUFDcEMsaUJBQU87QUFDVCxlQUFPLHNCQUFzQixHQUFHO0FBQUEsTUFDbEM7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUFBLEVBQ0EsTUFBTTtBQUFBLEVBQ04sT0FBTztBQUFBLElBQ0wsUUFBUTtBQUFBLElBQ1IsUUFBUTtBQUFBLElBQ1IsYUFBYTtBQUFBLEVBQ2Y7QUFBQSxFQUNBLFFBQVE7QUFBQSxJQUNOLFNBQVM7QUFBQSxNQUNQLEdBQUksUUFBUSxJQUFJLGlCQUFpQjtBQUFBLFFBQy9CLDJCQUEyQixRQUFRLElBQUk7QUFBQSxNQUN6QztBQUFBLElBQ0Y7QUFBQSxJQUNBLGNBQWMsQ0FBQyxpQ0FBaUMsYUFBYSxXQUFXO0FBQUEsRUFFMUU7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMLGdCQUFnQixLQUFLLFFBQVEsa0NBQVcsMEJBQTBCO0FBQUEsTUFDbEUsS0FBSyxHQUFHLEtBQUssUUFBUSxrQ0FBVyxPQUFPLENBQUM7QUFBQSxJQUMxQztBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/GUI/vite.config.ts.timestamp-1767946574215-f7ac6ce2fedaa.mjs b/GUI/vite.config.ts.timestamp-1767946574215-f7ac6ce2fedaa.mjs deleted file mode 100644 index b770c4c5..00000000 --- a/GUI/vite.config.ts.timestamp-1767946574215-f7ac6ce2fedaa.mjs +++ /dev/null @@ -1,70 +0,0 @@ -// 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"] - }, - 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+IHtcbiAgICAgICAgaWYoIWlkLmVuZHNXaXRoKCcvbWVudS1zdHJ1Y3R1cmUuanNvbicpKVxuICAgICAgICAgIHJldHVybiBzdHI7XG4gICAgICAgIHJldHVybiByZW1vdmVIaWRkZW5NZW51SXRlbXMoc3RyKTtcbiAgICAgIH0sXG4gICAgfSxcbiAgXSxcbiAgYmFzZTogJy9yYWctc2VhcmNoJyxcbiAgYnVpbGQ6IHtcbiAgICBvdXREaXI6ICcuL2J1aWxkJyxcbiAgICB0YXJnZXQ6ICdlczIwMTUnLFxuICAgIGVtcHR5T3V0RGlyOiB0cnVlLFxuICB9LFxuICBzZXJ2ZXI6IHtcbiAgICBoZWFkZXJzOiB7XG4gICAgICAuLi4ocHJvY2Vzcy5lbnYuUkVBQ1RfQVBQX0NTUCAmJiB7XG4gICAgICAgICdDb250ZW50LVNlY3VyaXR5LVBvbGljeSc6IHByb2Nlc3MuZW52LlJFQUNUX0FQUF9DU1AsXG4gICAgICB9KSxcbiAgICB9LFxuICAgIGFsbG93ZWRIb3N0czogWydlc3QtcmFnLXJ0Yy5yb290Y29kZS5zb2Z0d2FyZScsICdsb2NhbGhvc3QnLCAnMTI3LjAuMC4xJ10sXG5cbiAgfSxcbiAgcmVzb2x2ZToge1xuICAgIGFsaWFzOiB7XG4gICAgICAnfkBmb250c291cmNlJzogcGF0aC5yZXNvbHZlKF9fZGlybmFtZSwgJ25vZGVfbW9kdWxlcy9AZm9udHNvdXJjZScpLFxuICAgICAgJ0AnOiBgJHtwYXRoLnJlc29sdmUoX19kaXJuYW1lLCAnLi9zcmMnKX1gLFxuICAgIH0sXG4gIH0sXG59KTtcbiIsICJjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZGlybmFtZSA9IFwiL2FwcFwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL2FwcC92aXRlUGx1Z2luLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9hcHAvdml0ZVBsdWdpbi5qc1wiO2V4cG9ydCBmdW5jdGlvbiByZW1vdmVIaWRkZW5NZW51SXRlbXMoc3RyKSB7XG4gIGNvbnN0IGJhZEpzb24gPSBzdHIucmVwbGFjZSgnZXhwb3J0IGRlZmF1bHQgWycsICdbJykucmVwbGFjZSgnXTsnLCAnXScpO1xuICBjb25zdCBjb3JyZWN0SnNvbiA9IGJhZEpzb24ucmVwbGFjZSgvKFsnXCJdKT8oW2EtejAtOUEtWl9dKykoWydcIl0pPzovZywgJ1wiJDJcIjogJyk7XG5cbiBjb25zdCBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCA9IFxuICAgIHByb2Nlc3MuZW52LlJFQUNUX0FQUF9FTkFCTEVfSElEREVOX0ZFQVRVUkVTPy50b0xvd2VyQ2FzZSgpLnRyaW0oKSA9PT0gJ3RydWUnIHx8XG4gICAgcHJvY2Vzcy5lbnYuUkVBQ1RfQVBQX0VOQUJMRV9ISURERU5fRkVBVFVSRVM/LnRvTG93ZXJDYXNlKCkudHJpbSgpID09PSAnMSc7XG5cbiAgY29uc3QganNvbiA9IHJlbW92ZUhpZGRlbihKU09OLnBhcnNlKGNvcnJlY3RKc29uKSwgaXNIaWRkZW5GZWF0dXJlc0VuYWJsZWQpO1xuICBcbiAgY29uc3QgdXBkYXRlZEpzb24gPSBKU09OLnN0cmluZ2lmeShqc29uKTtcblxuICByZXR1cm4gJ2V4cG9ydCBkZWZhdWx0ICcgKyB1cGRhdGVkSnNvbiArICc7J1xufVxuXG5mdW5jdGlvbiByZW1vdmVIaWRkZW4obWVudUl0ZW1zLCBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCkge1xuICBpZighbWVudUl0ZW1zKSByZXR1cm4gbWVudUl0ZW1zO1xuICBjb25zdCBhcnIgPSBtZW51SXRlbXNcbiAgICA/LmZpbHRlcih4ID0+ICF4LmhpZGRlbilcbiAgICA/LmZpbHRlcih4ID0+IGlzSGlkZGVuRmVhdHVyZXNFbmFibGVkIHx8IHguaGlkZGVuTW9kZSAhPT0gXCJwcm9kdWN0aW9uXCIpO1xuICBmb3IgKGNvbnN0IGEgb2YgYXJyKSB7XG4gICAgYS5jaGlsZHJlbiA9IHJlbW92ZUhpZGRlbihhLmNoaWxkcmVuLCBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCk7XG4gIH1cbiAgcmV0dXJuIGFycjtcbn1cbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBOEwsU0FBUyxvQkFBb0I7QUFDM04sT0FBTyxXQUFXO0FBQ2xCLE9BQU8sbUJBQW1CO0FBQzFCLE9BQU8sVUFBVTtBQUNqQixPQUFPLFVBQVU7OztBQ0prTCxTQUFTLHNCQUFzQixLQUFLO0FBQXZPO0FBQ0UsUUFBTSxVQUFVLElBQUksUUFBUSxvQkFBb0IsR0FBRyxFQUFFLFFBQVEsTUFBTSxHQUFHO0FBQ3RFLFFBQU0sY0FBYyxRQUFRLFFBQVEsbUNBQW1DLFFBQVE7QUFFaEYsUUFBTSw0QkFDSCxhQUFRLElBQUkscUNBQVosbUJBQThDLGNBQWMsWUFBVyxZQUN2RSxhQUFRLElBQUkscUNBQVosbUJBQThDLGNBQWMsWUFBVztBQUV6RSxRQUFNLE9BQU8sYUFBYSxLQUFLLE1BQU0sV0FBVyxHQUFHLHVCQUF1QjtBQUUxRSxRQUFNLGNBQWMsS0FBSyxVQUFVLElBQUk7QUFFdkMsU0FBTyxvQkFBb0IsY0FBYztBQUMzQztBQUVBLFNBQVMsYUFBYSxXQUFXLHlCQUF5QjtBQWYxRDtBQWdCRSxNQUFHLENBQUM7QUFBVyxXQUFPO0FBQ3RCLFFBQU0sT0FBTSw0Q0FDUixPQUFPLE9BQUssQ0FBQyxFQUFFLFlBRFAsbUJBRVIsT0FBTyxPQUFLLDJCQUEyQixFQUFFLGVBQWU7QUFDNUQsYUFBVyxLQUFLLEtBQUs7QUFDbkIsTUFBRSxXQUFXLGFBQWEsRUFBRSxVQUFVLHVCQUF1QjtBQUFBLEVBQy9EO0FBQ0EsU0FBTztBQUNUOzs7QUR4QkEsSUFBTSxtQ0FBbUM7QUFRekMsSUFBTyxzQkFBUSxhQUFhO0FBQUEsRUFDMUIsV0FBVztBQUFBLEVBQ1gsU0FBUztBQUFBLElBQ1AsTUFBTTtBQUFBLElBQ04sY0FBYztBQUFBLElBQ2QsS0FBSztBQUFBLElBQ0w7QUFBQSxNQUNFLE1BQU07QUFBQSxNQUNOLFdBQVcsQ0FBQyxLQUFLLE9BQU87QUFDdEIsWUFBRyxDQUFDLEdBQUcsU0FBUyxzQkFBc0I7QUFDcEMsaUJBQU87QUFDVCxlQUFPLHNCQUFzQixHQUFHO0FBQUEsTUFDbEM7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUFBLEVBQ0EsTUFBTTtBQUFBLEVBQ04sT0FBTztBQUFBLElBQ0wsUUFBUTtBQUFBLElBQ1IsUUFBUTtBQUFBLElBQ1IsYUFBYTtBQUFBLEVBQ2Y7QUFBQSxFQUNBLFFBQVE7QUFBQSxJQUNOLFNBQVM7QUFBQSxNQUNQLEdBQUksUUFBUSxJQUFJLGlCQUFpQjtBQUFBLFFBQy9CLDJCQUEyQixRQUFRLElBQUk7QUFBQSxNQUN6QztBQUFBLElBQ0Y7QUFBQSxJQUNBLGNBQWMsQ0FBQyxpQ0FBaUMsYUFBYSxXQUFXO0FBQUEsRUFFMUU7QUFBQSxFQUNBLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMLGdCQUFnQixLQUFLLFFBQVEsa0NBQVcsMEJBQTBCO0FBQUEsTUFDbEUsS0FBSyxHQUFHLEtBQUssUUFBUSxrQ0FBVyxPQUFPLENBQUM7QUFBQSxJQUMxQztBQUFBLEVBQ0Y7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/GUI/vite.config.ts.timestamp-1768278822370-7924bd5f138d9.mjs b/GUI/vite.config.ts.timestamp-1768278822370-7924bd5f138d9.mjs deleted file mode 100644 index 3ffe5928..00000000 --- a/GUI/vite.config.ts.timestamp-1768278822370-7924bd5f138d9.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// 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/GUI/vite.config.ts.timestamp-1768278833602-e10c19bbae925.mjs b/GUI/vite.config.ts.timestamp-1768278833602-e10c19bbae925.mjs deleted file mode 100644 index 3ffe5928..00000000 --- a/GUI/vite.config.ts.timestamp-1768278833602-e10c19bbae925.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// 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== From 3324f6842653fe959a0cd796c823e078a717028e Mon Sep 17 00:00:00 2001 From: erangi-ar <111747955+erangi-ar@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:54:54 +0530 Subject: [PATCH 003/105] Prompt config module (#218) * updated docker compose ec2 * integrate streaming endpoint with test prodction connection page * formatted response with markdown * fe logic for the encryption * vault secret update after fixing issues * fixed formatting issue * integration with be * update cron manager vault script * tested integration of vault security update * fix security issues * creation success model changes * clean vite config generated files * fixed issue references are not sending with streming tokens * complete #192 and #206 bug fixes * production inference display logic change * change production inference display logic * fixed requested issue * Refactor Docker Compose configuration for vault agents and update CSP settings * Remove obsolete Vite configuration files and associated plugins * Add prompt configuration management feature * Add prompt configuration retrieval and update endpoint paths --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: erangi-ar Co-authored-by: nuwangeek Co-authored-by: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> --- .../rag-search-script-v5-prompt-config.sql | 8 ++ DSL/Liquibase/master.yml | 4 +- .../GET/get-prompt-configuration.sql | 5 + .../POST/insert-prompt-configuration.sql | 3 + .../POST/update-prompt-configuration.sql | 4 + .../GET/prompt-configuration/get.yml | 39 ++++++ .../POST/prompt-configuration/save.yml | 58 +++++++++ .../rag-search/POST/vault/secret/create.yml | 2 +- .../rag-search/POST/vault/secret/delete.yml | 2 +- GUI/src/App.tsx | 2 + GUI/src/components/MainNavigation/index.tsx | 16 ++- .../PromptConfigurations.scss | 39 ++++++ GUI/src/pages/PromptConfigurations/index.tsx | 100 +++++++++++++++ GUI/src/services/promptConfiguration.ts | 23 ++++ GUI/src/utils/endpoints.ts | 5 + GUI/src/utils/queryKeys.ts | 5 + GUI/translations/en/common.json | 19 ++- GUI/translations/et/common.json | 19 ++- README.md | 2 +- docker-compose-ec2.yml | 121 ++++++++++++++---- 20 files changed, 442 insertions(+), 34 deletions(-) create mode 100644 DSL/Liquibase/changelog/rag-search-script-v5-prompt-config.sql create mode 100644 DSL/Resql/rag-search/GET/get-prompt-configuration.sql create mode 100644 DSL/Resql/rag-search/POST/insert-prompt-configuration.sql create mode 100644 DSL/Resql/rag-search/POST/update-prompt-configuration.sql create mode 100644 DSL/Ruuter.private/rag-search/GET/prompt-configuration/get.yml create mode 100644 DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml create mode 100644 GUI/src/pages/PromptConfigurations/PromptConfigurations.scss create mode 100644 GUI/src/pages/PromptConfigurations/index.tsx create mode 100644 GUI/src/services/promptConfiguration.ts 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/master.yml b/DSL/Liquibase/master.yml index a1c31eb1..a17a2485 100644 --- a/DSL/Liquibase/master.yml +++ b/DSL/Liquibase/master.yml @@ -6,4 +6,6 @@ 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 \ 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..36b3f36b --- /dev/null +++ b/DSL/Resql/rag-search/GET/get-prompt-configuration.sql @@ -0,0 +1,5 @@ +SELECT + id, + prompt +FROM prompt_configuration +LIMIT 1 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..d60bd700 --- /dev/null +++ b/DSL/Resql/rag-search/POST/insert-prompt-configuration.sql @@ -0,0 +1,3 @@ +INSERT INTO prompt_configuration (prompt) +VALUES (:prompt) +RETURNING id, prompt 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..1d3fb205 --- /dev/null +++ b/DSL/Resql/rag-search/POST/update-prompt-configuration.sql @@ -0,0 +1,4 @@ +UPDATE prompt_configuration +SET prompt = :prompt +WHERE id = :id +RETURNING id, prompt 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/prompt-configuration/save.yml b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml new file mode 100644 index 00000000..ad90875d --- /dev/null +++ b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml @@ -0,0 +1,58 @@ +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: 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: return_update_success + +insert_prompt: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/insert-prompt-configuration" + body: + prompt: ${prompt} + result: insert_result + 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 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/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/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/pages/PromptConfigurations/PromptConfigurations.scss b/GUI/src/pages/PromptConfigurations/PromptConfigurations.scss new file mode 100644 index 00000000..edbeba13 --- /dev/null +++ b/GUI/src/pages/PromptConfigurations/PromptConfigurations.scss @@ -0,0 +1,39 @@ +.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); + + .form-actions { + margin-top: 1.5rem; + display: flex; + justify-content: flex-end; + gap: 1rem; + } + } +} diff --git a/GUI/src/pages/PromptConfigurations/index.tsx b/GUI/src/pages/PromptConfigurations/index.tsx new file mode 100644 index 00000000..0c7b5113 --- /dev/null +++ b/GUI/src/pages/PromptConfigurations/index.tsx @@ -0,0 +1,100 @@ +import { FC, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button, FormTextarea } from 'components'; +import { ButtonAppearanceTypes, ToastTypes } from 'enums/commonEnums'; +import CircularSpinner from 'components/molecules/CircularSpinner/CircularSpinner'; +import { getPromptConfiguration, savePromptConfiguration } 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); + + // 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) { + setPromptText(promptConfig[0].prompt || ''); + setIsUpdating(true); + } + }, [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'), + }); + }, + }); + + const handleSubmit = () => { + if (!promptText.trim()) { + return; + } + saveMutation.mutate(promptText); + }; + + if (isLoading) { + return ; + } + + return ( +
+
+
+
{t('promptConfigurations.title')}
+
+ +
+ setPromptText(e.target.value)} + minRows={10} + /> + +
+ +
+
+
+
+ ); +}; + +export default PromptConfigurations; diff --git a/GUI/src/services/promptConfiguration.ts b/GUI/src/services/promptConfiguration.ts new file mode 100644 index 00000000..09dc6d45 --- /dev/null +++ b/GUI/src/services/promptConfiguration.ts @@ -0,0 +1,23 @@ +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; +}; 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..abba3913 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", @@ -399,5 +404,17 @@ "aws": "AWS Bedrock", "azure": "Azure OpenAI" } + }, + "promptConfigurations": { + "title": "Prompt Configurations", + "subtitle": "Configure and manage your prompt templates", + "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." } } \ No newline at end of file diff --git a/GUI/translations/et/common.json b/GUI/translations/et/common.json index bd2d5504..2cf5b3c4 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", @@ -399,5 +404,17 @@ "aws": "AWS Bedrock", "azure": "Azure OpenAI" } + }, + "promptConfigurations": { + "title": "Viiba Seaded", + "subtitle": "Seadista ja halda oma viiba malle", + "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." } } \ No newline at end of file diff --git a/README.md b/README.md index 9e7dd82b..ad5edce9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The **BYK-RAG Module** is part of the Burokratt ecosystem, designed to provide * - Models searchable via dropdown with cache-enabled indicators. - **Enhanced Security with RSA Encryption** - - LLM credentials encrypted with RSA-2048 asymmetric encryption before storage. + - LLM credentials encrypted with RSA-2048 asymmetric encryption before storage. - GUI encrypts using public key; CronManager decrypts with private key. - Additional security layer beyond HashiCorp Vault's encryption. diff --git a/docker-compose-ec2.yml b/docker-compose-ec2.yml index 130a3484..26c19068 100644 --- a/docker-compose-ec2.yml +++ b/docker-compose-ec2.yml @@ -128,7 +128,7 @@ 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://est-rag-rtc.rootcode.software; + - 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; - DEBUG_ENABLED=true - CHOKIDAR_USEPOLLING=true - PORT=3001 @@ -174,25 +174,25 @@ services: cron-manager: container_name: cron-manager image: cron-manager-python:latest - user: "root" + user: root volumes: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_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 - ./datasets:/app/datasets # Direct access to datasets folder for diff identifier operations - ./grafana-configs/loki_logger.py:/app/src/vector_indexer/loki_logger.py - ./.env:/app/.env:ro - - vault-agent-token:/agent/out:ro # Mount vault token for accessing vault secrets environment: - server.port=9010 - PYTHONPATH=/app:/app/src/vector_indexer - - VAULT_ADDR=http://vault:8200 + - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 depends_on: - - vault-agent-llm + - vault-agent-cron networks: - bykstack @@ -496,10 +496,8 @@ services: - vault-data:/vault/file - ./vault/config:/vault/config:ro - ./vault/logs:/vault/logs - expose: - - "8200" networks: - - bykstack + - vault-network # Only on vault-network for security restart: unless-stopped healthcheck: test: ["CMD", "sh", "-c", "wget -q -O- http://127.0.0.1:8200/v1/sys/health || exit 0"] @@ -520,14 +518,74 @@ services: volumes: - vault-data:/vault/data - vault-agent-creds:/agent/credentials - - vault-agent-token:/agent/out + - vault-agent-gui-token:/agent/gui-token + - vault-agent-cron-token:/agent/cron-token + - vault-agent-llm-token:/agent/llm-token - ./vault-init.sh:/vault-init.sh:ro networks: - - bykstack + - vault-network # Access vault + - bykstack # Access to write agent tokens entrypoint: ["/bin/sh"] - command: ["-c", "apk add --no-cache curl jq && chmod -R 755 /agent/credentials && chmod -R 770 /agent/out && chown -R vault:vault /agent/credentials /agent/out && su vault -s /bin/sh /vault-init.sh"] + command: + - -c + - | + apk add --no-cache curl jq uuidgen openssl + # Create and set permissions for all agent directories + mkdir -p /agent/credentials /agent/gui-token /agent/cron-token /agent/llm-token /agent/out + chown -R vault:vault /agent/credentials /agent/gui-token /agent/cron-token /agent/llm-token /agent/out + chmod 755 /agent/credentials /agent/gui-token /agent/cron-token /agent/llm-token /agent/out + # Run vault initialization as vault user + su vault -s /bin/sh /vault-init.sh restart: "no" + vault-agent-gui: + image: hashicorp/vault:1.20.3 + container_name: vault-agent-gui + command: ["vault", "agent", "-config=/agent/config/gui-agent.hcl", "-log-level=info"] + depends_on: + vault-init: + condition: service_completed_successfully + cap_add: + - IPC_LOCK + volumes: + - ./vault/agents/gui/gui-agent.hcl:/agent/config/gui-agent.hcl:ro + - vault-agent-creds:/agent/credentials:ro + - vault-agent-gui-token:/agent/gui-token + networks: + - vault-network # Access vault + - bykstack # Accessible by GUI service + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "test -f /agent/gui-token/token && test -s /agent/gui-token/token"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + + vault-agent-cron: + image: hashicorp/vault:1.20.3 + container_name: vault-agent-cron + command: ["vault", "agent", "-config=/agent/config/cron-agent.hcl", "-log-level=info"] + depends_on: + vault-init: + condition: service_completed_successfully + cap_add: + - IPC_LOCK + volumes: + - ./vault/agents/cron/cron-agent.hcl:/agent/config/cron-agent.hcl:ro + - vault-agent-creds:/agent/credentials:ro + - vault-agent-cron-token:/agent/cron-token + networks: + - vault-network # Access vault + - bykstack # Accessible by CronManager service + restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "test -f /agent/cron-token/token && test -s /agent/cron-token/token"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + vault-agent-llm: image: hashicorp/vault:1.20.3 container_name: vault-agent-llm @@ -540,10 +598,17 @@ services: volumes: - ./vault/agents/llm/agent.hcl:/agent/config/agent.hcl:ro - vault-agent-creds:/agent/credentials:ro - - vault-agent-token:/agent/out + - vault-agent-llm-token:/agent/llm-token networks: - - bykstack + - vault-network # Access vault + - bykstack # Accessible by LLM service restart: unless-stopped + healthcheck: + test: ["CMD", "sh", "-c", "test -f /agent/llm-token/token && test -s /agent/llm-token/token"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s # LLM Orchestration Service llm-orchestration-service: @@ -558,24 +623,22 @@ services: - .env environment: - ENVIRONMENT=production - - VAULT_ADDR=http://vault:8200 - - VAULT_TOKEN=/agent/out/token + - VAULT_ADDR=http://vault-agent-llm:8201 + # VAULT_TOKEN not set - vault-agent-llm proxy handles authentication 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 - - vault-agent-token:/agent/out:ro networks: - bykstack depends_on: - - vault - vault-agent-llm - # healthcheck: - # test: ["CMD", "curl", "-f", "http://llm-orchestration-service:8100/health"] - # interval: 30s - # timeout: 10s - # start_period: 40s - # retries: 3 + healthcheck: + test: ["CMD", "curl", "-f", "http://llm-orchestration-service:8100/health"] + interval: 30s + timeout: 10s + start_period: 40s + retries: 3 volumes: loki-data: @@ -602,8 +665,12 @@ volumes: name: cron_data vault-agent-creds: name: vault-agent-creds - vault-agent-token: - name: vault-agent-token + vault-agent-gui-token: + name: vault-agent-gui-token + vault-agent-cron-token: + name: vault-agent-cron-token + vault-agent-llm-token: + name: vault-agent-llm-token opensearch-data: name: opensearch-data @@ -611,3 +678,7 @@ networks: bykstack: name: bykstack driver: bridge + vault-network: + name: vault-network + driver: bridge + internal: true # No external access - isolated network \ No newline at end of file From d45dc8a645a2b8caeb013328dcb5067878f76d87 Mon Sep 17 00:00:00 2001 From: erangi-ar <111747955+erangi-ar@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:16:15 +0530 Subject: [PATCH 004/105] Show used budget for llm connection (#215) * updated docker compose ec2 * integrate streaming endpoint with test prodction connection page * formatted response with markdown * fe logic for the encryption * vault secret update after fixing issues * fixed formatting issue * integration with be * update cron manager vault script * tested integration of vault security update * fix security issues * fixed issue references are not sending with streming tokens * complete #192 and #206 bug fixes * change production inference display logic * Add budget management features to LLMConnectionCard and translations * Remove obsolete Vite configuration files and associated plugins --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: erangi-ar Co-authored-by: nuwangeek Co-authored-by: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> --- .../molecules/LLMConnectionCard/index.tsx | 43 +++++++++++++++++++ GUI/src/pages/LLMConnections/index.tsx | 10 ++++- GUI/translations/en/common.json | 1 + GUI/translations/et/common.json | 1 + 4 files changed, 54 insertions(+), 1 deletion(-) 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/pages/LLMConnections/index.tsx b/GUI/src/pages/LLMConnections/index.tsx index 2484a82d..2c3542df 100644 --- a/GUI/src/pages/LLMConnections/index.tsx +++ b/GUI/src/pages/LLMConnections/index.tsx @@ -257,7 +257,11 @@ const LLMConnections: FC = () => { deploymentEnv={productionConnection.environment} budgetStatus={productionConnection.budgetStatus} platform={productionConnection.llmPlatform} - model={productionConnection.llmModel} + model={productionConnection.llmModel} + usedBudget={productionConnection.usedBudget} + monthlyBudget={productionConnection.monthlyBudget} + stopBudgetThreshold={productionConnection.stopBudgetThreshold} + disconnectOnBudgetExceed={productionConnection.disconnectOnBudgetExceed} />
@@ -278,6 +282,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} /> ); })} diff --git a/GUI/translations/en/common.json b/GUI/translations/en/common.json index abba3913..a8d17cef 100644 --- a/GUI/translations/en/common.json +++ b/GUI/translations/en/common.json @@ -163,6 +163,7 @@ "testing": "Testing", "production": "Production" }, + "budgetUsage": "Budget usage", "budgetStatus": { "withinBudget": "Within budget", "overBudget": "Over budget", diff --git a/GUI/translations/et/common.json b/GUI/translations/et/common.json index 2cf5b3c4..615c97aa 100644 --- a/GUI/translations/et/common.json +++ b/GUI/translations/et/common.json @@ -163,6 +163,7 @@ "testing": "Testimine", "production": "Toodang" }, + "budgetUsage": "Eelarve kasutamine", "budgetStatus": { "withinBudget": "Eelarve piires", "overBudget": "Eelarve ületatud", From 1b28136be352e8c584b028ed7694a6eb193a83d5 Mon Sep 17 00:00:00 2001 From: erangi-ar <111747955+erangi-ar@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:16:34 +0530 Subject: [PATCH 005/105] Refactor docker-compose-ec2.ym l file with new vault agent containers (#216) * updated docker compose ec2 * integrate streaming endpoint with test prodction connection page * formatted response with markdown * fe logic for the encryption * vault secret update after fixing issues * fixed formatting issue * integration with be * update cron manager vault script * tested integration of vault security update * fix security issues * creation success model changes * clean vite config generated files * fixed issue references are not sending with streming tokens * complete #192 and #206 bug fixes * production inference display logic change * change production inference display logic * fixed requested issue * Refactor Docker Compose configuration for vault agents and update CSP settings --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: erangi-ar Co-authored-by: nuwangeek Co-authored-by: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> From 45132f9ae782f929d70617dae0ea7617fdcac13f Mon Sep 17 00:00:00 2001 From: erangi-ar <111747955+erangi-ar@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:20:57 +0530 Subject: [PATCH 006/105] UI bug fixes (#219) * updated docker compose ec2 * integrate streaming endpoint with test prodction connection page * formatted response with markdown * fe logic for the encryption * vault secret update after fixing issues * fixed formatting issue * integration with be * update cron manager vault script * tested integration of vault security update * fix security issues * creation success model changes * clean vite config generated files * fixed issue references are not sending with streming tokens * complete #192 and #206 bug fixes * production inference display logic change * change production inference display logic * fixed requested issue * Refactor Docker Compose configuration for vault agents and update CSP settings * Remove obsolete Vite configuration files and associated plugins * bug fixes --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: erangi-ar Co-authored-by: nuwangeek Co-authored-by: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> --- .dockerignore | 1 + .../rag-search/POST/llm-connections/edit.yml | 33 +++++- .../FormElements/FormSelect/index.tsx | 10 +- .../LLMConnectionForm/LLMConnectionForm.scss | 24 ++-- .../molecules/LLMConnectionForm/index.tsx | 108 +++++++++-------- GUI/src/pages/LLMConnections/index.tsx | 111 +++++++----------- GUI/src/pages/TestModel/index.tsx | 5 + GUI/src/store/index.ts | 35 +++++- GUI/translations/en/common.json | 14 ++- GUI/translations/et/common.json | 11 +- 10 files changed, 202 insertions(+), 150 deletions(-) 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/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/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/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/pages/LLMConnections/index.tsx b/GUI/src/pages/LLMConnections/index.tsx index 2c3542df..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 = () => {
{ ); }; -export default LLMConnections; +export default LLMConnections; \ No newline at end of file diff --git a/GUI/src/pages/TestModel/index.tsx b/GUI/src/pages/TestModel/index.tsx index b6e66e76..c00c3ae1 100644 --- a/GUI/src/pages/TestModel/index.tsx +++ b/GUI/src/pages/TestModel/index.tsx @@ -75,6 +75,10 @@ const TestLLM: FC = () => { }; const handleChange = (key: string, value: string | number) => { + // Prevent changes while inference is loading + if (inferenceMutation.isLoading) { + return; + } setTestLLM((prev) => ({ ...prev, [key]: value, @@ -104,6 +108,7 @@ const TestLLM: FC = () => { }} value={testLLM?.connectionId === null ? t('testModels.connectionNotExist') || 'Connection does not exist' : undefined} defaultValue={testLLM?.connectionId ?? undefined} + disabled={inferenceMutation.isLoading} /> 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/translations/en/common.json b/GUI/translations/en/common.json index a8d17cef..8c2cac8a 100644 --- a/GUI/translations/en/common.json +++ b/GUI/translations/en/common.json @@ -127,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", @@ -300,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", @@ -328,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", @@ -348,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" @@ -366,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", diff --git a/GUI/translations/et/common.json b/GUI/translations/et/common.json index 615c97aa..1c093b6f 100644 --- a/GUI/translations/et/common.json +++ b/GUI/translations/et/common.json @@ -127,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", @@ -300,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", @@ -328,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", @@ -366,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", From 25c2a419a5c4b184f1212b2f6574d4ac49d42982 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 6 Feb 2026 10:25:08 +0530 Subject: [PATCH 007/105] prompt coniguration backend to be testing --- .../llm-connections/prompts/get-prompt.yml | 18 ++ src/llm_orchestration_service.py | 56 +++- src/llm_orchestration_service_api.py | 27 ++ .../llm_ochestrator_constants.py | 4 + src/response_generator/response_generate.py | 27 +- src/utils/prompt_config_loader.py | 276 ++++++++++++++++++ 6 files changed, 402 insertions(+), 6 deletions(-) create mode 100644 DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml create mode 100644 src/utils/prompt_config_loader.py 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..9c2d309f --- /dev/null +++ b/DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml @@ -0,0 +1,18 @@ +declaration: + call: declare + version: 0.1 + description: "Get custom prompt configuration from database" + method: post + accepts: json + returns: json + namespace: rag-search + +getPromptConfiguration: + call: http.get + args: + url: "[#RAG_SEARCH_RESQL]/get-prompt-configuration" + result: prompt_result + next: return_result + +return_result: + return: ${prompt_result.response.body[0]} diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 49b307d8..712e3013 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -39,6 +39,8 @@ 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 @@ -49,6 +51,7 @@ 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.guardrails import NeMoRailsAdapter, GuardrailCheckResult from src.contextual_retrieval import ContextualRetriever from src.llm_orchestrator_config.exceptions import ( @@ -99,6 +102,30 @@ class LLMOrchestrationService: 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, + ) + + # Warm up cache at startup (non-blocking) + 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." + ) @observe(name="orchestration_request", as_type="agent") def process_orchestration_request( @@ -2002,9 +2029,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 @@ -2012,6 +2044,28 @@ def _initialize_response_generator( except Exception as e: 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( diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index b58eac94..ddb3ca5d 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -718,6 +718,33 @@ async def get_available_embedding_models( ) +@app.get("/prompt-config/stats") +def get_prompt_config_stats(http_request: Request) -> Dict[str, Any]: + """ + Get prompt configuration cache statistics. + + Returns cache performance metrics and current configuration preview. + Useful for monitoring custom prompt configuration loading and caching. + """ + orchestration_service = http_request.app.state.orchestration_service + + if orchestration_service and hasattr(orchestration_service, "prompt_config_loader"): + stats = orchestration_service.prompt_config_loader.get_cache_stats() + + # Add preview of current configuration + custom_instructions = orchestration_service.prompt_config_loader.get_custom_instructions() + stats["current_prompt_preview"] = ( + custom_instructions[:200] + "..." + if len(custom_instructions) > 200 + else custom_instructions + ) + stats["applied_to"] = "ResponseGeneratorAgent only (not PromptRefinerAgent)" + + return stats + + return {"error": "Prompt configuration loader not initialized"} + + if __name__ == "__main__": logger.info("Starting LLM Orchestration Service API server on port 8100") uvicorn.run( diff --git a/src/llm_orchestrator_config/llm_ochestrator_constants.py b/src/llm_orchestrator_config/llm_ochestrator_constants.py index 61af6963..50749d68 100644 --- a/src/llm_orchestrator_config/llm_ochestrator_constants.py +++ b/src/llm_orchestrator_config/llm_ochestrator_constants.py @@ -124,3 +124,7 @@ def get_localized_message(message_dict: dict, language_code: str = "en") -> str: 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/response_generator/response_generate.py b/src/response_generator/response_generate.py index 23aa7442..589efd49 100644 --- a/src/response_generator/response_generate.py +++ b/src/response_generator/response_generate.py @@ -124,9 +124,10 @@ 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 @@ -237,6 +238,14 @@ async def stream_response( logger.info( f"Starting NATIVE DSPy streaming for question with {len(chunks)} chunks" ) + + # Prepend custom instructions if provided + augmented_question = question + if self._custom_instructions_prefix: + augmented_question = f"{self._custom_instructions_prefix}{question}" + logger.debug( + f"Applied custom instructions prefix to streaming ({len(self._custom_instructions_prefix)} chars)" + ) output_stream = None try: @@ -254,10 +263,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, ) @@ -390,6 +399,14 @@ def forward( max_blocks = ResponseGenerationConstants.DEFAULT_MAX_BLOCKS logger.info(f"Generating response for question: '{question}'") + + # Prepend custom instructions if provided + augmented_question = question + if self._custom_instructions_prefix: + augmented_question = f"{self._custom_instructions_prefix}{question}" + logger.debug( + f"Applied custom instructions prefix ({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 +415,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 +424,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/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py new file mode 100644 index 00000000..dda3d157 --- /dev/null +++ b/src/utils/prompt_config_loader.py @@ -0,0 +1,276 @@ +""" +Prompt configuration loader with HTTP client, caching, and retry logic. +""" + +import requests +from typing import Optional, Dict, Any +import time +import threading +from loguru import logger + + +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() + + # 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). + + Returns: + str: Custom instruction text, or empty string if unavailable + """ + with self._cache_lock: + # 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 - load from Ruuter + self._cache_misses += 1 + logger.info( + f"Prompt config cache MISS - loading from Ruuter " + f"(cache age: {self._get_cache_age():.1f}s)" + ) + + try: + prompt_text = self._load_from_ruuter_with_retry() + + 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 + else: + # No configuration found + logger.warning("⚠️ No prompt configuration found in database") + # Return stale cache if available, otherwise empty + return self._cached_prompt or "" + + except Exception as e: + self._load_failures += 1 + self._last_error = str(e) + logger.error( + f"❌ Failed to load prompt configuration: {e} " + f"(failures: {self._load_failures})" + ) + # Fallback to stale cache or empty string + if self._cached_prompt: + logger.warning( + f"⚠️ Using stale cache (age: {self._get_cache_age():.1f}s)" + ) + return self._cached_prompt or "" + + 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 or None if all retries fail + """ + 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() + + # DEBUG: Log the actual response structure + logger.info(f"📦 Response data type: {type(data)}") + logger.info(f"📦 Response data content: {data}") + + # 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.info(f"📦 Unwrapping 'response' key") + data = data["response"] + + # Now extract prompt from the unwrapped data + if isinstance(data, list) and len(data) > 0: + # Array format: [{"id": 1, "prompt": "..."}] + logger.info(f"📦 Extracting from list, first element: {data[0]}") + prompt = data[0].get("prompt", "").strip() + elif isinstance(data, dict): + # Dict format: {"id": 1, "prompt": "..."} + logger.info(f"📦 Extracting from dict, keys: {list(data.keys())}") + prompt = data.get("prompt", "").strip() + else: + logger.warning(f"⚠️ Unexpected data type: {type(data)}, value: {data}") + + logger.info(f"📦 Extracted prompt length: {len(prompt) if prompt else 0}") + + if prompt: + logger.info( + f"✅ Loaded prompt on attempt {attempt} " + f"({len(prompt)} chars)" + ) + return prompt + else: + logger.warning( + f"⚠️ Prompt field is empty (attempt {attempt})" + ) + return None # Database has no configuration + + 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}: " + f"{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 + logger.error( + f"❌ All {self.max_retries} attempts failed to load prompt configuration" + ) + return None + + def force_refresh(self) -> bool: + """ + Force immediate cache refresh. + + Returns: + bool: True if refresh successful, False otherwise + """ + logger.info("🔄 Forcing prompt configuration cache refresh") + with self._cache_lock: + self._cache_timestamp = None # Invalidate cache + + result = self.get_custom_instructions() + return bool(result) + + def get_cache_stats(self) -> Dict[str, Any]: + """Get cache statistics for monitoring.""" + with self._cache_lock: + 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, + } From 327e5b2b0881f99d3d7eba3639c7699b494021c7 Mon Sep 17 00:00:00 2001 From: erangi-ar Date: Mon, 9 Feb 2026 12:54:49 +0530 Subject: [PATCH 008/105] resolve pr comments --- .github/workflows/helm-dependency.yaml | 42 ++++++++++ kubernetes/CONTAINER_REGISTRY_SETUP.md | 35 -------- kubernetes/Chart.lock | 75 ----------------- kubernetes/Chart.yaml | 80 ------------------- .../templates/deployment-byk-clickhouse.yaml | 18 +++-- .../charts/ClickHouse/templates/secret.yaml | 13 +++ kubernetes/charts/ClickHouse/values.yaml | 7 +- .../templates/deployment-byk-cronmanager.yaml | 4 + kubernetes/charts/CronManager/values.yaml | 5 +- .../templates/deployment-byk-data-mapper.yaml | 4 + kubernetes/charts/DataMapper/values.yaml | 2 + .../GUI/templates/deployment-byk-gui.yaml | 28 +++---- kubernetes/charts/GUI/values.yaml | 6 +- .../templates/configmap-dashboards.yaml | 2 +- .../Grafana/templates/deployment-grafana.yaml | 6 ++ .../charts/Grafana/templates/secret.yaml | 10 +++ kubernetes/charts/Grafana/values.yaml | 13 +-- .../deployment-byk-langfuse-web.yaml | 6 ++ .../charts/Langfuse-Web/templates/secret.yaml | 25 ++++++ kubernetes/charts/Langfuse-Web/values.yaml | 29 +++---- .../deployment-byk-langfuse-worker.yaml | 6 ++ .../Langfuse-Worker/templates/secret.yaml | 24 ++++++ kubernetes/charts/Langfuse-Worker/values.yaml | 27 +++---- .../Liquibase/templates/liquibase-job.yaml | 18 ++++- .../charts/Liquibase/templates/secret.yaml | 12 +++ kubernetes/charts/Liquibase/values.yaml | 10 +-- .../Redis/templates/deployment-byk-redis.yaml | 10 +-- kubernetes/charts/Redis/templates/secret.yaml | 12 +++ kubernetes/charts/Redis/values.yaml | 9 ++- .../Resql/templates/deployment-byk-resql.yaml | 10 ++- kubernetes/charts/Resql/templates/secret.yaml | 11 +++ kubernetes/charts/Resql/values.yaml | 4 +- .../configmap-byk-ruuter-private.yaml | 3 +- .../deployment-byk-ruuter-private.yaml | 10 +++ .../Ruuter-Private/templates/secret.yaml | 11 +++ kubernetes/charts/Ruuter-Private/values.yaml | 2 + .../configmap-byk-ruuter-public.yaml | 3 +- .../deployment-byk-ruuter-public.yaml | 10 +++ .../Ruuter-Public/templates/secret.yaml | 11 +++ kubernetes/charts/Ruuter-Public/values.yaml | 3 + .../S3-Ferry/templates/configmap-s3.yaml | 10 --- .../S3-Ferry/templates/deployment-s3.yaml | 13 ++- .../charts/S3-Ferry/templates/secret.yaml | 12 +++ kubernetes/charts/S3-Ferry/values.yaml | 11 +-- kubernetes/charts/TIM-database/Chart.yaml | 6 -- .../templates/deployment-byk-timdb.yaml | 42 ---------- .../TIM-database/templates/pvc-byk-timdb.yaml | 19 ----- .../templates/secret-byk-timdb.yaml | 9 --- .../templates/service-byk-timdb.yaml | 15 ---- kubernetes/charts/TIM-database/values.yaml | 29 ------- .../charts/TIM/templates/secret-byk-tim.yaml | 9 ++- kubernetes/charts/TIM/values.yaml | 3 +- kubernetes/charts/database/Chart.lock | 6 -- kubernetes/charts/database/Chart.yaml | 10 +-- .../charts/database/templates/secret.yaml | 12 +++ .../charts/database/templates/service.yaml | 34 ++++++++ .../database/templates/statefulset.yaml | 66 +++++++++++++++ kubernetes/charts/database/values.yaml | 35 +++++--- .../minio/templates/deployment-minio.yaml | 10 ++- kubernetes/charts/minio/templates/secret.yaml | 10 +++ kubernetes/charts/minio/values.yaml | 7 +- kubernetes/dashboard-admin.yaml | 18 ----- kubernetes/values.yaml | 78 ------------------ 63 files changed, 530 insertions(+), 550 deletions(-) create mode 100644 .github/workflows/helm-dependency.yaml delete mode 100644 kubernetes/CONTAINER_REGISTRY_SETUP.md delete mode 100644 kubernetes/Chart.lock delete mode 100644 kubernetes/Chart.yaml create mode 100644 kubernetes/charts/ClickHouse/templates/secret.yaml create mode 100644 kubernetes/charts/Grafana/templates/secret.yaml create mode 100644 kubernetes/charts/Langfuse-Web/templates/secret.yaml create mode 100644 kubernetes/charts/Langfuse-Worker/templates/secret.yaml create mode 100644 kubernetes/charts/Liquibase/templates/secret.yaml create mode 100644 kubernetes/charts/Redis/templates/secret.yaml create mode 100644 kubernetes/charts/Resql/templates/secret.yaml create mode 100644 kubernetes/charts/Ruuter-Private/templates/secret.yaml create mode 100644 kubernetes/charts/Ruuter-Public/templates/secret.yaml delete mode 100644 kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml create mode 100644 kubernetes/charts/S3-Ferry/templates/secret.yaml delete mode 100644 kubernetes/charts/TIM-database/Chart.yaml delete mode 100644 kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml delete mode 100644 kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml delete mode 100644 kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml delete mode 100644 kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml delete mode 100644 kubernetes/charts/TIM-database/values.yaml delete mode 100644 kubernetes/charts/database/Chart.lock create mode 100644 kubernetes/charts/database/templates/secret.yaml create mode 100644 kubernetes/charts/database/templates/service.yaml create mode 100644 kubernetes/charts/database/templates/statefulset.yaml create mode 100644 kubernetes/charts/minio/templates/secret.yaml delete mode 100644 kubernetes/dashboard-admin.yaml delete mode 100644 kubernetes/values.yaml 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/kubernetes/CONTAINER_REGISTRY_SETUP.md b/kubernetes/CONTAINER_REGISTRY_SETUP.md deleted file mode 100644 index d88f16b6..00000000 --- a/kubernetes/CONTAINER_REGISTRY_SETUP.md +++ /dev/null @@ -1,35 +0,0 @@ -# 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 deleted file mode 100644 index aa9d953c..00000000 --- a/kubernetes/Chart.lock +++ /dev/null @@ -1,75 +0,0 @@ -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-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 -digest: sha256:ebf9bd6c7a999f2ab58598fdfff371579d6c7ca17d35e87fc8200668c2ae493e -generated: "2025-12-02T13:11:13.8392479+05:30" diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml deleted file mode 100644 index 698d4b9e..00000000 --- a/kubernetes/Chart.yaml +++ /dev/null @@ -1,80 +0,0 @@ -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" - - name: TIM-database - version: 0.1.0 - repository: "file://./charts/TIM-database" - - name: resql - version: 0.1.0 - repository: "file://./charts/Resql" - - name: ruuter-public - version: 0.1.0 - repository: "file://./charts/Ruuter-Public" - - name: ruuter-private - version: 0.1.0 - repository: "file://./charts/Ruuter-Private" - - name: data-mapper - version: 0.1.0 - repository: "file://./charts/DataMapper" - - name: TIM - version: 0.1.0 - repository: "file://./charts/TIM" - - name: Authentication-Layer - version: 0.1.0 - repository: "file://./charts/Authentication-Layer" - - name: CronManager - version: 0.1.0 - repository: "file://./charts/CronManager" - - name: GUI - version: 0.1.0 - repository: "file://./charts/GUI" - - name: Loki - version: 0.1.0 - repository: "file://./charts/Loki" - - name: Grafana - version: 0.1.0 - repository: "file://./charts/Grafana" - - name: S3-Ferry - version: 0.1.0 - repository: "file://./charts/S3-Ferry" - - name: minio - version: 0.1.0 - repository: "file://./charts/minio" - - name: Redis - version: 0.1.0 - repository: "file://./charts/Redis" - - name: Qdrant - version: 0.1.0 - repository: "file://./charts/Qdrant" - - name: ClickHouse - version: 0.1.0 - repository: "file://./charts/ClickHouse" - - name: Langfuse-Web - version: 0.1.0 - repository: "file://./charts/Langfuse-Web" - - name: Langfuse-Worker - version: 0.1.0 - repository: "file://./charts/Langfuse-Worker" - - name: Vault - version: 0.1.0 - repository: "file://./charts/Vault" - - name: Vault-Init - version: 0.1.0 - repository: "file://./charts/Vault-Init" - - name: Vault-Agent-LLM - version: 0.1.0 - repository: "file://./charts/Vault-Agent-LLM" - - name: LLM-Orchestration-Service - version: 0.1.0 - repository: "file://./charts/LLM-Orchestration-Service" - - name: Liquibase - version: 0.1.0 - repository: "file://./charts/Liquibase" - diff --git a/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml b/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml index 1deb38f2..78f99697 100644 --- a/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml +++ b/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml @@ -34,13 +34,15 @@ spec: - name: native containerPort: {{ .Values.service.nativePort }} protocol: TCP + # Non-sensitive env's from values.yaml env: - name: CLICKHOUSE_DB value: "{{ .Values.env.CLICKHOUSE_DB }}" - - name: CLICKHOUSE_USER - value: "{{ .Values.env.CLICKHOUSE_USER }}" - - name: CLICKHOUSE_PASSWORD - value: "{{ .Values.env.CLICKHOUSE_PASSWORD }}" + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} {{- if .Values.healthcheck.enabled }} livenessProbe: httpGet: @@ -61,9 +63,9 @@ spec: {{- end }} {{- if .Values.persistence.enabled }} volumeMounts: - - name: langfuse_clickhouse_data + - name: langfuse-clickhouse-data mountPath: /var/lib/clickhouse - - name: langfuse_clickhouse_logs + - name: langfuse-clickhouse-logs mountPath: /var/log/clickhouse-server {{- end }} resources: @@ -75,10 +77,10 @@ spec: cpu: "{{ .Values.resources.limits.cpu }}" {{- if .Values.persistence.enabled }} volumes: - - name: langfuse_clickhouse_data + - name: langfuse-clickhouse-data persistentVolumeClaim: claimName: "{{ .Values.release_name }}-data" - - name: langfuse_clickhouse_logs + - name: langfuse-clickhouse-logs persistentVolumeClaim: claimName: "{{ .Values.release_name }}-logs" {{- end }} 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/values.yaml b/kubernetes/charts/ClickHouse/values.yaml index 7d181c4a..287c84a5 100644 --- a/kubernetes/charts/ClickHouse/values.yaml +++ b/kubernetes/charts/ClickHouse/values.yaml @@ -19,8 +19,11 @@ service: # Environment variables env: CLICKHOUSE_DB: "default" - CLICKHOUSE_USER: "clickhouse" - CLICKHOUSE_PASSWORD: "changeme" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: clickhouse-secrets # Security context securityContext: diff --git a/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml index 0fbfc953..375d5db5 100644 --- a/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml +++ b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml @@ -12,6 +12,10 @@ spec: app: "{{ .Values.release_name }}" template: metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: app: "{{ .Values.release_name }}" spec: diff --git a/kubernetes/charts/CronManager/values.yaml b/kubernetes/charts/CronManager/values.yaml index 21c24d4d..825c8971 100644 --- a/kubernetes/charts/CronManager/values.yaml +++ b/kubernetes/charts/CronManager/values.yaml @@ -40,4 +40,7 @@ resources: cpu: "500m" limits: memory: "4Gi" - cpu: "2000m" \ No newline at end of file + cpu: "2000m" + +podAnnotations: + dsl-checksum: "initial" \ 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 index 09f23652..f52cf138 100644 --- a/kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml +++ b/kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml @@ -12,6 +12,10 @@ spec: app: "{{ .Values.release_name }}" template: metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: app: "{{ .Values.release_name }}" spec: diff --git a/kubernetes/charts/DataMapper/values.yaml b/kubernetes/charts/DataMapper/values.yaml index 220526ca..3267f3b9 100644 --- a/kubernetes/charts/DataMapper/values.yaml +++ b/kubernetes/charts/DataMapper/values.yaml @@ -29,3 +29,5 @@ resources: pullPolicy: IfNotPresent +podAnnotations: + dsl-checksum: "initial" \ No newline at end of file diff --git a/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml b/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml index 081b1842..94be8722 100644 --- a/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml +++ b/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml @@ -66,21 +66,21 @@ spec: 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 + # 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 + # readinessProbe: + # httpGet: + # path: / + # port: {{ .Values.gui.port }} + # initialDelaySeconds: 10 + # periodSeconds: 5 + # timeoutSeconds: 3 restartPolicy: Always diff --git a/kubernetes/charts/GUI/values.yaml b/kubernetes/charts/GUI/values.yaml index 192a9e52..e48710e3 100644 --- a/kubernetes/charts/GUI/values.yaml +++ b/kubernetes/charts/GUI/values.yaml @@ -2,9 +2,9 @@ gui: enabled: true release_name: gui image: - repository: "ghcr.io/buerokratt/rag-gui" # Update with actual GUI image repository - tag: latest - pullPolicy: IfNotPresent + repository: "ghcr.io/buerokratt/rag-gui" # Update with actual GUI image repository + tag: sha-84833e1 + pullPolicy: Always # React application configuration nodeEnv: production diff --git a/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml b/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml index 844aa70a..3228eca8 100644 --- a/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml +++ b/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml @@ -7,5 +7,5 @@ metadata: data: {{- range $path, $content := .Files.Glob "dashboards/*.json" }} {{ base $path }}: | -{{ $content | indent 4 }} +{{ $.Files.Get $path | indent 4 }} {{- 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 index 4b6fef87..d9191db7 100644 --- a/kubernetes/charts/Grafana/templates/deployment-grafana.yaml +++ b/kubernetes/charts/Grafana/templates/deployment-grafana.yaml @@ -22,11 +22,17 @@ spec: - 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 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/values.yaml b/kubernetes/charts/Grafana/values.yaml index 805b8404..bd0d08cf 100644 --- a/kubernetes/charts/Grafana/values.yaml +++ b/kubernetes/charts/Grafana/values.yaml @@ -31,9 +31,9 @@ resources: cpu: 250m memory: 256Mi +# Admin configuration admin: - user: admin - password: admin + enabled: true # Datasources configuration datasources: @@ -55,8 +55,11 @@ dashboardProviders: options: path: /var/lib/grafana/dashboards -# Environment variables +# Environment variables (non-sensitive) env: - GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: admin123 GF_USERS_ALLOW_SIGN_UP: "false" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: grafana-secrets diff --git a/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml index 59cff5c1..403e253f 100644 --- a/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml +++ b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml @@ -25,11 +25,17 @@ spec: - name: http containerPort: {{ .Values.service.targetPort }} 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: 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/values.yaml b/kubernetes/charts/Langfuse-Web/values.yaml index a85b0eae..6dfaf1cf 100644 --- a/kubernetes/charts/Langfuse-Web/values.yaml +++ b/kubernetes/charts/Langfuse-Web/values.yaml @@ -16,36 +16,25 @@ service: # Environment variables env: - # Database configuration + # Non-sensitive configuration NEXTAUTH_URL: "http://localhost:3000" - DATABASE_URL: "postgresql://postgres:dbadmin@rag_search_db:5432/rag-search" - SALT: "changeme" - ENCRYPTION_KEY: "changeme" - NEXTAUTH_SECRET: "changeme" TELEMETRY_ENABLED: "true" LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" - # ClickHouse configuration - CLICKHOUSE_MIGRATION_URL: "clickhouse://clickhouse:9000" + # ClickHouse configuration (non-sensitive) CLICKHOUSE_URL: "http://clickhouse:8123" - CLICKHOUSE_USER: "default" - CLICKHOUSE_PASSWORD: "clickhouse" CLICKHOUSE_CLUSTER_ENABLED: "false" - # S3/MinIO configuration + # 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_ACCESS_KEY_ID: "changeme" - LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "changeme" 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_ACCESS_KEY_ID: "changeme" - LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "changeme" LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://minio:9000" LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "langfuse/media/" @@ -56,16 +45,13 @@ env: 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_ACCESS_KEY_ID: "changeme" - LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: "changeme" LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" LANGFUSE_INGESTION_QUEUE_DELAY_MS: "" LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: "" - # Redis configuration + # Redis configuration (non-sensitive) REDIS_HOST: "redis" REDIS_PORT: "6379" - REDIS_AUTH: "myredissecret" REDIS_TLS_ENABLED: "false" REDIS_TLS_CA: "" REDIS_TLS_CERT: "" @@ -86,6 +72,13 @@ env: LANGFUSE_INIT_USER_NAME: "" LANGFUSE_INIT_USER_PASSWORD: "" +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: langfuse-web-secrets + + + resources: requests: memory: "512Mi" diff --git a/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml b/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml index 3a82d36c..1ab3c559 100644 --- a/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml +++ b/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml @@ -25,11 +25,17 @@ spec: - 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: 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/values.yaml b/kubernetes/charts/Langfuse-Worker/values.yaml index 61e5cb0c..0a7343eb 100644 --- a/kubernetes/charts/Langfuse-Worker/values.yaml +++ b/kubernetes/charts/Langfuse-Worker/values.yaml @@ -15,35 +15,25 @@ service: # Environment variables env: - # Database configuration + # Non-sensitive configuration NEXTAUTH_URL: "http://localhost:3000" - DATABASE_URL: "postgresql://postgres:dbadmin@rag_search_db:5432/rag-search" - SALT: "changeme" - ENCRYPTION_KEY: "changeme" TELEMETRY_ENABLED: "true" LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" - # ClickHouse configuration - CLICKHOUSE_MIGRATION_URL: "clickhouse://clickhouse:9000" + # ClickHouse configuration (non-sensitive) CLICKHOUSE_URL: "http://clickhouse:8123" - CLICKHOUSE_USER: "default" - CLICKHOUSE_PASSWORD: "clickhouse" CLICKHOUSE_CLUSTER_ENABLED: "false" - # S3/MinIO configuration + # 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_ACCESS_KEY_ID: "changeme" - LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "changeme" 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_ACCESS_KEY_ID: "changeme" - LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "changeme" LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://minio:9000" LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "langfuse/media/" @@ -54,16 +44,13 @@ env: 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_ACCESS_KEY_ID: "changeme" - LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: "changeme" LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" LANGFUSE_INGESTION_QUEUE_DELAY_MS: "" LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: "" - # Redis configuration + # Redis configuration (non-sensitive) REDIS_HOST: "redis" REDIS_PORT: "6379" - REDIS_AUTH: "myredissecret" REDIS_TLS_ENABLED: "false" REDIS_TLS_CA: "" REDIS_TLS_CERT: "" @@ -73,6 +60,12 @@ env: 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" diff --git a/kubernetes/charts/Liquibase/templates/liquibase-job.yaml b/kubernetes/charts/Liquibase/templates/liquibase-job.yaml index d9e54cee..3ec6bea8 100644 --- a/kubernetes/charts/Liquibase/templates/liquibase-job.yaml +++ b/kubernetes/charts/Liquibase/templates/liquibase-job.yaml @@ -9,6 +9,10 @@ spec: backoffLimit: {{ .Values.backoffLimit }} template: metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: app: "{{ .Values.release_name }}" spec: @@ -38,8 +42,18 @@ spec: - 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 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 index 0d538422..3d68b496 100644 --- a/kubernetes/charts/Liquibase/values.yaml +++ b/kubernetes/charts/Liquibase/values.yaml @@ -8,14 +8,14 @@ images: tag: "4.33.0" env: - - name: POSTGRES_USER - value: "postgres" - - name: POSTGRES_PASSWORD - value: "dbadmin" - name: LIQUIBASE_URL value: "jdbc:postgresql://rag-search-db:5432/rag-search" - name: LIQUIBASE_CHANGELOG_FILE value: /master.yml -pullPolicy: IfNotPresent \ No newline at end of file + +pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "211bdc77c12b" \ 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 index b67ab1d2..f60b6d69 100644 --- a/kubernetes/charts/Redis/templates/deployment-byk-redis.yaml +++ b/kubernetes/charts/Redis/templates/deployment-byk-redis.yaml @@ -31,11 +31,11 @@ spec: - --requirepass - $(REDIS_PASSWORD) {{- end }} - env: - {{- if .Values.auth.enabled }} - - name: REDIS_PASSWORD - value: "{{ .Values.auth.password }}" - {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} {{- if .Values.healthcheck.enabled }} livenessProbe: exec: 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/values.yaml b/kubernetes/charts/Redis/values.yaml index 01018687..784f8c1c 100644 --- a/kubernetes/charts/Redis/values.yaml +++ b/kubernetes/charts/Redis/values.yaml @@ -15,10 +15,11 @@ service: auth: enabled: true - password: "myredissecret" - -env: - REDIS_PASSWORD: "myredissecret" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: redis-secrets # Resource configuration resources: diff --git a/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml b/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml index edc52930..c44dc303 100644 --- a/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml +++ b/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml @@ -10,6 +10,10 @@ spec: app: "{{ .Values.release_name }}" template: metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: app: "{{ .Values.release_name }}" spec: @@ -48,8 +52,12 @@ spec: 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 - value: "{{ .Values.env.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 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/values.yaml b/kubernetes/charts/Resql/values.yaml index 470b7a0d..c49d1bab 100644 --- a/kubernetes/charts/Resql/values.yaml +++ b/kubernetes/charts/Resql/values.yaml @@ -17,7 +17,6 @@ env: SQLMS_DATASOURCES_0_NAME: "byk" SQLMS_DATASOURCES_0_JDBCURL: "jdbc:postgresql://rag-search-db:5432/rag-search" SQLMS_DATASOURCES_0_USERNAME: "postgres" - SQLMS_DATASOURCES_0_PASSWORD: "dbadmin" LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_BOOT: "INFO" JAVA_OPTS: "-Xms1g -Xmx3g" @@ -30,3 +29,6 @@ resources: cpu: "50m" pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "initial" diff --git a/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml index 9a20ec2d..6f84c283 100644 --- a/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml +++ b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml @@ -15,5 +15,4 @@ data: 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 - DB_PASSWORD=dbadmin \ No newline at end of file + 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 index c2082f6d..866d3a7d 100644 --- a/kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml +++ b/kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml @@ -12,6 +12,10 @@ spec: app: "{{ .Values.release_name }}" template: metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: app: "{{ .Values.release_name }}" spec: @@ -53,6 +57,12 @@ spec: 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 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/values.yaml b/kubernetes/charts/Ruuter-Private/values.yaml index a6a6d64d..3a48ac17 100644 --- a/kubernetes/charts/Ruuter-Private/values.yaml +++ b/kubernetes/charts/Ruuter-Private/values.yaml @@ -53,4 +53,6 @@ ingress: pullPolicy: IfNotPresent +podAnnotations: + dsl-checksum: "initial" diff --git a/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml index 354b6f2a..a6a56c0c 100644 --- a/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml +++ b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml @@ -15,5 +15,4 @@ data: 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 - DB_PASSWORD=dbadmin \ No newline at end of file + 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 index ebf2c2f8..e0814302 100644 --- a/kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml +++ b/kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml @@ -12,6 +12,10 @@ spec: app: "{{ .Values.release_name }}" template: metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} labels: app: "{{ .Values.release_name }}" spec: @@ -53,6 +57,12 @@ spec: 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 }}" 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/values.yaml b/kubernetes/charts/Ruuter-Public/values.yaml index 635d51c9..320d43f6 100644 --- a/kubernetes/charts/Ruuter-Public/values.yaml +++ b/kubernetes/charts/Ruuter-Public/values.yaml @@ -49,3 +49,6 @@ ingress: pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "94b84bb5ff4d" diff --git a/kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml b/kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml deleted file mode 100644 index 5a80ebe0..00000000 --- a/kubernetes/charts/S3-Ferry/templates/configmap-s3.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Values.release_name }}-config - labels: - app: {{ .Values.release_name }} -data: - {{- range $key, $value := .Values.env }} - {{ $key }}: {{ $value | quote }} - {{- end }} \ 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 index 16678196..af396e8c 100644 --- a/kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml +++ b/kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml @@ -22,9 +22,18 @@ spec: - 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: - - configMapRef: - name: {{ .Values.release_name }}-config + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + volumeMounts: {{- if .Values.persistence.enabled }} - name: shared 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/values.yaml b/kubernetes/charts/S3-Ferry/values.yaml index 69c03c5f..6274d414 100644 --- a/kubernetes/charts/S3-Ferry/values.yaml +++ b/kubernetes/charts/S3-Ferry/values.yaml @@ -38,7 +38,7 @@ resources: cpu: 250m memory: 256Mi -# Environment variables +# Environment variables (non-sensitive) env: API_CORS_ORIGIN: "*" API_DOCUMENTATION_ENABLED: "true" @@ -48,14 +48,15 @@ env: S3_DATA_BUCKET_PATH: "resources" S3_DATA_BUCKET_NAME: "rag-search" FS_DATA_DIRECTORY_PATH: "/app" - S3_SECRET_ACCESS_KEY: "changeme" - S3_ACCESS_KEY_ID: "changeme" S3_HEALTH_ENDPOINT: "http://minio:9000/minio/health/live" MINIO_BROWSER_REDIRECT_URL: "http://localhost:9091" - GF_SECURITY_ADMIN_USER: "admin" - GF_SECURITY_ADMIN_PASSWORD: "admin123" GF_USERS_ALLOW_SIGN_UP: "false" PORT: "3000" +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: s3-ferry-secrets + diff --git a/kubernetes/charts/TIM-database/Chart.yaml b/kubernetes/charts/TIM-database/Chart.yaml deleted file mode 100644 index 2bcdf24d..00000000 --- a/kubernetes/charts/TIM-database/Chart.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v2 -name: TIM-database -description: TIM postgresql database -type: application -version: 0.1.0 -appVersion: "1.0" diff --git a/kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml deleted file mode 100644 index 7e8fa6ba..00000000 --- a/kubernetes/charts/TIM-database/templates/deployment-byk-timdb.yaml +++ /dev/null @@ -1,42 +0,0 @@ -{{- if .Values.timPostgresql.enabled }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} - labels: - app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} -spec: - replicas: {{ .Values.timPostgresql.replicaCount | default 1 }} - selector: - matchLabels: - app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} - template: - metadata: - labels: - app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} - spec: - containers: - - name: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} - image: "{{ .Values.timPostgresql.image.repository }}:{{ .Values.timPostgresql.image.tag }}" - imagePullPolicy: {{ .Values.timPostgresql.image.pullPolicy }} - env: - {{- range .Values.timPostgresql.env }} - - name: {{ .name }} - value: {{ .value | quote }} - {{- end }} - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: {{ .Values.timPostgresql.secret.name }} - key: POSTGRES_PASSWORD - ports: - - containerPort: {{ .Values.timPostgresql.service.port }} - resources: - {{- toYaml .Values.timPostgresql.resources | nindent 12 }} - volumes: - {{- if .Values.timPostgresql.persistence.enabled }} - - name: postgres-storage - persistentVolumeClaim: - claimName: {{ .Values.timPostgresql.persistence.existingClaim | default (printf "%s-pvc" (.Values.timPostgresql.nameOverride | default "tim-postgresql")) }} - {{- end }} -{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml deleted file mode 100644 index 6dfdc707..00000000 --- a/kubernetes/charts/TIM-database/templates/pvc-byk-timdb.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- if and .Values.timPostgresql.enabled .Values.timPostgresql.persistence.enabled }} -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ .Values.timPostgresql.persistence.existingClaim | default (printf "%s-pvc" (.Values.timPostgresql.nameOverride | default "tim-postgresql")) }} - labels: - app: {{ .Values.timPostgresql.nameOverride | default "tim-postgresql" }} -spec: - accessModes: - {{- range .Values.timPostgresql.persistence.accessModes }} - - {{ . }} - {{- end }} - resources: - requests: - storage: {{ .Values.timPostgresql.persistence.size }} - {{- if .Values.timPostgresql.persistence.storageClass }} - storageClassName: {{ .Values.timPostgresql.persistence.storageClass }} - {{- end }} -{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml deleted file mode 100644 index 30b97aea..00000000 --- a/kubernetes/charts/TIM-database/templates/secret-byk-timdb.yaml +++ /dev/null @@ -1,9 +0,0 @@ -{{- if .Values.timPostgresql.secret.create }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ .Values.timPostgresql.secret.name }} -type: Opaque -data: - POSTGRES_PASSWORD: {{ .Values.timPostgresql.secret.keys.POSTGRES_PASSWORD | b64enc | quote }} -{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml b/kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml deleted file mode 100644 index 686d20ce..00000000 --- a/kubernetes/charts/TIM-database/templates/service-byk-timdb.yaml +++ /dev/null @@ -1,15 +0,0 @@ -{{- if .Values.timPostgresql.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ .Values.timPostgresql.nameOverride }} -spec: - type: {{ .Values.timPostgresql.service.type | default "ClusterIP" }} - selector: - app: {{ .Values.timPostgresql.nameOverride }} - ports: - - name: postgres - port: {{ .Values.timPostgresql.service.port }} - targetPort: {{ .Values.timPostgresql.service.port }} - nodePort: {{- if eq .Values.timPostgresql.service.type "NodePort" }} {{ .Values.timPostgresql.service.externalPort }} {{- end }} -{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM-database/values.yaml b/kubernetes/charts/TIM-database/values.yaml deleted file mode 100644 index 2e9ce2b7..00000000 --- a/kubernetes/charts/TIM-database/values.yaml +++ /dev/null @@ -1,29 +0,0 @@ -timPostgresql: - enabled: true - nameOverride: tim-postgresql - image: - repository: postgres - tag: "14.1" - pullPolicy: IfNotPresent - service: - type: ClusterIP - port: 5432 - externalPort: 9876 - env: - - name: POSTGRES_USER - value: "tim" - - name: POSTGRES_DB - value: "tim" - - name: POSTGRES_HOST_AUTH_METHOD - value: "trust" - secret: - create: true - name: tim-postgres-secret - keys: - POSTGRES_PASSWORD: "Ab123" - persistence: - enabled: true - size: 1Gi - storageClass: "" - accessModes: ["ReadWriteOnce"] - existingClaim: "" diff --git a/kubernetes/charts/TIM/templates/secret-byk-tim.yaml b/kubernetes/charts/TIM/templates/secret-byk-tim.yaml index 0692c868..81fc11a1 100644 --- a/kubernetes/charts/TIM/templates/secret-byk-tim.yaml +++ b/kubernetes/charts/TIM/templates/secret-byk-tim.yaml @@ -3,7 +3,8 @@ kind: Secret metadata: name: tim-env-secret type: Opaque -data: - oauth2_client_secret: "{{ .Values.tim.config.oauth2_client_secret | b64enc }}" - jwt_integration_key_store_password: "{{ .Values.tim.config.jwt_keystore_password | b64enc }}" - POSTGRES_PASSWORD: "{{ "dbadmin" | b64enc }}" +stringData: + oauth2_client_secret: "" + jwt_integration_key_store_password: "" + POSTGRES_PASSWORD: "" + diff --git a/kubernetes/charts/TIM/values.yaml b/kubernetes/charts/TIM/values.yaml index a057edcd..daba08c5 100644 --- a/kubernetes/charts/TIM/values.yaml +++ b/kubernetes/charts/TIM/values.yaml @@ -22,7 +22,6 @@ 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_keystore_password: "defaultpassword" jwt_issuer: "tim-issuer" spring_profiles_active: "dev" logging_level_root: "DEBUG" @@ -31,11 +30,11 @@ tim: legacy_referer_marker: "NA" legacy_url: "NA" oauth2_client_id: "your-client-id" - oauth2_client_secret: "my-secret-value" 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" diff --git a/kubernetes/charts/database/Chart.lock b/kubernetes/charts/database/Chart.lock deleted file mode 100644 index 641f6d08..00000000 --- a/kubernetes/charts/database/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -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 index 2facc943..9612978c 100644 --- a/kubernetes/charts/database/Chart.yaml +++ b/kubernetes/charts/database/Chart.yaml @@ -1,12 +1,6 @@ apiVersion: v2 name: database -description: PostgreSQL databases for RAG +description: PostgreSQL databases for RAG Module using pure PostgreSQL type: application -version: 0.1.0 - -dependencies: - - name: postgresql - version: 12.2.6 - repository: https://charts.bitnami.com/bitnami - alias: rag-search-db +version: 0.2.0 \ No newline at end of file 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..4ff65816 --- /dev/null +++ b/kubernetes/charts/database/templates/statefulset.yaml @@ -0,0 +1,66 @@ +{{- 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 + 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 index d7841e56..8c43f0f5 100644 --- a/kubernetes/charts/database/values.yaml +++ b/kubernetes/charts/database/values.yaml @@ -1,14 +1,25 @@ -rag-search-db: - fullnameOverride: rag-search-db - image: - tag: latest - auth: - postgresPassword: dbadmin +# Centralized database configuration using pure PostgreSQL +databases: + - name: rag-search-db username: postgres - password: dbadmin - database: rag-search - primary: - persistence: - enabled: true - size: 8Gi + password: "{{ ragSearchDB.password }}" + db: rag-search + storage: 8Gi + - 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"] diff --git a/kubernetes/charts/minio/templates/deployment-minio.yaml b/kubernetes/charts/minio/templates/deployment-minio.yaml index 2012d5bf..1ba2cc74 100644 --- a/kubernetes/charts/minio/templates/deployment-minio.yaml +++ b/kubernetes/charts/minio/templates/deployment-minio.yaml @@ -44,11 +44,17 @@ spec: - containerPort: {{ .Values.ports.console }} name: console protocol: TCP + # Non-sensitive env's from values.yaml env: -{{- range $key, $value := .Values.env }} + {{- range $key, $value := .Values.env }} - name: {{ $key }} value: "{{ $value }}" -{{- end }} + {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} volumeMounts: - name: minio-data mountPath: /data 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/values.yaml b/kubernetes/charts/minio/values.yaml index 5919e678..f2f7c7de 100644 --- a/kubernetes/charts/minio/values.yaml +++ b/kubernetes/charts/minio/values.yaml @@ -16,10 +16,13 @@ resources: cpu: "500m" env: - MINIO_ROOT_USER: "minioadmin" - MINIO_ROOT_PASSWORD: "minioadmin" MINIO_BROWSER_REDIRECT_URL: "http://localhost:9001" +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: minio-secrets + volumes: minio_data: type: pvc diff --git a/kubernetes/dashboard-admin.yaml b/kubernetes/dashboard-admin.yaml deleted file mode 100644 index 04855539..00000000 --- a/kubernetes/dashboard-admin.yaml +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 92c78531..00000000 --- a/kubernetes/values.yaml +++ /dev/null @@ -1,78 +0,0 @@ -# Global configuration for RAG Module -global: - domain: "rag-module.local" - namespace: "rag-module" - storageClass: "standard" - -# Individual service configurations -database: - enabled: true - -TIM-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: true - -Langfuse-Web: - enabled: true - -Langfuse-Worker: - enabled: true - -Vault: - enabled: true - -Vault-Init: - enabled: true - -Vault-Agent-LLM: - enabled: true - -LLM-Orchestration-Service: - enabled: true - -Liquibase: - enabled: true From 25c1b23a42176e3d4245bc04b7faeefe2a76aa13 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Mon, 9 Feb 2026 14:04:57 +0530 Subject: [PATCH 009/105] custom prompt configuration update and fixed Pyright issues --- .../POST/prompt-configuration/save.yml | 23 +- .../llm-connections/prompts/get-prompt.yml | 20 +- constants.ini | 1 + docs/CUSTOM_PROMPT_CONFIGURATION.md | 357 ++++++++++++++++++ .../contextual_retriever.py | 16 +- src/guardrails/dspy_nemo_adapter.py | 9 +- src/guardrails/nemo_rails_adapter.py | 52 ++- src/llm_orchestration_service.py | 44 ++- src/llm_orchestration_service_api.py | 70 ++-- src/llm_orchestrator_config/exceptions.py | 6 +- .../llm_ochestrator_constants.py | 4 +- src/optimization/metrics/generator_metrics.py | 8 +- src/response_generator/response_generate.py | 13 +- src/utils/prompt_config_loader.py | 140 ++++--- 14 files changed, 612 insertions(+), 151 deletions(-) create mode 100644 docs/CUSTOM_PROMPT_CONFIGURATION.md diff --git a/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml index ad90875d..84b68fdb 100644 --- a/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml +++ b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml @@ -38,7 +38,7 @@ update_prompt: id: ${existing_prompt.response.body[0].id} prompt: ${prompt} result: update_result - next: return_update_success + next: refresh_llm_cache insert_prompt: call: http.post @@ -47,6 +47,25 @@ insert_prompt: 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: @@ -55,4 +74,4 @@ return_update_success: return_insert_success: return: ${insert_result.response.body[0]} - next: end + 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 index 9c2d309f..125aa7ff 100644 --- 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 @@ -7,12 +7,28 @@ declaration: returns: json namespace: rag-search -getPromptConfiguration: +get_prompt_configuration: call: http.get args: url: "[#RAG_SEARCH_RESQL]/get-prompt-configuration" result: prompt_result - next: return_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/constants.ini b/constants.ini index bc09e038..63172d15 100644 --- a/constants.ini +++ b/constants.ini @@ -7,5 +7,6 @@ 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 diff --git a/docs/CUSTOM_PROMPT_CONFIGURATION.md b/docs/CUSTOM_PROMPT_CONFIGURATION.md new file mode 100644 index 00000000..1e4642af --- /dev/null +++ b/docs/CUSTOM_PROMPT_CONFIGURATION.md @@ -0,0 +1,357 @@ +# 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)" + +### **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 preview + +### **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"{custom_instructions_prefix}{user_question}" + ``` + +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 = "[#RAG_SEARCH_RUUTER_PUBLIC]/llm-connections/prompts/get-prompt" +PROMPT_CONFIG_CACHE_TTL = 300 # 5 minutes +``` + +### **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 +``` + +--- + +## 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" +- 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 custom prompt configuration found" + +--- + +## 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/src/contextual_retrieval/contextual_retriever.py b/src/contextual_retrieval/contextual_retriever.py index 8ab5d242..b6d4699b 100644 --- a/src/contextual_retrieval/contextual_retriever.py +++ b/src/contextual_retrieval/contextual_retriever.py @@ -206,18 +206,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( diff --git a/src/guardrails/dspy_nemo_adapter.py b/src/guardrails/dspy_nemo_adapter.py index 630b2657..488d1f47 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 @@ -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. @@ -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..432c9fde 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, ) @@ -65,9 +66,14 @@ def _register_custom_provider(self) -> None: logger.info("Registering DSPy custom LLM provider with NeMo Guardrails") - provider_factory = DSPyLLMProviderFactory() - - register_llm_provider("dspy-custom", provider_factory) + # NeMo Guardrails' register_llm_provider accepts callable factories at runtime, + # despite the type hint indicating type[BaseLLM]. DSPyLLMProviderFactory is a + # callable class that implements __call__ to return DSPyNeMoLLM instances (which + # properly inherit from BaseLLM). This is the documented pattern for custom providers. + # We use cast to satisfy the type checker while maintaining runtime correctness. + register_llm_provider( + "dspy-custom", cast(Type[BaseLLM], DSPyLLMProviderFactory) + ) logger.info("DSPy custom LLM provider registered successfully") except Exception as e: @@ -260,12 +266,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 +514,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/llm_orchestration_service.py b/src/llm_orchestration_service.py index 712e3013..beee41f2 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -102,7 +102,7 @@ class LLMOrchestrationService: 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, @@ -110,20 +110,20 @@ def __init__(self) -> None: max_retries=3, timeout_seconds=10, ) - + # Warm up cache at startup (non-blocking) try: custom_instructions = self.prompt_config_loader.get_custom_instructions() if custom_instructions: logger.info( - f"✅ Custom prompt configuration loaded at startup " + f"Custom prompt configuration loaded at startup " f"({len(custom_instructions)} chars)" ) else: - logger.info("ℹ️ No custom prompt configuration found - using defaults") + 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"Failed to load custom prompts at startup: {e}. " f"Service will continue with default behavior." ) @@ -168,7 +168,8 @@ def process_orchestration_request( ) # 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) # Initialize all service components components = self._initialize_service_components(request) @@ -296,7 +297,8 @@ async def stream_orchestration_response( ) # 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) # Use StreamManager for centralized tracking and guaranteed cleanup async with stream_manager.managed_stream( @@ -950,7 +952,7 @@ def _execute_orchestration_pipeline( components: Dict[str, Any], costs_dict: Dict[str, Dict[str, Any]], timing_dict: Dict[str, float], - ) -> OrchestrationResponse: + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """Execute the main orchestration pipeline with all components.""" # Step 1: Input Guardrails Check if components["guardrails_adapter"]: @@ -1004,17 +1006,27 @@ def _execute_orchestration_pipeline( timing_dict["response_generation"] = time.time() - start_time # Step 5: Output Guardrails Check + # Only apply guardrails to OrchestrationResponse (production/testing), + # TestOrchestrationResponse doesn't require chatId-based guardrail handling start_time = time.time() - output_guardrails_response = self.handle_output_guardrails( - components["guardrails_adapter"], generated_response, request, costs_dict - ) + if isinstance(generated_response, OrchestrationResponse): + output_guardrails_response = self.handle_output_guardrails( + components["guardrails_adapter"], + generated_response, + request, + costs_dict, + ) + else: + # TestOrchestrationResponse - skip output guardrails + output_guardrails_response = generated_response timing_dict["output_guardrails_check"] = time.time() - start_time # Step 6: 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, @@ -2031,7 +2043,7 @@ def _initialize_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( @@ -2044,16 +2056,16 @@ def _initialize_response_generator( except Exception as e: 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 """ diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index ddb3ca5d..8d3a3d8a 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -718,31 +718,59 @@ async def get_available_embedding_models( ) -@app.get("/prompt-config/stats") -def get_prompt_config_stats(http_request: Request) -> Dict[str, Any]: +@app.post("/prompt-config/refresh") +def refresh_prompt_config(http_request: Request) -> Dict[str, Any]: """ - Get prompt configuration cache statistics. - - Returns cache performance metrics and current configuration preview. - Useful for monitoring custom prompt configuration loading and caching. + 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 """ orchestration_service = http_request.app.state.orchestration_service - + if orchestration_service and hasattr(orchestration_service, "prompt_config_loader"): - stats = orchestration_service.prompt_config_loader.get_cache_stats() - - # Add preview of current configuration - custom_instructions = orchestration_service.prompt_config_loader.get_custom_instructions() - stats["current_prompt_preview"] = ( - custom_instructions[:200] + "..." - if len(custom_instructions) > 200 - else custom_instructions - ) - stats["applied_to"] = "ResponseGeneratorAgent only (not PromptRefinerAgent)" - - return stats - - return {"error": "Prompt configuration loader not initialized"} + try: + success = orchestration_service.prompt_config_loader.force_refresh() + + if success: + # Get the refreshed prompt preview + custom_instructions = ( + orchestration_service.prompt_config_loader.get_custom_instructions() + ) + prompt_length = len(custom_instructions) + + logger.info( + f"Prompt configuration cache refreshed successfully ({prompt_length} chars)" + ) + + return { + "refreshed": True, + "message": "Prompt configuration refreshed successfully", + "prompt_length": prompt_length, + "preview": custom_instructions[:100] + "..." + if prompt_length > 100 + else custom_instructions, + } + else: + logger.warning("Prompt configuration refresh returned empty result") + return { + "refreshed": False, + "message": "No prompt configuration found in database", + } + except Exception as e: + error_id = generate_error_id() + logger.error(f"[{error_id}] Failed to refresh prompt configuration: {e}") + return { + "refreshed": False, + "message": "Refresh failed, will use cached version", + "error_id": error_id, + } + + return {"refreshed": False, "error": "Prompt configuration loader not initialized"} if __name__ == "__main__": diff --git a/src/llm_orchestrator_config/exceptions.py b/src/llm_orchestrator_config/exceptions.py index 5d610636..1b74d0b4 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.""" @@ -52,7 +54,7 @@ class ContextualRetrievalFailureError(ContextualRetrievalError): class StreamTimeoutException(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): """ Initialize StreamTimeoutException with error tracking. @@ -76,7 +78,7 @@ class StreamSizeLimitException(LLMConfigError): class StreamException(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): """ Initialize StreamException with error tracking. diff --git a/src/llm_orchestrator_config/llm_ochestrator_constants.py b/src/llm_orchestrator_config/llm_ochestrator_constants.py index 50749d68..90d01ed5 100644 --- a/src/llm_orchestrator_config/llm_ochestrator_constants.py +++ b/src/llm_orchestrator_config/llm_ochestrator_constants.py @@ -126,5 +126,7 @@ def get_localized_message(message_dict: dict, language_code: str = "en") -> str: 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" +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/optimization/metrics/generator_metrics.py b/src/optimization/metrics/generator_metrics.py index becf64a0..3034f1e7 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 @@ -34,7 +35,7 @@ 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") @@ -97,6 +98,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: diff --git a/src/response_generator/response_generate.py b/src/response_generator/response_generate.py index 589efd49..e3aa18f6 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,7 +124,12 @@ class ResponseGeneratorAgent(dspy.Module): Returns a dict: {"answer": str, "questionOutOfLLMScope": bool, "usage": dict} """ - def __init__(self, max_retries: int = 2, use_optimized: bool = True, custom_instructions_prefix: str = "") -> 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 @@ -238,7 +243,7 @@ async def stream_response( logger.info( f"Starting NATIVE DSPy streaming for question with {len(chunks)} chunks" ) - + # Prepend custom instructions if provided augmented_question = question if self._custom_instructions_prefix: @@ -399,7 +404,7 @@ def forward( max_blocks = ResponseGenerationConstants.DEFAULT_MAX_BLOCKS logger.info(f"Generating response for question: '{question}'") - + # Prepend custom instructions if provided augmented_question = question if self._custom_instructions_prefix: diff --git a/src/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py index dda3d157..ef73db6b 100644 --- a/src/utils/prompt_config_loader.py +++ b/src/utils/prompt_config_loader.py @@ -12,7 +12,7 @@ class PromptConfigurationLoader: """ Loads custom prompt configurations from Ruuter endpoint. - + Features: - HTTP-based loading via Ruuter - 5-minute TTL cache (configurable) @@ -20,7 +20,7 @@ class PromptConfigurationLoader: - Thread-safe caching - Graceful degradation with stale cache fallback """ - + def __init__( self, ruuter_endpoint: str, @@ -30,7 +30,7 @@ def __init__( ) -> None: """ Initialize prompt configuration loader. - + Args: ruuter_endpoint: Full URL to Ruuter endpoint cache_ttl_seconds: Cache TTL in seconds (default: 300 = 5 minutes) @@ -41,27 +41,27 @@ def __init__( 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() - + # 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). - + Returns: str: Custom instruction text, or empty string if unavailable """ @@ -75,184 +75,180 @@ def get_custom_instructions(self) -> str: f"hits: {self._cache_hits}, misses: {self._cache_misses})" ) return self._cached_prompt or "" - + # Cache miss/expired - load from Ruuter self._cache_misses += 1 logger.info( f"Prompt config cache MISS - loading from Ruuter " f"(cache age: {self._get_cache_age():.1f}s)" ) - + try: prompt_text = self._load_from_ruuter_with_retry() - + 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"Prompt configuration loaded successfully " f"({len(prompt_text)} chars)" ) return prompt_text else: # No configuration found - logger.warning("⚠️ No prompt configuration found in database") + logger.warning("No prompt configuration found in database") # Return stale cache if available, otherwise empty return self._cached_prompt or "" - + except Exception as e: self._load_failures += 1 self._last_error = str(e) logger.error( - f"❌ Failed to load prompt configuration: {e} " + f"Failed to load prompt configuration: {e} " f"(failures: {self._load_failures})" ) # Fallback to stale cache or empty string if self._cached_prompt: logger.warning( - f"⚠️ Using stale cache (age: {self._get_cache_age():.1f}s)" + f"Using stale cache (age: {self._get_cache_age():.1f}s)" ) return self._cached_prompt or "" - + 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 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 or None if all retries fail """ for attempt in range(1, self.max_retries + 1): try: logger.debug( - f"📡 Calling Ruuter endpoint " + 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() - + # DEBUG: Log the actual response structure - logger.info(f"📦 Response data type: {type(data)}") - logger.info(f"📦 Response data content: {data}") - + logger.info(f"Response data type: {type(data)}") + logger.info(f"Response data content: {data}") + # 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.info(f"📦 Unwrapping 'response' key") + logger.info(f"Unwrapping 'response' key") data = data["response"] - + # Now extract prompt from the unwrapped data if isinstance(data, list) and len(data) > 0: # Array format: [{"id": 1, "prompt": "..."}] - logger.info(f"📦 Extracting from list, first element: {data[0]}") + logger.info(f"Extracting from list, first element: {data[0]}") prompt = data[0].get("prompt", "").strip() elif isinstance(data, dict): # Dict format: {"id": 1, "prompt": "..."} - logger.info(f"📦 Extracting from dict, keys: {list(data.keys())}") + logger.info(f"Extracting from dict, keys: {list(data.keys())}") prompt = data.get("prompt", "").strip() else: - logger.warning(f"⚠️ Unexpected data type: {type(data)}, value: {data}") - - logger.info(f"📦 Extracted prompt length: {len(prompt) if prompt else 0}") - + logger.warning( + f"Unexpected data type: {type(data)}, value: {data}" + ) + + logger.info( + f"Extracted prompt length: {len(prompt) if prompt else 0}" + ) + if prompt: logger.info( - f"✅ Loaded prompt on attempt {attempt} " - f"({len(prompt)} chars)" + f"Loaded prompt on attempt {attempt} ({len(prompt)} chars)" ) return prompt else: - logger.warning( - f"⚠️ Prompt field is empty (attempt {attempt})" - ) + logger.warning(f"Prompt field is empty (attempt {attempt})") return None # Database has no configuration - + else: logger.warning( - f"⚠️ HTTP {response.status_code} on attempt {attempt}: " + 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"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}: " - f"{str(e)[:100]}" - ) - + 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]}") - + 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}" - ) - + logger.error(f"Invalid response format on attempt {attempt}: {e}") + except Exception as e: - logger.error(f"❌ Unexpected error on attempt {attempt}: {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...") + logger.debug(f"Retrying in {wait_time}s...") time.sleep(wait_time) - + # All retries failed logger.error( - f"❌ All {self.max_retries} attempts failed to load prompt configuration" + f"All {self.max_retries} attempts failed to load prompt configuration" ) return None - + def force_refresh(self) -> bool: """ Force immediate cache refresh. - + Returns: bool: True if refresh successful, False otherwise """ - logger.info("🔄 Forcing prompt configuration cache refresh") + logger.info("Forcing prompt configuration cache refresh") with self._cache_lock: self._cache_timestamp = None # Invalidate cache - + result = self.get_custom_instructions() return bool(result) - + def get_cache_stats(self) -> Dict[str, Any]: """Get cache statistics for monitoring.""" with self._cache_lock: @@ -261,9 +257,7 @@ def get_cache_stats(self) -> Dict[str, Any]: "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 + 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(), From 5f31f45d7c04e56adc2115c7586ea28593a6b6d5 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Mon, 9 Feb 2026 15:43:12 +0530 Subject: [PATCH 010/105] fixed copilot reviews --- docs/CUSTOM_PROMPT_CONFIGURATION.md | 15 ++- src/guardrails/nemo_rails_adapter.py | 14 +-- src/llm_orchestration_service.py | 1 - src/llm_orchestration_service_api.py | 107 +++++++++++------ src/response_generator/response_generate.py | 12 +- src/utils/prompt_config_loader.py | 122 ++++++++++++++------ 6 files changed, 181 insertions(+), 90 deletions(-) diff --git a/docs/CUSTOM_PROMPT_CONFIGURATION.md b/docs/CUSTOM_PROMPT_CONFIGURATION.md index 1e4642af..81e82266 100644 --- a/docs/CUSTOM_PROMPT_CONFIGURATION.md +++ b/docs/CUSTOM_PROMPT_CONFIGURATION.md @@ -149,7 +149,8 @@ The custom prompt configuration system allows admins to configure prompts via UI 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 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** @@ -224,8 +225,10 @@ The custom prompt configuration system allows admins to configure prompts via UI ### **Constants** (`src/llm_orchestrator_config/llm_ochestrator_constants.py`) ```python -RUUTER_PROMPT_CONFIG_ENDPOINT = "[#RAG_SEARCH_RUUTER_PUBLIC]/llm-connections/prompts/get-prompt" -PROMPT_CONFIG_CACHE_TTL = 300 # 5 minutes +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`) @@ -263,7 +266,7 @@ WHERE id = 1; ``` ### **4. Verify Immediate Refresh** -- Check logs for: "✅ Prompt configuration cache refreshed successfully" +- Check logs for: "Prompt configuration cache refreshed successfully" - Test same question - response format should change immediately ### **5. Check Cache Status** @@ -333,7 +336,7 @@ Context: [retrieved documentation chunks...] ## Troubleshooting ### **Prompt Not Applied** -- Check logs for: "✅ Custom prompt configuration loaded" +- 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` @@ -345,7 +348,7 @@ Context: [retrieved documentation chunks...] ### **Empty Prompt** - Check Ruuter endpoint returns correct format - Verify response unwrapping logic in loader -- Check logs for "⚠️ No custom prompt configuration found" +- Check logs for "No prompt configuration found in database; caching empty result" --- diff --git a/src/guardrails/nemo_rails_adapter.py b/src/guardrails/nemo_rails_adapter.py index 432c9fde..1ae38982 100644 --- a/src/guardrails/nemo_rails_adapter.py +++ b/src/guardrails/nemo_rails_adapter.py @@ -66,14 +66,14 @@ def _register_custom_provider(self) -> None: logger.info("Registering DSPy custom LLM provider with NeMo Guardrails") - # NeMo Guardrails' register_llm_provider accepts callable factories at runtime, - # despite the type hint indicating type[BaseLLM]. DSPyLLMProviderFactory is a - # callable class that implements __call__ to return DSPyNeMoLLM instances (which - # properly inherit from BaseLLM). This is the documented pattern for custom providers. + # 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. - register_llm_provider( - "dspy-custom", cast(Type[BaseLLM], DSPyLLMProviderFactory) - ) + factory = DSPyLLMProviderFactory() + register_llm_provider("dspy-custom", cast(Type[BaseLLM], factory)) logger.info("DSPy custom LLM provider registered successfully") except Exception as e: diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index beee41f2..b7cce8f9 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -111,7 +111,6 @@ def __init__(self) -> None: timeout_seconds=10, ) - # Warm up cache at startup (non-blocking) try: custom_instructions = self.prompt_config_loader.get_custom_instructions() if custom_instructions: diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 8d3a3d8a..b2c9f9f5 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -276,7 +276,7 @@ def orchestrate_llm_request( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error occurred", - ) + ) from e @app.post( @@ -658,7 +658,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 +679,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 +715,7 @@ async def get_available_embedding_models( ) raise HTTPException( status_code=500, detail="Failed to retrieve embedding models" - ) + ) from e @app.post("/prompt-config/refresh") @@ -729,48 +729,81 @@ def refresh_prompt_config(http_request: Request) -> Dict[str, Any]: 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 - if orchestration_service and hasattr(orchestration_service, "prompt_config_loader"): - try: - success = orchestration_service.prompt_config_loader.force_refresh() + # 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, + }, + ) - if success: - # Get the refreshed prompt preview - custom_instructions = ( - orchestration_service.prompt_config_loader.get_custom_instructions() - ) - prompt_length = len(custom_instructions) + try: + success = orchestration_service.prompt_config_loader.force_refresh() - logger.info( - f"Prompt configuration cache refreshed successfully ({prompt_length} chars)" - ) + if success: + # Get prompt metadata without exposing content (security) + custom_instructions = ( + orchestration_service.prompt_config_loader.get_custom_instructions() + ) + prompt_length = len(custom_instructions) + + # Generate hash for verification purposes (without exposing content) + import hashlib + + prompt_hash = hashlib.sha256(custom_instructions.encode()).hexdigest()[:16] + + logger.info( + f"Prompt configuration cache refreshed successfully ({prompt_length} chars)" + ) - return { - "refreshed": True, - "message": "Prompt configuration refreshed successfully", - "prompt_length": prompt_length, - "preview": custom_instructions[:100] + "..." - if prompt_length > 100 - else custom_instructions, - } - else: - logger.warning("Prompt configuration refresh returned empty result") - return { - "refreshed": False, - "message": "No prompt configuration found in database", - } - except Exception as e: - error_id = generate_error_id() - logger.error(f"[{error_id}] Failed to refresh prompt configuration: {e}") return { - "refreshed": False, - "message": "Refresh failed, will use cached version", - "error_id": error_id, + "refreshed": True, + "message": "Prompt configuration refreshed successfully", + "prompt_length": prompt_length, + "content_hash": prompt_hash, # Safe: hash instead of preview } + else: + # No fresh data loaded - could be fetch failure or truly not found + error_id = generate_error_id() + logger.warning( + f"[{error_id}] Prompt configuration refresh returned empty result" + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "No prompt configuration found in database", + "error_id": error_id, + }, + ) - return {"refreshed": False, "error": "Prompt configuration loader not initialized"} + 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/response_generator/response_generate.py b/src/response_generator/response_generate.py index e3aa18f6..3dffbfb5 100644 --- a/src/response_generator/response_generate.py +++ b/src/response_generator/response_generate.py @@ -244,12 +244,12 @@ async def stream_response( f"Starting NATIVE DSPy streaming for question with {len(chunks)} chunks" ) - # Prepend custom instructions if provided + # Apply custom instructions while keeping the user question first, if provided augmented_question = question if self._custom_instructions_prefix: - augmented_question = f"{self._custom_instructions_prefix}{question}" + augmented_question = f"{question}\n\n{self._custom_instructions_prefix}" logger.debug( - f"Applied custom instructions prefix to streaming ({len(self._custom_instructions_prefix)} chars)" + f"Applied custom instructions after question for streaming ({len(self._custom_instructions_prefix)} chars)" ) output_stream = None @@ -405,12 +405,12 @@ def forward( logger.info(f"Generating response for question: '{question}'") - # Prepend custom instructions if provided + # Apply custom instructions while keeping the user question first, if provided augmented_question = question if self._custom_instructions_prefix: - augmented_question = f"{self._custom_instructions_prefix}{question}" + augmented_question = f"{question}\n\n{self._custom_instructions_prefix}" logger.debug( - f"Applied custom instructions prefix ({len(self._custom_instructions_prefix)} chars)" + f"Applied custom instructions after question ({len(self._custom_instructions_prefix)} chars)" ) lm = dspy.settings.lm diff --git a/src/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py index ef73db6b..f7d5d3e9 100644 --- a/src/utils/prompt_config_loader.py +++ b/src/utils/prompt_config_loader.py @@ -9,6 +9,12 @@ from loguru import logger +class PromptConfigLoadError(Exception): + """Raised when all retry attempts to load prompt configuration fail.""" + + pass + + class PromptConfigurationLoader: """ Loads custom prompt configurations from Ruuter endpoint. @@ -46,6 +52,8 @@ def __init__( 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 @@ -62,10 +70,17 @@ 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 """ - with self._cache_lock: + # Step 1: Quick cache check under lock + with self._cache_condition: # Check cache validity if self._is_cache_valid(): self._cache_hits += 1 @@ -76,16 +91,42 @@ def get_custom_instructions(self) -> str: ) return self._cached_prompt or "" - # Cache miss/expired - load from Ruuter + # 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)" ) - try: - prompt_text = self._load_from_ruuter_with_retry() + # 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 @@ -96,25 +137,36 @@ def get_custom_instructions(self) -> str: f"({len(prompt_text)} chars)" ) return prompt_text - else: - # No configuration found - logger.warning("No prompt configuration found in database") - # Return stale cache if available, otherwise empty - return self._cached_prompt or "" - except Exception as e: - self._load_failures += 1 - self._last_error = str(e) - logger.error( - f"Failed to load prompt configuration: {e} " - f"(failures: {self._load_failures})" - ) - # Fallback to stale cache or empty string - if self._cached_prompt: + elif prompt_text is None and fetch_error is None: + # No configuration found - cache empty result to avoid repeated loads logger.warning( - f"Using stale cache (age: {self._get_cache_age():.1f}s)" + "No prompt configuration found in database; caching empty result" ) - return self._cached_prompt or "" + 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.""" @@ -140,7 +192,10 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: - Attempt 3: 2s wait Returns: - Optional[str]: Prompt text or None if all retries fail + 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: @@ -160,16 +215,12 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: if response.status_code == 200: data = response.json() - # DEBUG: Log the actual response structure - logger.info(f"Response data type: {type(data)}") - logger.info(f"Response data content: {data}") - # 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.info(f"Unwrapping 'response' key") + logger.info("Unwrapping 'response' key") data = data["response"] # Now extract prompt from the unwrapped data @@ -229,29 +280,33 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: logger.debug(f"Retrying in {wait_time}s...") time.sleep(wait_time) - # All retries failed - logger.error( + # All retries failed - raise exception to distinguish from "not found" + error_msg = ( f"All {self.max_retries} attempts failed to load prompt configuration" ) - return None + logger.error(error_msg) + raise PromptConfigLoadError(error_msg) def force_refresh(self) -> bool: """ Force immediate cache refresh. Returns: - bool: True if refresh successful, False otherwise + bool: True if fresh data was successfully loaded, False otherwise """ logger.info("Forcing prompt configuration cache refresh") - with self._cache_lock: - self._cache_timestamp = None # Invalidate cache + 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 result = self.get_custom_instructions() return bool(result) def get_cache_stats(self) -> Dict[str, Any]: """Get cache statistics for monitoring.""" - with self._cache_lock: + with self._cache_condition: return { "cache_hits": self._cache_hits, "cache_misses": self._cache_misses, @@ -267,4 +322,5 @@ def get_cache_stats(self) -> Dict[str, Any]: "last_error": self._last_error, "ruuter_endpoint": self.ruuter_endpoint, "cache_ttl_seconds": self.cache_ttl_seconds, + "fetch_in_progress": self._fetch_in_progress, } From e15958a34383a092b437f40123aaf28b7fbd7bf9 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 10 Feb 2026 06:35:28 +0530 Subject: [PATCH 011/105] pre validation step added when user query is inserted --- src/llm_orchestration_service.py | 48 ++++++++++ .../llm_ochestrator_constants.py | 12 ++- src/models/request_models.py | 17 ++-- src/utils/query_validator.py | 89 +++++++++++++++++++ 4 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 src/utils/query_validator.py diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index b7cce8f9..68f4391a 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -34,6 +34,7 @@ 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, @@ -52,6 +53,7 @@ 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.llm_orchestrator_config.exceptions import ( @@ -299,6 +301,22 @@ async def stream_orchestration_response( # Using setattr for type safety - adds dynamic attribute to Pydantic model instance setattr(request, "_detected_language", detected_language) + # Step 0.5: Basic Query Validation (before guardrails) + validation_result = validate_query_basic(request.message) + 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( chat_id=request.chatId, author_id=request.authorId @@ -953,6 +971,36 @@ def _execute_orchestration_pipeline( timing_dict: Dict[str, float], ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """Execute the main orchestration pipeline with all components.""" + # Step 0: Basic Query Validation (before guardrails) + validation_result = validate_query_basic(request.message) + if not validation_result.is_valid: + logger.info( + f"[{request.chatId}] Query validation failed: {validation_result.rejection_reason}" + ) + # Get localized message + detected_lang = getattr(request, "_detected_language", "en") + validation_msg = get_localized_message( + QUERY_VALIDATION_FAILED_MESSAGES, detected_lang + ) + + # Return appropriate response type + 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, + ) + # Step 1: Input Guardrails Check if components["guardrails_adapter"]: start_time = time.time() diff --git a/src/llm_orchestrator_config/llm_ochestrator_constants.py b/src/llm_orchestrator_config/llm_ochestrator_constants.py index 90d01ed5..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,7 +123,7 @@ 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 diff --git a/src/models/request_models.py b/src/models/request_models.py index f4a073c5..689c68c3 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" diff --git a/src/utils/query_validator.py b/src/utils/query_validator.py new file mode 100644 index 00000000..a45ed4ce --- /dev/null +++ b/src/utils/query_validator.py @@ -0,0 +1,89 @@ +"""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 +- Messages with too few meaningful characters (< 2) +- Messages with only repetitive characters (e.g., "aaaa", "????") + +Out of scope for this module: +- Semantic validation (greetings, chitchat, intent detection) +- Language quality checks +- Content policy checks (handled by guardrails) +""" + +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: + >>> validate_query_basic("How to apply for benefits?") + QueryValidationResult(is_valid=True, rejection_reason=None) + + >>> 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') + """ + # 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 + # These are common non-meaningful characters that don't form queries + special_chars = ".,?!;:…-_()[]{}@#$%^&*+=~`|\\/<>\"' \t\n" + if all(c in special_chars for c in query): + return QueryValidationResult( + is_valid=False, rejection_reason="special_chars_only" + ) + + # Check 3: Too short (< 2 meaningful characters) + # Extract alphanumeric characters (letters + numbers) + meaningful_chars = "".join(c for c in query if c.isalnum()) + if len(meaningful_chars) < 2: + return QueryValidationResult(is_valid=False, rejection_reason="too_short") + + # Check 4: Only repetitive characters (e.g., "aaaa", "????") + # If all meaningful characters are the same, it's likely spam/noise + if len(set(meaningful_chars)) == 1: + return QueryValidationResult(is_valid=False, rejection_reason="repetitive") + + # Passed all checks - query is syntactically valid + return QueryValidationResult(is_valid=True) From 7ec8151ae4f3d64effc8c1a5e0adcb09af66ca85 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 10 Feb 2026 10:19:01 +0530 Subject: [PATCH 012/105] added more validation cases --- src/utils/query_validator.py | 47 ++++-- tests/conftest.py | 8 + tests/test_query_validator.py | 306 ++++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_query_validator.py diff --git a/src/utils/query_validator.py b/src/utils/query_validator.py index a45ed4ce..98766f78 100644 --- a/src/utils/query_validator.py +++ b/src/utils/query_validator.py @@ -5,16 +5,24 @@ Validation checks (all syntactic, NO semantic): - Empty or whitespace-only messages -- Messages containing only special characters/punctuation +- 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 @@ -47,17 +55,27 @@ def validate_query_basic(query: str) -> QueryValidationResult: 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() @@ -66,23 +84,28 @@ def validate_query_basic(query: str) -> QueryValidationResult: if not query: return QueryValidationResult(is_valid=False, rejection_reason="empty") - # Check 2: Only special characters/punctuation - # These are common non-meaningful characters that don't form queries - special_chars = ".,?!;:…-_()[]{}@#$%^&*+=~`|\\/<>\"' \t\n" - if all(c in special_chars for c in query): + # 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) - meaningful_chars = "".join(c for c in query if c.isalnum()) + # 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", "????") - # If all meaningful characters are the same, it's likely spam/noise - if len(set(meaningful_chars)) == 1: + # 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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d1633b76 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""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)) diff --git a/tests/test_query_validator.py b/tests/test_query_validator.py new file mode 100644 index 00000000..361b1b9d --- /dev/null +++ b/tests/test_query_validator.py @@ -0,0 +1,306 @@ +"""Unit tests for query validator. + +Tests cover all documented examples, edge cases, and boundary conditions +to prevent regressions as validation rules evolve. +""" + +import pytest +from src.utils.query_validator import validate_query_basic, QueryValidationResult + + +class TestQueryValidatorEmpty: + """Test empty and whitespace-only queries.""" + + @pytest.mark.parametrize( + "query", + [ + "", + " ", + "\t", + "\n", + "\t\n ", + " \t\n\r ", + ], + ) + def test_empty_queries_rejected(self, query): + """Empty or whitespace-only queries should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "empty" + + +class TestQueryValidatorSpecialCharsOnly: + """Test queries with only special characters or punctuation.""" + + @pytest.mark.parametrize( + "query", + [ + "...", + "???", + "!!!", + "!@#$%^&*()", + ".,?!;:", + "---", + # Note: "___" is repetitive, not special_chars (underscore matches \w) + "[]{}()", + "<>", + "//", + "\\\\", + "++", + "**", + "~~", + "``", + "''", + '""', + "—", + "–", + "''", + "•••", + "→→", + "※※", + "!?!?", + "...???", + "!!! ???", + "????", # 4 question marks - special chars only + ], + ) + def test_special_chars_only_rejected(self, query): + """Queries with only special characters should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "special_chars_only" + + +class TestQueryValidatorTooShort: + """Test queries that are too short.""" + + @pytest.mark.parametrize( + "query", + [ + "a", + "A", + "1", + "õ", + "я", + "a!", + "a?", + "1.", + "a...", + "!a!", + ], + ) + def test_too_short_queries_rejected(self, query): + """Queries with fewer than 2 meaningful characters should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "too_short" + + +class TestQueryValidatorRepetitive: + """Test queries with only repetitive characters.""" + + @pytest.mark.parametrize( + "query", + [ + "aa", + "AAA", + "aaa", + "aaaa", + "AAAAAAA", + "aAaAa", + "11", + "111", + "0000", + "99999", + "õõõõ", + "ääää", + "яяяя", + "aa!", + "!!!aaa!!!", + "a.a.a.a", + "___", # 3 underscores - repetitive (underscore is \w) + ], + ) + def test_repetitive_queries_rejected(self, query): + """Queries with only one unique meaningful character should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + +class TestQueryValidatorValid: + """Test valid queries that should pass validation.""" + + @pytest.mark.parametrize( + "query", + [ + "hi", + "hello", + "ok", + "ab", + "AB", + "Hi", + "123", + "12", + "abc123", + "test1", + "How to apply?", + "What is this?", + "When?", + "tere", + "kuidas", + "Mis on?", + "привет", + "как дела", + "ab!", + "hello!", + "test...", + "what???", + "a-b", + "test_case", + "test123!", + "hello world", + "a b c", + "http://test", + "test@email", + "a1", + "12ab", + "õõte", + ], + ) + def test_valid_queries_accepted(self, query): + """Valid queries with meaningful content should be accepted.""" + result = validate_query_basic(query) + assert result.is_valid is True + assert result.rejection_reason is None + + +class TestQueryValidatorEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_whitespace_trimmed(self): + """Leading and trailing whitespace should be trimmed before validation.""" + result = validate_query_basic(" hello ") + assert result.is_valid is True + + result = validate_query_basic(" ") + assert result.is_valid is False + assert result.rejection_reason == "empty" + + def test_case_insensitive_repetition(self): + """Repetition check should be case-insensitive.""" + result = validate_query_basic("AaAa") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + result = validate_query_basic("AaAaAa") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + def test_unicode_normalization(self): + """Unicode characters should be handled consistently.""" + result = validate_query_basic("привет") + assert result.is_valid is True + + result = validate_query_basic("你好") + assert result.is_valid is True + + result = validate_query_basic("مرحبا") + assert result.is_valid is True + + def test_mixed_scripts(self): + """Queries with mixed scripts should be valid.""" + result = validate_query_basic("hello мир") + assert result.is_valid is True + + result = validate_query_basic("test测试") + assert result.is_valid is True + + def test_numbers_are_valid(self): + """Numbers-only queries are considered valid.""" + result = validate_query_basic("123") + assert result.is_valid is True + + result = validate_query_basic("42") + assert result.is_valid is True + + result = validate_query_basic("2024") + assert result.is_valid is True + + def test_numbers_repetitive(self): + """Repetitive numbers should be rejected.""" + result = validate_query_basic("111") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + result = validate_query_basic("00") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + def test_punctuation_doesnt_count_as_meaningful(self): + """Punctuation should not count toward meaningful character count.""" + result = validate_query_basic("a!!!") + assert result.is_valid is False + assert result.rejection_reason == "too_short" + + result = validate_query_basic("ab!!!") + assert result.is_valid is True + + def test_emoji_with_text(self): + """Emojis combined with text should be valid.""" + result = validate_query_basic("hello world") + assert result.is_valid is True + + result = validate_query_basic("test case") + assert result.is_valid is True + + def test_long_repetitive_string(self): + """Long strings of repeated characters should be rejected.""" + result = validate_query_basic("a" * 100) + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + def test_result_is_pydantic_model(self): + """Result should be a valid Pydantic model.""" + result = validate_query_basic("test") + assert isinstance(result, QueryValidationResult) + assert hasattr(result, "is_valid") + assert hasattr(result, "rejection_reason") + + result_dict = result.model_dump() + assert "is_valid" in result_dict + assert "rejection_reason" in result_dict + + +class TestQueryValidatorDocumentedExamples: + """Test all examples from function docstring.""" + + def test_documented_valid_examples(self): + """All documented valid examples should pass.""" + examples = [ + "How to apply for benefits?", + "hi", + "123", + "ab!", + ] + for query in examples: + result = validate_query_basic(query) + assert result.is_valid is True, f"Expected '{query}' to be valid" + assert result.rejection_reason is None + + def test_documented_invalid_examples(self): + """All documented invalid examples should fail with correct reason.""" + examples = [ + ("...", "special_chars_only"), + ("", "empty"), + # Note: ???? is special_chars_only (not in \w), not repetitive + ("????", "special_chars_only"), + ("a", "too_short"), + ] + for query, expected_reason in examples: + result = validate_query_basic(query) + assert result.is_valid is False, f"Expected '{query}' to be invalid" + assert result.rejection_reason == expected_reason, ( + f"Expected '{query}' to fail with '{expected_reason}', " + f"got '{result.rejection_reason}'" + ) From 29787de830efccacf8cb22218903ced3cd9ca71f Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 10 Feb 2026 10:29:50 +0530 Subject: [PATCH 013/105] fixed review comments --- src/llm_orchestration_service.py | 62 ++++++++++++++++---------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 68f4391a..bcea2916 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -172,7 +172,36 @@ def process_orchestration_request( # Using setattr for type safety - adds dynamic attribute to Pydantic model instance setattr(request, "_detected_language", detected_language) - # Initialize all service components + # STEP 0.5: Basic Query Validation (before expensive component initialization) + validation_result = validate_query_basic(request.message) + 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 (only for valid queries) components = self._initialize_service_components(request) # Execute the orchestration pipeline @@ -971,35 +1000,8 @@ def _execute_orchestration_pipeline( timing_dict: Dict[str, float], ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """Execute the main orchestration pipeline with all components.""" - # Step 0: Basic Query Validation (before guardrails) - validation_result = validate_query_basic(request.message) - if not validation_result.is_valid: - logger.info( - f"[{request.chatId}] Query validation failed: {validation_result.rejection_reason}" - ) - # Get localized message - detected_lang = getattr(request, "_detected_language", "en") - validation_msg = get_localized_message( - QUERY_VALIDATION_FAILED_MESSAGES, detected_lang - ) - - # Return appropriate response type - 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, - ) + # Note: Query validation now happens in process_orchestration_request() + # before component initialization for true early rejection # Step 1: Input Guardrails Check if components["guardrails_adapter"]: From fa4e1a41e574f9fd3dba8ccec595d10e4e66551f Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:19:34 +0530 Subject: [PATCH 014/105] Custom prompt configuration feature and Pyright fixes (#297) * updated docker compose ec2 * integrate streaming endpoint with test prodction connection page * formatted response with markdown * fe logic for the encryption * vault secret update after fixing issues * fixed formatting issue * integration with be * update cron manager vault script * tested integration of vault security update * fix security issues * creation success model changes * clean vite config generated files * fixed issue references are not sending with streming tokens * complete #192 and #206 bug fixes * production inference display logic change * change production inference display logic * fixed requested issue * Refactor Docker Compose configuration for vault agents and update CSP settings * Remove obsolete Vite configuration files and associated plugins * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * fixed review comments --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: erangi-ar Co-authored-by: erangi-ar <111747955+erangi-ar@users.noreply.github.com> --- .../POST/prompt-configuration/save.yml | 23 +- .../llm-connections/prompts/get-prompt.yml | 34 ++ constants.ini | 1 + docs/CUSTOM_PROMPT_CONFIGURATION.md | 371 ++++++++++++++++ .../contextual_retriever.py | 16 +- src/guardrails/dspy_nemo_adapter.py | 9 +- src/guardrails/nemo_rails_adapter.py | 52 ++- src/llm_orchestration_service.py | 111 ++++- src/llm_orchestration_service_api.py | 128 +++++- src/llm_orchestrator_config/exceptions.py | 6 +- .../llm_ochestrator_constants.py | 6 + src/optimization/metrics/generator_metrics.py | 8 +- src/response_generator/response_generate.py | 34 +- src/utils/prompt_config_loader.py | 414 ++++++++++++++++++ 14 files changed, 1152 insertions(+), 61 deletions(-) create mode 100644 DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml create mode 100644 docs/CUSTOM_PROMPT_CONFIGURATION.md create mode 100644 src/utils/prompt_config_loader.py diff --git a/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml index ad90875d..84b68fdb 100644 --- a/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml +++ b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml @@ -38,7 +38,7 @@ update_prompt: id: ${existing_prompt.response.body[0].id} prompt: ${prompt} result: update_result - next: return_update_success + next: refresh_llm_cache insert_prompt: call: http.post @@ -47,6 +47,25 @@ insert_prompt: 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: @@ -55,4 +74,4 @@ return_update_success: return_insert_success: return: ${insert_result.response.body[0]} - next: end + 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/constants.ini b/constants.ini index bc09e038..63172d15 100644 --- a/constants.ini +++ b/constants.ini @@ -7,5 +7,6 @@ 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 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/src/contextual_retrieval/contextual_retriever.py b/src/contextual_retrieval/contextual_retriever.py index 8ab5d242..b6d4699b 100644 --- a/src/contextual_retrieval/contextual_retriever.py +++ b/src/contextual_retrieval/contextual_retriever.py @@ -206,18 +206,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( diff --git a/src/guardrails/dspy_nemo_adapter.py b/src/guardrails/dspy_nemo_adapter.py index 630b2657..488d1f47 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 @@ -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. @@ -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..1ae38982 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, ) @@ -65,9 +66,14 @@ def _register_custom_provider(self) -> None: logger.info("Registering DSPy custom LLM provider with NeMo Guardrails") - provider_factory = DSPyLLMProviderFactory() - - register_llm_provider("dspy-custom", provider_factory) + # 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.info("DSPy custom LLM provider registered successfully") except Exception as e: @@ -260,12 +266,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 +514,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/llm_orchestration_service.py b/src/llm_orchestration_service.py index 49b307d8..05303c26 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -39,6 +39,8 @@ 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 @@ -49,6 +51,7 @@ 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.guardrails import NeMoRailsAdapter, GuardrailCheckResult from src.contextual_retrieval import ContextualRetriever from src.llm_orchestrator_config.exceptions import ( @@ -100,6 +103,29 @@ 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." + ) + @observe(name="orchestration_request", as_type="agent") def process_orchestration_request( self, request: OrchestrationRequest @@ -141,7 +167,8 @@ def process_orchestration_request( ) # 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) # Initialize all service components components = self._initialize_service_components(request) @@ -269,7 +296,8 @@ async def stream_orchestration_response( ) # 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) # Use StreamManager for centralized tracking and guaranteed cleanup async with stream_manager.managed_stream( @@ -923,7 +951,7 @@ def _execute_orchestration_pipeline( components: Dict[str, Any], costs_dict: Dict[str, Dict[str, Any]], timing_dict: Dict[str, float], - ) -> OrchestrationResponse: + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """Execute the main orchestration pipeline with all components.""" # Step 1: Input Guardrails Check if components["guardrails_adapter"]: @@ -977,17 +1005,22 @@ def _execute_orchestration_pipeline( timing_dict["response_generation"] = time.time() - start_time # Step 5: 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 + components["guardrails_adapter"], + generated_response, + request, + costs_dict, ) timing_dict["output_guardrails_check"] = time.time() - start_time # Step 6: 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, @@ -1175,16 +1208,21 @@ async def _safe_retrieve_contextual_chunks( 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 ( + ) -> 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 - ): + ) + + if should_check_guardrails: + # Type assertion: should_check_guardrails guarantees guardrails_adapter is not None + assert guardrails_adapter is not None output_check_result = self._check_output_guardrails( guardrails_adapter=guardrails_adapter, assistant_message=generated_response.content, @@ -1201,13 +1239,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: @@ -2002,9 +2050,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 +2066,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]]]]], diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index b58eac94..3ed24ce2 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -30,6 +30,7 @@ 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, @@ -276,7 +277,7 @@ def orchestrate_llm_request( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error occurred", - ) + ) from e @app.post( @@ -658,7 +659,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 +680,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 +716,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/exceptions.py b/src/llm_orchestrator_config/exceptions.py index 5d610636..1b74d0b4 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.""" @@ -52,7 +54,7 @@ class ContextualRetrievalFailureError(ContextualRetrievalError): class StreamTimeoutException(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): """ Initialize StreamTimeoutException with error tracking. @@ -76,7 +78,7 @@ class StreamSizeLimitException(LLMConfigError): class StreamException(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): """ Initialize StreamException with error tracking. diff --git a/src/llm_orchestrator_config/llm_ochestrator_constants.py b/src/llm_orchestrator_config/llm_ochestrator_constants.py index 61af6963..90d01ed5 100644 --- a/src/llm_orchestrator_config/llm_ochestrator_constants.py +++ b/src/llm_orchestrator_config/llm_ochestrator_constants.py @@ -124,3 +124,9 @@ def get_localized_message(message_dict: dict, language_code: str = "en") -> str: 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/optimization/metrics/generator_metrics.py b/src/optimization/metrics/generator_metrics.py index becf64a0..3034f1e7 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 @@ -34,7 +35,7 @@ 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") @@ -97,6 +98,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: 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/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py new file mode 100644 index 00000000..8df8945b --- /dev/null +++ b/src/utils/prompt_config_loader.py @@ -0,0 +1,414 @@ +""" +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: + # No configuration found - cache empty result to avoid repeated loads + logger.warning( + "No prompt configuration 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.info("Unwrapping 'response' key") + data = data["response"] + + # Now extract prompt from the unwrapped data + 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.info( + f"Extracting from list, first element keys: {first_elem_keys}" + ) + prompt = data[0].get("prompt", "").strip() + elif isinstance(data, dict): + # Dict format: {"id": 1, "prompt": "..."} + logger.info(f"Extracting from dict, keys: {list(data.keys())}") + prompt = data.get("prompt", "").strip() + else: + logger.warning( + f"Unexpected data type: {type(data).__name__}, structure not recognized" + ) + + logger.info( + f"Extracted prompt length: {len(prompt) if prompt else 0}" + ) + + if prompt: + logger.info( + f"Loaded prompt on attempt {attempt} ({len(prompt)} chars)" + ) + return prompt + else: + logger.warning(f"Prompt field is empty (attempt {attempt})") + return None # Database has no configuration + + 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: + # Success - update cache + 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 (explicit empty response from Ruuter) + self._cached_prompt = "" + self._cache_timestamp = time.time() + self._last_error = None + logger.warning("No prompt configuration found in database") + return { + "status": RefreshStatus.NOT_FOUND, + "message": "No prompt configuration 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, + } From 0d11da2f3e5680aa8c6e48e75e1b5d413865af86 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:23:06 +0530 Subject: [PATCH 015/105] Added pre validation step (#298) * updated docker compose ec2 * integrate streaming endpoint with test prodction connection page * formatted response with markdown * fe logic for the encryption * vault secret update after fixing issues * fixed formatting issue * integration with be * update cron manager vault script * tested integration of vault security update * fix security issues * creation success model changes * clean vite config generated files * fixed issue references are not sending with streming tokens * complete #192 and #206 bug fixes * production inference display logic change * change production inference display logic * fixed requested issue * Refactor Docker Compose configuration for vault agents and update CSP settings * Remove obsolete Vite configuration files and associated plugins * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * pre validation step added when user query is inserted * added more validation cases * fixed review comments --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: erangi-ar Co-authored-by: erangi-ar <111747955+erangi-ar@users.noreply.github.com> --- src/llm_orchestration_service.py | 52 ++- src/llm_orchestration_service_api.py | 54 ++++ .../llm_ochestrator_constants.py | 12 +- src/models/request_models.py | 17 +- src/utils/query_validator.py | 112 +++++++ tests/conftest.py | 8 + tests/test_query_validator.py | 306 ++++++++++++++++++ 7 files changed, 549 insertions(+), 12 deletions(-) create mode 100644 src/utils/query_validator.py create mode 100644 tests/conftest.py create mode 100644 tests/test_query_validator.py diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 05303c26..92dd7b02 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -34,6 +34,7 @@ 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, @@ -52,6 +53,7 @@ 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.llm_orchestrator_config.exceptions import ( @@ -170,7 +172,36 @@ def process_orchestration_request( # Using setattr for type safety - adds dynamic attribute to Pydantic model instance setattr(request, "_detected_language", detected_language) - # Initialize all service components + # STEP 0.5: Basic Query Validation (before expensive component initialization) + validation_result = validate_query_basic(request.message) + 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 (only for valid queries) components = self._initialize_service_components(request) # Execute the orchestration pipeline @@ -299,6 +330,22 @@ async def stream_orchestration_response( # Using setattr for type safety - adds dynamic attribute to Pydantic model instance setattr(request, "_detected_language", detected_language) + # Step 0.5: Basic Query Validation (before guardrails) + validation_result = validate_query_basic(request.message) + 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( chat_id=request.chatId, author_id=request.authorId @@ -953,6 +1000,9 @@ def _execute_orchestration_pipeline( timing_dict: Dict[str, float], ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """Execute the main orchestration pipeline with all components.""" + # Note: Query validation now happens in process_orchestration_request() + # before component initialization for true early rejection + # Step 1: Input Guardrails Check if components["guardrails_adapter"]: start_time = time.time() diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 3ed24ce2..8bdc80cc 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -839,6 +839,60 @@ def refresh_prompt_config(http_request: Request) -> Dict[str, Any]: }, ) from e + try: + success = orchestration_service.prompt_config_loader.force_refresh() + + if success: + # Get prompt metadata without exposing content (security) + custom_instructions = ( + orchestration_service.prompt_config_loader.get_custom_instructions() + ) + prompt_length = len(custom_instructions) + + # Generate hash for verification purposes (without exposing content) + import hashlib + + prompt_hash = hashlib.sha256(custom_instructions.encode()).hexdigest()[:16] + + logger.info( + f"Prompt configuration cache refreshed successfully ({prompt_length} chars)" + ) + + return { + "refreshed": True, + "message": "Prompt configuration refreshed successfully", + "prompt_length": prompt_length, + "content_hash": prompt_hash, # Safe: hash instead of preview + } + else: + # No fresh data loaded - could be fetch failure or truly not found + error_id = generate_error_id() + logger.warning( + f"[{error_id}] Prompt configuration refresh returned empty result" + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": "No prompt configuration found in database", + "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__": logger.info("Starting LLM Orchestration Service API server on port 8100") diff --git a/src/llm_orchestrator_config/llm_ochestrator_constants.py b/src/llm_orchestrator_config/llm_ochestrator_constants.py index 90d01ed5..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,7 +123,7 @@ 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 diff --git a/src/models/request_models.py b/src/models/request_models.py index f4a073c5..689c68c3 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" 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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..d1633b76 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""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)) diff --git a/tests/test_query_validator.py b/tests/test_query_validator.py new file mode 100644 index 00000000..361b1b9d --- /dev/null +++ b/tests/test_query_validator.py @@ -0,0 +1,306 @@ +"""Unit tests for query validator. + +Tests cover all documented examples, edge cases, and boundary conditions +to prevent regressions as validation rules evolve. +""" + +import pytest +from src.utils.query_validator import validate_query_basic, QueryValidationResult + + +class TestQueryValidatorEmpty: + """Test empty and whitespace-only queries.""" + + @pytest.mark.parametrize( + "query", + [ + "", + " ", + "\t", + "\n", + "\t\n ", + " \t\n\r ", + ], + ) + def test_empty_queries_rejected(self, query): + """Empty or whitespace-only queries should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "empty" + + +class TestQueryValidatorSpecialCharsOnly: + """Test queries with only special characters or punctuation.""" + + @pytest.mark.parametrize( + "query", + [ + "...", + "???", + "!!!", + "!@#$%^&*()", + ".,?!;:", + "---", + # Note: "___" is repetitive, not special_chars (underscore matches \w) + "[]{}()", + "<>", + "//", + "\\\\", + "++", + "**", + "~~", + "``", + "''", + '""', + "—", + "–", + "''", + "•••", + "→→", + "※※", + "!?!?", + "...???", + "!!! ???", + "????", # 4 question marks - special chars only + ], + ) + def test_special_chars_only_rejected(self, query): + """Queries with only special characters should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "special_chars_only" + + +class TestQueryValidatorTooShort: + """Test queries that are too short.""" + + @pytest.mark.parametrize( + "query", + [ + "a", + "A", + "1", + "õ", + "я", + "a!", + "a?", + "1.", + "a...", + "!a!", + ], + ) + def test_too_short_queries_rejected(self, query): + """Queries with fewer than 2 meaningful characters should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "too_short" + + +class TestQueryValidatorRepetitive: + """Test queries with only repetitive characters.""" + + @pytest.mark.parametrize( + "query", + [ + "aa", + "AAA", + "aaa", + "aaaa", + "AAAAAAA", + "aAaAa", + "11", + "111", + "0000", + "99999", + "õõõõ", + "ääää", + "яяяя", + "aa!", + "!!!aaa!!!", + "a.a.a.a", + "___", # 3 underscores - repetitive (underscore is \w) + ], + ) + def test_repetitive_queries_rejected(self, query): + """Queries with only one unique meaningful character should be rejected.""" + result = validate_query_basic(query) + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + +class TestQueryValidatorValid: + """Test valid queries that should pass validation.""" + + @pytest.mark.parametrize( + "query", + [ + "hi", + "hello", + "ok", + "ab", + "AB", + "Hi", + "123", + "12", + "abc123", + "test1", + "How to apply?", + "What is this?", + "When?", + "tere", + "kuidas", + "Mis on?", + "привет", + "как дела", + "ab!", + "hello!", + "test...", + "what???", + "a-b", + "test_case", + "test123!", + "hello world", + "a b c", + "http://test", + "test@email", + "a1", + "12ab", + "õõte", + ], + ) + def test_valid_queries_accepted(self, query): + """Valid queries with meaningful content should be accepted.""" + result = validate_query_basic(query) + assert result.is_valid is True + assert result.rejection_reason is None + + +class TestQueryValidatorEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_whitespace_trimmed(self): + """Leading and trailing whitespace should be trimmed before validation.""" + result = validate_query_basic(" hello ") + assert result.is_valid is True + + result = validate_query_basic(" ") + assert result.is_valid is False + assert result.rejection_reason == "empty" + + def test_case_insensitive_repetition(self): + """Repetition check should be case-insensitive.""" + result = validate_query_basic("AaAa") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + result = validate_query_basic("AaAaAa") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + def test_unicode_normalization(self): + """Unicode characters should be handled consistently.""" + result = validate_query_basic("привет") + assert result.is_valid is True + + result = validate_query_basic("你好") + assert result.is_valid is True + + result = validate_query_basic("مرحبا") + assert result.is_valid is True + + def test_mixed_scripts(self): + """Queries with mixed scripts should be valid.""" + result = validate_query_basic("hello мир") + assert result.is_valid is True + + result = validate_query_basic("test测试") + assert result.is_valid is True + + def test_numbers_are_valid(self): + """Numbers-only queries are considered valid.""" + result = validate_query_basic("123") + assert result.is_valid is True + + result = validate_query_basic("42") + assert result.is_valid is True + + result = validate_query_basic("2024") + assert result.is_valid is True + + def test_numbers_repetitive(self): + """Repetitive numbers should be rejected.""" + result = validate_query_basic("111") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + result = validate_query_basic("00") + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + def test_punctuation_doesnt_count_as_meaningful(self): + """Punctuation should not count toward meaningful character count.""" + result = validate_query_basic("a!!!") + assert result.is_valid is False + assert result.rejection_reason == "too_short" + + result = validate_query_basic("ab!!!") + assert result.is_valid is True + + def test_emoji_with_text(self): + """Emojis combined with text should be valid.""" + result = validate_query_basic("hello world") + assert result.is_valid is True + + result = validate_query_basic("test case") + assert result.is_valid is True + + def test_long_repetitive_string(self): + """Long strings of repeated characters should be rejected.""" + result = validate_query_basic("a" * 100) + assert result.is_valid is False + assert result.rejection_reason == "repetitive" + + def test_result_is_pydantic_model(self): + """Result should be a valid Pydantic model.""" + result = validate_query_basic("test") + assert isinstance(result, QueryValidationResult) + assert hasattr(result, "is_valid") + assert hasattr(result, "rejection_reason") + + result_dict = result.model_dump() + assert "is_valid" in result_dict + assert "rejection_reason" in result_dict + + +class TestQueryValidatorDocumentedExamples: + """Test all examples from function docstring.""" + + def test_documented_valid_examples(self): + """All documented valid examples should pass.""" + examples = [ + "How to apply for benefits?", + "hi", + "123", + "ab!", + ] + for query in examples: + result = validate_query_basic(query) + assert result.is_valid is True, f"Expected '{query}' to be valid" + assert result.rejection_reason is None + + def test_documented_invalid_examples(self): + """All documented invalid examples should fail with correct reason.""" + examples = [ + ("...", "special_chars_only"), + ("", "empty"), + # Note: ???? is special_chars_only (not in \w), not repetitive + ("????", "special_chars_only"), + ("a", "too_short"), + ] + for query, expected_reason in examples: + result = validate_query_basic(query) + assert result.is_valid is False, f"Expected '{query}' to be invalid" + assert result.rejection_reason == expected_reason, ( + f"Expected '{query}' to fail with '{expected_reason}', " + f"got '{result.rejection_reason}'" + ) From 884f69e598c13f8157725414201f33cbe54e658e Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 18 Feb 2026 11:30:30 +0530 Subject: [PATCH 016/105] implement tool classification orchestration agent skeleton --- docs/TOOL_CLASSIFIER_SKELETON_USAGE.md | 542 ++++++++++++++++++ src/llm_orchestration_service.py | 186 +++++- src/llm_orchestration_service_api.py | 10 +- src/llm_orchestrator_config/feature_flags.py | 82 +++ src/tool_classifier/__init__.py | 20 + src/tool_classifier/base_workflow.py | 118 ++++ src/tool_classifier/classifier.py | 338 +++++++++++ src/tool_classifier/enums.py | 39 ++ src/tool_classifier/models.py | 81 +++ src/tool_classifier/workflows/__init__.py | 13 + .../workflows/context_workflow.py | 156 +++++ src/tool_classifier/workflows/ood_workflow.py | 131 +++++ src/tool_classifier/workflows/rag_workflow.py | 131 +++++ .../workflows/service_workflow.py | 137 +++++ 14 files changed, 1957 insertions(+), 27 deletions(-) create mode 100644 docs/TOOL_CLASSIFIER_SKELETON_USAGE.md create mode 100644 src/llm_orchestrator_config/feature_flags.py create mode 100644 src/tool_classifier/__init__.py create mode 100644 src/tool_classifier/base_workflow.py create mode 100644 src/tool_classifier/classifier.py create mode 100644 src/tool_classifier/enums.py create mode 100644 src/tool_classifier/models.py create mode 100644 src/tool_classifier/workflows/__init__.py create mode 100644 src/tool_classifier/workflows/context_workflow.py create mode 100644 src/tool_classifier/workflows/ood_workflow.py create mode 100644 src/tool_classifier/workflows/rag_workflow.py create mode 100644 src/tool_classifier/workflows/service_workflow.py diff --git a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md new file mode 100644 index 00000000..d602e0f4 --- /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/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 92dd7b02..7c5ba4b8 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -60,6 +60,8 @@ ContextualRetrieverInitializationError, ContextualRetrievalFailureError, ) +from src.llm_orchestrator_config.feature_flags import FeatureFlags +from src.tool_classifier import ToolClassifier class LangfuseConfig: @@ -128,8 +130,15 @@ def __init__(self) -> None: 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 + + # Log feature flag configuration + FeatureFlags.log_configuration() + @observe(name="orchestration_request", as_type="agent") - def process_orchestration_request( + async def process_orchestration_request( self, request: OrchestrationRequest ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """ @@ -204,10 +213,65 @@ def process_orchestration_request( # Initialize all service components (only for valid queries) components = self._initialize_service_components(request) - # Execute the orchestration pipeline - response = self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict - ) + # 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 + classification = await self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language, + ) + + logger.info( + f"[{request.chatId}] Classification: {classification.workflow.value} " + f"(confidence: {classification.confidence:.2f})" + ) + + # Route to appropriate workflow + response = await self.tool_classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + 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_dict, timing_dict + ) + 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_dict, timing_dict + ) # Log final costs and return response self._log_costs(costs_dict) @@ -390,7 +454,81 @@ async def stream_orchestration_response( f"[{request.chatId}] [{stream_ctx.stream_id}] Input guardrails passed " ) + # 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)" + ) + + # 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" + ) + + # Classify query to determine workflow + classification = await self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language, + ) + + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Classification: {classification.workflow.value} " + f"(confidence: {classification.confidence:.2f})" + ) + + # Route to appropriate workflow (streaming) + # route_to_workflow returns AsyncIterator[str] when is_streaming=True + stream_result = await self.tool_classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=True, + ) + + 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_dict) + log_step_timings(timing_dict, 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" + ) + # Continue to existing RAG streaming pipeline below + # STEP 2: REFINE USER PROMPT (blocking) + # NOTE: This step only executes if tool classifier is disabled or fallback occurred logger.info( f"[{request.chatId}] [{stream_ctx.stream_id}] Step 2: Refining user prompt" ) @@ -992,7 +1130,7 @@ 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], @@ -1006,7 +1144,7 @@ def _execute_orchestration_pipeline( # Step 1: Input Guardrails Check if components["guardrails_adapter"]: start_time = time.time() - input_blocked_response = self.handle_input_guardrails( + input_blocked_response = await self.handle_input_guardrails( components["guardrails_adapter"], request, costs_dict ) timing_dict["input_guardrails_check"] = time.time() - start_time @@ -1026,7 +1164,7 @@ def _execute_orchestration_pipeline( # Step 3: 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 @@ -1057,7 +1195,7 @@ def _execute_orchestration_pipeline( # Step 5: 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( + output_guardrails_response = await self.handle_output_guardrails( components["guardrails_adapter"], generated_response, request, @@ -1132,14 +1270,14 @@ 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]], ) -> 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, @@ -1186,21 +1324,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, @@ -1255,7 +1395,7 @@ 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: Union[OrchestrationResponse, TestOrchestrationResponse], @@ -1273,7 +1413,7 @@ def handle_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 = self._check_output_guardrails( + output_check_result = await self._check_output_guardrails( guardrails_adapter=guardrails_adapter, assistant_message=generated_response.content, costs_dict=costs_dict, @@ -1694,7 +1834,7 @@ 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, @@ -1714,7 +1854,7 @@ 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 diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 8bdc80cc..2a929db0 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -225,7 +225,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: @@ -262,7 +262,7 @@ def orchestrate_llm_request( ) # Process the request - response = orchestration_service.process_orchestration_request(request) + response = await orchestration_service.process_orchestration_request(request) logger.info(f"Successfully processed request for chatId: {request.chatId}") return response @@ -287,7 +287,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: @@ -341,7 +341,9 @@ def test_orchestrate_llm_request( 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): diff --git a/src/llm_orchestrator_config/feature_flags.py b/src/llm_orchestrator_config/feature_flags.py new file mode 100644 index 00000000..d0d3fff8 --- /dev/null +++ b/src/llm_orchestrator_config/feature_flags.py @@ -0,0 +1,82 @@ +"""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) + - CONTEXT_WORKFLOW_ENABLED: Enable Layer 2 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" + ) + 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): + """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" 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", "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, + "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/tool_classifier/__init__.py b/src/tool_classifier/__init__.py new file mode 100644 index 00000000..38b861d5 --- /dev/null +++ b/src/tool_classifier/__init__.py @@ -0,0 +1,20 @@ +""" +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 .classifier import ToolClassifier +from .enums import WorkflowType +from .models import ClassificationResult + +__all__ = [ + "ToolClassifier", + "WorkflowType", + "ClassificationResult", +] diff --git a/src/tool_classifier/base_workflow.py b/src/tool_classifier/base_workflow.py new file mode 100644 index 00000000..50faf7ad --- /dev/null +++ b/src/tool_classifier/base_workflow.py @@ -0,0 +1,118 @@ +"""Abstract base class for workflow executors.""" + +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, Dict, Optional + +from models.request_models import OrchestrationRequest, OrchestrationResponse + + +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], + ) -> Optional[OrchestrationResponse]: + """ + 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 + + 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], + ) -> 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 + + 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..a97ab6f0 --- /dev/null +++ b/src/tool_classifier/classifier.py @@ -0,0 +1,338 @@ +"""Main tool classifier for workflow routing.""" + +from typing import Any, AsyncIterator, Dict, List, Literal, Union, overload +from loguru import logger + +from models.request_models import ( + ConversationItem, + OrchestrationRequest, + OrchestrationResponse, +) +from tool_classifier.enums import WorkflowType, WORKFLOW_DISPLAY_NAMES +from tool_classifier.models import ClassificationResult +from tool_classifier.workflows import ( + ServiceWorkflowExecutor, + ContextWorkflowExecutor, + RAGWorkflowExecutor, + OODWorkflowExecutor, +) + + +class ToolClassifier: + """ + Main classifier that determines which workflow should handle user queries. + + 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 + + Each layer is tried in sequence. If a layer cannot handle the query + (returns None), the classifier falls back to the next layer. + + Architecture: + - Strategy Pattern: Each workflow is a pluggable strategy + - Chain of Responsibility: Layers form a fallback chain + - Dependency Injection: LLM manager and connections injected from main service + """ + + def __init__( + self, + llm_manager: Any, + orchestration_service: Any, + ): + """ + 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 + + # Initialize workflow executors + self.service_workflow = ServiceWorkflowExecutor( + llm_manager=llm_manager, + ) + self.context_workflow = ContextWorkflowExecutor( + llm_manager=llm_manager, + ) + self.rag_workflow = RAGWorkflowExecutor( + orchestration_service=orchestration_service, + ) + self.ood_workflow = OODWorkflowExecutor() + + logger.info("Tool classifier initialized with all workflow executors") + + async def classify( + self, + query: str, + conversation_history: List[ConversationItem], + language: str, + ) -> ClassificationResult: + """ + Classify a user query to determine which workflow should handle it. + + Implements layer-wise classification logic: + 1. Check if SERVICE workflow can handle (intent detection) + 2. Check if CONTEXT workflow can handle (greeting/history check) + 3. Default to RAG workflow (knowledge retrieval) + + Args: + query: User's query string + conversation_history: List of previous conversation messages + language: Detected language code (e.g., 'en', 'et') + + Returns: + ClassificationResult indicating which workflow to use + + Note: + In this skeleton, always defaults to RAG. Full implementation + will add Layer 1 and Layer 2 logic in separate tasks. + """ + logger.info(f"Classifying query: {query[:100]}...") + + # TODO: LAYER 1 - SERVICE WORKFLOW DETECTION + # Implementation task: Service workflow implementation + # Logic: + # 1. Count active services in database + # 2. If count > 50: Use Qdrant semantic search for top 20 services + # 3. If count <= 50: Use all services + # 4. Call LLM to detect intent and extract entities + # 5. If intent detected and service valid: return SERVICE classification + # Example: + # service_check = await self._check_service_layer(query, language) + # if service_check.can_handle: + # return ClassificationResult( + # workflow=WorkflowType.SERVICE, + # confidence=service_check.confidence, + # metadata=service_check.metadata, + # reasoning="Service intent detected" + # ) + + # TODO: LAYER 2 - CONTEXT WORKFLOW DETECTION + # Implementation task: Context workflow implementation + # Logic: + # 1. Check if query is a greeting using LLM + # 2. If greeting: return CONTEXT classification + # 3. If conversation_history exists: Check if query references history + # 4. Call LLM to determine if history contains answer + # 5. If can answer from history: return CONTEXT classification + # Example: + # context_check = await self._check_context_layer( + # query, conversation_history, language + # ) + # if context_check.can_handle: + # return ClassificationResult( + # workflow=WorkflowType.CONTEXT, + # confidence=context_check.confidence, + # metadata=context_check.metadata, + # reasoning="Greeting or answerable from history" + # ) + + # LAYER 3 - RAG WORKFLOW (DEFAULT) + # Always defaults to RAG for now + # RAG workflow will handle the query or return OOD if no chunks found + logger.info("Defaulting to RAG workflow (Layers 1-2 not implemented)") + return ClassificationResult( + workflow=WorkflowType.RAG, + confidence=1.0, + metadata={}, + reasoning="Default to RAG workflow (service and context layers not implemented)", + ) + + @overload + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: Literal[False] = False, + ) -> OrchestrationResponse: ... + + @overload + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: Literal[True], + ) -> AsyncIterator[str]: ... + + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: bool = False, + ) -> Union[OrchestrationResponse, 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) + + 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, + ) + 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, + ) + + def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: + """Get workflow executor instance for given workflow type.""" + workflow_map = { + WorkflowType.SERVICE: self.service_workflow, + WorkflowType.CONTEXT: self.context_workflow, + WorkflowType.RAG: self.rag_workflow, + WorkflowType.OOD: self.ood_workflow, + } + return workflow_map[workflow_type] + + async def _execute_with_fallback_async( + self, + workflow: Any, + request: OrchestrationRequest, + context: Dict[str, Any], + start_layer: WorkflowType, + ) -> OrchestrationResponse: + """ + Execute workflow with fallback to subsequent layers (non-streaming). + + TODO: Implement full fallback chain logic + Currently just executes the primary workflow. + + Full implementation should: + 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) + """ + 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: + result = await workflow.execute_async(request, context) + + if result is not None: + logger.info(f"[{chat_id}] {workflow_name} handled successfully") + return result + + # TODO: Implement fallback to next layer + # For now, if workflow returns None, call RAG as fallback + logger.warning( + f"[{chat_id}] {workflow_name} returned None, " + f"falling back to RAG workflow" + ) + rag_result = await self.rag_workflow.execute_async(request, {}) + if rag_result is not None: + return rag_result + else: + # This should never happen since RAG always returns a result + # But handle gracefully + raise RuntimeError("RAG workflow returned None unexpectedly") + + 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, {}) + if rag_result is not None: + return rag_result + else: + raise RuntimeError("RAG workflow returned None unexpectedly") + + async def _execute_with_fallback_streaming( + self, + workflow: Any, + request: OrchestrationRequest, + context: Dict[str, Any], + start_layer: WorkflowType, + ) -> AsyncIterator[str]: + """ + Execute workflow with fallback to subsequent layers (streaming). + + TODO: Implement full fallback chain logic + Currently just executes the primary workflow. + + Full implementation should: + 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) + """ + 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: + result = await workflow.execute_streaming(request, context) + + if result is not None: + logger.info(f"[{chat_id}] {workflow_name} streaming started") + async for chunk in result: + yield chunk + return + + # TODO: Implement fullback to next layer + # For now, if workflow returns None, call RAG as fallback + logger.warning( + f"[{chat_id}] {workflow_name} returned None, " + f"falling back to RAG workflow streaming" + ) + streaming_result = await self.rag_workflow.execute_streaming(request, {}) + if streaming_result is not None: + async for chunk in streaming_result: + yield chunk + else: + raise RuntimeError("RAG workflow returned None unexpectedly") + + 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, {}) + if streaming_result is not None: + async for chunk in streaming_result: + yield chunk + else: + raise RuntimeError("RAG workflow returned None unexpectedly") diff --git a/src/tool_classifier/enums.py b/src/tool_classifier/enums.py new file mode 100644 index 00000000..ce6c7859 --- /dev/null +++ b/src/tool_classifier/enums.py @@ -0,0 +1,39 @@ +"""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) + - CONTEXT: Conversation history or greetings (Layer 2) + - RAG: Knowledge base retrieval (Layer 3) + - OOD: Out-of-domain fallback (Layer 4) + """ + + SERVICE = "service" + 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.CONTEXT, # Layer 2: Then context + WorkflowType.RAG, # Layer 3: Then RAG + WorkflowType.OOD, # Layer 4: Finally OOD (always succeeds) +] + +# Workflow display names for logging +WORKFLOW_DISPLAY_NAMES = { + WorkflowType.SERVICE: "Service Workflow", + WorkflowType.CONTEXT: "Context Workflow", + WorkflowType.RAG: "RAG Workflow", + WorkflowType.OOD: "Out-of-Domain Workflow", +} diff --git a/src/tool_classifier/models.py b/src/tool_classifier/models.py new file mode 100644 index 00000000..9929473b --- /dev/null +++ b/src/tool_classifier/models.py @@ -0,0 +1,81 @@ +"""Data models for tool classifier system.""" + +from typing import Any, Dict, Optional +from pydantic import BaseModel, Field + +from tool_classifier.enums import 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" + ) diff --git a/src/tool_classifier/workflows/__init__.py b/src/tool_classifier/workflows/__init__.py new file mode 100644 index 00000000..d471b432 --- /dev/null +++ b/src/tool_classifier/workflows/__init__.py @@ -0,0 +1,13 @@ +"""Workflow executor implementations.""" + +from workflows.service_workflow import ServiceWorkflowExecutor +from workflows.context_workflow import ContextWorkflowExecutor +from workflows.rag_workflow import RAGWorkflowExecutor +from workflows.ood_workflow import OODWorkflowExecutor + +__all__ = [ + "ServiceWorkflowExecutor", + "ContextWorkflowExecutor", + "RAGWorkflowExecutor", + "OODWorkflowExecutor", +] diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py new file mode 100644 index 00000000..78d71c19 --- /dev/null +++ b/src/tool_classifier/workflows/context_workflow.py @@ -0,0 +1,156 @@ +"""Context workflow executor - Layer 2: Conversation history and greetings.""" + +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 ContextWorkflowExecutor(BaseWorkflow): + """ + Handles queries answerable from conversation history or greetings (Layer 2). + + This workflow handles two types of queries: + 1. Greetings and conversational pleasantries + - "Hello", "Good morning", "Thanks", "Goodbye" + 2. Queries referencing conversation history + - "What did you say earlier?" + - "Can you repeat that?" + - "What was the rate you mentioned?" + + Uses LLM-based detection (no regex patterns) for: + - Semantic greeting detection (multilingual) + - Context reference detection + - Answer extraction from conversation history + + Examples: + - "Tere!" → Friendly greeting response + - "Hello" → "Hello! How can I help you?" + - "What was the rate?" (history: "Rate is 1.08") → "The rate was 1.08" + + Implementation Status: SKELETON + Returns None (triggers fallback to RAG workflow) + + TODO - Full Implementation (Separate Task): + - Greeting detection using LLM + - Context availability check using LLM + - Answer extraction from conversation history + - Output guardrails for context-based responses + - Multilingual support (Estonian, English) + """ + + def __init__(self, llm_manager: Any): + """ + Initialize context workflow executor. + + Args: + llm_manager: LLM manager for context analysis + """ + self.llm_manager = llm_manager + logger.info("Context workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """ + Execute context workflow in non-streaming mode. + + TODO: Implement context workflow logic: + 1. Check if query is a greeting using LLM + - If yes: Generate appropriate greeting response + 2. If not greeting, check conversation history: + - Get recent history (last 10 turns) + - Call LLM to check if query can be answered from history + - If yes: Extract answer from history + 3. Validate answer with output guardrails + 4. Return OrchestrationResponse with context-based answer + + LLM Prompt for Context Check: + ``` + Conversation History: + 1. User: What's the exchange rate? + 2. Bot: EUR/USD rate is 1.08 + 3. User: Thanks + + Current Query: "What was the rate?" + + Can this be answered from history? If yes, provide answer. + ``` + + Failure scenarios: + - Not a greeting and no conversation history → return None + - Cannot answer from history → return None (fallback to RAG) + - Output guardrails blocked → return None or violation message + + Args: + request: Orchestration request with user query and history + context: Metadata with is_greeting, can_answer_from_history flags + + Returns: + OrchestrationResponse with context-based answer or None to fallback + """ + logger.debug( + f"[{request.chatId}] Context workflow execute_async called " + f"(not implemented - returning None)" + ) + + # TODO: Implement context workflow logic here + # For now, return None to trigger fallback to next layer (RAG) + return None + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """ + Execute context workflow in streaming mode. + + TODO: Implement context workflow streaming: + 1. Detect greeting or check conversation history (same as non-streaming) + 2. Get complete answer from history or generate greeting response + 3. Validate with output guardrails (validation-first) + 4. If blocked: yield violation message + END + 5. If allowed: chunk answer and stream token-by-token + 6. Simulate streaming for consistent UX with RAG + + Streaming approach (validation-first): + ```python + # Get complete context-based answer + context_result = await analyze_context(query, history) + + if not context_result.can_answer: + return None # Fallback to RAG + + # Validate BEFORE streaming + is_safe = await guardrails.check_output_async(context_result.answer) + if not is_safe: + yield format_sse(chatId, VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + + # Stream validated answer + for chunk in split_into_tokens(context_result.answer, chunk_size=5): + yield format_sse(chatId, chunk) + await asyncio.sleep(0.01) + yield format_sse(chatId, "END") + ``` + + Args: + request: Orchestration request with user query and history + context: Metadata with is_greeting, can_answer_from_history flags + + Returns: + AsyncIterator yielding SSE strings or None to fallback + """ + logger.debug( + f"[{request.chatId}] Context workflow execute_streaming called " + f"(not implemented - returning None)" + ) + + # TODO: Implement context streaming logic here + # For now, return None to trigger fallback to next layer (RAG) + 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..fed467a5 --- /dev/null +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -0,0 +1,131 @@ +"""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): + """Initialize OOD workflow executor.""" + logger.info("OOD workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> 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) + + 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], + ) -> 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..8c69456c --- /dev/null +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -0,0 +1,131 @@ +"""RAG workflow executor - Layer 3: Knowledge base retrieval.""" + +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 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: Any): + """ + 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], + ) -> Optional[OrchestrationResponse]: + """ + 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: Unused (RAG doesn't need classification metadata) + + 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_dict: Dict[str, Any] = {} + timing_dict: Dict[str, float] = {} + + # Initialize service components + components = self.orchestration_service._initialize_service_components(request) + + # Call existing RAG pipeline + response = self.orchestration_service._execute_orchestration_pipeline( + request=request, + components=components, + costs_dict=costs_dict, + timing_dict=timing_dict, + ) + + # Log costs and timings + self.orchestration_service._log_costs(costs_dict) + from src.utils.time_tracker import log_step_timings + + log_step_timings(timing_dict, request.chatId) + + return response + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """ + Execute RAG workflow in streaming mode. + + 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: Unused (RAG doesn't need classification metadata) + + Returns: + AsyncIterator yielding SSE-formatted strings + Never returns None (handles OOD internally) + """ + logger.info(f"[{request.chatId}] Executing RAG workflow (streaming)") + + # Delegate to existing streaming implementation + # This handles all RAG logic + guardrails + async for sse_chunk in self.orchestration_service.stream_orchestration_response( + request + ): + yield sse_chunk diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py new file mode 100644 index 00000000..8a6889bc --- /dev/null +++ b/src/tool_classifier/workflows/service_workflow.py @@ -0,0 +1,137 @@ +"""Service workflow executor - Layer 1: External service/API calls.""" + +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 ServiceWorkflowExecutor(BaseWorkflow): + """ + Executes external service calls via Ruuter endpoints (Layer 1). + + This workflow handles queries that require calling external government + services or APIs. It performs: + 1. Service discovery (semantic search if >50 services) + 2. Intent detection using LLM + 3. Entity extraction from query + 4. Service validation against database + 5. External API call via Ruuter + 6. Output guardrails validation + + Examples of Service queries: + - "What's the EUR to USD exchange rate?" + - "Check my document status" + - "Submit a tax declaration" + + Implementation Status: SKELETON + Returns None (triggers fallback to Context workflow) + + TODO - Full Implementation (Separate Task): + - Service discovery logic (Qdrant semantic search) + - Intent detection (LLM-based) + - Entity extraction and transformation + - Service validation (database lookup) + - Ruuter API integration + - Output guardrails for service responses + """ + + def __init__(self, llm_manager: Any): + """ + Initialize service workflow executor. + + Args: + llm_manager: LLM manager for intent detection + """ + self.llm_manager = llm_manager + logger.info("Service workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """ + Execute service workflow in non-streaming mode. + + TODO: Implement service workflow logic: + 1. Extract service metadata from context (service_id, intent, entities) + 2. Validate service exists and is active in database + 3. Transform entities to array format for service call + 4. Call Ruuter endpoint: POST {RUUTER_BASE_URL}/services/active{ServiceName} + 5. Validate response with output guardrails + 6. Return OrchestrationResponse with service result + + Failure scenarios: + - No service_id in context → return None (fallback to Context) + - Service not found/inactive → return None (fallback to Context) + - Service call timeout → return error response + - Output guardrails blocked → return violation response or None + + Args: + request: Orchestration request with user query + context: Metadata with service_id, intent, entities + + Returns: + OrchestrationResponse with service result or None to fallback + """ + logger.debug( + f"[{request.chatId}] Service workflow execute_async called " + f"(not implemented - returning None)" + ) + + # TODO: Implement service workflow logic here + # For now, return None to trigger fallback to next layer + return None + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """ + Execute service workflow in streaming mode. + + TODO: Implement service workflow streaming: + 1. Execute service call (same as non-streaming) + 2. Get complete service response + 3. Validate with output guardrails (validation-first) + 4. If blocked: yield violation message + END + 5. If allowed: chunk response and stream token-by-token + 6. Simulate streaming for consistent UX with RAG + + Streaming approach (validation-first): + ```python + # Get complete response + service_response = await call_service(...) + + # Validate BEFORE streaming + is_safe = await guardrails.check_output_async(service_response) + if not is_safe: + yield format_sse(chatId, VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + + # Stream validated response + for chunk in split_into_tokens(service_response, chunk_size=5): + yield format_sse(chatId, chunk) + await asyncio.sleep(0.01) + yield format_sse(chatId, "END") + ``` + + Args: + request: Orchestration request with user query + context: Metadata with service_id, intent, entities + + Returns: + AsyncIterator yielding SSE strings or None to fallback + """ + logger.debug( + f"[{request.chatId}] Service workflow execute_streaming called " + f"(not implemented - returning None)" + ) + + # TODO: Implement service streaming logic here + # For now, return None to trigger fallback to next layer + return None From 1cd5f54e223fdc55e8e6dbec22e47477602e28d5 Mon Sep 17 00:00:00 2001 From: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:17:03 +0530 Subject: [PATCH 017/105] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/TOOL_CLASSIFIER_SKELETON_USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md index d602e0f4..9dc87c88 100644 --- a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md +++ b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md @@ -26,7 +26,7 @@ This skeleton implements the **framework** for a multi-workflow routing system b - Output guardrails integration for new workflows - Database schema changes -###Current Behavior +### Current Behavior When `TOOL_CLASSIFIER_ENABLED=false` (default): - System works exactly as before (RAG-only pipeline) From 787ba4ad8c7c75be1e3d22923747ab70bc37aa70 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 18 Feb 2026 12:54:59 +0530 Subject: [PATCH 018/105] fixed copilot suggested changes --- src/llm_orchestration_service.py | 802 +++++++++--------- src/tool_classifier/workflows/__init__.py | 8 +- src/tool_classifier/workflows/rag_workflow.py | 49 +- 3 files changed, 439 insertions(+), 420 deletions(-) diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 7c5ba4b8..3c059f59 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -381,7 +381,6 @@ 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() # STEP 0: Detect language from user message detected_language = detect_language(request.message) @@ -525,468 +524,447 @@ async def stream_orchestration_response( logger.debug( f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier disabled - using RAG streaming" ) - # Continue to existing RAG streaming pipeline below - # STEP 2: REFINE USER PROMPT (blocking) - # NOTE: This step only executes if tool classifier is disabled or fallback occurred - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 2: Refining user prompt" - ) + # 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_dict=costs_dict, + timing_dict=timing_dict, + ): + yield sse_chunk + + # Pipeline completed successfully + return - 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, + 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 ) - timing_dict["prompt_refiner"] = time.time() - start_time - costs_dict["prompt_refiner"] = refiner_usage - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Prompt refinement complete " - ) + yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) + yield self._format_sse(request.chatId, "END") - # STEP 3: RETRIEVE CONTEXT CHUNKS (blocking) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 3: Retrieving context chunks" + self._log_costs(costs_dict) + log_step_timings(timing_dict, request.chatId) + + # Update budget even on outer exception + self._update_connection_budget( + request.connection_id, costs_dict, request.environment ) - 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 + 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}] Retrieved {len(relevant_chunks)} chunks " - ) + async def _stream_rag_pipeline( + self, + request: OrchestrationRequest, + components: Dict[str, Any], + stream_ctx: Any, + costs_dict: Dict[str, Dict[str, Any]], + timing_dict: Dict[str, float], + ) -> AsyncIterator[str]: + """ + Core RAG streaming pipeline without classifier routing. - # 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" - ) + This method contains the RAG pipeline logic that can be called directly + by workflows to avoid infinite recursion when the tool classifier is enabled. - 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, - ) - timing_dict["scope_check"] = time.time() - start_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) - 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 - ) - 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 + Args: + request: Orchestration request + components: Initialized service components (LLM, retriever, generator, guardrails) + stream_ctx: Stream context for tracking + costs_dict: Dictionary to accumulate costs + timing_dict: Dictionary to accumulate timings - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Question is in scope " - ) + Yields: + SSE-formatted strings + """ + streaming_start_time = datetime.now() + detected_language = getattr(request, "_detected_language", "en") - # 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)" - ) + # STEP 1: REFINE USER PROMPT (blocking) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] RAG Pipeline Step 1: Refining user prompt" + ) - streaming_step_start = time.time() + 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 - # Record history length before streaming - lm = dspy.settings.lm - history_length_before = ( - len(lm.history) if lm and hasattr(lm, "history") else 0 - ) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Prompt refinement complete" + ) - 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 + # STEP 2: RETRIEVE CONTEXT CHUNKS (blocking) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] RAG Pipeline Step 2: Retrieving context chunks" + ) - # Create and store bot_generator in stream context for guaranteed cleanup - bot_generator = bot_response_generator() - stream_ctx.bot_generator = bot_generator + 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" + ) + 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_dict) + log_step_timings(timing_dict, request.chatId) + stream_ctx.mark_completed() + return - # 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 + 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_dict) + log_step_timings(timing_dict, request.chatId) + stream_ctx.mark_completed() + return - 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}] Retrieved {len(relevant_chunks)} chunks" + ) - # 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 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" + ) - yield self._format_sse(request.chatId, "END") + 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, + ) + timing_dict["scope_check"] = time.time() - start_time - 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 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_dict) + log_step_timings(timing_dict, request.chatId) + stream_ctx.mark_completed() + return - yield self._format_sse(request.chatId, "END") + logger.info(f"[{request.chatId}] [{stream_ctx.stream_id}] Question is in scope") - # Extract usage information after streaming completes - usage_info = get_lm_usage_since(history_length_before) - costs_dict["streaming_generation"] = usage_info + # 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" + ) - # 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 + streaming_step_start = time.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" - ) + # Record history length before streaming + lm = dspy.settings.lm + history_length_before = len(lm.history) if lm and hasattr(lm, "history") else 0 - # Log costs and trace - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + 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 - # Update budget for the LLM connection - self._update_connection_budget( - request.connection_id, costs_dict, request.environment - ) + # Create and store bot_generator in stream context for guaranteed cleanup + bot_generator = bot_response_generator() + stream_ctx.bot_generator = bot_generator - 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 + # 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_dict["streaming_generation"] = usage_info + self._log_costs(costs_dict) + log_step_timings(timing_dict, 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 + + 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") - # Mark stream as completed successfully - stream_ctx.mark_completed() + 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 + # 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, token) + + # 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) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) yield self._format_sse(request.chatId, "END") - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + # Extract usage information after streaming completes + usage_info = get_lm_usage_since(history_length_before) + costs_dict["streaming_generation"] = usage_info - # Update budget even on outer exception - self._update_connection_budget( - request.connection_id, costs_dict, request.environment + # Record timings + timing_dict["streaming_generation"] = time.time() - streaming_step_start + timing_dict["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_dict) + log_step_timings(timing_dict, request.chatId) + + # Update budget + self._update_connection_budget( + request.connection_id, costs_dict, request.environment + ) + + # Langfuse tracking + 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() - 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_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 + ) + 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) + + # Update budget even on streaming error + self._update_connection_budget( + request.connection_id, costs_dict, request.environment + ) def _format_sse(self, chat_id: str, content: str) -> str: """ diff --git a/src/tool_classifier/workflows/__init__.py b/src/tool_classifier/workflows/__init__.py index d471b432..3d733d54 100644 --- a/src/tool_classifier/workflows/__init__.py +++ b/src/tool_classifier/workflows/__init__.py @@ -1,9 +1,9 @@ """Workflow executor implementations.""" -from workflows.service_workflow import ServiceWorkflowExecutor -from workflows.context_workflow import ContextWorkflowExecutor -from workflows.rag_workflow import RAGWorkflowExecutor -from workflows.ood_workflow import OODWorkflowExecutor +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__ = [ "ServiceWorkflowExecutor", diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index 8c69456c..e5779d2a 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -123,9 +123,50 @@ async def execute_streaming( """ logger.info(f"[{request.chatId}] Executing RAG workflow (streaming)") - # Delegate to existing streaming implementation - # This handles all RAG logic + guardrails - async for sse_chunk in self.orchestration_service.stream_orchestration_response( - request + # Initialize tracking dictionaries + costs_dict: Dict[str, Any] = {} + timing_dict: Dict[str, float] = {} + + # 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: + # Create minimal stream context when called via tool classifier + # In production flow, this is provided by stream_orchestration_response + 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: + """No-op: Tracking handled by orchestration service.""" + pass + + def mark_cancelled(self) -> None: + """No-op: Tracking handled by orchestration service.""" + pass + + def mark_error(self, error_id: str) -> None: + """No-op: Tracking handled by orchestration service.""" + pass + + stream_ctx = MinimalStreamContext(request.chatId) + + # Delegate to core RAG pipeline (bypasses classifier to avoid recursion) + async for sse_chunk in self.orchestration_service._stream_rag_pipeline( + request=request, + components=components, + stream_ctx=stream_ctx, + costs_dict=costs_dict, + timing_dict=timing_dict, ): yield sse_chunk From f66150c2b4974fd4399f9ef103405cc65a0d921c Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 18 Feb 2026 13:01:11 +0530 Subject: [PATCH 019/105] fixed issue --- src/tool_classifier/classifier.py | 2 +- src/tool_classifier/workflows/rag_workflow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index a97ab6f0..71a45922 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -313,7 +313,7 @@ async def _execute_with_fallback_streaming( yield chunk return - # TODO: Implement fullback to next layer + # TODO: Implement fallback to next layer # For now, if workflow returns None, call RAG as fallback logger.warning( f"[{chat_id}] {workflow_name} returned None, " diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index e5779d2a..d83080a7 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -79,7 +79,7 @@ async def execute_async( components = self.orchestration_service._initialize_service_components(request) # Call existing RAG pipeline - response = self.orchestration_service._execute_orchestration_pipeline( + response = await self.orchestration_service._execute_orchestration_pipeline( request=request, components=components, costs_dict=costs_dict, From d248199001c9c5a8e191864509234c3970a73f28 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:10:48 +0530 Subject: [PATCH 020/105] Tool classifier planning (#301) * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * pre validation step added when user query is inserted * added more validation cases * fixed review comments * added spec document to newly updated tool classification --- docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md | 1940 ++++++++++++++++++++++++ 1 file changed, 1940 insertions(+) create mode 100644 docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md diff --git a/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md b/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md new file mode 100644 index 00000000..469f8092 --- /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_dict["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_dict["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_dict: 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_dict["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_dict["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_dict: 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_dict["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_dict["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_dict + ) + if result: + return result # Context-based answer (validated) + else: + # Move to Layer 3 (RAG) + return await execute_rag_workflow(request, components, costs_dict) +except Exception as e: + logger.error(f"Context workflow failed: {e}") + # Fallback to RAG workflow + return await execute_rag_workflow(request, components, costs_dict) +``` + +**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_dict) +``` + +--- + +## 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_dict, timing_dict +) +``` + +**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_dict["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_dict["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_dict["context_check +costs_dict["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_dict: 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_dict["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 | + +--- From c1df52df3dc9472c556864c6bad040c71118a4e6 Mon Sep 17 00:00:00 2001 From: erangi-ar <111747955+erangi-ar@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:09:56 +0530 Subject: [PATCH 021/105] Show the response in markdown in test LLM page (#317) * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * pre validation step added when user query is inserted * added more validation cases * fixed review comments * feat: add react-markdown and remark-gfm for rendering markdown content in TestModel page --------- Co-authored-by: nuwangeek Co-authored-by: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Co-authored-by: erangi-ar --- GUI/package-lock.json | 1564 +++++++++++++++++++++++++++-- GUI/package.json | 2 + GUI/src/pages/TestModel/index.tsx | 10 +- 3 files changed, 1514 insertions(+), 62 deletions(-) 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/pages/TestModel/index.tsx b/GUI/src/pages/TestModel/index.tsx index c00c3ae1..4829d12b 100644 --- a/GUI/src/pages/TestModel/index.tsx +++ b/GUI/src/pages/TestModel/index.tsx @@ -3,6 +3,8 @@ import { Button, FormSelect, FormTextarea, Collapsible } from 'components'; import CircularSpinner from 'components/molecules/CircularSpinner/CircularSpinner'; import { 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'; @@ -139,7 +141,9 @@ const TestLLM: FC = () => {
Response:
- {inferenceResult.content} + + {inferenceResult.content} +
@@ -155,7 +159,9 @@ const TestLLM: FC = () => { Rank {contextItem.rank}
- {contextItem.chunkRetrieved} + + {contextItem.chunkRetrieved} +
))} From 078122cc1bd185e93627fd7aacb52571e75dce78 Mon Sep 17 00:00:00 2001 From: erangi-ar <111747955+erangi-ar@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:29:00 +0530 Subject: [PATCH 022/105] Streaming response formatting (#280) * refactor: update SSE connection URL to use environment variable * foramt markdown of the llm response * feat: add markdown support to MessageContent component * title fix * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * pre validation step added when user query is inserted * added more validation cases * fixed review comments * resolved pr comments --------- Co-authored-by: erangi-ar Co-authored-by: nuwangeek Co-authored-by: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- GUI/.env.development | 4 +- .../MessageContent/MessageContent.scss | 127 ++++++--- GUI/src/components/MessageContent/index.tsx | 108 ++------ GUI/src/hooks/useStreamingResponse.tsx | 17 +- .../TestProductionLLM/TestProductionLLM.scss | 28 ++ GUI/src/pages/TestProductionLLM/index.tsx | 260 ++++++++++-------- GUI/translations/en/common.json | 15 + GUI/translations/et/common.json | 15 + 8 files changed, 337 insertions(+), 237 deletions(-) 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/src/components/MessageContent/MessageContent.scss b/GUI/src/components/MessageContent/MessageContent.scss index 7b4eea5c..aec56409 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); + } } } diff --git a/GUI/src/components/MessageContent/index.tsx b/GUI/src/components/MessageContent/index.tsx index 63ff7f2a..69b7ffe3 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; diff --git a/GUI/src/hooks/useStreamingResponse.tsx b/GUI/src/hooks/useStreamingResponse.tsx index 211d44f5..8a9d7792 100644 --- a/GUI/src/hooks/useStreamingResponse.tsx +++ b/GUI/src/hooks/useStreamingResponse.tsx @@ -1,6 +1,19 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import axios from 'axios'; +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; conversationHistory: Array<{ authorRole: string; message: string; timestamp: string }>; @@ -50,7 +63,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); @@ -102,7 +115,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, { diff --git a/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss b/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss index 1bd8e0f1..df51e327 100644 --- a/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss +++ b/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss @@ -77,6 +77,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..d978ba16 100644 --- a/GUI/src/pages/TestProductionLLM/index.tsx +++ b/GUI/src/pages/TestProductionLLM/index.tsx @@ -1,153 +1,169 @@ -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 './TestProductionLLM.scss'; - +import MessageContent from 'components/MessageContent'; interface Message { id: string; content: string; isUser: boolean; timestamp: string; + hasError?: boolean; + errorMessage?: string; } 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]); + }); + }; - // 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); + } catch (error) { + console.error('[Component] Failed to start streaming:', error); + // Reset loading state if streaming fails to start setIsLoading(false); } }; @@ -161,10 +177,11 @@ const TestProductionLLM: FC = () => { 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 +189,9 @@ const TestProductionLLM: FC = () => {
-

{t('Test Production LLM')}

+

{t('testProductionLLM.title')}

@@ -182,8 +199,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 +209,21 @@ 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.hasError && ( +
+ ⚠️ + + {t('testProductionLLM.incompleteMessageError', { defaultValue: 'This message is incomplete due to an error' })} + {msg.errorMessage && `: ${msg.errorMessage}`} + +
+ )}
{new Date(msg.timestamp).toLocaleTimeString()} @@ -220,22 +248,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/translations/en/common.json b/GUI/translations/en/common.json index 8c2cac8a..a71a2f3e 100644 --- a/GUI/translations/en/common.json +++ b/GUI/translations/en/common.json @@ -414,6 +414,21 @@ "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", diff --git a/GUI/translations/et/common.json b/GUI/translations/et/common.json index 1c093b6f..b1030db5 100644 --- a/GUI/translations/et/common.json +++ b/GUI/translations/et/common.json @@ -415,6 +415,21 @@ "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", From 7cf554753ed17f4b65acd24d7a4b36a8b0002a37 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 11:49:50 +0530 Subject: [PATCH 023/105] data enrichment pipeline for service module partially completed --- DSL/CronManager/DSL/service_enrichment.yml | 5 + DSL/CronManager/script/service_enrichment.sh | 110 + .../rag-search/POST/services/enrich.yml | 116 + docker-compose-ec2.yml | 3 +- docker-compose.yml | 3 +- docs/SERVICE_DATA_ENRICHMENT_SPEC.md | 980 +++++++++ docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md | 1940 +++++++++++++++++ enrich.yml.backup | 157 ++ src/data_enrichment/__init__.py | 23 + src/data_enrichment/api_client.py | 191 ++ src/data_enrichment/constants.py | 46 + src/data_enrichment/main_enrichment.py | 198 ++ src/data_enrichment/models.py | 40 + src/data_enrichment/qdrant_manager.py | 174 ++ 14 files changed, 3984 insertions(+), 2 deletions(-) create mode 100644 DSL/CronManager/DSL/service_enrichment.yml create mode 100644 DSL/CronManager/script/service_enrichment.sh create mode 100644 DSL/Ruuter.public/rag-search/POST/services/enrich.yml create mode 100644 docs/SERVICE_DATA_ENRICHMENT_SPEC.md create mode 100644 docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md create mode 100644 enrich.yml.backup create mode 100644 src/data_enrichment/__init__.py create mode 100644 src/data_enrichment/api_client.py create mode 100644 src/data_enrichment/constants.py create mode 100644 src/data_enrichment/main_enrichment.py create mode 100644 src/data_enrichment/models.py create mode 100644 src/data_enrichment/qdrant_manager.py 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/service_enrichment.sh b/DSL/CronManager/script/service_enrichment.sh new file mode 100644 index 00000000..eaaca313 --- /dev/null +++ b/DSL/CronManager/script/service_enrichment.sh @@ -0,0 +1,110 @@ +#!/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/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/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..." +echo "[DEBUG] examples env var (base64): $examples" +echo "[DEBUG] entities env var (base64): $entities" + +# Write JSON arrays to temporary files to avoid bash parsing issues +# Arrays are base64 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 + echo "$examples" | base64 -d > "$EXAMPLES_FILE" + echo "[DEBUG] Decoded and written examples to: $EXAMPLES_FILE" + echo "[DEBUG] File content:" + cat "$EXAMPLES_FILE" +fi + +if [ -n "$entities" ]; then + echo "$entities" | base64 -d > "$ENTITIES_FILE" + echo "[DEBUG] Decoded and written entities to: $ENTITIES_FILE" + echo "[DEBUG] File content:" + cat "$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") + +echo "[COMMAND] python3 -u ${PYTHON_ARGS[@]}" + +# 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" + +echo "[DEBUG] Python execution completed with exit code: $PYTHON_EXIT_CODE" + +# 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/Ruuter.public/rag-search/POST/services/enrich.yml b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml new file mode 100644 index 00000000..16213a99 --- /dev/null +++ b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml @@ -0,0 +1,116 @@ +declaration: + call: declare + version: 0.1 + description: "Enrich service data and index in Qdrant" + 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: ${JSON.stringify(incoming.body.examples) || '[]'} + service_entities: ${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 + next: end + +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 + next: assign_success + on_error: handle_error + +handle_error: + log: "ERROR: Service enrichment failed - ${enrichment_result.error || 'Unknown error'}" + next: assign_failure + +assign_success: + assign: + response_data: + success: true + service_id: ${service_id} + message: "Service enriched and indexed successfully" + details: ${enrichment_result.response.body} + next: return_ok + +assign_failure: + assign: + response_data: + success: false + error: "ENRICHMENT_FAILED" + message: "Failed to enrich and index service" + details: ${enrichment_result.response.body || enrichment_result.error} + next: return_server_error + +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/docker-compose-ec2.yml b/docker-compose-ec2.yml index 26c19068..c6b8819b 100644 --- a/docker-compose-ec2.yml +++ b/docker-compose-ec2.yml @@ -179,6 +179,7 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer + - ./src/data_enrichment:/app/src/data_enrichment - ./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 +188,7 @@ services: - ./.env:/app/.env:ro environment: - server.port=9010 - - PYTHONPATH=/app:/app/src/vector_indexer + - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/data_enrichment - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 8a9d119e..5ac933ed 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -178,6 +178,7 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer + - ./src/data_enrichment:/app/src/data_enrichment - ./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 +187,7 @@ services: - ./.env:/app/.env:ro environment: - server.port=9010 - - PYTHONPATH=/app:/app/src/vector_indexer + - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/data_enrichment - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 diff --git a/docs/SERVICE_DATA_ENRICHMENT_SPEC.md b/docs/SERVICE_DATA_ENRICHMENT_SPEC.md new file mode 100644 index 00000000..3f6fc44d --- /dev/null +++ b/docs/SERVICE_DATA_ENRICHMENT_SPEC.md @@ -0,0 +1,980 @@ +# Service Data Enrichment Pipeline - Specification + +**Version**: 1.0 +**Date**: February 19, 2026 +**Status**: Specification + +--- + +## 1. Overview + +### 1.1 Purpose + +This specification defines the **Service Data Enrichment Pipeline** - a system that automatically enriches service metadata and indexes it in Qdrant for intent classification in the Tool Classifier workflow. + +### 1.2 Goals + +- **Enrich service data** with LLM-generated context (synonyms, related terms, alternate phrasings) +- **Index enriched data** in Qdrant's `intent_collection` for semantic search +- **Maintain synchronization** between PostgreSQL services table and Qdrant +- **Provide API endpoint** for manual triggering of enrichment + +### 1.3 Architecture Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Service Creation (External System) │ +│ - Admin UI / Management API │ +│ - Inserts service record into PostgreSQL services table │ +│ - Calls enrichment endpoint WITH service data │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. Enrichment API Call │ +│ POST /rag-search/services/enrich │ +│ Body: { service_id, name, description, examples, ... } │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. Ruuter Endpoint (DSL) │ +│ - Validates request payload │ +│ - Calls CronManager with service data │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. CronManager Execution │ +│ - Executes: script/service_enrichment.sh │ +│ - Environment: service_id, service_data (JSON) │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 5. Python Enrichment Script │ +│ src/service_enrichment/enrich_and_index.py │ +│ - Parse service data │ +│ - Call LLM to generate enriched context │ +│ - Construct embedding text │ +│ - Generate vector embedding (OpenAI text-embedding-3-large) │ +│ - Upsert document into Qdrant intent_collection │ +└────────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 6. Response │ +│ - Success: { success: true, service_id: "...", ... } │ +│ - Error: { success: false, error: "...", ... } │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.4 Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Trigger Mechanism | **Manual API call** | Clean separation, explicit control, easier debugging | +| Enrichment Strategy | **LLM-based expansion** | Generates high-quality synonyms and variations | +| Execution Mode | **Synchronous** | Guarantees service is indexed before returning | +| Qdrant Operation | **Upsert** | Idempotent, handles updates gracefully | +| Error Handling | **Graceful fallback** | Store original data if enrichment fails | + +--- + +## 2. Components + +### 2.1 Database Schema (Already Exists) + +**Table**: `public.services` + +```sql +CREATE TABLE public.services ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + service_id TEXT NOT NULL UNIQUE, + ruuter_type ruuter_request_type DEFAULT 'GET', + current_state service_state DEFAULT 'draft', + is_common BOOLEAN NOT NULL DEFAULT FALSE, + slot TEXT NOT NULL DEFAULT '', + entities text[] NOT NULL DEFAULT '{}', + examples text[] NOT NULL DEFAULT '{}', + structure JSON NOT NULL DEFAULT '{}', + endpoints JSON NOT NULL DEFAULT '[]', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +``` + +**Note**: The enrichment endpoint receives service data directly from the caller, so no Resql query is needed. + +### 2.2 Ruuter Endpoint (NEW) + +**File**: `DSL/Ruuter.public/rag-search/POST/services/enrich.yml` + +```yaml +declaration: + call: declare + version: 0.1 + description: "Enrich service data and index in Qdrant" + method: post + accepts: json + returns: json + namespace: rag-search + allowlist: + body: + - field: service_id + type: string + description: "Unique service identifier" + required: true + - field: name + type: string + description: "Service name" + required: true + - field: description + type: string + description: "Service description" + required: true + - field: examples + type: array + description: "Example queries" + required: false + - field: entities + type: array + description: "Expected entity names" + required: false + - field: ruuter_type + type: string + description: "HTTP method (GET/POST)" + required: false + - field: current_state + type: string + description: "Service state (active/inactive/draft)" + required: false + - field: is_common + type: boolean + description: "Is common service" + required: false + +validate_request: + assign: + service_id: ${incoming.body.service_id} + service_name: ${incoming.body.name} + service_description: ${incoming.body.description} + next: check_required_fields + +check_required_fields: + switch: + - condition: ${!service_id || service_id.trim() === ''} + next: return_missing_service_id + - condition: ${!service_name || service_name.trim() === ''} + next: return_missing_name + - condition: ${!service_description || service_description.trim() === ''} + next: return_missing_description + - condition: true + next: prepare_service_data + +return_missing_service_id: + status: 400 + return: + success: false + error: "MISSING_SERVICE_ID" + message: "service_id is required" + next: end + +return_missing_name: + status: 400 + return: + success: false + error: "MISSING_NAME" + message: "name is required" + next: end + +return_missing_description: + status: 400 + return: + success: false + error: "MISSING_DESCRIPTION" + message: "description is required" + next: end + +prepare_service_data: + assign: + service_data: + service_id: ${service_id} + name: ${service_name} + description: ${service_description} + examples: ${incoming.body.examples || []} + entities: ${incoming.body.entities || []} + ruuter_type: ${incoming.body.ruuter_type || 'GET'} + current_state: ${incoming.body.current_state || 'draft'} + is_common: ${incoming.body.is_common || false} + service_json: ${JSON.stringify(service_data)} + log: "Enriching service: ${service_id}" + next: execute_enrichment + +execute_enrichment: + call: http.post + args: + url: "[#RAG_SEARCH_CRON_MANAGER]/execute/service_enrichment/enrich_and_index" + query: + service_id: ${service_id} + service_data: ${service_json} + result: enrichment_result + next: check_enrichment_success + +check_enrichment_success: + switch: + - condition: ${enrichment_result.response.status >= 200 && enrichment_result.response.status < 300} + next: return_success + - condition: true + next: return_enrichment_error + +return_success: + status: 200 + return: + success: true + service_id: ${service_id} + message: "Service enriched and indexed successfully" + enrichment_details: ${enrichment_result.response.body} + next: end + +return_enrichment_error: + status: 500 + return: + success: false + error: "ENRICHMENT_FAILED" + message: "Failed to enrich and index service" + details: ${enrichment_result.response.body || enrichment_result.error} + next: end +``` + +### 2.3 CronManager DSL (NEW) + +**File**: `DSL/CronManager/DSL/service_enrichment.yml` + +```yaml +enrich_and_index: + trigger: off + type: exec + command: "../app/scripts/service_enrichment.sh" + allowedEnvs: ['service_id', 'service_data'] +``` + +### 2.4 Shell Script (NEW) + +**File**: `DSL/CronManager/script/service_enrichment.sh` + +```bash +#!/bin/bash + +echo "[SERVICE_ENRICHMENT] Starting service enrichment pipeline..." + +# Validate required environment variables +if [ -z "$service_id" ] || [ -z "$service_data" ]; then + echo "[ERROR] Missing required environment variables: service_id and service_data" + exit 1 +fi + +PYTHON_SCRIPT="/app/src/service_enrichment/enrich_and_index.py" + +echo "[INFO] Enriching service: $service_id" + +# 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" "openai>=1.12.0" || 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" "pydantic>=2.11.7" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "loguru>=0.7.3" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "python-dotenv>=1.0.0" || exit 1 + +echo "[PACKAGES] All packages installed successfully" + +# Set Python path +export PYTHONPATH="/app:/app/src:$PYTHONPATH" + +# Check if script exists +if [ ! -f "$PYTHON_SCRIPT" ]; then + echo "[ERROR] Python script not found: $PYTHON_SCRIPT" + exit 1 +fi + +# Execute Python enrichment script +echo "[EXECUTION] Running enrichment script..." +python3 "$PYTHON_SCRIPT" \ + --service-id "$service_id" \ + --service-data "$service_data" + +exit_code=$? + +if [ $exit_code -eq 0 ]; then + echo "[SUCCESS] Service enrichment completed successfully" +else + echo "[ERROR] Service enrichment failed with exit code: $exit_code" +fi + +exit $exit_code +``` + +### 2.5 Python Enrichment Script (NEW) + +**File**: `src/service_enrichment/enrich_and_index.py` + +This is the core enrichment logic - will be detailed in Section 3. + +--- + +## 3. Enrichment Logic + +### 3.1 Enrichment Prompt + +The LLM receives the service data and generates enriched context. + +**Prompt Template:** + +```python +ENRICHMENT_PROMPT = """ +You are a service intent enrichment assistant. Your task is to expand and enhance service descriptions to improve semantic search and intent classification. + +Given a service description, generate: +1. **Synonyms**: Alternative words/phrases with the same meaning +2. **Related Terms**: Contextually related concepts +3. **Query Variations**: Different ways users might ask for this service +4. **Entity Context**: Additional context about expected entities + +--- + +SERVICE INFORMATION: +- Name: {service_name} +- Description: {service_description} +- Examples: {service_examples} +- Expected Entities: {service_entities} + +--- + +TASK: +Generate enriched context that will help match user queries to this service. + +RESPONSE FORMAT (JSON only): +{{ + "synonyms": ["synonym1", "synonym2", ...], + "related_terms": ["term1", "term2", ...], + "query_variations": ["variation1", "variation2", ...], + "entity_context": {{ + "entity_name_1": "brief description of what this entity represents", + "entity_name_2": "brief description of what this entity represents" + }} +}} + +GUIDELINES: +- Focus on semantic similarity and user intent +- Include common misspellings and colloquialisms if relevant +- Keep language natural and conversational +- Generate 5-10 items per category +- Maintain consistency with the service's actual purpose +- Output ONLY valid JSON, no explanations + +EXAMPLE OUTPUT: +{{ + "synonyms": ["exchange rate", "currency conversion", "forex rate"], + "related_terms": ["EUR to USD", "currency pair", "forex", "money exchange"], + "query_variations": [ + "what is the current exchange rate", + "convert EUR to USD", + "how much is one euro in dollars", + "EUR USD rate today" + ], + "entity_context": {{ + "from_currency": "The currency code to convert from (e.g., EUR, USD, GBP)", + "to_currency": "The currency code to convert to (e.g., EUR, USD, GBP)" + }} +}} +""" +``` + +### 3.2 Embedding Text Construction + +After enrichment, construct the final text for embedding: + +```python +def construct_enriched_embedding_text( + service_data: Dict, + enrichment: Dict +) -> str: + """ + Construct embedding text from service data and enriched context. + + Format: + - Original description + - Original examples + - Enriched synonyms + - Enriched related terms + - Enriched query variations + - Entity context + + All sections newline-separated for optimal embedding. + """ + parts = [] + + # Original description + parts.append(service_data['description']) + + # Original examples + if service_data.get('examples'): + parts.extend(service_data['examples']) + + # Enriched synonyms + if enrichment.get('synonyms'): + parts.extend(enrichment['synonyms']) + + # Enriched related terms + if enrichment.get('related_terms'): + parts.extend(enrichment['related_terms']) + + # Enriched query variations + if enrichment.get('query_variations'): + parts.extend(enrichment['query_variations']) + + # Entity context (formatted as descriptions) + if enrichment.get('entity_context'): + for entity, context in enrichment['entity_context'].items(): + parts.append(f"{entity}: {context}") + + return "\n".join(parts) +``` + +**Example Output:** + +```text +Kasutaja soovib infot ettevõtte poolt tasutud tööjõumaksude kohta, näiteks palgamaksud ja sotsiaalmaks. +ettevõtte tasutud tööjõumaksud +kui palju maksis ettevõte tööjõumakse +firma poolt tasutud tööjõumaksud +salary taxes +payroll taxes +workforce contributions +employer taxes +labor costs +social security contributions +payroll expenses +employee-related taxes +company tax obligations +kuidas palju tööjõumakse +ettevõtte maksud töötajate eest +tööjõukulud maksud +company_name: The registered name of the company or business registry code +tax_period: The time period for which tax information is requested (e.g., year, quarter) +``` + +### 3.3 Qdrant Document Structure + +**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...", + "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": "... (full enriched text from section 3.2) ...", + "service_id": "common_service_companies_workforce_taxes", + "ruuter_type": "POST", + "current_state": "active", + "is_enriched": true, + "enriched_at": "2026-02-19T10:30:00Z", + "enrichment_version": "1.0" +} +``` + +### 3.4 Fallback Strategy + +If LLM enrichment fails: + +```python +def construct_fallback_embedding_text(service_data: Dict) -> str: + """ + Fallback: Use original data without enrichment. + """ + parts = [service_data['description']] + parts.extend(service_data.get('examples', [])) + return "\n".join(parts) +``` + +--- + +## 4. Python Script Implementation + +### 4.1 Script Structure + +``` +src/service_enrichment/ +├── __init__.py +├── enrich_and_index.py # Main script (CLI entry point) +├── enrichment_service.py # LLM enrichment logic +├── qdrant_indexer.py # Qdrant upsert logic +└── models.py # Pydantic models +``` + +### 4.2 Main Script Flow + +```python +""" +Main enrichment script: enrich_and_index.py + +Steps: +1. Parse command-line arguments (service_id, service_data JSON) +2. Load configuration (LLM connection, Qdrant connection) +3. Call LLM to enrich service data +4. Construct enriched embedding text +5. Generate vector embedding (OpenAI text-embedding-3-large) +6. Upsert document into Qdrant intent_collection +7. Return success/failure response +""" + +import sys +import json +import argparse +from typing import Dict, Optional +from loguru import logger + +# Configure logger +logger.remove() +logger.add(sys.stdout, level="INFO") +logger.add(sys.stderr, level="ERROR") + + +def main(): + parser = argparse.ArgumentParser(description="Enrich and index service data") + parser.add_argument("--service-id", required=True) + parser.add_argument("--service-data", required=True) + args = parser.parse_args() + + try: + # Parse service data + service_data = json.loads(args.service_data) + logger.info(f"Processing service: {args.service_id}") + + # Load configuration + config = load_configuration() + + # Initialize components + enrichment_service = EnrichmentService(config) + qdrant_indexer = QdrantIndexer(config) + + # Step 1: Enrich service data + enrichment = enrichment_service.enrich(service_data) + + # Step 2: Construct embedding text + embedding_text = construct_enriched_embedding_text( + service_data, enrichment + ) + + # Step 3: Generate vector embedding + embedding_vector = enrichment_service.generate_embedding( + embedding_text + ) + + # Step 4: Prepare Qdrant document + qdrant_doc = prepare_qdrant_document( + service_data, embedding_text, enrichment + ) + + # Step 5: Upsert into Qdrant + qdrant_indexer.upsert( + collection_name="intent_collection", + document_id=args.service_id, + vector=embedding_vector, + payload=qdrant_doc + ) + + logger.info(f"Successfully enriched and indexed service: {args.service_id}") + + # Output success JSON + print(json.dumps({ + "success": True, + "service_id": args.service_id, + "enriched": True, + "embedding_dimension": len(embedding_vector) + })) + + except Exception as e: + logger.error(f"Enrichment failed: {e}") + print(json.dumps({ + "success": False, + "error": str(e), + "service_id": args.service_id + })) + sys.exit(1) + + +if __name__ == "__main__": + main() +``` + +--- + +## 5. Configuration + +### 5.1 Required Environment Variables + +```bash +# LLM Configuration (OpenAI) +OPENAI_API_KEY=sk-... +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-4o-mini # For enrichment LLM calls +OPENAI_EMBEDDING_MODEL=text-embedding-3-large + +# Qdrant Configuration +QDRANT_HOST=localhost +QDRANT_PORT=6333 +QDRANT_API_KEY= # Optional + +# Intent Collection +INTENT_COLLECTION_NAME=intent_collection +INTENT_COLLECTION_DIMENSION=3072 # text-embedding-3-large dimension +``` + +### 5.2 LLM Connection + +Use existing `llm_connections` table or Vault integration: + +```python +# Option 1: Fetch from llm_connections table +connection = fetch_production_connection(connection_type="openai") + +# Option 2: Use environment variables directly +config = { + "api_key": os.getenv("OPENAI_API_KEY"), + "base_url": os.getenv("OPENAI_BASE_URL"), + "model": os.getenv("OPENAI_MODEL", "gpt-4o-mini"), + "embedding_model": os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-large") +} +``` + +--- + +## 6. API Usage Examples + +### 6.1 Enrich Single Service + +```bash +POST http://localhost:8080/rag-search/services/enrich +Content-Type: application/json + +{ + "service_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"], + "ruuter_type": "POST", + "current_state": "active", + "is_common": true +} +``` + +**Response (Success):** + +```json +{ + "success": true, + "service_id": "common_service_companies_workforce_taxes", + "message": "Service enriched and indexed successfully", + "enrichment_details": { + "success": true, + "service_id": "common_service_companies_workforce_taxes", + "enriched": true, + "embedding_dimension": 3072 + } +} +``` + +**Response (Error - Missing Required Field):** + +```json +{ + "success": false, + "error": "MISSING_DESCRIPTION", + "message": "description is required" +} +``` + +### 6.2 Integration with Service Creation + +When creating a service via admin API: + +```python +# Step 1: Insert service into PostgreSQL +service_data = { + "service_id": "exchange-rate-001", + "name": "Exchange Rate Service", + "description": "Returns current exchange rate between two currencies", + "examples": ["EUR to USD rate", "convert EUR to USD"], + "entities": ["from_currency", "to_currency"], + "ruuter_type": "GET", + "current_state": "active", + "is_common": False +} +insert_service_into_db(service_data) + +# Step 2: Trigger enrichment (pass service_data directly) +response = requests.post( + "http://localhost:8080/rag-search/services/enrich", + json=service_data # Send complete service data +) + +if response.json()["success"]: + logger.info(f"Service {service_data['service_id']} enriched and indexed") +else: + logger.error(f"Enrichment failed: {response.json()['error']}") +``` + +--- + +## 7. Error Handling + +### 7.1 Error Scenarios + +| Scenario | HTTP Status | Error Code | Response | +|----------|-------------|------------|----------| +| Missing service_id | 400 | `MISSING_SERVICE_ID` | `{"success": false, "error": "MISSING_SERVICE_ID", ...}` | +| Missing name | 400 | `MISSING_NAME` | `{"success": false, "error": "MISSING_NAME", ...}` | +| Missing description | 400 | `MISSING_DESCRIPTION` | `{"success": false, "error": "MISSING_DESCRIPTION", ...}` | +| LLM enrichment failed | 500 | `LLM_ENRICHMENT_FAILED` | Fallback to original data | +| Embedding generation failed | 500 | `EMBEDDING_FAILED` | `{"success": false, "error": "EMBEDDING_FAILED", ...}` | +| Qdrant upsert failed | 500 | `INDEXING_FAILED` | `{"success": false, "error": "INDEXING_FAILED", ...}` | + +### 7.2 Retry Strategy + +```python +# Retry LLM enrichment (max 3 attempts) +for attempt in range(3): + try: + enrichment = llm_enrichment_service.enrich(service_data) + break + except Exception as e: + if attempt == 2: + logger.warning("LLM enrichment failed, using fallback") + enrichment = {} # Use fallback + else: + time.sleep(2 ** attempt) # Exponential backoff + +# No retry for Qdrant (immediate failure) +``` + +--- + +## 8. Monitoring & Logging + +### 8.1 Log Events + +```python +# Key log events +logger.info(f"[ENRICHMENT_START] service_id={service_id}") +logger.info(f"[LLM_ENRICHMENT] Generated {len(enrichment['synonyms'])} synonyms") +logger.info(f"[EMBEDDING] Dimension: {len(embedding_vector)}") +logger.info(f"[QDRANT_UPSERT] Collection: intent_collection, ID: {service_id}") +logger.info(f"[ENRICHMENT_SUCCESS] service_id={service_id}, duration={duration}ms") +logger.error(f"[ENRICHMENT_FAILED] service_id={service_id}, error={error}") +``` + +### 8.2 Metrics to Track + +```python +# Future: Add metrics collection +metrics = { + "total_services_enriched": 0, + "enrichment_failures": 0, + "average_enrichment_time_ms": 0, + "llm_token_usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_cost_usd": 0.0 + } +} +``` + +--- + +## 9. Testing Strategy + +### 9.1 Unit Tests + +```python +# Test enrichment logic +def test_construct_enriched_embedding_text(): + service_data = {...} + enrichment = {...} + result = construct_enriched_embedding_text(service_data, enrichment) + assert "synonyms" in result + assert "related_terms" in result + +# Test fallback strategy +def test_fallback_on_llm_failure(): + service_data = {...} + result = construct_fallback_embedding_text(service_data) + assert service_data['description'] in result +``` + +### 9.2 Integration Tests + +```bash +# Test full pipeline +curl -X POST http://localhost:8080/rag-search/services/enrich \ + -H "Content-Type: application/json" \ + -d '{"service_id": "test-service-001"}' + +# Verify in Qdrant +curl http://localhost:6333/collections/intent_collection/points/test-service-001 +``` + +### 9.3 Test Service Data + +```sql +-- Insert test service +INSERT INTO public.services ( + service_id, name, description, examples, entities, ruuter_type, current_state +) VALUES ( + 'test-service-001', + 'Test Exchange Rate Service', + 'Returns current exchange rate between two currencies', + ARRAY['EUR to USD rate', 'convert EUR to USD'], + ARRAY['from_currency', 'to_currency'], + 'GET', + 'active' +); +``` + +--- + +## 10. Implementation Checklist + +### Phase 1: Ruuter Endpoint +- [ ] Create Ruuter endpoint: `services/enrich.yml` +- [ ] Test endpoint with mock service data payload +- [ ] Verify error handling (missing fields, validation) + +### Phase 2: CronManager Configuration +- [ ] Create CronManager DSL: `service_enrichment.yml` +- [ ] Create shell script: `service_enrichment.sh` +- [ ] Test shell script execution manually + +### Phase 3: Python Enrichment Script +- [ ] Create module structure: `src/service_enrichment/` +- [ ] Implement `enrich_and_index.py` (main script) +- [ ] Implement `enrichment_service.py` (LLM logic) +- [ ] Implement `qdrant_indexer.py` (Qdrant operations) +- [ ] Implement `models.py` (Pydantic models) +- [ ] Add unit tests + +### Phase 4: Integration Testing +- [ ] Test full pipeline end-to-end +- [ ] Verify Qdrant documents match schema +- [ ] Test error scenarios (missing fields, LLM failure, etc.) +- [ ] Test fallback strategy + +### Phase 5: Documentation +- [ ] Add API endpoint to `endpoints.md` +- [ ] Update README with enrichment pipeline section +- [ ] Create usage examples + +--- + +## 11. Future Enhancements + +### 11.1 Batch Enrichment + +```bash +POST /rag-search/services/enrich-batch +{ + "service_ids": [ + "service-001", + "service-002", + "service-003" + ] +} +``` + +### 11.2 Re-enrichment Strategy + +```bash +# Re-enrich all active services +POST /rag-search/services/re-enrich-all +{ + "force": true, # Re-enrich even if already enriched + "state_filter": "active" # Only active services +} +``` + +### 11.3 Enrichment Quality Scoring + +Track enrichment quality metrics: + +```python +quality_score = { + "synonym_diversity": 0.85, # Unique vs. total synonyms + "query_variation_coverage": 0.90, # Coverage of expected queries + "embedding_quality": 0.88 # Cosine similarity to original +} +``` + +--- + +## 12. Open Questions + +1. **LLM Model Choice**: Should we use `gpt-4o-mini` for cost efficiency or `gpt-4o` for better quality? + - **Recommendation**: Start with `gpt-4o-mini`, monitor quality, upgrade if needed + +2. **Enrichment Versioning**: Should we track enrichment versions for re-enrichment? + - **Recommendation**: Add `enrichment_version` field to Qdrant payload + +3. **Multi-language Support**: Should enrichment handle Estonian vs. English differently? + - **Recommendation**: Single prompt works for both, LLM detects language automatically + +4. **Caching Strategy**: Should we cache enriched data in PostgreSQL? + - **Recommendation**: No - Qdrant is the source of truth for enriched data + +--- + +## 13. Summary + +This specification defines a **synchronous, LLM-based service enrichment pipeline** that: + +1. ✅ Receives service data via REST API +2. ✅ Enriches data using LLM (synonyms, related terms, query variations) +3. ✅ Generates vector embeddings using OpenAI text-embedding-3-large +4. ✅ Upserts enriched documents into Qdrant intent_collection +5. ✅ Provides graceful fallback if enrichment fails +6. ✅ Returns synchronous success/error response + +**Next Step**: Await user confirmation before implementation. + +--- diff --git a/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md b/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md new file mode 100644 index 00000000..e198114a --- /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_dict["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_dict["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_dict: 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_dict["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_dict["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_dict: 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_dict["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_dict["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_dict + ) + if result: + return result # Context-based answer (validated) + else: + # Move to Layer 3 (RAG) + return await execute_rag_workflow(request, components, costs_dict) +except Exception as e: + logger.error(f"Context workflow failed: {e}") + # Fallback to RAG workflow + return await execute_rag_workflow(request, components, costs_dict) +``` + +**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_dict) +``` + +--- + +## 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_dict, timing_dict +) +``` + +**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_dict["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_dict["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_dict["context_check +costs_dict["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_dict: 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_dict["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 | + +--- \ No newline at end of file diff --git a/enrich.yml.backup b/enrich.yml.backup new file mode 100644 index 00000000..28cd5b31 --- /dev/null +++ b/enrich.yml.backup @@ -0,0 +1,157 @@ +declaration: + call: declare + version: 0.1 + description: "Enrich service data and index in Qdrant" + 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" + +validate_request: + assign: + service_id: ${incoming.body.service_id} + service_name: ${incoming.body.name} + service_description: ${incoming.body.description} + next: check_required_fields + +check_required_fields: + switch: + - condition: ${!service_id} + next: assign_missing_service_id_error + - condition: ${!service_name} + next: assign_missing_name_error + - condition: ${!service_description} + next: assign_missing_description_error + next: prepare_service_data + +assign_missing_service_id_error: + assign: + error_response: { + success: false, + error: "MISSING_SERVICE_ID", + message: "service_id is required" + } + next: return_missing_service_id + +return_missing_service_id: + status: 400 + return: ${error_response} + next: end + +assign_missing_name_error: + assign: + error_response: { + success: false, + error: "MISSING_NAME", + message: "name is required" + } + next: return_missing_name + +return_missing_name: + status: 400 + return: ${error_response} + next: end + +assign_missing_description_error: + assign: + error_response: { + success: false, + error: "MISSING_DESCRIPTION", + message: "description is required" + } + next: return_missing_description + +return_missing_description: + status: 400 + return: ${error_response} + next: end + +prepare_service_data: + assign: + service_data: { + service_id: ${service_id}, + name: ${service_name}, + description: ${service_description}, + examples: ${incoming.body.examples || []}, + entities: ${incoming.body.entities || []}, + ruuter_type: ${incoming.body.ruuter_type || 'GET'}, + current_state: ${incoming.body.current_state || 'draft'}, + is_common: ${incoming.body.is_common || false} + } + next: stringify_service_data + +stringify_service_data: + assign: + service_json: ${JSON.stringify(service_data)} + next: execute_enrichment + +execute_enrichment: + call: http.post + args: + url: "[#RAG_SEARCH_CRON_MANAGER]/execute/service_enrichment/enrich_and_index" + query: + service_id: ${service_id} + service_data: ${service_json} + result: enrichment_result + next: assign_success_response + on_error: handle_enrichment_error + +handle_enrichment_error: + log: "ERROR: Service enrichment failed - ${enrichment_result.error || 'Unknown error'}" + next: assign_error_response + +assign_success_response: + assign: + success_response: { + success: true, + service_id: ${service_id}, + message: "Service enriched and indexed successfully", + enrichment_details: ${enrichment_result.response.body} + } + next: return_success + +assign_error_response: + assign: + error_response: { + success: false, + error: "ENRICHMENT_FAILED", + message: "Failed to enrich and index service", + details: ${enrichment_result.response.body || enrichment_result.error} + } + next: return_enrichment_error + +return_success: + status: 200 + return: ${success_response} + next: end + +return_enrichment_error: + status: 500 + return: ${error_response} + next: end diff --git a/src/data_enrichment/__init__.py b/src/data_enrichment/__init__.py new file mode 100644 index 00000000..2cb4ccff --- /dev/null +++ b/src/data_enrichment/__init__.py @@ -0,0 +1,23 @@ +""" +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 data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult +from data_enrichment.api_client import LLMAPIClient +from data_enrichment.qdrant_manager import QdrantManager +from data_enrichment.constants import EnrichmentConstants + +__all__ = [ + "ServiceData", + "EnrichedService", + "EnrichmentResult", + "LLMAPIClient", + "QdrantManager", + "EnrichmentConstants", +] + diff --git a/src/data_enrichment/api_client.py b/src/data_enrichment/api_client.py new file mode 100644 index 00000000..ee8f0af7 --- /dev/null +++ b/src/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 data_enrichment.constants import EnrichmentConstants +from 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/data_enrichment/constants.py b/src/data_enrichment/constants.py new file mode 100644 index 00000000..0b44d438 --- /dev/null +++ b/src/data_enrichment/constants.py @@ -0,0 +1,46 @@ +"""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" + + # 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 + +Answer only with the enriched context and nothing else.""" diff --git a/src/data_enrichment/main_enrichment.py b/src/data_enrichment/main_enrichment.py new file mode 100644 index 00000000..f45c6a66 --- /dev/null +++ b/src/data_enrichment/main_enrichment.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Service Data Enrichment Script + +This script receives service data, enriches it with LLM-generated context, +creates embeddings, and stores in Qdrant intent_collections. +""" + +import sys +import json +import argparse +import asyncio +from datetime import datetime +from loguru import logger + +from data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult +from data_enrichment.api_client import LLMAPIClient +from data_enrichment.qdrant_manager import QdrantManager + + +def log_separator(title: str = "") -> None: + """Log a formatted separator line""" + separator = "=" * 80 + if title: + logger.info(f"\n{separator}") + logger.info(f" {title}") + logger.info(f"{separator}\n") + else: + logger.info(f"\n{separator}\n") + + +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) + logger.debug(f"Loaded {len(examples)} examples from file") + 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) + logger.debug(f"Loaded {len(entities)} entities from file") + 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 embedding, store in Qdrant. + + Args: + service_data: Service data to enrich + + Returns: + EnrichmentResult with success/failure information + """ + try: + # Step 1: Generate rich context using LLM + 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 embedding for the context + logger.info("Step 2: Creating embedding vector") + embedding = await api_client.create_embedding(context) + logger.success(f"Embedding created: {len(embedding)}-dimensional vector") + + # Step 3: Prepare enriched service + enriched_service = 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=embedding, + ) + + # Step 4: Store in Qdrant + logger.info("Step 3: Storing in Qdrant") + qdrant = QdrantManager() + qdrant.connect() + qdrant.ensure_collection() + + success = qdrant.upsert_service(enriched_service) + qdrant.close() + + if success: + return EnrichmentResult( + success=True, + service_id=service_data.service_id, + message=f"Service '{service_data.name}' enriched and indexed successfully", + context_length=len(context), + embedding_dimension=len(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""" + log_separator("SERVICE DATA ENRICHMENT PIPELINE") + logger.info(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + logger.info(f"Python: {sys.version}") + + try: + # Parse arguments + log_separator("STEP 1: PARSING INPUT") + 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 + log_separator("STEP 2: ENRICHMENT PIPELINE") + result = asyncio.run(enrich_service(service_data)) + + # Log results + log_separator("ENRICHMENT RESULT") + 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}") + log_separator() + 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}") + log_separator() + return 1 + + except Exception as e: + logger.error(f"Fatal error: {e}") + log_separator("ENRICHMENT FAILED") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/data_enrichment/models.py b/src/data_enrichment/models.py new file mode 100644 index 00000000..04ca1b5a --- /dev/null +++ b/src/data_enrichment/models.py @@ -0,0 +1,40 @@ +"""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.""" + + 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="Context embedding vector") + + +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/data_enrichment/qdrant_manager.py b/src/data_enrichment/qdrant_manager.py new file mode 100644 index 00000000..ba3a2c8d --- /dev/null +++ b/src/data_enrichment/qdrant_manager.py @@ -0,0 +1,174 @@ +"""Qdrant manager for intent collections.""" + +import uuid +from typing import Optional +from loguru import logger +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, VectorParams, PointStruct + +from data_enrichment.constants import EnrichmentConstants +from data_enrichment.models import EnrichedService + +# Error messages +_CLIENT_NOT_INITIALIZED = "Qdrant client not initialized" + + +class QdrantManager: + """Manages Qdrant operations for intent collections.""" + + 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}") + # Suppress version compatibility warning (client 1.17.0 vs server 1.15.1) + # Minor version difference is acceptable (see warning in logs) + 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 correct vector size.""" + 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: + # Check if existing collection has correct vector size + collection_info = self.client.get_collection(self.collection_name) + + # Qdrant vectors config is a dict - get the default vector config + vectors_config = collection_info.config.params.vectors + + existing_vector_size: Optional[int] = None + if isinstance(vectors_config, dict): + # Get first vector config (usually the default/unnamed one) + if vectors_config: + vector_params = next(iter(vectors_config.values())) + existing_vector_size = vector_params.size + elif vectors_config is not None: + # Direct VectorParams object (older API) + existing_vector_size = vectors_config.size + + if existing_vector_size is None: + logger.warning(f"Could not determine vector size for '{self.collection_name}', recreating") + self.client.delete_collection(self.collection_name) + self._create_collection() + elif existing_vector_size != EnrichmentConstants.VECTOR_SIZE: + logger.warning( + f"Collection '{self.collection_name}' exists with wrong vector size: " + f"{existing_vector_size} (expected {EnrichmentConstants.VECTOR_SIZE})" + ) + logger.info(f"Deleting and recreating collection '{self.collection_name}'") + self.client.delete_collection(self.collection_name) + self._create_collection() + else: + logger.info( + f"Collection '{self.collection_name}' already exists " + f"with correct vector size ({existing_vector_size})" + ) + 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 correct vector configuration.""" + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + logger.info( + f"Creating collection '{self.collection_name}' " + f"with vector size {EnrichmentConstants.VECTOR_SIZE}" + ) + self.client.create_collection( + collection_name=self.collection_name, + vectors_config=VectorParams( + size=EnrichmentConstants.VECTOR_SIZE, + distance=Distance.COSINE, + ), + ) + logger.success(f"Collection '{self.collection_name}' created successfully") + + def upsert_service(self, enriched_service: EnrichedService) -> bool: + """ + Upsert enriched service to Qdrant (update if exists, insert if new). + + Args: + enriched_service: Enric_CLIENT_NOT_INITIALIZED + + Returns: + True if successful, False otherwise + """ + try: + if not self.client: + raise RuntimeError("Qdrant client not initialized") + + logger.info(f"Upserting service '{enriched_service.id}' to Qdrant") + + # Convert service_id to UUID for Qdrant compatibility + # Qdrant requires point IDs to be either integers or UUIDs + point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, enriched_service.id)) + logger.debug(f"Generated UUID: {point_id} for service_id: {enriched_service.id}") + + # Prepare payload (all metadata except embedding) + payload = { + "service_id": enriched_service.id, # Store original ID in payload + "name": enriched_service.name, + "description": enriched_service.description, + "examples": enriched_service.examples, + "entities": enriched_service.entities, + "context": enriched_service.context, + } + + # Create point with UUID + point = PointStruct( + id=point_id, # ✓ Now using UUID string + vector=enriched_service.embedding, + payload=payload, + ) + + # Upsert to Qdrant + self.client.upsert( + collection_name=self.collection_name, + points=[point], + ) + + logger.success( + f"Successfully upserted service '{enriched_service.id}' " + f"({len(enriched_service.embedding)}-dim vector)" + ) + return True + + except Exception as e: + logger.error(f"Failed to upsert service '{enriched_service.id}': {e}") + return False + + def close(self) -> None: + """Close Qdrant connection.""" + if self.client: + logger.info("Closing Qdrant connection") + self.client.close() From 05f0f94ff6de5ce06d0abf3ffc3594e487bcbe83 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:29:32 +0530 Subject: [PATCH 024/105] Implement multi-layer Tool classification agent workflow routing skeleton with BaseWorkflow abstract class (#318) * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * pre validation step added when user query is inserted * added more validation cases * fixed review comments * implement tool classification orchestration agent skeleton * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed copilot suggested changes * fixed issue * added skills * fixed issue --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Thiru Dinesh --- .github/copilot-instructions.md | 304 ++++++ .github/skills/code-review/SKILL.md | 4 + .gitignore | 1 + docs/TOOL_CLASSIFIER_SKELETON_USAGE.md | 542 ++++++++++ pyproject.toml | 2 +- src/llm_orchestration_service.py | 982 ++++++++++-------- src/llm_orchestration_service_api.py | 10 +- src/llm_orchestrator_config/feature_flags.py | 82 ++ src/tool_classifier/__init__.py | 20 + src/tool_classifier/base_workflow.py | 118 +++ src/tool_classifier/classifier.py | 338 ++++++ src/tool_classifier/enums.py | 39 + src/tool_classifier/models.py | 81 ++ src/tool_classifier/workflows/__init__.py | 13 + .../workflows/context_workflow.py | 86 ++ src/tool_classifier/workflows/ood_workflow.py | 131 +++ src/tool_classifier/workflows/rag_workflow.py | 172 +++ .../workflows/service_workflow.py | 137 +++ 18 files changed, 2625 insertions(+), 437 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/skills/code-review/SKILL.md create mode 100644 docs/TOOL_CLASSIFIER_SKELETON_USAGE.md create mode 100644 src/llm_orchestrator_config/feature_flags.py create mode 100644 src/tool_classifier/__init__.py create mode 100644 src/tool_classifier/base_workflow.py create mode 100644 src/tool_classifier/classifier.py create mode 100644 src/tool_classifier/enums.py create mode 100644 src/tool_classifier/models.py create mode 100644 src/tool_classifier/workflows/__init__.py create mode 100644 src/tool_classifier/workflows/context_workflow.py create mode 100644 src/tool_classifier/workflows/ood_workflow.py create mode 100644 src/tool_classifier/workflows/rag_workflow.py create mode 100644 src/tool_classifier/workflows/service_workflow.py 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/.gitignore b/.gitignore index d0dc8cb8..77ec7863 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ datasets logs/ data_sets vault/agent-out +.vscode/ # RSA Private Keys - DO NOT COMMIT vault/keys/rsa_private_key.pem diff --git a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md new file mode 100644 index 00000000..9dc87c88 --- /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/pyproject.toml b/pyproject.toml index dd8f876c..56e14264 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,4 +123,4 @@ exclude = [ ] # --- Global strictness --- -typeCheckingMode = "standard" # Standard typechecking mode \ No newline at end of file +typeCheckingMode = "standard" # Standard typechecking mode diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 92dd7b02..3c059f59 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -60,6 +60,8 @@ ContextualRetrieverInitializationError, ContextualRetrievalFailureError, ) +from src.llm_orchestrator_config.feature_flags import FeatureFlags +from src.tool_classifier import ToolClassifier class LangfuseConfig: @@ -128,8 +130,15 @@ def __init__(self) -> None: 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 + + # Log feature flag configuration + FeatureFlags.log_configuration() + @observe(name="orchestration_request", as_type="agent") - def process_orchestration_request( + async def process_orchestration_request( self, request: OrchestrationRequest ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """ @@ -204,10 +213,65 @@ def process_orchestration_request( # Initialize all service components (only for valid queries) components = self._initialize_service_components(request) - # Execute the orchestration pipeline - response = self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict - ) + # 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 + classification = await self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language, + ) + + logger.info( + f"[{request.chatId}] Classification: {classification.workflow.value} " + f"(confidence: {classification.confidence:.2f})" + ) + + # Route to appropriate workflow + response = await self.tool_classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + 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_dict, timing_dict + ) + 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_dict, timing_dict + ) # Log final costs and return response self._log_costs(costs_dict) @@ -317,7 +381,6 @@ 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() # STEP 0: Detect language from user message detected_language = detect_language(request.message) @@ -390,465 +453,518 @@ async def stream_orchestration_response( 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 + classification = await self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language, + ) - # 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 + stream_result = await self.tool_classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=True, + ) + + 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_dict) + log_step_timings(timing_dict, 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_dict=costs_dict, + timing_dict=timing_dict, + ): + 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_dict) + log_step_timings(timing_dict, request.chatId) + + # Update budget even on outer exception + self._update_connection_budget( + request.connection_id, costs_dict, 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: Any, + costs_dict: Dict[str, Dict[str, Any]], + timing_dict: 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_dict: Dictionary to accumulate costs + timing_dict: 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, + ) + timing_dict["prompt_refiner"] = time.time() - start_time + costs_dict["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 + ) + 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" + ) + 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_dict) + log_step_timings(timing_dict, 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_dict) + log_step_timings(timing_dict, 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, + ) + timing_dict["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_dict) + log_step_timings(timing_dict, 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_dict["streaming_generation"] = usage_info + self._log_costs(costs_dict) + log_step_timings(timing_dict, 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 + + 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") - # Mark stream as completed successfully - stream_ctx.mark_completed() + 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 + # 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, token) + + # 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) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) yield self._format_sse(request.chatId, "END") - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + # Extract usage information after streaming completes + usage_info = get_lm_usage_since(history_length_before) + costs_dict["streaming_generation"] = usage_info - # Update budget even on outer exception - self._update_connection_budget( - request.connection_id, costs_dict, request.environment + # Record timings + timing_dict["streaming_generation"] = time.time() - streaming_step_start + timing_dict["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_dict) + log_step_timings(timing_dict, request.chatId) + + # Update budget + self._update_connection_budget( + request.connection_id, costs_dict, request.environment + ) + + # Langfuse tracking + 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() - 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_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 + ) + 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) + + # Update budget even on streaming error + self._update_connection_budget( + request.connection_id, costs_dict, request.environment + ) def _format_sse(self, chat_id: str, content: str) -> str: """ @@ -992,7 +1108,7 @@ 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], @@ -1006,7 +1122,7 @@ def _execute_orchestration_pipeline( # Step 1: Input Guardrails Check if components["guardrails_adapter"]: start_time = time.time() - input_blocked_response = self.handle_input_guardrails( + input_blocked_response = await self.handle_input_guardrails( components["guardrails_adapter"], request, costs_dict ) timing_dict["input_guardrails_check"] = time.time() - start_time @@ -1026,7 +1142,7 @@ def _execute_orchestration_pipeline( # Step 3: 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 @@ -1057,7 +1173,7 @@ def _execute_orchestration_pipeline( # Step 5: 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( + output_guardrails_response = await self.handle_output_guardrails( components["guardrails_adapter"], generated_response, request, @@ -1132,14 +1248,14 @@ 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]], ) -> 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, @@ -1186,21 +1302,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, @@ -1255,7 +1373,7 @@ 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: Union[OrchestrationResponse, TestOrchestrationResponse], @@ -1273,7 +1391,7 @@ def handle_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 = self._check_output_guardrails( + output_check_result = await self._check_output_guardrails( guardrails_adapter=guardrails_adapter, assistant_message=generated_response.content, costs_dict=costs_dict, @@ -1694,7 +1812,7 @@ 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, @@ -1714,7 +1832,7 @@ 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 diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 8bdc80cc..2a929db0 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -225,7 +225,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: @@ -262,7 +262,7 @@ def orchestrate_llm_request( ) # Process the request - response = orchestration_service.process_orchestration_request(request) + response = await orchestration_service.process_orchestration_request(request) logger.info(f"Successfully processed request for chatId: {request.chatId}") return response @@ -287,7 +287,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: @@ -341,7 +341,9 @@ def test_orchestrate_llm_request( 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): diff --git a/src/llm_orchestrator_config/feature_flags.py b/src/llm_orchestrator_config/feature_flags.py new file mode 100644 index 00000000..d0d3fff8 --- /dev/null +++ b/src/llm_orchestrator_config/feature_flags.py @@ -0,0 +1,82 @@ +"""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) + - CONTEXT_WORKFLOW_ENABLED: Enable Layer 2 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" + ) + 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): + """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" 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", "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, + "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/tool_classifier/__init__.py b/src/tool_classifier/__init__.py new file mode 100644 index 00000000..38b861d5 --- /dev/null +++ b/src/tool_classifier/__init__.py @@ -0,0 +1,20 @@ +""" +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 .classifier import ToolClassifier +from .enums import WorkflowType +from .models import ClassificationResult + +__all__ = [ + "ToolClassifier", + "WorkflowType", + "ClassificationResult", +] diff --git a/src/tool_classifier/base_workflow.py b/src/tool_classifier/base_workflow.py new file mode 100644 index 00000000..50faf7ad --- /dev/null +++ b/src/tool_classifier/base_workflow.py @@ -0,0 +1,118 @@ +"""Abstract base class for workflow executors.""" + +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, Dict, Optional + +from models.request_models import OrchestrationRequest, OrchestrationResponse + + +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], + ) -> Optional[OrchestrationResponse]: + """ + 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 + + 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], + ) -> 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 + + 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..71a45922 --- /dev/null +++ b/src/tool_classifier/classifier.py @@ -0,0 +1,338 @@ +"""Main tool classifier for workflow routing.""" + +from typing import Any, AsyncIterator, Dict, List, Literal, Union, overload +from loguru import logger + +from models.request_models import ( + ConversationItem, + OrchestrationRequest, + OrchestrationResponse, +) +from tool_classifier.enums import WorkflowType, WORKFLOW_DISPLAY_NAMES +from tool_classifier.models import ClassificationResult +from tool_classifier.workflows import ( + ServiceWorkflowExecutor, + ContextWorkflowExecutor, + RAGWorkflowExecutor, + OODWorkflowExecutor, +) + + +class ToolClassifier: + """ + Main classifier that determines which workflow should handle user queries. + + 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 + + Each layer is tried in sequence. If a layer cannot handle the query + (returns None), the classifier falls back to the next layer. + + Architecture: + - Strategy Pattern: Each workflow is a pluggable strategy + - Chain of Responsibility: Layers form a fallback chain + - Dependency Injection: LLM manager and connections injected from main service + """ + + def __init__( + self, + llm_manager: Any, + orchestration_service: Any, + ): + """ + 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 + + # Initialize workflow executors + self.service_workflow = ServiceWorkflowExecutor( + llm_manager=llm_manager, + ) + self.context_workflow = ContextWorkflowExecutor( + llm_manager=llm_manager, + ) + self.rag_workflow = RAGWorkflowExecutor( + orchestration_service=orchestration_service, + ) + self.ood_workflow = OODWorkflowExecutor() + + logger.info("Tool classifier initialized with all workflow executors") + + async def classify( + self, + query: str, + conversation_history: List[ConversationItem], + language: str, + ) -> ClassificationResult: + """ + Classify a user query to determine which workflow should handle it. + + Implements layer-wise classification logic: + 1. Check if SERVICE workflow can handle (intent detection) + 2. Check if CONTEXT workflow can handle (greeting/history check) + 3. Default to RAG workflow (knowledge retrieval) + + Args: + query: User's query string + conversation_history: List of previous conversation messages + language: Detected language code (e.g., 'en', 'et') + + Returns: + ClassificationResult indicating which workflow to use + + Note: + In this skeleton, always defaults to RAG. Full implementation + will add Layer 1 and Layer 2 logic in separate tasks. + """ + logger.info(f"Classifying query: {query[:100]}...") + + # TODO: LAYER 1 - SERVICE WORKFLOW DETECTION + # Implementation task: Service workflow implementation + # Logic: + # 1. Count active services in database + # 2. If count > 50: Use Qdrant semantic search for top 20 services + # 3. If count <= 50: Use all services + # 4. Call LLM to detect intent and extract entities + # 5. If intent detected and service valid: return SERVICE classification + # Example: + # service_check = await self._check_service_layer(query, language) + # if service_check.can_handle: + # return ClassificationResult( + # workflow=WorkflowType.SERVICE, + # confidence=service_check.confidence, + # metadata=service_check.metadata, + # reasoning="Service intent detected" + # ) + + # TODO: LAYER 2 - CONTEXT WORKFLOW DETECTION + # Implementation task: Context workflow implementation + # Logic: + # 1. Check if query is a greeting using LLM + # 2. If greeting: return CONTEXT classification + # 3. If conversation_history exists: Check if query references history + # 4. Call LLM to determine if history contains answer + # 5. If can answer from history: return CONTEXT classification + # Example: + # context_check = await self._check_context_layer( + # query, conversation_history, language + # ) + # if context_check.can_handle: + # return ClassificationResult( + # workflow=WorkflowType.CONTEXT, + # confidence=context_check.confidence, + # metadata=context_check.metadata, + # reasoning="Greeting or answerable from history" + # ) + + # LAYER 3 - RAG WORKFLOW (DEFAULT) + # Always defaults to RAG for now + # RAG workflow will handle the query or return OOD if no chunks found + logger.info("Defaulting to RAG workflow (Layers 1-2 not implemented)") + return ClassificationResult( + workflow=WorkflowType.RAG, + confidence=1.0, + metadata={}, + reasoning="Default to RAG workflow (service and context layers not implemented)", + ) + + @overload + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: Literal[False] = False, + ) -> OrchestrationResponse: ... + + @overload + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: Literal[True], + ) -> AsyncIterator[str]: ... + + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: bool = False, + ) -> Union[OrchestrationResponse, 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) + + 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, + ) + 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, + ) + + def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: + """Get workflow executor instance for given workflow type.""" + workflow_map = { + WorkflowType.SERVICE: self.service_workflow, + WorkflowType.CONTEXT: self.context_workflow, + WorkflowType.RAG: self.rag_workflow, + WorkflowType.OOD: self.ood_workflow, + } + return workflow_map[workflow_type] + + async def _execute_with_fallback_async( + self, + workflow: Any, + request: OrchestrationRequest, + context: Dict[str, Any], + start_layer: WorkflowType, + ) -> OrchestrationResponse: + """ + Execute workflow with fallback to subsequent layers (non-streaming). + + TODO: Implement full fallback chain logic + Currently just executes the primary workflow. + + Full implementation should: + 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) + """ + 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: + result = await workflow.execute_async(request, context) + + if result is not None: + logger.info(f"[{chat_id}] {workflow_name} handled successfully") + return result + + # TODO: Implement fallback to next layer + # For now, if workflow returns None, call RAG as fallback + logger.warning( + f"[{chat_id}] {workflow_name} returned None, " + f"falling back to RAG workflow" + ) + rag_result = await self.rag_workflow.execute_async(request, {}) + if rag_result is not None: + return rag_result + else: + # This should never happen since RAG always returns a result + # But handle gracefully + raise RuntimeError("RAG workflow returned None unexpectedly") + + 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, {}) + if rag_result is not None: + return rag_result + else: + raise RuntimeError("RAG workflow returned None unexpectedly") + + async def _execute_with_fallback_streaming( + self, + workflow: Any, + request: OrchestrationRequest, + context: Dict[str, Any], + start_layer: WorkflowType, + ) -> AsyncIterator[str]: + """ + Execute workflow with fallback to subsequent layers (streaming). + + TODO: Implement full fallback chain logic + Currently just executes the primary workflow. + + Full implementation should: + 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) + """ + 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: + result = await workflow.execute_streaming(request, context) + + if result is not None: + logger.info(f"[{chat_id}] {workflow_name} streaming started") + async for chunk in result: + yield chunk + return + + # TODO: Implement fallback to next layer + # For now, if workflow returns None, call RAG as fallback + logger.warning( + f"[{chat_id}] {workflow_name} returned None, " + f"falling back to RAG workflow streaming" + ) + streaming_result = await self.rag_workflow.execute_streaming(request, {}) + if streaming_result is not None: + async for chunk in streaming_result: + yield chunk + else: + raise RuntimeError("RAG workflow returned None unexpectedly") + + 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, {}) + if streaming_result is not None: + async for chunk in streaming_result: + yield chunk + else: + raise RuntimeError("RAG workflow returned None unexpectedly") diff --git a/src/tool_classifier/enums.py b/src/tool_classifier/enums.py new file mode 100644 index 00000000..ce6c7859 --- /dev/null +++ b/src/tool_classifier/enums.py @@ -0,0 +1,39 @@ +"""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) + - CONTEXT: Conversation history or greetings (Layer 2) + - RAG: Knowledge base retrieval (Layer 3) + - OOD: Out-of-domain fallback (Layer 4) + """ + + SERVICE = "service" + 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.CONTEXT, # Layer 2: Then context + WorkflowType.RAG, # Layer 3: Then RAG + WorkflowType.OOD, # Layer 4: Finally OOD (always succeeds) +] + +# Workflow display names for logging +WORKFLOW_DISPLAY_NAMES = { + WorkflowType.SERVICE: "Service Workflow", + WorkflowType.CONTEXT: "Context Workflow", + WorkflowType.RAG: "RAG Workflow", + WorkflowType.OOD: "Out-of-Domain Workflow", +} diff --git a/src/tool_classifier/models.py b/src/tool_classifier/models.py new file mode 100644 index 00000000..9929473b --- /dev/null +++ b/src/tool_classifier/models.py @@ -0,0 +1,81 @@ +"""Data models for tool classifier system.""" + +from typing import Any, Dict, Optional +from pydantic import BaseModel, Field + +from tool_classifier.enums import 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" + ) diff --git a/src/tool_classifier/workflows/__init__.py b/src/tool_classifier/workflows/__init__.py new file mode 100644 index 00000000..3d733d54 --- /dev/null +++ b/src/tool_classifier/workflows/__init__.py @@ -0,0 +1,13 @@ +"""Workflow executor implementations.""" + +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__ = [ + "ServiceWorkflowExecutor", + "ContextWorkflowExecutor", + "RAGWorkflowExecutor", + "OODWorkflowExecutor", +] diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py new file mode 100644 index 00000000..88212efa --- /dev/null +++ b/src/tool_classifier/workflows/context_workflow.py @@ -0,0 +1,86 @@ +"""Context workflow executor - Layer 2: Conversation history and greetings.""" + +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 ContextWorkflowExecutor(BaseWorkflow): + """ + Handles greetings and conversation history queries (Layer 2). + + Detects: + - Greetings: "Hello", "Thanks", "Goodbye" + - History references: "What did you say earlier?", "Can you repeat that?" + + Uses LLM for semantic detection (multilingual), no regex patterns. + + Status: SKELETON - Returns None (fallback to RAG) + TODO: Implement greeting/context detection, answer extraction, guardrails + """ + + def __init__(self, llm_manager: Any): + """ + Initialize context workflow executor. + + Args: + llm_manager: LLM manager for context analysis + """ + self.llm_manager = llm_manager + logger.info("Context workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """ + Execute context workflow in non-streaming mode. + + TODO: Check greeting (LLM) → generate response, OR check history (last 10 turns) + → extract answer → validate with guardrails. Return None if cannot answer. + + Args: + request: Orchestration request with user query and history + context: Metadata with is_greeting, can_answer_from_history flags + + Returns: + OrchestrationResponse with context-based answer or None to fallback + """ + logger.debug( + f"[{request.chatId}] Context workflow execute_async called " + f"(not implemented - returning None)" + ) + + # TODO: Implement context workflow logic here + # For now, return None to trigger fallback to next layer (RAG) + return None + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """ + Execute context workflow in streaming mode. + + TODO: Get answer (greeting/history) → validate BEFORE streaming → chunk and + yield as SSE. Return None if cannot answer. + + Args: + request: Orchestration request with user query and history + context: Metadata with is_greeting, can_answer_from_history flags + + Returns: + AsyncIterator yielding SSE strings or None to fallback + """ + logger.debug( + f"[{request.chatId}] Context workflow execute_streaming called " + f"(not implemented - returning None)" + ) + + # TODO: Implement context streaming logic here + # For now, return None to trigger fallback to next layer (RAG) + 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..fed467a5 --- /dev/null +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -0,0 +1,131 @@ +"""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): + """Initialize OOD workflow executor.""" + logger.info("OOD workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> 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) + + 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], + ) -> 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..d83080a7 --- /dev/null +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -0,0 +1,172 @@ +"""RAG workflow executor - Layer 3: Knowledge base retrieval.""" + +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 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: Any): + """ + 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], + ) -> Optional[OrchestrationResponse]: + """ + 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: Unused (RAG doesn't need classification metadata) + + 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_dict: Dict[str, Any] = {} + timing_dict: Dict[str, float] = {} + + # Initialize service components + components = self.orchestration_service._initialize_service_components(request) + + # Call existing RAG pipeline + response = await self.orchestration_service._execute_orchestration_pipeline( + request=request, + components=components, + costs_dict=costs_dict, + timing_dict=timing_dict, + ) + + # Log costs and timings + self.orchestration_service._log_costs(costs_dict) + from src.utils.time_tracker import log_step_timings + + log_step_timings(timing_dict, request.chatId) + + return response + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """ + Execute RAG workflow in streaming mode. + + 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: Unused (RAG doesn't need classification metadata) + + 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_dict: Dict[str, Any] = {} + timing_dict: Dict[str, float] = {} + + # 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: + # Create minimal stream context when called via tool classifier + # In production flow, this is provided by stream_orchestration_response + 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: + """No-op: Tracking handled by orchestration service.""" + pass + + def mark_cancelled(self) -> None: + """No-op: Tracking handled by orchestration service.""" + pass + + def mark_error(self, error_id: str) -> None: + """No-op: Tracking handled by orchestration service.""" + pass + + stream_ctx = MinimalStreamContext(request.chatId) + + # Delegate to core RAG pipeline (bypasses classifier to avoid recursion) + async for sse_chunk in self.orchestration_service._stream_rag_pipeline( + request=request, + components=components, + stream_ctx=stream_ctx, + costs_dict=costs_dict, + timing_dict=timing_dict, + ): + yield sse_chunk diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py new file mode 100644 index 00000000..8a6889bc --- /dev/null +++ b/src/tool_classifier/workflows/service_workflow.py @@ -0,0 +1,137 @@ +"""Service workflow executor - Layer 1: External service/API calls.""" + +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 ServiceWorkflowExecutor(BaseWorkflow): + """ + Executes external service calls via Ruuter endpoints (Layer 1). + + This workflow handles queries that require calling external government + services or APIs. It performs: + 1. Service discovery (semantic search if >50 services) + 2. Intent detection using LLM + 3. Entity extraction from query + 4. Service validation against database + 5. External API call via Ruuter + 6. Output guardrails validation + + Examples of Service queries: + - "What's the EUR to USD exchange rate?" + - "Check my document status" + - "Submit a tax declaration" + + Implementation Status: SKELETON + Returns None (triggers fallback to Context workflow) + + TODO - Full Implementation (Separate Task): + - Service discovery logic (Qdrant semantic search) + - Intent detection (LLM-based) + - Entity extraction and transformation + - Service validation (database lookup) + - Ruuter API integration + - Output guardrails for service responses + """ + + def __init__(self, llm_manager: Any): + """ + Initialize service workflow executor. + + Args: + llm_manager: LLM manager for intent detection + """ + self.llm_manager = llm_manager + logger.info("Service workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """ + Execute service workflow in non-streaming mode. + + TODO: Implement service workflow logic: + 1. Extract service metadata from context (service_id, intent, entities) + 2. Validate service exists and is active in database + 3. Transform entities to array format for service call + 4. Call Ruuter endpoint: POST {RUUTER_BASE_URL}/services/active{ServiceName} + 5. Validate response with output guardrails + 6. Return OrchestrationResponse with service result + + Failure scenarios: + - No service_id in context → return None (fallback to Context) + - Service not found/inactive → return None (fallback to Context) + - Service call timeout → return error response + - Output guardrails blocked → return violation response or None + + Args: + request: Orchestration request with user query + context: Metadata with service_id, intent, entities + + Returns: + OrchestrationResponse with service result or None to fallback + """ + logger.debug( + f"[{request.chatId}] Service workflow execute_async called " + f"(not implemented - returning None)" + ) + + # TODO: Implement service workflow logic here + # For now, return None to trigger fallback to next layer + return None + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """ + Execute service workflow in streaming mode. + + TODO: Implement service workflow streaming: + 1. Execute service call (same as non-streaming) + 2. Get complete service response + 3. Validate with output guardrails (validation-first) + 4. If blocked: yield violation message + END + 5. If allowed: chunk response and stream token-by-token + 6. Simulate streaming for consistent UX with RAG + + Streaming approach (validation-first): + ```python + # Get complete response + service_response = await call_service(...) + + # Validate BEFORE streaming + is_safe = await guardrails.check_output_async(service_response) + if not is_safe: + yield format_sse(chatId, VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + + # Stream validated response + for chunk in split_into_tokens(service_response, chunk_size=5): + yield format_sse(chatId, chunk) + await asyncio.sleep(0.01) + yield format_sse(chatId, "END") + ``` + + Args: + request: Orchestration request with user query + context: Metadata with service_id, intent, entities + + Returns: + AsyncIterator yielding SSE strings or None to fallback + """ + logger.debug( + f"[{request.chatId}] Service workflow execute_streaming called " + f"(not implemented - returning None)" + ) + + # TODO: Implement service streaming logic here + # For now, return None to trigger fallback to next layer + return None From 4d14ae2a7ef37bfc6997ee90d60b44452d6c7862 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 14:46:25 +0530 Subject: [PATCH 025/105] complete error handling --- DSL/CronManager/script/service_enrichment.sh | 23 ++++------- .../rag-search/POST/services/enrich.yml | 40 +++++++++---------- src/data_enrichment/main_enrichment.py | 28 ++----------- src/data_enrichment/qdrant_manager.py | 3 -- 4 files changed, 31 insertions(+), 63 deletions(-) diff --git a/DSL/CronManager/script/service_enrichment.sh b/DSL/CronManager/script/service_enrichment.sh index eaaca313..48288333 100644 --- a/DSL/CronManager/script/service_enrichment.sh +++ b/DSL/CronManager/script/service_enrichment.sh @@ -51,27 +51,24 @@ echo "[FOUND] Python script at: $PYTHON_SCRIPT" # Run enrichment script with arguments echo "[STARTING] Service enrichment processing..." -echo "[DEBUG] examples env var (base64): $examples" -echo "[DEBUG] entities env var (base64): $entities" + +# 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 base64 encoded from Ruuter, need to decode them +# 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 - echo "$examples" | base64 -d > "$EXAMPLES_FILE" - echo "[DEBUG] Decoded and written examples to: $EXAMPLES_FILE" - echo "[DEBUG] File content:" - cat "$EXAMPLES_FILE" + url_decode "$examples" > "$EXAMPLES_FILE" fi if [ -n "$entities" ]; then - echo "$entities" | base64 -d > "$ENTITIES_FILE" - echo "[DEBUG] Decoded and written entities to: $ENTITIES_FILE" - echo "[DEBUG] File content:" - cat "$ENTITIES_FILE" + url_decode "$entities" > "$ENTITIES_FILE" fi # Build Python command arguments array @@ -89,8 +86,6 @@ PYTHON_ARGS=( [ -n "$examples" ] && PYTHON_ARGS+=(--examples-file "$EXAMPLES_FILE") [ -n "$entities" ] && PYTHON_ARGS+=(--entities-file "$ENTITIES_FILE") -echo "[COMMAND] python3 -u ${PYTHON_ARGS[@]}" - # Execute Python script directly (no eval to avoid parsing issues) python3 -u "${PYTHON_ARGS[@]}" 2>&1 PYTHON_EXIT_CODE=$? @@ -98,8 +93,6 @@ PYTHON_EXIT_CODE=$? # Cleanup temporary files rm -rf "$TEMP_DIR" -echo "[DEBUG] Python execution completed with exit code: $PYTHON_EXIT_CODE" - # Handle exit codes if [ $PYTHON_EXIT_CODE -eq 0 ]; then echo "[SUCCESS] Service enrichment completed successfully" diff --git a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml index 16213a99..d0644986 100644 --- a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml +++ b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml @@ -1,7 +1,7 @@ declaration: call: declare version: 0.1 - description: "Enrich service data and index in Qdrant" + description: "Enrich service data and index in Qdrant (async via CronManager)" method: post accepts: json returns: json @@ -38,8 +38,8 @@ extract_request_data: service_id: ${incoming.body.service_id} service_name: ${incoming.body.name} service_description: ${incoming.body.description} - service_examples: ${JSON.stringify(incoming.body.examples) || '[]'} - service_entities: ${JSON.stringify(incoming.body.entities) || '[]'} + 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} @@ -58,8 +58,7 @@ return_missing_fields: error: "MISSING_REQUIRED_FIELDS", message: "service_id, name, and description are required" } - # next: return_bad_request - next: end + next: return_bad_request execute_enrichment: call: http.post @@ -76,30 +75,31 @@ execute_enrichment: is_common: ${service_is_common} result: enrichment_result next: assign_success - on_error: handle_error + on_error: handle_cron_error + +handle_cron_error: + log: "ERROR: Failed to queue enrichment job - ${enrichment_result.error || 'CronManager unreachable'}" + next: assign_cron_failure -handle_error: - log: "ERROR: Service enrichment failed - ${enrichment_result.error || 'Unknown error'}" - next: assign_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 enriched and indexed successfully" - details: ${enrichment_result.response.body} + message: "Service enrichment job queued successfully. Processing asynchronously." + status: "queued" + note: "The enrichment pipeline runs asynchronously. Check logs for completion status." next: return_ok -assign_failure: - assign: - response_data: - success: false - error: "ENRICHMENT_FAILED" - message: "Failed to enrich and index service" - details: ${enrichment_result.response.body || enrichment_result.error} - next: return_server_error - return_ok: status: 200 return: ${response_data} diff --git a/src/data_enrichment/main_enrichment.py b/src/data_enrichment/main_enrichment.py index f45c6a66..d18f6a3f 100644 --- a/src/data_enrichment/main_enrichment.py +++ b/src/data_enrichment/main_enrichment.py @@ -10,7 +10,6 @@ import json import argparse import asyncio -from datetime import datetime from loguru import logger from data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult @@ -18,17 +17,6 @@ from data_enrichment.qdrant_manager import QdrantManager -def log_separator(title: str = "") -> None: - """Log a formatted separator line""" - separator = "=" * 80 - if title: - logger.info(f"\n{separator}") - logger.info(f" {title}") - logger.info(f"{separator}\n") - else: - logger.info(f"\n{separator}\n") - - def parse_arguments() -> ServiceData: """Parse command line arguments into ServiceData model.""" parser = argparse.ArgumentParser(description='Service Data Enrichment') @@ -51,7 +39,6 @@ def parse_arguments() -> ServiceData: content = f.read().strip() if content: examples = json.loads(content) - logger.debug(f"Loaded {len(examples)} examples from file") except (FileNotFoundError, json.JSONDecodeError) as e: logger.warning(f"Failed to read/parse examples file: {e}") @@ -62,7 +49,6 @@ def parse_arguments() -> ServiceData: content = f.read().strip() if content: entities = json.loads(content) - logger.debug(f"Loaded {len(entities)} entities from file") except (FileNotFoundError, json.JSONDecodeError) as e: logger.warning(f"Failed to read/parse entities file: {e}") @@ -153,13 +139,10 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: def main() -> int: """Main entry point for service enrichment""" - log_separator("SERVICE DATA ENRICHMENT PIPELINE") - logger.info(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - logger.info(f"Python: {sys.version}") + logger.info("Starting service data enrichment pipeline") try: # Parse arguments - log_separator("STEP 1: PARSING INPUT") service_data = parse_arguments() logger.info(f"Service ID: {service_data.service_id}") logger.info(f"Service Name: {service_data.name}") @@ -167,30 +150,25 @@ def main() -> int: logger.info(f"Entities: {len(service_data.entities)} provided") # Run enrichment pipeline - log_separator("STEP 2: ENRICHMENT PIPELINE") result = asyncio.run(enrich_service(service_data)) # Log results - log_separator("ENRICHMENT RESULT") if result.success: - logger.success("✓ Enrichment completed successfully") + 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}") - log_separator() return 0 else: - logger.error("✗ Enrichment failed") + logger.error("Enrichment failed") logger.error(f"Service: {result.service_id}") logger.error(f"Message: {result.message}") logger.error(f"Error: {result.error}") - log_separator() return 1 except Exception as e: logger.error(f"Fatal error: {e}") - log_separator("ENRICHMENT FAILED") return 1 diff --git a/src/data_enrichment/qdrant_manager.py b/src/data_enrichment/qdrant_manager.py index ba3a2c8d..d964f734 100644 --- a/src/data_enrichment/qdrant_manager.py +++ b/src/data_enrichment/qdrant_manager.py @@ -31,8 +31,6 @@ def connect(self) -> None: """Connect to Qdrant.""" try: logger.info(f"Connecting to Qdrant at {self.host}:{self.port}") - # Suppress version compatibility warning (client 1.17.0 vs server 1.15.1) - # Minor version difference is acceptable (see warning in logs) self.client = QdrantClient( host=self.host, port=self.port, @@ -132,7 +130,6 @@ def upsert_service(self, enriched_service: EnrichedService) -> bool: # Convert service_id to UUID for Qdrant compatibility # Qdrant requires point IDs to be either integers or UUIDs point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, enriched_service.id)) - logger.debug(f"Generated UUID: {point_id} for service_id: {enriched_service.id}") # Prepare payload (all metadata except embedding) payload = { From 9bf37fa30c10382655b2a9043da876e088ba6a48 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 15:57:19 +0530 Subject: [PATCH 026/105] added intent enrichment pipeline --- .../rag-search/POST/services/enrich.yml | 2 - docs/SERVICE_DATA_ENRICHMENT_SPEC.md | 980 ------------------ .../__init__.py | 1 - .../api_client.py | 20 +- .../constants.py | 6 +- .../main_enrichment.py | 66 +- .../models.py | 8 +- .../qdrant_manager.py | 16 +- 8 files changed, 67 insertions(+), 1032 deletions(-) delete mode 100644 docs/SERVICE_DATA_ENRICHMENT_SPEC.md rename src/{data_enrichment => intent_data_enrichment}/__init__.py (99%) rename src/{data_enrichment => intent_data_enrichment}/api_client.py (91%) rename src/{data_enrichment => intent_data_enrichment}/constants.py (98%) rename src/{data_enrichment => intent_data_enrichment}/main_enrichment.py (83%) rename src/{data_enrichment => intent_data_enrichment}/models.py (89%) rename src/{data_enrichment => intent_data_enrichment}/qdrant_manager.py (95%) diff --git a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml index d0644986..8e427373 100644 --- a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml +++ b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml @@ -96,8 +96,6 @@ assign_success: success: true service_id: ${service_id} message: "Service enrichment job queued successfully. Processing asynchronously." - status: "queued" - note: "The enrichment pipeline runs asynchronously. Check logs for completion status." next: return_ok return_ok: diff --git a/docs/SERVICE_DATA_ENRICHMENT_SPEC.md b/docs/SERVICE_DATA_ENRICHMENT_SPEC.md deleted file mode 100644 index 3f6fc44d..00000000 --- a/docs/SERVICE_DATA_ENRICHMENT_SPEC.md +++ /dev/null @@ -1,980 +0,0 @@ -# Service Data Enrichment Pipeline - Specification - -**Version**: 1.0 -**Date**: February 19, 2026 -**Status**: Specification - ---- - -## 1. Overview - -### 1.1 Purpose - -This specification defines the **Service Data Enrichment Pipeline** - a system that automatically enriches service metadata and indexes it in Qdrant for intent classification in the Tool Classifier workflow. - -### 1.2 Goals - -- **Enrich service data** with LLM-generated context (synonyms, related terms, alternate phrasings) -- **Index enriched data** in Qdrant's `intent_collection` for semantic search -- **Maintain synchronization** between PostgreSQL services table and Qdrant -- **Provide API endpoint** for manual triggering of enrichment - -### 1.3 Architecture Flow - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ 1. Service Creation (External System) │ -│ - Admin UI / Management API │ -│ - Inserts service record into PostgreSQL services table │ -│ - Calls enrichment endpoint WITH service data │ -└────────────────────┬────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 2. Enrichment API Call │ -│ POST /rag-search/services/enrich │ -│ Body: { service_id, name, description, examples, ... } │ -└────────────────────┬────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 3. Ruuter Endpoint (DSL) │ -│ - Validates request payload │ -│ - Calls CronManager with service data │ -└────────────────────┬────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 4. CronManager Execution │ -│ - Executes: script/service_enrichment.sh │ -│ - Environment: service_id, service_data (JSON) │ -└────────────────────┬────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 5. Python Enrichment Script │ -│ src/service_enrichment/enrich_and_index.py │ -│ - Parse service data │ -│ - Call LLM to generate enriched context │ -│ - Construct embedding text │ -│ - Generate vector embedding (OpenAI text-embedding-3-large) │ -│ - Upsert document into Qdrant intent_collection │ -└────────────────────┬────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────┐ -│ 6. Response │ -│ - Success: { success: true, service_id: "...", ... } │ -│ - Error: { success: false, error: "...", ... } │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 1.4 Key Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Trigger Mechanism | **Manual API call** | Clean separation, explicit control, easier debugging | -| Enrichment Strategy | **LLM-based expansion** | Generates high-quality synonyms and variations | -| Execution Mode | **Synchronous** | Guarantees service is indexed before returning | -| Qdrant Operation | **Upsert** | Idempotent, handles updates gracefully | -| Error Handling | **Graceful fallback** | Store original data if enrichment fails | - ---- - -## 2. Components - -### 2.1 Database Schema (Already Exists) - -**Table**: `public.services` - -```sql -CREATE TABLE public.services ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL, - description TEXT NOT NULL, - service_id TEXT NOT NULL UNIQUE, - ruuter_type ruuter_request_type DEFAULT 'GET', - current_state service_state DEFAULT 'draft', - is_common BOOLEAN NOT NULL DEFAULT FALSE, - slot TEXT NOT NULL DEFAULT '', - entities text[] NOT NULL DEFAULT '{}', - examples text[] NOT NULL DEFAULT '{}', - structure JSON NOT NULL DEFAULT '{}', - endpoints JSON NOT NULL DEFAULT '[]', - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); -``` - -**Note**: The enrichment endpoint receives service data directly from the caller, so no Resql query is needed. - -### 2.2 Ruuter Endpoint (NEW) - -**File**: `DSL/Ruuter.public/rag-search/POST/services/enrich.yml` - -```yaml -declaration: - call: declare - version: 0.1 - description: "Enrich service data and index in Qdrant" - method: post - accepts: json - returns: json - namespace: rag-search - allowlist: - body: - - field: service_id - type: string - description: "Unique service identifier" - required: true - - field: name - type: string - description: "Service name" - required: true - - field: description - type: string - description: "Service description" - required: true - - field: examples - type: array - description: "Example queries" - required: false - - field: entities - type: array - description: "Expected entity names" - required: false - - field: ruuter_type - type: string - description: "HTTP method (GET/POST)" - required: false - - field: current_state - type: string - description: "Service state (active/inactive/draft)" - required: false - - field: is_common - type: boolean - description: "Is common service" - required: false - -validate_request: - assign: - service_id: ${incoming.body.service_id} - service_name: ${incoming.body.name} - service_description: ${incoming.body.description} - next: check_required_fields - -check_required_fields: - switch: - - condition: ${!service_id || service_id.trim() === ''} - next: return_missing_service_id - - condition: ${!service_name || service_name.trim() === ''} - next: return_missing_name - - condition: ${!service_description || service_description.trim() === ''} - next: return_missing_description - - condition: true - next: prepare_service_data - -return_missing_service_id: - status: 400 - return: - success: false - error: "MISSING_SERVICE_ID" - message: "service_id is required" - next: end - -return_missing_name: - status: 400 - return: - success: false - error: "MISSING_NAME" - message: "name is required" - next: end - -return_missing_description: - status: 400 - return: - success: false - error: "MISSING_DESCRIPTION" - message: "description is required" - next: end - -prepare_service_data: - assign: - service_data: - service_id: ${service_id} - name: ${service_name} - description: ${service_description} - examples: ${incoming.body.examples || []} - entities: ${incoming.body.entities || []} - ruuter_type: ${incoming.body.ruuter_type || 'GET'} - current_state: ${incoming.body.current_state || 'draft'} - is_common: ${incoming.body.is_common || false} - service_json: ${JSON.stringify(service_data)} - log: "Enriching service: ${service_id}" - next: execute_enrichment - -execute_enrichment: - call: http.post - args: - url: "[#RAG_SEARCH_CRON_MANAGER]/execute/service_enrichment/enrich_and_index" - query: - service_id: ${service_id} - service_data: ${service_json} - result: enrichment_result - next: check_enrichment_success - -check_enrichment_success: - switch: - - condition: ${enrichment_result.response.status >= 200 && enrichment_result.response.status < 300} - next: return_success - - condition: true - next: return_enrichment_error - -return_success: - status: 200 - return: - success: true - service_id: ${service_id} - message: "Service enriched and indexed successfully" - enrichment_details: ${enrichment_result.response.body} - next: end - -return_enrichment_error: - status: 500 - return: - success: false - error: "ENRICHMENT_FAILED" - message: "Failed to enrich and index service" - details: ${enrichment_result.response.body || enrichment_result.error} - next: end -``` - -### 2.3 CronManager DSL (NEW) - -**File**: `DSL/CronManager/DSL/service_enrichment.yml` - -```yaml -enrich_and_index: - trigger: off - type: exec - command: "../app/scripts/service_enrichment.sh" - allowedEnvs: ['service_id', 'service_data'] -``` - -### 2.4 Shell Script (NEW) - -**File**: `DSL/CronManager/script/service_enrichment.sh` - -```bash -#!/bin/bash - -echo "[SERVICE_ENRICHMENT] Starting service enrichment pipeline..." - -# Validate required environment variables -if [ -z "$service_id" ] || [ -z "$service_data" ]; then - echo "[ERROR] Missing required environment variables: service_id and service_data" - exit 1 -fi - -PYTHON_SCRIPT="/app/src/service_enrichment/enrich_and_index.py" - -echo "[INFO] Enriching service: $service_id" - -# 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" "openai>=1.12.0" || 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" "pydantic>=2.11.7" || exit 1 -"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "loguru>=0.7.3" || exit 1 -"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "python-dotenv>=1.0.0" || exit 1 - -echo "[PACKAGES] All packages installed successfully" - -# Set Python path -export PYTHONPATH="/app:/app/src:$PYTHONPATH" - -# Check if script exists -if [ ! -f "$PYTHON_SCRIPT" ]; then - echo "[ERROR] Python script not found: $PYTHON_SCRIPT" - exit 1 -fi - -# Execute Python enrichment script -echo "[EXECUTION] Running enrichment script..." -python3 "$PYTHON_SCRIPT" \ - --service-id "$service_id" \ - --service-data "$service_data" - -exit_code=$? - -if [ $exit_code -eq 0 ]; then - echo "[SUCCESS] Service enrichment completed successfully" -else - echo "[ERROR] Service enrichment failed with exit code: $exit_code" -fi - -exit $exit_code -``` - -### 2.5 Python Enrichment Script (NEW) - -**File**: `src/service_enrichment/enrich_and_index.py` - -This is the core enrichment logic - will be detailed in Section 3. - ---- - -## 3. Enrichment Logic - -### 3.1 Enrichment Prompt - -The LLM receives the service data and generates enriched context. - -**Prompt Template:** - -```python -ENRICHMENT_PROMPT = """ -You are a service intent enrichment assistant. Your task is to expand and enhance service descriptions to improve semantic search and intent classification. - -Given a service description, generate: -1. **Synonyms**: Alternative words/phrases with the same meaning -2. **Related Terms**: Contextually related concepts -3. **Query Variations**: Different ways users might ask for this service -4. **Entity Context**: Additional context about expected entities - ---- - -SERVICE INFORMATION: -- Name: {service_name} -- Description: {service_description} -- Examples: {service_examples} -- Expected Entities: {service_entities} - ---- - -TASK: -Generate enriched context that will help match user queries to this service. - -RESPONSE FORMAT (JSON only): -{{ - "synonyms": ["synonym1", "synonym2", ...], - "related_terms": ["term1", "term2", ...], - "query_variations": ["variation1", "variation2", ...], - "entity_context": {{ - "entity_name_1": "brief description of what this entity represents", - "entity_name_2": "brief description of what this entity represents" - }} -}} - -GUIDELINES: -- Focus on semantic similarity and user intent -- Include common misspellings and colloquialisms if relevant -- Keep language natural and conversational -- Generate 5-10 items per category -- Maintain consistency with the service's actual purpose -- Output ONLY valid JSON, no explanations - -EXAMPLE OUTPUT: -{{ - "synonyms": ["exchange rate", "currency conversion", "forex rate"], - "related_terms": ["EUR to USD", "currency pair", "forex", "money exchange"], - "query_variations": [ - "what is the current exchange rate", - "convert EUR to USD", - "how much is one euro in dollars", - "EUR USD rate today" - ], - "entity_context": {{ - "from_currency": "The currency code to convert from (e.g., EUR, USD, GBP)", - "to_currency": "The currency code to convert to (e.g., EUR, USD, GBP)" - }} -}} -""" -``` - -### 3.2 Embedding Text Construction - -After enrichment, construct the final text for embedding: - -```python -def construct_enriched_embedding_text( - service_data: Dict, - enrichment: Dict -) -> str: - """ - Construct embedding text from service data and enriched context. - - Format: - - Original description - - Original examples - - Enriched synonyms - - Enriched related terms - - Enriched query variations - - Entity context - - All sections newline-separated for optimal embedding. - """ - parts = [] - - # Original description - parts.append(service_data['description']) - - # Original examples - if service_data.get('examples'): - parts.extend(service_data['examples']) - - # Enriched synonyms - if enrichment.get('synonyms'): - parts.extend(enrichment['synonyms']) - - # Enriched related terms - if enrichment.get('related_terms'): - parts.extend(enrichment['related_terms']) - - # Enriched query variations - if enrichment.get('query_variations'): - parts.extend(enrichment['query_variations']) - - # Entity context (formatted as descriptions) - if enrichment.get('entity_context'): - for entity, context in enrichment['entity_context'].items(): - parts.append(f"{entity}: {context}") - - return "\n".join(parts) -``` - -**Example Output:** - -```text -Kasutaja soovib infot ettevõtte poolt tasutud tööjõumaksude kohta, näiteks palgamaksud ja sotsiaalmaks. -ettevõtte tasutud tööjõumaksud -kui palju maksis ettevõte tööjõumakse -firma poolt tasutud tööjõumaksud -salary taxes -payroll taxes -workforce contributions -employer taxes -labor costs -social security contributions -payroll expenses -employee-related taxes -company tax obligations -kuidas palju tööjõumakse -ettevõtte maksud töötajate eest -tööjõukulud maksud -company_name: The registered name of the company or business registry code -tax_period: The time period for which tax information is requested (e.g., year, quarter) -``` - -### 3.3 Qdrant Document Structure - -**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...", - "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": "... (full enriched text from section 3.2) ...", - "service_id": "common_service_companies_workforce_taxes", - "ruuter_type": "POST", - "current_state": "active", - "is_enriched": true, - "enriched_at": "2026-02-19T10:30:00Z", - "enrichment_version": "1.0" -} -``` - -### 3.4 Fallback Strategy - -If LLM enrichment fails: - -```python -def construct_fallback_embedding_text(service_data: Dict) -> str: - """ - Fallback: Use original data without enrichment. - """ - parts = [service_data['description']] - parts.extend(service_data.get('examples', [])) - return "\n".join(parts) -``` - ---- - -## 4. Python Script Implementation - -### 4.1 Script Structure - -``` -src/service_enrichment/ -├── __init__.py -├── enrich_and_index.py # Main script (CLI entry point) -├── enrichment_service.py # LLM enrichment logic -├── qdrant_indexer.py # Qdrant upsert logic -└── models.py # Pydantic models -``` - -### 4.2 Main Script Flow - -```python -""" -Main enrichment script: enrich_and_index.py - -Steps: -1. Parse command-line arguments (service_id, service_data JSON) -2. Load configuration (LLM connection, Qdrant connection) -3. Call LLM to enrich service data -4. Construct enriched embedding text -5. Generate vector embedding (OpenAI text-embedding-3-large) -6. Upsert document into Qdrant intent_collection -7. Return success/failure response -""" - -import sys -import json -import argparse -from typing import Dict, Optional -from loguru import logger - -# Configure logger -logger.remove() -logger.add(sys.stdout, level="INFO") -logger.add(sys.stderr, level="ERROR") - - -def main(): - parser = argparse.ArgumentParser(description="Enrich and index service data") - parser.add_argument("--service-id", required=True) - parser.add_argument("--service-data", required=True) - args = parser.parse_args() - - try: - # Parse service data - service_data = json.loads(args.service_data) - logger.info(f"Processing service: {args.service_id}") - - # Load configuration - config = load_configuration() - - # Initialize components - enrichment_service = EnrichmentService(config) - qdrant_indexer = QdrantIndexer(config) - - # Step 1: Enrich service data - enrichment = enrichment_service.enrich(service_data) - - # Step 2: Construct embedding text - embedding_text = construct_enriched_embedding_text( - service_data, enrichment - ) - - # Step 3: Generate vector embedding - embedding_vector = enrichment_service.generate_embedding( - embedding_text - ) - - # Step 4: Prepare Qdrant document - qdrant_doc = prepare_qdrant_document( - service_data, embedding_text, enrichment - ) - - # Step 5: Upsert into Qdrant - qdrant_indexer.upsert( - collection_name="intent_collection", - document_id=args.service_id, - vector=embedding_vector, - payload=qdrant_doc - ) - - logger.info(f"Successfully enriched and indexed service: {args.service_id}") - - # Output success JSON - print(json.dumps({ - "success": True, - "service_id": args.service_id, - "enriched": True, - "embedding_dimension": len(embedding_vector) - })) - - except Exception as e: - logger.error(f"Enrichment failed: {e}") - print(json.dumps({ - "success": False, - "error": str(e), - "service_id": args.service_id - })) - sys.exit(1) - - -if __name__ == "__main__": - main() -``` - ---- - -## 5. Configuration - -### 5.1 Required Environment Variables - -```bash -# LLM Configuration (OpenAI) -OPENAI_API_KEY=sk-... -OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_MODEL=gpt-4o-mini # For enrichment LLM calls -OPENAI_EMBEDDING_MODEL=text-embedding-3-large - -# Qdrant Configuration -QDRANT_HOST=localhost -QDRANT_PORT=6333 -QDRANT_API_KEY= # Optional - -# Intent Collection -INTENT_COLLECTION_NAME=intent_collection -INTENT_COLLECTION_DIMENSION=3072 # text-embedding-3-large dimension -``` - -### 5.2 LLM Connection - -Use existing `llm_connections` table or Vault integration: - -```python -# Option 1: Fetch from llm_connections table -connection = fetch_production_connection(connection_type="openai") - -# Option 2: Use environment variables directly -config = { - "api_key": os.getenv("OPENAI_API_KEY"), - "base_url": os.getenv("OPENAI_BASE_URL"), - "model": os.getenv("OPENAI_MODEL", "gpt-4o-mini"), - "embedding_model": os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-large") -} -``` - ---- - -## 6. API Usage Examples - -### 6.1 Enrich Single Service - -```bash -POST http://localhost:8080/rag-search/services/enrich -Content-Type: application/json - -{ - "service_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"], - "ruuter_type": "POST", - "current_state": "active", - "is_common": true -} -``` - -**Response (Success):** - -```json -{ - "success": true, - "service_id": "common_service_companies_workforce_taxes", - "message": "Service enriched and indexed successfully", - "enrichment_details": { - "success": true, - "service_id": "common_service_companies_workforce_taxes", - "enriched": true, - "embedding_dimension": 3072 - } -} -``` - -**Response (Error - Missing Required Field):** - -```json -{ - "success": false, - "error": "MISSING_DESCRIPTION", - "message": "description is required" -} -``` - -### 6.2 Integration with Service Creation - -When creating a service via admin API: - -```python -# Step 1: Insert service into PostgreSQL -service_data = { - "service_id": "exchange-rate-001", - "name": "Exchange Rate Service", - "description": "Returns current exchange rate between two currencies", - "examples": ["EUR to USD rate", "convert EUR to USD"], - "entities": ["from_currency", "to_currency"], - "ruuter_type": "GET", - "current_state": "active", - "is_common": False -} -insert_service_into_db(service_data) - -# Step 2: Trigger enrichment (pass service_data directly) -response = requests.post( - "http://localhost:8080/rag-search/services/enrich", - json=service_data # Send complete service data -) - -if response.json()["success"]: - logger.info(f"Service {service_data['service_id']} enriched and indexed") -else: - logger.error(f"Enrichment failed: {response.json()['error']}") -``` - ---- - -## 7. Error Handling - -### 7.1 Error Scenarios - -| Scenario | HTTP Status | Error Code | Response | -|----------|-------------|------------|----------| -| Missing service_id | 400 | `MISSING_SERVICE_ID` | `{"success": false, "error": "MISSING_SERVICE_ID", ...}` | -| Missing name | 400 | `MISSING_NAME` | `{"success": false, "error": "MISSING_NAME", ...}` | -| Missing description | 400 | `MISSING_DESCRIPTION` | `{"success": false, "error": "MISSING_DESCRIPTION", ...}` | -| LLM enrichment failed | 500 | `LLM_ENRICHMENT_FAILED` | Fallback to original data | -| Embedding generation failed | 500 | `EMBEDDING_FAILED` | `{"success": false, "error": "EMBEDDING_FAILED", ...}` | -| Qdrant upsert failed | 500 | `INDEXING_FAILED` | `{"success": false, "error": "INDEXING_FAILED", ...}` | - -### 7.2 Retry Strategy - -```python -# Retry LLM enrichment (max 3 attempts) -for attempt in range(3): - try: - enrichment = llm_enrichment_service.enrich(service_data) - break - except Exception as e: - if attempt == 2: - logger.warning("LLM enrichment failed, using fallback") - enrichment = {} # Use fallback - else: - time.sleep(2 ** attempt) # Exponential backoff - -# No retry for Qdrant (immediate failure) -``` - ---- - -## 8. Monitoring & Logging - -### 8.1 Log Events - -```python -# Key log events -logger.info(f"[ENRICHMENT_START] service_id={service_id}") -logger.info(f"[LLM_ENRICHMENT] Generated {len(enrichment['synonyms'])} synonyms") -logger.info(f"[EMBEDDING] Dimension: {len(embedding_vector)}") -logger.info(f"[QDRANT_UPSERT] Collection: intent_collection, ID: {service_id}") -logger.info(f"[ENRICHMENT_SUCCESS] service_id={service_id}, duration={duration}ms") -logger.error(f"[ENRICHMENT_FAILED] service_id={service_id}, error={error}") -``` - -### 8.2 Metrics to Track - -```python -# Future: Add metrics collection -metrics = { - "total_services_enriched": 0, - "enrichment_failures": 0, - "average_enrichment_time_ms": 0, - "llm_token_usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_cost_usd": 0.0 - } -} -``` - ---- - -## 9. Testing Strategy - -### 9.1 Unit Tests - -```python -# Test enrichment logic -def test_construct_enriched_embedding_text(): - service_data = {...} - enrichment = {...} - result = construct_enriched_embedding_text(service_data, enrichment) - assert "synonyms" in result - assert "related_terms" in result - -# Test fallback strategy -def test_fallback_on_llm_failure(): - service_data = {...} - result = construct_fallback_embedding_text(service_data) - assert service_data['description'] in result -``` - -### 9.2 Integration Tests - -```bash -# Test full pipeline -curl -X POST http://localhost:8080/rag-search/services/enrich \ - -H "Content-Type: application/json" \ - -d '{"service_id": "test-service-001"}' - -# Verify in Qdrant -curl http://localhost:6333/collections/intent_collection/points/test-service-001 -``` - -### 9.3 Test Service Data - -```sql --- Insert test service -INSERT INTO public.services ( - service_id, name, description, examples, entities, ruuter_type, current_state -) VALUES ( - 'test-service-001', - 'Test Exchange Rate Service', - 'Returns current exchange rate between two currencies', - ARRAY['EUR to USD rate', 'convert EUR to USD'], - ARRAY['from_currency', 'to_currency'], - 'GET', - 'active' -); -``` - ---- - -## 10. Implementation Checklist - -### Phase 1: Ruuter Endpoint -- [ ] Create Ruuter endpoint: `services/enrich.yml` -- [ ] Test endpoint with mock service data payload -- [ ] Verify error handling (missing fields, validation) - -### Phase 2: CronManager Configuration -- [ ] Create CronManager DSL: `service_enrichment.yml` -- [ ] Create shell script: `service_enrichment.sh` -- [ ] Test shell script execution manually - -### Phase 3: Python Enrichment Script -- [ ] Create module structure: `src/service_enrichment/` -- [ ] Implement `enrich_and_index.py` (main script) -- [ ] Implement `enrichment_service.py` (LLM logic) -- [ ] Implement `qdrant_indexer.py` (Qdrant operations) -- [ ] Implement `models.py` (Pydantic models) -- [ ] Add unit tests - -### Phase 4: Integration Testing -- [ ] Test full pipeline end-to-end -- [ ] Verify Qdrant documents match schema -- [ ] Test error scenarios (missing fields, LLM failure, etc.) -- [ ] Test fallback strategy - -### Phase 5: Documentation -- [ ] Add API endpoint to `endpoints.md` -- [ ] Update README with enrichment pipeline section -- [ ] Create usage examples - ---- - -## 11. Future Enhancements - -### 11.1 Batch Enrichment - -```bash -POST /rag-search/services/enrich-batch -{ - "service_ids": [ - "service-001", - "service-002", - "service-003" - ] -} -``` - -### 11.2 Re-enrichment Strategy - -```bash -# Re-enrich all active services -POST /rag-search/services/re-enrich-all -{ - "force": true, # Re-enrich even if already enriched - "state_filter": "active" # Only active services -} -``` - -### 11.3 Enrichment Quality Scoring - -Track enrichment quality metrics: - -```python -quality_score = { - "synonym_diversity": 0.85, # Unique vs. total synonyms - "query_variation_coverage": 0.90, # Coverage of expected queries - "embedding_quality": 0.88 # Cosine similarity to original -} -``` - ---- - -## 12. Open Questions - -1. **LLM Model Choice**: Should we use `gpt-4o-mini` for cost efficiency or `gpt-4o` for better quality? - - **Recommendation**: Start with `gpt-4o-mini`, monitor quality, upgrade if needed - -2. **Enrichment Versioning**: Should we track enrichment versions for re-enrichment? - - **Recommendation**: Add `enrichment_version` field to Qdrant payload - -3. **Multi-language Support**: Should enrichment handle Estonian vs. English differently? - - **Recommendation**: Single prompt works for both, LLM detects language automatically - -4. **Caching Strategy**: Should we cache enriched data in PostgreSQL? - - **Recommendation**: No - Qdrant is the source of truth for enriched data - ---- - -## 13. Summary - -This specification defines a **synchronous, LLM-based service enrichment pipeline** that: - -1. ✅ Receives service data via REST API -2. ✅ Enriches data using LLM (synonyms, related terms, query variations) -3. ✅ Generates vector embeddings using OpenAI text-embedding-3-large -4. ✅ Upserts enriched documents into Qdrant intent_collection -5. ✅ Provides graceful fallback if enrichment fails -6. ✅ Returns synchronous success/error response - -**Next Step**: Await user confirmation before implementation. - ---- diff --git a/src/data_enrichment/__init__.py b/src/intent_data_enrichment/__init__.py similarity index 99% rename from src/data_enrichment/__init__.py rename to src/intent_data_enrichment/__init__.py index 2cb4ccff..8b538d69 100644 --- a/src/data_enrichment/__init__.py +++ b/src/intent_data_enrichment/__init__.py @@ -20,4 +20,3 @@ "QdrantManager", "EnrichmentConstants", ] - diff --git a/src/data_enrichment/api_client.py b/src/intent_data_enrichment/api_client.py similarity index 91% rename from src/data_enrichment/api_client.py rename to src/intent_data_enrichment/api_client.py index ee8f0af7..903e642c 100644 --- a/src/data_enrichment/api_client.py +++ b/src/intent_data_enrichment/api_client.py @@ -62,15 +62,15 @@ async def generate_context(self, service_data: ServiceData) -> str: 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)}""" +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), + examples=", ".join(service_data.examples), ) request_data = { @@ -93,8 +93,7 @@ async def generate_context(self, service_data: ServiceData) -> str: raise RuntimeError("HTTP session not initialized") response = await self.session.post( - f"{self.api_base_url}/generate-context", - json=request_data + f"{self.api_base_url}/generate-context", json=request_data ) response.raise_for_status() result = response.json() @@ -117,7 +116,7 @@ async def generate_context(self, service_data: ServiceData) -> str: ) if attempt < self.max_retries - 1: - delay = self.retry_delay_base ** attempt + delay = self.retry_delay_base**attempt logger.info(f"Retrying in {delay} seconds...") await asyncio.sleep(delay) @@ -160,8 +159,7 @@ async def create_embedding(self, text: str) -> List[float]: raise RuntimeError("HTTP session not initialized") response = await self.session.post( - f"{self.api_base_url}/embeddings", - json=request_data + f"{self.api_base_url}/embeddings", json=request_data ) response.raise_for_status() result = response.json() @@ -181,11 +179,13 @@ async def create_embedding(self, text: str) -> List[float]: logger.warning(f"Embedding creation attempt {attempt + 1} failed: {e}") if attempt < self.max_retries - 1: - delay = self.retry_delay_base ** attempt + 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}" + 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/data_enrichment/constants.py b/src/intent_data_enrichment/constants.py similarity index 98% rename from src/data_enrichment/constants.py rename to src/intent_data_enrichment/constants.py index 0b44d438..fd15a6ac 100644 --- a/src/data_enrichment/constants.py +++ b/src/intent_data_enrichment/constants.py @@ -11,19 +11,19 @@ class EnrichmentConstants: 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" - + # Context Generation CONTEXT_TEMPLATE = """ {full_service_info} diff --git a/src/data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py similarity index 83% rename from src/data_enrichment/main_enrichment.py rename to src/intent_data_enrichment/main_enrichment.py index d18f6a3f..2ed294fd 100644 --- a/src/data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -19,39 +19,49 @@ 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') - + 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: + 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: + 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, @@ -60,17 +70,17 @@ def parse_arguments() -> ServiceData: entities=entities, ruuter_type=args.ruuter_type, current_state=args.current_state, - is_common=args.is_common.lower() == 'true', + is_common=args.is_common.lower() == "true", ) async def enrich_service(service_data: ServiceData) -> EnrichmentResult: """ Main enrichment pipeline: generate context, create embedding, store in Qdrant. - + Args: service_data: Service data to enrich - + Returns: EnrichmentResult with success/failure information """ @@ -80,12 +90,12 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: 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 embedding for the context logger.info("Step 2: Creating embedding vector") embedding = await api_client.create_embedding(context) logger.success(f"Embedding created: {len(embedding)}-dimensional vector") - + # Step 3: Prepare enriched service enriched_service = EnrichedService( id=service_data.service_id, @@ -96,16 +106,16 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: context=context, embedding=embedding, ) - + # Step 4: Store in Qdrant logger.info("Step 3: Storing in Qdrant") qdrant = QdrantManager() qdrant.connect() qdrant.ensure_collection() - + success = qdrant.upsert_service(enriched_service) qdrant.close() - + if success: return EnrichmentResult( success=True, @@ -124,7 +134,7 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: embedding_dimension=None, error="Qdrant upsert operation failed", ) - + except Exception as e: logger.error(f"Enrichment pipeline failed: {e}") return EnrichmentResult( @@ -140,7 +150,7 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: def main() -> int: """Main entry point for service enrichment""" logger.info("Starting service data enrichment pipeline") - + try: # Parse arguments service_data = parse_arguments() @@ -148,10 +158,10 @@ def main() -> int: 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") @@ -166,7 +176,7 @@ def main() -> int: 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 diff --git a/src/data_enrichment/models.py b/src/intent_data_enrichment/models.py similarity index 89% rename from src/data_enrichment/models.py rename to src/intent_data_enrichment/models.py index 04ca1b5a..eb0ef64e 100644 --- a/src/data_enrichment/models.py +++ b/src/intent_data_enrichment/models.py @@ -11,7 +11,9 @@ class ServiceData(BaseModel): 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") + 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") @@ -36,5 +38,7 @@ class EnrichmentResult(BaseModel): 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") + embedding_dimension: Optional[int] = Field( + None, description="Embedding vector dimension" + ) error: Optional[str] = Field(None, description="Error message if failed") diff --git a/src/data_enrichment/qdrant_manager.py b/src/intent_data_enrichment/qdrant_manager.py similarity index 95% rename from src/data_enrichment/qdrant_manager.py rename to src/intent_data_enrichment/qdrant_manager.py index d964f734..3aaad611 100644 --- a/src/data_enrichment/qdrant_manager.py +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -55,10 +55,10 @@ def ensure_collection(self) -> None: if self.collection_name in collection_names: # Check if existing collection has correct vector size collection_info = self.client.get_collection(self.collection_name) - + # Qdrant vectors config is a dict - get the default vector config vectors_config = collection_info.config.params.vectors - + existing_vector_size: Optional[int] = None if isinstance(vectors_config, dict): # Get first vector config (usually the default/unnamed one) @@ -68,9 +68,11 @@ def ensure_collection(self) -> None: elif vectors_config is not None: # Direct VectorParams object (older API) existing_vector_size = vectors_config.size - + if existing_vector_size is None: - logger.warning(f"Could not determine vector size for '{self.collection_name}', recreating") + logger.warning( + f"Could not determine vector size for '{self.collection_name}', recreating" + ) self.client.delete_collection(self.collection_name) self._create_collection() elif existing_vector_size != EnrichmentConstants.VECTOR_SIZE: @@ -78,7 +80,9 @@ def ensure_collection(self) -> None: f"Collection '{self.collection_name}' exists with wrong vector size: " f"{existing_vector_size} (expected {EnrichmentConstants.VECTOR_SIZE})" ) - logger.info(f"Deleting and recreating collection '{self.collection_name}'") + logger.info( + f"Deleting and recreating collection '{self.collection_name}'" + ) self.client.delete_collection(self.collection_name) self._create_collection() else: @@ -97,7 +101,7 @@ def _create_collection(self) -> None: """Create the collection with correct vector configuration.""" if not self.client: raise RuntimeError(_CLIENT_NOT_INITIALIZED) - + logger.info( f"Creating collection '{self.collection_name}' " f"with vector size {EnrichmentConstants.VECTOR_SIZE}" From 6e5c22c450188f897ebf42fa279fcf04b97550f5 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 16:09:11 +0530 Subject: [PATCH 027/105] remove unwanted file --- enrich.yml.backup | 157 ---------------------------------------------- 1 file changed, 157 deletions(-) delete mode 100644 enrich.yml.backup diff --git a/enrich.yml.backup b/enrich.yml.backup deleted file mode 100644 index 28cd5b31..00000000 --- a/enrich.yml.backup +++ /dev/null @@ -1,157 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Enrich service data and index in Qdrant" - 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" - -validate_request: - assign: - service_id: ${incoming.body.service_id} - service_name: ${incoming.body.name} - service_description: ${incoming.body.description} - next: check_required_fields - -check_required_fields: - switch: - - condition: ${!service_id} - next: assign_missing_service_id_error - - condition: ${!service_name} - next: assign_missing_name_error - - condition: ${!service_description} - next: assign_missing_description_error - next: prepare_service_data - -assign_missing_service_id_error: - assign: - error_response: { - success: false, - error: "MISSING_SERVICE_ID", - message: "service_id is required" - } - next: return_missing_service_id - -return_missing_service_id: - status: 400 - return: ${error_response} - next: end - -assign_missing_name_error: - assign: - error_response: { - success: false, - error: "MISSING_NAME", - message: "name is required" - } - next: return_missing_name - -return_missing_name: - status: 400 - return: ${error_response} - next: end - -assign_missing_description_error: - assign: - error_response: { - success: false, - error: "MISSING_DESCRIPTION", - message: "description is required" - } - next: return_missing_description - -return_missing_description: - status: 400 - return: ${error_response} - next: end - -prepare_service_data: - assign: - service_data: { - service_id: ${service_id}, - name: ${service_name}, - description: ${service_description}, - examples: ${incoming.body.examples || []}, - entities: ${incoming.body.entities || []}, - ruuter_type: ${incoming.body.ruuter_type || 'GET'}, - current_state: ${incoming.body.current_state || 'draft'}, - is_common: ${incoming.body.is_common || false} - } - next: stringify_service_data - -stringify_service_data: - assign: - service_json: ${JSON.stringify(service_data)} - next: execute_enrichment - -execute_enrichment: - call: http.post - args: - url: "[#RAG_SEARCH_CRON_MANAGER]/execute/service_enrichment/enrich_and_index" - query: - service_id: ${service_id} - service_data: ${service_json} - result: enrichment_result - next: assign_success_response - on_error: handle_enrichment_error - -handle_enrichment_error: - log: "ERROR: Service enrichment failed - ${enrichment_result.error || 'Unknown error'}" - next: assign_error_response - -assign_success_response: - assign: - success_response: { - success: true, - service_id: ${service_id}, - message: "Service enriched and indexed successfully", - enrichment_details: ${enrichment_result.response.body} - } - next: return_success - -assign_error_response: - assign: - error_response: { - success: false, - error: "ENRICHMENT_FAILED", - message: "Failed to enrich and index service", - details: ${enrichment_result.response.body || enrichment_result.error} - } - next: return_enrichment_error - -return_success: - status: 200 - return: ${success_response} - next: end - -return_enrichment_error: - status: 500 - return: ${error_response} - next: end From 38d05337f6c1ea7cd35e5e73b5c3b4b219bbeeb3 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 16:33:02 +0530 Subject: [PATCH 028/105] updated changes --- src/intent_data_enrichment/__init__.py | 8 ++++---- src/intent_data_enrichment/api_client.py | 4 ++-- src/intent_data_enrichment/main_enrichment.py | 6 +++--- src/intent_data_enrichment/qdrant_manager.py | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/intent_data_enrichment/__init__.py b/src/intent_data_enrichment/__init__.py index 8b538d69..eb197d33 100644 --- a/src/intent_data_enrichment/__init__.py +++ b/src/intent_data_enrichment/__init__.py @@ -7,10 +7,10 @@ __version__ = "1.0.0" -from data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult -from data_enrichment.api_client import LLMAPIClient -from data_enrichment.qdrant_manager import QdrantManager -from data_enrichment.constants import EnrichmentConstants +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", diff --git a/src/intent_data_enrichment/api_client.py b/src/intent_data_enrichment/api_client.py index 903e642c..31ed96e2 100644 --- a/src/intent_data_enrichment/api_client.py +++ b/src/intent_data_enrichment/api_client.py @@ -6,8 +6,8 @@ from types import TracebackType from loguru import logger -from data_enrichment.constants import EnrichmentConstants -from data_enrichment.models import ServiceData +from intent_data_enrichment.constants import EnrichmentConstants +from intent_data_enrichment.models import ServiceData class LLMAPIClient: diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py index 2ed294fd..c1341663 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -12,9 +12,9 @@ import asyncio from loguru import logger -from data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult -from data_enrichment.api_client import LLMAPIClient -from data_enrichment.qdrant_manager import QdrantManager +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 def parse_arguments() -> ServiceData: diff --git a/src/intent_data_enrichment/qdrant_manager.py b/src/intent_data_enrichment/qdrant_manager.py index 3aaad611..44af0e46 100644 --- a/src/intent_data_enrichment/qdrant_manager.py +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -6,8 +6,8 @@ from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct -from data_enrichment.constants import EnrichmentConstants -from data_enrichment.models import EnrichedService +from intent_data_enrichment.constants import EnrichmentConstants +from intent_data_enrichment.models import EnrichedService # Error messages _CLIENT_NOT_INITIALIZED = "Qdrant client not initialized" From 72b8ae1fd64c64f093bd23e8284a4f4aea828c0e Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 16:49:32 +0530 Subject: [PATCH 029/105] fixed requested changes --- DSL/CronManager/script/service_enrichment.sh | 4 +-- docker-compose-ec2.yml | 4 +-- docker-compose.yml | 4 +-- src/intent_data_enrichment/main_enrichment.py | 11 +++---- src/intent_data_enrichment/qdrant_manager.py | 29 ++++++++++++------- 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/DSL/CronManager/script/service_enrichment.sh b/DSL/CronManager/script/service_enrichment.sh index 48288333..c50a490a 100644 --- a/DSL/CronManager/script/service_enrichment.sh +++ b/DSL/CronManager/script/service_enrichment.sh @@ -8,7 +8,7 @@ if [ -z "$service_id" ] || [ -z "$name" ] || [ -z "$description" ]; then exit 1 fi -PYTHON_SCRIPT="/app/src/data_enrichment/main_enrichment.py" +PYTHON_SCRIPT="/app/src/intent_data_enrichment/main_enrichment.py" echo "[INFO] Service ID: $service_id" echo "[INFO] Service Name: $name" @@ -42,7 +42,7 @@ echo "[PACKAGES] Installing required packages..." echo "[PACKAGES] All packages installed successfully" # Set Python path -export PYTHONPATH="/app:/app/src:/app/src/data_enrichment:$PYTHONPATH" +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; } diff --git a/docker-compose-ec2.yml b/docker-compose-ec2.yml index c6b8819b..cc48c1c9 100644 --- a/docker-compose-ec2.yml +++ b/docker-compose-ec2.yml @@ -179,7 +179,7 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer - - ./src/data_enrichment:/app/src/data_enrichment + - ./src/intent_data_enrichment:/app/src/intent_data_enrichment - ./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 @@ -188,7 +188,7 @@ services: - ./.env:/app/.env:ro environment: - server.port=9010 - - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/data_enrichment + - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/intent_data_enrichment - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 5ac933ed..1fec54b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -178,7 +178,7 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer - - ./src/data_enrichment:/app/src/data_enrichment + - ./src/intent_data_enrichment:/app/src/intent_data_enrichment - ./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 +187,7 @@ services: - ./.env:/app/.env:ro environment: - server.port=9010 - - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/data_enrichment + - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/intent_data_enrichment - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py index c1341663..2aedb264 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -110,11 +110,12 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: # Step 4: Store in Qdrant logger.info("Step 3: Storing in Qdrant") qdrant = QdrantManager() - qdrant.connect() - qdrant.ensure_collection() - - success = qdrant.upsert_service(enriched_service) - qdrant.close() + try: + qdrant.connect() + qdrant.ensure_collection() + success = qdrant.upsert_service(enriched_service) + finally: + qdrant.close() if success: return EnrichmentResult( diff --git a/src/intent_data_enrichment/qdrant_manager.py b/src/intent_data_enrichment/qdrant_manager.py index 44af0e46..5024e236 100644 --- a/src/intent_data_enrichment/qdrant_manager.py +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -70,21 +70,27 @@ def ensure_collection(self) -> None: existing_vector_size = vectors_config.size if existing_vector_size is None: - logger.warning( - f"Could not determine vector size for '{self.collection_name}', recreating" + logger.error( + f"Collection '{self.collection_name}' exists but vector size cannot be determined" + ) + raise RuntimeError( + f"Collection '{self.collection_name}' exists but vector size cannot be determined. " + "This may indicate a Qdrant API issue or unexpected collection configuration. " + "Manual intervention required: verify Qdrant health, inspect collection config, " + "or manually delete the collection if recreating is intended." ) - self.client.delete_collection(self.collection_name) - self._create_collection() elif existing_vector_size != EnrichmentConstants.VECTOR_SIZE: - logger.warning( - f"Collection '{self.collection_name}' exists with wrong vector size: " + logger.error( + f"Collection '{self.collection_name}' has incompatible vector size: " f"{existing_vector_size} (expected {EnrichmentConstants.VECTOR_SIZE})" ) - logger.info( - f"Deleting and recreating collection '{self.collection_name}'" + raise RuntimeError( + f"Collection '{self.collection_name}' has incompatible vector size " + f"({existing_vector_size} vs expected {EnrichmentConstants.VECTOR_SIZE}). " + "This prevents automatic deletion to avoid accidental data loss. " + "To recreate the collection, manually delete it first using: " + f"qdrant.client.delete_collection('{self.collection_name}') or via Qdrant UI/API." ) - self.client.delete_collection(self.collection_name) - self._create_collection() else: logger.info( f"Collection '{self.collection_name}' already exists " @@ -120,7 +126,8 @@ def upsert_service(self, enriched_service: EnrichedService) -> bool: Upsert enriched service to Qdrant (update if exists, insert if new). Args: - enriched_service: Enric_CLIENT_NOT_INITIALIZED + enriched_service: EnrichedService instance containing the embedding and + associated metadata to upsert into Qdrant. Returns: True if successful, False otherwise From 9b7bc7b4d68a65ce828ef3c5c2e25c820b7ce8d0 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 20 Feb 2026 18:07:26 +0530 Subject: [PATCH 030/105] fixed issue --- DSL/Ruuter.public/rag-search/POST/services/enrich.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml index 8e427373..5748ad59 100644 --- a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml +++ b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml @@ -74,8 +74,14 @@ execute_enrichment: current_state: ${service_current_state} is_common: ${service_is_common} result: enrichment_result - next: assign_success 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'}" From 1ecad39e8662c2c2b28e905ca0f795bfce707ba8 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Sat, 21 Feb 2026 17:53:28 +0530 Subject: [PATCH 031/105] Intent enrichment pipeline (#319) * prompt coniguration backend to be testing * custom prompt configuration update and fixed Pyright issues * fixed copilot reviews * pre validation step added when user query is inserted * added more validation cases * fixed review comments * implement tool classification orchestration agent skeleton * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed copilot suggested changes * fixed issue * data enrichment pipeline for service module partially completed * complete error handling * added intent enrichment pipeline * remove unwanted file * updated changes * fixed requested changes * fixed issue --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Thiru Dinesh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: erangi-ar <111747955+erangi-ar@users.noreply.github.com> --- DSL/CronManager/DSL/service_enrichment.yml | 5 + DSL/CronManager/script/service_enrichment.sh | 103 ++++++++++ .../rag-search/POST/services/enrich.yml | 120 +++++++++++ docker-compose-ec2.yml | 3 +- docker-compose.yml | 3 +- src/intent_data_enrichment/__init__.py | 22 ++ src/intent_data_enrichment/api_client.py | 191 ++++++++++++++++++ src/intent_data_enrichment/constants.py | 46 +++++ src/intent_data_enrichment/main_enrichment.py | 187 +++++++++++++++++ src/intent_data_enrichment/models.py | 44 ++++ src/intent_data_enrichment/qdrant_manager.py | 182 +++++++++++++++++ 11 files changed, 904 insertions(+), 2 deletions(-) create mode 100644 DSL/CronManager/DSL/service_enrichment.yml create mode 100644 DSL/CronManager/script/service_enrichment.sh create mode 100644 DSL/Ruuter.public/rag-search/POST/services/enrich.yml create mode 100644 src/intent_data_enrichment/__init__.py create mode 100644 src/intent_data_enrichment/api_client.py create mode 100644 src/intent_data_enrichment/constants.py create mode 100644 src/intent_data_enrichment/main_enrichment.py create mode 100644 src/intent_data_enrichment/models.py create mode 100644 src/intent_data_enrichment/qdrant_manager.py 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/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/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/docker-compose-ec2.yml b/docker-compose-ec2.yml index 26c19068..cc48c1c9 100644 --- a/docker-compose-ec2.yml +++ b/docker-compose-ec2.yml @@ -179,6 +179,7 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer + - ./src/intent_data_enrichment:/app/src/intent_data_enrichment - ./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 +188,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 - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 diff --git a/docker-compose.yml b/docker-compose.yml index 8a9d119e..1fec54b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -178,6 +178,7 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer + - ./src/intent_data_enrichment:/app/src/intent_data_enrichment - ./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 +187,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 - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 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..fd15a6ac --- /dev/null +++ b/src/intent_data_enrichment/constants.py @@ -0,0 +1,46 @@ +"""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" + + # 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 + +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..2aedb264 --- /dev/null +++ b/src/intent_data_enrichment/main_enrichment.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Service Data Enrichment Script + +This script receives service data, enriches it with LLM-generated context, +creates embeddings, and stores in Qdrant intent_collections. +""" + +import sys +import json +import argparse +import asyncio +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 + + +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 embedding, store in Qdrant. + + Args: + service_data: Service data to enrich + + Returns: + EnrichmentResult with success/failure information + """ + try: + # Step 1: Generate rich context using LLM + 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 embedding for the context + logger.info("Step 2: Creating embedding vector") + embedding = await api_client.create_embedding(context) + logger.success(f"Embedding created: {len(embedding)}-dimensional vector") + + # Step 3: Prepare enriched service + enriched_service = 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=embedding, + ) + + # Step 4: Store in Qdrant + logger.info("Step 3: Storing in Qdrant") + qdrant = QdrantManager() + try: + qdrant.connect() + qdrant.ensure_collection() + success = qdrant.upsert_service(enriched_service) + 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", + context_length=len(context), + embedding_dimension=len(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..eb0ef64e --- /dev/null +++ b/src/intent_data_enrichment/models.py @@ -0,0 +1,44 @@ +"""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.""" + + 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="Context embedding vector") + + +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..5024e236 --- /dev/null +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -0,0 +1,182 @@ +"""Qdrant manager for intent collections.""" + +import uuid +from typing import Optional +from loguru import logger +from qdrant_client import QdrantClient +from qdrant_client.models import Distance, VectorParams, PointStruct + +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.""" + + 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 correct vector size.""" + 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: + # Check if existing collection has correct vector size + collection_info = self.client.get_collection(self.collection_name) + + # Qdrant vectors config is a dict - get the default vector config + vectors_config = collection_info.config.params.vectors + + existing_vector_size: Optional[int] = None + if isinstance(vectors_config, dict): + # Get first vector config (usually the default/unnamed one) + if vectors_config: + vector_params = next(iter(vectors_config.values())) + existing_vector_size = vector_params.size + elif vectors_config is not None: + # Direct VectorParams object (older API) + existing_vector_size = vectors_config.size + + if existing_vector_size is None: + logger.error( + f"Collection '{self.collection_name}' exists but vector size cannot be determined" + ) + raise RuntimeError( + f"Collection '{self.collection_name}' exists but vector size cannot be determined. " + "This may indicate a Qdrant API issue or unexpected collection configuration. " + "Manual intervention required: verify Qdrant health, inspect collection config, " + "or manually delete the collection if recreating is intended." + ) + elif 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}). " + "This prevents automatic deletion to avoid accidental data loss. " + "To recreate the collection, manually delete it first using: " + f"qdrant.client.delete_collection('{self.collection_name}') or via Qdrant UI/API." + ) + else: + logger.info( + f"Collection '{self.collection_name}' already exists " + f"with correct vector size ({existing_vector_size})" + ) + 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 correct vector configuration.""" + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + logger.info( + f"Creating collection '{self.collection_name}' " + f"with vector size {EnrichmentConstants.VECTOR_SIZE}" + ) + self.client.create_collection( + collection_name=self.collection_name, + vectors_config=VectorParams( + size=EnrichmentConstants.VECTOR_SIZE, + distance=Distance.COSINE, + ), + ) + logger.success(f"Collection '{self.collection_name}' created successfully") + + def upsert_service(self, enriched_service: EnrichedService) -> bool: + """ + Upsert enriched service to Qdrant (update if exists, insert if new). + + Args: + enriched_service: EnrichedService instance containing the embedding and + associated metadata to upsert into Qdrant. + + Returns: + True if successful, False otherwise + """ + try: + if not self.client: + raise RuntimeError("Qdrant client not initialized") + + logger.info(f"Upserting service '{enriched_service.id}' to Qdrant") + + # Convert service_id to UUID for Qdrant compatibility + # Qdrant requires point IDs to be either integers or UUIDs + point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, enriched_service.id)) + + # Prepare payload (all metadata except embedding) + payload = { + "service_id": enriched_service.id, # Store original ID in payload + "name": enriched_service.name, + "description": enriched_service.description, + "examples": enriched_service.examples, + "entities": enriched_service.entities, + "context": enriched_service.context, + } + + # Create point with UUID + point = PointStruct( + id=point_id, # ✓ Now using UUID string + vector=enriched_service.embedding, + payload=payload, + ) + + # Upsert to Qdrant + self.client.upsert( + collection_name=self.collection_name, + points=[point], + ) + + logger.success( + f"Successfully upserted service '{enriched_service.id}' " + f"({len(enriched_service.embedding)}-dim vector)" + ) + return True + + except Exception as e: + logger.error(f"Failed to upsert service '{enriched_service.id}': {e}") + return False + + def close(self) -> None: + """Close Qdrant connection.""" + if self.client: + logger.info("Closing Qdrant connection") + self.client.close() From a2084e59331fde9601920902d1355f6c3cb0969d Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 24 Feb 2026 15:24:40 +0530 Subject: [PATCH 032/105] service workflow implementation without calling service endpoints --- .../rag-search/POST/count-active-services.sql | 11 + .../POST/get-all-active-services.sql | 20 + .../rag-search/POST/get-service-by-id.sql | 24 + .../rag-search/GET/services/get-services.yml | 60 ++ docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 659 ++++++++++++++ src/intent_data_enrichment/constants.py | 2 + src/intent_data_enrichment/main_enrichment.py | 35 +- src/tool_classifier/classifier.py | 155 ++-- src/tool_classifier/constants.py | 64 ++ src/tool_classifier/intent_detector.py | 133 +++ .../workflows/service_workflow.py | 855 +++++++++++++++--- 11 files changed, 1827 insertions(+), 191 deletions(-) create mode 100644 DSL/Resql/rag-search/POST/count-active-services.sql create mode 100644 DSL/Resql/rag-search/POST/get-all-active-services.sql create mode 100644 DSL/Resql/rag-search/POST/get-service-by-id.sql create mode 100644 DSL/Ruuter.public/rag-search/GET/services/get-services.yml create mode 100644 docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md create mode 100644 src/tool_classifier/constants.py create mode 100644 src/tool_classifier/intent_detector.py diff --git a/DSL/Resql/rag-search/POST/count-active-services.sql b/DSL/Resql/rag-search/POST/count-active-services.sql new file mode 100644 index 00000000..d68d273f --- /dev/null +++ b/DSL/Resql/rag-search/POST/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/get-all-active-services.sql b/DSL/Resql/rag-search/POST/get-all-active-services.sql new file mode 100644 index 00000000..5bd981b7 --- /dev/null +++ b/DSL/Resql/rag-search/POST/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/get-service-by-id.sql b/DSL/Resql/rag-search/POST/get-service-by-id.sql new file mode 100644 index 00000000..dbf375ae --- /dev/null +++ b/DSL/Resql/rag-search/POST/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/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..d1ed3950 --- /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]/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]/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/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md new file mode 100644 index 00000000..bb8ad44a --- /dev/null +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -0,0 +1,659 @@ +# 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 + +``` +1. Service Discovery + ↓ +2. Service Selection (Semantic Search or LLM-based) + ↓ +3. Intent Detection (DSPy LLM Call) + ↓ +4. Entity Extraction (From LLM Output) + ↓ +5. Entity Validation (Against Service Schema) + ↓ +6. Entity Transformation (Dict → Ordered Array) + ↓ +7. Service Call (TODO: Ruuter endpoint invocation) +``` + +--- + +## 1. Service Discovery + +### Method: `_call_service_discovery()` + +Calls Ruuter public endpoint to fetch available services: + +```python +GET /rag-search/get-services-from-llm +``` + +**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=5) +``` + +--- + +## 2. Service Selection + +### 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": 5, # Top 5 services + "score_threshold": 0.4, # Minimum similarity + "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 + +--- + +## 3. 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"] + } +] + +# 2. Prepare conversation context (last 3 turns) +conversation_context = """ +user: Hello +assistant: Hi! How can I help? +user: How much is 100 EUR in USD? +""" + +# 3. Call DSPy module +intent_result = intent_detector.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 confidence < 0.7: + # Low confidence → Service workflow returns None → Fallback to 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_dict["intent_detection"] = usage_info + +# Later: orchestration_service._log_costs(costs_dict) +``` + +--- + +## 4. 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" + } +} +``` + +--- + +## 5. 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 + +--- + +## 6. 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( + entities_dict: Dict[str, str], + entity_order: List[str] +) -> List[str]: + """Transform entity dict to ordered array.""" + ordered_array = [] + + for entity_key in entity_order: + # Get value from dict, or empty string if missing + value = entities_dict.get(entity_key, "") + ordered_array.append(value) + + return ordered_array +``` + +### 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 +``` + +--- + +## 7. Service Call (TODO: Step 7) + +### Endpoint Construction + +```python +endpoint_url = f"{RUUTER_BASE_URL}{service_metadata['ruuter_url']}" +# Example: "http://ruuter:8080/currency/convert" +``` + +### Payload Construction (Planned) + +```python +payload = { + "input": entities_array, # ["USD", "EUR", "100"] + "authorId": request.authorId, + "chatId": request.chatId +} +``` + +### HTTP Call (Planned) + +```python +# Non-streaming +response = await httpx.post( + endpoint_url, + json=payload, + timeout=5.0 +) + +# Streaming +async with httpx.stream("POST", endpoint_url, json=payload) as stream: + async for line in stream.aiter_lines(): + yield orchestration_service._format_sse(chat_id, line) +``` + +--- + +## 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. Service Discovery +```json +{ + "service_count": 5, + "services": [ + { + "serviceId": "currency_conversion_eur", + "name": "Currency Conversion (EUR)", + "entities": ["target_currency"], + "examples": ["How much is EUR in USD?"] + } + ] +} +``` + +#### 2. Service Selection +```python +# Few services (5 <= 10) → Use all for intent detection +services = discovery_result["services"] +``` + +#### 3. Intent Detection (LLM Call) +```json +{ + "matched_service_id": "currency_conversion_eur", + "confidence": 0.92, + "entities": { + "target_currency": "THB" + }, + "reasoning": "User wants to convert EUR to THB" +} +``` + +#### 4. Entity Extraction +```python +entities_dict = {"target_currency": "THB"} +``` + +#### 5. Entity Validation +```python +validation_result = { + "is_valid": True, + "missing_entities": [], + "extra_entities": [], + "validation_errors": [] +} +``` + +#### 6. Entity Transformation +```python +# Schema: ["target_currency"] +# Dict: {"target_currency": "THB"} +# Array: ["THB"] +entities_array = ["THB"] +``` + +#### 7. Service Call (TODO) +```python +# Planned implementation +response = await call_service( + url="http://ruuter:8080/currency/convert", + method="POST", + payload={"input": ["THB"], "chatId": "..."} +) +``` + +--- + +## Cost Tracking + +Service workflow tracks LLM costs following the RAG workflow pattern: + +```python +# Create costs dict at workflow level +costs_dict: Dict[str, Dict[str, Any]] = {} + +# Intent detection captures costs +intent_result, intent_usage = await _detect_service_intent(...) +costs_dict["intent_detection"] = intent_usage + +# Log costs after workflow completes +orchestration_service._log_costs(costs_dict) +``` + +**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 match (confidence < 0.7) +if not intent_result or intent_result.get("confidence", 0) < 0.7: + return None # Fallback to CONTEXT layer + +# Scenario 2: Service validation failed +if not validated_service: + return None # Fallback to CONTEXT layer + +# Scenario 3: No services discovered +if not services: + 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 +# Service discovery +RUUTER_BASE_URL = "http://ruuter.public:8080" +SERVICE_DISCOVERY_TIMEOUT = 5.0 # seconds + +# Service selection thresholds +SERVICE_COUNT_THRESHOLD = 10 # Switch to semantic search if exceeded +MAX_SERVICES_FOR_LLM_CONTEXT = 20 # Max services to pass to LLM + +# Semantic search +QDRANT_COLLECTION = "services_collection" +SEMANTIC_SEARCH_TOP_K = 5 # Top 5 relevant services +SEMANTIC_SEARCH_THRESHOLD = 0.4 # Minimum similarity score +QDRANT_TIMEOUT = 2.0 # seconds + +# Intent detection +INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed +``` + +--- + +## 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. **Two-Stage Service Selection** +- Few services (≤10): Pass all to LLM +- Many services (>10): Semantic search first + +### 4. **LLM-Based Intent Detection** +- 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 + +--- + +## Summary + +The Tool Classifier's layer architecture enables intelligent query routing with graceful fallbacks. The Service Workflow (Layer 1) uses **LLM-based intent detection** to match user queries to external services, extract entities, validate them against service schemas, and prepare them for service invocation—all while maintaining comprehensive cost tracking and seamless integration with the broader RAG pipeline. diff --git a/src/intent_data_enrichment/constants.py b/src/intent_data_enrichment/constants.py index fd15a6ac..f1f35f38 100644 --- a/src/intent_data_enrichment/constants.py +++ b/src/intent_data_enrichment/constants.py @@ -43,4 +43,6 @@ class EnrichmentConstants: - 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 index 2aedb264..d718678e 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -91,12 +91,35 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: context = await api_client.generate_context(service_data) logger.success(f"Context generated: {len(context)} characters") - # Step 2: Create embedding for the context - logger.info("Step 2: Creating embedding vector") - embedding = await api_client.create_embedding(context) + # Step 2: Combine generated context with original metadata for embedding + logger.info("Step 2: Combining context with original service metadata") + 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)}" + ) + + # Add generated context last (enriched understanding) + combined_text_parts.append(f"Enriched Context: {context}") + + combined_text = "\n".join(combined_text_parts) + logger.info(f"Combined text length: {len(combined_text)} characters") + + # Step 3: Create embedding for combined text + logger.info("Step 3: Creating embedding vector for combined text") + embedding = await api_client.create_embedding(combined_text) logger.success(f"Embedding created: {len(embedding)}-dimensional vector") - # Step 3: Prepare enriched service + # Step 4: Prepare enriched service enriched_service = EnrichedService( id=service_data.service_id, name=service_data.name, @@ -107,8 +130,8 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: embedding=embedding, ) - # Step 4: Store in Qdrant - logger.info("Step 3: Storing in Qdrant") + # Step 5: Store in Qdrant + logger.info("Step 5: Storing in Qdrant") qdrant = QdrantManager() try: qdrant.connect() diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 71a45922..ec9dc953 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -55,6 +55,7 @@ def __init__( # Initialize workflow executors self.service_workflow = ServiceWorkflowExecutor( llm_manager=llm_manager, + orchestration_service=orchestration_service, ) self.context_workflow = ContextWorkflowExecutor( llm_manager=llm_manager, @@ -75,10 +76,11 @@ async def classify( """ Classify a user query to determine which workflow should handle it. - Implements layer-wise classification logic: - 1. Check if SERVICE workflow can handle (intent detection) - 2. Check if CONTEXT workflow can handle (greeting/history check) - 3. Default to RAG workflow (knowledge retrieval) + Implements layer-wise classification logic with fallback chain: + 1. SERVICE workflow (external API calls) + 2. CONTEXT workflow (greetings/conversation history) + 3. RAG workflow (knowledge base retrieval) + 4. OOD workflow (out-of-domain) Args: query: User's query string @@ -87,60 +89,15 @@ async def classify( Returns: ClassificationResult indicating which workflow to use - - Note: - In this skeleton, always defaults to RAG. Full implementation - will add Layer 1 and Layer 2 logic in separate tasks. """ logger.info(f"Classifying query: {query[:100]}...") - # TODO: LAYER 1 - SERVICE WORKFLOW DETECTION - # Implementation task: Service workflow implementation - # Logic: - # 1. Count active services in database - # 2. If count > 50: Use Qdrant semantic search for top 20 services - # 3. If count <= 50: Use all services - # 4. Call LLM to detect intent and extract entities - # 5. If intent detected and service valid: return SERVICE classification - # Example: - # service_check = await self._check_service_layer(query, language) - # if service_check.can_handle: - # return ClassificationResult( - # workflow=WorkflowType.SERVICE, - # confidence=service_check.confidence, - # metadata=service_check.metadata, - # reasoning="Service intent detected" - # ) - - # TODO: LAYER 2 - CONTEXT WORKFLOW DETECTION - # Implementation task: Context workflow implementation - # Logic: - # 1. Check if query is a greeting using LLM - # 2. If greeting: return CONTEXT classification - # 3. If conversation_history exists: Check if query references history - # 4. Call LLM to determine if history contains answer - # 5. If can answer from history: return CONTEXT classification - # Example: - # context_check = await self._check_context_layer( - # query, conversation_history, language - # ) - # if context_check.can_handle: - # return ClassificationResult( - # workflow=WorkflowType.CONTEXT, - # confidence=context_check.confidence, - # metadata=context_check.metadata, - # reasoning="Greeting or answerable from history" - # ) - - # LAYER 3 - RAG WORKFLOW (DEFAULT) - # Always defaults to RAG for now - # RAG workflow will handle the query or return OOD if no chunks found - logger.info("Defaulting to RAG workflow (Layers 1-2 not implemented)") + logger.info("Starting layer-wise fallback: ") return ClassificationResult( - workflow=WorkflowType.RAG, + workflow=WorkflowType.SERVICE, confidence=1.0, metadata={}, - reasoning="Default to RAG workflow (service and context layers not implemented)", + reasoning="Start with Service workflow - will cascade through layers", ) @overload @@ -235,10 +192,7 @@ async def _execute_with_fallback_async( """ Execute workflow with fallback to subsequent layers (non-streaming). - TODO: Implement full fallback chain logic - Currently just executes the primary workflow. - - Full implementation should: + Implementation: 1. Try primary workflow 2. If returns None, try next layer in WORKFLOW_LAYER_ORDER 3. Continue until workflow returns non-None result @@ -256,19 +210,38 @@ async def _execute_with_fallback_async( logger.info(f"[{chat_id}] {workflow_name} handled successfully") return result - # TODO: Implement fallback to next layer - # For now, if workflow returns None, call RAG as fallback - logger.warning( + # Implement layer-wise fallback chain + logger.info( f"[{chat_id}] {workflow_name} returned None, " - f"falling back to RAG workflow" + f"trying next layer in fallback chain" ) - rag_result = await self.rag_workflow.execute_async(request, {}) - if rag_result is not None: - return rag_result - else: - # This should never happen since RAG always returns a result - # But handle gracefully - raise RuntimeError("RAG workflow returned None unexpectedly") + + # Get the layer order starting from current layer + from tool_classifier.enums import WORKFLOW_LAYER_ORDER + + 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: + 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} (Layer {current_index + 2})" + ) + + result = await next_workflow.execute_async(request, {}) + + 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...") + current_index += 1 + + # 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}") @@ -290,10 +263,7 @@ async def _execute_with_fallback_streaming( """ Execute workflow with fallback to subsequent layers (streaming). - TODO: Implement full fallback chain logic - Currently just executes the primary workflow. - - Full implementation should: + 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 @@ -313,18 +283,41 @@ async def _execute_with_fallback_streaming( yield chunk return - # TODO: Implement fallback to next layer - # For now, if workflow returns None, call RAG as fallback - logger.warning( + # Implement layer-wise fallback chain for streaming + logger.info( f"[{chat_id}] {workflow_name} returned None, " - f"falling back to RAG workflow streaming" + f"trying next layer in fallback chain" ) - streaming_result = await self.rag_workflow.execute_streaming(request, {}) - if streaming_result is not None: - async for chunk in streaming_result: - yield chunk - else: - raise RuntimeError("RAG workflow returned None unexpectedly") + + # Get the layer order starting from current layer + from tool_classifier.enums import WORKFLOW_LAYER_ORDER + + 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: + 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} streaming " + f"(Layer {current_index + 2})" + ) + + result = await next_workflow.execute_streaming(request, {}) + + 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...") + current_index += 1 + + # 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}") diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py new file mode 100644 index 00000000..a2a17f76 --- /dev/null +++ b/src/tool_classifier/constants.py @@ -0,0 +1,64 @@ +"""Constants and configuration for tool classifier module.""" + + +# ============================================================================ +# Qdrant Vector Database Configuration +# ============================================================================ + +import os + + +QDRANT_HOST = "qdrant" +"""Qdrant server hostname.""" + +QDRANT_PORT = int("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.""" + +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.""" + + +# ============================================================================ +# 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.""" diff --git a/src/tool_classifier/intent_detector.py b/src/tool_classifier/intent_detector.py new file mode 100644 index 00000000..24c1538a --- /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 ChainOfThought.""" + super().__init__() + self.detector = dspy.ChainOfThought(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/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index 8a6889bc..9c7f83ef 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -1,137 +1,784 @@ """Service workflow executor - Layer 1: External service/API calls.""" -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, List, Optional, Protocol + +import dspy +import httpx from loguru import logger -from models.request_models import OrchestrationRequest, OrchestrationResponse +from src.utils.cost_utils import get_lm_usage_since + +from models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, +) 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_BASE_URL, + SEMANTIC_SEARCH_THRESHOLD, + SEMANTIC_SEARCH_TOP_K, + SERVICE_COUNT_THRESHOLD, + SERVICE_DISCOVERY_TIMEOUT, +) +from tool_classifier.intent_detector import IntentDetectionModule -class ServiceWorkflowExecutor(BaseWorkflow): - """ - Executes external service calls via Ruuter endpoints (Layer 1). - - This workflow handles queries that require calling external government - services or APIs. It performs: - 1. Service discovery (semantic search if >50 services) - 2. Intent detection using LLM - 3. Entity extraction from query - 4. Service validation against database - 5. External API call via Ruuter - 6. Output guardrails validation - - Examples of Service queries: - - "What's the EUR to USD exchange rate?" - - "Check my document status" - - "Submit a tax declaration" - - Implementation Status: SKELETON - Returns None (triggers fallback to Context workflow) - - TODO - Full Implementation (Separate Task): - - Service discovery logic (Qdrant semantic search) - - Intent detection (LLM-based) - - Entity extraction and transformation - - Service validation (database lookup) - - Ruuter API integration - - Output guardrails for service responses - """ - - def __init__(self, llm_manager: Any): +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) -> str: + """Format content as SSE message. + + Args: + chat_id: Chat/channel identifier + content: Content to send (token, "END", error message, etc.) + + Returns: + SSE-formatted string: "data: {json}\\n\\n" """ - Initialize service workflow executor. + ... + + def _log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: + """Log cost information for tracking. Args: - llm_manager: LLM manager for intent detection + costs_dict: Dictionary of costs per component """ + ... + + +class ServiceWorkflowExecutor(BaseWorkflow): + """Executes external service calls via Ruuter endpoints (Layer 1).""" + + def __init__( + self, + llm_manager: Any, + orchestration_service: Optional[LLMServiceProtocol] = None, + ) -> None: + """Initialize service workflow executor.""" self.llm_manager = llm_manager - logger.info("Service workflow executor initialized (skeleton)") + self.orchestration_service = orchestration_service + self._qdrant_client: Optional[httpx.AsyncClient] = None - async def execute_async( + async def _get_qdrant_client(self) -> httpx.AsyncClient: + """Get or create Qdrant HTTP client (lazy initialization).""" + if self._qdrant_client is None: + qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" + self._qdrant_client = httpx.AsyncClient( + base_url=qdrant_url, timeout=QDRANT_TIMEOUT + ) + return self._qdrant_client + + async def _semantic_search_services( self, + query: str, request: OrchestrationRequest, - context: Dict[str, Any], - ) -> Optional[OrchestrationResponse]: + chat_id: str, + top_k: int = SEMANTIC_SEARCH_TOP_K, + ) -> Optional[List[Dict[str, Any]]]: + """Search services using semantic search via Qdrant.""" + if not self.orchestration_service: + logger.error( + f"[{chat_id}] Semantic search unavailable: orchestration service not provided" + ) + return None + + try: + # Generate embedding using orchestration service + 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] + + # Verify collection exists and has data + client = await self._get_qdrant_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 Qdrant collection + client = await self._get_qdrant_client() + + 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 + + # Transform Qdrant results to service format + 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", []), + # Note: endpoint not stored in intent_collections, + # will be resolved via database lookup if needed + "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 10s") + 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: + # Ensure DSPy is configured with LLMManager + if self.llm_manager: + self.llm_manager.ensure_global_config() + else: + logger.error(f"[{chat_id}] LLM Manager not available") + return None, {} + + # Capture history length before LLM call for cost tracking + lm = dspy.settings.lm + history_length_before = ( + len(lm.history) if lm and hasattr(lm, "history") else 0 + ) + + # Create DSPy module + intent_module = IntentDetectionModule() + + # Convert conversation history to dict format + history_dicts = [ + {"authorRole": msg.authorRole, "message": msg.message} + for msg in conversation_history + if hasattr(msg, "authorRole") and hasattr(msg, "message") + ] + + # Call DSPy forward with task-local config + with self.llm_manager.use_task_local(): + intent_result = intent_module.forward( + user_query=user_query, + services=services, + conversation_history=history_dicts, + ) + + # Extract usage information after LLM call + 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 + + def _extract_service_metadata( + self, context: Dict[str, Any], chat_id: str + ) -> Optional[Dict[str, Any]]: + """Extract service and entity metadata from context.""" + # Check if service_id exists + service_id = context.get("service_id") + if not service_id: + logger.error(f"[{chat_id}] Missing service_id in context") + return None + + # Check if service_data exists + service_data = context.get("service_data") + if not service_data: + logger.error(f"[{chat_id}] Missing service_data in context") + return None + + # Extract entities dict from context (LLM extracted) + entities_dict = context.get("entities", {}) + + # Extract entity schema from service_data (expected order) + entity_schema = service_data.get("entities", []) + if entity_schema is None: + entity_schema = [] + + # Extract service name + service_name = service_data.get("name", service_id) + + # Extract HTTP method (ruuter_type) - defaults to GET if not specified + ruuter_type = service_data.get("ruuter_type", "GET") + + return { + "service_id": service_id, + "service_name": service_name, + "entities_dict": entities_dict, + "entity_schema": entity_schema, + "ruuter_type": ruuter_type, + "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]: """ - Execute service workflow in non-streaming mode. - - TODO: Implement service workflow logic: - 1. Extract service metadata from context (service_id, intent, entities) - 2. Validate service exists and is active in database - 3. Transform entities to array format for service call - 4. Call Ruuter endpoint: POST {RUUTER_BASE_URL}/services/active{ServiceName} - 5. Validate response with output guardrails - 6. Return OrchestrationResponse with service result - - Failure scenarios: - - No service_id in context → return None (fallback to Context) - - Service not found/inactive → return None (fallback to Context) - - Service call timeout → return error response - - Output guardrails blocked → return violation response or None + Validate extracted entities against service schema. Args: - request: Orchestration request with user query - context: Metadata with service_id, intent, entities + 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: - OrchestrationResponse with service result or None to fallback + 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 """ - logger.debug( - f"[{request.chatId}] Service workflow execute_async called " - f"(not implemented - returning None)" + 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) + for entity_key in extracted_entities: + if entity_key not in service_schema: + extra_entities.append(entity_key) + + # Determine overall validity + # We consider it valid even with missing entities (will send empty strings) + # Let the external service validate required parameters + is_valid = True # Always true - we proceed with partial entities + + 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 [] + + # Transform to ordered array, filling missing with empty strings + return [entities_dict.get(key, "") for key in entity_order] + + def _construct_service_endpoint(self, service_name: str, chat_id: str) -> str: + """Construct the full service endpoint URL for Ruuter.""" + return f"{RUUTER_BASE_URL}/services/active{service_name}" + + def _format_debug_response( + self, + service_name: str, + endpoint_url: str, + http_method: str, + entities_array: List[str], + ) -> str: + """Format debug information for testing (temporary before Step 7 implementation).""" + entities_str = ", ".join(f'"{e}"' for e in entities_array) + return ( + f" Service Validated: {service_name}\n" + f" Endpoint URL: {endpoint_url}\n" + f" HTTP Method: {http_method}\n" + f" Extracted Entities: [{entities_str}]\n\n" ) - # TODO: Implement service workflow logic here - # For now, return None to trigger fallback to next layer - return None + async def _log_request_details( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + mode: str, + costs_dict: 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_dict: Dictionary to accumulate cost tracking information + """ + chat_id = request.chatId + logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") + + # Service Discovery + discovery_result = await self._call_service_discovery(chat_id) + + if discovery_result: + # Extract data from nested response structure + response_data = discovery_result.get("response", {}) + use_semantic = response_data.get("use_semantic_search", False) + service_count = response_data.get("service_count", 0) + + # Handle service_count if it's a string or NaN + if isinstance(service_count, str): + try: + service_count = int(service_count) + except (ValueError, TypeError): + service_count = 0 + + services_from_ruuter = response_data.get("services", []) + + # Use semantic search if count > threshold + if service_count > SERVICE_COUNT_THRESHOLD: + use_semantic = True + + if use_semantic: + # Use semantic search to find relevant services + 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: + intent_result, intent_usage = await self._detect_service_intent( + user_query=request.message, + services=services, + conversation_history=request.conversationHistory, + chat_id=chat_id, + ) + costs_dict["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 + else: + services = response_data.get("services", []) + + if services: + intent_result, intent_usage = await self._detect_service_intent( + user_query=request.message, + services=services, + conversation_history=request.conversationHistory, + chat_id=chat_id, + ) + costs_dict["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 + else: + logger.warning(f"[{chat_id}] Service discovery failed") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """Execute service workflow in non-streaming mode.""" + chat_id = request.chatId + + # Create costs tracking dictionary (follows RAG workflow pattern) + costs_dict: Dict[str, Dict[str, Any]] = {} + + # Log comprehensive request details and perform service discovery + await self._log_request_details( + request, context, mode="non-streaming", costs_dict=costs_dict + ) + + # Check if service was detected and validated + if not context.get("service_id"): + logger.info( + f"[{chat_id}] No service detected or validated - " + f"returning None to fallback to next layer" + ) + return None + + # Entity Transformation & Validation + logger.info(f"[{chat_id}] Entity Transformation:") + + # Step 1: Extract service metadata from context + service_metadata = self._extract_service_metadata(context, chat_id) + if not service_metadata: + logger.error( + f"[{chat_id}] - Metadata extraction failed - " + f"returning None to fallback" + ) + return None + + logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") + logger.info( + f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" + ) + logger.info( + f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + ) + + # Step 2: Validate entities against schema + 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, + ) + + logger.info( + f"[{chat_id}] - Validation status: " + f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" + ) + + if validation_result["missing_entities"]: + logger.warning( + f"[{chat_id}] - Missing entities (will send empty strings): " + f"{validation_result['missing_entities']}" + ) + + if validation_result["extra_entities"]: + logger.info( + f"[{chat_id}] - Extra entities (ignored): " + f"{validation_result['extra_entities']}" + ) + + if validation_result["validation_errors"]: + for error in validation_result["validation_errors"]: + logger.warning(f"[{chat_id}] - Validation warning: {error}") + + # Step 3: Transform entities dict to ordered array + 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 + + # Construct service endpoint URL + endpoint_url = self._construct_service_endpoint( + service_name=service_metadata["service_name"], chat_id=chat_id + ) + + context["endpoint_url"] = endpoint_url + context["http_method"] = service_metadata["ruuter_type"] + + logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") + + # TODO: STEP 7 - Call Ruuter service endpoint and return response + # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} + # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT + # 3. Parse Ruuter response and extract result + # 4. Return OrchestrationResponse with actual service result + # 5. Handle errors (timeout, HTTP errors, malformed JSON) + # 6. Remove debug response code below (lines 589-601) after implementation + + # STEP 6: Return debug response (temporary until Step 7 - Ruuter call implemented) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) + debug_content = self._format_debug_response( + service_name=service_metadata["service_name"], + endpoint_url=endpoint_url, + http_method=service_metadata["ruuter_type"], + entities_array=entities_array, + ) + + logger.info(f"[{chat_id}] Returning debug response (Step 7 pending)") + + # Log costs after service workflow completes (follows RAG workflow pattern) + if self.orchestration_service: + self.orchestration_service._log_costs(costs_dict) + + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=debug_content, + ) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], ) -> Optional[AsyncIterator[str]]: - """ - Execute service workflow in streaming mode. - - TODO: Implement service workflow streaming: - 1. Execute service call (same as non-streaming) - 2. Get complete service response - 3. Validate with output guardrails (validation-first) - 4. If blocked: yield violation message + END - 5. If allowed: chunk response and stream token-by-token - 6. Simulate streaming for consistent UX with RAG - - Streaming approach (validation-first): - ```python - # Get complete response - service_response = await call_service(...) - - # Validate BEFORE streaming - is_safe = await guardrails.check_output_async(service_response) - if not is_safe: - yield format_sse(chatId, VIOLATION_MESSAGE) - yield format_sse(chatId, "END") - return - - # Stream validated response - for chunk in split_into_tokens(service_response, chunk_size=5): - yield format_sse(chatId, chunk) - await asyncio.sleep(0.01) - yield format_sse(chatId, "END") - ``` + """Execute service workflow in streaming mode.""" + chat_id = request.chatId - Args: - request: Orchestration request with user query - context: Metadata with service_id, intent, entities + # Create costs tracking dictionary (follows RAG workflow pattern) + costs_dict: Dict[str, Dict[str, Any]] = {} - Returns: - AsyncIterator yielding SSE strings or None to fallback - """ - logger.debug( - f"[{request.chatId}] Service workflow execute_streaming called " - f"(not implemented - returning None)" + # Log comprehensive request details and perform service discovery + await self._log_request_details( + request, context, mode="streaming", costs_dict=costs_dict ) - # TODO: Implement service streaming logic here - # For now, return None to trigger fallback to next layer - return None + # Check if service was detected and validated + if not context.get("service_id"): + logger.info( + f"[{chat_id}] No service detected or validated - " + f"returning None to fallback to next layer" + ) + return None + + # Entity Transformation & Validation + logger.info(f"[{chat_id}] Entity Transformation:") + + # Step 1: Extract service metadata from context + service_metadata = self._extract_service_metadata(context, chat_id) + if not service_metadata: + logger.error( + f"[{chat_id}] - Metadata extraction failed - " + f"returning None to fallback" + ) + return None + + logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") + logger.info( + f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" + ) + logger.info( + f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + ) + + # Step 2: Validate entities against schema + 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, + ) + + logger.info( + f"[{chat_id}] - Validation status: " + f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" + ) + + if validation_result["missing_entities"]: + logger.warning( + f"[{chat_id}] - Missing entities (will send empty strings): " + f"{validation_result['missing_entities']}" + ) + + if validation_result["extra_entities"]: + logger.info( + f"[{chat_id}] - Extra entities (ignored): " + f"{validation_result['extra_entities']}" + ) + + if validation_result["validation_errors"]: + for error in validation_result["validation_errors"]: + logger.warning(f"[{chat_id}] - Validation warning: {error}") + + # Step 3: Transform entities dict to ordered array + 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 + + # Construct service endpoint URL + endpoint_url = self._construct_service_endpoint( + service_name=service_metadata["service_name"], chat_id=chat_id + ) + + context["endpoint_url"] = endpoint_url + context["http_method"] = service_metadata["ruuter_type"] + + logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") + + # TODO: STEP 7 - Call Ruuter service endpoint and stream response + # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} + # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT + # 3. Parse Ruuter response and extract result + # 4. Format result as SSE and yield chunks + # 5. Handle errors (timeout, HTTP errors, malformed JSON) + # 6. Remove debug response code below (lines 697-709) after implementation + + # STEP 6: Return debug response as async iterator (temporary until Step 7) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) + debug_content = self._format_debug_response( + service_name=service_metadata["service_name"], + endpoint_url=endpoint_url, + http_method=service_metadata["ruuter_type"], + entities_array=entities_array, + ) + + logger.info(f"[{chat_id}] Streaming debug response (Step 7 pending)") + + if self.orchestration_service is None: + raise RuntimeError("Orchestration service not initialized for streaming") + + # Store reference for closure (helps type checker) + orchestration_service = self.orchestration_service + + async def debug_stream() -> AsyncIterator[str]: + yield orchestration_service._format_sse(chat_id, debug_content) + yield orchestration_service._format_sse(chat_id, "END") + + # Log costs after streaming completes (follows RAG workflow pattern) + # Must be inside generator because costs are accumulated during streaming + orchestration_service._log_costs(costs_dict) + + return debug_stream() + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) From 864ad30682ecf88a858bbb6bbe11e4d120cd05bb Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 24 Feb 2026 17:08:07 +0530 Subject: [PATCH 033/105] fixed requested changes --- src/tool_classifier/constants.py | 3 - .../workflows/service_workflow.py | 238 +++++++++--------- 2 files changed, 125 insertions(+), 116 deletions(-) diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index a2a17f76..e967e3c2 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -5,9 +5,6 @@ # Qdrant Vector Database Configuration # ============================================================================ -import os - - QDRANT_HOST = "qdrant" """Qdrant server hostname.""" diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index 9c7f83ef..9cd8a766 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -84,16 +84,6 @@ def __init__( """Initialize service workflow executor.""" self.llm_manager = llm_manager self.orchestration_service = orchestration_service - self._qdrant_client: Optional[httpx.AsyncClient] = None - - async def _get_qdrant_client(self) -> httpx.AsyncClient: - """Get or create Qdrant HTTP client (lazy initialization).""" - if self._qdrant_client is None: - qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" - self._qdrant_client = httpx.AsyncClient( - base_url=qdrant_url, timeout=QDRANT_TIMEOUT - ) - return self._qdrant_client async def _semantic_search_services( self, @@ -102,7 +92,11 @@ async def _semantic_search_services( chat_id: str, top_k: int = SEMANTIC_SEARCH_TOP_K, ) -> Optional[List[Dict[str, Any]]]: - """Search services using semantic search via Qdrant.""" + """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" @@ -125,73 +119,76 @@ async def _semantic_search_services( query_embedding = embeddings[0] - # Verify collection exists and has data - client = await self._get_qdrant_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 Qdrant collection - client = await self._get_qdrant_client() - - 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, - ) + # Create Qdrant client with proper resource cleanup via context manager + qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" + async with httpx.AsyncClient( + base_url=qdrant_url, timeout=QDRANT_TIMEOUT + ) as client: + # Verify collection exists and has data + 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 Qdrant collection + search_payload = { + "vector": query_embedding, + "limit": top_k, + "score_threshold": SEMANTIC_SEARCH_THRESHOLD, + "with_payload": True, + } - if response.status_code != 200: - logger.error( - f"[{chat_id}] Qdrant search failed: HTTP {response.status_code}" + response = await client.post( + f"/collections/{QDRANT_COLLECTION}/points/search", + json=search_payload, ) - 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 + if response.status_code != 200: + logger.error( + f"[{chat_id}] Qdrant search failed: HTTP {response.status_code}" + ) + return None - # Transform Qdrant results to service format - 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", []), - # Note: endpoint not stored in intent_collections, - # will be resolved via database lookup if needed - "similarity_score": score, - } - services.append(service) + search_results = response.json() + points = search_results.get("result", []) - logger.info( - f"[{chat_id}] Found {len(services)} services via semantic search" - ) - return services + if len(points) == 0: + logger.warning( + f"[{chat_id}] No services matched (threshold={SEMANTIC_SEARCH_THRESHOLD})" + ) + return None + + # Transform Qdrant results to service format + 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", []), + # Note: endpoint not stored in intent_collections, + # will be resolved via database lookup if needed + "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) @@ -291,6 +288,53 @@ def _validate_detected_service( ) return None + async def _process_intent_detection( + self, + services: List[Dict[str, Any]], + request: OrchestrationRequest, + chat_id: str, + context: Dict[str, Any], + costs_dict: 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_dict: 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_dict["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]]: @@ -478,56 +522,24 @@ async def _log_request_details( services = [] if services: - intent_result, intent_usage = await self._detect_service_intent( - user_query=request.message, + await self._process_intent_detection( services=services, - conversation_history=request.conversationHistory, + request=request, chat_id=chat_id, + context=context, + costs_dict=costs_dict, ) - costs_dict["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 else: services = response_data.get("services", []) if services: - intent_result, intent_usage = await self._detect_service_intent( - user_query=request.message, + await self._process_intent_detection( services=services, - conversation_history=request.conversationHistory, + request=request, chat_id=chat_id, + context=context, + costs_dict=costs_dict, ) - costs_dict["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 else: logger.warning(f"[{chat_id}] Service discovery failed") From 25f9614a7b451426b78ae03f23b6cb0ab65535e0 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 24 Feb 2026 18:43:21 +0530 Subject: [PATCH 034/105] fixed issues --- docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 5 +++-- src/tool_classifier/constants.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md index bb8ad44a..6be3f8e2 100644 --- a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -440,8 +440,9 @@ def _transform_entities_to_array( ### Endpoint Construction ```python -endpoint_url = f"{RUUTER_BASE_URL}{service_metadata['ruuter_url']}" -# Example: "http://ruuter:8080/currency/convert" +endpoint_url = f"{RUUTER_BASE_URL}/services/active{service_metadata['service_name']}" +# Example: "http://ruuter:8080/services/active/currency-conversion" +# (Note: service_name from service metadata, e.g., "/currency-conversion") ``` ### Payload Construction (Planned) diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index e967e3c2..c885b524 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -8,7 +8,7 @@ QDRANT_HOST = "qdrant" """Qdrant server hostname.""" -QDRANT_PORT = int("6333") +QDRANT_PORT = 6333 """Qdrant server port.""" QDRANT_TIMEOUT = 10.0 @@ -38,7 +38,6 @@ """Base URL for Ruuter private service endpoints.""" RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" - """Public Ruuter endpoint for RAG search service discovery.""" SERVICE_CALL_TIMEOUT = 10 From 69c12799bd7853d6b4fbd9d4cf5f0fff15bad945 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 24 Feb 2026 20:17:04 +0530 Subject: [PATCH 035/105] protocol related requested changes --- docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 6 +- docs/TOOL_CLASSIFIER_SKELETON_USAGE.md | 4 +- src/llm_orchestration_service.py | 82 +++++++++---------- src/tool_classifier/workflows/ood_workflow.py | 4 +- src/tool_classifier/workflows/rag_workflow.py | 2 +- .../workflows/service_workflow.py | 12 +-- 6 files changed, 55 insertions(+), 55 deletions(-) diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md index 6be3f8e2..afd4303f 100644 --- a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -246,7 +246,7 @@ intent_result = intent_module.forward(...) usage_info = get_lm_usage_since(history_length_before) costs_dict["intent_detection"] = usage_info -# Later: orchestration_service._log_costs(costs_dict) +# Later: orchestration_service.log_costs(costs_dict) ``` --- @@ -468,7 +468,7 @@ response = await httpx.post( # Streaming async with httpx.stream("POST", endpoint_url, json=payload) as stream: async for line in stream.aiter_lines(): - yield orchestration_service._format_sse(chat_id, line) + yield orchestration_service.format_sse(chat_id, line) ``` --- @@ -564,7 +564,7 @@ intent_result, intent_usage = await _detect_service_intent(...) costs_dict["intent_detection"] = intent_usage # Log costs after workflow completes -orchestration_service._log_costs(costs_dict) +orchestration_service.log_costs(costs_dict) ``` **Cost Breakdown Logged:** diff --git a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md index 9dc87c88..38ce1f53 100644 --- a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md +++ b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md @@ -361,9 +361,9 @@ class MyCustomWorkflow(BaseWorkflow): # Stream result token-by-token async def stream_result(): for chunk in self._split_into_tokens(result): - yield self._format_sse(request.chatId, chunk) + yield self.format_sse(request.chatId, chunk) await asyncio.sleep(0.01) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") return stream_result() ``` diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 3c059f59..e2eb0c9e 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -274,7 +274,7 @@ async def process_orchestration_request( ) # Log final costs and return response - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget for the LLM connection @@ -331,7 +331,7 @@ async def process_orchestration_request( } ) langfuse.flush() - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on error @@ -405,8 +405,8 @@ async def stream_orchestration_response( ) # Yield SSE format error + END marker - yield self._format_sse(request.chatId, validation_msg) - yield self._format_sse(request.chatId, "END") + 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 @@ -441,11 +441,11 @@ async def stream_orchestration_response( f"[{request.chatId}] [{stream_ctx.stream_id}] Input blocked by guardrails: " f"{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_dict) stream_ctx.mark_completed() return @@ -500,7 +500,7 @@ async def stream_orchestration_response( ) # Log costs and timings - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) stream_ctx.mark_completed() return # Exit after successful classifier routing @@ -546,10 +546,10 @@ async def stream_orchestration_response( logger, error_id, "streaming_orchestration", request.chatId, e ) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) + yield self.format_sse(request.chatId, "END") - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on outer exception @@ -645,9 +645,9 @@ async def _stream_rag_pipeline( 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_dict) + 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 @@ -659,9 +659,9 @@ async def _stream_rag_pipeline( 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_dict) + 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 @@ -690,9 +690,9 @@ async def _stream_rag_pipeline( 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_dict) + 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 @@ -755,14 +755,14 @@ async def bot_response_generator() -> AsyncIterator[str]: f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded: " f"{stream_ctx.token_count} > {StreamConfig.MAX_TOKENS_PER_STREAM}" ) - yield self._format_sse( + yield self.format_sse( request.chatId, STREAM_TOKEN_LIMIT_MESSAGE ) - yield self._format_sse(request.chatId, "END") + 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) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) stream_ctx.mark_completed() return @@ -784,20 +784,20 @@ async def bot_response_generator() -> AsyncIterator[str]: logger.warning( f"[{request.chatId}] [{stream_ctx.stream_id}] Guardrails violation detected" ) - yield self._format_sse( + yield self.format_sse( request.chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE ) - yield self._format_sse(request.chatId, "END") + 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) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) stream_ctx.mark_completed() return # Yield the validated chunk to client - yield self._format_sse(request.chatId, validated_chunk) + yield self.format_sse(request.chatId, validated_chunk) except GeneratorExit: stream_ctx.mark_cancelled() logger.info( @@ -816,9 +816,9 @@ async def bot_response_generator() -> AsyncIterator[str]: f"{i + 1}. [{ref.document_url}]({ref.document_url})" for i, ref in enumerate(doc_references) ) - yield self._format_sse(request.chatId, refs_text) + yield self.format_sse(request.chatId, refs_text) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") else: # No guardrails - stream directly @@ -837,14 +837,14 @@ async def bot_response_generator() -> AsyncIterator[str]: logger.error( f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded (no guardrails)" ) - yield self._format_sse( + yield self.format_sse( request.chatId, STREAM_TOKEN_LIMIT_MESSAGE ) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") stream_ctx.mark_completed() return - yield self._format_sse(request.chatId, token) + yield self.format_sse(request.chatId, token) # Send document references before END token doc_references = self._extract_document_references(relevant_chunks) @@ -853,9 +853,9 @@ async def bot_response_generator() -> AsyncIterator[str]: f"{i + 1}. [{ref.document_url}]({ref.document_url})" for i, ref in enumerate(doc_references) ) - yield self._format_sse(request.chatId, refs_text) + yield self.format_sse(request.chatId, refs_text) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") # Extract usage information after streaming completes usage_info = get_lm_usage_since(history_length_before) @@ -872,7 +872,7 @@ async def bot_response_generator() -> AsyncIterator[str]: ) # Log costs and trace - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget @@ -935,7 +935,7 @@ async def bot_response_generator() -> AsyncIterator[str]: ) usage_info = get_lm_usage_since(history_length_before) costs_dict["streaming_generation"] = usage_info - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on client disconnect @@ -953,12 +953,12 @@ async def bot_response_generator() -> AsyncIterator[str]: request.chatId, stream_error, ) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) - yield self._format_sse(request.chatId, "END") + 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) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on streaming error @@ -966,7 +966,7 @@ async def bot_response_generator() -> AsyncIterator[str]: request.connection_id, costs_dict, request.environment ) - def _format_sse(self, chat_id: str, content: str) -> str: + def format_sse(self, chat_id: str, content: str) -> str: """ Format SSE message with exact specification. @@ -1885,7 +1885,7 @@ async def _check_output_guardrails( usage={}, ) - def _log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: + def log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: """ Log cost information for tracking. diff --git a/src/tool_classifier/workflows/ood_workflow.py b/src/tool_classifier/workflows/ood_workflow.py index fed467a5..cd114f70 100644 --- a/src/tool_classifier/workflows/ood_workflow.py +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -104,9 +104,9 @@ async def execute_streaming( # 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) + yield self.format_sse(request.chatId, chunk) await asyncio.sleep(0.01) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") return stream_ood_message() ``` diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index d83080a7..6c586481 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -87,7 +87,7 @@ async def execute_async( ) # Log costs and timings - self.orchestration_service._log_costs(costs_dict) + self.orchestration_service.log_costs(costs_dict) from src.utils.time_tracker import log_step_timings log_step_timings(timing_dict, request.chatId) diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index 9cd8a766..97d98041 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -52,7 +52,7 @@ def create_embeddings_for_indexer( """ ... - def _format_sse(self, chat_id: str, content: str) -> str: + def format_sse(self, chat_id: str, content: str) -> str: """Format content as SSE message. Args: @@ -64,7 +64,7 @@ def _format_sse(self, chat_id: str, content: str) -> str: """ ... - def _log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: + def log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: """Log cost information for tracking. Args: @@ -656,7 +656,7 @@ async def execute_async( # Log costs after service workflow completes (follows RAG workflow pattern) if self.orchestration_service: - self.orchestration_service._log_costs(costs_dict) + self.orchestration_service.log_costs(costs_dict) return OrchestrationResponse( chatId=request.chatId, @@ -785,12 +785,12 @@ async def execute_streaming( orchestration_service = self.orchestration_service async def debug_stream() -> AsyncIterator[str]: - yield orchestration_service._format_sse(chat_id, debug_content) - yield orchestration_service._format_sse(chat_id, "END") + yield orchestration_service.format_sse(chat_id, debug_content) + yield orchestration_service.format_sse(chat_id, "END") # Log costs after streaming completes (follows RAG workflow pattern) # Must be inside generator because costs are accumulated during streaming - orchestration_service._log_costs(costs_dict) + orchestration_service.log_costs(costs_dict) return debug_stream() # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) From 07f2e0f1c6c1d4612f7e7d2b64eef3bec54af757 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 24 Feb 2026 20:25:56 +0530 Subject: [PATCH 036/105] fixed requested changes --- docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 2 +- src/tool_classifier/classifier.py | 6 ++++-- src/tool_classifier/workflows/service_workflow.py | 6 +++--- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md index afd4303f..15669e4d 100644 --- a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -440,7 +440,7 @@ def _transform_entities_to_array( ### Endpoint Construction ```python -endpoint_url = f"{RUUTER_BASE_URL}/services/active{service_metadata['service_name']}" +endpoint_url = f"{RUUTER_BASE_URL}/services/active{service_name}" # Example: "http://ruuter:8080/services/active/currency-conversion" # (Note: service_name from service metadata, e.g., "/currency-conversion") ``` diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index ec9dc953..c8bef8ae 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -228,7 +228,8 @@ async def _execute_with_fallback_async( next_name = WORKFLOW_DISPLAY_NAMES.get(next_layer, next_layer.value) logger.info( - f"[{chat_id}] Falling back to {next_name} (Layer {current_index + 2})" + f"[{chat_id}] Falling back to {next_name} " + f"(Layer {WORKFLOW_LAYER_ORDER.index(next_layer) + 1})" ) result = await next_workflow.execute_async(request, {}) @@ -300,9 +301,10 @@ async def _execute_with_fallback_streaming( 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 {current_index + 2})" + f"(Layer {layer_number})" ) result = await next_workflow.execute_streaming(request, {}) diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index 97d98041..d71e2d92 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -205,7 +205,9 @@ async def _call_service_discovery(self, chat_id: str) -> Optional[Dict[str, Any] data = response.json() return data except httpx.TimeoutException: - logger.error(f"[{chat_id}] Service discovery timeout after 10s") + logger.error( + f"[{chat_id}] Service discovery timeout after {SERVICE_DISCOVERY_TIMEOUT}s" + ) return None except httpx.HTTPStatusError as e: logger.error( @@ -641,7 +643,6 @@ async def execute_async( # 3. Parse Ruuter response and extract result # 4. Return OrchestrationResponse with actual service result # 5. Handle errors (timeout, HTTP errors, malformed JSON) - # 6. Remove debug response code below (lines 589-601) after implementation # STEP 6: Return debug response (temporary until Step 7 - Ruuter call implemented) # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) @@ -765,7 +766,6 @@ async def execute_streaming( # 3. Parse Ruuter response and extract result # 4. Format result as SSE and yield chunks # 5. Handle errors (timeout, HTTP errors, malformed JSON) - # 6. Remove debug response code below (lines 697-709) after implementation # STEP 6: Return debug response as async iterator (temporary until Step 7) # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) From f63f777582c22bba3524e38a2d916096fe134380 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 25 Feb 2026 08:46:52 +0530 Subject: [PATCH 037/105] update time tracking --- src/llm_orchestration_service.py | 67 +++++++++++++++---- src/tool_classifier/base_workflow.py | 4 ++ src/tool_classifier/classifier.py | 38 +++++++++-- .../workflows/context_workflow.py | 4 ++ src/tool_classifier/workflows/ood_workflow.py | 3 + src/tool_classifier/workflows/rag_workflow.py | 20 ++++-- .../workflows/service_workflow.py | 38 +++++++++-- src/utils/time_tracker.py | 8 +++ 8 files changed, 151 insertions(+), 31 deletions(-) diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index e2eb0c9e..2705a29a 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -170,9 +170,11 @@ async 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) + timing_dict["language_detection"] = time.time() - start_time logger.info( f"[{request.chatId}] Detected language: {language_name} ({detected_language})" ) @@ -182,7 +184,9 @@ async def process_orchestration_request( setattr(request, "_detected_language", detected_language) # STEP 0.5: Basic Query Validation (before expensive component initialization) + start_time = time.time() validation_result = validate_query_basic(request.message) + timing_dict["query_validation"] = time.time() - start_time if not validation_result.is_valid: logger.info( f"[{request.chatId}] Query validation failed: {validation_result.rejection_reason}" @@ -210,8 +214,10 @@ async def process_orchestration_request( content=validation_msg, ) - # Initialize all service components (only for valid queries) + # Initialize all service components (only for valid queries, with timing) + start_time = time.time() components = self._initialize_service_components(request) + timing_dict["initialization"] = time.time() - start_time # TOOL CLASSIFIER INTEGRATION # Route through tool classifier if enabled, otherwise use existing RAG pipeline @@ -229,24 +235,29 @@ async def process_orchestration_request( ) logger.info("Tool classifier initialized") - # Classify query to determine workflow + # 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, ) + timing_dict["classifier.classify"] = time.time() - start_time logger.info( f"[{request.chatId}] Classification: {classification.workflow.value} " f"(confidence: {classification.confidence:.2f})" ) - # Route to appropriate workflow + # 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, + timing_dict=timing_dict, ) + timing_dict["classifier.route"] = time.time() - start_time except Exception as classifier_error: logger.error( @@ -382,9 +393,11 @@ async def stream_orchestration_response( costs_dict: Dict[str, Dict[str, Any]] = {} timing_dict: 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) + timing_dict["language_detection"] = time.time() - start_time logger.info( f"[{request.chatId}] Streaming request - Detected language: {language_name} ({detected_language})" ) @@ -393,8 +406,10 @@ async def stream_orchestration_response( # Using setattr for type safety - adds dynamic attribute to Pydantic model instance setattr(request, "_detected_language", detected_language) - # Step 0.5: Basic Query Validation (before guardrails) + # Step 0.5: Basic Query Validation (before guardrails, with timing) + start_time = time.time() validation_result = validate_query_basic(request.message) + timing_dict["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}" @@ -419,8 +434,10 @@ 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) + timing_dict["initialization"] = time.time() - start_time # STEP 1: CHECK INPUT GUARDRAILS (blocking) logger.info( @@ -1114,8 +1131,17 @@ async def _execute_orchestration_pipeline( components: Dict[str, Any], costs_dict: Dict[str, Dict[str, Any]], timing_dict: Dict[str, float], + prefix: str = "", ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: - """Execute the main orchestration pipeline with all components.""" + """Execute the main orchestration pipeline with all components. + + Args: + request: Orchestration request + components: Initialized service components + costs_dict: Dictionary for cost tracking + timing_dict: Dictionary for timing tracking + prefix: Optional prefix for timing keys (e.g., "rag" for workflow namespacing) + """ # Note: Query validation now happens in process_orchestration_request() # before component initialization for true early rejection @@ -1125,7 +1151,12 @@ async def _execute_orchestration_pipeline( input_blocked_response = await self.handle_input_guardrails( components["guardrails_adapter"], request, costs_dict ) - timing_dict["input_guardrails_check"] = time.time() - start_time + timing_key = ( + f"{prefix}.input_guardrails_check" + if prefix + else "input_guardrails_check" + ) + timing_dict[timing_key] = time.time() - start_time if input_blocked_response: return input_blocked_response @@ -1136,7 +1167,8 @@ async def _execute_orchestration_pipeline( original_message=request.message, conversation_history=request.conversationHistory, ) - timing_dict["prompt_refiner"] = time.time() - start_time + timing_key = f"{prefix}.prompt_refiner" if prefix else "prompt_refiner" + timing_dict[timing_key] = time.time() - start_time costs_dict["prompt_refiner"] = refiner_usage # Step 3: Retrieve relevant chunks using contextual retrieval @@ -1145,7 +1177,10 @@ async def _execute_orchestration_pipeline( 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" + ) + timing_dict[timing_key] = time.time() - start_time except ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, @@ -1168,7 +1203,10 @@ async def _execute_orchestration_pipeline( response_generator=components["response_generator"], costs_dict=costs_dict, ) - timing_dict["response_generation"] = time.time() - start_time + timing_key = ( + f"{prefix}.response_generation" if prefix else "response_generation" + ) + timing_dict[timing_key] = time.time() - start_time # Step 5: Output Guardrails Check # Apply guardrails to all response types for consistent safety across all environments @@ -1179,7 +1217,10 @@ async def _execute_orchestration_pipeline( request, costs_dict, ) - timing_dict["output_guardrails_check"] = time.time() - start_time + timing_key = ( + f"{prefix}.output_guardrails_check" if prefix else "output_guardrails_check" + ) + timing_dict[timing_key] = time.time() - start_time # Step 6: Store inference data (for production and testing environments) # Only store OrchestrationResponse (has chatId), not TestOrchestrationResponse diff --git a/src/tool_classifier/base_workflow.py b/src/tool_classifier/base_workflow.py index 50faf7ad..45886fa9 100644 --- a/src/tool_classifier/base_workflow.py +++ b/src/tool_classifier/base_workflow.py @@ -33,6 +33,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute workflow in non-streaming mode. @@ -43,6 +44,7 @@ async def execute_async( Args: request: The orchestration request containing user query and context context: Workflow-specific metadata from ClassificationResult.metadata + timing_dict: Optional dictionary for tracking step execution times Returns: OrchestrationResponse if workflow can handle this query @@ -68,6 +70,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute workflow in streaming mode (Server-Sent Events). @@ -78,6 +81,7 @@ async def execute_streaming( Args: request: The orchestration request containing user query and context context: Workflow-specific metadata from ClassificationResult.metadata + timing_dict: Optional dictionary for tracking step execution times Returns: AsyncIterator[str] yielding SSE-formatted strings if workflow can handle diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index c8bef8ae..ab9e402e 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -1,6 +1,6 @@ """Main tool classifier for workflow routing.""" -from typing import Any, AsyncIterator, Dict, List, Literal, Union, overload +from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union, overload from loguru import logger from models.request_models import ( @@ -106,6 +106,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: Literal[False] = False, + timing_dict: Optional[Dict[str, float]] = None, ) -> OrchestrationResponse: ... @overload @@ -114,6 +115,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: Literal[True], + timing_dict: Optional[Dict[str, float]] = None, ) -> AsyncIterator[str]: ... async def route_to_workflow( @@ -121,6 +123,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: bool = False, + timing_dict: Optional[Dict[str, float]] = None, ) -> Union[OrchestrationResponse, AsyncIterator[str]]: """ Route request to appropriate workflow based on classification. @@ -132,6 +135,7 @@ async def route_to_workflow( classification: Classification result from classify() request: Original orchestration request is_streaming: Whether to use streaming mode (for /orchestrate/stream) + timing_dict: Optional timing dictionary for workflow step tracking Returns: OrchestrationResponse for non-streaming mode @@ -162,6 +166,7 @@ async def route_to_workflow( request=request, context=classification.metadata, start_layer=classification.workflow, + timing_dict=timing_dict, ) else: # NON-STREAMING MODE: For /orchestrate and /orchestrate/test endpoints @@ -170,6 +175,7 @@ async def route_to_workflow( request=request, context=classification.metadata, start_layer=classification.workflow, + timing_dict=timing_dict, ) def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: @@ -188,6 +194,7 @@ async def _execute_with_fallback_async( request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, + timing_dict: Optional[Dict[str, float]] = None, ) -> OrchestrationResponse: """ Execute workflow with fallback to subsequent layers (non-streaming). @@ -197,6 +204,13 @@ async def _execute_with_fallback_async( 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 + timing_dict: Optional timing dictionary for tracking """ chat_id = request.chatId workflow_name = WORKFLOW_DISPLAY_NAMES.get(start_layer, start_layer.value) @@ -204,7 +218,7 @@ async def _execute_with_fallback_async( logger.info(f"[{chat_id}] Executing {workflow_name} (non-streaming)") try: - result = await workflow.execute_async(request, context) + result = await workflow.execute_async(request, context, timing_dict) if result is not None: logger.info(f"[{chat_id}] {workflow_name} handled successfully") @@ -232,7 +246,7 @@ async def _execute_with_fallback_async( f"(Layer {WORKFLOW_LAYER_ORDER.index(next_layer) + 1})" ) - result = await next_workflow.execute_async(request, {}) + result = await next_workflow.execute_async(request, {}, timing_dict) if result is not None: logger.info(f"[{chat_id}] {next_name} handled successfully") @@ -248,7 +262,7 @@ async def _execute_with_fallback_async( 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, {}) + rag_result = await self.rag_workflow.execute_async(request, {}, timing_dict) if rag_result is not None: return rag_result else: @@ -260,6 +274,7 @@ async def _execute_with_fallback_streaming( request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, + timing_dict: Optional[Dict[str, float]] = None, ) -> AsyncIterator[str]: """ Execute workflow with fallback to subsequent layers (streaming). @@ -269,6 +284,13 @@ async def _execute_with_fallback_streaming( 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 + timing_dict: Optional timing dictionary for tracking """ chat_id = request.chatId workflow_name = WORKFLOW_DISPLAY_NAMES.get(start_layer, start_layer.value) @@ -276,7 +298,7 @@ async def _execute_with_fallback_streaming( logger.info(f"[{chat_id}] Executing {workflow_name} (streaming)") try: - result = await workflow.execute_streaming(request, context) + result = await workflow.execute_streaming(request, context, timing_dict) if result is not None: logger.info(f"[{chat_id}] {workflow_name} streaming started") @@ -307,7 +329,7 @@ async def _execute_with_fallback_streaming( f"(Layer {layer_number})" ) - result = await next_workflow.execute_streaming(request, {}) + result = await next_workflow.execute_streaming(request, {}, timing_dict) if result is not None: logger.info(f"[{chat_id}] {next_name} streaming started") @@ -325,7 +347,9 @@ async def _execute_with_fallback_streaming( 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, {}) + streaming_result = await self.rag_workflow.execute_streaming( + request, {}, timing_dict + ) if streaming_result is not None: async for chunk in streaming_result: yield chunk diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index 88212efa..4039b239 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -35,6 +35,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute context workflow in non-streaming mode. @@ -45,6 +46,7 @@ async def execute_async( Args: request: Orchestration request with user query and history context: Metadata with is_greeting, can_answer_from_history flags + timing_dict: Optional timing dictionary for future timing tracking Returns: OrchestrationResponse with context-based answer or None to fallback @@ -62,6 +64,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute context workflow in streaming mode. @@ -72,6 +75,7 @@ async def execute_streaming( Args: request: Orchestration request with user query and history context: Metadata with is_greeting, can_answer_from_history flags + timing_dict: Optional timing dictionary for future timing tracking Returns: AsyncIterator yielding SSE strings or None to fallback diff --git a/src/tool_classifier/workflows/ood_workflow.py b/src/tool_classifier/workflows/ood_workflow.py index cd114f70..c3f92156 100644 --- a/src/tool_classifier/workflows/ood_workflow.py +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -39,6 +39,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute OOD workflow in non-streaming mode. @@ -68,6 +69,7 @@ async def execute_async( Args: request: Orchestration request with user query context: Unused (OOD doesn't need metadata) + timing_dict: Optional timing dictionary for future timing tracking Returns: OrchestrationResponse with OOD message @@ -86,6 +88,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute OOD workflow in streaming mode. diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index 6c586481..5c8cd057 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -50,6 +50,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute RAG workflow in non-streaming mode. @@ -64,6 +65,7 @@ async def execute_async( Args: request: Orchestration request with user query context: Unused (RAG doesn't need classification metadata) + timing_dict: Optional timing dictionary from parent (for unified tracking) Returns: OrchestrationResponse with RAG-generated answer @@ -73,24 +75,24 @@ async def execute_async( # Initialize components needed for RAG pipeline costs_dict: Dict[str, Any] = {} - timing_dict: Dict[str, float] = {} + # Use parent timing_dict or create new one + if timing_dict is None: + timing_dict = {} # Initialize service components components = self.orchestration_service._initialize_service_components(request) - # Call existing RAG pipeline + # Call existing RAG pipeline with "rag" prefix for namespacing response = await self.orchestration_service._execute_orchestration_pipeline( request=request, components=components, costs_dict=costs_dict, timing_dict=timing_dict, + prefix="rag", ) - # Log costs and timings + # Log costs (timing is logged by parent orchestration service) self.orchestration_service.log_costs(costs_dict) - from src.utils.time_tracker import log_step_timings - - log_step_timings(timing_dict, request.chatId) return response @@ -98,6 +100,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute RAG workflow in streaming mode. @@ -116,6 +119,7 @@ async def execute_streaming( Args: request: Orchestration request with user query context: Unused (RAG doesn't need classification metadata) + timing_dict: Optional timing dictionary from parent (for unified tracking) Returns: AsyncIterator yielding SSE-formatted strings @@ -125,7 +129,9 @@ async def execute_streaming( # Initialize tracking dictionaries costs_dict: Dict[str, Any] = {} - timing_dict: Dict[str, float] = {} + # Use parent timing_dict or create new one + if timing_dict is None: + timing_dict = {} # Get components from context if provided, otherwise initialize components = context.get("components") diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index d71e2d92..bed97dde 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -549,17 +549,31 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: - """Execute service workflow in non-streaming mode.""" + """Execute service workflow in non-streaming mode. + + Args: + request: Orchestration request + context: Workflow context + timing_dict: Optional timing dictionary for unified tracking + """ + import time + chat_id = request.chatId # Create costs tracking dictionary (follows RAG workflow pattern) costs_dict: Dict[str, Dict[str, Any]] = {} + # Use parent timing_dict or create new one + if timing_dict is None: + timing_dict = {} - # Log comprehensive request details and perform service discovery + # Service discovery with timing + start_time = time.time() await self._log_request_details( request, context, mode="non-streaming", costs_dict=costs_dict ) + timing_dict["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): @@ -573,6 +587,7 @@ async def execute_async( logger.info(f"[{chat_id}] Entity Transformation:") # Step 1: Extract service metadata from context + start_time = time.time() service_metadata = self._extract_service_metadata(context, chat_id) if not service_metadata: logger.error( @@ -596,6 +611,7 @@ async def execute_async( service_name=service_metadata["service_name"], chat_id=chat_id, ) + timing_dict["service.entity_validation"] = time.time() - start_time logger.info( f"[{chat_id}] - Validation status: " @@ -672,17 +688,31 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], + timing_dict: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: - """Execute service workflow in streaming mode.""" + """Execute service workflow in streaming mode. + + Args: + request: Orchestration request + context: Workflow context + timing_dict: Optional timing dictionary for unified tracking + """ + import time + chat_id = request.chatId # Create costs tracking dictionary (follows RAG workflow pattern) costs_dict: Dict[str, Dict[str, Any]] = {} + # Use parent timing_dict or create new one + if timing_dict is None: + timing_dict = {} - # Log comprehensive request details and perform service discovery + # Service discovery with timing + start_time = time.time() await self._log_request_details( request, context, mode="streaming", costs_dict=costs_dict ) + timing_dict["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): diff --git a/src/utils/time_tracker.py b/src/utils/time_tracker.py index 5b6d8dea..606e5301 100644 --- a/src/utils/time_tracker.py +++ b/src/utils/time_tracker.py @@ -17,11 +17,19 @@ def log_step_timings( if not timing_dict: 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(): + # 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)") From 5429bc05dc510dc6e2d33d16ac813abf26bca643 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 25 Feb 2026 13:09:16 +0530 Subject: [PATCH 038/105] added time tracking and reloacate input guardrail before toolclassifiier --- ...ces.sql => mock-count-active-services.sql} | 0 ...s.sql => mock-get-all-active-services.sql} | 0 ...e-by-id.sql => mock-get-service-by-id.sql} | 0 .../rag-search/GET/services/get-services.yml | 4 +- src/llm_orchestration_service.py | 125 +++++++++++++----- 5 files changed, 96 insertions(+), 33 deletions(-) rename DSL/Resql/rag-search/POST/{count-active-services.sql => mock-count-active-services.sql} (100%) rename DSL/Resql/rag-search/POST/{get-all-active-services.sql => mock-get-all-active-services.sql} (100%) rename DSL/Resql/rag-search/POST/{get-service-by-id.sql => mock-get-service-by-id.sql} (100%) diff --git a/DSL/Resql/rag-search/POST/count-active-services.sql b/DSL/Resql/rag-search/POST/mock-count-active-services.sql similarity index 100% rename from DSL/Resql/rag-search/POST/count-active-services.sql rename to DSL/Resql/rag-search/POST/mock-count-active-services.sql diff --git a/DSL/Resql/rag-search/POST/get-all-active-services.sql b/DSL/Resql/rag-search/POST/mock-get-all-active-services.sql similarity index 100% rename from DSL/Resql/rag-search/POST/get-all-active-services.sql rename to DSL/Resql/rag-search/POST/mock-get-all-active-services.sql diff --git a/DSL/Resql/rag-search/POST/get-service-by-id.sql b/DSL/Resql/rag-search/POST/mock-get-service-by-id.sql similarity index 100% rename from DSL/Resql/rag-search/POST/get-service-by-id.sql rename to DSL/Resql/rag-search/POST/mock-get-service-by-id.sql diff --git a/DSL/Ruuter.public/rag-search/GET/services/get-services.yml b/DSL/Ruuter.public/rag-search/GET/services/get-services.yml index d1ed3950..01356d94 100644 --- a/DSL/Ruuter.public/rag-search/GET/services/get-services.yml +++ b/DSL/Ruuter.public/rag-search/GET/services/get-services.yml @@ -10,7 +10,7 @@ declaration: count_services: call: http.post args: - url: "[#RAG_SEARCH_RESQL]/count-active-services" + url: "[#RAG_SEARCH_RESQL]/mock-count-active-services" body: {} result: count_result next: check_service_count @@ -41,7 +41,7 @@ return_semantic_search_response: fetch_all_services: call: http.post args: - url: "[#RAG_SEARCH_RESQL]/get-all-active-services" + url: "[#RAG_SEARCH_RESQL]/mock-get-all-active-services" body: {} result: services_result next: return_all_services diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 2705a29a..30d2eddf 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -134,9 +134,46 @@ def __init__(self) -> None: # This allows components to be initialized per-request with proper context self.tool_classifier = None + # Initialize shared guardrails adapter at startup + self.shared_guardrails_adapter = self._initialize_shared_guardrails_at_startup() + # Log feature flag configuration FeatureFlags.log_configuration() + def _initialize_shared_guardrails_at_startup(self) -> Optional[NeMoRailsAdapter]: + """ + Initialize shared guardrails at startup. + + Returns: + NeMoRailsAdapter if successful, None on failure (graceful degradation) + """ + try: + logger.info(" Initializing shared guardrails at startup...") + start_time = time.time() + + # Initialize with production environment and no specific connection + # This creates a shared guardrails instance using default/production config + guardrails_adapter = self._initialize_guardrails( + environment="production", + connection_id=None, # Shared configuration, not user-specific + ) + + elapsed_time = time.time() - start_time + logger.info( + f" Shared guardrails initialized successfully in {elapsed_time:.3f}s" + ) + + return guardrails_adapter + + except Exception as e: + logger.error(f" Failed to initialize shared guardrails at startup: {e}") + logger.error( + " Service will continue without guardrails (graceful degradation)" + ) + # Return None - service continues without guardrails + # Per-request fallback will be attempted if needed + return None + @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( self, request: OrchestrationRequest @@ -219,6 +256,26 @@ async def process_orchestration_request( components = self._initialize_service_components(request) timing_dict["initialization"] = time.time() - start_time + if components["guardrails_adapter"]: + start_time = time.time() + input_blocked_response = await self.handle_input_guardrails( + components["guardrails_adapter"], request, {} + ) + timing_dict["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(timing_dict, 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: @@ -439,9 +496,12 @@ async def stream_orchestration_response( components = self._initialize_service_components(request) timing_dict["initialization"] = time.time() - start_time - # STEP 1: CHECK INPUT GUARDRAILS (blocking) + # PRIORITY 1 OPTIMIZATION: Input Guardrails Check BEFORE Classifier + # This implements fail-fast principle - block malicious/policy-violating inputs + # before expensive operations (service discovery, LLM calls, streaming setup) + # Saves 6.4s + $0.002 per blocked request! 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"]: @@ -455,19 +515,26 @@ async def stream_orchestration_response( 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( request.chatId, INPUT_GUARDRAIL_VIOLATION_MESSAGE ) yield self.format_sse(request.chatId, "END") self.log_costs(costs_dict) + # Log timings before returning (for visibility) + log_step_timings(timing_dict, 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" ) # TOOL CLASSIFIER INTEGRATION (STREAMING) @@ -1015,10 +1082,20 @@ 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 - ) + # Use shared guardrails adapter (initialized at startup) + # Falls back to per-request initialization if shared instance unavailable + if self.shared_guardrails_adapter is not None: + logger.debug( + f"Using shared guardrails adapter (startup-initialized, zero overhead)" + ) + components["guardrails_adapter"] = self.shared_guardrails_adapter + else: + logger.warning( + f"Shared guardrails unavailable, 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( @@ -1142,25 +1219,11 @@ async def _execute_orchestration_pipeline( timing_dict: Dictionary for timing tracking prefix: Optional prefix for timing keys (e.g., "rag" for workflow namespacing) """ - # Note: Query validation now happens in process_orchestration_request() - # before component initialization for true early rejection - - # Step 1: Input Guardrails Check - if components["guardrails_adapter"]: - start_time = time.time() - input_blocked_response = await self.handle_input_guardrails( - components["guardrails_adapter"], request, costs_dict - ) - timing_key = ( - f"{prefix}.input_guardrails_check" - if prefix - else "input_guardrails_check" - ) - timing_dict[timing_key] = time.time() - start_time - if input_blocked_response: - return input_blocked_response + # 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 2: Refine user prompt + # Step 1: Refine user prompt start_time = time.time() refined_output, refiner_usage = self._refine_user_prompt( llm_manager=components["llm_manager"], @@ -1171,7 +1234,7 @@ async def _execute_orchestration_pipeline( timing_dict[timing_key] = time.time() - start_time costs_dict["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 = await self._safe_retrieve_contextual_chunks( @@ -1193,7 +1256,7 @@ async 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"], @@ -1208,7 +1271,7 @@ async def _execute_orchestration_pipeline( ) timing_dict[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 = await self.handle_output_guardrails( @@ -1222,7 +1285,7 @@ async def _execute_orchestration_pipeline( ) timing_dict[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, From c7b66d040e12229fa3d5fa3921af6c53662a52e2 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:27:16 +0530 Subject: [PATCH 039/105] Service layer validation in tool classifier (#321) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * rename mock resql endpoints --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- .../POST/mock-count-active-services.sql | 11 + .../POST/mock-get-all-active-services.sql | 20 + .../POST/mock-get-service-by-id.sql | 24 + .../rag-search/GET/services/get-services.yml | 60 ++ docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 660 +++++++++++++ docs/TOOL_CLASSIFIER_SKELETON_USAGE.md | 4 +- enrich.yml.backup | 157 ---- src/intent_data_enrichment/constants.py | 2 + src/intent_data_enrichment/main_enrichment.py | 35 +- src/llm_orchestration_service.py | 82 +- src/tool_classifier/classifier.py | 157 ++-- src/tool_classifier/constants.py | 60 ++ src/tool_classifier/intent_detector.py | 133 +++ src/tool_classifier/workflows/ood_workflow.py | 4 +- src/tool_classifier/workflows/rag_workflow.py | 2 +- .../workflows/service_workflow.py | 865 +++++++++++++++--- 16 files changed, 1883 insertions(+), 393 deletions(-) create mode 100644 DSL/Resql/rag-search/POST/mock-count-active-services.sql create mode 100644 DSL/Resql/rag-search/POST/mock-get-all-active-services.sql create mode 100644 DSL/Resql/rag-search/POST/mock-get-service-by-id.sql create mode 100644 DSL/Ruuter.public/rag-search/GET/services/get-services.yml create mode 100644 docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md delete mode 100644 enrich.yml.backup create mode 100644 src/tool_classifier/constants.py create mode 100644 src/tool_classifier/intent_detector.py 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-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/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/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md new file mode 100644 index 00000000..15669e4d --- /dev/null +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -0,0 +1,660 @@ +# 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 + +``` +1. Service Discovery + ↓ +2. Service Selection (Semantic Search or LLM-based) + ↓ +3. Intent Detection (DSPy LLM Call) + ↓ +4. Entity Extraction (From LLM Output) + ↓ +5. Entity Validation (Against Service Schema) + ↓ +6. Entity Transformation (Dict → Ordered Array) + ↓ +7. Service Call (TODO: Ruuter endpoint invocation) +``` + +--- + +## 1. Service Discovery + +### Method: `_call_service_discovery()` + +Calls Ruuter public endpoint to fetch available services: + +```python +GET /rag-search/get-services-from-llm +``` + +**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=5) +``` + +--- + +## 2. Service Selection + +### 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": 5, # Top 5 services + "score_threshold": 0.4, # Minimum similarity + "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 + +--- + +## 3. 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"] + } +] + +# 2. Prepare conversation context (last 3 turns) +conversation_context = """ +user: Hello +assistant: Hi! How can I help? +user: How much is 100 EUR in USD? +""" + +# 3. Call DSPy module +intent_result = intent_detector.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 confidence < 0.7: + # Low confidence → Service workflow returns None → Fallback to 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_dict["intent_detection"] = usage_info + +# Later: orchestration_service.log_costs(costs_dict) +``` + +--- + +## 4. 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" + } +} +``` + +--- + +## 5. 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 + +--- + +## 6. 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( + entities_dict: Dict[str, str], + entity_order: List[str] +) -> List[str]: + """Transform entity dict to ordered array.""" + ordered_array = [] + + for entity_key in entity_order: + # Get value from dict, or empty string if missing + value = entities_dict.get(entity_key, "") + ordered_array.append(value) + + return ordered_array +``` + +### 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 +``` + +--- + +## 7. Service Call (TODO: Step 7) + +### Endpoint Construction + +```python +endpoint_url = f"{RUUTER_BASE_URL}/services/active{service_name}" +# Example: "http://ruuter:8080/services/active/currency-conversion" +# (Note: service_name from service metadata, e.g., "/currency-conversion") +``` + +### Payload Construction (Planned) + +```python +payload = { + "input": entities_array, # ["USD", "EUR", "100"] + "authorId": request.authorId, + "chatId": request.chatId +} +``` + +### HTTP Call (Planned) + +```python +# Non-streaming +response = await httpx.post( + endpoint_url, + json=payload, + timeout=5.0 +) + +# Streaming +async with httpx.stream("POST", endpoint_url, json=payload) as stream: + async for line in stream.aiter_lines(): + yield orchestration_service.format_sse(chat_id, line) +``` + +--- + +## 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. Service Discovery +```json +{ + "service_count": 5, + "services": [ + { + "serviceId": "currency_conversion_eur", + "name": "Currency Conversion (EUR)", + "entities": ["target_currency"], + "examples": ["How much is EUR in USD?"] + } + ] +} +``` + +#### 2. Service Selection +```python +# Few services (5 <= 10) → Use all for intent detection +services = discovery_result["services"] +``` + +#### 3. Intent Detection (LLM Call) +```json +{ + "matched_service_id": "currency_conversion_eur", + "confidence": 0.92, + "entities": { + "target_currency": "THB" + }, + "reasoning": "User wants to convert EUR to THB" +} +``` + +#### 4. Entity Extraction +```python +entities_dict = {"target_currency": "THB"} +``` + +#### 5. Entity Validation +```python +validation_result = { + "is_valid": True, + "missing_entities": [], + "extra_entities": [], + "validation_errors": [] +} +``` + +#### 6. Entity Transformation +```python +# Schema: ["target_currency"] +# Dict: {"target_currency": "THB"} +# Array: ["THB"] +entities_array = ["THB"] +``` + +#### 7. Service Call (TODO) +```python +# Planned implementation +response = await call_service( + url="http://ruuter:8080/currency/convert", + method="POST", + payload={"input": ["THB"], "chatId": "..."} +) +``` + +--- + +## Cost Tracking + +Service workflow tracks LLM costs following the RAG workflow pattern: + +```python +# Create costs dict at workflow level +costs_dict: Dict[str, Dict[str, Any]] = {} + +# Intent detection captures costs +intent_result, intent_usage = await _detect_service_intent(...) +costs_dict["intent_detection"] = intent_usage + +# Log costs after workflow completes +orchestration_service.log_costs(costs_dict) +``` + +**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 match (confidence < 0.7) +if not intent_result or intent_result.get("confidence", 0) < 0.7: + return None # Fallback to CONTEXT layer + +# Scenario 2: Service validation failed +if not validated_service: + return None # Fallback to CONTEXT layer + +# Scenario 3: No services discovered +if not services: + 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 +# Service discovery +RUUTER_BASE_URL = "http://ruuter.public:8080" +SERVICE_DISCOVERY_TIMEOUT = 5.0 # seconds + +# Service selection thresholds +SERVICE_COUNT_THRESHOLD = 10 # Switch to semantic search if exceeded +MAX_SERVICES_FOR_LLM_CONTEXT = 20 # Max services to pass to LLM + +# Semantic search +QDRANT_COLLECTION = "services_collection" +SEMANTIC_SEARCH_TOP_K = 5 # Top 5 relevant services +SEMANTIC_SEARCH_THRESHOLD = 0.4 # Minimum similarity score +QDRANT_TIMEOUT = 2.0 # seconds + +# Intent detection +INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed +``` + +--- + +## 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. **Two-Stage Service Selection** +- Few services (≤10): Pass all to LLM +- Many services (>10): Semantic search first + +### 4. **LLM-Based Intent Detection** +- 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 + +--- + +## Summary + +The Tool Classifier's layer architecture enables intelligent query routing with graceful fallbacks. The Service Workflow (Layer 1) uses **LLM-based intent detection** to match user queries to external services, extract entities, validate them against service schemas, and prepare them for service invocation—all while maintaining comprehensive cost tracking and seamless integration with the broader RAG pipeline. diff --git a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md index 9dc87c88..38ce1f53 100644 --- a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md +++ b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md @@ -361,9 +361,9 @@ class MyCustomWorkflow(BaseWorkflow): # Stream result token-by-token async def stream_result(): for chunk in self._split_into_tokens(result): - yield self._format_sse(request.chatId, chunk) + yield self.format_sse(request.chatId, chunk) await asyncio.sleep(0.01) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") return stream_result() ``` diff --git a/enrich.yml.backup b/enrich.yml.backup deleted file mode 100644 index 28cd5b31..00000000 --- a/enrich.yml.backup +++ /dev/null @@ -1,157 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Enrich service data and index in Qdrant" - 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" - -validate_request: - assign: - service_id: ${incoming.body.service_id} - service_name: ${incoming.body.name} - service_description: ${incoming.body.description} - next: check_required_fields - -check_required_fields: - switch: - - condition: ${!service_id} - next: assign_missing_service_id_error - - condition: ${!service_name} - next: assign_missing_name_error - - condition: ${!service_description} - next: assign_missing_description_error - next: prepare_service_data - -assign_missing_service_id_error: - assign: - error_response: { - success: false, - error: "MISSING_SERVICE_ID", - message: "service_id is required" - } - next: return_missing_service_id - -return_missing_service_id: - status: 400 - return: ${error_response} - next: end - -assign_missing_name_error: - assign: - error_response: { - success: false, - error: "MISSING_NAME", - message: "name is required" - } - next: return_missing_name - -return_missing_name: - status: 400 - return: ${error_response} - next: end - -assign_missing_description_error: - assign: - error_response: { - success: false, - error: "MISSING_DESCRIPTION", - message: "description is required" - } - next: return_missing_description - -return_missing_description: - status: 400 - return: ${error_response} - next: end - -prepare_service_data: - assign: - service_data: { - service_id: ${service_id}, - name: ${service_name}, - description: ${service_description}, - examples: ${incoming.body.examples || []}, - entities: ${incoming.body.entities || []}, - ruuter_type: ${incoming.body.ruuter_type || 'GET'}, - current_state: ${incoming.body.current_state || 'draft'}, - is_common: ${incoming.body.is_common || false} - } - next: stringify_service_data - -stringify_service_data: - assign: - service_json: ${JSON.stringify(service_data)} - next: execute_enrichment - -execute_enrichment: - call: http.post - args: - url: "[#RAG_SEARCH_CRON_MANAGER]/execute/service_enrichment/enrich_and_index" - query: - service_id: ${service_id} - service_data: ${service_json} - result: enrichment_result - next: assign_success_response - on_error: handle_enrichment_error - -handle_enrichment_error: - log: "ERROR: Service enrichment failed - ${enrichment_result.error || 'Unknown error'}" - next: assign_error_response - -assign_success_response: - assign: - success_response: { - success: true, - service_id: ${service_id}, - message: "Service enriched and indexed successfully", - enrichment_details: ${enrichment_result.response.body} - } - next: return_success - -assign_error_response: - assign: - error_response: { - success: false, - error: "ENRICHMENT_FAILED", - message: "Failed to enrich and index service", - details: ${enrichment_result.response.body || enrichment_result.error} - } - next: return_enrichment_error - -return_success: - status: 200 - return: ${success_response} - next: end - -return_enrichment_error: - status: 500 - return: ${error_response} - next: end diff --git a/src/intent_data_enrichment/constants.py b/src/intent_data_enrichment/constants.py index fd15a6ac..f1f35f38 100644 --- a/src/intent_data_enrichment/constants.py +++ b/src/intent_data_enrichment/constants.py @@ -43,4 +43,6 @@ class EnrichmentConstants: - 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 index 2aedb264..d718678e 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -91,12 +91,35 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: context = await api_client.generate_context(service_data) logger.success(f"Context generated: {len(context)} characters") - # Step 2: Create embedding for the context - logger.info("Step 2: Creating embedding vector") - embedding = await api_client.create_embedding(context) + # Step 2: Combine generated context with original metadata for embedding + logger.info("Step 2: Combining context with original service metadata") + 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)}" + ) + + # Add generated context last (enriched understanding) + combined_text_parts.append(f"Enriched Context: {context}") + + combined_text = "\n".join(combined_text_parts) + logger.info(f"Combined text length: {len(combined_text)} characters") + + # Step 3: Create embedding for combined text + logger.info("Step 3: Creating embedding vector for combined text") + embedding = await api_client.create_embedding(combined_text) logger.success(f"Embedding created: {len(embedding)}-dimensional vector") - # Step 3: Prepare enriched service + # Step 4: Prepare enriched service enriched_service = EnrichedService( id=service_data.service_id, name=service_data.name, @@ -107,8 +130,8 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: embedding=embedding, ) - # Step 4: Store in Qdrant - logger.info("Step 3: Storing in Qdrant") + # Step 5: Store in Qdrant + logger.info("Step 5: Storing in Qdrant") qdrant = QdrantManager() try: qdrant.connect() diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 3c059f59..e2eb0c9e 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -274,7 +274,7 @@ async def process_orchestration_request( ) # Log final costs and return response - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget for the LLM connection @@ -331,7 +331,7 @@ async def process_orchestration_request( } ) langfuse.flush() - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on error @@ -405,8 +405,8 @@ async def stream_orchestration_response( ) # Yield SSE format error + END marker - yield self._format_sse(request.chatId, validation_msg) - yield self._format_sse(request.chatId, "END") + 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 @@ -441,11 +441,11 @@ async def stream_orchestration_response( f"[{request.chatId}] [{stream_ctx.stream_id}] Input blocked by guardrails: " f"{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_dict) stream_ctx.mark_completed() return @@ -500,7 +500,7 @@ async def stream_orchestration_response( ) # Log costs and timings - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) stream_ctx.mark_completed() return # Exit after successful classifier routing @@ -546,10 +546,10 @@ async def stream_orchestration_response( logger, error_id, "streaming_orchestration", request.chatId, e ) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) + yield self.format_sse(request.chatId, "END") - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on outer exception @@ -645,9 +645,9 @@ async def _stream_rag_pipeline( 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_dict) + 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 @@ -659,9 +659,9 @@ async def _stream_rag_pipeline( 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_dict) + 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 @@ -690,9 +690,9 @@ async def _stream_rag_pipeline( 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_dict) + 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 @@ -755,14 +755,14 @@ async def bot_response_generator() -> AsyncIterator[str]: f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded: " f"{stream_ctx.token_count} > {StreamConfig.MAX_TOKENS_PER_STREAM}" ) - yield self._format_sse( + yield self.format_sse( request.chatId, STREAM_TOKEN_LIMIT_MESSAGE ) - yield self._format_sse(request.chatId, "END") + 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) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) stream_ctx.mark_completed() return @@ -784,20 +784,20 @@ async def bot_response_generator() -> AsyncIterator[str]: logger.warning( f"[{request.chatId}] [{stream_ctx.stream_id}] Guardrails violation detected" ) - yield self._format_sse( + yield self.format_sse( request.chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE ) - yield self._format_sse(request.chatId, "END") + 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) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) stream_ctx.mark_completed() return # Yield the validated chunk to client - yield self._format_sse(request.chatId, validated_chunk) + yield self.format_sse(request.chatId, validated_chunk) except GeneratorExit: stream_ctx.mark_cancelled() logger.info( @@ -816,9 +816,9 @@ async def bot_response_generator() -> AsyncIterator[str]: f"{i + 1}. [{ref.document_url}]({ref.document_url})" for i, ref in enumerate(doc_references) ) - yield self._format_sse(request.chatId, refs_text) + yield self.format_sse(request.chatId, refs_text) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") else: # No guardrails - stream directly @@ -837,14 +837,14 @@ async def bot_response_generator() -> AsyncIterator[str]: logger.error( f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded (no guardrails)" ) - yield self._format_sse( + yield self.format_sse( request.chatId, STREAM_TOKEN_LIMIT_MESSAGE ) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") stream_ctx.mark_completed() return - yield self._format_sse(request.chatId, token) + yield self.format_sse(request.chatId, token) # Send document references before END token doc_references = self._extract_document_references(relevant_chunks) @@ -853,9 +853,9 @@ async def bot_response_generator() -> AsyncIterator[str]: f"{i + 1}. [{ref.document_url}]({ref.document_url})" for i, ref in enumerate(doc_references) ) - yield self._format_sse(request.chatId, refs_text) + yield self.format_sse(request.chatId, refs_text) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") # Extract usage information after streaming completes usage_info = get_lm_usage_since(history_length_before) @@ -872,7 +872,7 @@ async def bot_response_generator() -> AsyncIterator[str]: ) # Log costs and trace - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget @@ -935,7 +935,7 @@ async def bot_response_generator() -> AsyncIterator[str]: ) usage_info = get_lm_usage_since(history_length_before) costs_dict["streaming_generation"] = usage_info - self._log_costs(costs_dict) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on client disconnect @@ -953,12 +953,12 @@ async def bot_response_generator() -> AsyncIterator[str]: request.chatId, stream_error, ) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) - yield self._format_sse(request.chatId, "END") + 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) + self.log_costs(costs_dict) log_step_timings(timing_dict, request.chatId) # Update budget even on streaming error @@ -966,7 +966,7 @@ async def bot_response_generator() -> AsyncIterator[str]: request.connection_id, costs_dict, request.environment ) - def _format_sse(self, chat_id: str, content: str) -> str: + def format_sse(self, chat_id: str, content: str) -> str: """ Format SSE message with exact specification. @@ -1885,7 +1885,7 @@ async def _check_output_guardrails( usage={}, ) - def _log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: + def log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: """ Log cost information for tracking. diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 71a45922..c8bef8ae 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -55,6 +55,7 @@ def __init__( # Initialize workflow executors self.service_workflow = ServiceWorkflowExecutor( llm_manager=llm_manager, + orchestration_service=orchestration_service, ) self.context_workflow = ContextWorkflowExecutor( llm_manager=llm_manager, @@ -75,10 +76,11 @@ async def classify( """ Classify a user query to determine which workflow should handle it. - Implements layer-wise classification logic: - 1. Check if SERVICE workflow can handle (intent detection) - 2. Check if CONTEXT workflow can handle (greeting/history check) - 3. Default to RAG workflow (knowledge retrieval) + Implements layer-wise classification logic with fallback chain: + 1. SERVICE workflow (external API calls) + 2. CONTEXT workflow (greetings/conversation history) + 3. RAG workflow (knowledge base retrieval) + 4. OOD workflow (out-of-domain) Args: query: User's query string @@ -87,60 +89,15 @@ async def classify( Returns: ClassificationResult indicating which workflow to use - - Note: - In this skeleton, always defaults to RAG. Full implementation - will add Layer 1 and Layer 2 logic in separate tasks. """ logger.info(f"Classifying query: {query[:100]}...") - # TODO: LAYER 1 - SERVICE WORKFLOW DETECTION - # Implementation task: Service workflow implementation - # Logic: - # 1. Count active services in database - # 2. If count > 50: Use Qdrant semantic search for top 20 services - # 3. If count <= 50: Use all services - # 4. Call LLM to detect intent and extract entities - # 5. If intent detected and service valid: return SERVICE classification - # Example: - # service_check = await self._check_service_layer(query, language) - # if service_check.can_handle: - # return ClassificationResult( - # workflow=WorkflowType.SERVICE, - # confidence=service_check.confidence, - # metadata=service_check.metadata, - # reasoning="Service intent detected" - # ) - - # TODO: LAYER 2 - CONTEXT WORKFLOW DETECTION - # Implementation task: Context workflow implementation - # Logic: - # 1. Check if query is a greeting using LLM - # 2. If greeting: return CONTEXT classification - # 3. If conversation_history exists: Check if query references history - # 4. Call LLM to determine if history contains answer - # 5. If can answer from history: return CONTEXT classification - # Example: - # context_check = await self._check_context_layer( - # query, conversation_history, language - # ) - # if context_check.can_handle: - # return ClassificationResult( - # workflow=WorkflowType.CONTEXT, - # confidence=context_check.confidence, - # metadata=context_check.metadata, - # reasoning="Greeting or answerable from history" - # ) - - # LAYER 3 - RAG WORKFLOW (DEFAULT) - # Always defaults to RAG for now - # RAG workflow will handle the query or return OOD if no chunks found - logger.info("Defaulting to RAG workflow (Layers 1-2 not implemented)") + logger.info("Starting layer-wise fallback: ") return ClassificationResult( - workflow=WorkflowType.RAG, + workflow=WorkflowType.SERVICE, confidence=1.0, metadata={}, - reasoning="Default to RAG workflow (service and context layers not implemented)", + reasoning="Start with Service workflow - will cascade through layers", ) @overload @@ -235,10 +192,7 @@ async def _execute_with_fallback_async( """ Execute workflow with fallback to subsequent layers (non-streaming). - TODO: Implement full fallback chain logic - Currently just executes the primary workflow. - - Full implementation should: + Implementation: 1. Try primary workflow 2. If returns None, try next layer in WORKFLOW_LAYER_ORDER 3. Continue until workflow returns non-None result @@ -256,19 +210,39 @@ async def _execute_with_fallback_async( logger.info(f"[{chat_id}] {workflow_name} handled successfully") return result - # TODO: Implement fallback to next layer - # For now, if workflow returns None, call RAG as fallback - logger.warning( + # Implement layer-wise fallback chain + logger.info( f"[{chat_id}] {workflow_name} returned None, " - f"falling back to RAG workflow" + f"trying next layer in fallback chain" ) - rag_result = await self.rag_workflow.execute_async(request, {}) - if rag_result is not None: - return rag_result - else: - # This should never happen since RAG always returns a result - # But handle gracefully - raise RuntimeError("RAG workflow returned None unexpectedly") + + # Get the layer order starting from current layer + from tool_classifier.enums import WORKFLOW_LAYER_ORDER + + 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: + 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, {}) + + 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...") + current_index += 1 + + # 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}") @@ -290,10 +264,7 @@ async def _execute_with_fallback_streaming( """ Execute workflow with fallback to subsequent layers (streaming). - TODO: Implement full fallback chain logic - Currently just executes the primary workflow. - - Full implementation should: + 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 @@ -313,18 +284,42 @@ async def _execute_with_fallback_streaming( yield chunk return - # TODO: Implement fallback to next layer - # For now, if workflow returns None, call RAG as fallback - logger.warning( + # Implement layer-wise fallback chain for streaming + logger.info( f"[{chat_id}] {workflow_name} returned None, " - f"falling back to RAG workflow streaming" + f"trying next layer in fallback chain" ) - streaming_result = await self.rag_workflow.execute_streaming(request, {}) - if streaming_result is not None: - async for chunk in streaming_result: - yield chunk - else: - raise RuntimeError("RAG workflow returned None unexpectedly") + + # Get the layer order starting from current layer + from tool_classifier.enums import WORKFLOW_LAYER_ORDER + + 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: + 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, {}) + + 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...") + current_index += 1 + + # 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}") diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py new file mode 100644 index 00000000..c885b524 --- /dev/null +++ b/src/tool_classifier/constants.py @@ -0,0 +1,60 @@ +"""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.""" + +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.""" + + +# ============================================================================ +# 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.""" diff --git a/src/tool_classifier/intent_detector.py b/src/tool_classifier/intent_detector.py new file mode 100644 index 00000000..24c1538a --- /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 ChainOfThought.""" + super().__init__() + self.detector = dspy.ChainOfThought(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/workflows/ood_workflow.py b/src/tool_classifier/workflows/ood_workflow.py index fed467a5..cd114f70 100644 --- a/src/tool_classifier/workflows/ood_workflow.py +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -104,9 +104,9 @@ async def execute_streaming( # 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) + yield self.format_sse(request.chatId, chunk) await asyncio.sleep(0.01) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, "END") return stream_ood_message() ``` diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index d83080a7..6c586481 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -87,7 +87,7 @@ async def execute_async( ) # Log costs and timings - self.orchestration_service._log_costs(costs_dict) + self.orchestration_service.log_costs(costs_dict) from src.utils.time_tracker import log_step_timings log_step_timings(timing_dict, request.chatId) diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index 8a6889bc..d71e2d92 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -1,137 +1,796 @@ """Service workflow executor - Layer 1: External service/API calls.""" -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, List, Optional, Protocol + +import dspy +import httpx from loguru import logger -from models.request_models import OrchestrationRequest, OrchestrationResponse +from src.utils.cost_utils import get_lm_usage_since + +from models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, +) 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_BASE_URL, + SEMANTIC_SEARCH_THRESHOLD, + SEMANTIC_SEARCH_TOP_K, + SERVICE_COUNT_THRESHOLD, + SERVICE_DISCOVERY_TIMEOUT, +) +from tool_classifier.intent_detector import IntentDetectionModule -class ServiceWorkflowExecutor(BaseWorkflow): - """ - Executes external service calls via Ruuter endpoints (Layer 1). - - This workflow handles queries that require calling external government - services or APIs. It performs: - 1. Service discovery (semantic search if >50 services) - 2. Intent detection using LLM - 3. Entity extraction from query - 4. Service validation against database - 5. External API call via Ruuter - 6. Output guardrails validation - - Examples of Service queries: - - "What's the EUR to USD exchange rate?" - - "Check my document status" - - "Submit a tax declaration" - - Implementation Status: SKELETON - Returns None (triggers fallback to Context workflow) - - TODO - Full Implementation (Separate Task): - - Service discovery logic (Qdrant semantic search) - - Intent detection (LLM-based) - - Entity extraction and transformation - - Service validation (database lookup) - - Ruuter API integration - - Output guardrails for service responses - """ - - def __init__(self, llm_manager: Any): +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) -> str: + """Format content as SSE message. + + Args: + chat_id: Chat/channel identifier + content: Content to send (token, "END", error message, etc.) + + Returns: + SSE-formatted string: "data: {json}\\n\\n" """ - Initialize service workflow executor. + ... + + def log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: + """Log cost information for tracking. Args: - llm_manager: LLM manager for intent detection + costs_dict: Dictionary of costs per component """ + ... + + +class ServiceWorkflowExecutor(BaseWorkflow): + """Executes external service calls via Ruuter endpoints (Layer 1).""" + + def __init__( + self, + llm_manager: Any, + orchestration_service: Optional[LLMServiceProtocol] = None, + ) -> None: + """Initialize service workflow executor.""" self.llm_manager = llm_manager - logger.info("Service workflow executor initialized (skeleton)") + self.orchestration_service = orchestration_service - async def execute_async( + async def _semantic_search_services( self, + query: str, request: OrchestrationRequest, - context: Dict[str, Any], - ) -> Optional[OrchestrationResponse]: + 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). """ - Execute service workflow in non-streaming mode. - - TODO: Implement service workflow logic: - 1. Extract service metadata from context (service_id, intent, entities) - 2. Validate service exists and is active in database - 3. Transform entities to array format for service call - 4. Call Ruuter endpoint: POST {RUUTER_BASE_URL}/services/active{ServiceName} - 5. Validate response with output guardrails - 6. Return OrchestrationResponse with service result - - Failure scenarios: - - No service_id in context → return None (fallback to Context) - - Service not found/inactive → return None (fallback to Context) - - Service call timeout → return error response - - Output guardrails blocked → return violation response or None + if not self.orchestration_service: + logger.error( + f"[{chat_id}] Semantic search unavailable: orchestration service not provided" + ) + return None - Args: - request: Orchestration request with user query - context: Metadata with service_id, intent, entities + try: + # Generate embedding using orchestration service + 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] + + # Create Qdrant client with proper resource cleanup via context manager + qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" + async with httpx.AsyncClient( + base_url=qdrant_url, timeout=QDRANT_TIMEOUT + ) as client: + # Verify collection exists and has data + 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 Qdrant collection + 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 + + # Transform Qdrant results to service format + 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", []), + # Note: endpoint not stored in intent_collections, + # will be resolved via database lookup if needed + "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: - OrchestrationResponse with service result or None to fallback + Tuple of (intent_result, usage_info): + - intent_result: Intent detection result dict (or None on error) + - usage_info: Cost and token usage information """ - logger.debug( - f"[{request.chatId}] Service workflow execute_async called " - f"(not implemented - returning None)" - ) + try: + # Ensure DSPy is configured with LLMManager + if self.llm_manager: + self.llm_manager.ensure_global_config() + else: + logger.error(f"[{chat_id}] LLM Manager not available") + return None, {} - # TODO: Implement service workflow logic here - # For now, return None to trigger fallback to next layer + # Capture history length before LLM call for cost tracking + lm = dspy.settings.lm + history_length_before = ( + len(lm.history) if lm and hasattr(lm, "history") else 0 + ) + + # Create DSPy module + intent_module = IntentDetectionModule() + + # Convert conversation history to dict format + history_dicts = [ + {"authorRole": msg.authorRole, "message": msg.message} + for msg in conversation_history + if hasattr(msg, "authorRole") and hasattr(msg, "message") + ] + + # Call DSPy forward with task-local config + with self.llm_manager.use_task_local(): + intent_result = intent_module.forward( + user_query=user_query, + services=services, + conversation_history=history_dicts, + ) + + # Extract usage information after LLM call + 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 execute_streaming( + async def _process_intent_detection( self, + services: List[Dict[str, Any]], request: OrchestrationRequest, + chat_id: str, context: Dict[str, Any], - ) -> Optional[AsyncIterator[str]]: + costs_dict: 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_dict: Dictionary to track LLM costs """ - Execute service workflow in streaming mode. - - TODO: Implement service workflow streaming: - 1. Execute service call (same as non-streaming) - 2. Get complete service response - 3. Validate with output guardrails (validation-first) - 4. If blocked: yield violation message + END - 5. If allowed: chunk response and stream token-by-token - 6. Simulate streaming for consistent UX with RAG - - Streaming approach (validation-first): - ```python - # Get complete response - service_response = await call_service(...) - - # Validate BEFORE streaming - is_safe = await guardrails.check_output_async(service_response) - if not is_safe: - yield format_sse(chatId, VIOLATION_MESSAGE) - yield format_sse(chatId, "END") - return - - # Stream validated response - for chunk in split_into_tokens(service_response, chunk_size=5): - yield format_sse(chatId, chunk) - await asyncio.sleep(0.01) - yield format_sse(chatId, "END") - ``` + intent_result, intent_usage = await self._detect_service_intent( + user_query=request.message, + services=services, + conversation_history=request.conversationHistory, + chat_id=chat_id, + ) + costs_dict["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.""" + # Check if service_id exists + service_id = context.get("service_id") + if not service_id: + logger.error(f"[{chat_id}] Missing service_id in context") + return None + + # Check if service_data exists + service_data = context.get("service_data") + if not service_data: + logger.error(f"[{chat_id}] Missing service_data in context") + return None + + # Extract entities dict from context (LLM extracted) + entities_dict = context.get("entities", {}) + + # Extract entity schema from service_data (expected order) + entity_schema = service_data.get("entities", []) + if entity_schema is None: + entity_schema = [] + + # Extract service name + service_name = service_data.get("name", service_id) + + # Extract HTTP method (ruuter_type) - defaults to GET if not specified + ruuter_type = service_data.get("ruuter_type", "GET") + + return { + "service_id": service_id, + "service_name": service_name, + "entities_dict": entities_dict, + "entity_schema": entity_schema, + "ruuter_type": ruuter_type, + "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: - request: Orchestration request with user query - context: Metadata with service_id, intent, entities + 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: - AsyncIterator yielding SSE strings or None to fallback + 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 """ - logger.debug( - f"[{request.chatId}] Service workflow execute_streaming called " - f"(not implemented - returning None)" + 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) + for entity_key in extracted_entities: + if entity_key not in service_schema: + extra_entities.append(entity_key) + + # Determine overall validity + # We consider it valid even with missing entities (will send empty strings) + # Let the external service validate required parameters + is_valid = True # Always true - we proceed with partial entities + + 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 [] + + # Transform to ordered array, filling missing with empty strings + return [entities_dict.get(key, "") for key in entity_order] + + def _construct_service_endpoint(self, service_name: str, chat_id: str) -> str: + """Construct the full service endpoint URL for Ruuter.""" + return f"{RUUTER_BASE_URL}/services/active{service_name}" + + def _format_debug_response( + self, + service_name: str, + endpoint_url: str, + http_method: str, + entities_array: List[str], + ) -> str: + """Format debug information for testing (temporary before Step 7 implementation).""" + entities_str = ", ".join(f'"{e}"' for e in entities_array) + return ( + f" Service Validated: {service_name}\n" + f" Endpoint URL: {endpoint_url}\n" + f" HTTP Method: {http_method}\n" + f" Extracted Entities: [{entities_str}]\n\n" ) - # TODO: Implement service streaming logic here - # For now, return None to trigger fallback to next layer - return None + async def _log_request_details( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + mode: str, + costs_dict: 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_dict: Dictionary to accumulate cost tracking information + """ + chat_id = request.chatId + logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") + + # Service Discovery + discovery_result = await self._call_service_discovery(chat_id) + + if discovery_result: + # Extract data from nested response structure + response_data = discovery_result.get("response", {}) + use_semantic = response_data.get("use_semantic_search", False) + service_count = response_data.get("service_count", 0) + + # Handle service_count if it's a string or NaN + if isinstance(service_count, str): + try: + service_count = int(service_count) + except (ValueError, TypeError): + service_count = 0 + + services_from_ruuter = response_data.get("services", []) + + # Use semantic search if count > threshold + if service_count > SERVICE_COUNT_THRESHOLD: + use_semantic = True + + if use_semantic: + # Use semantic search to find relevant services + 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_dict=costs_dict, + ) + else: + services = response_data.get("services", []) + + if services: + await self._process_intent_detection( + services=services, + request=request, + chat_id=chat_id, + context=context, + costs_dict=costs_dict, + ) + else: + logger.warning(f"[{chat_id}] Service discovery failed") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """Execute service workflow in non-streaming mode.""" + chat_id = request.chatId + + # Create costs tracking dictionary (follows RAG workflow pattern) + costs_dict: Dict[str, Dict[str, Any]] = {} + + # Log comprehensive request details and perform service discovery + await self._log_request_details( + request, context, mode="non-streaming", costs_dict=costs_dict + ) + + # Check if service was detected and validated + if not context.get("service_id"): + logger.info( + f"[{chat_id}] No service detected or validated - " + f"returning None to fallback to next layer" + ) + return None + + # Entity Transformation & Validation + logger.info(f"[{chat_id}] Entity Transformation:") + + # Step 1: Extract service metadata from context + service_metadata = self._extract_service_metadata(context, chat_id) + if not service_metadata: + logger.error( + f"[{chat_id}] - Metadata extraction failed - " + f"returning None to fallback" + ) + return None + + logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") + logger.info( + f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" + ) + logger.info( + f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + ) + + # Step 2: Validate entities against schema + 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, + ) + + logger.info( + f"[{chat_id}] - Validation status: " + f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" + ) + + if validation_result["missing_entities"]: + logger.warning( + f"[{chat_id}] - Missing entities (will send empty strings): " + f"{validation_result['missing_entities']}" + ) + + if validation_result["extra_entities"]: + logger.info( + f"[{chat_id}] - Extra entities (ignored): " + f"{validation_result['extra_entities']}" + ) + + if validation_result["validation_errors"]: + for error in validation_result["validation_errors"]: + logger.warning(f"[{chat_id}] - Validation warning: {error}") + + # Step 3: Transform entities dict to ordered array + 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 + + # Construct service endpoint URL + endpoint_url = self._construct_service_endpoint( + service_name=service_metadata["service_name"], chat_id=chat_id + ) + + context["endpoint_url"] = endpoint_url + context["http_method"] = service_metadata["ruuter_type"] + + logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") + + # TODO: STEP 7 - Call Ruuter service endpoint and return response + # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} + # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT + # 3. Parse Ruuter response and extract result + # 4. Return OrchestrationResponse with actual service result + # 5. Handle errors (timeout, HTTP errors, malformed JSON) + + # STEP 6: Return debug response (temporary until Step 7 - Ruuter call implemented) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) + debug_content = self._format_debug_response( + service_name=service_metadata["service_name"], + endpoint_url=endpoint_url, + http_method=service_metadata["ruuter_type"], + entities_array=entities_array, + ) + + logger.info(f"[{chat_id}] Returning debug response (Step 7 pending)") + + # Log costs after service workflow completes (follows RAG workflow pattern) + if self.orchestration_service: + self.orchestration_service.log_costs(costs_dict) + + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=debug_content, + ) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """Execute service workflow in streaming mode.""" + chat_id = request.chatId + + # Create costs tracking dictionary (follows RAG workflow pattern) + costs_dict: Dict[str, Dict[str, Any]] = {} + + # Log comprehensive request details and perform service discovery + await self._log_request_details( + request, context, mode="streaming", costs_dict=costs_dict + ) + + # Check if service was detected and validated + if not context.get("service_id"): + logger.info( + f"[{chat_id}] No service detected or validated - " + f"returning None to fallback to next layer" + ) + return None + + # Entity Transformation & Validation + logger.info(f"[{chat_id}] Entity Transformation:") + + # Step 1: Extract service metadata from context + service_metadata = self._extract_service_metadata(context, chat_id) + if not service_metadata: + logger.error( + f"[{chat_id}] - Metadata extraction failed - " + f"returning None to fallback" + ) + return None + + logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") + logger.info( + f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" + ) + logger.info( + f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + ) + + # Step 2: Validate entities against schema + 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, + ) + + logger.info( + f"[{chat_id}] - Validation status: " + f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" + ) + + if validation_result["missing_entities"]: + logger.warning( + f"[{chat_id}] - Missing entities (will send empty strings): " + f"{validation_result['missing_entities']}" + ) + + if validation_result["extra_entities"]: + logger.info( + f"[{chat_id}] - Extra entities (ignored): " + f"{validation_result['extra_entities']}" + ) + + if validation_result["validation_errors"]: + for error in validation_result["validation_errors"]: + logger.warning(f"[{chat_id}] - Validation warning: {error}") + + # Step 3: Transform entities dict to ordered array + 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 + + # Construct service endpoint URL + endpoint_url = self._construct_service_endpoint( + service_name=service_metadata["service_name"], chat_id=chat_id + ) + + context["endpoint_url"] = endpoint_url + context["http_method"] = service_metadata["ruuter_type"] + + logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") + + # TODO: STEP 7 - Call Ruuter service endpoint and stream response + # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} + # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT + # 3. Parse Ruuter response and extract result + # 4. Format result as SSE and yield chunks + # 5. Handle errors (timeout, HTTP errors, malformed JSON) + + # STEP 6: Return debug response as async iterator (temporary until Step 7) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) + debug_content = self._format_debug_response( + service_name=service_metadata["service_name"], + endpoint_url=endpoint_url, + http_method=service_metadata["ruuter_type"], + entities_array=entities_array, + ) + + logger.info(f"[{chat_id}] Streaming debug response (Step 7 pending)") + + if self.orchestration_service is None: + raise RuntimeError("Orchestration service not initialized for streaming") + + # Store reference for closure (helps type checker) + orchestration_service = self.orchestration_service + + async def debug_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse(chat_id, debug_content) + yield orchestration_service.format_sse(chat_id, "END") + + # Log costs after streaming completes (follows RAG workflow pattern) + # Must be inside generator because costs are accumulated during streaming + orchestration_service.log_costs(costs_dict) + + return debug_stream() + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) From 721263a44cf34bb6bcece1228b4bb73436b3fb3d Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 25 Feb 2026 16:36:08 +0530 Subject: [PATCH 040/105] fixed issue --- src/llm_orchestration_service.py | 9 +++------ src/tool_classifier/intent_detector.py | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 30d2eddf..74329578 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -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, @@ -67,7 +66,7 @@ class LangfuseConfig: """Configuration for Langfuse integration.""" - def __init__(self): + def __init__(self) -> None: self.langfuse_client: Optional[Langfuse] = None self._initialize_langfuse() @@ -496,10 +495,8 @@ async def stream_orchestration_response( components = self._initialize_service_components(request) timing_dict["initialization"] = time.time() - start_time - # PRIORITY 1 OPTIMIZATION: Input Guardrails Check BEFORE Classifier # This implements fail-fast principle - block malicious/policy-violating inputs # before expensive operations (service discovery, LLM calls, streaming setup) - # Saves 6.4s + $0.002 per blocked request! logger.info( f"[{request.chatId}] [{stream_ctx.stream_id}] Checking input guardrails (before classifier)" ) @@ -1086,12 +1083,12 @@ def _initialize_service_components( # Falls back to per-request initialization if shared instance unavailable if self.shared_guardrails_adapter is not None: logger.debug( - f"Using shared guardrails adapter (startup-initialized, zero overhead)" + "Using shared guardrails adapter (startup-initialized, zero overhead)" ) components["guardrails_adapter"] = self.shared_guardrails_adapter else: logger.warning( - f"Shared guardrails unavailable, initializing per-request (slower)" + "Shared guardrails unavailable, initializing per-request (slower)" ) components["guardrails_adapter"] = self._safe_initialize_guardrails( request.environment, request.connection_id diff --git a/src/tool_classifier/intent_detector.py b/src/tool_classifier/intent_detector.py index 24c1538a..a2abb74f 100644 --- a/src/tool_classifier/intent_detector.py +++ b/src/tool_classifier/intent_detector.py @@ -42,9 +42,9 @@ class IntentDetectionModule(dspy.Module): """DSPy Module for service intent detection.""" def __init__(self) -> None: - """Initialize intent detection module with ChainOfThought.""" + """Initialize intent detection module with Predict (direct prediction).""" super().__init__() - self.detector = dspy.ChainOfThought(ServiceIntentDetector) + self.detector = dspy.Predict(ServiceIntentDetector) def forward( self, From f8a82b6c1f6237013ade045b5842f9c9158f8115 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Wed, 25 Feb 2026 17:58:53 +0530 Subject: [PATCH 041/105] fixed issue --- src/llm_orchestration_service.py | 90 ++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 74329578..0224a536 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -133,45 +133,69 @@ def __init__(self) -> None: # This allows components to be initialized per-request with proper context self.tool_classifier = None - # Initialize shared guardrails adapter at startup - self.shared_guardrails_adapter = self._initialize_shared_guardrails_at_startup() + # 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) -> Optional[NeMoRailsAdapter]: + def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter]: """ - Initialize shared guardrails at startup. + Initialize shared guardrails adapters at startup for production and testing environments. Returns: - NeMoRailsAdapter if successful, None on failure (graceful degradation) + Dictionary mapping environment names to NeMoRailsAdapter instances. + Empty dict on failure (graceful degradation). """ - try: - logger.info(" Initializing shared guardrails at startup...") - start_time = time.time() + adapters: Dict[str, NeMoRailsAdapter] = {} - # Initialize with production environment and no specific connection - # This creates a shared guardrails instance using default/production config - guardrails_adapter = self._initialize_guardrails( - environment="production", - connection_id=None, # Shared configuration, not user-specific - ) + # Initialize adapters for commonly-used environments + environments_to_initialize = ["production", "testing"] - elapsed_time = time.time() - start_time - logger.info( - f" Shared guardrails initialized successfully in {elapsed_time:.3f}s" - ) + logger.info(" Initializing shared guardrails at startup...") + total_start_time = time.time() - return guardrails_adapter + for env in environments_to_initialize: + try: + logger.info(f" Initializing guardrails for environment: {env}") + start_time = time.time() - except Exception as e: - logger.error(f" Failed to initialize shared guardrails at startup: {e}") + # Initialize with specific environment and no connection (shared config) + guardrails_adapter = self._initialize_guardrails( + environment=env, + connection_id=None, # Shared configuration, not user-specific + ) + + elapsed_time = time.time() - start_time + adapters[env] = guardrails_adapter + logger.info( + f" Guardrails for '{env}' initialized successfully in {elapsed_time:.3f}s" + ) + + 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( - " Service will continue without guardrails (graceful degradation)" + " Failed to initialize any shared guardrails - " + "service will use per-request initialization (slower)" ) - # Return None - service continues without guardrails - # Per-request fallback will be attempted if needed - return None + + return adapters @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( @@ -1079,16 +1103,18 @@ def _initialize_service_components( environment=request.environment, connection_id=request.connection_id ) - # Use shared guardrails adapter (initialized at startup) - # Falls back to per-request initialization if shared instance unavailable - if self.shared_guardrails_adapter is not None: - logger.debug( - "Using shared guardrails adapter (startup-initialized, zero overhead)" + 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_adapter + components["guardrails_adapter"] = self.shared_guardrails_adapters[ + request.environment + ] else: logger.warning( - "Shared guardrails unavailable, initializing per-request (slower)" + 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 From 3b89fba35cf3b053c2d4fc9153224473ca0bda1a Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Thu, 26 Feb 2026 12:01:34 +0530 Subject: [PATCH 042/105] added hybrid search for the service detection --- docker-compose.yml | 1 + src/intent_data_enrichment/constants.py | 4 + src/intent_data_enrichment/main_enrichment.py | 151 ++++++-- src/intent_data_enrichment/models.py | 21 +- src/intent_data_enrichment/qdrant_manager.py | 274 ++++++++++---- src/tool_classifier/classifier.py | 350 ++++++++++++++++-- src/tool_classifier/constants.py | 19 + src/tool_classifier/sparse_encoder.py | 82 ++++ .../workflows/service_workflow.py | 156 +++++++- 9 files changed, 916 insertions(+), 142 deletions(-) create mode 100644 src/tool_classifier/sparse_encoder.py diff --git a/docker-compose.yml b/docker-compose.yml index 1fec54b5..976e27ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -178,6 +178,7 @@ 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/utils/decrypt_vault_secrets.py:/app/src/utils/decrypt_vault_secrets.py:ro # Decryption utility (read-only) - cron_data:/app/data diff --git a/src/intent_data_enrichment/constants.py b/src/intent_data_enrichment/constants.py index f1f35f38..f506880a 100644 --- a/src/intent_data_enrichment/constants.py +++ b/src/intent_data_enrichment/constants.py @@ -24,6 +24,10 @@ class EnrichmentConstants: 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} diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py index d718678e..d82358b2 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -3,19 +3,61 @@ Service Data Enrichment Script This script receives service data, enriches it with LLM-generated context, -creates embeddings, and stores in Qdrant intent_collections. +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.""" @@ -76,7 +118,8 @@ def parse_arguments() -> ServiceData: async def enrich_service(service_data: ServiceData) -> EnrichmentResult: """ - Main enrichment pipeline: generate context, create embedding, store in Qdrant. + Main enrichment pipeline: generate context, create per-example embeddings, + store in Qdrant with hybrid vectors (dense + sparse). Args: service_data: Service data to enrich @@ -85,14 +128,51 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: EnrichmentResult with success/failure information """ try: - # Step 1: Generate rich context using LLM + # 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: Combine generated context with original metadata for embedding - logger.info("Step 2: Combining context with original service metadata") + # 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", + ) + ) + + # 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}", @@ -108,35 +188,44 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: f"Required Entities: {', '.join(service_data.entities)}" ) - # Add generated context last (enriched understanding) combined_text_parts.append(f"Enriched Context: {context}") - combined_text = "\n".join(combined_text_parts) - logger.info(f"Combined text length: {len(combined_text)} characters") - - # Step 3: Create embedding for combined text - logger.info("Step 3: Creating embedding vector for combined text") - embedding = await api_client.create_embedding(combined_text) - logger.success(f"Embedding created: {len(embedding)}-dimensional vector") - - # Step 4: Prepare enriched service - enriched_service = 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=embedding, - ) - # Step 5: Store in Qdrant - logger.info("Step 5: Storing in Qdrant") + 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", + ) + ) + + # 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() - success = qdrant.upsert_service(enriched_service) + + # Delete old points before inserting new ones + qdrant.delete_service_points(service_data.service_id) + + # 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() @@ -144,9 +233,13 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: return EnrichmentResult( success=True, service_id=service_data.service_id, - message=f"Service '{service_data.name}' enriched and indexed successfully", + 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(embedding), + embedding_dimension=len(summary_embedding), error=None, ) else: diff --git a/src/intent_data_enrichment/models.py b/src/intent_data_enrichment/models.py index eb0ef64e..9390e736 100644 --- a/src/intent_data_enrichment/models.py +++ b/src/intent_data_enrichment/models.py @@ -20,7 +20,12 @@ class ServiceData(BaseModel): class EnrichedService(BaseModel): - """Enriched service data ready for storage.""" + """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") @@ -28,7 +33,19 @@ class EnrichedService(BaseModel): 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="Context embedding vector") + 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'" + ) class EnrichmentResult(BaseModel): diff --git a/src/intent_data_enrichment/qdrant_manager.py b/src/intent_data_enrichment/qdrant_manager.py index 5024e236..579357a6 100644 --- a/src/intent_data_enrichment/qdrant_manager.py +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -1,10 +1,21 @@ -"""Qdrant manager for intent collections.""" +"""Qdrant manager for intent collections with hybrid search support.""" import uuid -from typing import Optional +from typing import Optional, List from loguru import logger from qdrant_client import QdrantClient -from qdrant_client.models import Distance, VectorParams, PointStruct +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 @@ -14,7 +25,7 @@ class QdrantManager: - """Manages Qdrant operations for intent collections.""" + """Manages Qdrant operations for intent collections with hybrid search.""" def __init__( self, @@ -44,7 +55,12 @@ def connect(self) -> None: raise def ensure_collection(self) -> None: - """Ensure the intent_collections collection exists with correct vector size.""" + """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) @@ -53,48 +69,60 @@ def ensure_collection(self) -> None: collection_names = [col.name for col in collections] if self.collection_name in collection_names: - # Check if existing collection has correct vector size collection_info = self.client.get_collection(self.collection_name) - - # Qdrant vectors config is a dict - get the default vector config vectors_config = collection_info.config.params.vectors - existing_vector_size: Optional[int] = None + # Check if collection has the expected named vector configuration if isinstance(vectors_config, dict): - # Get first vector config (usually the default/unnamed one) - if vectors_config: - vector_params = next(iter(vectors_config.values())) - existing_vector_size = vector_params.size + 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 (older API) - existing_vector_size = vectors_config.size - - if existing_vector_size is None: + # Direct VectorParams object (old single-vector format) logger.error( - f"Collection '{self.collection_name}' exists but vector size cannot be determined" + f"Collection '{self.collection_name}' exists but uses old single-vector format." ) raise RuntimeError( - f"Collection '{self.collection_name}' exists but vector size cannot be determined. " - "This may indicate a Qdrant API issue or unexpected collection configuration. " - "Manual intervention required: verify Qdrant health, inspect collection config, " - "or manually delete the collection if recreating is intended." + 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 existing_vector_size != EnrichmentConstants.VECTOR_SIZE: + else: logger.error( - f"Collection '{self.collection_name}' has incompatible vector size: " - f"{existing_vector_size} (expected {EnrichmentConstants.VECTOR_SIZE})" + f"Collection '{self.collection_name}' exists but vector config cannot be determined" ) raise RuntimeError( - f"Collection '{self.collection_name}' has incompatible vector size " - f"({existing_vector_size} vs expected {EnrichmentConstants.VECTOR_SIZE}). " - "This prevents automatic deletion to avoid accidental data loss. " - "To recreate the collection, manually delete it first using: " - f"qdrant.client.delete_collection('{self.collection_name}') or via Qdrant UI/API." - ) - else: - logger.info( - f"Collection '{self.collection_name}' already exists " - f"with correct vector size ({existing_vector_size})" + f"Collection '{self.collection_name}' exists but vector config cannot be determined. " + "Manual intervention required." ) else: self._create_collection() @@ -104,77 +132,175 @@ def ensure_collection(self) -> None: raise def _create_collection(self) -> None: - """Create the collection with correct vector configuration.""" + """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 vector size {EnrichmentConstants.VECTOR_SIZE}" + f"with hybrid vectors (dense: {EnrichmentConstants.VECTOR_SIZE}d + sparse)" ) self.client.create_collection( collection_name=self.collection_name, - vectors_config=VectorParams( - size=EnrichmentConstants.VECTOR_SIZE, - distance=Distance.COSINE, - ), + 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 upsert_service(self, enriched_service: EnrichedService) -> bool: - """ - Upsert enriched service to Qdrant (update if exists, insert if new). + 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: - enriched_service: EnrichedService instance containing the embedding and - associated metadata to upsert into Qdrant. + service_id: Service identifier to delete all points for Returns: True if successful, False otherwise """ try: if not self.client: - raise RuntimeError("Qdrant client not initialized") - - logger.info(f"Upserting service '{enriched_service.id}' to Qdrant") - - # Convert service_id to UUID for Qdrant compatibility - # Qdrant requires point IDs to be either integers or UUIDs - point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, enriched_service.id)) - - # Prepare payload (all metadata except embedding) - payload = { - "service_id": enriched_service.id, # Store original ID in payload - "name": enriched_service.name, - "description": enriched_service.description, - "examples": enriched_service.examples, - "entities": enriched_service.entities, - "context": enriched_service.context, - } - - # Create point with UUID - point = PointStruct( - id=point_id, # ✓ Now using UUID string - vector=enriched_service.embedding, - payload=payload, + 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) - # Upsert to Qdrant + 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, + } + + # 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=[point], + points=points, ) logger.success( - f"Successfully upserted service '{enriched_service.id}' " - f"({len(enriched_service.embedding)}-dim vector)" + 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 '{enriched_service.id}': {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: diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index ab9e402e..2313b94d 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -1,6 +1,7 @@ -"""Main tool classifier for workflow routing.""" +"""Main tool classifier for workflow routing with hybrid search classification.""" from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union, overload +import httpx from loguru import logger from models.request_models import ( @@ -10,6 +11,17 @@ ) from tool_classifier.enums import WorkflowType, WORKFLOW_DISPLAY_NAMES from tool_classifier.models import ClassificationResult +from tool_classifier.constants import ( + QDRANT_HOST, + QDRANT_PORT, + QDRANT_COLLECTION, + QDRANT_TIMEOUT, + HYBRID_SEARCH_TOP_K, + HYBRID_SEARCH_MIN_THRESHOLD, + SCORE_RATIO_THRESHOLD, + SCORE_GAP_THRESHOLD, +) +from tool_classifier.sparse_encoder import compute_sparse_vector from tool_classifier.workflows import ( ServiceWorkflowExecutor, ContextWorkflowExecutor, @@ -22,19 +34,16 @@ class ToolClassifier: """ Main classifier that determines which workflow should handle user queries. + Uses Qdrant hybrid search (dense + sparse + RRF fusion) to classify queries: + - 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 - - Each layer is tried in sequence. If a layer cannot handle the query - (returns None), the classifier falls back to the next layer. - - Architecture: - - Strategy Pattern: Each workflow is a pluggable strategy - - Chain of Responsibility: Layers form a fallback chain - - Dependency Injection: LLM manager and connections injected from main service """ def __init__( @@ -52,6 +61,17 @@ def __init__( 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.service_workflow = ServiceWorkflowExecutor( llm_manager=llm_manager, @@ -65,7 +85,10 @@ def __init__( ) self.ood_workflow = OODWorkflowExecutor() - logger.info("Tool classifier initialized with all workflow executors") + logger.info( + "Tool classifier initialized with hybrid search classification " + f"(Qdrant: {self._qdrant_base_url})" + ) async def classify( self, @@ -74,13 +97,16 @@ async def classify( language: str, ) -> ClassificationResult: """ - Classify a user query to determine which workflow should handle it. + Classify a user query using Qdrant hybrid search (dense + sparse + RRF). - Implements layer-wise classification logic with fallback chain: - 1. SERVICE workflow (external API calls) - 2. CONTEXT workflow (greetings/conversation history) - 3. RAG workflow (knowledge base retrieval) - 4. OOD workflow (out-of-domain) + Classification flow: + 1. Generate dense embedding for the query + 2. Generate sparse vector for the query (BM25-style) + 3. Run hybrid search on intent_collections (prefetch dense + sparse → RRF fusion) + 4. Apply score-gap analysis: + - Clear winner (high ratio + gap) → SERVICE with high confidence + - Ambiguous (scores exist but close) → SERVICE with LLM confirmation flag + - No match (low/no scores) → CONTEXT (skip SERVICE entirely) Args: query: User's query string @@ -92,13 +118,291 @@ async def classify( """ logger.info(f"Classifying query: {query[:100]}...") - logger.info("Starting layer-wise fallback: ") - return ClassificationResult( - workflow=WorkflowType.SERVICE, - confidence=1.0, - metadata={}, - reasoning="Start with Service workflow - will cascade through layers", - ) + try: + # 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: Generate sparse vector for query + query_sparse = compute_sparse_vector(query) + + # Step 3: Qdrant hybrid search with RRF fusion + results = await self._hybrid_search( + dense_vector=query_embedding, + sparse_vector=query_sparse, + top_k=HYBRID_SEARCH_TOP_K, + ) + + if not results: + logger.info("No hybrid search results - routing to CONTEXT/RAG") + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={"reason": "no_service_match"}, + reasoning="No services matched the query", + ) + + # Step 4: Score-gap analysis + top = results[0] + top_score = top.get("rrf_score", 0.0) + top_service_id = top.get("service_id", "unknown") + top_service_name = top.get("name", "unknown") + + second_score = results[1].get("rrf_score", 0.0) if len(results) > 1 else 0.0 + + score_ratio = top_score / max(second_score, 0.0001) + score_gap = top_score - second_score + + logger.info( + f"Hybrid search results - " + f"top: {top_service_name} (score={top_score:.6f}), " + f"second: {results[1].get('name', 'none') if len(results) > 1 else 'none'} " + f"(score={second_score:.6f}), " + f"ratio={score_ratio:.2f}, gap={score_gap:.6f}" + ) + + # High confidence: clear winner → SERVICE (skip discovery + intent detection) + if score_ratio > SCORE_RATIO_THRESHOLD and score_gap > SCORE_GAP_THRESHOLD: + logger.info( + f"High-confidence service match: {top_service_name} " + f"(ratio={score_ratio:.2f}, gap={score_gap:.6f})" + ) + return ClassificationResult( + workflow=WorkflowType.SERVICE, + confidence=min(score_ratio / 5.0, 1.0), + metadata={ + "matched_service_id": top_service_id, + "matched_service_name": top_service_name, + "rrf_score": top_score, + "score_gap": score_gap, + "score_ratio": score_ratio, + "needs_llm_confirmation": False, + "top_results": results[:3], + }, + reasoning=( + f"High-confidence match: {top_service_name} " + f"(ratio={score_ratio:.2f}, gap={score_gap:.6f})" + ), + ) + + # Medium confidence: ambiguous → SERVICE with LLM confirmation + if top_score > HYBRID_SEARCH_MIN_THRESHOLD: + logger.info( + f"Ambiguous service match: {top_service_name} " + f"(score={top_score:.6f}, ratio={score_ratio:.2f}) - needs LLM confirmation" + ) + return ClassificationResult( + workflow=WorkflowType.SERVICE, + confidence=0.5, + metadata={ + "matched_service_id": top_service_id, + "matched_service_name": top_service_name, + "rrf_score": top_score, + "score_gap": score_gap, + "score_ratio": score_ratio, + "needs_llm_confirmation": True, + "top_results": results[:3], + }, + reasoning=( + f"Ambiguous match: {top_service_name} " + f"(score={top_score:.6f}) - LLM confirmation needed" + ), + ) + + # No confidence: skip SERVICE entirely → CONTEXT/RAG + logger.info( + f"No service match (top_score={top_score:.6f} below threshold " + f"{HYBRID_SEARCH_MIN_THRESHOLD}) - routing to CONTEXT/RAG" + ) + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={"reason": "below_threshold", "top_score": top_score}, + reasoning=f"Top score {top_score:.6f} below threshold - skip to Context/RAG", + ) + + except Exception as e: + logger.error(f"Hybrid classification failed: {e}", exc_info=True) + # Fallback: route to CONTEXT/RAG on any error + 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 _hybrid_search( + self, + dense_vector: List[float], + sparse_vector: Any, + 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( diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index c885b524..7db6aa95 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -58,3 +58,22 @@ 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 classification.""" + +HYBRID_SEARCH_MIN_THRESHOLD = 0.01 +"""Minimum RRF score to consider a result as a potential match.""" + +SCORE_RATIO_THRESHOLD = 2.0 +"""Score ratio (top/second) for confident service classification. +If the top result's RRF score is > 2x the second result, it's a high-confidence match.""" + +SCORE_GAP_THRESHOLD = 0.005 +"""Absolute score gap for confident classification. +Prevents false positives when both scores are very low.""" diff --git a/src/tool_classifier/sparse_encoder.py b/src/tool_classifier/sparse_encoder.py new file mode 100644 index 00000000..0d0dc3f6 --- /dev/null +++ b/src/tool_classifier/sparse_encoder.py @@ -0,0 +1,82 @@ +""" +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 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) + # Collisions are handled by summing values at the same index + hash_counts: dict[int, float] = {} + for token, count in token_counts.items(): + idx = hash(token) % 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/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index bed97dde..747c987f 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -553,9 +553,14 @@ async def execute_async( ) -> 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 + context: Workflow context (contains classification metadata) timing_dict: Optional timing dictionary for unified tracking """ import time @@ -568,12 +573,77 @@ async def execute_async( if timing_dict is None: timing_dict = {} - # Service discovery with timing - start_time = time.time() - await self._log_request_details( - request, context, mode="non-streaming", costs_dict=costs_dict - ) - timing_dict["service.discovery"] = time.time() - start_time + # Check if classifier provided hybrid search metadata + needs_llm_confirmation = context.get("needs_llm_confirmation") + + if needs_llm_confirmation is False: + # HIGH CONFIDENCE PATH: Classifier matched a service with high confidence + # Skip service discovery — use hybrid search match directly + matched_service_id = context.get("matched_service_id") + matched_service_name = context.get("matched_service_name") + rrf_score = context.get("rrf_score", 0) + + logger.info( + f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (non-streaming): " + f"{matched_service_name} (rrf_score={rrf_score:.6f}) - " + f"skipping discovery" + ) + + # Get service details from top_results (already retrieved by classifier) + top_results = context.get("top_results", []) + if top_results: + matched = top_results[0] + + # Run entity extraction via LLM (DSPy) for this single service + start_time = time.time() + await self._process_intent_detection( + services=[matched], + request=request, + chat_id=chat_id, + context=context, + costs_dict=costs_dict, + ) + timing_dict["service.intent_detection"] = time.time() - start_time + + # Ensure service_data is populated from hybrid match + # _process_intent_detection may not set it if DSPy returns + # a different service_id format, so we populate it explicitly + if not context.get("service_data"): + context["service_id"] = matched.get("service_id") + context["service_data"] = matched + logger.info( + f"[{chat_id}] Populated service_data from hybrid match: " + f"{matched.get('name')}" + ) + + elif needs_llm_confirmation is True: + # AMBIGUOUS PATH: Multiple services scored similarly + # Run LLM intent detection only on candidate services (not all services) + top_results = context.get("top_results", []) + logger.info( + f"[{chat_id}] AMBIGUOUS SERVICE MATCH (non-streaming): " + f"running LLM 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_dict=costs_dict, + ) + timing_dict["service.discovery"] = time.time() - start_time + + else: + # LEGACY PATH: No hybrid search metadata (classifier disabled or error) + # Full service discovery + intent detection (original behavior) + start_time = time.time() + await self._log_request_details( + request, context, mode="non-streaming", costs_dict=costs_dict + ) + timing_dict["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): @@ -692,9 +762,11 @@ async def execute_streaming( ) -> 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 + context: Workflow context (contains classification metadata) timing_dict: Optional timing dictionary for unified tracking """ import time @@ -707,12 +779,68 @@ async def execute_streaming( if timing_dict is None: timing_dict = {} - # Service discovery with timing - start_time = time.time() - await self._log_request_details( - request, context, mode="streaming", costs_dict=costs_dict - ) - timing_dict["service.discovery"] = time.time() - start_time + # Check if classifier provided hybrid search metadata + needs_llm_confirmation = context.get("needs_llm_confirmation") + + if needs_llm_confirmation is False: + # HIGH CONFIDENCE PATH: Skip discovery, use matched service + matched_service_name = context.get("matched_service_name") + rrf_score = context.get("rrf_score", 0) + + logger.info( + f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (streaming): " + f"{matched_service_name} (rrf_score={rrf_score:.6f})" + ) + + 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_dict=costs_dict, + ) + timing_dict["service.intent_detection"] = time.time() - start_time + + # Ensure service_data is populated from hybrid match + if not context.get("service_data"): + context["service_id"] = matched.get("service_id") + context["service_data"] = matched + logger.info( + f"[{chat_id}] Populated service_data from hybrid match: " + f"{matched.get('name')}" + ) + + elif needs_llm_confirmation is True: + # AMBIGUOUS PATH: Run LLM intent detection on candidates + top_results = context.get("top_results", []) + logger.info( + f"[{chat_id}] AMBIGUOUS SERVICE MATCH (streaming): " + f"{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_dict=costs_dict, + ) + timing_dict["service.discovery"] = time.time() - start_time + + else: + # LEGACY PATH: Full service discovery (original behavior) + start_time = time.time() + await self._log_request_details( + request, context, mode="streaming", costs_dict=costs_dict + ) + timing_dict["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): From 31365ddf20eabc5cf6c06c2f7fab9f27bc891a5a Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:41:42 +0530 Subject: [PATCH 043/105] Relocate input guardrail and workflow optimization (#322) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * update time tracking * added time tracking and reloacate input guardrail before toolclassifiier * fixed issue * fixed issue * fixed requested changes --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 10 +- docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md | 38 +- .../contextual_retrieval.md | 2 +- src/guardrails/readme.md | 2 +- src/llm_orchestration_service.py | 391 ++++++++++++------ src/tool_classifier/base_workflow.py | 4 + src/tool_classifier/classifier.py | 38 +- src/tool_classifier/intent_detector.py | 4 +- .../workflows/context_workflow.py | 4 + src/tool_classifier/workflows/ood_workflow.py | 3 + src/tool_classifier/workflows/rag_workflow.py | 34 +- .../workflows/service_workflow.py | 67 ++- src/utils/budget_tracker.py | 8 +- src/utils/time_tracker.py | 16 +- 14 files changed, 414 insertions(+), 207 deletions(-) diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md index 15669e4d..398299a8 100644 --- a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -244,9 +244,9 @@ intent_result = intent_module.forward(...) # After LLM call usage_info = get_lm_usage_since(history_length_before) -costs_dict["intent_detection"] = usage_info +costs_metric["intent_detection"] = usage_info -# Later: orchestration_service.log_costs(costs_dict) +# Later: orchestration_service.log_costs(costs_metric) ``` --- @@ -557,14 +557,14 @@ Service workflow tracks LLM costs following the RAG workflow pattern: ```python # Create costs dict at workflow level -costs_dict: Dict[str, Dict[str, Any]] = {} +costs_metric: Dict[str, Dict[str, Any]] = {} # Intent detection captures costs intent_result, intent_usage = await _detect_service_intent(...) -costs_dict["intent_detection"] = intent_usage +costs_metric["intent_detection"] = intent_usage # Log costs after workflow completes -orchestration_service.log_costs(costs_dict) +orchestration_service.log_costs(costs_metric) ``` **Cost Breakdown Logged:** diff --git a/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md b/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md index 469f8092..38d81898 100644 --- a/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md +++ b/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md @@ -425,7 +425,7 @@ formatted_content = format_service_response(service_response) # Apply output guardrails if guardrails_adapter: output_check = await guardrails_adapter.check_output_async(formatted_content) - costs_dict["output_guardrails"] = output_check.usage + costs_metric["output_guardrails"] = output_check.usage if not output_check.allowed: logger.warning(f"Service response blocked by guardrails: {output_check.reason}") @@ -449,7 +449,7 @@ 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_dict["output_guardrails"] = output_check.usage + costs_metric["output_guardrails"] = output_check.usage if not output_check.allowed: logger.warning(f"Service response blocked by guardrails") @@ -791,7 +791,7 @@ async def execute_context_workflow( request: OrchestrationRequest, llm_manager: LLMManager, guardrails_adapter: Optional[NeMoRailsAdapter], - costs_dict: Dict + costs_metric: Dict ) -> Optional[OrchestrationResponse]: """ Execute context-based response workflow with output guardrails. @@ -807,7 +807,7 @@ async def execute_context_workflow( ) # Track costs - costs_dict["context_check"] = get_lm_usage_since(history_before) + 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( @@ -820,7 +820,7 @@ async def execute_context_workflow( output_check = await guardrails_adapter.check_output_async( context_result.answer ) - costs_dict["output_guardrails"] = output_check.usage + costs_metric["output_guardrails"] = output_check.usage if not output_check.allowed: logger.warning( @@ -852,7 +852,7 @@ async def execute_context_workflow_streaming( request: OrchestrationRequest, llm_manager: LLMManager, guardrails_adapter: Optional[NeMoRailsAdapter], - costs_dict: Dict + costs_metric: Dict ) -> Optional[AsyncIterator[str]]: """ Execute context workflow with streaming support and output guardrails. @@ -871,7 +871,7 @@ async def execute_context_workflow_streaming( ) # Track costs - costs_dict["context_check"] = get_lm_usage_since(history_before) + 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( @@ -884,7 +884,7 @@ async def execute_context_workflow_streaming( output_check = await guardrails_adapter.check_output_async( context_result.answer ) - costs_dict["output_guardrails"] = output_check.usage + costs_metric["output_guardrails"] = output_check.usage if not output_check.allowed: logger.warning( @@ -941,17 +941,17 @@ def split_into_tokens(text: str, chunk_size: int = 5) -> List[str]: ```python try: result = await execute_context_workflow( - request, llm_manager, guardrails_adapter, costs_dict + 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_dict) + 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_dict) + return await execute_rag_workflow(request, components, costs_metric) ``` **Guardrail Violation Fallback:** @@ -963,7 +963,7 @@ if not output_check.allowed: # 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_dict) + return await execute_rag_workflow(request, components, costs_metric) ``` --- @@ -978,7 +978,7 @@ if not output_check.allowed: ```python # Reuse existing RAG pipeline return self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict + request, components, costs_metric, time_metric ) ``` @@ -1121,7 +1121,7 @@ if context_result.can_answer_from_context: - **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_dict["output_guardrails"]` +- **Cost**: Separate validation call tracked in `costs_metric["output_guardrails"]` - **UX Consistency**: Simulates streaming to match RAG workflow behavior ### Why Different Approaches? @@ -1601,15 +1601,15 @@ CREATE INDEX idx_classifier_decisions_workflow **Add tracking for new LLM calls:** # Service workflow - intent detection -costs_dict["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_dict["context_check -costs_dict["intent_detection"] = { +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) @@ -1663,7 +1663,7 @@ async def stream_validated_response( response_text: str, guardrails_adapter: NeMoRailsAdapter, request: OrchestrationRequest, - costs_dict: Dict + costs_metric: Dict ) -> AsyncIterator[str]: """ Apply output guardrails and stream validated response. @@ -1677,7 +1677,7 @@ async def stream_validated_response( output_check = await guardrails_adapter.check_output_async(response_text) # Track costs - costs_dict["output_guardrails"] = output_check.usage + costs_metric["output_guardrails"] = output_check.usage if not output_check.allowed: logger.warning(f"[{request.chatId}] Output blocked by guardrails") 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/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/llm_orchestration_service.py b/src/llm_orchestration_service.py index e2eb0c9e..0d32941d 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -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, @@ -67,7 +66,7 @@ class LangfuseConfig: """Configuration for Langfuse integration.""" - def __init__(self): + def __init__(self) -> None: self.langfuse_client: Optional[Langfuse] = None self._initialize_langfuse() @@ -134,9 +133,70 @@ def __init__(self) -> None: # This allows components to be initialized per-request with proper context self.tool_classifier = 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 + ) + + elapsed_time = time.time() - start_time + adapters[env] = guardrails_adapter + logger.info( + f" Guardrails for '{env}' initialized successfully in {elapsed_time:.3f}s" + ) + + 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 + @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( self, request: OrchestrationRequest @@ -161,8 +221,8 @@ async 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( @@ -170,9 +230,11 @@ async 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})" ) @@ -182,7 +244,9 @@ async def process_orchestration_request( setattr(request, "_detected_language", detected_language) # 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}" @@ -210,8 +274,30 @@ async def process_orchestration_request( content=validation_msg, ) - # Initialize all service components (only for valid queries) + # 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 + + 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 @@ -229,24 +315,29 @@ async def process_orchestration_request( ) logger.info("Tool classifier initialized") - # Classify query to determine workflow + # 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, ) + 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 + # 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( @@ -260,7 +351,7 @@ async def process_orchestration_request( ) # Execute existing RAG pipeline as fallback response = await self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict + request, components, costs_metric, time_metric ) else: raise @@ -270,27 +361,27 @@ async def process_orchestration_request( f"[{request.chatId}] Tool classifier disabled - using RAG pipeline" ) response = await self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict + 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( @@ -307,7 +398,7 @@ async 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, @@ -331,12 +422,12 @@ async 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) @@ -379,12 +470,14 @@ async def stream_orchestration_response( """ # Track costs after streaming completes - costs_dict: Dict[str, Dict[str, Any]] = {} - timing_dict: Dict[str, float] = {} + 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})" ) @@ -393,8 +486,10 @@ async def stream_orchestration_response( # Using setattr for type safety - adds dynamic attribute to Pydantic model instance setattr(request, "_detected_language", detected_language) - # Step 0.5: Basic Query Validation (before guardrails) + # 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}" @@ -419,12 +514,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"]: @@ -432,25 +530,32 @@ 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( request.chatId, INPUT_GUARDRAIL_VIOLATION_MESSAGE ) yield self.format_sse(request.chatId, "END") - self.log_costs(costs_dict) + 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" ) # TOOL CLASSIFIER INTEGRATION (STREAMING) @@ -500,8 +605,8 @@ async def stream_orchestration_response( ) # Log costs and timings - self.log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return # Exit after successful classifier routing @@ -531,8 +636,8 @@ async def stream_orchestration_response( request=request, components=components, stream_ctx=stream_ctx, - costs_dict=costs_dict, - timing_dict=timing_dict, + costs_metric=costs_metric, + time_metric=time_metric, ): yield sse_chunk @@ -549,12 +654,12 @@ async def stream_orchestration_response( yield self.format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) yield self.format_sse(request.chatId, "END") - 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 outer exception self._update_connection_budget( - request.connection_id, costs_dict, request.environment + request.connection_id, costs_metric, request.environment ) if self.langfuse_config.langfuse_client: @@ -575,8 +680,8 @@ async def _stream_rag_pipeline( request: OrchestrationRequest, components: Dict[str, Any], stream_ctx: Any, - costs_dict: Dict[str, Dict[str, Any]], - timing_dict: Dict[str, float], + costs_metric: Dict[str, Dict[str, Any]], + time_metric: Dict[str, float], ) -> AsyncIterator[str]: """ Core RAG streaming pipeline without classifier routing. @@ -594,8 +699,8 @@ async def _stream_rag_pipeline( request: Orchestration request components: Initialized service components (LLM, retriever, generator, guardrails) stream_ctx: Stream context for tracking - costs_dict: Dictionary to accumulate costs - timing_dict: Dictionary to accumulate timings + costs_metric: Dictionary to accumulate costs + time_metric: Dictionary to accumulate timings Yields: SSE-formatted strings @@ -614,8 +719,8 @@ async def _stream_rag_pipeline( original_message=request.message, conversation_history=request.conversationHistory, ) - timing_dict["prompt_refiner"] = time.time() - start_time - costs_dict["prompt_refiner"] = refiner_usage + time_metric["prompt_refiner"] = time.time() - start_time + costs_metric["prompt_refiner"] = refiner_usage logger.info( f"[{request.chatId}] [{stream_ctx.stream_id}] Prompt refinement complete" @@ -631,7 +736,7 @@ async def _stream_rag_pipeline( relevant_chunks = await self._safe_retrieve_contextual_chunks( components["contextual_retriever"], refined_output, request ) - timing_dict["contextual_retrieval"] = time.time() - start_time + time_metric["contextual_retrieval"] = time.time() - start_time except ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, @@ -647,8 +752,8 @@ async def _stream_rag_pipeline( ) 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) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -661,8 +766,8 @@ async def _stream_rag_pipeline( ) 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) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -681,7 +786,7 @@ async def _stream_rag_pipeline( chunks=relevant_chunks, max_blocks=ResponseGenerationConstants.DEFAULT_MAX_BLOCKS, ) - timing_dict["scope_check"] = time.time() - start_time + time_metric["scope_check"] = time.time() - start_time if is_out_of_scope: logger.info( @@ -692,8 +797,8 @@ async def _stream_rag_pipeline( ) 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) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -761,9 +866,9 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -790,9 +895,9 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -859,11 +964,11 @@ async def bot_response_generator() -> AsyncIterator[str]: # Extract usage information after streaming completes usage_info = get_lm_usage_since(history_length_before) - costs_dict["streaming_generation"] = usage_info + costs_metric["streaming_generation"] = usage_info # Record timings - timing_dict["streaming_generation"] = time.time() - streaming_step_start - timing_dict["output_guardrails"] = 0.0 # Inline during streaming + 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() @@ -872,18 +977,18 @@ async def bot_response_generator() -> AsyncIterator[str]: ) # Log costs and trace - 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 self._update_connection_budget( - request.connection_id, costs_dict, request.environment + 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_dict) + total_costs = calculate_total_costs(costs_metric) langfuse.update_current_generation( model=components["llm_manager"] @@ -899,7 +1004,7 @@ async def bot_response_generator() -> AsyncIterator[str]: "streaming": True, "streaming_duration_seconds": streaming_duration, "chunks_streamed": chunk_count, - "cost_breakdown": costs_dict, + "cost_breakdown": costs_metric, "chat_id": request.chatId, "environment": request.environment, "stream_id": stream_ctx.stream_id, @@ -934,13 +1039,13 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + 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_dict, request.environment + request.connection_id, costs_metric, request.environment ) raise except Exception as stream_error: @@ -957,13 +1062,13 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + 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_dict, request.environment + request.connection_id, costs_metric, request.environment ) def format_sse(self, chat_id: str, content: str) -> str: @@ -998,10 +1103,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( @@ -1112,40 +1229,44 @@ async def _execute_orchestration_pipeline( self, request: OrchestrationRequest, components: Dict[str, Any], - costs_dict: Dict[str, Dict[str, Any]], - timing_dict: Dict[str, float], + 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.""" - # Note: Query validation now happens in process_orchestration_request() - # before component initialization for true early rejection + """Execute the main orchestration pipeline with all components. - # Step 1: Input Guardrails Check - if components["guardrails_adapter"]: - start_time = time.time() - input_blocked_response = await 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 + 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 2: Refine user prompt + # 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 = 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, @@ -1158,7 +1279,7 @@ async 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"], @@ -1166,22 +1287,28 @@ async 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_key = ( + f"{prefix}.response_generation" if prefix else "response_generation" ) - timing_dict["response_generation"] = time.time() - start_time + 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 = await self.handle_output_guardrails( components["guardrails_adapter"], generated_response, request, - costs_dict, + costs_metric, ) - timing_dict["output_guardrails_check"] = time.time() - start_time + timing_key = ( + f"{prefix}.output_guardrails_check" if prefix else "output_guardrails_check" + ) + 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, @@ -1252,13 +1379,13 @@ 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 = 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: @@ -1378,7 +1505,7 @@ async def handle_output_guardrails( guardrails_adapter: Optional[NeMoRailsAdapter], generated_response: Union[OrchestrationResponse, TestOrchestrationResponse], request: OrchestrationRequest, - costs_dict: Dict[str, Dict[str, Any]], + 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) @@ -1394,7 +1521,7 @@ async def handle_output_guardrails( 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: @@ -1671,7 +1798,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). @@ -1679,7 +1806,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 @@ -1691,7 +1818,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( @@ -1744,7 +1871,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). @@ -1752,7 +1879,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 @@ -1763,7 +1890,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( @@ -1816,7 +1943,7 @@ 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. @@ -1824,7 +1951,7 @@ async 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 @@ -1835,7 +1962,7 @@ async def _check_output_guardrails( 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( @@ -1885,22 +2012,22 @@ async 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, " @@ -1954,7 +2081,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: """ @@ -1963,7 +2090,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: @@ -1991,7 +2118,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"): @@ -2346,7 +2475,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. @@ -2354,8 +2483,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: @@ -2413,7 +2542,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( diff --git a/src/tool_classifier/base_workflow.py b/src/tool_classifier/base_workflow.py index 50faf7ad..3f5835c4 100644 --- a/src/tool_classifier/base_workflow.py +++ b/src/tool_classifier/base_workflow.py @@ -33,6 +33,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute workflow in non-streaming mode. @@ -43,6 +44,7 @@ async def execute_async( 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 @@ -68,6 +70,7 @@ 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). @@ -78,6 +81,7 @@ async def execute_streaming( 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 diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index c8bef8ae..4455f8ca 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -1,6 +1,6 @@ """Main tool classifier for workflow routing.""" -from typing import Any, AsyncIterator, Dict, List, Literal, Union, overload +from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union, overload from loguru import logger from models.request_models import ( @@ -106,6 +106,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: Literal[False] = False, + time_metric: Optional[Dict[str, float]] = None, ) -> OrchestrationResponse: ... @overload @@ -114,6 +115,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: Literal[True], + time_metric: Optional[Dict[str, float]] = None, ) -> AsyncIterator[str]: ... async def route_to_workflow( @@ -121,6 +123,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: bool = False, + time_metric: Optional[Dict[str, float]] = None, ) -> Union[OrchestrationResponse, AsyncIterator[str]]: """ Route request to appropriate workflow based on classification. @@ -132,6 +135,7 @@ async def route_to_workflow( 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 @@ -162,6 +166,7 @@ async def route_to_workflow( request=request, context=classification.metadata, start_layer=classification.workflow, + time_metric=time_metric, ) else: # NON-STREAMING MODE: For /orchestrate and /orchestrate/test endpoints @@ -170,6 +175,7 @@ async def route_to_workflow( request=request, context=classification.metadata, start_layer=classification.workflow, + time_metric=time_metric, ) def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: @@ -188,6 +194,7 @@ async def _execute_with_fallback_async( request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, + time_metric: Optional[Dict[str, float]] = None, ) -> OrchestrationResponse: """ Execute workflow with fallback to subsequent layers (non-streaming). @@ -197,6 +204,13 @@ async def _execute_with_fallback_async( 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) @@ -204,7 +218,7 @@ async def _execute_with_fallback_async( logger.info(f"[{chat_id}] Executing {workflow_name} (non-streaming)") try: - result = await workflow.execute_async(request, context) + result = await workflow.execute_async(request, context, time_metric) if result is not None: logger.info(f"[{chat_id}] {workflow_name} handled successfully") @@ -232,7 +246,7 @@ async def _execute_with_fallback_async( f"(Layer {WORKFLOW_LAYER_ORDER.index(next_layer) + 1})" ) - result = await next_workflow.execute_async(request, {}) + result = await next_workflow.execute_async(request, {}, time_metric) if result is not None: logger.info(f"[{chat_id}] {next_name} handled successfully") @@ -248,7 +262,7 @@ async def _execute_with_fallback_async( 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, {}) + rag_result = await self.rag_workflow.execute_async(request, {}, time_metric) if rag_result is not None: return rag_result else: @@ -260,6 +274,7 @@ async def _execute_with_fallback_streaming( 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). @@ -269,6 +284,13 @@ async def _execute_with_fallback_streaming( 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) @@ -276,7 +298,7 @@ async def _execute_with_fallback_streaming( logger.info(f"[{chat_id}] Executing {workflow_name} (streaming)") try: - result = await workflow.execute_streaming(request, context) + result = await workflow.execute_streaming(request, context, time_metric) if result is not None: logger.info(f"[{chat_id}] {workflow_name} streaming started") @@ -307,7 +329,7 @@ async def _execute_with_fallback_streaming( f"(Layer {layer_number})" ) - result = await next_workflow.execute_streaming(request, {}) + result = await next_workflow.execute_streaming(request, {}, time_metric) if result is not None: logger.info(f"[{chat_id}] {next_name} streaming started") @@ -325,7 +347,9 @@ async def _execute_with_fallback_streaming( 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, {}) + 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 diff --git a/src/tool_classifier/intent_detector.py b/src/tool_classifier/intent_detector.py index 24c1538a..a2abb74f 100644 --- a/src/tool_classifier/intent_detector.py +++ b/src/tool_classifier/intent_detector.py @@ -42,9 +42,9 @@ class IntentDetectionModule(dspy.Module): """DSPy Module for service intent detection.""" def __init__(self) -> None: - """Initialize intent detection module with ChainOfThought.""" + """Initialize intent detection module with Predict (direct prediction).""" super().__init__() - self.detector = dspy.ChainOfThought(ServiceIntentDetector) + self.detector = dspy.Predict(ServiceIntentDetector) def forward( self, diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index 88212efa..dc23e8bf 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -35,6 +35,7 @@ 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. @@ -45,6 +46,7 @@ async def execute_async( Args: request: Orchestration request with user query and history context: Metadata with is_greeting, can_answer_from_history flags + time_metric: Optional timing dictionary for future timing tracking Returns: OrchestrationResponse with context-based answer or None to fallback @@ -62,6 +64,7 @@ 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. @@ -72,6 +75,7 @@ async def execute_streaming( Args: request: Orchestration request with user query and history context: Metadata with is_greeting, can_answer_from_history flags + time_metric: Optional timing dictionary for future timing tracking Returns: AsyncIterator yielding SSE strings or None to fallback diff --git a/src/tool_classifier/workflows/ood_workflow.py b/src/tool_classifier/workflows/ood_workflow.py index cd114f70..35a16827 100644 --- a/src/tool_classifier/workflows/ood_workflow.py +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -39,6 +39,7 @@ 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. @@ -68,6 +69,7 @@ async def execute_async( 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 @@ -86,6 +88,7 @@ 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. diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index 6c586481..b5da35b1 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -50,6 +50,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute RAG workflow in non-streaming mode. @@ -64,6 +65,7 @@ async def execute_async( Args: request: Orchestration request with user query context: Unused (RAG doesn't need classification metadata) + time_metric: Optional timing dictionary from parent (for unified tracking) Returns: OrchestrationResponse with RAG-generated answer @@ -72,25 +74,25 @@ async def execute_async( logger.info(f"[{request.chatId}] Executing RAG workflow (non-streaming)") # Initialize components needed for RAG pipeline - costs_dict: Dict[str, Any] = {} - timing_dict: Dict[str, float] = {} + costs_metric: Dict[str, Any] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} # Initialize service components components = self.orchestration_service._initialize_service_components(request) - # Call existing RAG pipeline + # Call existing RAG pipeline with "rag" prefix for namespacing response = await self.orchestration_service._execute_orchestration_pipeline( request=request, components=components, - costs_dict=costs_dict, - timing_dict=timing_dict, + costs_metric=costs_metric, + time_metric=time_metric, + prefix="rag", ) - # Log costs and timings - self.orchestration_service.log_costs(costs_dict) - from src.utils.time_tracker import log_step_timings - - log_step_timings(timing_dict, request.chatId) + # Log costs (timing is logged by parent orchestration service) + self.orchestration_service.log_costs(costs_metric) return response @@ -98,6 +100,7 @@ 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. @@ -116,6 +119,7 @@ async def execute_streaming( Args: request: Orchestration request with user query context: Unused (RAG doesn't need classification metadata) + time_metric: Optional timing dictionary from parent (for unified tracking) Returns: AsyncIterator yielding SSE-formatted strings @@ -124,8 +128,10 @@ async def execute_streaming( logger.info(f"[{request.chatId}] Executing RAG workflow (streaming)") # Initialize tracking dictionaries - costs_dict: Dict[str, Any] = {} - timing_dict: Dict[str, float] = {} + 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") @@ -166,7 +172,7 @@ def mark_error(self, error_id: str) -> None: request=request, components=components, stream_ctx=stream_ctx, - costs_dict=costs_dict, - timing_dict=timing_dict, + costs_metric=costs_metric, + time_metric=time_metric, ): yield sse_chunk diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index d71e2d92..b432c628 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -27,6 +27,7 @@ SERVICE_DISCOVERY_TIMEOUT, ) from tool_classifier.intent_detector import IntentDetectionModule +import time class LLMServiceProtocol(Protocol): @@ -64,11 +65,11 @@ def format_sse(self, chat_id: str, content: str) -> str: """ ... - 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 """ ... @@ -296,7 +297,7 @@ async def _process_intent_detection( request: OrchestrationRequest, chat_id: str, context: Dict[str, Any], - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> None: """Detect intent, validate service, and populate context. @@ -311,7 +312,7 @@ async def _process_intent_detection( request: Orchestration request chat_id: Chat ID for logging context: Context dict to populate with results - costs_dict: Dictionary to track LLM costs + costs_metric: Dictionary to track LLM costs """ intent_result, intent_usage = await self._detect_service_intent( user_query=request.message, @@ -319,7 +320,7 @@ async def _process_intent_detection( conversation_history=request.conversationHistory, chat_id=chat_id, ) - costs_dict["intent_detection"] = intent_usage + costs_metric["intent_detection"] = intent_usage if intent_result and intent_result.get("matched_service_id"): service_id = intent_result["matched_service_id"] @@ -463,7 +464,7 @@ async def _log_request_details( request: OrchestrationRequest, context: Dict[str, Any], mode: str, - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> None: """Log request details and perform service discovery. @@ -471,7 +472,7 @@ async def _log_request_details( request: The orchestration request context: Workflow context dictionary mode: Execution mode ("streaming" or "non-streaming") - costs_dict: Dictionary to accumulate cost tracking information + costs_metric: Dictionary to accumulate cost tracking information """ chat_id = request.chatId logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") @@ -529,7 +530,7 @@ async def _log_request_details( request=request, chat_id=chat_id, context=context, - costs_dict=costs_dict, + costs_metric=costs_metric, ) else: services = response_data.get("services", []) @@ -540,7 +541,7 @@ async def _log_request_details( request=request, chat_id=chat_id, context=context, - costs_dict=costs_dict, + costs_metric=costs_metric, ) else: logger.warning(f"[{chat_id}] Service discovery failed") @@ -549,17 +550,30 @@ 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.""" + """Execute service workflow in non-streaming mode. + + Args: + request: Orchestration request + context: Workflow context + time_metric: Optional timing dictionary for unified tracking + """ + chat_id = request.chatId # Create costs tracking dictionary (follows RAG workflow pattern) - costs_dict: Dict[str, Dict[str, Any]] = {} + costs_metric: Dict[str, Dict[str, Any]] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} - # Log comprehensive request details and perform service discovery + # Service discovery with timing + start_time = time.time() await self._log_request_details( - request, context, mode="non-streaming", costs_dict=costs_dict + request, context, mode="non-streaming", costs_metric=costs_metric ) + time_metric["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): @@ -573,6 +587,7 @@ async def execute_async( logger.info(f"[{chat_id}] Entity Transformation:") # Step 1: Extract service metadata from context + start_time = time.time() service_metadata = self._extract_service_metadata(context, chat_id) if not service_metadata: logger.error( @@ -596,6 +611,7 @@ async def execute_async( service_name=service_metadata["service_name"], chat_id=chat_id, ) + time_metric["service.entity_validation"] = time.time() - start_time logger.info( f"[{chat_id}] - Validation status: " @@ -657,7 +673,7 @@ async def execute_async( # Log costs after service workflow completes (follows RAG workflow pattern) if self.orchestration_service: - self.orchestration_service.log_costs(costs_dict) + self.orchestration_service.log_costs(costs_metric) return OrchestrationResponse( chatId=request.chatId, @@ -672,17 +688,30 @@ 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.""" + """Execute service workflow in streaming mode. + + Args: + request: Orchestration request + context: Workflow context + time_metric: Optional timing dictionary for unified tracking + """ + chat_id = request.chatId # Create costs tracking dictionary (follows RAG workflow pattern) - costs_dict: Dict[str, Dict[str, Any]] = {} + costs_metric: Dict[str, Dict[str, Any]] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} - # Log comprehensive request details and perform service discovery + # Service discovery with timing + start_time = time.time() await self._log_request_details( - request, context, mode="streaming", costs_dict=costs_dict + request, context, mode="streaming", costs_metric=costs_metric ) + time_metric["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): @@ -790,7 +819,7 @@ async def debug_stream() -> AsyncIterator[str]: # Log costs after streaming completes (follows RAG workflow pattern) # Must be inside generator because costs are accumulated during streaming - orchestration_service.log_costs(costs_dict) + orchestration_service.log_costs(costs_metric) return debug_stream() # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) diff --git a/src/utils/budget_tracker.py b/src/utils/budget_tracker.py index 134b034e..aaa3b15f 100644 --- a/src/utils/budget_tracker.py +++ b/src/utils/budget_tracker.py @@ -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/time_tracker.py b/src/utils/time_tracker.py index 5b6d8dea..fce45f45 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)") From 52c5beb5378fce309f7b0d5b37417e0c29c782bb Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:00:22 +0200 Subject: [PATCH 044/105] Create .env.gui --- .env.gui | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .env.gui diff --git a/.env.gui b/.env.gui new file mode 100644 index 00000000..c488b0a2 --- /dev/null +++ b/.env.gui @@ -0,0 +1,4 @@ +RELEASE=test +VERSION=1 +BUILD=1 +FIX=0 From 4a476143a2f9348e899ac295478df3dd42f0be4f Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:02:32 +0200 Subject: [PATCH 045/105] Create ci-build-image.yml --- .github/workflows/ci-build-image.yml | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/ci-build-image.yml diff --git a/.github/workflows/ci-build-image.yml b/.github/workflows/ci-build-image.yml new file mode 100644 index 00000000..c968bcc6 --- /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 . + + - 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 From af38013ec23e21b1225fe226736cb2937ae3483a Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:02:57 +0200 Subject: [PATCH 046/105] Update .env.gui --- .env.gui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.gui b/.env.gui index c488b0a2..abf16f0f 100644 --- a/.env.gui +++ b/.env.gui @@ -1,4 +1,4 @@ RELEASE=test VERSION=1 BUILD=1 -FIX=0 +FIX=0 From d669d0f5c474564b0fb3fb3b64b1c640f760327a Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:07:33 +0200 Subject: [PATCH 047/105] Update ci-build-image.yml --- .github/workflows/ci-build-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-build-image.yml b/.github/workflows/ci-build-image.yml index c968bcc6..b8588cbf 100644 --- a/.github/workflows/ci-build-image.yml +++ b/.github/workflows/ci-build-image.yml @@ -34,7 +34,7 @@ jobs: - name: Docker Build run: | cd GUI - docker image build --tag $DOCKER_TAG_CUSTOM . + 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 From 34493c59843408abf1aca73a28d59960bd130137 Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:07:45 +0200 Subject: [PATCH 048/105] Update .env.gui --- .env.gui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.gui b/.env.gui index abf16f0f..c488b0a2 100644 --- a/.env.gui +++ b/.env.gui @@ -1,4 +1,4 @@ RELEASE=test VERSION=1 BUILD=1 -FIX=0 +FIX=0 From 789f062e3fdd6aca000ce1551fc8f411328020d8 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Sun, 1 Mar 2026 10:38:40 +0530 Subject: [PATCH 049/105] update tool classifier --- docs/HYBRID_SEARCH_CLASSIFICATION.md | 380 +++++++++++++++++++++++++++ src/tool_classifier/classifier.py | 263 ++++++++++++------ src/tool_classifier/constants.py | 24 +- 3 files changed, 579 insertions(+), 88 deletions(-) create mode 100644 docs/HYBRID_SEARCH_CLASSIFICATION.md diff --git a/docs/HYBRID_SEARCH_CLASSIFICATION.md b/docs/HYBRID_SEARCH_CLASSIFICATION.md new file mode 100644 index 00000000..3e29b993 --- /dev/null +++ b/docs/HYBRID_SEARCH_CLASSIFICATION.md @@ -0,0 +1,380 @@ +# 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` | `EnrichedService` data model | +| `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): "Valuutakursid - Kasutaja soovib infot..." + dense: 3072-dim embedding of name + description + LLM context + 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 +text = "Mis suhe on euro ja usd vahel" +tokens = re.findall(r"\w+", text.lower()) # ["mis", "suhe", "on", "euro", ...] +# Each token → hashed to index in [0, VOCAB_SIZE), value = term frequency +# 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()) +} +``` + +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", + "point_index": 0 +} +``` + +### 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 + 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 +``` + +### 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, + "with_payload": true +} +``` + +Results are deduplicated by `service_id` (best score per service). + +**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. + +```python +# classifier.py → _hybrid_search() +POST /collections/intent_collections/points/query +{ + "prefetch": [ + {"query": dense_vector, "using": "dense", "limit": 20}, + {"query": {"indices": [...], "values": [...]}, "using": "sparse", "limit": 20} + ], + "query": {"fusion": "rrf"}, + "limit": 5, + "with_payload": true +} +``` + +### Routing Decision + +``` +Dense cosine score + gap + │ + ├─ cosine < 0.20 → PATH 1: Skip SERVICE → CONTEXT/RAG + │ + ├─ cosine ≥ 0.40 AND → PATH 2: HIGH-CONFIDENCE SERVICE + │ gap ≥ 0.05 (skip discovery, entity extraction only) + │ + └─ else (0.20 ≤ 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: "Tere, kuidas läheb?" +Dense: top cosine=0.15 → below 0.20 → skip SERVICE +→ Routes directly to CONTEXT → RAG (saves ~2-4s) +``` + +### 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 entity extraction on matched service only +→ Entities: {currency_from: EUR, currency_to: THB} +→ Validation: PASSED ✓ +``` + +### 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.35), gap=0.02 +→ 0.35 ≥ 0.20 but 0.35 < 0.40 → AMBIGUOUS +→ Runs LLM Intent Detection on top 3 candidates +→ LLM confirms or rejects → falls back to RAG if rejected +``` + +### 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 2-3 candidates | Yes — if LLM matches | +| Non-service | Not run | Not run | + +### Intent Detection Module (DSPy) + +**File:** `src/tool_classifier/intent_detector.py` + +The DSPy `IntentDetectionModule` receives: +- User query +- Candidate services (formatted as JSON) +- Conversation history (last 3 turns) + +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"] +``` + +--- + +## Thresholds & Configuration + +All defined in `src/tool_classifier/constants.py`. + +### Classification Thresholds + +| Constant | Value | Description | +|----------|-------|-------------| +| `DENSE_MIN_THRESHOLD` | `0.20` | Minimum cosine to consider any service match. Below → skip SERVICE entirely. Set low because multilingual (Estonian) queries yield lower cosine (0.25–0.55). | +| `DENSE_HIGH_CONFIDENCE_THRESHOLD` | `0.40` | Cosine for HIGH-CONFIDENCE path. Service queries with correct match score > 0.40 (observed: 0.55). 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.24, non-service gaps: ~0.01. | + +### 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 real Estonian query testing: + +| Metric | Service Query | Non-Service Query | +|--------|:------------:|:-----------------:| +| Top cosine | **0.55** | 0.27 – 0.35 | +| Cosine gap | **0.24** | 0.005 – 0.017 | +| Decision | HIGH-CONFIDENCE | AMBIGUOUS → LLM reject | + +### Performance by Path + +| Path | Latency | LLM Calls | Cost | +|------|:-------:|:---------:|:----:| +| Non-service (below threshold) | ~0.3s | 0 | $0 | +| HIGH-CONFIDENCE service | ~2.0s | 1 | ~$0.002 | +| AMBIGUOUS service | ~3.5s | 1-2 | ~$0.002–0.004 | +| Legacy (no classifier) | ~4.0s | 2+ | ~$0.004+ | + +### 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/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 2313b94d..181b6cf8 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -9,7 +9,7 @@ OrchestrationRequest, OrchestrationResponse, ) -from tool_classifier.enums import WorkflowType, WORKFLOW_DISPLAY_NAMES +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, @@ -17,9 +17,10 @@ QDRANT_COLLECTION, QDRANT_TIMEOUT, HYBRID_SEARCH_TOP_K, - HYBRID_SEARCH_MIN_THRESHOLD, - SCORE_RATIO_THRESHOLD, - SCORE_GAP_THRESHOLD, + DENSE_SEARCH_TOP_K, + DENSE_MIN_THRESHOLD, + DENSE_HIGH_CONFIDENCE_THRESHOLD, + DENSE_SCORE_GAP_THRESHOLD, ) from tool_classifier.sparse_encoder import compute_sparse_vector from tool_classifier.workflows import ( @@ -34,7 +35,11 @@ class ToolClassifier: """ Main classifier that determines which workflow should handle user queries. - Uses Qdrant hybrid search (dense + sparse + RRF fusion) to classify 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) @@ -97,16 +102,15 @@ async def classify( language: str, ) -> ClassificationResult: """ - Classify a user query using Qdrant hybrid search (dense + sparse + RRF). + 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 - Classification flow: - 1. Generate dense embedding for the query - 2. Generate sparse vector for the query (BM25-style) - 3. Run hybrid search on intent_collections (prefetch dense + sparse → RRF fusion) - 4. Apply score-gap analysis: - - Clear winner (high ratio + gap) → SERVICE with high confidence - - Ambiguous (scores exist but close) → SERVICE with LLM confirmation flag - - No match (low/no scores) → CONTEXT (skip SERVICE entirely) + Routing: + - cosine < DENSE_MIN_THRESHOLD → CONTEXT/RAG (skip SERVICE) + - cosine ≥ HIGH_CONFIDENCE + large gap → SERVICE (no LLM needed) + - else → SERVICE with LLM confirmation Args: query: User's query string @@ -130,107 +134,126 @@ async def classify( reasoning="Could not generate embedding - skip to Context/RAG", ) - # Step 2: Generate sparse vector for query - query_sparse = compute_sparse_vector(query) - - # Step 3: Qdrant hybrid search with RRF fusion - results = await self._hybrid_search( + # Step 2: Dense-only search → get actual cosine similarity scores + dense_results = await self._dense_search( dense_vector=query_embedding, - sparse_vector=query_sparse, - top_k=HYBRID_SEARCH_TOP_K, + top_k=DENSE_SEARCH_TOP_K, ) - if not results: - logger.info("No hybrid search results - routing to CONTEXT/RAG") + if not dense_results: + logger.info("No dense search results - routing to CONTEXT/RAG") return ClassificationResult( workflow=WorkflowType.CONTEXT, confidence=1.0, metadata={"reason": "no_service_match"}, - reasoning="No services matched the query", + reasoning="No services matched the query (dense search empty)", ) - # Step 4: Score-gap analysis - top = results[0] - top_score = top.get("rrf_score", 0.0) - top_service_id = top.get("service_id", "unknown") - top_service_name = top.get("name", "unknown") - - second_score = results[1].get("rrf_score", 0.0) if len(results) > 1 else 0.0 - - score_ratio = top_score / max(second_score, 0.0001) - score_gap = top_score - second_score + 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"Hybrid search results - " - f"top: {top_service_name} (score={top_score:.6f}), " - f"second: {results[1].get('name', 'none') if len(results) > 1 else 'none'} " - f"(score={second_score:.6f}), " - f"ratio={score_ratio:.2f}, gap={score_gap:.6f}" + 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}" ) - # High confidence: clear winner → SERVICE (skip discovery + intent detection) - if score_ratio > SCORE_RATIO_THRESHOLD and score_gap > SCORE_GAP_THRESHOLD: + # Decision: Is this a service query at all? + if top_cosine < DENSE_MIN_THRESHOLD: logger.info( - f"High-confidence service match: {top_service_name} " - f"(ratio={score_ratio:.2f}, gap={score_gap:.6f})" + f"Low relevance (cosine={top_cosine:.4f} < {DENSE_MIN_THRESHOLD}) " + f"- routing to CONTEXT/RAG, skipping SERVICE" ) return ClassificationResult( - workflow=WorkflowType.SERVICE, - confidence=min(score_ratio / 5.0, 1.0), + workflow=WorkflowType.CONTEXT, + confidence=1.0, metadata={ - "matched_service_id": top_service_id, - "matched_service_name": top_service_name, - "rrf_score": top_score, - "score_gap": score_gap, - "score_ratio": score_ratio, - "needs_llm_confirmation": False, - "top_results": results[:3], + "reason": "below_dense_threshold", + "top_cosine": top_cosine, + "top_service": top_service_name, }, reasoning=( - f"High-confidence match: {top_service_name} " - f"(ratio={score_ratio:.2f}, gap={score_gap:.6f})" + f"Dense cosine {top_cosine:.4f} below threshold " + f"{DENSE_MIN_THRESHOLD} - skip to Context/RAG" ), ) - # Medium confidence: ambiguous → SERVICE with LLM confirmation - if top_score > HYBRID_SEARCH_MIN_THRESHOLD: + # 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"Ambiguous service match: {top_service_name} " - f"(score={top_score:.6f}, ratio={score_ratio:.2f}) - needs LLM confirmation" + f"HIGH-CONFIDENCE match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}, gap={cosine_gap:.4f})" ) return ClassificationResult( workflow=WorkflowType.SERVICE, - confidence=0.5, + confidence=min(top_cosine, 1.0), metadata={ "matched_service_id": top_service_id, - "matched_service_name": top_service_name, - "rrf_score": top_score, - "score_gap": score_gap, - "score_ratio": score_ratio, - "needs_llm_confirmation": True, - "top_results": results[:3], + "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"Ambiguous match: {top_service_name} " - f"(score={top_score:.6f}) - LLM confirmation needed" + f"High-confidence match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}, gap={cosine_gap:.4f})" ), ) - # No confidence: skip SERVICE entirely → CONTEXT/RAG + # Medium confidence: above min threshold but ambiguous logger.info( - f"No service match (top_score={top_score:.6f} below threshold " - f"{HYBRID_SEARCH_MIN_THRESHOLD}) - routing to CONTEXT/RAG" + f"AMBIGUOUS match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}, gap={cosine_gap:.4f}) - needs LLM confirmation" ) return ClassificationResult( - workflow=WorkflowType.CONTEXT, - confidence=1.0, - metadata={"reason": "below_threshold", "top_score": top_score}, - reasoning=f"Top score {top_score:.6f} below threshold - skip to Context/RAG", + 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) - # Fallback: route to CONTEXT/RAG on any error return ClassificationResult( workflow=WorkflowType.CONTEXT, confidence=1.0, @@ -269,6 +292,92 @@ def _get_query_embedding(self, query: str) -> Optional[List[float]]: 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], @@ -535,7 +644,6 @@ async def _execute_with_fallback_async( ) # Get the layer order starting from current layer - from tool_classifier.enums import WORKFLOW_LAYER_ORDER current_index = WORKFLOW_LAYER_ORDER.index(start_layer) remaining_layers = WORKFLOW_LAYER_ORDER[current_index + 1 :] @@ -557,7 +665,6 @@ async def _execute_with_fallback_async( return result logger.info(f"[{chat_id}] {next_name} returned None, continuing...") - current_index += 1 # This should never happen since RAG/OOD should always return result raise RuntimeError("All workflows returned None (unexpected)") @@ -617,7 +724,6 @@ async def _execute_with_fallback_streaming( ) # Get the layer order starting from current layer - from tool_classifier.enums import WORKFLOW_LAYER_ORDER current_index = WORKFLOW_LAYER_ORDER.index(start_layer) remaining_layers = WORKFLOW_LAYER_ORDER[current_index + 1 :] @@ -642,7 +748,6 @@ async def _execute_with_fallback_streaming( return logger.info(f"[{chat_id}] {next_name} returned None, continuing...") - current_index += 1 # This should never happen raise RuntimeError("All workflows returned None in streaming (unexpected)") diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index 7db6aa95..9c4adf8e 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -65,15 +65,21 @@ # ============================================================================ HYBRID_SEARCH_TOP_K = 5 -"""Number of top results from hybrid search for classification.""" +"""Number of top results from hybrid search for service identification.""" -HYBRID_SEARCH_MIN_THRESHOLD = 0.01 -"""Minimum RRF score to consider a result as a potential match.""" +DENSE_SEARCH_TOP_K = 3 +"""Number of top results from dense-only search for relevance scoring.""" -SCORE_RATIO_THRESHOLD = 2.0 -"""Score ratio (top/second) for confident service classification. -If the top result's RRF score is > 2x the second result, it's a high-confidence match.""" +DENSE_MIN_THRESHOLD = 0.20 +"""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.""" -SCORE_GAP_THRESHOLD = 0.005 -"""Absolute score gap for confident classification. -Prevents false positives when both scores are very low.""" +DENSE_HIGH_CONFIDENCE_THRESHOLD = 0.40 +"""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.""" From 609e6d583cd2662803770656f8b4127f2c10ce3d Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Sun, 1 Mar 2026 10:46:15 +0530 Subject: [PATCH 050/105] fixing merge conflicts --- src/llm_orchestration_service.py | 314 ++++++++++-------- src/tool_classifier/base_workflow.py | 10 +- src/tool_classifier/classifier.py | 34 +- .../workflows/context_workflow.py | 10 +- src/tool_classifier/workflows/ood_workflow.py | 8 +- src/tool_classifier/workflows/rag_workflow.py | 36 +- .../workflows/service_workflow.py | 61 ++-- src/utils/time_tracker.py | 10 +- 8 files changed, 255 insertions(+), 228 deletions(-) diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 74329578..5095893c 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -133,45 +133,69 @@ def __init__(self) -> None: # This allows components to be initialized per-request with proper context self.tool_classifier = None - # Initialize shared guardrails adapter at startup - self.shared_guardrails_adapter = self._initialize_shared_guardrails_at_startup() + # 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) -> Optional[NeMoRailsAdapter]: + def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter]: """ - Initialize shared guardrails at startup. + Initialize shared guardrails adapters at startup for production and testing environments. Returns: - NeMoRailsAdapter if successful, None on failure (graceful degradation) + Dictionary mapping environment names to NeMoRailsAdapter instances. + Empty dict on failure (graceful degradation). """ - try: - logger.info(" Initializing shared guardrails at startup...") - start_time = time.time() + adapters: Dict[str, NeMoRailsAdapter] = {} - # Initialize with production environment and no specific connection - # This creates a shared guardrails instance using default/production config - guardrails_adapter = self._initialize_guardrails( - environment="production", - connection_id=None, # Shared configuration, not user-specific - ) + # Initialize adapters for commonly-used environments + environments_to_initialize = ["production", "testing"] - elapsed_time = time.time() - start_time - logger.info( - f" Shared guardrails initialized successfully in {elapsed_time:.3f}s" - ) + logger.info(" Initializing shared guardrails at startup...") + total_start_time = time.time() - return guardrails_adapter + for env in environments_to_initialize: + try: + logger.info(f" Initializing guardrails for environment: {env}") + start_time = time.time() - except Exception as e: - logger.error(f" Failed to initialize shared guardrails at startup: {e}") + # Initialize with specific environment and no connection (shared config) + guardrails_adapter = self._initialize_guardrails( + environment=env, + connection_id=None, # Shared configuration, not user-specific + ) + + elapsed_time = time.time() - start_time + adapters[env] = guardrails_adapter + logger.info( + f" Guardrails for '{env}' initialized successfully in {elapsed_time:.3f}s" + ) + + 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( - " Service will continue without guardrails (graceful degradation)" + " Failed to initialize any shared guardrails - " + "service will use per-request initialization (slower)" ) - # Return None - service continues without guardrails - # Per-request fallback will be attempted if needed - return None + + return adapters @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( @@ -197,8 +221,8 @@ async 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( @@ -210,7 +234,7 @@ async def process_orchestration_request( start_time = time.time() detected_language = detect_language(request.message) language_name = get_language_name(detected_language) - timing_dict["language_detection"] = time.time() - start_time + time_metric["language_detection"] = time.time() - start_time logger.info( f"[{request.chatId}] Detected language: {language_name} ({detected_language})" ) @@ -222,7 +246,7 @@ async def process_orchestration_request( # STEP 0.5: Basic Query Validation (before expensive component initialization) start_time = time.time() validation_result = validate_query_basic(request.message) - timing_dict["query_validation"] = time.time() - start_time + 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}" @@ -253,21 +277,21 @@ async def process_orchestration_request( # Initialize all service components (only for valid queries, with timing) start_time = time.time() components = self._initialize_service_components(request) - timing_dict["initialization"] = time.time() - start_time + time_metric["initialization"] = time.time() - start_time if components["guardrails_adapter"]: start_time = time.time() input_blocked_response = await self.handle_input_guardrails( components["guardrails_adapter"], request, {} ) - timing_dict["input_guardrails_check"] = time.time() - start_time + 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(timing_dict, request.chatId) + log_step_timings(time_metric, request.chatId) return input_blocked_response else: logger.info( @@ -298,7 +322,7 @@ async def process_orchestration_request( conversation_history=request.conversationHistory, language=detected_language, ) - timing_dict["classifier.classify"] = time.time() - start_time + time_metric["classifier.classify"] = time.time() - start_time logger.info( f"[{request.chatId}] Classification: {classification.workflow.value} " @@ -311,9 +335,9 @@ async def process_orchestration_request( classification=classification, request=request, is_streaming=False, - timing_dict=timing_dict, + time_metric=time_metric, ) - timing_dict["classifier.route"] = time.time() - start_time + time_metric["classifier.route"] = time.time() - start_time except Exception as classifier_error: logger.error( @@ -327,7 +351,7 @@ async def process_orchestration_request( ) # Execute existing RAG pipeline as fallback response = await self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict + request, components, costs_metric, time_metric ) else: raise @@ -337,27 +361,27 @@ async def process_orchestration_request( f"[{request.chatId}] Tool classifier disabled - using RAG pipeline" ) response = await self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict + 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( @@ -374,7 +398,7 @@ async 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, @@ -398,12 +422,12 @@ async 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) @@ -446,14 +470,14 @@ async def stream_orchestration_response( """ # Track costs after streaming completes - costs_dict: Dict[str, Dict[str, Any]] = {} - timing_dict: Dict[str, float] = {} + costs_metric: Dict[str, Dict[str, Any]] = {} + time_metric: Dict[str, float] = {} # 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) - timing_dict["language_detection"] = time.time() - start_time + time_metric["language_detection"] = time.time() - start_time logger.info( f"[{request.chatId}] Streaming request - Detected language: {language_name} ({detected_language})" ) @@ -465,7 +489,7 @@ async def stream_orchestration_response( # Step 0.5: Basic Query Validation (before guardrails, with timing) start_time = time.time() validation_result = validate_query_basic(request.message) - timing_dict["query_validation"] = time.time() - start_time + 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}" @@ -493,7 +517,7 @@ async def stream_orchestration_response( # Initialize all service components (with timing) start_time = time.time() components = self._initialize_service_components(request) - timing_dict["initialization"] = time.time() - start_time + time_metric["initialization"] = time.time() - start_time # This implements fail-fast principle - block malicious/policy-violating inputs # before expensive operations (service discovery, LLM calls, streaming setup) @@ -506,9 +530,9 @@ 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( @@ -519,9 +543,9 @@ async def stream_orchestration_response( request.chatId, INPUT_GUARDRAIL_VIOLATION_MESSAGE ) yield self.format_sse(request.chatId, "END") - self.log_costs(costs_dict) + self.log_costs(costs_metric) # Log timings before returning (for visibility) - log_step_timings(timing_dict, request.chatId) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return else: @@ -581,8 +605,8 @@ async def stream_orchestration_response( ) # Log costs and timings - self.log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return # Exit after successful classifier routing @@ -612,8 +636,8 @@ async def stream_orchestration_response( request=request, components=components, stream_ctx=stream_ctx, - costs_dict=costs_dict, - timing_dict=timing_dict, + costs_metric=costs_metric, + time_metric=time_metric, ): yield sse_chunk @@ -630,12 +654,12 @@ async def stream_orchestration_response( yield self.format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) yield self.format_sse(request.chatId, "END") - 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 outer exception self._update_connection_budget( - request.connection_id, costs_dict, request.environment + request.connection_id, costs_metric, request.environment ) if self.langfuse_config.langfuse_client: @@ -656,8 +680,8 @@ async def _stream_rag_pipeline( request: OrchestrationRequest, components: Dict[str, Any], stream_ctx: Any, - costs_dict: Dict[str, Dict[str, Any]], - timing_dict: Dict[str, float], + costs_metric: Dict[str, Dict[str, Any]], + time_metric: Dict[str, float], ) -> AsyncIterator[str]: """ Core RAG streaming pipeline without classifier routing. @@ -675,8 +699,8 @@ async def _stream_rag_pipeline( request: Orchestration request components: Initialized service components (LLM, retriever, generator, guardrails) stream_ctx: Stream context for tracking - costs_dict: Dictionary to accumulate costs - timing_dict: Dictionary to accumulate timings + costs_metric: Dictionary to accumulate costs + time_metric: Dictionary to accumulate timings Yields: SSE-formatted strings @@ -695,8 +719,8 @@ async def _stream_rag_pipeline( original_message=request.message, conversation_history=request.conversationHistory, ) - timing_dict["prompt_refiner"] = time.time() - start_time - costs_dict["prompt_refiner"] = refiner_usage + time_metric["prompt_refiner"] = time.time() - start_time + costs_metric["prompt_refiner"] = refiner_usage logger.info( f"[{request.chatId}] [{stream_ctx.stream_id}] Prompt refinement complete" @@ -712,7 +736,7 @@ async def _stream_rag_pipeline( relevant_chunks = await self._safe_retrieve_contextual_chunks( components["contextual_retriever"], refined_output, request ) - timing_dict["contextual_retrieval"] = time.time() - start_time + time_metric["contextual_retrieval"] = time.time() - start_time except ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, @@ -728,8 +752,8 @@ async def _stream_rag_pipeline( ) 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) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -742,8 +766,8 @@ async def _stream_rag_pipeline( ) 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) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -762,7 +786,7 @@ async def _stream_rag_pipeline( chunks=relevant_chunks, max_blocks=ResponseGenerationConstants.DEFAULT_MAX_BLOCKS, ) - timing_dict["scope_check"] = time.time() - start_time + time_metric["scope_check"] = time.time() - start_time if is_out_of_scope: logger.info( @@ -773,8 +797,8 @@ async def _stream_rag_pipeline( ) 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) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -842,9 +866,9 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -871,9 +895,9 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return @@ -940,11 +964,11 @@ async def bot_response_generator() -> AsyncIterator[str]: # Extract usage information after streaming completes usage_info = get_lm_usage_since(history_length_before) - costs_dict["streaming_generation"] = usage_info + costs_metric["streaming_generation"] = usage_info # Record timings - timing_dict["streaming_generation"] = time.time() - streaming_step_start - timing_dict["output_guardrails"] = 0.0 # Inline during streaming + 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() @@ -953,18 +977,18 @@ async def bot_response_generator() -> AsyncIterator[str]: ) # Log costs and trace - 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 self._update_connection_budget( - request.connection_id, costs_dict, request.environment + 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_dict) + total_costs = calculate_total_costs(costs_metric) langfuse.update_current_generation( model=components["llm_manager"] @@ -980,7 +1004,7 @@ async def bot_response_generator() -> AsyncIterator[str]: "streaming": True, "streaming_duration_seconds": streaming_duration, "chunks_streamed": chunk_count, - "cost_breakdown": costs_dict, + "cost_breakdown": costs_metric, "chat_id": request.chatId, "environment": request.environment, "stream_id": stream_ctx.stream_id, @@ -1015,13 +1039,13 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + 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_dict, request.environment + request.connection_id, costs_metric, request.environment ) raise except Exception as stream_error: @@ -1038,13 +1062,13 @@ async def bot_response_generator() -> AsyncIterator[str]: 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) + 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_dict, request.environment + request.connection_id, costs_metric, request.environment ) def format_sse(self, chat_id: str, content: str) -> str: @@ -1079,16 +1103,18 @@ def _initialize_service_components( environment=request.environment, connection_id=request.connection_id ) - # Use shared guardrails adapter (initialized at startup) - # Falls back to per-request initialization if shared instance unavailable - if self.shared_guardrails_adapter is not None: - logger.debug( - "Using shared guardrails adapter (startup-initialized, zero overhead)" + 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_adapter + components["guardrails_adapter"] = self.shared_guardrails_adapters[ + request.environment + ] else: logger.warning( - "Shared guardrails unavailable, initializing per-request (slower)" + 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 @@ -1203,8 +1229,8 @@ async def _execute_orchestration_pipeline( self, request: OrchestrationRequest, components: Dict[str, Any], - costs_dict: Dict[str, Dict[str, Any]], - timing_dict: Dict[str, float], + 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. @@ -1212,8 +1238,8 @@ async def _execute_orchestration_pipeline( Args: request: Orchestration request components: Initialized service components - costs_dict: Dictionary for cost tracking - timing_dict: Dictionary for timing tracking + 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 @@ -1228,8 +1254,8 @@ async def _execute_orchestration_pipeline( conversation_history=request.conversationHistory, ) timing_key = f"{prefix}.prompt_refiner" if prefix else "prompt_refiner" - timing_dict[timing_key] = time.time() - start_time - costs_dict["prompt_refiner"] = refiner_usage + time_metric[timing_key] = time.time() - start_time + costs_metric["prompt_refiner"] = refiner_usage # Step 2: Retrieve relevant chunks using contextual retrieval try: @@ -1240,7 +1266,7 @@ async def _execute_orchestration_pipeline( timing_key = ( f"{prefix}.contextual_retrieval" if prefix else "contextual_retrieval" ) - timing_dict[timing_key] = time.time() - start_time + time_metric[timing_key] = time.time() - start_time except ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, @@ -1261,12 +1287,12 @@ async 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_key = ( f"{prefix}.response_generation" if prefix else "response_generation" ) - timing_dict[timing_key] = time.time() - start_time + time_metric[timing_key] = time.time() - start_time # Step 4: Output Guardrails Check # Apply guardrails to all response types for consistent safety across all environments @@ -1275,12 +1301,12 @@ async def _execute_orchestration_pipeline( components["guardrails_adapter"], generated_response, request, - costs_dict, + costs_metric, ) timing_key = ( f"{prefix}.output_guardrails_check" if prefix else "output_guardrails_check" ) - timing_dict[timing_key] = time.time() - start_time + time_metric[timing_key] = time.time() - start_time # Step 5: Store inference data (for production and testing environments) # Only store OrchestrationResponse (has chatId), not TestOrchestrationResponse @@ -1353,13 +1379,13 @@ 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 = 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: @@ -1479,7 +1505,7 @@ async def handle_output_guardrails( guardrails_adapter: Optional[NeMoRailsAdapter], generated_response: Union[OrchestrationResponse, TestOrchestrationResponse], request: OrchestrationRequest, - costs_dict: Dict[str, Dict[str, Any]], + 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) @@ -1495,7 +1521,7 @@ async def handle_output_guardrails( 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: @@ -1772,7 +1798,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). @@ -1780,7 +1806,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 @@ -1792,7 +1818,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( @@ -1845,7 +1871,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). @@ -1853,7 +1879,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 @@ -1864,7 +1890,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( @@ -1917,7 +1943,7 @@ 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. @@ -1925,7 +1951,7 @@ async 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 @@ -1936,7 +1962,7 @@ async def _check_output_guardrails( 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( @@ -1986,22 +2012,22 @@ async 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, " @@ -2055,7 +2081,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: """ @@ -2064,7 +2090,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: @@ -2092,7 +2118,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"): @@ -2447,7 +2475,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. @@ -2455,8 +2483,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: @@ -2514,7 +2542,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( @@ -2794,4 +2822,4 @@ def _get_config_loader(self): self._config_loader = ConfigurationLoader() logger.debug("Lazy initialized ConfigurationLoader for vector indexer") - return self._config_loader + return self._config_loader \ No newline at end of file diff --git a/src/tool_classifier/base_workflow.py b/src/tool_classifier/base_workflow.py index 45886fa9..8f070769 100644 --- a/src/tool_classifier/base_workflow.py +++ b/src/tool_classifier/base_workflow.py @@ -33,7 +33,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute workflow in non-streaming mode. @@ -44,7 +44,7 @@ async def execute_async( Args: request: The orchestration request containing user query and context context: Workflow-specific metadata from ClassificationResult.metadata - timing_dict: Optional dictionary for tracking step execution times + time_metric: Optional dictionary for tracking step execution times Returns: OrchestrationResponse if workflow can handle this query @@ -70,7 +70,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute workflow in streaming mode (Server-Sent Events). @@ -81,7 +81,7 @@ async def execute_streaming( Args: request: The orchestration request containing user query and context context: Workflow-specific metadata from ClassificationResult.metadata - timing_dict: Optional dictionary for tracking step execution times + time_metric: Optional dictionary for tracking step execution times Returns: AsyncIterator[str] yielding SSE-formatted strings if workflow can handle @@ -119,4 +119,4 @@ async def stream_response(): return stream_response() """ - pass + pass \ No newline at end of file diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index ab9e402e..f9d83e5e 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -106,7 +106,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: Literal[False] = False, - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> OrchestrationResponse: ... @overload @@ -115,7 +115,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: Literal[True], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> AsyncIterator[str]: ... async def route_to_workflow( @@ -123,7 +123,7 @@ async def route_to_workflow( classification: ClassificationResult, request: OrchestrationRequest, is_streaming: bool = False, - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Union[OrchestrationResponse, AsyncIterator[str]]: """ Route request to appropriate workflow based on classification. @@ -135,7 +135,7 @@ async def route_to_workflow( classification: Classification result from classify() request: Original orchestration request is_streaming: Whether to use streaming mode (for /orchestrate/stream) - timing_dict: Optional timing dictionary for workflow step tracking + time_metric: Optional timing dictionary for workflow step tracking Returns: OrchestrationResponse for non-streaming mode @@ -166,7 +166,7 @@ async def route_to_workflow( request=request, context=classification.metadata, start_layer=classification.workflow, - timing_dict=timing_dict, + time_metric=time_metric, ) else: # NON-STREAMING MODE: For /orchestrate and /orchestrate/test endpoints @@ -175,7 +175,7 @@ async def route_to_workflow( request=request, context=classification.metadata, start_layer=classification.workflow, - timing_dict=timing_dict, + time_metric=time_metric, ) def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: @@ -194,7 +194,7 @@ async def _execute_with_fallback_async( request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> OrchestrationResponse: """ Execute workflow with fallback to subsequent layers (non-streaming). @@ -210,7 +210,7 @@ async def _execute_with_fallback_async( request: Orchestration request context: Workflow context/metadata start_layer: Starting workflow type - timing_dict: Optional timing dictionary for tracking + time_metric: Optional timing dictionary for tracking """ chat_id = request.chatId workflow_name = WORKFLOW_DISPLAY_NAMES.get(start_layer, start_layer.value) @@ -218,7 +218,7 @@ async def _execute_with_fallback_async( logger.info(f"[{chat_id}] Executing {workflow_name} (non-streaming)") try: - result = await workflow.execute_async(request, context, timing_dict) + result = await workflow.execute_async(request, context, time_metric) if result is not None: logger.info(f"[{chat_id}] {workflow_name} handled successfully") @@ -246,7 +246,7 @@ async def _execute_with_fallback_async( f"(Layer {WORKFLOW_LAYER_ORDER.index(next_layer) + 1})" ) - result = await next_workflow.execute_async(request, {}, timing_dict) + result = await next_workflow.execute_async(request, {}, time_metric) if result is not None: logger.info(f"[{chat_id}] {next_name} handled successfully") @@ -262,7 +262,7 @@ async def _execute_with_fallback_async( 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, {}, timing_dict) + rag_result = await self.rag_workflow.execute_async(request, {}, time_metric) if rag_result is not None: return rag_result else: @@ -274,7 +274,7 @@ async def _execute_with_fallback_streaming( request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> AsyncIterator[str]: """ Execute workflow with fallback to subsequent layers (streaming). @@ -290,7 +290,7 @@ async def _execute_with_fallback_streaming( request: Orchestration request context: Workflow context/metadata start_layer: Starting workflow type - timing_dict: Optional timing dictionary for tracking + time_metric: Optional timing dictionary for tracking """ chat_id = request.chatId workflow_name = WORKFLOW_DISPLAY_NAMES.get(start_layer, start_layer.value) @@ -298,7 +298,7 @@ async def _execute_with_fallback_streaming( logger.info(f"[{chat_id}] Executing {workflow_name} (streaming)") try: - result = await workflow.execute_streaming(request, context, timing_dict) + result = await workflow.execute_streaming(request, context, time_metric) if result is not None: logger.info(f"[{chat_id}] {workflow_name} streaming started") @@ -329,7 +329,7 @@ async def _execute_with_fallback_streaming( f"(Layer {layer_number})" ) - result = await next_workflow.execute_streaming(request, {}, timing_dict) + result = await next_workflow.execute_streaming(request, {}, time_metric) if result is not None: logger.info(f"[{chat_id}] {next_name} streaming started") @@ -348,10 +348,10 @@ async def _execute_with_fallback_streaming( # 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, {}, timing_dict + 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") + raise RuntimeError("RAG workflow returned None unexpectedly") \ No newline at end of file diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index 4039b239..2dc19989 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -35,7 +35,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute context workflow in non-streaming mode. @@ -46,7 +46,7 @@ async def execute_async( Args: request: Orchestration request with user query and history context: Metadata with is_greeting, can_answer_from_history flags - timing_dict: Optional timing dictionary for future timing tracking + time_metric: Optional timing dictionary for future timing tracking Returns: OrchestrationResponse with context-based answer or None to fallback @@ -64,7 +64,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute context workflow in streaming mode. @@ -75,7 +75,7 @@ async def execute_streaming( Args: request: Orchestration request with user query and history context: Metadata with is_greeting, can_answer_from_history flags - timing_dict: Optional timing dictionary for future timing tracking + time_metric: Optional timing dictionary for future timing tracking Returns: AsyncIterator yielding SSE strings or None to fallback @@ -87,4 +87,4 @@ async def execute_streaming( # TODO: Implement context streaming logic here # For now, return None to trigger fallback to next layer (RAG) - return None + return None \ No newline at end of file diff --git a/src/tool_classifier/workflows/ood_workflow.py b/src/tool_classifier/workflows/ood_workflow.py index c3f92156..1e585521 100644 --- a/src/tool_classifier/workflows/ood_workflow.py +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -39,7 +39,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute OOD workflow in non-streaming mode. @@ -69,7 +69,7 @@ async def execute_async( Args: request: Orchestration request with user query context: Unused (OOD doesn't need metadata) - timing_dict: Optional timing dictionary for future timing tracking + time_metric: Optional timing dictionary for future timing tracking Returns: OrchestrationResponse with OOD message @@ -88,7 +88,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute OOD workflow in streaming mode. @@ -131,4 +131,4 @@ async def stream_ood_message(): # TODO: Implement OOD streaming logic here # For now, return None (will be implemented as simple message streaming) - return None + return None \ No newline at end of file diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index 5c8cd057..426359f0 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -50,7 +50,7 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ Execute RAG workflow in non-streaming mode. @@ -65,7 +65,7 @@ async def execute_async( Args: request: Orchestration request with user query context: Unused (RAG doesn't need classification metadata) - timing_dict: Optional timing dictionary from parent (for unified tracking) + time_metric: Optional timing dictionary from parent (for unified tracking) Returns: OrchestrationResponse with RAG-generated answer @@ -74,10 +74,10 @@ async def execute_async( logger.info(f"[{request.chatId}] Executing RAG workflow (non-streaming)") # Initialize components needed for RAG pipeline - costs_dict: Dict[str, Any] = {} - # Use parent timing_dict or create new one - if timing_dict is None: - timing_dict = {} + costs_metric: Dict[str, Any] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} # Initialize service components components = self.orchestration_service._initialize_service_components(request) @@ -86,13 +86,13 @@ async def execute_async( response = await self.orchestration_service._execute_orchestration_pipeline( request=request, components=components, - costs_dict=costs_dict, - timing_dict=timing_dict, + 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_dict) + self.orchestration_service.log_costs(costs_metric) return response @@ -100,7 +100,7 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ Execute RAG workflow in streaming mode. @@ -119,7 +119,7 @@ async def execute_streaming( Args: request: Orchestration request with user query context: Unused (RAG doesn't need classification metadata) - timing_dict: Optional timing dictionary from parent (for unified tracking) + time_metric: Optional timing dictionary from parent (for unified tracking) Returns: AsyncIterator yielding SSE-formatted strings @@ -128,10 +128,10 @@ async def execute_streaming( logger.info(f"[{request.chatId}] Executing RAG workflow (streaming)") # Initialize tracking dictionaries - costs_dict: Dict[str, Any] = {} - # Use parent timing_dict or create new one - if timing_dict is None: - timing_dict = {} + 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") @@ -172,7 +172,7 @@ def mark_error(self, error_id: str) -> None: request=request, components=components, stream_ctx=stream_ctx, - costs_dict=costs_dict, - timing_dict=timing_dict, + costs_metric=costs_metric, + time_metric=time_metric, ): - yield sse_chunk + yield sse_chunk \ No newline at end of file diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index bed97dde..64605447 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -27,6 +27,7 @@ SERVICE_DISCOVERY_TIMEOUT, ) from tool_classifier.intent_detector import IntentDetectionModule +import time class LLMServiceProtocol(Protocol): @@ -64,11 +65,11 @@ def format_sse(self, chat_id: str, content: str) -> str: """ ... - 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 """ ... @@ -296,7 +297,7 @@ async def _process_intent_detection( request: OrchestrationRequest, chat_id: str, context: Dict[str, Any], - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> None: """Detect intent, validate service, and populate context. @@ -311,7 +312,7 @@ async def _process_intent_detection( request: Orchestration request chat_id: Chat ID for logging context: Context dict to populate with results - costs_dict: Dictionary to track LLM costs + costs_metric: Dictionary to track LLM costs """ intent_result, intent_usage = await self._detect_service_intent( user_query=request.message, @@ -319,7 +320,7 @@ async def _process_intent_detection( conversation_history=request.conversationHistory, chat_id=chat_id, ) - costs_dict["intent_detection"] = intent_usage + costs_metric["intent_detection"] = intent_usage if intent_result and intent_result.get("matched_service_id"): service_id = intent_result["matched_service_id"] @@ -463,7 +464,7 @@ async def _log_request_details( request: OrchestrationRequest, context: Dict[str, Any], mode: str, - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> None: """Log request details and perform service discovery. @@ -471,7 +472,7 @@ async def _log_request_details( request: The orchestration request context: Workflow context dictionary mode: Execution mode ("streaming" or "non-streaming") - costs_dict: Dictionary to accumulate cost tracking information + costs_metric: Dictionary to accumulate cost tracking information """ chat_id = request.chatId logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") @@ -529,7 +530,7 @@ async def _log_request_details( request=request, chat_id=chat_id, context=context, - costs_dict=costs_dict, + costs_metric=costs_metric, ) else: services = response_data.get("services", []) @@ -540,7 +541,7 @@ async def _log_request_details( request=request, chat_id=chat_id, context=context, - costs_dict=costs_dict, + costs_metric=costs_metric, ) else: logger.warning(f"[{chat_id}] Service discovery failed") @@ -549,31 +550,30 @@ async def execute_async( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """Execute service workflow in non-streaming mode. Args: request: Orchestration request context: Workflow context - timing_dict: Optional timing dictionary for unified tracking + time_metric: Optional timing dictionary for unified tracking """ - import time chat_id = request.chatId # Create costs tracking dictionary (follows RAG workflow pattern) - costs_dict: Dict[str, Dict[str, Any]] = {} - # Use parent timing_dict or create new one - if timing_dict is None: - timing_dict = {} + costs_metric: Dict[str, Dict[str, Any]] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} # Service discovery with timing start_time = time.time() await self._log_request_details( - request, context, mode="non-streaming", costs_dict=costs_dict + request, context, mode="non-streaming", costs_metric=costs_metric ) - timing_dict["service.discovery"] = time.time() - start_time + time_metric["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): @@ -611,7 +611,7 @@ async def execute_async( service_name=service_metadata["service_name"], chat_id=chat_id, ) - timing_dict["service.entity_validation"] = time.time() - start_time + time_metric["service.entity_validation"] = time.time() - start_time logger.info( f"[{chat_id}] - Validation status: " @@ -673,7 +673,7 @@ async def execute_async( # Log costs after service workflow completes (follows RAG workflow pattern) if self.orchestration_service: - self.orchestration_service.log_costs(costs_dict) + self.orchestration_service.log_costs(costs_metric) return OrchestrationResponse( chatId=request.chatId, @@ -688,31 +688,30 @@ async def execute_streaming( self, request: OrchestrationRequest, context: Dict[str, Any], - timing_dict: Optional[Dict[str, float]] = None, + time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """Execute service workflow in streaming mode. Args: request: Orchestration request context: Workflow context - timing_dict: Optional timing dictionary for unified tracking + time_metric: Optional timing dictionary for unified tracking """ - import time chat_id = request.chatId # Create costs tracking dictionary (follows RAG workflow pattern) - costs_dict: Dict[str, Dict[str, Any]] = {} - # Use parent timing_dict or create new one - if timing_dict is None: - timing_dict = {} + costs_metric: Dict[str, Dict[str, Any]] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} # Service discovery with timing start_time = time.time() await self._log_request_details( - request, context, mode="streaming", costs_dict=costs_dict + request, context, mode="streaming", costs_metric=costs_metric ) - timing_dict["service.discovery"] = time.time() - start_time + time_metric["service.discovery"] = time.time() - start_time # Check if service was detected and validated if not context.get("service_id"): @@ -820,7 +819,7 @@ async def debug_stream() -> AsyncIterator[str]: # Log costs after streaming completes (follows RAG workflow pattern) # Must be inside generator because costs are accumulated during streaming - orchestration_service.log_costs(costs_dict) + orchestration_service.log_costs(costs_metric) return debug_stream() - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) + # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) \ No newline at end of file diff --git a/src/utils/time_tracker.py b/src/utils/time_tracker.py index 606e5301..c9c11413 100644 --- a/src/utils/time_tracker.py +++ b/src/utils/time_tracker.py @@ -5,16 +5,16 @@ 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 @@ -25,7 +25,7 @@ def log_step_timings( 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 @@ -37,4 +37,4 @@ def log_step_timings( logger.info(f" {step_name:25s}: {elapsed_time:.3f}s") total_time += elapsed_time - logger.info(f" {'TOTAL':25s}: {total_time:.3f}s") + logger.info(f" {'TOTAL':25s}: {total_time:.3f}s") \ No newline at end of file From bee9fbfba8e0ce9bfaf62788159dfe8deb451b55 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Mon, 2 Mar 2026 21:57:15 +0530 Subject: [PATCH 051/105] fixed issue --- src/tool_classifier/sparse_encoder.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tool_classifier/sparse_encoder.py b/src/tool_classifier/sparse_encoder.py index 0d0dc3f6..06f38a86 100644 --- a/src/tool_classifier/sparse_encoder.py +++ b/src/tool_classifier/sparse_encoder.py @@ -8,6 +8,7 @@ Uses hash-based indexing compatible with Qdrant's sparse vector format. """ +import hashlib import re from collections import Counter from dataclasses import dataclass, field @@ -68,10 +69,12 @@ def compute_sparse_vector(text: str) -> SparseVector: token_counts = Counter(tokens) # Hash-based indexing: map each token to an index in [0, SPARSE_VOCAB_SIZE) - # Collisions are handled by summing values at the same index + # 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(): - idx = hash(token) % SPARSE_VOCAB_SIZE + 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) From 0a0806ff72e9bb8e36ea3576917f9f397aff067d Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 3 Mar 2026 09:26:39 +0530 Subject: [PATCH 052/105] optimize first user query response generation time --- src/contextual_retrieval/bm25_search.py | 45 ++++++++++-- .../contextual_retriever.py | 30 ++++++-- src/guardrails/nemo_rails_adapter.py | 41 ++++------- src/llm_orchestration_service.py | 56 ++++++++++++++- src/llm_orchestration_service_api.py | 68 ++++--------------- .../vault/vault_client.py | 25 +------ src/utils/prompt_config_loader.py | 10 +-- 7 files changed, 156 insertions(+), 119 deletions(-) diff --git a/src/contextual_retrieval/bm25_search.py b/src/contextual_retrieval/bm25_search.py index 5bde02d0..d9545d0e 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 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 ( @@ -33,6 +34,11 @@ 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): """Get the HTTP client manager instance.""" @@ -103,10 +109,16 @@ 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() + 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) if not self.bm25_index: logger.error("BM25 index not initialized") @@ -162,6 +174,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]] = [] diff --git a/src/contextual_retrieval/contextual_retriever.py b/src/contextual_retrieval/contextual_retriever.py index b6d4699b..048c131b 100644 --- a/src/contextual_retrieval/contextual_retriever.py +++ b/src/contextual_retrieval/contextual_retriever.py @@ -42,6 +42,7 @@ def __init__( connection_id: Optional[str] = None, config_path: Optional[str] = None, llm_service: Optional["LLMOrchestrationService"] = None, + shared_bm25: Optional[SmartBM25Search] = 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") diff --git a/src/guardrails/nemo_rails_adapter.py b/src/guardrails/nemo_rails_adapter.py index 1ae38982..17f6585e 100644 --- a/src/guardrails/nemo_rails_adapter.py +++ b/src/guardrails/nemo_rails_adapter.py @@ -57,14 +57,14 @@ 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") # NeMo Guardrails' register_llm_provider accepts callable factories at runtime. # We instantiate DSPyLLMProviderFactory first, then register the instance. @@ -74,7 +74,7 @@ def _register_custom_provider(self) -> None: # We use cast to satisfy the type checker while maintaining runtime correctness. factory = DSPyLLMProviderFactory() register_llm_provider("dspy-custom", cast(Type[BaseLLM], factory)) - logger.info("DSPy custom LLM provider registered successfully") + logger.debug("DSPy custom LLM provider registered successfully") except Exception as e: logger.error(f"Failed to register DSPy custom provider: {str(e)}") @@ -86,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 @@ -106,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 @@ -144,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)}") diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 0d32941d..88c85dd3 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -55,6 +55,7 @@ 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, @@ -133,6 +134,13 @@ def __init__(self) -> None: # This allows components to be initialized per-request with proper context self.tool_classifier = 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() @@ -168,10 +176,17 @@ def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter 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}' initialized successfully in {elapsed_time:.3f}s" + f" Guardrails for '{env}' fully initialized in {elapsed_time:.3f}s " + f"(NeMo Rails + embedding model loaded)" ) except Exception as e: @@ -197,6 +212,43 @@ def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter 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)" + ) + @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( self, request: OrchestrationRequest @@ -1786,7 +1838,6 @@ def _initialize_guardrails( environment=environment, connection_id=connection_id ) - logger.info("Guardrails adapter initialized successfully") return guardrails_adapter except Exception as e: @@ -2322,6 +2373,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") diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 2a929db0..12c5dc35 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 @@ -49,10 +50,23 @@ 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( @@ -841,60 +855,6 @@ def refresh_prompt_config(http_request: Request) -> Dict[str, Any]: }, ) from e - try: - success = orchestration_service.prompt_config_loader.force_refresh() - - if success: - # Get prompt metadata without exposing content (security) - custom_instructions = ( - orchestration_service.prompt_config_loader.get_custom_instructions() - ) - prompt_length = len(custom_instructions) - - # Generate hash for verification purposes (without exposing content) - import hashlib - - prompt_hash = hashlib.sha256(custom_instructions.encode()).hexdigest()[:16] - - logger.info( - f"Prompt configuration cache refreshed successfully ({prompt_length} chars)" - ) - - return { - "refreshed": True, - "message": "Prompt configuration refreshed successfully", - "prompt_length": prompt_length, - "content_hash": prompt_hash, # Safe: hash instead of preview - } - else: - # No fresh data loaded - could be fetch failure or truly not found - error_id = generate_error_id() - logger.warning( - f"[{error_id}] Prompt configuration refresh returned empty result" - ) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={ - "error": "No prompt configuration found in database", - "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__": logger.info("Starting LLM Orchestration Service API server on port 8100") 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/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py index 8df8945b..01a40c84 100644 --- a/src/utils/prompt_config_loader.py +++ b/src/utils/prompt_config_loader.py @@ -229,7 +229,7 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: # Unwrap Ruuter's response wrapper if present if isinstance(data, dict) and "response" in data: - logger.info("Unwrapping 'response' key") + logger.debug("Unwrapping 'response' key") data = data["response"] # Now extract prompt from the unwrapped data @@ -238,25 +238,25 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: first_elem_keys = ( list(data[0].keys()) if isinstance(data[0], dict) else [] ) - logger.info( + logger.debug( f"Extracting from list, first element keys: {first_elem_keys}" ) prompt = data[0].get("prompt", "").strip() elif isinstance(data, dict): # Dict format: {"id": 1, "prompt": "..."} - logger.info(f"Extracting from dict, keys: {list(data.keys())}") + logger.debug(f"Extracting from dict, keys: {list(data.keys())}") prompt = data.get("prompt", "").strip() else: logger.warning( f"Unexpected data type: {type(data).__name__}, structure not recognized" ) - logger.info( + logger.debug( f"Extracted prompt length: {len(prompt) if prompt else 0}" ) if prompt: - logger.info( + logger.debug( f"Loaded prompt on attempt {attempt} ({len(prompt)} chars)" ) return prompt From 1eb8b4750b9cdeff2c1107c436f902db5aacc721 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 3 Mar 2026 10:05:34 +0530 Subject: [PATCH 053/105] fixed pr reviewed issues --- src/contextual_retrieval/bm25_search.py | 22 +++++++++++++------ src/intent_data_enrichment/main_enrichment.py | 22 ++++++++++++------- src/llm_orchestration_service.py | 10 +++++++++ src/llm_orchestration_service_api.py | 7 ++++-- src/tool_classifier/classifier.py | 8 +++++++ .../workflows/service_workflow.py | 12 +++++----- 6 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/contextual_retrieval/bm25_search.py b/src/contextual_retrieval/bm25_search.py index d9545d0e..2be66e4c 100644 --- a/src/contextual_retrieval/bm25_search.py +++ b/src/contextual_retrieval/bm25_search.py @@ -112,13 +112,21 @@ async def search_bm25( # 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 - 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) + # 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") diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py index 16db8c66..9724683a 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -219,14 +219,20 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: qdrant.ensure_collection() # Delete old points before inserting new ones - qdrant.delete_service_points(service_data.service_id) - - # 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) + 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() diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 88c85dd3..7f7432fc 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -249,6 +249,16 @@ async def _prewarm_shared_bm25(self) -> None: 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") + @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( self, request: OrchestrationRequest diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 12c5dc35..0e9b1273 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -85,8 +85,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # 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 diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 0d4644df..f18ef3ec 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -99,6 +99,14 @@ def __init__( 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, diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index dbb52113..bb72f785 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -581,11 +581,11 @@ async def execute_async( # Skip service discovery — use hybrid search match directly matched_service_id = context.get("matched_service_id") matched_service_name = context.get("matched_service_name") - rrf_score = context.get("rrf_score", 0) + cosine_score = context.get("cosine_score", 0.0) logger.info( f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (non-streaming): " - f"{matched_service_name} (rrf_score={rrf_score:.6f}) - " + f"{matched_service_name} (cosine_score={cosine_score:.4f}) - " f"skipping discovery" ) @@ -634,7 +634,7 @@ async def execute_async( context=context, costs_metric=costs_metric, ) - time_metric["service.discovery"] = time.time() - start_time + time_metric["service.intent_detection"] = time.time() - start_time else: # LEGACY PATH: No hybrid search metadata (classifier disabled or error) @@ -784,11 +784,11 @@ async def execute_streaming( if needs_llm_confirmation is False: # HIGH CONFIDENCE PATH: Skip discovery, use matched service matched_service_name = context.get("matched_service_name") - rrf_score = context.get("rrf_score", 0) + cosine_score = context.get("cosine_score", 0.0) logger.info( f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (streaming): " - f"{matched_service_name} (rrf_score={rrf_score:.6f})" + f"{matched_service_name} (cosine_score={cosine_score:.4f})" ) top_results = context.get("top_results", []) @@ -831,7 +831,7 @@ async def execute_streaming( context=context, costs_metric=costs_metric, ) - time_metric["service.discovery"] = time.time() - start_time + time_metric["service.intent_detection"] = time.time() - start_time else: # LEGACY PATH: Full service discovery (original behavior) From da8d9401fe3ba7d02147f0966777ecc1df6976c0 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:51:45 +0530 Subject: [PATCH 054/105] Optimize intent data enrichment and service classification (#325) --- docker-compose.yml | 2 + docs/HYBRID_SEARCH_CLASSIFICATION.md | 393 +++++++++++++++ src/contextual_retrieval/constants.py | 5 +- src/intent_data_enrichment/constants.py | 4 + src/intent_data_enrichment/main_enrichment.py | 152 ++++-- src/intent_data_enrichment/models.py | 21 +- src/intent_data_enrichment/qdrant_manager.py | 266 +++++++--- src/tool_classifier/classifier.py | 476 ++++++++++++++++-- src/tool_classifier/constants.py | 25 + src/tool_classifier/sparse_encoder.py | 85 ++++ .../workflows/service_workflow.py | 152 +++++- src/vector_indexer/constants.py | 9 +- 12 files changed, 1440 insertions(+), 150 deletions(-) create mode 100644 docs/HYBRID_SEARCH_CLASSIFICATION.md create mode 100644 src/tool_classifier/sparse_encoder.py diff --git a/docker-compose.yml b/docker-compose.yml index 1fec54b5..0a825082 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -178,6 +178,7 @@ 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/utils/decrypt_vault_secrets.py:/app/src/utils/decrypt_vault_secrets.py:ro # Decryption utility (read-only) - cron_data:/app/data @@ -577,6 +578,7 @@ services: - ./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: diff --git a/docs/HYBRID_SEARCH_CLASSIFICATION.md b/docs/HYBRID_SEARCH_CLASSIFICATION.md new file mode 100644 index 00000000..18c512ae --- /dev/null +++ b/docs/HYBRID_SEARCH_CLASSIFICATION.md @@ -0,0 +1,393 @@ +# 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` | `EnrichedService` data model | +| `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): "Valuutakursid - Kasutaja soovib infot..." + dense: 3072-dim embedding of name + description + LLM context + 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 +text = "Mis suhe on euro ja usd vahel" +tokens = re.findall(r"\w+", text.lower()) # ["mis", "suhe", "on", "euro", ...] +# Each token → hashed to index in [0, VOCAB_SIZE), value = term frequency +# 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 + 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 +``` + +### 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, + "with_payload": true +} +``` + +Results are deduplicated by `service_id` (best score per service). + +**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() +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 +} +``` + +### 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 ✓ +``` + +### 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 2-3 candidates | Yes — if LLM matches | +| Non-service | Not run | Not run | + +### Intent Detection Module (DSPy) + +**File:** `src/tool_classifier/intent_detector.py` + +The DSPy `IntentDetectionModule` receives: +- User query +- Candidate services (formatted as JSON) +- Conversation history (last 3 turns) + +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"] +``` + +--- + +## 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. + +### Current Limitations + +- **Step 7 (Ruuter service call) is not yet implemented.** The service workflow currently returns a debug response with service metadata (endpoint URL, HTTP method, extracted entities) instead of calling the actual Ruuter service endpoint. See the `TODO: STEP 7` comments in `src/tool_classifier/workflows/service_workflow.py`. diff --git a/src/contextual_retrieval/constants.py b/src/contextual_retrieval/constants.py index 7ca58cb8..cb7c49c3 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.""" @@ -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/intent_data_enrichment/constants.py b/src/intent_data_enrichment/constants.py index f1f35f38..f506880a 100644 --- a/src/intent_data_enrichment/constants.py +++ b/src/intent_data_enrichment/constants.py @@ -24,6 +24,10 @@ class EnrichmentConstants: 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} diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py index d718678e..16db8c66 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -3,19 +3,61 @@ Service Data Enrichment Script This script receives service data, enriches it with LLM-generated context, -creates embeddings, and stores in Qdrant intent_collections. +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.""" @@ -76,7 +118,8 @@ def parse_arguments() -> ServiceData: async def enrich_service(service_data: ServiceData) -> EnrichmentResult: """ - Main enrichment pipeline: generate context, create embedding, store in Qdrant. + Main enrichment pipeline: generate context, create per-example embeddings, + store in Qdrant with hybrid vectors (dense + sparse). Args: service_data: Service data to enrich @@ -85,14 +128,52 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: EnrichmentResult with success/failure information """ try: - # Step 1: Generate rich context using LLM + # 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: Combine generated context with original metadata for embedding - logger.info("Step 2: Combining context with original service metadata") + # 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", + ) + ) + + # 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}", @@ -108,35 +189,44 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: f"Required Entities: {', '.join(service_data.entities)}" ) - # Add generated context last (enriched understanding) combined_text_parts.append(f"Enriched Context: {context}") - combined_text = "\n".join(combined_text_parts) - logger.info(f"Combined text length: {len(combined_text)} characters") - - # Step 3: Create embedding for combined text - logger.info("Step 3: Creating embedding vector for combined text") - embedding = await api_client.create_embedding(combined_text) - logger.success(f"Embedding created: {len(embedding)}-dimensional vector") - - # Step 4: Prepare enriched service - enriched_service = 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=embedding, - ) - # Step 5: Store in Qdrant - logger.info("Step 5: Storing in Qdrant") + 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", + ) + ) + + # 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() - success = qdrant.upsert_service(enriched_service) + + # Delete old points before inserting new ones + qdrant.delete_service_points(service_data.service_id) + + # 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() @@ -144,9 +234,13 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: return EnrichmentResult( success=True, service_id=service_data.service_id, - message=f"Service '{service_data.name}' enriched and indexed successfully", + 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(embedding), + embedding_dimension=len(summary_embedding), error=None, ) else: diff --git a/src/intent_data_enrichment/models.py b/src/intent_data_enrichment/models.py index eb0ef64e..9390e736 100644 --- a/src/intent_data_enrichment/models.py +++ b/src/intent_data_enrichment/models.py @@ -20,7 +20,12 @@ class ServiceData(BaseModel): class EnrichedService(BaseModel): - """Enriched service data ready for storage.""" + """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") @@ -28,7 +33,19 @@ class EnrichedService(BaseModel): 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="Context embedding vector") + 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'" + ) class EnrichmentResult(BaseModel): diff --git a/src/intent_data_enrichment/qdrant_manager.py b/src/intent_data_enrichment/qdrant_manager.py index 5024e236..2e2ef2e5 100644 --- a/src/intent_data_enrichment/qdrant_manager.py +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -1,10 +1,21 @@ -"""Qdrant manager for intent collections.""" +"""Qdrant manager for intent collections with hybrid search support.""" import uuid -from typing import Optional +from typing import Optional, List from loguru import logger from qdrant_client import QdrantClient -from qdrant_client.models import Distance, VectorParams, PointStruct +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 @@ -14,7 +25,7 @@ class QdrantManager: - """Manages Qdrant operations for intent collections.""" + """Manages Qdrant operations for intent collections with hybrid search.""" def __init__( self, @@ -44,7 +55,12 @@ def connect(self) -> None: raise def ensure_collection(self) -> None: - """Ensure the intent_collections collection exists with correct vector size.""" + """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) @@ -53,48 +69,60 @@ def ensure_collection(self) -> None: collection_names = [col.name for col in collections] if self.collection_name in collection_names: - # Check if existing collection has correct vector size collection_info = self.client.get_collection(self.collection_name) - - # Qdrant vectors config is a dict - get the default vector config vectors_config = collection_info.config.params.vectors - existing_vector_size: Optional[int] = None + # Check if collection has the expected named vector configuration if isinstance(vectors_config, dict): - # Get first vector config (usually the default/unnamed one) - if vectors_config: - vector_params = next(iter(vectors_config.values())) - existing_vector_size = vector_params.size + 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 (older API) - existing_vector_size = vectors_config.size - - if existing_vector_size is None: + # Direct VectorParams object (old single-vector format) logger.error( - f"Collection '{self.collection_name}' exists but vector size cannot be determined" + f"Collection '{self.collection_name}' exists but uses old single-vector format." ) raise RuntimeError( - f"Collection '{self.collection_name}' exists but vector size cannot be determined. " - "This may indicate a Qdrant API issue or unexpected collection configuration. " - "Manual intervention required: verify Qdrant health, inspect collection config, " - "or manually delete the collection if recreating is intended." + 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 existing_vector_size != EnrichmentConstants.VECTOR_SIZE: + else: logger.error( - f"Collection '{self.collection_name}' has incompatible vector size: " - f"{existing_vector_size} (expected {EnrichmentConstants.VECTOR_SIZE})" + f"Collection '{self.collection_name}' exists but vector config cannot be determined" ) raise RuntimeError( - f"Collection '{self.collection_name}' has incompatible vector size " - f"({existing_vector_size} vs expected {EnrichmentConstants.VECTOR_SIZE}). " - "This prevents automatic deletion to avoid accidental data loss. " - "To recreate the collection, manually delete it first using: " - f"qdrant.client.delete_collection('{self.collection_name}') or via Qdrant UI/API." - ) - else: - logger.info( - f"Collection '{self.collection_name}' already exists " - f"with correct vector size ({existing_vector_size})" + f"Collection '{self.collection_name}' exists but vector config cannot be determined. " + "Manual intervention required." ) else: self._create_collection() @@ -104,77 +132,167 @@ def ensure_collection(self) -> None: raise def _create_collection(self) -> None: - """Create the collection with correct vector configuration.""" + """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 vector size {EnrichmentConstants.VECTOR_SIZE}" + f"with hybrid vectors (dense: {EnrichmentConstants.VECTOR_SIZE}d + sparse)" ) self.client.create_collection( collection_name=self.collection_name, - vectors_config=VectorParams( - size=EnrichmentConstants.VECTOR_SIZE, - distance=Distance.COSINE, - ), + 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 upsert_service(self, enriched_service: EnrichedService) -> bool: - """ - Upsert enriched service to Qdrant (update if exists, insert if new). + 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: - enriched_service: EnrichedService instance containing the embedding and - associated metadata to upsert into Qdrant. + service_id: Service identifier to delete all points for Returns: True if successful, False otherwise """ try: if not self.client: - raise RuntimeError("Qdrant client not initialized") - - logger.info(f"Upserting service '{enriched_service.id}' to Qdrant") - - # Convert service_id to UUID for Qdrant compatibility - # Qdrant requires point IDs to be either integers or UUIDs - point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, enriched_service.id)) - - # Prepare payload (all metadata except embedding) - payload = { - "service_id": enriched_service.id, # Store original ID in payload - "name": enriched_service.name, - "description": enriched_service.description, - "examples": enriched_service.examples, - "entities": enriched_service.entities, - "context": enriched_service.context, - } - - # Create point with UUID - point = PointStruct( - id=point_id, # ✓ Now using UUID string - vector=enriched_service.embedding, - payload=payload, + 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), + ) + ] + ) + ), ) - # Upsert to Qdrant + 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, + } + + # 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=[point], + points=points, ) logger.success( - f"Successfully upserted service '{enriched_service.id}' " - f"({len(enriched_service.embedding)}-dim vector)" + 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 '{enriched_service.id}': {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: diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 4455f8ca..0d4644df 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -1,6 +1,7 @@ -"""Main tool classifier for workflow routing.""" +"""Main tool classifier for workflow routing with hybrid search classification.""" from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union, overload +import httpx from loguru import logger from models.request_models import ( @@ -8,8 +9,24 @@ OrchestrationRequest, OrchestrationResponse, ) -from tool_classifier.enums import WorkflowType, WORKFLOW_DISPLAY_NAMES +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 compute_sparse_vector from tool_classifier.workflows import ( ServiceWorkflowExecutor, ContextWorkflowExecutor, @@ -22,19 +39,20 @@ 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 - - Each layer is tried in sequence. If a layer cannot handle the query - (returns None), the classifier falls back to the next layer. - - Architecture: - - Strategy Pattern: Each workflow is a pluggable strategy - - Chain of Responsibility: Layers form a fallback chain - - Dependency Injection: LLM manager and connections injected from main service """ def __init__( @@ -52,6 +70,17 @@ def __init__( 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.service_workflow = ServiceWorkflowExecutor( llm_manager=llm_manager, @@ -65,7 +94,10 @@ def __init__( ) self.ood_workflow = OODWorkflowExecutor() - logger.info("Tool classifier initialized with all workflow executors") + logger.info( + "Tool classifier initialized with hybrid search classification " + f"(Qdrant: {self._qdrant_base_url})" + ) async def classify( self, @@ -74,13 +106,15 @@ async def classify( language: str, ) -> ClassificationResult: """ - Classify a user query to determine which workflow should handle it. + Classify a user query using a two-step search approach. - Implements layer-wise classification logic with fallback chain: - 1. SERVICE workflow (external API calls) - 2. CONTEXT workflow (greetings/conversation history) - 3. RAG workflow (knowledge base retrieval) - 4. OOD workflow (out-of-domain) + Step 1: Dense-only search → cosine similarity for relevance check + Step 2: Hybrid search (dense + sparse + RRF) → service identification + + Routing: + - cosine < DENSE_MIN_THRESHOLD → CONTEXT/RAG (skip SERVICE) + - cosine ≥ HIGH_CONFIDENCE + large gap → SERVICE (no LLM needed) + - else → SERVICE with LLM confirmation Args: query: User's query string @@ -92,13 +126,403 @@ async def classify( """ logger.info(f"Classifying query: {query[:100]}...") - logger.info("Starting layer-wise fallback: ") - return ClassificationResult( - workflow=WorkflowType.SERVICE, - confidence=1.0, - metadata={}, - reasoning="Start with Service workflow - will cascade through layers", - ) + try: + # 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 - 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 relevance (cosine={top_cosine:.4f} < {DENSE_MIN_THRESHOLD}) " + f"- routing to CONTEXT/RAG, skipping SERVICE" + ) + 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: Any, + 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( @@ -231,7 +655,6 @@ async def _execute_with_fallback_async( ) # Get the layer order starting from current layer - from tool_classifier.enums import WORKFLOW_LAYER_ORDER current_index = WORKFLOW_LAYER_ORDER.index(start_layer) remaining_layers = WORKFLOW_LAYER_ORDER[current_index + 1 :] @@ -253,7 +676,6 @@ async def _execute_with_fallback_async( return result logger.info(f"[{chat_id}] {next_name} returned None, continuing...") - current_index += 1 # This should never happen since RAG/OOD should always return result raise RuntimeError("All workflows returned None (unexpected)") @@ -313,7 +735,6 @@ async def _execute_with_fallback_streaming( ) # Get the layer order starting from current layer - from tool_classifier.enums import WORKFLOW_LAYER_ORDER current_index = WORKFLOW_LAYER_ORDER.index(start_layer) remaining_layers = WORKFLOW_LAYER_ORDER[current_index + 1 :] @@ -338,7 +759,6 @@ async def _execute_with_fallback_streaming( return logger.info(f"[{chat_id}] {next_name} returned None, continuing...") - current_index += 1 # This should never happen raise RuntimeError("All workflows returned None in streaming (unexpected)") diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index c885b524..65f30332 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -58,3 +58,28 @@ 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.38 +"""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.40 +"""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.""" 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/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index b432c628..dbb52113 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -554,6 +554,11 @@ async def execute_async( ) -> 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 @@ -568,12 +573,77 @@ async def execute_async( if time_metric is None: time_metric = {} - # Service discovery with timing - 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 + # Check if classifier provided hybrid search metadata + needs_llm_confirmation = context.get("needs_llm_confirmation") + + if needs_llm_confirmation is False: + # HIGH CONFIDENCE PATH: Classifier matched a service with high confidence + # Skip service discovery — use hybrid search match directly + matched_service_id = context.get("matched_service_id") + matched_service_name = context.get("matched_service_name") + rrf_score = context.get("rrf_score", 0) + + logger.info( + f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (non-streaming): " + f"{matched_service_name} (rrf_score={rrf_score:.6f}) - " + f"skipping discovery" + ) + + # Get service details from top_results (already retrieved by classifier) + top_results = context.get("top_results", []) + if top_results: + matched = top_results[0] + + # Run entity extraction via LLM (DSPy) for this single service + 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 + + # Ensure service_data is populated from hybrid match + # _process_intent_detection may not set it if DSPy returns + # a different service_id format, so we populate it explicitly + if not context.get("service_data"): + context["service_id"] = matched.get("service_id") + context["service_data"] = matched + logger.info( + f"[{chat_id}] Populated service_data from hybrid match: " + f"{matched.get('name')}" + ) + + elif needs_llm_confirmation is True: + # AMBIGUOUS PATH: Multiple services scored similarly + # Run LLM intent detection only on candidate services (not all services) + top_results = context.get("top_results", []) + logger.info( + f"[{chat_id}] AMBIGUOUS SERVICE MATCH (non-streaming): " + f"running LLM 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.discovery"] = time.time() - start_time + + else: + # LEGACY PATH: No hybrid search metadata (classifier disabled or error) + # Full service discovery + intent detection (original behavior) + 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 # Check if service was detected and validated if not context.get("service_id"): @@ -692,6 +762,8 @@ async def execute_streaming( ) -> 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 @@ -706,12 +778,68 @@ async def execute_streaming( if time_metric is None: time_metric = {} - # Service discovery with timing - 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 + # Check if classifier provided hybrid search metadata + needs_llm_confirmation = context.get("needs_llm_confirmation") + + if needs_llm_confirmation is False: + # HIGH CONFIDENCE PATH: Skip discovery, use matched service + matched_service_name = context.get("matched_service_name") + rrf_score = context.get("rrf_score", 0) + + logger.info( + f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (streaming): " + f"{matched_service_name} (rrf_score={rrf_score:.6f})" + ) + + 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 + + # Ensure service_data is populated from hybrid match + if not context.get("service_data"): + context["service_id"] = matched.get("service_id") + context["service_data"] = matched + logger.info( + f"[{chat_id}] Populated service_data from hybrid match: " + f"{matched.get('name')}" + ) + + elif needs_llm_confirmation is True: + # AMBIGUOUS PATH: Run LLM intent detection on candidates + top_results = context.get("top_results", []) + logger.info( + f"[{chat_id}] AMBIGUOUS SERVICE MATCH (streaming): " + f"{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.discovery"] = time.time() - start_time + + else: + # LEGACY PATH: Full service discovery (original behavior) + 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 # Check if service was detected and validated if not context.get("service_id"): diff --git a/src/vector_indexer/constants.py b/src/vector_indexer/constants.py index c4f38100..b6854283 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 From adae7901ef9b23456330ec338d3d3b6410a39bd5 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:36:09 +0530 Subject: [PATCH 055/105] Optimize first user query response generation time (#326) --- src/contextual_retrieval/bm25_search.py | 53 ++++++++++++- .../contextual_retriever.py | 30 ++++++-- src/guardrails/nemo_rails_adapter.py | 41 ++++------ src/intent_data_enrichment/main_enrichment.py | 22 ++++-- src/llm_orchestration_service.py | 66 +++++++++++++++- src/llm_orchestration_service_api.py | 75 +++++-------------- .../vault/vault_client.py | 25 +------ src/tool_classifier/classifier.py | 8 ++ .../workflows/service_workflow.py | 12 +-- src/utils/prompt_config_loader.py | 10 +-- 10 files changed, 207 insertions(+), 135 deletions(-) diff --git a/src/contextual_retrieval/bm25_search.py b/src/contextual_retrieval/bm25_search.py index 5bde02d0..2be66e4c 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 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 ( @@ -33,6 +34,11 @@ 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): """Get the HTTP client manager instance.""" @@ -103,10 +109,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 +182,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]] = [] diff --git a/src/contextual_retrieval/contextual_retriever.py b/src/contextual_retrieval/contextual_retriever.py index b6d4699b..048c131b 100644 --- a/src/contextual_retrieval/contextual_retriever.py +++ b/src/contextual_retrieval/contextual_retriever.py @@ -42,6 +42,7 @@ def __init__( connection_id: Optional[str] = None, config_path: Optional[str] = None, llm_service: Optional["LLMOrchestrationService"] = None, + shared_bm25: Optional[SmartBM25Search] = 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") diff --git a/src/guardrails/nemo_rails_adapter.py b/src/guardrails/nemo_rails_adapter.py index 1ae38982..17f6585e 100644 --- a/src/guardrails/nemo_rails_adapter.py +++ b/src/guardrails/nemo_rails_adapter.py @@ -57,14 +57,14 @@ 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") # NeMo Guardrails' register_llm_provider accepts callable factories at runtime. # We instantiate DSPyLLMProviderFactory first, then register the instance. @@ -74,7 +74,7 @@ def _register_custom_provider(self) -> None: # We use cast to satisfy the type checker while maintaining runtime correctness. factory = DSPyLLMProviderFactory() register_llm_provider("dspy-custom", cast(Type[BaseLLM], factory)) - logger.info("DSPy custom LLM provider registered successfully") + logger.debug("DSPy custom LLM provider registered successfully") except Exception as e: logger.error(f"Failed to register DSPy custom provider: {str(e)}") @@ -86,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 @@ -106,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 @@ -144,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)}") diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py index 16db8c66..9724683a 100644 --- a/src/intent_data_enrichment/main_enrichment.py +++ b/src/intent_data_enrichment/main_enrichment.py @@ -219,14 +219,20 @@ async def enrich_service(service_data: ServiceData) -> EnrichmentResult: qdrant.ensure_collection() # Delete old points before inserting new ones - qdrant.delete_service_points(service_data.service_id) - - # 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) + 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() diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 0d32941d..7f7432fc 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -55,6 +55,7 @@ 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, @@ -133,6 +134,13 @@ def __init__(self) -> None: # This allows components to be initialized per-request with proper context self.tool_classifier = 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() @@ -168,10 +176,17 @@ def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter 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}' initialized successfully in {elapsed_time:.3f}s" + f" Guardrails for '{env}' fully initialized in {elapsed_time:.3f}s " + f"(NeMo Rails + embedding model loaded)" ) except Exception as e: @@ -197,6 +212,53 @@ def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter 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") + @observe(name="orchestration_request", as_type="agent") async def process_orchestration_request( self, request: OrchestrationRequest @@ -1786,7 +1848,6 @@ def _initialize_guardrails( environment=environment, connection_id=connection_id ) - logger.info("Guardrails adapter initialized successfully") return guardrails_adapter except Exception as e: @@ -2322,6 +2383,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") diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 2a929db0..0e9b1273 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 @@ -49,10 +50,23 @@ 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( @@ -71,8 +85,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # 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 @@ -841,60 +858,6 @@ def refresh_prompt_config(http_request: Request) -> Dict[str, Any]: }, ) from e - try: - success = orchestration_service.prompt_config_loader.force_refresh() - - if success: - # Get prompt metadata without exposing content (security) - custom_instructions = ( - orchestration_service.prompt_config_loader.get_custom_instructions() - ) - prompt_length = len(custom_instructions) - - # Generate hash for verification purposes (without exposing content) - import hashlib - - prompt_hash = hashlib.sha256(custom_instructions.encode()).hexdigest()[:16] - - logger.info( - f"Prompt configuration cache refreshed successfully ({prompt_length} chars)" - ) - - return { - "refreshed": True, - "message": "Prompt configuration refreshed successfully", - "prompt_length": prompt_length, - "content_hash": prompt_hash, # Safe: hash instead of preview - } - else: - # No fresh data loaded - could be fetch failure or truly not found - error_id = generate_error_id() - logger.warning( - f"[{error_id}] Prompt configuration refresh returned empty result" - ) - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={ - "error": "No prompt configuration found in database", - "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__": logger.info("Starting LLM Orchestration Service API server on port 8100") 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/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 0d4644df..f18ef3ec 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -99,6 +99,14 @@ def __init__( 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, diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index dbb52113..bb72f785 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -581,11 +581,11 @@ async def execute_async( # Skip service discovery — use hybrid search match directly matched_service_id = context.get("matched_service_id") matched_service_name = context.get("matched_service_name") - rrf_score = context.get("rrf_score", 0) + cosine_score = context.get("cosine_score", 0.0) logger.info( f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (non-streaming): " - f"{matched_service_name} (rrf_score={rrf_score:.6f}) - " + f"{matched_service_name} (cosine_score={cosine_score:.4f}) - " f"skipping discovery" ) @@ -634,7 +634,7 @@ async def execute_async( context=context, costs_metric=costs_metric, ) - time_metric["service.discovery"] = time.time() - start_time + time_metric["service.intent_detection"] = time.time() - start_time else: # LEGACY PATH: No hybrid search metadata (classifier disabled or error) @@ -784,11 +784,11 @@ async def execute_streaming( if needs_llm_confirmation is False: # HIGH CONFIDENCE PATH: Skip discovery, use matched service matched_service_name = context.get("matched_service_name") - rrf_score = context.get("rrf_score", 0) + cosine_score = context.get("cosine_score", 0.0) logger.info( f"[{chat_id}] HIGH-CONFIDENCE SERVICE MATCH (streaming): " - f"{matched_service_name} (rrf_score={rrf_score:.6f})" + f"{matched_service_name} (cosine_score={cosine_score:.4f})" ) top_results = context.get("top_results", []) @@ -831,7 +831,7 @@ async def execute_streaming( context=context, costs_metric=costs_metric, ) - time_metric["service.discovery"] = time.time() - start_time + time_metric["service.intent_detection"] = time.time() - start_time else: # LEGACY PATH: Full service discovery (original behavior) diff --git a/src/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py index 8df8945b..01a40c84 100644 --- a/src/utils/prompt_config_loader.py +++ b/src/utils/prompt_config_loader.py @@ -229,7 +229,7 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: # Unwrap Ruuter's response wrapper if present if isinstance(data, dict) and "response" in data: - logger.info("Unwrapping 'response' key") + logger.debug("Unwrapping 'response' key") data = data["response"] # Now extract prompt from the unwrapped data @@ -238,25 +238,25 @@ def _load_from_ruuter_with_retry(self) -> Optional[str]: first_elem_keys = ( list(data[0].keys()) if isinstance(data[0], dict) else [] ) - logger.info( + logger.debug( f"Extracting from list, first element keys: {first_elem_keys}" ) prompt = data[0].get("prompt", "").strip() elif isinstance(data, dict): # Dict format: {"id": 1, "prompt": "..."} - logger.info(f"Extracting from dict, keys: {list(data.keys())}") + logger.debug(f"Extracting from dict, keys: {list(data.keys())}") prompt = data.get("prompt", "").strip() else: logger.warning( f"Unexpected data type: {type(data).__name__}, structure not recognized" ) - logger.info( + logger.debug( f"Extracted prompt length: {len(prompt) if prompt else 0}" ) if prompt: - logger.info( + logger.debug( f"Loaded prompt on attempt {attempt} ({len(prompt)} chars)" ) return prompt From bb1601fd09c4db75c656978174f2da37b7dcc96c Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Sun, 8 Mar 2026 14:41:18 +0530 Subject: [PATCH 056/105] service integration --- .../hbs/bot_responses_to_messages.handlebars | 14 + DSL/DMapper/rag-search/lib/helpers.js | 29 ++ DSL/Ruuter.public/services/GET/.guard | 28 ++ .../GET/account/user-profile-settings.yml | 27 ++ .../services/GET/active-services.yml | 20 + DSL/Ruuter.public/services/GET/generic/.guard | 4 + .../accounts/customer-support-activity.yml | 22 + .../accounts/user-profile-settings.yml | 26 ++ .../GET/generic/accounts/user-role.yml | 19 + .../services/GET/generic/csa/active-chats.yml | 76 ++++ .../services/GET/generic/userinfo.yml | 29 ++ DSL/Ruuter.public/services/GET/get-sticky.yml | 40 ++ .../services/GET/internal/domain-file.yml | 40 ++ .../GET/internal/return-file-locations.yml | 27 ++ .../GET/mocks/client-input-variables.yml | 19 + .../services/GET/mocks/service-settings.yml | 19 + .../services/GET/mocks/validation-mock.yml | 26 ++ .../services/GET/rasa/rule-names.yml | 29 ++ .../services/GET/secrets-with-priority.yml | 34 ++ DSL/Ruuter.public/services/GET/secrets.yml | 20 + .../services/GET/service-settings.yml | 19 + .../services/GET/services/active/.guard | 4 + .../services/GET/services/draft/.guard | 4 + .../services/GET/services/inactive/.guard | 4 + .../services/GET/services/log-by-request.yml | 33 ++ .../services/GET/services/log-by-service.yml | 33 ++ .../GET/services/services-detailed/nok.yml | 52 +++ .../services/GET/services/statistics.yml | 21 + .../services/GET/services/status.yml | 24 ++ DSL/Ruuter.public/services/GET/slots.yml | 26 ++ .../services/GET/steps/preferences.yml | 72 ++++ .../services/GET/sticky/example.yml | 58 +++ DSL/Ruuter.public/services/POST/.guard | 28 ++ DSL/Ruuter.public/services/POST/auth/.guard | 4 + .../services/POST/auth/login.yml | 101 +++++ DSL/Ruuter.public/services/POST/csv.yml | 50 +++ .../POST/dates/calculate-difference.yml | 82 ++++ .../services/POST/endpoints/common.yml | 48 +++ .../services/POST/file/rename.yml | 42 ++ .../services/POST/mocks/RBAC-mock.yml | 50 +++ .../POST/mocks/dates/calculate-difference.yml | 136 +++++++ .../services/POST/mocks/service-settings.yml | 19 + .../services/POST/mocks/services/add.yml | 44 ++ .../mocks/services/open-api-spec-mock.yml | 17 + .../services/POST/mocks/user-info.yml | 52 +++ .../services/POST/mocks/validation-mock.yml | 26 ++ .../services/POST/rasa/rules/add.yml | 129 ++++++ .../services/POST/saveJsonToYml.yml | 39 ++ .../services/POST/service-by-id.yml | 90 +++++ .../services/POST/service-settings.yml | 29 ++ DSL/Ruuter.public/services/POST/services.yml | 43 ++ .../services/POST/services/active/.guard | 4 + .../services/active/Broneeringu_kinnitus.yml | 65 +++ .../active/Kalastusloa_uuendamise_teade.yml | 60 +++ .../POST/services/active/Koolivaheajad.yml | 63 +++ .../services/active/Lihtne_test_teenus.yml | 61 +++ .../services/active/customer_feedback.yml | 82 ++++ .../services/POST/services/add.yml | 177 ++++++++ .../POST/services/create-endpoint.yml | 46 +++ .../POST/services/delete-endpoint.yml | 34 ++ .../services/POST/services/delete.yml | 155 +++++++ .../services/domain-intent-service-link.yml | 157 ++++++++ .../services/POST/services/draft/.guard | 4 + .../services/POST/services/draft/test.tmp | 48 +++ .../services/POST/services/edit.yml | 381 ++++++++++++++++++ .../POST/services/endpoint-url-validation.yml | 32 ++ .../POST/services/import-services.yml | 71 ++++ .../services/POST/services/inactive/.guard | 4 + .../services/POST/services/open-api-spec.yml | 34 ++ .../POST/services/requests/explain.yml | 95 +++++ .../services/POST/services/resql/add.yml | 62 +++ .../services/POST/services/status.yml | 303 ++++++++++++++ .../POST/services/update-endpoint.yml | 61 +++ .../services/POST/steps/preferences.yml | 68 ++++ DSL/Ruuter.public/services/POST/user-info.yml | 16 + DSL/Ruuter.public/services/TEMPLATES/RBAC.yml | 51 +++ .../TEMPLATES/check-user-authority.yml | 50 +++ .../services/TEMPLATES/client-input.yml | 19 + .../services/TEMPLATES/direct-to-cs.yml | 42 ++ .../services/TEMPLATES/end-conversation.yml | 42 ++ .../services/TEMPLATES/file-generate.yml | 45 +++ .../services/TEMPLATES/file-signing.yml | 35 ++ .../services/TEMPLATES/open-webpage.yml | 44 ++ .../TEMPLATES/send-message-to-client.yml | 42 ++ DSL/Ruuter.public/services/TEMPLATES/siga.yml | 132 ++++++ DSL/Ruuter.public/services/TEMPLATES/tara.yml | 51 +++ .../TEMPLATES/validation-template.yml | 56 +++ constants.ini | 5 +- docs/HYBRID_SEARCH_CLASSIFICATION.md | 59 ++- docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 262 ++++++------ new.txt | 38 ++ src/tool_classifier/constants.py | 3 + .../workflows/service_workflow.py | 331 ++++++--------- tests/data/classification_test_queries.json | 266 ++++++++++++ 94 files changed, 5246 insertions(+), 337 deletions(-) create mode 100644 DSL/DMapper/rag-search/hbs/bot_responses_to_messages.handlebars create mode 100644 DSL/Ruuter.public/services/GET/.guard create mode 100644 DSL/Ruuter.public/services/GET/account/user-profile-settings.yml create mode 100644 DSL/Ruuter.public/services/GET/active-services.yml create mode 100644 DSL/Ruuter.public/services/GET/generic/.guard create mode 100644 DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml create mode 100644 DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml create mode 100644 DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml create mode 100644 DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml create mode 100644 DSL/Ruuter.public/services/GET/generic/userinfo.yml create mode 100644 DSL/Ruuter.public/services/GET/get-sticky.yml create mode 100644 DSL/Ruuter.public/services/GET/internal/domain-file.yml create mode 100644 DSL/Ruuter.public/services/GET/internal/return-file-locations.yml create mode 100644 DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml create mode 100644 DSL/Ruuter.public/services/GET/mocks/service-settings.yml create mode 100644 DSL/Ruuter.public/services/GET/mocks/validation-mock.yml create mode 100644 DSL/Ruuter.public/services/GET/rasa/rule-names.yml create mode 100644 DSL/Ruuter.public/services/GET/secrets-with-priority.yml create mode 100644 DSL/Ruuter.public/services/GET/secrets.yml create mode 100644 DSL/Ruuter.public/services/GET/service-settings.yml create mode 100644 DSL/Ruuter.public/services/GET/services/active/.guard create mode 100644 DSL/Ruuter.public/services/GET/services/draft/.guard create mode 100644 DSL/Ruuter.public/services/GET/services/inactive/.guard create mode 100644 DSL/Ruuter.public/services/GET/services/log-by-request.yml create mode 100644 DSL/Ruuter.public/services/GET/services/log-by-service.yml create mode 100644 DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml create mode 100644 DSL/Ruuter.public/services/GET/services/statistics.yml create mode 100644 DSL/Ruuter.public/services/GET/services/status.yml create mode 100644 DSL/Ruuter.public/services/GET/slots.yml create mode 100644 DSL/Ruuter.public/services/GET/steps/preferences.yml create mode 100644 DSL/Ruuter.public/services/GET/sticky/example.yml create mode 100644 DSL/Ruuter.public/services/POST/.guard create mode 100644 DSL/Ruuter.public/services/POST/auth/.guard create mode 100644 DSL/Ruuter.public/services/POST/auth/login.yml create mode 100644 DSL/Ruuter.public/services/POST/csv.yml create mode 100644 DSL/Ruuter.public/services/POST/dates/calculate-difference.yml create mode 100644 DSL/Ruuter.public/services/POST/endpoints/common.yml create mode 100644 DSL/Ruuter.public/services/POST/file/rename.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/service-settings.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/services/add.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/user-info.yml create mode 100644 DSL/Ruuter.public/services/POST/mocks/validation-mock.yml create mode 100644 DSL/Ruuter.public/services/POST/rasa/rules/add.yml create mode 100644 DSL/Ruuter.public/services/POST/saveJsonToYml.yml create mode 100644 DSL/Ruuter.public/services/POST/service-by-id.yml create mode 100644 DSL/Ruuter.public/services/POST/service-settings.yml create mode 100644 DSL/Ruuter.public/services/POST/services.yml create mode 100644 DSL/Ruuter.public/services/POST/services/active/.guard create mode 100644 DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml create mode 100644 DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml create mode 100644 DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml create mode 100644 DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml create mode 100644 DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml create mode 100644 DSL/Ruuter.public/services/POST/services/add.yml create mode 100644 DSL/Ruuter.public/services/POST/services/create-endpoint.yml create mode 100644 DSL/Ruuter.public/services/POST/services/delete-endpoint.yml create mode 100644 DSL/Ruuter.public/services/POST/services/delete.yml create mode 100644 DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml create mode 100644 DSL/Ruuter.public/services/POST/services/draft/.guard create mode 100644 DSL/Ruuter.public/services/POST/services/draft/test.tmp create mode 100644 DSL/Ruuter.public/services/POST/services/edit.yml create mode 100644 DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml create mode 100644 DSL/Ruuter.public/services/POST/services/import-services.yml create mode 100644 DSL/Ruuter.public/services/POST/services/inactive/.guard create mode 100644 DSL/Ruuter.public/services/POST/services/open-api-spec.yml create mode 100644 DSL/Ruuter.public/services/POST/services/requests/explain.yml create mode 100644 DSL/Ruuter.public/services/POST/services/resql/add.yml create mode 100644 DSL/Ruuter.public/services/POST/services/status.yml create mode 100644 DSL/Ruuter.public/services/POST/services/update-endpoint.yml create mode 100644 DSL/Ruuter.public/services/POST/steps/preferences.yml create mode 100644 DSL/Ruuter.public/services/POST/user-info.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/RBAC.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/client-input.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/file-generate.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/file-signing.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/siga.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/tara.yml create mode 100644 DSL/Ruuter.public/services/TEMPLATES/validation-template.yml create mode 100644 new.txt create mode 100644 tests/data/classification_test_queries.json 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/Ruuter.public/services/GET/.guard b/DSL/Ruuter.public/services/GET/.guard new file mode 100644 index 00000000..4fd565b6 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/.guard @@ -0,0 +1,28 @@ +check_for_cookie: + switch: + - condition: ${incoming.headers == null || incoming.headers.cookie == null} + next: guard_fail + next: authenticate + +authenticate: + template: "[#SERVICE_PROJECT_LAYER]/check-user-authority" + requestType: templates + headers: + cookie: ${incoming.headers.cookie} + result: authority_result + +check_authority_result: + switch: + - condition: ${authority_result !== "false"} + next: guard_success + next: guard_fail + +guard_success: + return: "success" + status: 200 + next: end + +guard_fail: + return: "unauthorized" + status: 401 + next: end diff --git a/DSL/Ruuter.public/services/GET/account/user-profile-settings.yml b/DSL/Ruuter.public/services/GET/account/user-profile-settings.yml new file mode 100644 index 00000000..320d4af9 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/account/user-profile-settings.yml @@ -0,0 +1,27 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'USER-PROFILE-SETTINGS'" + method: get + accepts: json + returns: json + namespace: service + +step_1: + call: reflect.mock + args: + response: + response: + - userId: EE30303039914 + forwardedChatPopupNotifications: false + forwardedChatSoundNotifications: false + forwardedChatEmailNotifications: false + newChatPopupNotifications: false + newChatSoundNotifications: false + newChatEmailNotifications: false + useAutocorrect: true + result: reflected_request + +step_2: + wrapper: false + return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/active-services.yml b/DSL/Ruuter.public/services/GET/active-services.yml new file mode 100644 index 00000000..873bb54e --- /dev/null +++ b/DSL/Ruuter.public/services/GET/active-services.yml @@ -0,0 +1,20 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ACTIVE-SERVICES'" + method: get + accepts: json + returns: json + namespace: service + +get_services_list: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-active-services-list" + result: results + +return_ok: + status: 200 + wrapper: false + return: ${results.response.body} + next: end diff --git a/DSL/Ruuter.public/services/GET/generic/.guard b/DSL/Ruuter.public/services/GET/generic/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/generic/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml b/DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml new file mode 100644 index 00000000..25c2e33b --- /dev/null +++ b/DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml @@ -0,0 +1,22 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CUSTOMER-SUPPORT-ACTIVITY'" + method: get + accepts: json + returns: json + namespace: service + +step_1: + call: reflect.mock + args: + response: + response: + - idCode: 'EE49902216518' + active: 'true' + status: 'idle' + result: reflected_request + +step_2: + wrapper: true + return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml b/DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml new file mode 100644 index 00000000..344b83cb --- /dev/null +++ b/DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml @@ -0,0 +1,26 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'USER-PROFILE-SETTINGS'" + method: get + accepts: json + returns: json + namespace: service + +step_1: + call: reflect.mock + args: + response: + data: + - userId: EE30303039914 + forwardedChatPopupNotifications: false + forwardedChatSoundNotifications: false + forwardedChatEmailNotifications: false + newChatPopupNotifications: false + newChatSoundNotifications: false + newChatEmailNotifications: false + useAutocorrect: true + result: reflected_request + +step_2: + return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml b/DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml new file mode 100644 index 00000000..7794dbb6 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml @@ -0,0 +1,19 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'USER-ROLE'" + method: get + accepts: json + returns: json + namespace: service + +step_1: + call: reflect.mock + args: + response: + response: + - "ROLE_ADMINISTRATOR" + result: reflected_request + +step_2: + return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml b/DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml new file mode 100644 index 00000000..02d95eb8 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml @@ -0,0 +1,76 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ACTIVE-CHATS'" + method: get + accepts: json + returns: json + namespace: service + +step_1: + call: reflect.mock + args: + response: + response: + - id: '22fa5630-6f92-4d50-92ba-685c872383af' + customerSupportId: '' + customerSupportDisplayName: '' + endUserId: '' + endUserFirstName: '' + endUserLastName: '' + status: 'OPEN' + created: '2023-01-17T13:18:38.808+00:00' + updated: '2023-01-17T13:19:26.348+00:00' + ended: null + endUserOs: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36' + endUserUrl: 'https://test.buerokratt.ee/' + forwardedToName: null + forwardedByUser: '' + forwardedFromCsa: '' + forwardedToCsa: '' + lastMessage: 'Suunan teid klienditeenindajale. Varuge natukene kannatust.' + contactsMessage: null + lastMessageTimestamp: '2023-01-17T13:19:26.316+00:00' + - id: '5206b7bd-0812-40a8-ae1d-3774f07f06f0' + customerSupportId: '' + customerSupportDisplayName: '' + endUserId: '' + endUserFirstName: '' + endUserLastName: '' + status: 'OPEN' + created: '2023-01-19T13:38:32.421+00:00' + updated: '2023-01-19T13:38:32.430+00:00' + ended: null + endUserOs: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36' + endUserUrl: 'https://test.buerokratt.ee/' + forwardedToName: null + forwardedByUser: '' + forwardedFromCsa: '' + forwardedToCsa: '' + lastMessage: 'aitäh' + contactsMessage: null + lastMessageTimestamp: '2022-11-23T09:33:56.803+00:00' + - id: 'b7bba1c2-b7ab-4b17-825a-2d66a7d16fc4' + customerSupportId: '' + customerSupportDisplayName: '' + endUserId: '' + endUserFirstName: '' + endUserLastName: '' + status: 'OPEN' + created: '2023-01-19T13:38:32.421+00:00' + updated: '2023-01-19T13:38:32.430+00:00' + ended: null + endUserOs: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36' + endUserUrl: 'https://test.buerokratt.ee/' + forwardedToName: null + forwardedByUser: '' + forwardedFromCsa: '' + forwardedToCsa: '' + lastMessage: 'dasnhpwa' + contactsMessage: null + lastMessageTimestamp: '2023-01-18T12:24:54.557+00:00' + result: reflected_request + +step_2: + wrapper: true + return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/userinfo.yml b/DSL/Ruuter.public/services/GET/generic/userinfo.yml new file mode 100644 index 00000000..1278132c --- /dev/null +++ b/DSL/Ruuter.public/services/GET/generic/userinfo.yml @@ -0,0 +1,29 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'USERINFO'" + method: get + accepts: json + returns: json + namespace: service + +step_1: + call: reflect.mock + args: + response: + data: + firstName: OK + lastName: TESTNUMBER + idCode: EE30303039914 + displayName: OK + JWTCreated: 1704724715000 + login: EE30303039914 + csaEmail: mail@mail.ee + authorities: + - ROLE_ADMINISTRATOR + csaTitle: OG + JWTExpirationTimestamp: 1704739715000 + result: reflected_request + +step_2: + return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/get-sticky.yml b/DSL/Ruuter.public/services/GET/get-sticky.yml new file mode 100644 index 00000000..ca906d6d --- /dev/null +++ b/DSL/Ruuter.public/services/GET/get-sticky.yml @@ -0,0 +1,40 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'GET-STICKY'" + method: get + accepts: json + returns: json + namespace: service + allowlist: + params: + - field: name + type: string + description: "Parameter 'name'" + +check_for_parameters: + switch: + - condition: ${incoming.params == null || incoming.params.name == null} + next: get_all_sticky_services + next: get_single_sticky_service + +get_single_sticky_service: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/ruuter/sticky/steps" + query: + name: ${incoming.params.name} + result: results + next: return_ok + +get_all_sticky_services: + call: http.get + args: + url: "[#SERVICE_DMAPPER]/ruuter/sticky" + result: results + next: return_ok + +return_ok: + status: 200 + return: ${results.response.body} + next: end diff --git a/DSL/Ruuter.public/services/GET/internal/domain-file.yml b/DSL/Ruuter.public/services/GET/internal/domain-file.yml new file mode 100644 index 00000000..2e42dc93 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/internal/domain-file.yml @@ -0,0 +1,40 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'DOMAIN-FILE'" + method: get + accepts: json + returns: json + namespace: service + allowlist: + headers: + - field: cookie + type: string + description: "Cookie field" + +getFileLocations: + call: http.get + args: + url: "[#SERVICE_RUUTER]/internal/return-file-locations" + headers: + cookie: ${incoming.headers.cookie} + result: fileLocations + +getDomainFile: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/read-file" + body: + file_path: ${fileLocations.response.body.response.domain_location} + result: domainFile + +convertYamlToJson: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/yaml_to_json" + body: + file: ${domainFile.response.body.file} + result: domainData + +return_value: + return: ${domainData.response.body} diff --git a/DSL/Ruuter.public/services/GET/internal/return-file-locations.yml b/DSL/Ruuter.public/services/GET/internal/return-file-locations.yml new file mode 100644 index 00000000..0ebab6ab --- /dev/null +++ b/DSL/Ruuter.public/services/GET/internal/return-file-locations.yml @@ -0,0 +1,27 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'RETURN-FILE-LOCATIONS'" + method: get + accepts: json + returns: json + namespace: service + allowlist: + headers: + - field: cookie + type: string + description: "Cookie field" + +assign_step: + assign: + locations: + rules_location: "[#DMAPPER_LOCATIONS_PATH]/data/rules.yml" + stories_location: "[#DMAPPER_LOCATIONS_PATH]/data/stories.yml" + domain_location: "[#DMAPPER_LOCATIONS_PATH]/data/domain.yml" + test_stories_location: "[#DMAPPER_LOCATIONS_PATH]/test_stories.yml" + intents_location: "[#DMAPPER_LOCATIONS_PATH]/nlu/" + regex_location: "[#DMAPPER_LOCATIONS_PATH]/regex" + training_result_location: "[#DMAPPER_LOCATIONS_PATH]/results" + config_location: "[#DMAPPER_LOCATIONS_PATH]/data/config.yml" +return_value: + return: ${locations} diff --git a/DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml b/DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml new file mode 100644 index 00000000..dab285a3 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml @@ -0,0 +1,19 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CLIENT-INPUT-VARIABLES'" + method: get + accepts: json + returns: json + namespace: service + +mock_variables: + call: reflect.mock + args: + response: + data: ['{{user.firstname}}', '{{user.lastname}}', '{{user.birthdate}}', '{{user.email}}', '{{invoice.total}}', '{{invoice.subtotal}}'] + result: mock_res + +return_result: + wrapper: false + return: ${mock_res.response.body.data} diff --git a/DSL/Ruuter.public/services/GET/mocks/service-settings.yml b/DSL/Ruuter.public/services/GET/mocks/service-settings.yml new file mode 100644 index 00000000..35482b26 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/mocks/service-settings.yml @@ -0,0 +1,19 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SERVICE-SETTINGS'" + method: get + accepts: json + returns: json + namespace: service + +call_mock: + call: reflect.mock + args: + response: + maxInputTry: 4 + result: mock_res + +return_result: + wrapper: false + return: ${mock_res.response.body} diff --git a/DSL/Ruuter.public/services/GET/mocks/validation-mock.yml b/DSL/Ruuter.public/services/GET/mocks/validation-mock.yml new file mode 100644 index 00000000..e4452555 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/mocks/validation-mock.yml @@ -0,0 +1,26 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'VALIDATION-MOCK'" + method: get + accepts: json + returns: json + namespace: service + +call_mock: + call: reflect.mock + args: + response: + project: "Bürokratt" + website: "www.kratid.ee" + result: mock_res + +call_template: + template: "[#SERVICE_PROJECT_LAYER]/validation-template" + requestType: templates + body: + response: ${mock_res.response} + result: templateResult + +return_result: + return: ${templateResult} diff --git a/DSL/Ruuter.public/services/GET/rasa/rule-names.yml b/DSL/Ruuter.public/services/GET/rasa/rule-names.yml new file mode 100644 index 00000000..5fd45b29 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/rasa/rule-names.yml @@ -0,0 +1,29 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'RULE-NAMES'" + method: get + accepts: json + returns: json + namespace: service + +getRules: + call: http.get + args: + url: "[#SERVICE_OPENSEARCH]/rules/_search?size=1000" + result: getRulesResult + +mapRulesData: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/get_rule_names" + headers: + type: 'json' + body: + hits: ${getRulesResult.response.body.hits.hits} + result: rulesData + next: returnSuccess + +returnSuccess: + return: ${rulesData.response.body} + next: end diff --git a/DSL/Ruuter.public/services/GET/secrets-with-priority.yml b/DSL/Ruuter.public/services/GET/secrets-with-priority.yml new file mode 100644 index 00000000..602eeff6 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/secrets-with-priority.yml @@ -0,0 +1,34 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SECRETS-WITH-PRIORITY'" + method: get + accepts: json + returns: json + namespace: service + +check_parameters: + switch: + - condition: ${incoming.params !== null && incoming.params.type === 'test'} + next: get_ruuter_secrets_test_priority + next: get_ruuter_secrets_prod_priority + +get_ruuter_secrets_prod_priority: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/secrets/get-with-priority" + result: results + next: return_ok + +get_ruuter_secrets_test_priority: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/secrets/get-with-priority?priority=test" + result: results + next: return_ok + +return_ok: + status: 200 + wrapper: false + return: ${results.response.body} + next: end diff --git a/DSL/Ruuter.public/services/GET/secrets.yml b/DSL/Ruuter.public/services/GET/secrets.yml new file mode 100644 index 00000000..60913c81 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/secrets.yml @@ -0,0 +1,20 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SECRETS'" + method: get + accepts: json + returns: json + namespace: service + +get_ruuter_secrets: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/secrets/get-all" + result: results + +return_ok: + status: 200 + wrapper: false + return: ${results.response.body} + next: end diff --git a/DSL/Ruuter.public/services/GET/service-settings.yml b/DSL/Ruuter.public/services/GET/service-settings.yml new file mode 100644 index 00000000..abe4ff0f --- /dev/null +++ b/DSL/Ruuter.public/services/GET/service-settings.yml @@ -0,0 +1,19 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SERVICE-SETTINGS'" + method: get + accepts: json + returns: json + namespace: service + +updateSettings: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-settings" + result: results + +returnSuccess: + wrapper: false + status: 200 + return: ${results.response.body} diff --git a/DSL/Ruuter.public/services/GET/services/active/.guard b/DSL/Ruuter.public/services/GET/services/active/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/active/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/GET/services/draft/.guard b/DSL/Ruuter.public/services/GET/services/draft/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/draft/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/GET/services/inactive/.guard b/DSL/Ruuter.public/services/GET/services/inactive/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/inactive/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/GET/services/log-by-request.yml b/DSL/Ruuter.public/services/GET/services/log-by-request.yml new file mode 100644 index 00000000..9ee096b1 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/log-by-request.yml @@ -0,0 +1,33 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'LOG-BY-REQUEST'" + method: get + accepts: json + returns: json + namespace: service + +get_services_stat: + call: http.post + args: + url: "[#SERVICE_OPENSEARCH]/services/_search/template" + body: + id: 'get-log-by-request' + params: ${incoming.params} + result: results + +check_result: + switch: + - condition: ${results.response.body.found === true} + next: return_ok + next: return_not_found + +return_not_found: + status: 404 + return: 'index not found' + next: end + +return_ok: + status: 200 + return: ${results.response.body._source} + next: end diff --git a/DSL/Ruuter.public/services/GET/services/log-by-service.yml b/DSL/Ruuter.public/services/GET/services/log-by-service.yml new file mode 100644 index 00000000..0613800a --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/log-by-service.yml @@ -0,0 +1,33 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'LOG-BY-SERVICE'" + method: get + accepts: json + returns: json + namespace: service + +get_services_stat: + call: http.post + args: + url: "[#SERVICE_OPENSEARCH]/services/_search/template" + body: + id: 'get-log-by-service' + params: ${incoming.params} + result: results + +check_result: + switch: + - condition: ${results.response.body.found === true} + next: return_ok + next: return_not_found + +return_not_found: + status: 404 + return: 'index not found' + next: end + +return_ok: + status: 200 + return: ${results.response.body._source} + next: end diff --git a/DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml b/DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml new file mode 100644 index 00000000..5fea8712 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml @@ -0,0 +1,52 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'NOK'" + method: get + accepts: json + returns: json + namespace: service + allowlist: + params: + - field: page + type: number + description: "Parameter 'page'" + - field: page_size + type: number + description: "Parameter 'page_size'" + - field: sorting + type: string + description: "Parameter 'sorting'" + - field: order + type: string + description: "Parameter 'order'" + +getFaults: + call: http.post + args: + url: "[#SERVICE_OPENSEARCH]/ruuterlog/_search" + query: + from: ${(incoming.params.page - 1) * incoming.params.page_size} + size: ${incoming.params.page_size} + _source_excludes: "stackTrace,statusCode" + body: + sort: [{ "timestamp": { "order": "${incoming.params.order}" } }] + query: + match_phrase_prefix: + dslName: + query: "services/active" + result: getFaultsResult + +mapFaultsData: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/get-faults" + headers: + type: "json" + body: + data: { "hits": "${getFaultsResult.response.body.hits.hits}" } + result: faultsData + +returnSuccess: + wrapper: false + return: ${[faultsData.response.body, getFaultsResult.response.body.hits.total.value]} diff --git a/DSL/Ruuter.public/services/GET/services/statistics.yml b/DSL/Ruuter.public/services/GET/services/statistics.yml new file mode 100644 index 00000000..6b3110fc --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/statistics.yml @@ -0,0 +1,21 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'STATISTICS'" + method: get + accepts: json + returns: json + namespace: service + +get_services_stat: + call: http.post + args: + url: "[#SERVICE_OPENSEARCH]/services/_search/template" + body: + id: 'get-services-stat' + result: results + +return_ok: + status: 200 + return: ${results.response.body.hits.hits} + next: end diff --git a/DSL/Ruuter.public/services/GET/services/status.yml b/DSL/Ruuter.public/services/GET/services/status.yml new file mode 100644 index 00000000..779451af --- /dev/null +++ b/DSL/Ruuter.public/services/GET/services/status.yml @@ -0,0 +1,24 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'STATUS'" + method: get + accepts: json + returns: json + namespace: service + allowlist: + params: + - field: id + type: string + description: "Parameter 'id'" + +get_status: + call: http.post + args: + url: "[#SERVICE_RESQL]/status" + body: + id: ${incoming.params.id} + result: res + +return_value: + return: ${res.response.body} diff --git a/DSL/Ruuter.public/services/GET/slots.yml b/DSL/Ruuter.public/services/GET/slots.yml new file mode 100644 index 00000000..d38375cd --- /dev/null +++ b/DSL/Ruuter.public/services/GET/slots.yml @@ -0,0 +1,26 @@ +declaration: + call: declare + version: 0.1 + description: "Get slots from OpenSearch" + method: get + accepts: json + returns: json + namespace: service + +getSlots: + call: http.post + args: + url: "[#SERVICE_OPENSEARCH]/domain/_search/template" + body: + id: "domain-objects-with-pagination" + params: + type: "slots" + filter: "" + from: 0 + size: 1000 + result: getSlotsResult + +returnSuccess: + return: ${getSlotsResult.response.body.hits.hits[0].fields.filtered_items[0]} + wrapper: false + next: end diff --git a/DSL/Ruuter.public/services/GET/steps/preferences.yml b/DSL/Ruuter.public/services/GET/steps/preferences.yml new file mode 100644 index 00000000..283ac696 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/steps/preferences.yml @@ -0,0 +1,72 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'PREFERENCES'" + method: get + accepts: json + returns: json + namespace: service + +get_user_info: + call: http.post + args: + url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" + contentType: plaintext + headers: + cookie: ${incoming.headers.cookie} + plaintext: "customJwtCookie" + result: res + +check_user_info_response: + switch: + - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} + next: assignIdCode + next: return_unauthorized + +assignIdCode: + assign: + idCode: ${res.response.body.idCode} + +get_user_step_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-user-step-preferences" + body: + user_id_code: ${idCode} + result: preferences + +check_preferences_response: + switch: + - condition: ${preferences.response.body.length > 0} + next: return_preferences + next: seed_default_user_preferences + +seed_default_user_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/seed-user-step-preferences" + body: + user_id_code: ${idCode} + result: seed_preferences_res + next: refetch_user_step_preferences + +refetch_user_step_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-user-step-preferences" + body: + user_id_code: ${idCode} + result: refetched_preferences + +return_refetched_preferences: + return: ${refetched_preferences.response.body[0]} + next: end + +return_preferences: + return: ${preferences.response.body[0]} + next: end + +return_unauthorized: + status: 401 + return: "unauthorized" + next: end diff --git a/DSL/Ruuter.public/services/GET/sticky/example.yml b/DSL/Ruuter.public/services/GET/sticky/example.yml new file mode 100644 index 00000000..cedef902 --- /dev/null +++ b/DSL/Ruuter.public/services/GET/sticky/example.yml @@ -0,0 +1,58 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'EXAMPLE'" + method: get + accepts: json + returns: json + namespace: service + allowlist: + headers: + - field: cookie + type: string + description: "Cookie field" + +check_for_cookie: + switch: + - condition: ${incoming.headers.cookie == null || incoming.headers.cookie == ""} + next: return_unauthorized + next: extract_request_data + +extract_request_data: + assign: + cookie: ${incoming.headers.cookie} + +extract_cookie_data: + call: http.post + args: + url: "[#SERVICE_RUUTER]/mocks/mock-custom-jwt-userinfo" + headers: + cookie: ${cookie} + body: + cookieName: "customJwtCookie" + result: jwtResult + next: allow_only_admins + +allow_only_admins: + switch: + - condition: ${jwtResult.response.body.response.authorities.includes("ROLE_ADMIN")} + next: get_data + next: return_unauthorized + +get_data: + call: reflect.mock + args: + response: + type: "mock-value" + id: 1234567 + result: reflectedRequest + next: return_value + +return_value: + return: ${reflectedRequest.response.body} + next: end + +return_unauthorized: + status: 401 + return: "unauthorized" + next: end diff --git a/DSL/Ruuter.public/services/POST/.guard b/DSL/Ruuter.public/services/POST/.guard new file mode 100644 index 00000000..4fd565b6 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/.guard @@ -0,0 +1,28 @@ +check_for_cookie: + switch: + - condition: ${incoming.headers == null || incoming.headers.cookie == null} + next: guard_fail + next: authenticate + +authenticate: + template: "[#SERVICE_PROJECT_LAYER]/check-user-authority" + requestType: templates + headers: + cookie: ${incoming.headers.cookie} + result: authority_result + +check_authority_result: + switch: + - condition: ${authority_result !== "false"} + next: guard_success + next: guard_fail + +guard_success: + return: "success" + status: 200 + next: end + +guard_fail: + return: "unauthorized" + status: 401 + next: end diff --git a/DSL/Ruuter.public/services/POST/auth/.guard b/DSL/Ruuter.public/services/POST/auth/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/auth/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/POST/auth/login.yml b/DSL/Ruuter.public/services/POST/auth/login.yml new file mode 100644 index 00000000..30771935 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/auth/login.yml @@ -0,0 +1,101 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'LOGIN'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: login + type: string + description: "Body field 'login'" + - field: password + type: string + description: "Body field 'password'" + +check_for_required_parameters: + switch: + - condition: ${incoming.body.login == null || incoming.body.password == null} + next: return_incorrect_request + next: extract_request_data + +extract_request_data: + assign: + login: ${incoming.body.login} + password: ${incoming.body.password} + next: login_user + +login_user: + call: http.post + args: + url: "[#SERVICE_USERS_RESQL]/get-user-by-login" + body: + login: ${login} + password: ${password} + result: results + next: check_login_result + +check_login_result: + switch: + - condition: ${results.response.body.length != 0} + next: get_session_length + next: return_user_not_found + +get_session_length: + call: http.post + args: + url: "[#SERVICE_USERS_RESQL]/get-configuration" + body: + key: "session_length" + result: session_result + next: check_session_length_result + +check_session_length_result: + switch: + - condition: ${session_result.response.body.length != 0} + next: generate_cookie + next: return_session_length_not_found + +generate_cookie: + call: http.post + args: + url: "[#SERVICE_TIM]/jwt/custom-jwt-generate" + body: + JWTName: "customJwtCookie" + expirationInMinutes: ${session_result.response.body[0].value} + content: ${results.response.body[0]} + result: cookie_result + next: assign_cookie + +assign_cookie: + assign: + setCookie: + customJwtCookie: ${cookie_result.response.body.token} + Domain: "[#DOMAIN]" + Secure: true + HttpOnly: true + SameSite: "Lax" + next: return_value + +return_value: + headers: + Set-Cookie: ${setCookie} + return: ${cookie_result.response.body.token} + next: end + +return_session_length_not_found: + status: 400 + return: "Could not fetch session length" + next: end + +return_user_not_found: + status: 400 + return: "User Not Found" + next: end + +return_incorrect_request: + status: 400 + return: "Required parameter(s) missing" + next: end diff --git a/DSL/Ruuter.public/services/POST/csv.yml b/DSL/Ruuter.public/services/POST/csv.yml new file mode 100644 index 00000000..52e1f177 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/csv.yml @@ -0,0 +1,50 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CSV'" + method: post + accepts: json + returns: data + namespace: service + allowlist: + body: + - field: data + type: string + description: "Body field 'data'" + - field: del + type: string + description: "Body field 'del'" + - field: qul + type: string + description: "Body field 'qul'" + +check_for_required_parameters: + switch: + - condition: ${incoming.body == null} + next: return_incorrect_request + next: get_csv + +get_csv: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/get-csv" + headers: + type: 'csv' + body: + data: ${incoming.body.data} + del: ${incoming.body.del} + qul: ${incoming.body.qul} + layout: false + result: result + +return_value: + wrapper: false + headers: + Content-disposition: "attachment;filename=result.csv" + return: ${result.response.body.response} + next: end + +return_incorrect_request: + status: 400 + return: 'missing parameters' + next: end diff --git a/DSL/Ruuter.public/services/POST/dates/calculate-difference.yml b/DSL/Ruuter.public/services/POST/dates/calculate-difference.yml new file mode 100644 index 00000000..2c5afd4e --- /dev/null +++ b/DSL/Ruuter.public/services/POST/dates/calculate-difference.yml @@ -0,0 +1,82 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CALCULATE-DIFFERENCE'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: startDate + type: string + description: "Body field 'startDate'" + - field: endDate + type: string + description: "Body field 'endDate'" + params: + - field: outputType + type: string + description: "Parameter 'outputType'" + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: return_incorrect_request + next: check_for_required_parameters + +check_for_required_parameters: + switch: + - condition: ${incoming.body.startDate == null} + next: return_incorrect_request + next: extract_request_data + +extract_request_data: + assign: + outputType: ${incoming.params.outputType ?? 'days'} + startDate: ${incoming.body.startDate} + endDate: ${incoming.body.endDate ?? new Date().toISOString()} + +check_is_end_date_greater_than_start_date: + switch: + - condition: ${new Date(endDate) < new Date(startDate)} + next: return_incorrect_date + +check_is_output_type_valid: + switch: + - condition: ${outputType !== null && !['years','months','hours','days','minutes', 'seconds'].includes(outputType)} + next: return_incorrect_output_type + +calculate_difference: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/calculate-date-difference" + headers: + type: "json" + body: + startDate: ${startDate} + endDate: ${endDate} + outputType: ${outputType} + layout: false + result: result + next: return_value + +return_value: + status: 200 + return: ${result.response.body} + next: end + +return_incorrect_date: + status: 400 + return: "Start date can not be greater than the end date/ today" + next: end + +return_incorrect_output_type: + status: 400 + return: "Output type must be: years, months, hours, days, minutes or seconds" + next: end + +return_incorrect_request: + status: 400 + return: "Start date is required" + next: end diff --git a/DSL/Ruuter.public/services/POST/endpoints/common.yml b/DSL/Ruuter.public/services/POST/endpoints/common.yml new file mode 100644 index 00000000..611faca7 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/endpoints/common.yml @@ -0,0 +1,48 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'Common'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: pagination + type: string + description: "Body field 'pagination'" + - field: page + type: string + description: "Body field 'page'" + - field: pageSize + type: string + description: "Body field 'pageSize'" + - field: sorting + type: string + description: "Body field 'sorting'" + - field: search + type: string + description: "Body field 'search'" + +extract_request_data: + assign: + pagination: ${incoming.body.pagination} + page: ${incoming.body.page} + pageSize: ${incoming.body.pageSize} + sorting: ${incoming.body.sorting} + search: ${incoming.body.search} + +get_common_endpoints: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/get_common_endpoints" + body: + pagination: ${pagination} + page: ${page} + page_size: ${pageSize} + sorting: ${sorting} + search: ${search} + result: res + +return_result: + return: ${res.response.body} diff --git a/DSL/Ruuter.public/services/POST/file/rename.yml b/DSL/Ruuter.public/services/POST/file/rename.yml new file mode 100644 index 00000000..c0e7b1c6 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/file/rename.yml @@ -0,0 +1,42 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'RENAME'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: current_path + type: string + description: "Body field 'current_path'" + - field: new_path + type: string + description: "Body field 'new_path'" + +check_for_body: + switch: + - condition: ${incoming.body == null || incoming.body.current_path == null || incoming.body.new_path == null} + next: return_incorrect_request + +rename_file: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/rename" + headers: + type: 'json' + body: + current_path: ${incoming.body.current_path} + new_path: ${incoming.body.new_path} + layout: false + result: result + +return_value: + status: 200 + return: ${result.response.body} + next: end + +return_incorrect_request: + status: 400 + return: "Start date is required" diff --git a/DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml b/DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml new file mode 100644 index 00000000..eabaf3f8 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml @@ -0,0 +1,50 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'RBAC-MOCK'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: allowedRoles + type: object + description: "Body field 'allowedRoles'" + - field: userId + type: string + description: "Body field 'userId'" + + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: missing_parameter + +extract_request_data: + assign: + userId: ${incoming.body.userId} + allowedRoles: ${incoming.body.allowedRoles.sort()} + +check_for_required_parameters: + switch: + - condition: ${userId === null || allowedRoles === null} + next: missing_parameter + next: fetch_user_roles_from_db + +fetch_user_roles_from_db: + call: reflect.mock + args: + response: + isAllowed: TRUE + result: result + +return_value: + status: 200 + return: "${result.response.body}" + next: end + +missing_parameter: + status: 400 + return: "userId, allowedRoles - missing" + next: end diff --git a/DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml b/DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml new file mode 100644 index 00000000..261e2273 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml @@ -0,0 +1,136 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CALCULATE-DIFFERENCE'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: startDate + type: string + description: "Body field 'startDate'" + - field: endDate + type: string + description: "Body field 'endDate'" + params: + - field: outputType + type: string + description: "Parameter 'outputType'" + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: return_incorrect_request + next: check_for_required_parameters + +check_for_required_parameters: + switch: + - condition: ${incoming.body.startDate == null} + next: return_incorrect_request + next: extract_request_data + +extract_request_data: + assign: + outputType: ${incoming.params.outputType ?? 'days'} + startDate: ${incoming.body.startDate} + endDate: ${incoming.body.endDate ?? new Date().toISOString()} + +check_is_end_date_greater_than_start_date: + switch: + - condition: ${new Date(endDate) < new Date(startDate)} + next: return_incorrect_date + +check_is_output_type_valid: + switch: + - condition: ${outputType !== null && !['years','months','hours','days','minutes', 'seconds'].includes(outputType)} + next: return_incorrect_output_type + +calculate_difference: + switch: + - condition: ${outputType === 'years'} + next: calculate_difference_in_years + - condition: ${outputType === 'months'} + next: calculate_difference_in_months + - condition: ${outputType === 'hours'} + next: calculate_difference_in_hours + - condition: ${outputType === 'minutes'} + next: calculate_difference_in_minutes + - condition: ${outputType === 'seconds'} + next: calculate_difference_in_seconds + next: calculate_difference_in_days + +calculate_difference_in_years: + call: reflect.mock + args: + response: { + result: 0 + } + result: result + next: return_value + +calculate_difference_in_months: + call: reflect.mock + args: + response: { + result: 11 + } + result: result + next: return_value + +calculate_difference_in_days: + call: reflect.mock + args: + response: { + result: 1 + } + result: result + next: return_value + +calculate_difference_in_hours: + call: reflect.mock + args: + response: { + result: 24 + } + result: result + next: return_value + +calculate_difference_in_minutes: + call: reflect.mock + args: + response: { + result: 59 + } + result: result + next: return_value + +calculate_difference_in_seconds: + call: reflect.mock + args: + response: { + result: 201 + } + result: result + next: return_value + +return_value: + status: 200 + return: ${result.response.body} + next: end + +return_incorrect_date: + status: 400 + return: "Start date can not be greater than the end date/ today" + next: end + +return_incorrect_output_type: + status: 400 + return: "Output type must be: years, months, hours, days, minutes or seconds" + next: end + +return_incorrect_request: + status: 400 + return: "Start date is required" + next: end diff --git a/DSL/Ruuter.public/services/POST/mocks/service-settings.yml b/DSL/Ruuter.public/services/POST/mocks/service-settings.yml new file mode 100644 index 00000000..c22da07f --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/service-settings.yml @@ -0,0 +1,19 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SERVICE-SETTINGS'" + method: post + accepts: json + returns: json + namespace: service + +call_mock: + call: reflect.mock + args: + response: + maxInputTry: 4 + result: mock_res + +return_result: + wrapper: false + return: ${mock_res.response.body} diff --git a/DSL/Ruuter.public/services/POST/mocks/services/add.yml b/DSL/Ruuter.public/services/POST/mocks/services/add.yml new file mode 100644 index 00000000..3b1e46c2 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/services/add.yml @@ -0,0 +1,44 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ADD'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: name + type: string + description: "Body field 'name'" + - field: description + type: string + description: "Body field 'description'" + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: return_incorrect_request + next: check_for_required_parameters + +check_for_required_parameters: + switch: + - condition: ${incoming.body.name == null || incoming.body.description == null} + next: return_incorrect_request + next: service_add + +service_add: + call: reflect.mock + args: + response: {} + result: createdService + +return_value: + status: 201 + wrapper: FALSE + return: "" + next: end + +return_incorrect_request: + status: 400 + return: "Required parameter(s) missing" diff --git a/DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml b/DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml new file mode 100644 index 00000000..e8ea3f87 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml @@ -0,0 +1,17 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'OPEN-API-SPEC-MOCK'" + method: post + accepts: json + returns: json + namespace: service + +get_message: + call: http.get + args: + url: https://petstore3.swagger.io/api/v3/openapi.json + result: res + +return_value: + return: ${res.response.body} diff --git a/DSL/Ruuter.public/services/POST/mocks/user-info.yml b/DSL/Ruuter.public/services/POST/mocks/user-info.yml new file mode 100644 index 00000000..ac0b322a --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/user-info.yml @@ -0,0 +1,52 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'USER-INFO'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: isTokenExpired + type: boolean + description: "Body field 'isTokenExpired'" + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: return_user_info_mock + next: do_decision + +do_decision: + switch: + - condition: ${incoming.body.isTokenExpired === true} + next: return_unauthorized + next: return_user_info_mock + +return_unauthorized: + status: 401 + return: "Unauthorized" + next: end + +return_user_info_mock: + call: reflect.mock + args: + response: + sub: "" + firstName: "MARY ÄNN" + idCode: "EE60001019906" + displayName: "MARY ÄNN" + iss: "test.buerokratt.ee" + exp: 1670250948 + login: "EE60001019906" + iat: 1670243748 + jti: "e14a5084-3b30-4a55-8720-c2ee22f43c2c" + authorities: [ + "ROLE_ADMINISTRATOR" + ] + result: reflected_request + next: return_value + +return_value: + return: ${reflected_request.response} diff --git a/DSL/Ruuter.public/services/POST/mocks/validation-mock.yml b/DSL/Ruuter.public/services/POST/mocks/validation-mock.yml new file mode 100644 index 00000000..fb8cb4a7 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/mocks/validation-mock.yml @@ -0,0 +1,26 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'VALIDATION-MOCK'" + method: post + accepts: json + returns: json + namespace: service + +call_mock: + call: reflect.mock + args: + response: + project: "Bürokratt" + website: "www.kratid.ee" + result: mock_res + +call_template: + template: "[#SERVICE_PROJECT_LAYER]/validation-template" + requestType: templates + body: + response: ${mock_res.response} + result: templateResult + +return_result: + return: ${templateResult} diff --git a/DSL/Ruuter.public/services/POST/rasa/rules/add.yml b/DSL/Ruuter.public/services/POST/rasa/rules/add.yml new file mode 100644 index 00000000..c41581ab --- /dev/null +++ b/DSL/Ruuter.public/services/POST/rasa/rules/add.yml @@ -0,0 +1,129 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ADD'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: data + type: object + description: "Body field 'data'" + headers: + - field: cookie + type: string + description: "Cookie field" + +assign_values: + assign: + body: ${incoming.body.data} + +validateRules: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/validate/validate-stories-rules" + body: + story: ${body} + category: "rules" + result: validateRulesResult + +validateRulesCheck: + switch: + - condition: ${validateRulesResult.response.body.result == true} + next: getRuleNames + next: returnDuplicateIntentOrEntity + +getRuleNames: + call: http.get + args: + url: "[#SERVICE_RUUTER]/rasa/rule-names" + headers: + cookie: ${incoming.headers.cookie} + result: ruleResult + +validateRuleName: + switch: + - condition: ${ruleResult.response.body.response.names == null} + next: getFileLocations + - condition: ${!ruleResult.response.body.response.names.includes(body.id)} + next: getFileLocations + next: returnStoryExists + +getFileLocations: + call: http.get + args: + url: "[#SERVICE_RUUTER]/internal/return-file-locations" + headers: + cookie: ${incoming.headers.cookie} + result: fileLocations + +getRulesFile: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/read-file" + body: + file_path: ${fileLocations.response.body.response.rules_location} + result: ruleFile + +convertYamlToJson: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/yaml_to_json" + body: + file: ${ruleFile.response.body.file} + result: rulesData + +mergeRules: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/merge" + body: + array1: ${rulesData.response.body.rules ?? []} + array2: ${[body]} + iteratee: "rule" + result: mergedRules + +convertJsonToYaml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json-to-yaml-stories" + headers: + content-type: "application/json" + body: + rules: ${mergedRules.response.body.array} + result: rulesYaml + +saveRulesFile: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: ${fileLocations.response.body.response.rules_location} + content: ${rulesYaml.response.body.json} + result: fileResult + next: updateOpenSearch + +updateOpenSearch: + call: http.post + args: + url: "[#SERVICES_PIPELINE]/bulk/rules/rule" + body: + input: ${rulesYaml.response.body.json} + result: updateSearchResult + next: returnSuccess + +returnSuccess: + return: "Rule added" + next: end + +returnRuleExists: + return: "Rule exists" + status: 409 + next: end + +returnDuplicateIntentOrEntity: + return: "Rule may not have duplicate consecutive intents or entities" + status: 406 + next: end diff --git a/DSL/Ruuter.public/services/POST/saveJsonToYml.yml b/DSL/Ruuter.public/services/POST/saveJsonToYml.yml new file mode 100644 index 00000000..4305cb28 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/saveJsonToYml.yml @@ -0,0 +1,39 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SAVEJSONTOYML'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: result + type: string + description: "Body field 'result'" + params: + - field: location + type: string + description: "Parameter 'location'" + +toYml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data" + body: + data: ${incoming.body.result} + result: r + +saveFile: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: ${incoming.params.location} + content: ${r.response.body.yaml} + result: fileResult + +saved_seccessfully: + reloadDsl: true + return: "" + next: end diff --git a/DSL/Ruuter.public/services/POST/service-by-id.yml b/DSL/Ruuter.public/services/POST/service-by-id.yml new file mode 100644 index 00000000..45fd1b03 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/service-by-id.yml @@ -0,0 +1,90 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SERVICE-BY-ID'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + header: + - field: cookie + type: string + description: "Cookie field" + body: + - field: id + type: string + description: "Body field 'id'" + - field: search + type: string + description: "Body field 'search'" + +extract_request_data: + assign: + id: ${incoming.body.id} + search: ${incoming.body.search} + +get_user_info: + call: http.post + args: + url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" + contentType: plaintext + headers: + cookie: ${incoming.headers.cookie} + plaintext: "customJwtCookie" + result: res + +check_user_info_response: + switch: + - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} + next: assignIdCode + next: return_unauthorized + +assignIdCode: + assign: + idCode: ${res.response.body.idCode} + +get_service_by_id: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-service-by-id" + body: + id: ${id} + result: service_results + +get_endpoints_by_service_id: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/get_endpoints_by_service_id" + body: + id: ${id} + user_id_code: ${idCode} + search: ${search} + result: endpoints_results + +prepare_results: + assign: + results: + id: ${service_results.response.body[0].id} + name: ${service_results.response.body[0].name} + description: ${service_results.response.body[0].description} + slot: ${service_results.response.body[0].slot} + examples: ${service_results.response.body[0].examples} + entities: ${service_results.response.body[0].entities} + state: ${service_results.response.body[0].state} + type: ${service_results.response.body[0].type} + isCommon: ${service_results.response.body[0].isCommon} + structure: ${service_results.response.body[0].structure} + endpoints: ${endpoints_results.response.body} + serviceId: ${service_results.response.body[0].serviceId} + +return_ok: + status: 200 + wrapper: false + return: ${results} + next: end + +return_unauthorized: + status: 401 + return: "unauthorized" + next: end diff --git a/DSL/Ruuter.public/services/POST/service-settings.yml b/DSL/Ruuter.public/services/POST/service-settings.yml new file mode 100644 index 00000000..af9021c4 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/service-settings.yml @@ -0,0 +1,29 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SERVICE-SETTINGS'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: name + type: string + description: "Body field 'name'" + - field: value + type: string + description: "Body field 'value'" + +updateSettings: + call: http.post + args: + url: "[#SERVICE_RESQL]/update-settings" + body: + name: ${incoming.body.name} + value: ${incoming.body.value} + result: getResult + +returnSuccess: + status: 200 + return: 'ok' diff --git a/DSL/Ruuter.public/services/POST/services.yml b/DSL/Ruuter.public/services/POST/services.yml new file mode 100644 index 00000000..8188fac2 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services.yml @@ -0,0 +1,43 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SERVICES'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: page + type: number + description: "Body field 'page'" + - field: page_size + type: number + description: "Body field 'page_size'" + - field: sorting + type: string + description: "Body field 'sorting'" + - field: is_common + type: boolean + description: "Body field 'is_common'" + +get_services_list: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-services-list" + body: + page: ${incoming.body.page} + page_size: ${incoming.body.page_size} + sorting: ${incoming.body.sorting} + is_common: ${incoming.body.is_common} + limit: 400 + result: services_res + +assign_services_result: + assign: + services: ${services_res.response.body} + +return_ok: + status: 200 + return: ${[services]} + next: end diff --git a/DSL/Ruuter.public/services/POST/services/active/.guard b/DSL/Ruuter.public/services/POST/services/active/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/active/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml b/DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml new file mode 100644 index 00000000..ff07aba5 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml @@ -0,0 +1,65 @@ +declaration: + call: declare + version: 0.1 + description: Teenuse test, mis kinnitab kasutaja broneeringu ja tagastab + sisestatud väärtuse. + method: post + accepts: json + returns: json + namespace: service + allowList: + body: + - field: chatId + type: string + description: The chat ID for the message + - field: authorId + type: string + description: The author ID for the message + - field: input + type: object + description: The Input from the user +prepare: + assign: + chatId: ${incoming.body.chatId} + authorId: ${incoming.body.authorId} + input: ${incoming.body.input} + buttons: [] + res: + result: "" + next: assign_1 +assign_1: + assign: + entity: ${incoming.body.input[0]} + next: sõnum_kliendile_1 +sõnum_kliendile_1: + assign: + res: + result: "Teie broneering on registreeritud. Kohtumiseni! Entity: ${entity}" + next: teenuse_lõpetamine_1 +teenuse_lõpetamine_1: + template: "[#SERVICE_PROJECT_LAYER]/end-conversation" + requestType: templates + body: + message: "" + result: teenuse_lõpetamine_1_result + next: format_messages +format_messages: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" + headers: + type: json + body: + data: + botMessages: ${[res]} + chatId: ${chatId ?? ''} + authorId: ${authorId ?? ''} + authorFirstName: "" + authorLastName: "" + authorTimestamp: ${new Date().toISOString()} + created: ${new Date().toISOString()} + buttons: ${buttons ?? []} + result: formatMessage + next: service-end +service-end: + return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml b/DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml new file mode 100644 index 00000000..18b213d7 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml @@ -0,0 +1,60 @@ +declaration: + call: declare + version: 0.1 + description: Teenuse test, mis teavitab kasutajat, et tema kalastusluba vajab uuendamist. + method: post + accepts: json + returns: json + namespace: service + allowList: + body: + - field: chatId + type: string + description: The chat ID for the message + - field: authorId + type: string + description: The author ID for the message + - field: input + type: object + description: The Input from the user +prepare: + assign: + chatId: ${incoming.body.chatId} + authorId: ${incoming.body.authorId} + input: ${incoming.body.input} + buttons: [] + res: + result: "" + next: send_message_to_client_1 +send_message_to_client_1: + assign: + res: + result: Su kalastusluba vajab uuendamist! + next: end_service_1 +end_service_1: + template: "[#SERVICE_PROJECT_LAYER]/end-conversation" + requestType: templates + body: + message: "" + result: end_service_1_result + next: format_messages +format_messages: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" + headers: + type: json + body: + data: + botMessages: ${[res]} + chatId: ${chatId ?? ''} + authorId: ${authorId ?? ''} + authorFirstName: "" + authorLastName: "" + authorTimestamp: ${new Date().toISOString()} + created: ${new Date().toISOString()} + buttons: ${buttons ?? []} + result: formatMessage + next: service-end +service-end: + return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml b/DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml new file mode 100644 index 00000000..df123c09 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml @@ -0,0 +1,63 @@ +declaration: + call: declare + version: 0.1 + description: Kasutaja soovib infot koolivaheaegade kohta antud õppeaastal. + method: post + accepts: json + returns: json + namespace: service + allowList: + body: + - field: chatId + type: string + description: The chat ID for the message + - field: authorId + type: string + description: The author ID for the message + - field: input + type: object + description: The Input from the user +prepare: + assign: + chatId: ${incoming.body.chatId} + authorId: ${incoming.body.authorId} + input: ${incoming.body.input} + buttons: [] + res: + result: "" + next: sõnum_kliendile_1 +sõnum_kliendile_1: + assign: + res: + result: E 20. oktoober 2025 - P 26. oktoober 2025 Sügisvaheaeg E 22. detsember + 2025 - P 11. jaanuar 2026 Jõuluvaheaeg E 23. veebruar 2026 - P 01. märts + 2026 Talvevaheaeg E 13. aprill 2026 - P 19. aprill 2026 Kevadvaheaeg K + 17. juuni 2026 - E 31. august 2026 Suvevaheaeg + next: teenuse_lõpetamine_1 +teenuse_lõpetamine_1: + template: "[#SERVICE_PROJECT_LAYER]/end-conversation" + requestType: templates + body: + message: "" + result: teenuse_lõpetamine_1_result + next: format_messages +format_messages: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" + headers: + type: json + body: + data: + botMessages: ${[res]} + chatId: ${chatId ?? ''} + authorId: ${authorId ?? ''} + authorFirstName: "" + authorLastName: "" + authorTimestamp: ${new Date().toISOString()} + created: ${new Date().toISOString()} + buttons: ${buttons ?? []} + result: formatMessage + next: service-end +service-end: + return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml b/DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml new file mode 100644 index 00000000..67373c3d --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml @@ -0,0 +1,61 @@ +declaration: + call: declare + version: 0.1 + description: Lihtne testteenus, mis küsib kasutajalt valikvastustega küsimusi ja + suunab vastavalt vastusele järgmisse sammu. + method: post + accepts: json + returns: json + namespace: service + allowList: + body: + - field: chatId + type: string + description: The chat ID for the message + - field: authorId + type: string + description: The author ID for the message + - field: input + type: object + description: The Input from the user +prepare: + assign: + chatId: ${incoming.body.chatId} + authorId: ${incoming.body.authorId} + input: ${incoming.body.input} + buttons: [] + res: + result: "" + next: multi_choice_question_1 +multi_choice_question_1: + assign: + buttons: + - id: "1" + title: Jah + payload: "#service, /POST/services/active/lihtne_teenus_test_mcq_1_0" + - id: "2" + title: Ei + payload: "#service, /POST/services/active/lihtne_teenus_test_mcq_1_1" + res: + result: lithsa teenuse küsims + next: format_messages +format_messages: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" + headers: + type: json + body: + data: + botMessages: ${[res]} + chatId: ${chatId ?? ''} + authorId: ${authorId ?? ''} + authorFirstName: "" + authorLastName: "" + authorTimestamp: ${new Date().toISOString()} + created: ${new Date().toISOString()} + buttons: ${buttons ?? []} + result: formatMessage + next: service-end +service-end: + return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml b/DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml new file mode 100644 index 00000000..0f858468 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml @@ -0,0 +1,82 @@ +declaration: + call: declare + version: 0.1 + description: Description placeholder for 'customer_feedback' + method: post + accepts: json + returns: json + namespace: service + allowList: + body: + - field: chatId + type: string + description: The chat ID for the message + - field: authorId + type: string + description: The author ID for the message + - field: input + type: object + description: The Input from the user +prepare: + assign: + chatId: ${incoming.body.chatId} + authorId: ${incoming.body.authorId} + input: ${incoming.body.input} + buttons: [] + res: + result: "" + next: assign_1 +assign_1: + assign: + customer_rating: ${incoming.body.input[0]} + next: condition_1 +condition_1: + switch: + - condition: ${customer_rating > 3} + next: send_message_to_client_1 + next: send_message_to_client_2 +send_message_to_client_1: + assign: + res: + result: Thank you for your positive feedback with ${customer_rating} + next: end_service_1 +send_message_to_client_2: + assign: + res: + result: Thank you for your feedback with rating ${customer_rating} . and we are + trying to improve the system + next: end_service_2 +end_service_1: + template: "[#SERVICE_PROJECT_LAYER]/end-conversation" + requestType: templates + body: + message: "" + result: end_service_1_result + next: format_messages +end_service_2: + template: "[#SERVICE_PROJECT_LAYER]/end-conversation" + requestType: templates + body: + message: "" + result: end_service_2_result + next: format_messages +format_messages: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" + headers: + type: json + body: + data: + botMessages: ${[res]} + chatId: ${chatId ?? ''} + authorId: ${authorId ?? ''} + authorFirstName: "" + authorLastName: "" + authorTimestamp: ${new Date().toISOString()} + created: ${new Date().toISOString()} + buttons: ${buttons ?? []} + result: formatMessage + next: service-end +service-end: + return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/add.yml b/DSL/Ruuter.public/services/POST/services/add.yml new file mode 100644 index 00000000..a9a81ec4 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/add.yml @@ -0,0 +1,177 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ADD'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + header: + - field: cookie + type: string + description: "Cookie field" + body: + - field: content + type: string + description: "Body field 'content'" + - field: description + type: string + description: "Body field 'description'" + - field: entities + type: object + description: "Body field 'Entities'" + - field: examples + type: object + description: "Body field 'Examples'" + - field: isCommon + type: boolean + description: "Body field 'isCommon'" + - field: name + type: string + description: "Body field 'name'" + - field: serviceId + type: string + description: "Body field 'serviceId'" + - field: structure + type: object + description: "Body field 'structure'" + - field: type + type: string + description: "Body field 'type'" + - field: updateServiceDb + type: boolean + description: "Body field 'updateServiceDb'" + - field: state + type: string + description: "Body field 'state'" + +check_for_required_parameters: + switch: + - condition: ${incoming.body.name == null || incoming.body.description == null || incoming.body.type == null || incoming.body.content == null || incoming.body.serviceId == null || incoming.body.isCommon == null || incoming.body.structure == null} + next: return_incorrect_request + next: extract_request_data + +extract_request_data: + assign: + name: ${incoming.body.name} + description: ${incoming.body.description} + slot: ${""} + entities: ${incoming.body.entities} + examples: ${incoming.body.examples} + type: ${incoming.body.type.toUpperCase()} + content: ${incoming.body.content} + serviceId: ${incoming.body.serviceId} + isCommon: ${incoming.body.isCommon} + structure: ${incoming.body.structure} + state: ${incoming.body.state} + next: check_if_update_service_db + +check_if_update_service_db: + switch: + - condition: ${incoming.body.updateServiceDb === true} + next: check_if_name_exists + next: delete_all_mcq_files + +check_if_name_exists: + call: http.post + args: + url: "[#SERVICE_RESQL]/services/check_name_exist" + body: + name: ${name} + result: name_exists_res + next: check_name_exists_result + +check_name_exists_result: + switch: + - condition: ${name_exists_res.response.body[0].nameExists} + next: return_name_already_exists + next: service_add + +service_add: + call: http.post + args: + url: "[#SERVICE_RESQL]/add" + body: + name: ${name} + description: ${description} + slot: ${slot} + entities: ${entities} + examples: ${examples} + ruuter_type: ${type} + service_id: ${serviceId} + is_common: ${isCommon} + state: ${state} + structure: ${structure} + result: createdService + next: convert_json_content_to_yml + +delete_all_mcq_files: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" + body: + path: "[#RUUTER_SERVICES_PATH]/${type}/services/draft" + keyword: "${name}_" + result: deleteRes + +convert_json_content_to_yml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data" + body: + data: ${content} + result: ymlResult + +check_for_type: + switch: + - condition: ${type === 'GET'} + next: add_get_dsl + next: add_post_dsl + +add_get_dsl: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: "[#RUUTER_SERVICES_GET_PATH]/draft/${name}.tmp" + content: ${ymlResult.response.body.yaml} + result: results + next: check_result + +add_post_dsl: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: "[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp" + content: ${ymlResult.response.body.yaml} + result: results + next: check_result + +check_result: + switch: + - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} + next: return_ok + next: return_bad_request + +return_ok: + reloadDsl: true + status: 200 + return: ${results.response.body.message} + next: end + +return_bad_request: + status: 400 + return: ${results.response.body.message} + next: end + +return_incorrect_request: + status: 400 + return: "Required parameter(s) missing" + next: end + +return_name_already_exists: + status: 409 + return: "Service name already exists" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/create-endpoint.yml b/DSL/Ruuter.public/services/POST/services/create-endpoint.yml new file mode 100644 index 00000000..2c73fd45 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/create-endpoint.yml @@ -0,0 +1,46 @@ +declaration: + call: declare + version: 0.1 + description: "Creates a new endpoint" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: endpointId + type: string + description: "Endpoint UUID" + - field: name + type: string + description: "Endpoint name" + - field: type + type: string + description: "Endpoint type" + - field: isCommon + type: boolean + description: "Endpoint common status" + - field: serviceId + type: string + description: "Service UUID" + - field: definitions + type: object + description: "Endpoint definitions" + +create_endpoint: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/create_endpoint" + body: + endpointId: ${incoming.body.endpointId} + name: ${incoming.body.name} + type: ${incoming.body.type} + isCommon: ${incoming.body.isCommon} + serviceId: ${incoming.body.serviceId ?? ''} + definitions: ${incoming.body.definitions} + result: res + +return_ok: + status: 200 + return: "Endpoint created" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/delete-endpoint.yml b/DSL/Ruuter.public/services/POST/services/delete-endpoint.yml new file mode 100644 index 00000000..f585495e --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/delete-endpoint.yml @@ -0,0 +1,34 @@ +declaration: + call: declare + version: 0.1 + description: "Deletes an endpoint" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: id + type: string + description: "Endpoint UUID" + +delete_endpoint: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/delete_endpoint" + body: + id: ${incoming.body.id} + result: res + +remove_from_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/remove_endpoint_from_preferences" + body: + endpoint_id: ${incoming.body.id} + result: preferences_res + +return_ok: + status: 200 + return: "Endpoint deleted" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/delete.yml b/DSL/Ruuter.public/services/POST/services/delete.yml new file mode 100644 index 00000000..c9a7bef5 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/delete.yml @@ -0,0 +1,155 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'DELETE'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + headers: + - field: cookie + type: string + description: "Cookie field" + body: + - field: id + type: string + description: "Body field 'id'" + - field: type + type: string + description: "Body field 'type'" + +check_for_required_parameters: + switch: + - condition: ${incoming.body.id == null || incoming.body.type == null} + next: return_incorrect_request + next: extract_request_data + +extract_request_data: + assign: + id: ${incoming.body.id} + ruuter_type: ${incoming.body.type.toUpperCase()} + next: get_service_name + +get_service_name: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-service-name-by-id" + body: + id: ${id} + result: name_res + next: get_current_status + +get_current_status: + call: http.post + args: + url: "[#SERVICE_RESQL]/status" + body: + id: ${id} + result: status_res + next: check_status + +check_status: + switch: + - condition: ${status_res.response.body[0].currentState === 'active'} + next: return_cannot_delete_active_service + - condition: ${status_res.response.body[0].currentState === 'ready'} + next: assign_draft_path + next: assign_old_path + +assign_old_path: + assign: + old_file_status_path: ${status_res.response.body[0].currentState} + next: delete_service + +assign_draft_path: + assign: + old_file_status_path: "draft" + next: delete_service + +delete_service: + call: http.post + args: + url: "[#SERVICE_RESQL]/delete-service" + body: + id: ${id} + result: res + next: check_service_file_exists + +check_service_file_exists: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/exists" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name_res.response.body[0].name}.tmp" + result: service_file_exists + next: validate_service_file_exists + +validate_service_file_exists: + switch: + - condition: ${!!service_file_exists.response.body} + next: delete_deactivated_service + next: delete_endpoints_by_service_id + +delete_deactivated_service: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/delete" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name_res.response.body[0].name}.tmp" + result: results + next: check_result + +check_result: + switch: + - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} + next: delete_endpoints_by_service_id + next: return_bad_request + +delete_endpoints_by_service_id: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/delete_endpoints_by_service_id" + body: + serviceId: ${id} + result: delete_endpoint_results + next: remove_service_endpoints_from_preferences + +remove_service_endpoints_from_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/remove_service_endpoints_from_preferences" + body: + serviceId: ${id} + result: remove_preferences_results + next: delete_mcq_files + +delete_mcq_files: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" + body: + path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" + keyword: "${name_res.response.body[0].name}_" + result: deleted_mcq + +return_ok: + reloadDsl: true + status: 200 + return: "Service Deleted Successfully" + next: end + +return_bad_request: + status: 400 + return: ${results.response.body.message} + next: end + +return_incorrect_request: + status: 400 + return: "Required parameter(s) missing" + next: end + +return_cannot_delete_active_service: + status: 400 + return: "Cannot delete active service" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml b/DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml new file mode 100644 index 00000000..b38fcd43 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml @@ -0,0 +1,157 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'domain-intent-service-link'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: intent + type: string + description: "Body field 'intent'" + - field: serviceName + type: string + description: "Body field 'serviceName'" + - field: serviceMethod + type: string + description: "Body field 'serviceMethod'" + - field: serviceSlot + type: string + description: "Slot input" + - field: type + type: string + description: "Body field 'type'" + header: + - field: cookie + type: string + description: "Cookie field" + +extract_request_data: + assign: + serviceName: "${incoming.body.serviceName || ''}" + serviceMethod: "${incoming.body.serviceMethod || 'POST'}" + serviceSlot: ", ({${incoming.body.serviceSlot ? incoming.body.serviceSlot : ''}})" + intent: "${incoming.body.intent}" + type: "${incoming.body.type}" + service_path: "#service, /${serviceMethod}/services/active/${serviceName + (incoming.body.serviceSlot ? serviceSlot : '')}" + +get_file_locations: + call: http.get + args: + url: "[#SERVICE_RUUTER]/internal/return-file-locations" + headers: + cookie: ${incoming.headers.cookie} + result: fileLocations + next: get_domain_file + +get_domain_file: + call: http.get + args: + url: "[#SERVICE_RUUTER]/internal/domain-file" + headers: + cookie: ${incoming.headers.cookie} + result: domainData + next: assign_domain_file_data + +assign_domain_file_data: + assign: + domain_data_json: ${domainData.response.body.response} + next: check_if_intent_exists + +check_if_intent_exists: + switch: + - condition: ${domain_data_json.intents.includes(intent)} + next: update_existing_domain_response + next: return_intent_does_not_exist + +update_existing_domain_response: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/domain/update-existing-response" + body: + json: ${domain_data_json.responses} + searchKey: ${intent} + newKey: ${"utter_" + intent} + newKeyValue: '${type === "Add" ? service_path : "UNKNOWN"}' + deleteOldValue: false + createIfAbsent: true + result: updatedResponses + next: check_for_type + +check_for_type: + switch: + - condition: ${type === "Add"} + next: assignRuleData + next: convert_domain_json_to_yaml + +assignRuleData: + assign: + data: + rule: "rule${intent}" + steps: [ + { + intent: "${intent}", + }, + { + action: "utter_${intent}", + }, + ] + next: add_rule + +add_rule: + call: http.post + args: + url: "[#SERVICE_RUUTER]/rasa/rules/add" + headers: + cookie: ${incoming.headers.cookie} + body: + data: ${data} + result: add_rule_res + next: convert_domain_json_to_yaml + +convert_domain_json_to_yaml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_domain" + body: + version: ${domain_data_json.version} + session_config: ${domain_data_json.session_config} + intents: ${domain_data_json.intents} + entities: ${domain_data_json.entities} + slots: ${domain_data_json.slots} + forms: ${domain_data_json.forms} + actions: ${domain_data_json.actions} + responses: ${updatedResponses.response.body} + result: domainYaml + next: resave_domain_file + +resave_domain_file: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: ${fileLocations.response.body.response.domain_location} + content: ${domainYaml.response.body.json} + result: fileResult + next: updateOpenSearch + +updateOpenSearch: + call: http.post + args: + url: "[#SERVICES_PIPELINE]/bulk/domain" + body: + input: ${domainYaml.response.body.json} + result: updateSearchResult + next: return_result + +return_result: + status: 200 + return: "Connection request sent successfully" + next: end + +return_intent_does_not_exist: + status: 400 + return: "Intent does not exists" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/draft/.guard b/DSL/Ruuter.public/services/POST/services/draft/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/draft/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/POST/services/draft/test.tmp b/DSL/Ruuter.public/services/POST/services/draft/test.tmp new file mode 100644 index 00000000..2625647c --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/draft/test.tmp @@ -0,0 +1,48 @@ +declaration: + call: declare + version: 0.1 + description: Description placeholder for 'test' + method: post + accepts: json + returns: json + namespace: service + allowList: + body: + - field: chatId + type: string + description: The chat ID for the message + - field: authorId + type: string + description: The author ID for the message + - field: input + type: object + description: The Input from the user +prepare: + assign: + chatId: ${incoming.body.chatId} + authorId: ${incoming.body.authorId} + input: ${incoming.body.input} + buttons: [] + res: + result: "" + next: format_messages +format_messages: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" + headers: + type: json + body: + data: + botMessages: ${[res]} + chatId: ${chatId ?? ''} + authorId: ${authorId ?? ''} + authorFirstName: "" + authorLastName: "" + authorTimestamp: ${new Date().toISOString()} + created: ${new Date().toISOString()} + buttons: ${buttons ?? []} + result: formatMessage + next: service-end +service-end: + return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/edit.yml b/DSL/Ruuter.public/services/POST/services/edit.yml new file mode 100644 index 00000000..57563146 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/edit.yml @@ -0,0 +1,381 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'EDIT'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: content + type: string + description: "Body field 'content'" + - field: description + type: string + description: "Body field 'description'" + - field: isCommon + type: boolean + description: "Body field 'isCommon'" + - field: entities + type: object + description: "Body field 'Entities'" + - field: examples + type: object + description: "Body field 'Examples'" + - field: name + type: string + description: "Body field 'name'" + - field: structure + type: object + description: "Body field 'structure'" + - field: type + type: string + description: "Body field 'type'" + - field: updateServiceDb + type: boolean + description: "Body field 'updateServiceDb'" + - field: state + type: string + description: "Body field 'state'" + params: + - field: id + type: string + description: "Parameter 'id'" + headers: + - field: cookie + type: string + description: "Cookie field" + +extract_request_data: + assign: + id: ${incoming.params.id} + name: ${incoming.body.name} + description: ${incoming.body.description} + isCommon: ${incoming.body.isCommon} + slot: ${""} + entities: ${incoming.body.entities} + examples: ${incoming.body.examples} + type: ${incoming.body.type} + content: ${incoming.body.content} + structure: ${incoming.body.structure} + updateServiceDb: ${incoming.body.updateServiceDb} + state: ${incoming.body.state} + +check_for_update_service_db: + switch: + - condition: ${incoming.body.updateServiceDb === true} + next: get_service + next: check_for_content + +get_service: + call: http.post + args: + url: "[#SERVICE_RESQL]/services/get_services_by_ids" + body: + serviceIds: "${id}" + result: get_service_result + +check_if_name_is_the_same: + switch: + - condition: ${get_service_result.response.body[0].name === name} + next: delete_all_mcq_files + next: check_if_name_exists + +check_if_name_exists: + call: http.post + args: + url: "[#SERVICE_RESQL]/services/check_name_exist" + body: + name: ${name} + result: name_exists_res + next: check_name_exists_result + +check_name_exists_result: + switch: + - condition: ${name_exists_res.response.body[0].nameExists} + next: return_name_already_exists + next: delete_all_mcq_files + +delete_all_mcq_files: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" + body: + path: "[#RUUTER_SERVICES_PATH]/${type}/services/draft" + keyword: "${name}_" + result: deleteRes + next: check_for_content + +check_for_content: + switch: + - condition: ${content === null} + next: check_for_required_parameters + next: convert_json_content_to_yml + +convert_json_content_to_yml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data" + body: + data: ${content} + result: ymlResult + +check_for_type: + switch: + - condition: ${type === 'GET'} + next: add_get_dsl + next: add_post_dsl + +add_get_dsl: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: "[#RUUTER_SERVICES_GET_PATH]/draft/${name}.tmp" + content: ${ymlResult.response.body.yaml} + result: results + next: check_for_required_parameters + +add_post_dsl: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: "[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp" + content: ${ymlResult.response.body.yaml} + result: results + next: check_for_required_parameters + +check_for_required_parameters: + switch: + - condition: ${id === null || name === null || description === null} + next: return_incorrect_request + - condition: ${type === null} + next: return_incorrect_request + +upper_case_type: + assign: + type: ${type.toUpperCase()} + +check_type: + switch: + - condition: ${type !== 'GET' && type !== 'POST'} + next: return_incorrect_request + +check_if_update_service_db: + switch: + - condition: ${incoming.body.updateServiceDb === true} + next: get_service_by_id + next: return_ok + +get_service_by_id: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-service-by-id" + body: + id: ${id} + result: old_service_result + +assign_values: + assign: + old_name: ${old_service_result.response.body[0].name} + old_structure: ${old_service_result.response.body[0].structure} + old_state: ${old_service_result.response.body[0].state} + service_type: ${old_service_result.response.body[0].type} + +check_new_structure: + switch: + - condition: ${structure === null} + next: use_old_structure + - condition: ${structure !== null} + next: use_new_structure + +use_new_structure: + assign: + new_structure: ${structure} + next: rename_dsl + +use_old_structure: + assign: + new_structure: ${old_structure.value} + next: rename_dsl + +rename_dsl: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${type}/[#RUUTER_SERVICES_DIR_PATH]/${old_state}/${old_name}.tmp" + new_path: "[#RUUTER_SERVICES_PATH]/${type}/[#RUUTER_SERVICES_DIR_PATH]/${old_state}/${name}.tmp" + result: results + +service_edit: + call: http.post + args: + url: "[#SERVICE_RESQL]/edit" + body: + id: ${id} + name: ${name} + description: ${description} + is_common: ${isCommon ?? false} + slot: ${slot} + examples: ${examples} + entities: ${entities} + structure: ${new_structure} + state: ${state ?? 'draft'} + result: editedService + +check for_state: + switch: + - condition: ${state === 'draft'} + next: check_remove_blob_then_draft + - condition: ${state === 'active'} + next: check_if_service_data_exists + next: check_remove_blob_then_ok + +check_remove_blob_then_draft: + switch: + - condition: ${old_state === 'active'} + next: delete_from_storage_edit + next: change_state_to_draft + +check_remove_blob_then_ok: + switch: + - condition: ${old_state === 'active'} + next: delete_from_storage_edit + next: return_ok + +delete_from_storage_edit: + call: http.delete + args: + url: "[#STORAGE_FERRY]/v1/files/delete" + body: + files: + - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" + container: "[#STORAGE_FERRY_CONTAINER]" + fileName: "${old_name}.json" + result: ferry_delete_result + next: check_if_azure_configured_after_delete_edit + +check_if_azure_configured_after_delete_edit: + switch: + - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} + next: trigger_azure_indexer_after_delete_edit + next: log_azure_not_configured_after_delete_edit + +log_azure_not_configured_after_delete_edit: + log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" + next: after_delete_from_storage_edit + +trigger_azure_indexer_after_delete_edit: + call: http.post + args: + url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" + headers: + api-key: "[#AZURE_SEARCH_API_KEY]" + Content-Type: "application/json" + result: azure_indexer_result + next: after_delete_from_storage_edit + +after_delete_from_storage_edit: + switch: + - condition: ${state === 'draft'} + next: change_state_to_draft + next: return_ok + +change_state_to_draft: + call: http.post + args: + url: "[#SERVICE_RUUTER]/services/status" + headers: + cookie: ${incoming.headers.cookie} + body: + id: ${id} + state: "draft" + type: ${service_type ?? 'POST'} + result: changeStateResult + next: return_ok + +check_if_service_data_exists: + switch: + - condition: ${old_service_result !== undefined && old_service_result !== null} + next: generate_service_json_from_existing + next: get_service_data_for_json + +get_service_data_for_json: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-service-by-id" + body: + id: ${id} + result: service_data_result + next: generate_service_json + +generate_service_json_from_existing: + assign: + service_data_result: ${old_service_result} + next: generate_service_json + +generate_service_json: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/service_declaration" + headers: + type: 'json' + body: + name: ${name} + description: ${service_data_result.response.body[0].description} + examples: ${service_data_result.response.body[0].examples} + entities: ${service_data_result.response.body[0].entities} + result: service_json_result + next: replace_in_storage + +replace_in_storage: + call: http.post + args: + url: "[#STORAGE_FERRY]/v1/files/create" + body: + files: + - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" + container: "[#STORAGE_FERRY_CONTAINER]" + fileName: "${name}.json" + content: ${JSON.stringify(service_json_result.response.body)} + result: ferry_upload_result + next: check_if_azure_configured + +check_if_azure_configured: + switch: + - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} + next: trigger_azure_indexer + next: log_azure_not_configured + +log_azure_not_configured: + log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" + next: return_ok + +trigger_azure_indexer: + call: http.post + args: + url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" + headers: + api-key: "[#AZURE_SEARCH_API_KEY]" + Content-Type: "application/json" + result: azure_indexer_result + next: return_ok + +return_ok: + reloadDsl: true + status: 200 + return: "Edited Successfully" + next: end + +return_incorrect_request: + status: 400 + return: "Required parameter(s) missing" + next: end + +return_name_already_exists: + status: 409 + return: "Service name already exists" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml b/DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml new file mode 100644 index 00000000..a6280794 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml @@ -0,0 +1,32 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ENDPOINT-URL-VALIDATION'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: type + type: string + description: "Body field 'type'" + - field: url + type: string + description: "Body field 'url'" + +extract_request_data: + assign: + url: ${incoming.body.url} + type: ${incoming.body.type} + +call_template: + template: "[#SERVICE_PROJECT_LAYER]/validation-template" + requestType: templates + body: + response: ${url} + type: ${type} + result: templateResult + +return_result: + return: ${templateResult} diff --git a/DSL/Ruuter.public/services/POST/services/import-services.yml b/DSL/Ruuter.public/services/POST/services/import-services.yml new file mode 100644 index 00000000..89a51644 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/import-services.yml @@ -0,0 +1,71 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'IMPORT-SERVICES'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: services + type: object + description: "Body field 'services'" + - field: timezone + type: string + description: "Body field 'timezone'" + +extract_request_data: + assign: + services: ${incoming.body.services ?? []} + names: ${services.map(s => s.fileName).join(",") ?? []} + timezone: ${incoming.body.timezone} + +get_import_names: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-import-names" + body: + names: ${names} + timezone: ${timezone} + result: import_names_res + +assign_imported_names: + assign: + imported_names: ${import_names_res.response.body[0].names.split(",")} + services: "$=services.map((s, i) => ({ ...s, fileName: imported_names[i] }))=" + file_names: ${services.map(s => s.fileName)} + +insert_services: + call: http.post + args: + url: "[#SERVICE_RESQL]/add-services" + body: + names: ${file_names} + structures: ${services.map(s => s.flowData)} + result: insert_services_res + +convert_json_content_to_yml: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data_multiple" + body: + data: ${services.map(s => s.content)} + result: ymls_res + +prepare_files: + assign: + file_paths: "$=file_names.map(name => `[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp`)=" + yaml_contents: ${ymls_res.response.body.yamls} + +add_dsls: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create_multiple" + body: + file_paths: ${file_paths} + contents: ${yaml_contents} + result: add_dsls_res + +return_result: + return: "Services imported successfully" diff --git a/DSL/Ruuter.public/services/POST/services/inactive/.guard b/DSL/Ruuter.public/services/POST/services/inactive/.guard new file mode 100644 index 00000000..64435377 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/inactive/.guard @@ -0,0 +1,4 @@ +guard_allow_all: + return: "success" + status: 200 + next: end diff --git a/DSL/Ruuter.public/services/POST/services/open-api-spec.yml b/DSL/Ruuter.public/services/POST/services/open-api-spec.yml new file mode 100644 index 00000000..3dcc2c85 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/open-api-spec.yml @@ -0,0 +1,34 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'OPEN-API-SPEC'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: url + type: string + description: "Body field 'url'" + +check_for_required_parameters: + switch: + - condition: ${incoming.body == null || incoming.body.url == null} + next: return_incorrect_request + next: get_spec + +get_spec: + call: http.get + args: + url: ${incoming.body.url} + result: result + +return_value: + return: ${result.response.body} + next: end + +return_incorrect_request: + status: 400 + return: "missing parameters" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/requests/explain.yml b/DSL/Ruuter.public/services/POST/services/requests/explain.yml new file mode 100644 index 00000000..e5fce84d --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/requests/explain.yml @@ -0,0 +1,95 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'EXPLAIN'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: requests + type: object + description: "request object containing multiple requests" + +check_for_requests: + switch: + - condition: ${incoming.body.requests == null} + next: missing_requests + next: initialize_responses + +initialize_responses: + assign: + requests: ${incoming.body.requests} + responses: [] + index: 0 + next: process_next_request + +process_next_request: + switch: + - condition: ${index < requests.length} + next: assign_current_request + next: return_responses + +assign_current_request: + assign: + current_request: ${requests[index]} + next: check_method + +check_method: + switch: + - condition: ${current_request.method.toLowerCase() == 'post'} + next: request_explain_post + next: request_explain_get + +request_explain_get: + call: http.get + args: + url: ${current_request.url} + headers: + Content-Type: "application/json" + result: res + next: assign_result + +request_explain_post: + call: http.post + args: + url: ${current_request.url} + headers: + Content-Type: "application/json" + body: + data: ${current_request.body} + result: res + next: assign_result + +assign_result: + assign: + result_res: ${[res.response.body]} + next: check_responses_list + +check_responses_list: + switch: + - condition: ${responses.length === 0} + next: append_first_response + next: append_response + +append_first_response: + assign: + responses: ${[responses, ...result_res]} + index: ${index + 1} + next: process_next_request + +append_response: + assign: + responses: ${[...responses, ...result_res]} + index: ${index + 1} + next: process_next_request + +return_responses: + return: ${responses.splice(1 , responses.length - 1)} + next: end + +missing_requests: + status: 400 + return: "required requests were not provided" + next: end \ No newline at end of file diff --git a/DSL/Ruuter.public/services/POST/services/resql/add.yml b/DSL/Ruuter.public/services/POST/services/resql/add.yml new file mode 100644 index 00000000..b936d8fe --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/resql/add.yml @@ -0,0 +1,62 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'ADD'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: name + type: string + description: "Body field 'name'" + - field: sql + type: string + description: "Body field 'sql'" + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: missing_parameter + +extract_request_data: + assign: + name: ${incoming.body.name} + sql: ${incoming.body.sql} + +check_for_required_parameters: + switch: + - condition: ${name == null || sql == null} + next: missing_parameter + next: add_resql + +missing_parameter: + status: 400 + return: "required parameters were not provided" + next: end + +add_resql: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/create" + body: + file_path: "/Resql/services/${name}.sql" + content: ${sql} + result: results + +check_result: + switch: + - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} + next: return_ok + next: return_bad_request + +return_ok: + status: 200 + return: ${results.response.body.message} + next: end + +return_bad_request: + status: 400 + return: ${results.response.body.message} + next: end diff --git a/DSL/Ruuter.public/services/POST/services/status.yml b/DSL/Ruuter.public/services/POST/services/status.yml new file mode 100644 index 00000000..788aad71 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/status.yml @@ -0,0 +1,303 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'STATUS'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: id + type: string + description: "Body field 'id'" + - field: state + type: string + description: "Body field 'state'" + - field: type + type: string + description: "Body field 'type'" + headers: + - field: cookie + type: string + description: "Cookie field" + +extract_request_data: + assign: + id: ${incoming.body.id} + new_state: ${incoming.body.state} + ruuter_type: ${incoming.body.type} + +check_for_required_parameters: + switch: + - condition: ${id === null || new_state === null || ruuter_type === null} + next: return_incorrect_request + - condition: ${new_state === "ready"} + next: set_plain_status + next: get_current_status + +get_current_status: + call: http.post + args: + url: "[#SERVICE_RESQL]/status" + body: + id: ${id} + result: status_res + next: assign_old_status_and_path + +assign_old_status_and_path: + assign: + old_file_status_path: "${status_res.response.body[0].currentState === 'ready' ? 'draft' : status_res.response.body[0].currentState}" + old_file_end: "${status_res.response.body[0].currentState !== 'active' ? '.tmp' : '.yml'}" + next: check_status + +check_status: + switch: + - condition: ${new_state === "draft"} + next: set_status + - condition: ${status_res.response.body[0].currentState === new_state} + next: return_same_state_update + next: set_status + +set_status: + call: http.post + args: + url: "[#SERVICE_RESQL]/set-status" + body: + id: ${id} + new_state: ${new_state} + result: res + next: get_status_name + +set_plain_status: + call: http.post + args: + url: "[#SERVICE_RESQL]/set-status" + body: + id: ${id} + new_state: ${new_state} + result: draft_res + next: return_ok + +get_status_name: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-service-name-by-id" + body: + id: ${id} + result: name_res + next: assign_values + +assign_values: + assign: + name: ${name_res.response.body[0].name} + service_name: "service_${name_res.response.body[0].name}" + next: check_file_exists + +check_file_exists: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/exists" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" + result: service_file_exists_result + next: validate_file_exists + +validate_file_exists: + switch: + - condition: ${!!service_file_exists_result.response.body} + next: check_for_status + next: return_service_file_missing + +check_for_status: + switch: + - condition: ${new_state === "active"} + next: activate_service + - condition: ${new_state === "draft"} + next: draft_service + next: deactivate_service + +activate_service: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" + new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/active/${name}.yml" + result: activate_service_result + next: get_service_data_for_json + +get_service_data_for_json: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-service-by-id" + body: + id: ${id} + result: service_data_result + next: generate_service_json + +generate_service_json: + call: http.post + args: + url: "[#SERVICE_DMAPPER_HBS]/service_declaration" + headers: + type: 'json' + body: + name: ${name} + description: ${service_data_result.response.body[0].description} + examples: ${service_data_result.response.body[0].examples} + entities: ${service_data_result.response.body[0].entities} + result: service_json_result + next: create_in_storage + +create_in_storage: + call: http.post + args: + url: "[#STORAGE_FERRY]/v1/files/create" + body: + files: + - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" + container: "[#STORAGE_FERRY_CONTAINER]" + fileName: "${name}.json" + content: ${JSON.stringify(service_json_result.response.body)} + result: ferry_upload_result + next: check_if_azure_configured_after_create + +check_if_azure_configured_after_create: + switch: + - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} + next: trigger_azure_indexer_after_create + next: log_azure_not_configured_after_create + +log_azure_not_configured_after_create: + log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" + next: activate_all_mcq_services + +trigger_azure_indexer_after_create: + call: http.post + args: + url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" + headers: + api-key: "[#AZURE_SEARCH_API_KEY]" + Content-Type: "application/json" + result: azure_indexer_result + next: activate_all_mcq_services + +activate_all_mcq_services: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move-all-that-starts-with" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" + new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/active" + keyword: "${name}_" + format: "yml" + result: active_move_results + next: return_ok + +deactivate_service: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" + new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/inactive/${name}.tmp" + result: deactivate_service_result + next: delete_from_storage + +delete_from_storage: + call: http.delete + args: + url: "[#STORAGE_FERRY]/v1/files/delete" + body: + files: + - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" + container: "[#STORAGE_FERRY_CONTAINER]" + fileName: "${name}.json" + result: ferry_delete_result + next: check_if_azure_configured_after_delete + +check_if_azure_configured_after_delete: + switch: + - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} + next: trigger_azure_indexer_after_delete + next: log_azure_not_configured_after_delete + +log_azure_not_configured_after_delete: + log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" + next: dactivate_all_mcq_services + +trigger_azure_indexer_after_delete: + call: http.post + args: + url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" + headers: + api-key: "[#AZURE_SEARCH_API_KEY]" + Content-Type: "application/json" + result: azure_indexer_result + next: dactivate_all_mcq_services + +dactivate_all_mcq_services: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move-all-that-starts-with" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" + new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/inactive" + keyword: "${name}_" + format: "tmp" + result: inactive_move_results + next: return_ok + +draft_service: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" + new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/draft/${name}.tmp" + result: draft_service_result + next: draft_all_mcq_services + +draft_all_mcq_services: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/file-manager/move-all-that-starts-with" + body: + file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" + new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/draft" + keyword: "${name}_" + format: "tmp" + result: inactive_move_results + next: return_ok + +return_ok: + reloadDsl: true + status: 200 + return: "Status Changed Successfully" + next: end + +return_bad_request: + status: 400 + return: ${err_result.response.body.message} + next: end + +return_incorrect_request: + status: 400 + return: "Required parameter(s) missing" + next: end + +return_same_state_update: + status: 200 + return: "Service is already in this state" + next: end + +return_service_file_missing: + status: 500 + return: "Service file to update is missing" + next: end + +return_intent_does_not_exist: + status: 400 + return: "does not exists" + next: end diff --git a/DSL/Ruuter.public/services/POST/services/update-endpoint.yml b/DSL/Ruuter.public/services/POST/services/update-endpoint.yml new file mode 100644 index 00000000..9228079d --- /dev/null +++ b/DSL/Ruuter.public/services/POST/services/update-endpoint.yml @@ -0,0 +1,61 @@ +declaration: + call: declare + version: 0.1 + description: "Updates an existing endpoint" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: name + type: string + description: "Endpoint name" + - field: type + type: string + description: "Endpoint type" + - field: isCommon + type: boolean + description: "Endpoint common status" + - field: serviceId + type: string + description: "Service UUID" + - field: definitions + type: object + description: "Endpoint definitions" + params: + - field: id + type: string + description: "Endpoint UUID" + +extract_request_data: + assign: + id: ${incoming.params.id} + +check_for_type: + switch: + - condition: ${id == null} + next: return_no_type_error + +update_endpoint: + call: http.post + args: + url: "[#SERVICE_RESQL]/endpoints/update_endpoint" + body: + endpointId: ${id} + name: ${incoming.body.name} + type: ${incoming.body.type} + isCommon: ${incoming.body.isCommon} + serviceId: ${incoming.body.serviceId ?? ''} + definitions: ${incoming.body.definitions} + result: res + +return_ok: + status: 200 + return: "Endpoint updated" + next: end + +return_no_type_error: + status: 400 + return: "Please provide an endpoint ID" + next: end diff --git a/DSL/Ruuter.public/services/POST/steps/preferences.yml b/DSL/Ruuter.public/services/POST/steps/preferences.yml new file mode 100644 index 00000000..61f72722 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/steps/preferences.yml @@ -0,0 +1,68 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'PREFERENCES'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: steps + type: string + description: "Body field 'steps'" + - field: endpoints + type: string + description: "Body field 'endpoints'" + +extractRequestData: + assign: + steps: ${incoming.body.steps.join(",")} + endpoints: ${incoming.body.endpoints.join(",")} + +get_user_info: + call: http.post + args: + url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" + contentType: plaintext + headers: + cookie: ${incoming.headers.cookie} + plaintext: "customJwtCookie" + result: res + +check_user_info_response: + switch: + - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} + next: assignIdCode + next: return_unauthorized + +assignIdCode: + assign: + idCode: ${res.response.body.idCode} + +update_user_step_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/update-user-step-preferences" + body: + steps: "{${steps}}" + endpoints: "{${endpoints}}" + user_id_code: ${idCode} + result: update_preferences_res + +get_user_step_preferences: + call: http.post + args: + url: "[#SERVICE_RESQL]/get-user-step-preferences" + body: + user_id_code: ${idCode} + result: preferences + +return_preferences: + return: ${preferences.response.body[0]} + next: end + +return_unauthorized: + status: 401 + return: "unauthorized" + next: end diff --git a/DSL/Ruuter.public/services/POST/user-info.yml b/DSL/Ruuter.public/services/POST/user-info.yml new file mode 100644 index 00000000..e6309aa5 --- /dev/null +++ b/DSL/Ruuter.public/services/POST/user-info.yml @@ -0,0 +1,16 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'USER-INFO'" + method: post + accepts: json + returns: json + namespace: service + +get_tara_info: + template: "[#SERVICE_PROJECT_LAYER]/tara" + requestType: templates + result: TARA + +return_authorized: + return: ${TARA.response.body} diff --git a/DSL/Ruuter.public/services/TEMPLATES/RBAC.yml b/DSL/Ruuter.public/services/TEMPLATES/RBAC.yml new file mode 100644 index 00000000..fbe0f033 --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/RBAC.yml @@ -0,0 +1,51 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'RBAC'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: allowedRoles + type: object + description: "Body field 'allowedRoles'" + - field: userId + type: string + description: "Body field 'userId'" + +check_for_body: + switch: + - condition: ${incoming.body == null} + next: missing_parameter + +extract_request_data: + assign: + userId: ${incoming.body.userId} + allowedRoles: ${incoming.body.allowedRoles.sort()} + +check_for_required_parameters: + switch: + - condition: ${userId === null || allowedRoles === null} + next: missing_parameter + next: fetch_user_roles_from_db + +fetch_user_roles_from_db: + call: http.post + args: + url: "[#SERVICE_RESQL_USERS]:[#SERVICE_RESQL_USERS_PORT]/is-user-roles-allowed" + body: + userId: ${userId} + allowedRoles: ${allowedRoles} + result: result + +return_value: + status: 200 + return: "${result.response.body[0]}" + next: end + +missing_parameter: + status: 400 + return: "userId, allowedRoles - missing" + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml b/DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml new file mode 100644 index 00000000..25641137 --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml @@ -0,0 +1,50 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CHECK-USER-AUTHORITY'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + headers: + - field: cookie + type: string + description: "Cookie field" + +get_cookie_info: + call: http.post + args: + url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" + contentType: plaintext + headers: + cookie: ${incoming.headers.cookie} + plaintext: "customJwtCookie" + result: res + next: check_cookie_info_response + +check_cookie_info_response: + switch: + - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} + next: check_user_authority + next: return_bad_request + +check_user_authority: + switch: + - condition: ${res.response.body.authorities.includes("ROLE_ADMINISTRATOR") || res.response.body.authorities.includes("ROLE_SERVICE_MANAGER")} + next: return_authorized + next: return_unauthorized + +return_authorized: + return: ${res.response.body} + next: end + +return_unauthorized: + status: 200 + return: false + next: end + +return_bad_request: + status: 400 + return: false + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/client-input.yml b/DSL/Ruuter.public/services/TEMPLATES/client-input.yml new file mode 100644 index 00000000..1d635eae --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/client-input.yml @@ -0,0 +1,19 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'CLIENT-INPUT'" + method: post + accepts: json + returns: json + namespace: service + +# TODO: replace with correct request to get user input +request_client_input: + call: reflect.mock + args: + response: + input: "Yes" + result: clientInput + +return_value: + return: ${clientInput.response.body} diff --git a/DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml b/DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml new file mode 100644 index 00000000..175f028e --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml @@ -0,0 +1,42 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'DIRECT-TO-CS'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: message + type: string + description: "Body field 'message'" + +# Direct to customer support +check_for_body: + switch: + - condition: ${incoming.body == null || incoming.body.message == null || incoming.body.message == ""} + next: missing_body_parameter + next: extract_request_data + +extract_request_data: + assign: + message: ${incoming.body.message} + +# TODO: do the actual request +send_message_to_client: + call: reflect.mock + args: + response: + status: 'OK' + message: "Teid suunatakse klienditeenindusse" + result: result + +return_value: + return: ${result.response.body} + next: end + +missing_body_parameter: + status: 400 + return: 'message - missing' + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml b/DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml new file mode 100644 index 00000000..43dbf6be --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml @@ -0,0 +1,42 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'END-CONVERSATION'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: message + type: string + description: "Body field 'message'" + +# End conversation +check_for_body: + switch: + - condition: ${incoming.body == null || incoming.body.message == null || incoming.body.message == ""} + next: missing_body_parameter + next: extract_request_data + +extract_request_data: + assign: + message: ${incoming.body.message} + +# TODO: do the actual request +send_message_to_client: + call: reflect.mock + args: + response: + status: 'OK' + message: "Teenus on lõpetatud" + result: result + +return_value: + return: ${result.response.body} + next: end + +missing_body_parameter: + status: 400 + return: 'message - missing' + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/file-generate.yml b/DSL/Ruuter.public/services/TEMPLATES/file-generate.yml new file mode 100644 index 00000000..4eb9f423 --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/file-generate.yml @@ -0,0 +1,45 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'FILE-GENERATE'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: fileContent + type: string + description: "Body field 'fileContent'" + - field: fileName + type: string + description: "Body field 'fileName'" + +check_for_body: + switch: + - condition: ${incoming.body == null || incoming.body.fileName == null || incoming.body.fileContent == null} + next: missing_body_parameters + next: extract_request_data + +extract_request_data: + assign: + fileName: ${incoming.body.fileName} + fileContent: ${incoming.body.fileContent} + +generate_pdf_file: + call: http.post + args: + url: "[#SERVICE_DMAPPER]/js/generate/pdf" + body: + filename: ${fileName} + template: ${fileContent} + result: result + +return: + return: ${result.response.body} + next: end + +missing_body_parameters: + status: 400 + return: "fileName, fileContent - missing" + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/file-signing.yml b/DSL/Ruuter.public/services/TEMPLATES/file-signing.yml new file mode 100644 index 00000000..4d2571c5 --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/file-signing.yml @@ -0,0 +1,35 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'FILE-SIGNING'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: country + type: string + description: "Body field 'country'" + - field: personIdentifier + type: string + description: "Body field 'personIdentifier'" + - field: phoneNr + type: string + description: "Body field 'phoneNr'" + - field: type + type: string + description: "Body field 'type'" + +siga_template_request: + template: siga + requestType: templates + body: + type: ${incoming.body.type} + personIdentifier: ${incoming.body.personIdentifier} + country: ${incoming.body.country} + phoneNr: ${incoming.body.phoneNr} + result: result + +return_result: + return: ${result.response.body} diff --git a/DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml b/DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml new file mode 100644 index 00000000..9435e427 --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml @@ -0,0 +1,44 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'OPEN-WEBPAGE'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: link + type: string + description: "Body field 'link'" + - field: linkText + type: string + description: "Body field 'linkText'" + +check_for_body: + switch: + - condition: ${incoming.body == null || incoming.body.link == null || incoming.body.link == "" || incoming.body.linkText == null || incoming.body.linkText == ""} + next: missing_body_parameter + next: extract_request_data + +extract_request_data: + assign: + link: ${incoming.body.link} + linkText: ${incoming.body.linkText} + +send_link_to_client: + call: reflect.mock + args: + response: + status: "OK" + link:
Link Text + result: result + +return_value: + return: ${result.response.body} + next: end + +missing_body_parameter: + status: 400 + return: "link, linkText - both or one of these fields are missing" + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml b/DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml new file mode 100644 index 00000000..0db2430f --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml @@ -0,0 +1,42 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SEND-MESSAGE-TO-CLIENT'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: message + type: string + description: "Body field 'message'" + +# Message to client element +check_for_body: + switch: + - condition: ${incoming.body == null || incoming.body.message == null || incoming.body.message == ""} + next: missing_body_parameter + next: extract_request_data + +extract_request_data: + assign: + message: ${incoming.body.message} + +# TODO: do the actual request +send_message_to_client: + call: reflect.mock + args: + response: + status: 'OK' + message: "Hello, Muki" + result: result + +return_value: + return: ${result.response.body} + next: end + +missing_body_parameter: + status: 400 + return: 'message - missing' + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/siga.yml b/DSL/Ruuter.public/services/TEMPLATES/siga.yml new file mode 100644 index 00000000..adb8984b --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/siga.yml @@ -0,0 +1,132 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'SIGA'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: containerType + type: string + description: "Body field 'containerType'" + - field: country + type: string + description: "Body field 'country'" + - field: file + type: string + description: "Body field 'file'" + - field: phoneNumber + type: string + description: "Body field 'phoneNumber'" + - field: type + type: string + description: "Body field 'type'" + +extract_request_data: + assign: + file: ${incoming.body.file} + signType: ${incoming.body.type} + country: ${incoming.body.country} + phoneNumber: ${incoming.body.phoneNumber} + containerType: ${incoming.body.containerType} + next: get_tara_info + +get_tara_info: + template: tara + requestType: templates + result: tara_res + next: extract_tara_data + +extract_tara_data: + assign: + identifier: ${tara_res.response.body.idCode} + next: check_for_container_type + +check_for_container_type: + switch: + - condition: ${containerType === "ASIC".toLowerCase()} + next: create_asic_container + - condition: ${containerType === "HASHCODE".toLowerCase()} + next: create_hashcode_container + next: missing_container_type + +create_asic_container: + call: http.post + args: + url: "[#SERVICE_SIGA]/create-container" + contentType: formdata + body: + file:file[0]:uploadedFile.pdf: ${file} + return: container_res + next: check_if_sign_type_missing + +create_hashcode_container: + call: http.post + args: + url: "[#SERVICE_SIGA]/create-hashcode-container" + contentType: formdata + body: + file:file[0]:uploadedFile.pdf: ${file} + return: container_res + next: check_if_sign_type_missing + +check_if_sign_type_missing: + switch: + - condition: ${signType === null} + next: missing_sign_type + next: check_for_sign_type + +check_for_sign_type: + switch: + - condition: ${signType === "smart_id"} + next: sign_via_smart_id + - condition: ${signType === "mobile_sign"} + next: sign_via_mobile + next: missing_sign_type + +sign_via_smart_id: + call: http.post + args: + url: "[#SERVICE_SIGA]/smartid-signing" + body: + containerId: ${container_res.response.body.id} + containerType: ${containerType.toUpperCase()} + personIdentifier: ${identifier} + country: ${country} + return: res + next: end + +sign_via_mobile: + call: http.post + args: + url: "[#SERVICE_SIGA]/mobile-signing" + body: + containerId: ${container_res.response.body.id} + containerType: ${containerType.toUpperCase()} + phoneNr: ${phoneNumber} + personIdentifier: ${identifier} + country: ${country} + return: res + next: end + +missing_smart_id_params: + status: 400 + return: "Id, country - missing" + next: end + +missing_mobile_sign_params: + status: 400 + return: "Phone number, country - missing" + next: end + +missing_sign_type: + status: 400 + return: "Sign type is missing" + next: end + +missing_container_type: + status: 400 + return: "Container type is missing" + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/tara.yml b/DSL/Ruuter.public/services/TEMPLATES/tara.yml new file mode 100644 index 00000000..28ac86dc --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/tara.yml @@ -0,0 +1,51 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'TARA'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + headers: + - field: cookie + type: string + description: "Cookie field" + + +check_for_body: + switch: + - condition: ${incoming.headers == null || incoming.headers.cookie == null} + next: missing_cookie + next: get_cookie_info + +get_cookie_info: + call: http.post + args: + url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" + contentType: plaintext + headers: + cookie: ${incoming.headers.cookie} + plaintext: "customJwtCookie" + result: res + next: check_cookie_info_response + +check_cookie_info_response: + switch: + - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} + next: return_auth_result + next: return_bad_request + +return_auth_result: + return: ${res.response.body} + next: end + +return_bad_request: + status: 400 + return: false + next: end + +missing_cookie: + status: 401 + return: "no authentication cookie" + next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/validation-template.yml b/DSL/Ruuter.public/services/TEMPLATES/validation-template.yml new file mode 100644 index 00000000..63f21c88 --- /dev/null +++ b/DSL/Ruuter.public/services/TEMPLATES/validation-template.yml @@ -0,0 +1,56 @@ +declaration: + call: declare + version: 0.1 + description: "Decription placeholder for 'VALIDATION-TEMPLATE'" + method: post + accepts: json + returns: json + namespace: service + allowlist: + body: + - field: response + type: string + description: "Body field 'response'" + - field: type + type: string + description: "Body field 'type'" + +assign_step: + assign: + res: ${incoming.body.response} + type: ${incoming.body.type} + next: check_for_type + +check_for_type: + switch: + - condition: ${type.toLowerCase() === 'get'} + next: validate_get_request + next: validate_post_request + +validate_get_request: + call: http.get + args: + url: ${res} + result: results + next: validate_status_code + +validate_post_request: + call: http.post + args: + url: ${res} + result: results + next: validate_status_code + +validate_status_code: + switch: + - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} + next: return_true + next: return_false + +return_true: + return: true + next: end + +return_false: + return: false + next: end diff --git a/constants.ini b/constants.ini index 63172d15..af507306 100644 --- a/constants.ini +++ b/constants.ini @@ -9,4 +9,7 @@ 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-public:8086/services +SERVICE_DMAPPER_HBS=http://data-mapper:3000/hbs/rag-search +SERVICE_PROJECT_LAYER=services \ No newline at end of file diff --git a/docs/HYBRID_SEARCH_CLASSIFICATION.md b/docs/HYBRID_SEARCH_CLASSIFICATION.md index 18c512ae..1de3f7f5 100644 --- a/docs/HYBRID_SEARCH_CLASSIFICATION.md +++ b/docs/HYBRID_SEARCH_CLASSIFICATION.md @@ -53,7 +53,8 @@ The system has two phases: | `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` | `EnrichedService` data model | +| `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 @@ -78,8 +79,8 @@ Service "Valuutakursid" → 4 Qdrant points dense: 3072-dim embedding of this exact text sparse: BM25 vector → {euro: 1.0, gbp: 1.0, kurss: 1.0, ...} - Point 3 (summary): "Valuutakursid - Kasutaja soovib infot..." - dense: 3072-dim embedding of name + description + LLM context + 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 ``` @@ -101,9 +102,12 @@ Service "Valuutakursid" → 4 Qdrant points ```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 → hashed to index in [0, VOCAB_SIZE), value = term frequency +# 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, ...]) ``` @@ -146,7 +150,7 @@ service_enrichment.sh │ ├─ Generate dense embedding (text-embedding-3-large) │ └─ Generate sparse vector (BM25 term hashing) │ - ├─ Step 3: Summary point (name + description + LLM context): + ├─ Step 3: Summary point (name + description + examples + entities + LLM context): │ ├─ Generate dense embedding │ └─ Generate sparse vector │ @@ -155,6 +159,17 @@ service_enrichment.sh └─ 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: @@ -186,12 +201,12 @@ POST /collections/intent_collections/points/query { "query": [0.023, -0.041, ...], # 3072-dim dense vector "using": "dense", - "limit": 6, + "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). +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. @@ -203,6 +218,7 @@ 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": [ @@ -215,6 +231,10 @@ POST /collections/intent_collections/points/query } ``` +> **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 ``` @@ -251,6 +271,7 @@ Dense: Valuutakursid (cosine=0.5511), gap=0.2371 → 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 @@ -285,17 +306,17 @@ SERVICE (Layer 1) → CONTEXT (Layer 2) → RAG (Layer 3) → OOD (Layer 4 | Path | Intent Detection | Entity Extraction | |------|-----------------|-------------------| | HIGH-CONFIDENCE | On 1 service (matched) | Yes — from LLM output | -| AMBIGUOUS | On 2-3 candidates | Yes — if LLM matches | +| 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` receives: +The DSPy `IntentDetectionModule` uses `dspy.Predict` (direct prediction) and receives: - User query -- Candidate services (formatted as JSON) -- Conversation history (last 3 turns) +- 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 @@ -336,6 +357,18 @@ Entities dict → ordered array matching service schema: # 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 @@ -387,7 +420,3 @@ Based on empirical testing with 42 Estonian queries (20 SERVICE, 22 RAG): - **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. - -### Current Limitations - -- **Step 7 (Ruuter service call) is not yet implemented.** The service workflow currently returns a debug response with service metadata (endpoint URL, HTTP method, extracted entities) instead of calling the actual Ruuter service endpoint. See the `TODO: STEP 7` comments in `src/tool_classifier/workflows/service_workflow.py`. diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md index 398299a8..ac92abb2 100644 --- a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -59,32 +59,45 @@ Handle queries that require calling external services/APIs: ### 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. Service Discovery - ↓ -2. Service Selection (Semantic Search or LLM-based) - ↓ -3. Intent Detection (DSPy LLM Call) - ↓ -4. Entity Extraction (From LLM Output) - ↓ -5. Entity Validation (Against Service Schema) - ↓ -6. Entity Transformation (Dict → Ordered Array) - ↓ -7. Service Call (TODO: Ruuter endpoint invocation) +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) ``` --- -## 1. Service Discovery +## Service Discovery (Legacy Path) ### Method: `_call_service_discovery()` Calls Ruuter public endpoint to fetch available services: ```python -GET /rag-search/get-services-from-llm +GET {RAG_SEARCH_RUUTER_PUBLIC}/services/get-services +# Default: http://ruuter-public:8086/rag-search/services/get-services ``` **Response Structure:** @@ -122,16 +135,14 @@ if service_count <= 10: elif service_count > 10: # Many services → Use semantic search to narrow down - services = await _semantic_search_services(query, top_k=5) + services = await _semantic_search_services(query, top_k=10) ``` --- -## 2. Service Selection +## Semantic Search (When Many Services) -### Semantic Search (When Many Services) - -**Method:** `_semantic_search_services()` +### Method: `_semantic_search_services()` Uses Qdrant vector database to find relevant services: @@ -142,8 +153,8 @@ embedding = orchestration_service.create_embeddings_for_indexer([query]) # 2. Search Qdrant collection search_payload = { "vector": query_embedding, - "limit": 5, # Top 5 services - "score_threshold": 0.4, # Minimum similarity + "limit": 10, # Top 10 services (SEMANTIC_SEARCH_TOP_K) + "score_threshold": 0.2, # Minimum similarity (SEMANTIC_SEARCH_THRESHOLD) "with_payload": True } @@ -157,7 +168,7 @@ response = qdrant_client.post( --- -## 3. Intent Detection (LLM-Based) +## Intent Detection (LLM-Based) ### Method: `_detect_service_intent()` @@ -189,23 +200,24 @@ services_formatted = [ "name": "Currency Conversion", "description": "Convert EUR to other currencies", "required_entities": ["target_currency"], - "examples": ["How much is EUR in USD?", "Convert EUR to JPY"] + "examples": ["How much is EUR in USD?", "Convert EUR to JPY"] # Top 3 examples } ] # 2. Prepare conversation context (last 3 turns) conversation_context = """ -user: Hello -assistant: Hi! How can I help? -user: How much is 100 EUR in USD? +end_user: Hello +backoffice_user: Hi! How can I help? +end_user: How much is 100 EUR in USD? """ -# 3. Call DSPy module -intent_result = intent_detector.forward( - user_query="How much is 100 EUR in USD?", - services=services_formatted, - conversation_history=conversation_history -) +# 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 @@ -226,8 +238,8 @@ The LLM returns structured JSON: ### Confidence Threshold ```python -if confidence < 0.7: - # Low confidence → Service workflow returns None → Fallback to RAG +if matched_service_id is None or confidence < 0.7: + # Low confidence → Service workflow returns None → Fallback to Context/RAG return None ``` @@ -251,7 +263,7 @@ costs_metric["intent_detection"] = usage_info --- -## 4. Entity Extraction +## Entity Extraction ### From LLM Output @@ -299,7 +311,7 @@ Entities are extracted as **key-value pairs** where: --- -## 5. Entity Validation +## Entity Validation ### Method: `_validate_entities()` @@ -367,7 +379,7 @@ validation_errors = ["Entity 'target_currency' has empty value"] --- -## 6. Entity Transformation +## Entity Transformation ### Method: `_transform_entities_to_array()` @@ -397,18 +409,14 @@ entities_array = ["USD", "EUR", "100"] ```python def _transform_entities_to_array( + self, entities_dict: Dict[str, str], entity_order: List[str] ) -> List[str]: """Transform entity dict to ordered array.""" - ordered_array = [] - - for entity_key in entity_order: - # Get value from dict, or empty string if missing - value = entities_dict.get(entity_key, "") - ordered_array.append(value) - - return ordered_array + if not entity_order: + return [] + return [entities_dict.get(key, "") for key in entity_order] ``` ### Example @@ -435,40 +443,62 @@ def _transform_entities_to_array( --- -## 7. Service Call (TODO: Step 7) +## Service Call (Step 7 — Implemented) ### Endpoint Construction ```python -endpoint_url = f"{RUUTER_BASE_URL}/services/active{service_name}" -# Example: "http://ruuter:8080/services/active/currency-conversion" -# (Note: service_name from service metadata, e.g., "/currency-conversion") +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 (Planned) +### Payload Construction ```python payload = { + "chatId": chat_id, + "authorId": author_id, "input": entities_array, # ["USD", "EUR", "100"] - "authorId": request.authorId, - "chatId": request.chatId } ``` -### HTTP Call (Planned) +### HTTP Call ```python -# Non-streaming -response = await httpx.post( - endpoint_url, - json=payload, - timeout=5.0 -) +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 -async with httpx.stream("POST", endpoint_url, json=payload) as stream: - async for line in stream.aiter_lines(): - yield orchestration_service.format_sse(chat_id, line) +### 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) ``` --- @@ -483,28 +513,14 @@ async with httpx.stream("POST", endpoint_url, json=payload) as stream: ### Step-by-Step Execution -#### 1. Service Discovery -```json -{ - "service_count": 5, - "services": [ - { - "serviceId": "currency_conversion_eur", - "name": "Currency Conversion (EUR)", - "entities": ["target_currency"], - "examples": ["How much is EUR in USD?"] - } - ] -} -``` - -#### 2. Service Selection +#### 1. Classification (Hybrid Search) ```python -# Few services (5 <= 10) → Use all for intent detection -services = discovery_result["services"] +# Dense search finds best service match +# cosine=0.5511, gap=0.2371 +# → HIGH-CONFIDENCE path (needs_llm_confirmation=False) ``` -#### 3. Intent Detection (LLM Call) +#### 2. Intent Detection (LLM Call on matched service only) ```json { "matched_service_id": "currency_conversion_eur", @@ -516,12 +532,12 @@ services = discovery_result["services"] } ``` -#### 4. Entity Extraction +#### 3. Entity Extraction ```python entities_dict = {"target_currency": "THB"} ``` -#### 5. Entity Validation +#### 4. Entity Validation ```python validation_result = { "is_valid": True, @@ -531,7 +547,7 @@ validation_result = { } ``` -#### 6. Entity Transformation +#### 5. Entity Transformation ```python # Schema: ["target_currency"] # Dict: {"target_currency": "THB"} @@ -539,14 +555,17 @@ validation_result = { entities_array = ["THB"] ``` -#### 7. Service Call (TODO) +#### 6. Service Call ```python -# Planned implementation -response = await call_service( - url="http://ruuter:8080/currency/convert", - method="POST", - payload={"input": ["THB"], "chatId": "..."} +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 ``` --- @@ -580,16 +599,16 @@ LLM USAGE COSTS BREAKDOWN: ### When Service Workflow Returns None ```python -# Scenario 1: No service match (confidence < 0.7) -if not intent_result or intent_result.get("confidence", 0) < 0.7: +# 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 validation failed -if not validated_service: +# Scenario 2: Service metadata extraction failed +if not service_metadata: return None # Fallback to CONTEXT layer -# Scenario 3: No services discovered -if not services: +# Scenario 3: Service endpoint call failed +if service_content is None: return None # Fallback to CONTEXT layer ``` @@ -607,22 +626,31 @@ Query: "What is VAT?" ## Configuration Constants ```python -# Service discovery -RUUTER_BASE_URL = "http://ruuter.public:8080" -SERVICE_DISCOVERY_TIMEOUT = 5.0 # seconds +# 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 = 20 # Max services to pass to LLM +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 = "services_collection" -SEMANTIC_SEARCH_TOP_K = 5 # Top 5 relevant services -SEMANTIC_SEARCH_THRESHOLD = 0.4 # Minimum similarity score -QDRANT_TIMEOUT = 2.0 # seconds +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 -# Intent detection -INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed +# 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 ``` --- @@ -639,11 +667,13 @@ INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed - Schema defines canonical order - Missing entities → empty strings -### 3. **Two-Stage Service Selection** -- Few services (≤10): Pass all to LLM -- Many services (>10): Semantic search first +### 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) @@ -653,8 +683,14 @@ INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed - 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 **LLM-based intent detection** to match user queries to external services, extract entities, validate them against service schemas, and prepare them for service invocation—all while maintaining comprehensive cost tracking and seamless integration with the broader RAG pipeline. +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/new.txt b/new.txt new file mode 100644 index 00000000..9e7525fb --- /dev/null +++ b/new.txt @@ -0,0 +1,38 @@ +1️⃣ Broneeringu kinnitus (Booking Confirmation) + +Estonian → English + +Kas minu broneering on kinnitatud? +→ Is my booking confirmed? + +Palun kinnita minu broneering. +→ Please confirm my booking. + +Kas broneering sai edukalt tehtud? +→ Was the booking successfully made? + +2️⃣ Kalastusloa uuendamise teade (Fishing License Renewal) + +Estonian → English + +Kas minu kalastusluba tuleb uuendada? +→ Do I need to renew my fishing license? + +Millal mu kalastusluba aegub? +→ When does my fishing license expire? + +Kas mu kalastusluba on veel kehtiv? +→ Is my fishing license still valid? + +3️⃣ Koolivaheajad (School Holidays) + +Estonian → English + +Millal on järgmine koolivaheaeg? +→ When is the next school holiday? + +Kas sa saad öelda selle aasta koolivaheajad? +→ Can you tell me the school holidays for this year? + +Millal algab suvevaheaeg? +→ When does the summer holiday start? \ No newline at end of file diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index 65f30332..64fdbe55 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -37,6 +37,9 @@ RUUTER_BASE_URL = "http://ruuter-private:8086" """Base URL for Ruuter private service endpoints.""" +RUUTER_SERVICE_BASE_URL = "http://ruuter-public:8086/services" +"""Base URL for Ruuter public service endpoints (active services).""" + RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" """Public Ruuter endpoint for RAG search service discovery.""" diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index bb72f785..b7fe561b 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -20,9 +20,10 @@ QDRANT_PORT, QDRANT_TIMEOUT, RAG_SEARCH_RUUTER_PUBLIC, - RUUTER_BASE_URL, + RUUTER_SERVICE_BASE_URL, SEMANTIC_SEARCH_THRESHOLD, SEMANTIC_SEARCH_TOP_K, + SERVICE_CALL_TIMEOUT, SERVICE_COUNT_THRESHOLD, SERVICE_DISCOVERY_TIMEOUT, ) @@ -105,7 +106,6 @@ async def _semantic_search_services( return None try: - # Generate embedding using orchestration service embedding_result = self.orchestration_service.create_embeddings_for_indexer( texts=[query], environment=request.environment, @@ -120,12 +120,10 @@ async def _semantic_search_services( query_embedding = embeddings[0] - # Create Qdrant client with proper resource cleanup via context manager qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" async with httpx.AsyncClient( base_url=qdrant_url, timeout=QDRANT_TIMEOUT ) as client: - # Verify collection exists and has data try: collection_info = await client.get( f"/collections/{QDRANT_COLLECTION}" @@ -139,7 +137,6 @@ async def _semantic_search_services( except Exception as e: logger.warning(f"[{chat_id}] Could not verify collection: {e}") - # Search Qdrant collection search_payload = { "vector": query_embedding, "limit": top_k, @@ -167,7 +164,6 @@ async def _semantic_search_services( ) return None - # Transform Qdrant results to service format services: List[Dict[str, Any]] = [] for point in points: payload = point.get("payload", {}) @@ -180,8 +176,6 @@ async def _semantic_search_services( "description": payload.get("description"), "examples": payload.get("examples", []), "entities": payload.get("entities", []), - # Note: endpoint not stored in intent_collections, - # will be resolved via database lookup if needed "similarity_score": score, } services.append(service) @@ -234,30 +228,24 @@ async def _detect_service_intent( - usage_info: Cost and token usage information """ try: - # Ensure DSPy is configured with LLMManager if self.llm_manager: self.llm_manager.ensure_global_config() else: logger.error(f"[{chat_id}] LLM Manager not available") return None, {} - # Capture history length before LLM call for cost tracking lm = dspy.settings.lm history_length_before = ( len(lm.history) if lm and hasattr(lm, "history") else 0 ) - # Create DSPy module intent_module = IntentDetectionModule() - - # Convert conversation history to dict format history_dicts = [ {"authorRole": msg.authorRole, "message": msg.message} for msg in conversation_history if hasattr(msg, "authorRole") and hasattr(msg, "message") ] - # Call DSPy forward with task-local config with self.llm_manager.use_task_local(): intent_result = intent_module.forward( user_query=user_query, @@ -265,7 +253,6 @@ async def _detect_service_intent( conversation_history=history_dicts, ) - # Extract usage information after LLM call usage_info = get_lm_usage_since(history_length_before) return intent_result, usage_info @@ -342,31 +329,20 @@ def _extract_service_metadata( self, context: Dict[str, Any], chat_id: str ) -> Optional[Dict[str, Any]]: """Extract service and entity metadata from context.""" - # Check if service_id exists service_id = context.get("service_id") if not service_id: logger.error(f"[{chat_id}] Missing service_id in context") return None - # Check if service_data exists service_data = context.get("service_data") if not service_data: logger.error(f"[{chat_id}] Missing service_data in context") return None - # Extract entities dict from context (LLM extracted) entities_dict = context.get("entities", {}) - - # Extract entity schema from service_data (expected order) - entity_schema = service_data.get("entities", []) - if entity_schema is None: - entity_schema = [] - - # Extract service name + entity_schema = service_data.get("entities", []) or [] service_name = service_data.get("name", service_id) - - # Extract HTTP method (ruuter_type) - defaults to GET if not specified - ruuter_type = service_data.get("ruuter_type", "GET") + ruuter_type = service_data.get("ruuter_type", "POST") return { "service_id": service_id, @@ -417,10 +393,7 @@ def _validate_entities( if entity_key not in service_schema: extra_entities.append(entity_key) - # Determine overall validity - # We consider it valid even with missing entities (will send empty strings) - # Let the external service validate required parameters - is_valid = True # Always true - we proceed with partial entities + is_valid = True return { "is_valid": is_valid, @@ -435,29 +408,98 @@ def _transform_entities_to_array( """Transform entity dictionary to ordered array based on service schema.""" if not entity_order: return [] - - # Transform to ordered array, filling missing with empty strings return [entities_dict.get(key, "") for key in entity_order] + _INVISIBLE_CHAR_TABLE = str.maketrans( + "", "", "\u2060\u200b\u200c\u200d\ufeff\u00ad\u200e\u200f" + ) + def _construct_service_endpoint(self, service_name: str, chat_id: str) -> str: """Construct the full service endpoint URL for Ruuter.""" - return f"{RUUTER_BASE_URL}/services/active{service_name}" + clean_name = ( + service_name.strip().translate(self._INVISIBLE_CHAR_TABLE).replace(" ", "_") + ) + return f"{RUUTER_SERVICE_BASE_URL}/services/active/{clean_name}" - def _format_debug_response( + async def _call_service_endpoint( self, - service_name: str, endpoint_url: str, http_method: str, entities_array: List[str], - ) -> str: - """Format debug information for testing (temporary before Step 7 implementation).""" - entities_str = ", ".join(f'"{e}"' for e in entities_array) - return ( - f" Service Validated: {service_name}\n" - f" Endpoint URL: {endpoint_url}\n" - f" HTTP Method: {http_method}\n" - f" Extracted Entities: [{entities_str}]\n\n" - ) + chat_id: str, + author_id: str, + ) -> Optional[str]: + """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: + Service response content string, 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"] + + # DMapper returns a JSON array; each item has a "content" field + if isinstance(data, list) and len(data) > 0: + content = data[0].get("content", "") + if content: + logger.info( + f"[{chat_id}] Service endpoint returned content " + f"({len(content)} chars)" + ) + return content + + logger.warning( + f"[{chat_id}] Service response missing 'content' field" + ) + return None + + 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, @@ -477,16 +519,13 @@ async def _log_request_details( chat_id = request.chatId logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") - # Service Discovery discovery_result = await self._call_service_discovery(chat_id) if discovery_result: - # Extract data from nested response structure response_data = discovery_result.get("response", {}) use_semantic = response_data.get("use_semantic_search", False) service_count = response_data.get("service_count", 0) - # Handle service_count if it's a string or NaN if isinstance(service_count, str): try: service_count = int(service_count) @@ -495,12 +534,10 @@ async def _log_request_details( services_from_ruuter = response_data.get("services", []) - # Use semantic search if count > threshold if service_count > SERVICE_COUNT_THRESHOLD: use_semantic = True if use_semantic: - # Use semantic search to find relevant services services = await self._semantic_search_services( query=request.message, request=request, @@ -567,34 +604,25 @@ async def execute_async( chat_id = request.chatId - # Create costs tracking dictionary (follows RAG workflow pattern) costs_metric: Dict[str, Dict[str, Any]] = {} - # Use parent time_metric or create new one if time_metric is None: time_metric = {} - # Check if classifier provided hybrid search metadata needs_llm_confirmation = context.get("needs_llm_confirmation") if needs_llm_confirmation is False: - # HIGH CONFIDENCE PATH: Classifier matched a service with high confidence - # Skip service discovery — use hybrid search match directly - matched_service_id = context.get("matched_service_id") 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 (non-streaming): " - f"{matched_service_name} (cosine_score={cosine_score:.4f}) - " - f"skipping discovery" + f"[{chat_id}] High-confidence service match: " + f"{matched_service_name} (score={cosine_score:.4f})" ) - # Get service details from top_results (already retrieved by classifier) top_results = context.get("top_results", []) if top_results: matched = top_results[0] - # Run entity extraction via LLM (DSPy) for this single service start_time = time.time() await self._process_intent_detection( services=[matched], @@ -605,24 +633,15 @@ async def execute_async( ) time_metric["service.intent_detection"] = time.time() - start_time - # Ensure service_data is populated from hybrid match - # _process_intent_detection may not set it if DSPy returns - # a different service_id format, so we populate it explicitly if not context.get("service_data"): context["service_id"] = matched.get("service_id") context["service_data"] = matched - logger.info( - f"[{chat_id}] Populated service_data from hybrid match: " - f"{matched.get('name')}" - ) elif needs_llm_confirmation is True: - # AMBIGUOUS PATH: Multiple services scored similarly - # Run LLM intent detection only on candidate services (not all services) top_results = context.get("top_results", []) logger.info( - f"[{chat_id}] AMBIGUOUS SERVICE MATCH (non-streaming): " - f"running LLM intent detection on {len(top_results)} candidates" + f"[{chat_id}] Ambiguous match: " + f"running intent detection on {len(top_results)} candidates" ) start_time = time.time() @@ -637,44 +656,26 @@ async def execute_async( time_metric["service.intent_detection"] = time.time() - start_time else: - # LEGACY PATH: No hybrid search metadata (classifier disabled or error) - # Full service discovery + intent detection (original behavior) 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 - # Check if service was detected and validated if not context.get("service_id"): - logger.info( - f"[{chat_id}] No service detected or validated - " - f"returning None to fallback to next layer" - ) + logger.info(f"[{chat_id}] No service matched, falling back") return None - # Entity Transformation & Validation - logger.info(f"[{chat_id}] Entity Transformation:") - - # Step 1: Extract service metadata from context start_time = time.time() service_metadata = self._extract_service_metadata(context, chat_id) if not service_metadata: - logger.error( - f"[{chat_id}] - Metadata extraction failed - " - f"returning None to fallback" - ) return None - logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") - logger.info( - f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" - ) logger.info( - f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + f"[{chat_id}] Service: {service_metadata['service_name']}, " + f"entities: {service_metadata['entities_dict']}" ) - # Step 2: Validate entities against schema validation_result = self._validate_entities( extracted_entities=service_metadata["entities_dict"], service_schema=service_metadata["entity_schema"], @@ -683,28 +684,11 @@ async def execute_async( ) time_metric["service.entity_validation"] = time.time() - start_time - logger.info( - f"[{chat_id}] - Validation status: " - f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" - ) - if validation_result["missing_entities"]: logger.warning( - f"[{chat_id}] - Missing entities (will send empty strings): " - f"{validation_result['missing_entities']}" - ) - - if validation_result["extra_entities"]: - logger.info( - f"[{chat_id}] - Extra entities (ignored): " - f"{validation_result['extra_entities']}" + f"[{chat_id}] Missing entities: {validation_result['missing_entities']}" ) - if validation_result["validation_errors"]: - for error in validation_result["validation_errors"]: - logger.warning(f"[{chat_id}] - Validation warning: {error}") - - # Step 3: Transform entities dict to ordered array entities_array = self._transform_entities_to_array( entities_dict=service_metadata["entities_dict"], entity_order=service_metadata["entity_schema"], @@ -713,46 +697,36 @@ async def execute_async( context["entities_array"] = entities_array context["validation_result"] = validation_result - # Construct service endpoint URL endpoint_url = self._construct_service_endpoint( service_name=service_metadata["service_name"], chat_id=chat_id ) - context["endpoint_url"] = endpoint_url context["http_method"] = service_metadata["ruuter_type"] - logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") - - # TODO: STEP 7 - Call Ruuter service endpoint and return response - # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} - # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT - # 3. Parse Ruuter response and extract result - # 4. Return OrchestrationResponse with actual service result - # 5. Handle errors (timeout, HTTP errors, malformed JSON) - - # STEP 6: Return debug response (temporary until Step 7 - Ruuter call implemented) - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) - debug_content = self._format_debug_response( - service_name=service_metadata["service_name"], + start_time = time.time() + service_content = 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 - logger.info(f"[{chat_id}] Returning debug response (Step 7 pending)") - - # Log costs after service workflow completes (follows RAG workflow pattern) if self.orchestration_service: self.orchestration_service.log_costs(costs_metric) + if service_content is None: + logger.warning(f"[{chat_id}] Service endpoint call failed, falling back") + return None + return OrchestrationResponse( chatId=request.chatId, llmServiceActive=True, questionOutOfLLMScope=False, inputGuardFailed=False, - content=debug_content, + content=service_content, ) - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) async def execute_streaming( self, @@ -772,23 +746,19 @@ async def execute_streaming( chat_id = request.chatId - # Create costs tracking dictionary (follows RAG workflow pattern) costs_metric: Dict[str, Dict[str, Any]] = {} - # Use parent time_metric or create new one if time_metric is None: time_metric = {} - # Check if classifier provided hybrid search metadata needs_llm_confirmation = context.get("needs_llm_confirmation") if needs_llm_confirmation is False: - # HIGH CONFIDENCE PATH: Skip discovery, use matched service 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 (streaming): " - f"{matched_service_name} (cosine_score={cosine_score:.4f})" + f"[{chat_id}] High-confidence service match: " + f"{matched_service_name} (score={cosine_score:.4f})" ) top_results = context.get("top_results", []) @@ -805,21 +775,15 @@ async def execute_streaming( ) time_metric["service.intent_detection"] = time.time() - start_time - # Ensure service_data is populated from hybrid match if not context.get("service_data"): context["service_id"] = matched.get("service_id") context["service_data"] = matched - logger.info( - f"[{chat_id}] Populated service_data from hybrid match: " - f"{matched.get('name')}" - ) elif needs_llm_confirmation is True: - # AMBIGUOUS PATH: Run LLM intent detection on candidates top_results = context.get("top_results", []) logger.info( - f"[{chat_id}] AMBIGUOUS SERVICE MATCH (streaming): " - f"{len(top_results)} candidates" + f"[{chat_id}] Ambiguous match: " + f"running intent detection on {len(top_results)} candidates" ) start_time = time.time() @@ -834,42 +798,25 @@ async def execute_streaming( time_metric["service.intent_detection"] = time.time() - start_time else: - # LEGACY PATH: Full service discovery (original behavior) 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 - # Check if service was detected and validated if not context.get("service_id"): - logger.info( - f"[{chat_id}] No service detected or validated - " - f"returning None to fallback to next layer" - ) + logger.info(f"[{chat_id}] No service matched, falling back") return None - # Entity Transformation & Validation - logger.info(f"[{chat_id}] Entity Transformation:") - - # Step 1: Extract service metadata from context service_metadata = self._extract_service_metadata(context, chat_id) if not service_metadata: - logger.error( - f"[{chat_id}] - Metadata extraction failed - " - f"returning None to fallback" - ) return None - logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") logger.info( - f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" - ) - logger.info( - f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + f"[{chat_id}] Service: {service_metadata['service_name']}, " + f"entities: {service_metadata['entities_dict']}" ) - # Step 2: Validate entities against schema validation_result = self._validate_entities( extracted_entities=service_metadata["entities_dict"], service_schema=service_metadata["entity_schema"], @@ -877,28 +824,11 @@ async def execute_streaming( chat_id=chat_id, ) - logger.info( - f"[{chat_id}] - Validation status: " - f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" - ) - if validation_result["missing_entities"]: logger.warning( - f"[{chat_id}] - Missing entities (will send empty strings): " - f"{validation_result['missing_entities']}" + f"[{chat_id}] Missing entities: {validation_result['missing_entities']}" ) - if validation_result["extra_entities"]: - logger.info( - f"[{chat_id}] - Extra entities (ignored): " - f"{validation_result['extra_entities']}" - ) - - if validation_result["validation_errors"]: - for error in validation_result["validation_errors"]: - logger.warning(f"[{chat_id}] - Validation warning: {error}") - - # Step 3: Transform entities dict to ordered array entities_array = self._transform_entities_to_array( entities_dict=service_metadata["entities_dict"], entity_order=service_metadata["entity_schema"], @@ -907,47 +837,32 @@ async def execute_streaming( context["entities_array"] = entities_array context["validation_result"] = validation_result - # Construct service endpoint URL endpoint_url = self._construct_service_endpoint( service_name=service_metadata["service_name"], chat_id=chat_id ) - context["endpoint_url"] = endpoint_url context["http_method"] = service_metadata["ruuter_type"] - logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") - - # TODO: STEP 7 - Call Ruuter service endpoint and stream response - # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} - # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT - # 3. Parse Ruuter response and extract result - # 4. Format result as SSE and yield chunks - # 5. Handle errors (timeout, HTTP errors, malformed JSON) - - # STEP 6: Return debug response as async iterator (temporary until Step 7) - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) - debug_content = self._format_debug_response( - service_name=service_metadata["service_name"], + service_content = 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, ) - logger.info(f"[{chat_id}] Streaming debug response (Step 7 pending)") + if service_content 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") - # Store reference for closure (helps type checker) orchestration_service = self.orchestration_service - async def debug_stream() -> AsyncIterator[str]: - yield orchestration_service.format_sse(chat_id, debug_content) + async def service_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse(chat_id, service_content) yield orchestration_service.format_sse(chat_id, "END") - - # Log costs after streaming completes (follows RAG workflow pattern) - # Must be inside generator because costs are accumulated during streaming orchestration_service.log_costs(costs_metric) - return debug_stream() - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) + return service_stream() 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" + } +] From 9ce1da2897f1b3a80e70d350a7b77a4cc2f075b3 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Mon, 9 Mar 2026 09:18:57 +0530 Subject: [PATCH 057/105] context based response generation flow --- docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md | 323 +++++++ src/llm_orchestration_service.py | 5 + src/llm_orchestration_service_api.py | 2 +- src/llm_orchestrator_config/stream_config.py | 7 +- src/tool_classifier/classifier.py | 15 +- src/tool_classifier/constants.py | 6 +- src/tool_classifier/context_analyzer.py | 893 ++++++++++++++++++ src/tool_classifier/greeting_constants.py | 40 + .../workflows/context_workflow.py | 350 ++++++- src/tool_classifier/workflows/rag_workflow.py | 47 +- .../workflows/service_workflow.py | 17 + src/utils/rate_limiter.py | 131 ++- 12 files changed, 1702 insertions(+), 134 deletions(-) create mode 100644 docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md create mode 100644 src/tool_classifier/context_analyzer.py create mode 100644 src/tool_classifier/greeting_constants.py diff --git a/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md b/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md new file mode 100644 index 00000000..4df8d1eb --- /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 + +The LLM generates contextually appropriate responses in the **same language** as the query. If the LLM detects a greeting but fails to produce an answer (e.g., JSON parse error), the system falls back to predefined static responses from `greeting_constants.py`. + +**Fallback responses (`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/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 7f7432fc..78899870 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -639,11 +639,13 @@ async def stream_orchestration_response( ) # 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, ) + time_metric["classifier.classify"] = time.time() - start_time logger.info( f"[{request.chatId}] [{stream_ctx.stream_id}] Classification: {classification.workflow.value} " @@ -652,11 +654,14 @@ async def stream_orchestration_response( # Route to appropriate workflow (streaming) # route_to_workflow returns AsyncIterator[str] when is_streaming=True + 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 diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 0e9b1273..110c2991 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -71,7 +71,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 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: 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/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index f18ef3ec..1ada8940 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -57,9 +57,9 @@ class ToolClassifier: def __init__( self, - llm_manager: Any, - orchestration_service: Any, - ): + llm_manager: Any, # noqa: ANN401 + orchestration_service: Any, # noqa: ANN401 + ) -> None: """ Initialize tool classifier with required dependencies. @@ -88,6 +88,7 @@ def __init__( ) self.context_workflow = ContextWorkflowExecutor( llm_manager=llm_manager, + orchestration_service=orchestration_service, ) self.rag_workflow = RAGWorkflowExecutor( orchestration_service=orchestration_service, @@ -622,7 +623,7 @@ def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: async def _execute_with_fallback_async( self, - workflow: Any, + workflow: Any, # noqa: ANN401 request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, @@ -696,11 +697,11 @@ async def _execute_with_fallback_async( if rag_result is not None: return rag_result else: - raise RuntimeError("RAG workflow returned None unexpectedly") + raise RuntimeError("RAG workflow returned None unexpectedly") from e async def _execute_with_fallback_streaming( self, - workflow: Any, + workflow: Any, # noqa: ANN401 request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, @@ -782,4 +783,4 @@ async def _execute_with_fallback_streaming( async for chunk in streaming_result: yield chunk else: - raise RuntimeError("RAG workflow returned None unexpectedly") + raise RuntimeError("RAG workflow returned None unexpectedly") from e diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index 65f30332..d839e2cf 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -70,13 +70,15 @@ DENSE_SEARCH_TOP_K = 3 """Number of top results from dense-only search for relevance scoring.""" -DENSE_MIN_THRESHOLD = 0.38 +# DENSE_MIN_THRESHOLD = 0.38 +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.40 +# DENSE_HIGH_CONFIDENCE_THRESHOLD = 0.40 +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.""" diff --git a/src/tool_classifier/context_analyzer.py b/src/tool_classifier/context_analyzer.py new file mode 100644 index 00000000..51aa214a --- /dev/null +++ b/src/tool_classifier/context_analyzer.py @@ -0,0 +1,893 @@ +"""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 src.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 or extract answers from history + + 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 + + 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") + 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 + + 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, "can_answer_from_context": bool, ' + '"reasoning": str, "context_snippet": str|null}. ' + "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." + ) + + +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 + self._stream_predictor: Optional[Any] = 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), + 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 stream_context_response( + self, + query: str, + context_snippet: str, + ) -> AsyncIterator[str]: + """ + Phase 2 (streaming): Stream a generated answer using DSPy native streaming. + + Uses ContextResponseGenerationSignature with DSPy's streamify() so tokens + are yielded in real time — same mechanism as ResponseGeneratorAgent.stream_response(). + + 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 + """ + logger.info(f"CONTEXT GENERATOR: Phase 2 streaming | Query: '{query[:100]}'") + + self.llm_manager.ensure_global_config() + output_stream = None + stream_started = False + try: + with self.llm_manager.use_task_local(): + if self._stream_predictor is None: + answer_listener = StreamListener(signature_field_name="answer") + self._stream_predictor = dspy.streamify( + dspy.Predict(ContextResponseGenerationSignature), + stream_listeners=[answer_listener], + ) + output_stream = self._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: + logger.warning( + "Context streaming finished but no 'answer' tokens received." + ) + 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}" + ) + + 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["en"]) 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/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index dc23e8bf..2c837694 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -1,10 +1,22 @@ """Context workflow executor - Layer 2: Conversation history and greetings.""" from typing import Any, AsyncIterator, Dict, Optional +import time +import dspy from loguru import logger from 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): @@ -12,24 +24,222 @@ class ContextWorkflowExecutor(BaseWorkflow): Handles greetings and conversation history queries (Layer 2). Detects: - - Greetings: "Hello", "Thanks", "Goodbye" + - 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. - Status: SKELETON - Returns None (fallback to RAG) - TODO: Implement greeting/context detection, answer extraction, guardrails + 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: Any): + 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 - logger.info("Context workflow executor initialized (skeleton)") + 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. Returns ContextDetectionResult or None on error.""" + try: + start = time.time() + result, cost = await self.context_analyzer.detect_context( + 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 = 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, @@ -38,26 +248,62 @@ async def execute_async( time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ - Execute context workflow in non-streaming mode. - - TODO: Check greeting (LLM) → generate response, OR check history (last 10 turns) - → extract answer → validate with guardrails. Return None if cannot answer. + Execute context workflow in non-streaming mode (two-phase). - Args: - request: Orchestration request with user query and history - context: Metadata with is_greeting, can_answer_from_history flags - time_metric: Optional timing dictionary for future timing tracking + 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 with context-based answer or None to fallback + OrchestrationResponse or None to fallback to RAG """ - logger.debug( - f"[{request.chatId}] Context workflow execute_async called " - f"(not implemented - returning None)" + 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) - # TODO: Implement context workflow logic here - # For now, return None to trigger fallback to next layer (RAG) + 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(language=language) + self._log_costs(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 + ): + 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) return None async def execute_streaming( @@ -67,24 +313,62 @@ async def execute_streaming( time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ - Execute context workflow in streaming mode. + Execute context workflow in streaming mode (two-phase). - TODO: Get answer (greeting/history) → validate BEFORE streaming → chunk and - yield as SSE. Return None if cannot answer. - - Args: - request: Orchestration request with user query and history - context: Metadata with is_greeting, can_answer_from_history flags - time_metric: Optional timing dictionary for future timing tracking + 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 + AsyncIterator yielding SSE strings or None to fallback to RAG """ - logger.debug( - f"[{request.chatId}] Context workflow execute_streaming called " - f"(not implemented - returning None)" + 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}" ) - # TODO: Implement context streaming logic here - # For now, return None to trigger fallback to next layer (RAG) + if detection_result.is_greeting: + from src.tool_classifier.greeting_constants import get_greeting_response + + greeting = get_greeting_response(language=language) + orchestration_service = self.orchestration_service + chat_id = request.chatId + + async def _stream_greeting() -> AsyncIterator[str]: + if orchestration_service: + 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/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index b5da35b1..1b3ba4d3 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -64,7 +64,7 @@ async def execute_async( Args: request: Orchestration request with user query - context: Unused (RAG doesn't need classification metadata) + context: May contain pre-initialized "components" to avoid duplicate init time_metric: Optional timing dictionary from parent (for unified tracking) Returns: @@ -79,8 +79,12 @@ async def execute_async( if time_metric is None: time_metric = {} - # Initialize service components - components = self.orchestration_service._initialize_service_components(request) + # 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( @@ -105,6 +109,10 @@ async def execute_streaming( """ Execute RAG workflow in streaming mode. + Returns an AsyncIterator (not an async generator) so callers can + safely use ``await workflow.execute_streaming(...)`` without hitting + a TypeError from awaiting an async generator. + Delegates to existing streaming pipeline which handles: - Prompt refinement (blocking) - Chunk retrieval (blocking) @@ -118,7 +126,7 @@ async def execute_streaming( Args: request: Orchestration request with user query - context: Unused (RAG doesn't need classification metadata) + context: May contain pre-initialized "components" and "stream_ctx" time_metric: Optional timing dictionary from parent (for unified tracking) Returns: @@ -143,8 +151,7 @@ async def execute_streaming( # Get stream context from context if provided, otherwise create minimal tracking stream_ctx = context.get("stream_ctx") if stream_ctx is None: - # Create minimal stream context when called via tool classifier - # In production flow, this is provided by stream_orchestration_response + class MinimalStreamContext: """Minimal stream context for RAG workflow when called directly.""" @@ -154,25 +161,29 @@ def __init__(self, chat_id: str) -> None: self.bot_generator = None def mark_completed(self) -> None: - """No-op: Tracking handled by orchestration service.""" + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context pass def mark_cancelled(self) -> None: - """No-op: Tracking handled by orchestration service.""" + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context pass def mark_error(self, error_id: str) -> None: - """No-op: Tracking handled by orchestration service.""" + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context pass stream_ctx = MinimalStreamContext(request.chatId) - # Delegate to core RAG pipeline (bypasses classifier to avoid recursion) - async for sse_chunk in self.orchestration_service._stream_rag_pipeline( - request=request, - components=components, - stream_ctx=stream_ctx, - costs_metric=costs_metric, - time_metric=time_metric, - ): - yield sse_chunk + # 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=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 index bb72f785..78825502 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -6,6 +6,7 @@ import httpx from loguru import logger +from src.guardrails.nemo_rails_adapter import NeMoRailsAdapter from src.utils.cost_utils import get_lm_usage_since from models.request_models import ( @@ -73,6 +74,22 @@ def log_costs(self, costs_metric: Dict[str, Dict[str, Any]]) -> None: """ ... + 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: OrchestrationResponse, + request: OrchestrationRequest, + costs_metric: Dict[str, Dict[str, Any]], + ) -> OrchestrationResponse: + """Apply output guardrails to the generated response.""" + ... + class ServiceWorkflowExecutor(BaseWorkflow): """Executes external service calls via Ruuter endpoints (Layer 1).""" diff --git a/src/utils/rate_limiter.py b/src/utils/rate_limiter.py index 4b88d9d7..5de46d8d 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,29 @@ 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, ): """ 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 # 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 +92,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 +122,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 +187,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,29 +201,31 @@ 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 limit + if current_token_usage + estimated_tokens > self.tokens_per_minute: + # 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"needed: {estimated_tokens}, " + f"current_usage: {current_token_usage}/{self.tokens_per_minute} " f"(retry after {retry_after}s)" ) @@ -231,8 +233,8 @@ def _check_token_limit( 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_minute, ) return RateLimitResult(allowed=True) @@ -254,20 +256,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 +285,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 +316,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 +332,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}") From d647f865175e450aa5148fac4558a01b41c2f741 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Mon, 9 Mar 2026 10:00:46 +0530 Subject: [PATCH 058/105] fixed pr review suggested issues --- docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md | 6 +++--- src/tool_classifier/context_analyzer.py | 11 +++++++++-- .../workflows/context_workflow.py | 18 ++++++++++++------ src/tool_classifier/workflows/rag_workflow.py | 7 ++++--- src/utils/rate_limiter.py | 15 ++++++++++----- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md b/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md index 4df8d1eb..8a67e841 100644 --- a/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md +++ b/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md @@ -144,9 +144,9 @@ Calls `stream_context_response(query, context_snippet)` which uses DSPy native s ### Greeting Response Generation -The LLM generates contextually appropriate responses in the **same language** as the query. If the LLM detects a greeting but fails to produce an answer (e.g., JSON parse error), the system falls back to predefined static responses from `greeting_constants.py`. - -**Fallback responses (`greeting_constants.py`):** +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 = { diff --git a/src/tool_classifier/context_analyzer.py b/src/tool_classifier/context_analyzer.py index 51aa214a..4572aefd 100644 --- a/src/tool_classifier/context_analyzer.py +++ b/src/tool_classifier/context_analyzer.py @@ -109,6 +109,10 @@ 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" ) @@ -143,8 +147,10 @@ class ContextDetectionSignature(dspy.Signature): ) user_query: str = dspy.InputField(desc="Current user query to classify") detection_result: str = dspy.OutputField( - desc='JSON object with: {"is_greeting": bool, "can_answer_from_context": bool, ' + 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." ) @@ -323,6 +329,7 @@ async def detect_context( 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 ), @@ -890,4 +897,4 @@ def get_fallback_greeting_response(self, language: str = "et") -> str: "et": "Tere! Kuidas ma saan sind aidata?", "en": "Hello! How can I help you?", } - return greetings.get(language, greetings["en"]) + return greetings.get(language, greetings["et"]) diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index 2c837694..8d69675c 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -282,7 +282,9 @@ async def execute_async( if detection_result.is_greeting: from src.tool_classifier.greeting_constants import get_greeting_response - greeting = get_greeting_response(language=language) + greeting = get_greeting_response( + greeting_type=detection_result.greeting_type, language=language + ) self._log_costs(costs_metric) return OrchestrationResponse( chatId=request.chatId, @@ -347,15 +349,19 @@ async def execute_streaming( if detection_result.is_greeting: from src.tool_classifier.greeting_constants import get_greeting_response - greeting = get_greeting_response(language=language) + 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]: - if orchestration_service: - yield orchestration_service.format_sse(chat_id, greeting) - yield orchestration_service.format_sse(chat_id, "END") - orchestration_service.log_costs(costs_metric) + 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() diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index 1b3ba4d3..9c983ced 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -109,9 +109,10 @@ async def execute_streaming( """ Execute RAG workflow in streaming mode. - Returns an AsyncIterator (not an async generator) so callers can - safely use ``await workflow.execute_streaming(...)`` without hitting - a TypeError from awaiting an async generator. + 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) diff --git a/src/utils/rate_limiter.py b/src/utils/rate_limiter.py index 5de46d8d..d86829f8 100644 --- a/src/utils/rate_limiter.py +++ b/src/utils/rate_limiter.py @@ -76,6 +76,9 @@ def __init__( 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 self._request_history: Dict[str, Deque[float]] = defaultdict(deque) @@ -211,8 +214,8 @@ def _check_token_limit( # 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 limit - if current_token_usage + estimated_tokens > self.tokens_per_minute: + # 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] @@ -225,8 +228,10 @@ def _check_token_limit( logger.warning( f"Token rate limit exceeded for {author_id} - " f"needed: {estimated_tokens}, " - f"current_usage: {current_token_usage}/{self.tokens_per_minute} " - f"(retry after {retry_after}s)" + 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( @@ -234,7 +239,7 @@ def _check_token_limit( retry_after=retry_after, limit_type="tokens", current_usage=current_token_usage, - limit=self.tokens_per_minute, + limit=self.tokens_per_window, ) return RateLimitResult(allowed=True) From 6c46d3c9da11e6f3d4a59843e11e07576801bd40 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Tue, 10 Mar 2026 16:38:50 +0530 Subject: [PATCH 059/105] removed service project layer --- DSL/Ruuter.public/services/GET/.guard | 28 -- .../GET/account/user-profile-settings.yml | 27 -- .../services/GET/active-services.yml | 20 - DSL/Ruuter.public/services/GET/generic/.guard | 4 - .../accounts/customer-support-activity.yml | 22 - .../accounts/user-profile-settings.yml | 26 -- .../GET/generic/accounts/user-role.yml | 19 - .../services/GET/generic/csa/active-chats.yml | 76 ---- .../services/GET/generic/userinfo.yml | 29 -- DSL/Ruuter.public/services/GET/get-sticky.yml | 40 -- .../services/GET/internal/domain-file.yml | 40 -- .../GET/internal/return-file-locations.yml | 27 -- .../GET/mocks/client-input-variables.yml | 19 - .../services/GET/mocks/service-settings.yml | 19 - .../services/GET/mocks/validation-mock.yml | 26 -- .../services/GET/rasa/rule-names.yml | 29 -- .../services/GET/secrets-with-priority.yml | 34 -- DSL/Ruuter.public/services/GET/secrets.yml | 20 - .../services/GET/service-settings.yml | 19 - .../services/GET/services/active/.guard | 4 - .../services/GET/services/draft/.guard | 4 - .../services/GET/services/inactive/.guard | 4 - .../services/GET/services/log-by-request.yml | 33 -- .../services/GET/services/log-by-service.yml | 33 -- .../GET/services/services-detailed/nok.yml | 52 --- .../services/GET/services/statistics.yml | 21 - .../services/GET/services/status.yml | 24 -- DSL/Ruuter.public/services/GET/slots.yml | 26 -- .../services/GET/steps/preferences.yml | 72 ---- .../services/GET/sticky/example.yml | 58 --- DSL/Ruuter.public/services/POST/.guard | 28 -- DSL/Ruuter.public/services/POST/auth/.guard | 4 - .../services/POST/auth/login.yml | 101 ----- DSL/Ruuter.public/services/POST/csv.yml | 50 --- .../POST/dates/calculate-difference.yml | 82 ---- .../services/POST/endpoints/common.yml | 48 --- .../services/POST/file/rename.yml | 42 -- .../services/POST/mocks/RBAC-mock.yml | 50 --- .../POST/mocks/dates/calculate-difference.yml | 136 ------- .../services/POST/mocks/service-settings.yml | 19 - .../services/POST/mocks/services/add.yml | 44 -- .../mocks/services/open-api-spec-mock.yml | 17 - .../services/POST/mocks/user-info.yml | 52 --- .../services/POST/mocks/validation-mock.yml | 26 -- .../services/POST/rasa/rules/add.yml | 129 ------ .../services/POST/saveJsonToYml.yml | 39 -- .../services/POST/service-by-id.yml | 90 ----- .../services/POST/service-settings.yml | 29 -- DSL/Ruuter.public/services/POST/services.yml | 43 -- .../services/POST/services/active/.guard | 4 - .../services/active/Broneeringu_kinnitus.yml | 65 --- .../active/Kalastusloa_uuendamise_teade.yml | 60 --- .../POST/services/active/Koolivaheajad.yml | 63 --- .../services/active/Lihtne_test_teenus.yml | 61 --- .../services/active/customer_feedback.yml | 82 ---- .../services/POST/services/add.yml | 177 -------- .../POST/services/create-endpoint.yml | 46 --- .../POST/services/delete-endpoint.yml | 34 -- .../services/POST/services/delete.yml | 155 ------- .../services/domain-intent-service-link.yml | 157 -------- .../services/POST/services/draft/.guard | 4 - .../services/POST/services/draft/test.tmp | 48 --- .../services/POST/services/edit.yml | 381 ------------------ .../POST/services/endpoint-url-validation.yml | 32 -- .../POST/services/import-services.yml | 71 ---- .../services/POST/services/inactive/.guard | 4 - .../services/POST/services/open-api-spec.yml | 34 -- .../POST/services/requests/explain.yml | 95 ----- .../services/POST/services/resql/add.yml | 62 --- .../services/POST/services/status.yml | 303 -------------- .../POST/services/update-endpoint.yml | 61 --- .../services/POST/steps/preferences.yml | 68 ---- DSL/Ruuter.public/services/POST/user-info.yml | 16 - DSL/Ruuter.public/services/TEMPLATES/RBAC.yml | 51 --- .../TEMPLATES/check-user-authority.yml | 50 --- .../services/TEMPLATES/client-input.yml | 19 - .../services/TEMPLATES/direct-to-cs.yml | 42 -- .../services/TEMPLATES/end-conversation.yml | 42 -- .../services/TEMPLATES/file-generate.yml | 45 --- .../services/TEMPLATES/file-signing.yml | 35 -- .../services/TEMPLATES/open-webpage.yml | 44 -- .../TEMPLATES/send-message-to-client.yml | 42 -- DSL/Ruuter.public/services/TEMPLATES/siga.yml | 132 ------ DSL/Ruuter.public/services/TEMPLATES/tara.yml | 51 --- .../TEMPLATES/validation-template.yml | 56 --- src/tool_classifier/constants.py | 2 +- 86 files changed, 1 insertion(+), 4577 deletions(-) delete mode 100644 DSL/Ruuter.public/services/GET/.guard delete mode 100644 DSL/Ruuter.public/services/GET/account/user-profile-settings.yml delete mode 100644 DSL/Ruuter.public/services/GET/active-services.yml delete mode 100644 DSL/Ruuter.public/services/GET/generic/.guard delete mode 100644 DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml delete mode 100644 DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml delete mode 100644 DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml delete mode 100644 DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml delete mode 100644 DSL/Ruuter.public/services/GET/generic/userinfo.yml delete mode 100644 DSL/Ruuter.public/services/GET/get-sticky.yml delete mode 100644 DSL/Ruuter.public/services/GET/internal/domain-file.yml delete mode 100644 DSL/Ruuter.public/services/GET/internal/return-file-locations.yml delete mode 100644 DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml delete mode 100644 DSL/Ruuter.public/services/GET/mocks/service-settings.yml delete mode 100644 DSL/Ruuter.public/services/GET/mocks/validation-mock.yml delete mode 100644 DSL/Ruuter.public/services/GET/rasa/rule-names.yml delete mode 100644 DSL/Ruuter.public/services/GET/secrets-with-priority.yml delete mode 100644 DSL/Ruuter.public/services/GET/secrets.yml delete mode 100644 DSL/Ruuter.public/services/GET/service-settings.yml delete mode 100644 DSL/Ruuter.public/services/GET/services/active/.guard delete mode 100644 DSL/Ruuter.public/services/GET/services/draft/.guard delete mode 100644 DSL/Ruuter.public/services/GET/services/inactive/.guard delete mode 100644 DSL/Ruuter.public/services/GET/services/log-by-request.yml delete mode 100644 DSL/Ruuter.public/services/GET/services/log-by-service.yml delete mode 100644 DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml delete mode 100644 DSL/Ruuter.public/services/GET/services/statistics.yml delete mode 100644 DSL/Ruuter.public/services/GET/services/status.yml delete mode 100644 DSL/Ruuter.public/services/GET/slots.yml delete mode 100644 DSL/Ruuter.public/services/GET/steps/preferences.yml delete mode 100644 DSL/Ruuter.public/services/GET/sticky/example.yml delete mode 100644 DSL/Ruuter.public/services/POST/.guard delete mode 100644 DSL/Ruuter.public/services/POST/auth/.guard delete mode 100644 DSL/Ruuter.public/services/POST/auth/login.yml delete mode 100644 DSL/Ruuter.public/services/POST/csv.yml delete mode 100644 DSL/Ruuter.public/services/POST/dates/calculate-difference.yml delete mode 100644 DSL/Ruuter.public/services/POST/endpoints/common.yml delete mode 100644 DSL/Ruuter.public/services/POST/file/rename.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/service-settings.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/services/add.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/user-info.yml delete mode 100644 DSL/Ruuter.public/services/POST/mocks/validation-mock.yml delete mode 100644 DSL/Ruuter.public/services/POST/rasa/rules/add.yml delete mode 100644 DSL/Ruuter.public/services/POST/saveJsonToYml.yml delete mode 100644 DSL/Ruuter.public/services/POST/service-by-id.yml delete mode 100644 DSL/Ruuter.public/services/POST/service-settings.yml delete mode 100644 DSL/Ruuter.public/services/POST/services.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/active/.guard delete mode 100644 DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/add.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/create-endpoint.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/delete-endpoint.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/delete.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/draft/.guard delete mode 100644 DSL/Ruuter.public/services/POST/services/draft/test.tmp delete mode 100644 DSL/Ruuter.public/services/POST/services/edit.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/import-services.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/inactive/.guard delete mode 100644 DSL/Ruuter.public/services/POST/services/open-api-spec.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/requests/explain.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/resql/add.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/status.yml delete mode 100644 DSL/Ruuter.public/services/POST/services/update-endpoint.yml delete mode 100644 DSL/Ruuter.public/services/POST/steps/preferences.yml delete mode 100644 DSL/Ruuter.public/services/POST/user-info.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/RBAC.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/client-input.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/file-generate.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/file-signing.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/siga.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/tara.yml delete mode 100644 DSL/Ruuter.public/services/TEMPLATES/validation-template.yml diff --git a/DSL/Ruuter.public/services/GET/.guard b/DSL/Ruuter.public/services/GET/.guard deleted file mode 100644 index 4fd565b6..00000000 --- a/DSL/Ruuter.public/services/GET/.guard +++ /dev/null @@ -1,28 +0,0 @@ -check_for_cookie: - switch: - - condition: ${incoming.headers == null || incoming.headers.cookie == null} - next: guard_fail - next: authenticate - -authenticate: - template: "[#SERVICE_PROJECT_LAYER]/check-user-authority" - requestType: templates - headers: - cookie: ${incoming.headers.cookie} - result: authority_result - -check_authority_result: - switch: - - condition: ${authority_result !== "false"} - next: guard_success - next: guard_fail - -guard_success: - return: "success" - status: 200 - next: end - -guard_fail: - return: "unauthorized" - status: 401 - next: end diff --git a/DSL/Ruuter.public/services/GET/account/user-profile-settings.yml b/DSL/Ruuter.public/services/GET/account/user-profile-settings.yml deleted file mode 100644 index 320d4af9..00000000 --- a/DSL/Ruuter.public/services/GET/account/user-profile-settings.yml +++ /dev/null @@ -1,27 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'USER-PROFILE-SETTINGS'" - method: get - accepts: json - returns: json - namespace: service - -step_1: - call: reflect.mock - args: - response: - response: - - userId: EE30303039914 - forwardedChatPopupNotifications: false - forwardedChatSoundNotifications: false - forwardedChatEmailNotifications: false - newChatPopupNotifications: false - newChatSoundNotifications: false - newChatEmailNotifications: false - useAutocorrect: true - result: reflected_request - -step_2: - wrapper: false - return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/active-services.yml b/DSL/Ruuter.public/services/GET/active-services.yml deleted file mode 100644 index 873bb54e..00000000 --- a/DSL/Ruuter.public/services/GET/active-services.yml +++ /dev/null @@ -1,20 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ACTIVE-SERVICES'" - method: get - accepts: json - returns: json - namespace: service - -get_services_list: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-active-services-list" - result: results - -return_ok: - status: 200 - wrapper: false - return: ${results.response.body} - next: end diff --git a/DSL/Ruuter.public/services/GET/generic/.guard b/DSL/Ruuter.public/services/GET/generic/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/GET/generic/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml b/DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml deleted file mode 100644 index 25c2e33b..00000000 --- a/DSL/Ruuter.public/services/GET/generic/accounts/customer-support-activity.yml +++ /dev/null @@ -1,22 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CUSTOMER-SUPPORT-ACTIVITY'" - method: get - accepts: json - returns: json - namespace: service - -step_1: - call: reflect.mock - args: - response: - response: - - idCode: 'EE49902216518' - active: 'true' - status: 'idle' - result: reflected_request - -step_2: - wrapper: true - return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml b/DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml deleted file mode 100644 index 344b83cb..00000000 --- a/DSL/Ruuter.public/services/GET/generic/accounts/user-profile-settings.yml +++ /dev/null @@ -1,26 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'USER-PROFILE-SETTINGS'" - method: get - accepts: json - returns: json - namespace: service - -step_1: - call: reflect.mock - args: - response: - data: - - userId: EE30303039914 - forwardedChatPopupNotifications: false - forwardedChatSoundNotifications: false - forwardedChatEmailNotifications: false - newChatPopupNotifications: false - newChatSoundNotifications: false - newChatEmailNotifications: false - useAutocorrect: true - result: reflected_request - -step_2: - return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml b/DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml deleted file mode 100644 index 7794dbb6..00000000 --- a/DSL/Ruuter.public/services/GET/generic/accounts/user-role.yml +++ /dev/null @@ -1,19 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'USER-ROLE'" - method: get - accepts: json - returns: json - namespace: service - -step_1: - call: reflect.mock - args: - response: - response: - - "ROLE_ADMINISTRATOR" - result: reflected_request - -step_2: - return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml b/DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml deleted file mode 100644 index 02d95eb8..00000000 --- a/DSL/Ruuter.public/services/GET/generic/csa/active-chats.yml +++ /dev/null @@ -1,76 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ACTIVE-CHATS'" - method: get - accepts: json - returns: json - namespace: service - -step_1: - call: reflect.mock - args: - response: - response: - - id: '22fa5630-6f92-4d50-92ba-685c872383af' - customerSupportId: '' - customerSupportDisplayName: '' - endUserId: '' - endUserFirstName: '' - endUserLastName: '' - status: 'OPEN' - created: '2023-01-17T13:18:38.808+00:00' - updated: '2023-01-17T13:19:26.348+00:00' - ended: null - endUserOs: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36' - endUserUrl: 'https://test.buerokratt.ee/' - forwardedToName: null - forwardedByUser: '' - forwardedFromCsa: '' - forwardedToCsa: '' - lastMessage: 'Suunan teid klienditeenindajale. Varuge natukene kannatust.' - contactsMessage: null - lastMessageTimestamp: '2023-01-17T13:19:26.316+00:00' - - id: '5206b7bd-0812-40a8-ae1d-3774f07f06f0' - customerSupportId: '' - customerSupportDisplayName: '' - endUserId: '' - endUserFirstName: '' - endUserLastName: '' - status: 'OPEN' - created: '2023-01-19T13:38:32.421+00:00' - updated: '2023-01-19T13:38:32.430+00:00' - ended: null - endUserOs: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36' - endUserUrl: 'https://test.buerokratt.ee/' - forwardedToName: null - forwardedByUser: '' - forwardedFromCsa: '' - forwardedToCsa: '' - lastMessage: 'aitäh' - contactsMessage: null - lastMessageTimestamp: '2022-11-23T09:33:56.803+00:00' - - id: 'b7bba1c2-b7ab-4b17-825a-2d66a7d16fc4' - customerSupportId: '' - customerSupportDisplayName: '' - endUserId: '' - endUserFirstName: '' - endUserLastName: '' - status: 'OPEN' - created: '2023-01-19T13:38:32.421+00:00' - updated: '2023-01-19T13:38:32.430+00:00' - ended: null - endUserOs: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36' - endUserUrl: 'https://test.buerokratt.ee/' - forwardedToName: null - forwardedByUser: '' - forwardedFromCsa: '' - forwardedToCsa: '' - lastMessage: 'dasnhpwa' - contactsMessage: null - lastMessageTimestamp: '2023-01-18T12:24:54.557+00:00' - result: reflected_request - -step_2: - wrapper: true - return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/generic/userinfo.yml b/DSL/Ruuter.public/services/GET/generic/userinfo.yml deleted file mode 100644 index 1278132c..00000000 --- a/DSL/Ruuter.public/services/GET/generic/userinfo.yml +++ /dev/null @@ -1,29 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'USERINFO'" - method: get - accepts: json - returns: json - namespace: service - -step_1: - call: reflect.mock - args: - response: - data: - firstName: OK - lastName: TESTNUMBER - idCode: EE30303039914 - displayName: OK - JWTCreated: 1704724715000 - login: EE30303039914 - csaEmail: mail@mail.ee - authorities: - - ROLE_ADMINISTRATOR - csaTitle: OG - JWTExpirationTimestamp: 1704739715000 - result: reflected_request - -step_2: - return: ${reflected_request.response.body} diff --git a/DSL/Ruuter.public/services/GET/get-sticky.yml b/DSL/Ruuter.public/services/GET/get-sticky.yml deleted file mode 100644 index ca906d6d..00000000 --- a/DSL/Ruuter.public/services/GET/get-sticky.yml +++ /dev/null @@ -1,40 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'GET-STICKY'" - method: get - accepts: json - returns: json - namespace: service - allowlist: - params: - - field: name - type: string - description: "Parameter 'name'" - -check_for_parameters: - switch: - - condition: ${incoming.params == null || incoming.params.name == null} - next: get_all_sticky_services - next: get_single_sticky_service - -get_single_sticky_service: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/ruuter/sticky/steps" - query: - name: ${incoming.params.name} - result: results - next: return_ok - -get_all_sticky_services: - call: http.get - args: - url: "[#SERVICE_DMAPPER]/ruuter/sticky" - result: results - next: return_ok - -return_ok: - status: 200 - return: ${results.response.body} - next: end diff --git a/DSL/Ruuter.public/services/GET/internal/domain-file.yml b/DSL/Ruuter.public/services/GET/internal/domain-file.yml deleted file mode 100644 index 2e42dc93..00000000 --- a/DSL/Ruuter.public/services/GET/internal/domain-file.yml +++ /dev/null @@ -1,40 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'DOMAIN-FILE'" - method: get - accepts: json - returns: json - namespace: service - allowlist: - headers: - - field: cookie - type: string - description: "Cookie field" - -getFileLocations: - call: http.get - args: - url: "[#SERVICE_RUUTER]/internal/return-file-locations" - headers: - cookie: ${incoming.headers.cookie} - result: fileLocations - -getDomainFile: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/read-file" - body: - file_path: ${fileLocations.response.body.response.domain_location} - result: domainFile - -convertYamlToJson: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/yaml_to_json" - body: - file: ${domainFile.response.body.file} - result: domainData - -return_value: - return: ${domainData.response.body} diff --git a/DSL/Ruuter.public/services/GET/internal/return-file-locations.yml b/DSL/Ruuter.public/services/GET/internal/return-file-locations.yml deleted file mode 100644 index 0ebab6ab..00000000 --- a/DSL/Ruuter.public/services/GET/internal/return-file-locations.yml +++ /dev/null @@ -1,27 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'RETURN-FILE-LOCATIONS'" - method: get - accepts: json - returns: json - namespace: service - allowlist: - headers: - - field: cookie - type: string - description: "Cookie field" - -assign_step: - assign: - locations: - rules_location: "[#DMAPPER_LOCATIONS_PATH]/data/rules.yml" - stories_location: "[#DMAPPER_LOCATIONS_PATH]/data/stories.yml" - domain_location: "[#DMAPPER_LOCATIONS_PATH]/data/domain.yml" - test_stories_location: "[#DMAPPER_LOCATIONS_PATH]/test_stories.yml" - intents_location: "[#DMAPPER_LOCATIONS_PATH]/nlu/" - regex_location: "[#DMAPPER_LOCATIONS_PATH]/regex" - training_result_location: "[#DMAPPER_LOCATIONS_PATH]/results" - config_location: "[#DMAPPER_LOCATIONS_PATH]/data/config.yml" -return_value: - return: ${locations} diff --git a/DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml b/DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml deleted file mode 100644 index dab285a3..00000000 --- a/DSL/Ruuter.public/services/GET/mocks/client-input-variables.yml +++ /dev/null @@ -1,19 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CLIENT-INPUT-VARIABLES'" - method: get - accepts: json - returns: json - namespace: service - -mock_variables: - call: reflect.mock - args: - response: - data: ['{{user.firstname}}', '{{user.lastname}}', '{{user.birthdate}}', '{{user.email}}', '{{invoice.total}}', '{{invoice.subtotal}}'] - result: mock_res - -return_result: - wrapper: false - return: ${mock_res.response.body.data} diff --git a/DSL/Ruuter.public/services/GET/mocks/service-settings.yml b/DSL/Ruuter.public/services/GET/mocks/service-settings.yml deleted file mode 100644 index 35482b26..00000000 --- a/DSL/Ruuter.public/services/GET/mocks/service-settings.yml +++ /dev/null @@ -1,19 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SERVICE-SETTINGS'" - method: get - accepts: json - returns: json - namespace: service - -call_mock: - call: reflect.mock - args: - response: - maxInputTry: 4 - result: mock_res - -return_result: - wrapper: false - return: ${mock_res.response.body} diff --git a/DSL/Ruuter.public/services/GET/mocks/validation-mock.yml b/DSL/Ruuter.public/services/GET/mocks/validation-mock.yml deleted file mode 100644 index e4452555..00000000 --- a/DSL/Ruuter.public/services/GET/mocks/validation-mock.yml +++ /dev/null @@ -1,26 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'VALIDATION-MOCK'" - method: get - accepts: json - returns: json - namespace: service - -call_mock: - call: reflect.mock - args: - response: - project: "Bürokratt" - website: "www.kratid.ee" - result: mock_res - -call_template: - template: "[#SERVICE_PROJECT_LAYER]/validation-template" - requestType: templates - body: - response: ${mock_res.response} - result: templateResult - -return_result: - return: ${templateResult} diff --git a/DSL/Ruuter.public/services/GET/rasa/rule-names.yml b/DSL/Ruuter.public/services/GET/rasa/rule-names.yml deleted file mode 100644 index 5fd45b29..00000000 --- a/DSL/Ruuter.public/services/GET/rasa/rule-names.yml +++ /dev/null @@ -1,29 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'RULE-NAMES'" - method: get - accepts: json - returns: json - namespace: service - -getRules: - call: http.get - args: - url: "[#SERVICE_OPENSEARCH]/rules/_search?size=1000" - result: getRulesResult - -mapRulesData: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/get_rule_names" - headers: - type: 'json' - body: - hits: ${getRulesResult.response.body.hits.hits} - result: rulesData - next: returnSuccess - -returnSuccess: - return: ${rulesData.response.body} - next: end diff --git a/DSL/Ruuter.public/services/GET/secrets-with-priority.yml b/DSL/Ruuter.public/services/GET/secrets-with-priority.yml deleted file mode 100644 index 602eeff6..00000000 --- a/DSL/Ruuter.public/services/GET/secrets-with-priority.yml +++ /dev/null @@ -1,34 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SECRETS-WITH-PRIORITY'" - method: get - accepts: json - returns: json - namespace: service - -check_parameters: - switch: - - condition: ${incoming.params !== null && incoming.params.type === 'test'} - next: get_ruuter_secrets_test_priority - next: get_ruuter_secrets_prod_priority - -get_ruuter_secrets_prod_priority: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/secrets/get-with-priority" - result: results - next: return_ok - -get_ruuter_secrets_test_priority: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/secrets/get-with-priority?priority=test" - result: results - next: return_ok - -return_ok: - status: 200 - wrapper: false - return: ${results.response.body} - next: end diff --git a/DSL/Ruuter.public/services/GET/secrets.yml b/DSL/Ruuter.public/services/GET/secrets.yml deleted file mode 100644 index 60913c81..00000000 --- a/DSL/Ruuter.public/services/GET/secrets.yml +++ /dev/null @@ -1,20 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SECRETS'" - method: get - accepts: json - returns: json - namespace: service - -get_ruuter_secrets: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/secrets/get-all" - result: results - -return_ok: - status: 200 - wrapper: false - return: ${results.response.body} - next: end diff --git a/DSL/Ruuter.public/services/GET/service-settings.yml b/DSL/Ruuter.public/services/GET/service-settings.yml deleted file mode 100644 index abe4ff0f..00000000 --- a/DSL/Ruuter.public/services/GET/service-settings.yml +++ /dev/null @@ -1,19 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SERVICE-SETTINGS'" - method: get - accepts: json - returns: json - namespace: service - -updateSettings: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-settings" - result: results - -returnSuccess: - wrapper: false - status: 200 - return: ${results.response.body} diff --git a/DSL/Ruuter.public/services/GET/services/active/.guard b/DSL/Ruuter.public/services/GET/services/active/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/GET/services/active/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/GET/services/draft/.guard b/DSL/Ruuter.public/services/GET/services/draft/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/GET/services/draft/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/GET/services/inactive/.guard b/DSL/Ruuter.public/services/GET/services/inactive/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/GET/services/inactive/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/GET/services/log-by-request.yml b/DSL/Ruuter.public/services/GET/services/log-by-request.yml deleted file mode 100644 index 9ee096b1..00000000 --- a/DSL/Ruuter.public/services/GET/services/log-by-request.yml +++ /dev/null @@ -1,33 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'LOG-BY-REQUEST'" - method: get - accepts: json - returns: json - namespace: service - -get_services_stat: - call: http.post - args: - url: "[#SERVICE_OPENSEARCH]/services/_search/template" - body: - id: 'get-log-by-request' - params: ${incoming.params} - result: results - -check_result: - switch: - - condition: ${results.response.body.found === true} - next: return_ok - next: return_not_found - -return_not_found: - status: 404 - return: 'index not found' - next: end - -return_ok: - status: 200 - return: ${results.response.body._source} - next: end diff --git a/DSL/Ruuter.public/services/GET/services/log-by-service.yml b/DSL/Ruuter.public/services/GET/services/log-by-service.yml deleted file mode 100644 index 0613800a..00000000 --- a/DSL/Ruuter.public/services/GET/services/log-by-service.yml +++ /dev/null @@ -1,33 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'LOG-BY-SERVICE'" - method: get - accepts: json - returns: json - namespace: service - -get_services_stat: - call: http.post - args: - url: "[#SERVICE_OPENSEARCH]/services/_search/template" - body: - id: 'get-log-by-service' - params: ${incoming.params} - result: results - -check_result: - switch: - - condition: ${results.response.body.found === true} - next: return_ok - next: return_not_found - -return_not_found: - status: 404 - return: 'index not found' - next: end - -return_ok: - status: 200 - return: ${results.response.body._source} - next: end diff --git a/DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml b/DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml deleted file mode 100644 index 5fea8712..00000000 --- a/DSL/Ruuter.public/services/GET/services/services-detailed/nok.yml +++ /dev/null @@ -1,52 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'NOK'" - method: get - accepts: json - returns: json - namespace: service - allowlist: - params: - - field: page - type: number - description: "Parameter 'page'" - - field: page_size - type: number - description: "Parameter 'page_size'" - - field: sorting - type: string - description: "Parameter 'sorting'" - - field: order - type: string - description: "Parameter 'order'" - -getFaults: - call: http.post - args: - url: "[#SERVICE_OPENSEARCH]/ruuterlog/_search" - query: - from: ${(incoming.params.page - 1) * incoming.params.page_size} - size: ${incoming.params.page_size} - _source_excludes: "stackTrace,statusCode" - body: - sort: [{ "timestamp": { "order": "${incoming.params.order}" } }] - query: - match_phrase_prefix: - dslName: - query: "services/active" - result: getFaultsResult - -mapFaultsData: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/get-faults" - headers: - type: "json" - body: - data: { "hits": "${getFaultsResult.response.body.hits.hits}" } - result: faultsData - -returnSuccess: - wrapper: false - return: ${[faultsData.response.body, getFaultsResult.response.body.hits.total.value]} diff --git a/DSL/Ruuter.public/services/GET/services/statistics.yml b/DSL/Ruuter.public/services/GET/services/statistics.yml deleted file mode 100644 index 6b3110fc..00000000 --- a/DSL/Ruuter.public/services/GET/services/statistics.yml +++ /dev/null @@ -1,21 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'STATISTICS'" - method: get - accepts: json - returns: json - namespace: service - -get_services_stat: - call: http.post - args: - url: "[#SERVICE_OPENSEARCH]/services/_search/template" - body: - id: 'get-services-stat' - result: results - -return_ok: - status: 200 - return: ${results.response.body.hits.hits} - next: end diff --git a/DSL/Ruuter.public/services/GET/services/status.yml b/DSL/Ruuter.public/services/GET/services/status.yml deleted file mode 100644 index 779451af..00000000 --- a/DSL/Ruuter.public/services/GET/services/status.yml +++ /dev/null @@ -1,24 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'STATUS'" - method: get - accepts: json - returns: json - namespace: service - allowlist: - params: - - field: id - type: string - description: "Parameter 'id'" - -get_status: - call: http.post - args: - url: "[#SERVICE_RESQL]/status" - body: - id: ${incoming.params.id} - result: res - -return_value: - return: ${res.response.body} diff --git a/DSL/Ruuter.public/services/GET/slots.yml b/DSL/Ruuter.public/services/GET/slots.yml deleted file mode 100644 index d38375cd..00000000 --- a/DSL/Ruuter.public/services/GET/slots.yml +++ /dev/null @@ -1,26 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Get slots from OpenSearch" - method: get - accepts: json - returns: json - namespace: service - -getSlots: - call: http.post - args: - url: "[#SERVICE_OPENSEARCH]/domain/_search/template" - body: - id: "domain-objects-with-pagination" - params: - type: "slots" - filter: "" - from: 0 - size: 1000 - result: getSlotsResult - -returnSuccess: - return: ${getSlotsResult.response.body.hits.hits[0].fields.filtered_items[0]} - wrapper: false - next: end diff --git a/DSL/Ruuter.public/services/GET/steps/preferences.yml b/DSL/Ruuter.public/services/GET/steps/preferences.yml deleted file mode 100644 index 283ac696..00000000 --- a/DSL/Ruuter.public/services/GET/steps/preferences.yml +++ /dev/null @@ -1,72 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'PREFERENCES'" - method: get - accepts: json - returns: json - namespace: service - -get_user_info: - call: http.post - args: - url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" - contentType: plaintext - headers: - cookie: ${incoming.headers.cookie} - plaintext: "customJwtCookie" - result: res - -check_user_info_response: - switch: - - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} - next: assignIdCode - next: return_unauthorized - -assignIdCode: - assign: - idCode: ${res.response.body.idCode} - -get_user_step_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-user-step-preferences" - body: - user_id_code: ${idCode} - result: preferences - -check_preferences_response: - switch: - - condition: ${preferences.response.body.length > 0} - next: return_preferences - next: seed_default_user_preferences - -seed_default_user_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/seed-user-step-preferences" - body: - user_id_code: ${idCode} - result: seed_preferences_res - next: refetch_user_step_preferences - -refetch_user_step_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-user-step-preferences" - body: - user_id_code: ${idCode} - result: refetched_preferences - -return_refetched_preferences: - return: ${refetched_preferences.response.body[0]} - next: end - -return_preferences: - return: ${preferences.response.body[0]} - next: end - -return_unauthorized: - status: 401 - return: "unauthorized" - next: end diff --git a/DSL/Ruuter.public/services/GET/sticky/example.yml b/DSL/Ruuter.public/services/GET/sticky/example.yml deleted file mode 100644 index cedef902..00000000 --- a/DSL/Ruuter.public/services/GET/sticky/example.yml +++ /dev/null @@ -1,58 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'EXAMPLE'" - method: get - accepts: json - returns: json - namespace: service - allowlist: - headers: - - field: cookie - type: string - description: "Cookie field" - -check_for_cookie: - switch: - - condition: ${incoming.headers.cookie == null || incoming.headers.cookie == ""} - next: return_unauthorized - next: extract_request_data - -extract_request_data: - assign: - cookie: ${incoming.headers.cookie} - -extract_cookie_data: - call: http.post - args: - url: "[#SERVICE_RUUTER]/mocks/mock-custom-jwt-userinfo" - headers: - cookie: ${cookie} - body: - cookieName: "customJwtCookie" - result: jwtResult - next: allow_only_admins - -allow_only_admins: - switch: - - condition: ${jwtResult.response.body.response.authorities.includes("ROLE_ADMIN")} - next: get_data - next: return_unauthorized - -get_data: - call: reflect.mock - args: - response: - type: "mock-value" - id: 1234567 - result: reflectedRequest - next: return_value - -return_value: - return: ${reflectedRequest.response.body} - next: end - -return_unauthorized: - status: 401 - return: "unauthorized" - next: end diff --git a/DSL/Ruuter.public/services/POST/.guard b/DSL/Ruuter.public/services/POST/.guard deleted file mode 100644 index 4fd565b6..00000000 --- a/DSL/Ruuter.public/services/POST/.guard +++ /dev/null @@ -1,28 +0,0 @@ -check_for_cookie: - switch: - - condition: ${incoming.headers == null || incoming.headers.cookie == null} - next: guard_fail - next: authenticate - -authenticate: - template: "[#SERVICE_PROJECT_LAYER]/check-user-authority" - requestType: templates - headers: - cookie: ${incoming.headers.cookie} - result: authority_result - -check_authority_result: - switch: - - condition: ${authority_result !== "false"} - next: guard_success - next: guard_fail - -guard_success: - return: "success" - status: 200 - next: end - -guard_fail: - return: "unauthorized" - status: 401 - next: end diff --git a/DSL/Ruuter.public/services/POST/auth/.guard b/DSL/Ruuter.public/services/POST/auth/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/POST/auth/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/POST/auth/login.yml b/DSL/Ruuter.public/services/POST/auth/login.yml deleted file mode 100644 index 30771935..00000000 --- a/DSL/Ruuter.public/services/POST/auth/login.yml +++ /dev/null @@ -1,101 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'LOGIN'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: login - type: string - description: "Body field 'login'" - - field: password - type: string - description: "Body field 'password'" - -check_for_required_parameters: - switch: - - condition: ${incoming.body.login == null || incoming.body.password == null} - next: return_incorrect_request - next: extract_request_data - -extract_request_data: - assign: - login: ${incoming.body.login} - password: ${incoming.body.password} - next: login_user - -login_user: - call: http.post - args: - url: "[#SERVICE_USERS_RESQL]/get-user-by-login" - body: - login: ${login} - password: ${password} - result: results - next: check_login_result - -check_login_result: - switch: - - condition: ${results.response.body.length != 0} - next: get_session_length - next: return_user_not_found - -get_session_length: - call: http.post - args: - url: "[#SERVICE_USERS_RESQL]/get-configuration" - body: - key: "session_length" - result: session_result - next: check_session_length_result - -check_session_length_result: - switch: - - condition: ${session_result.response.body.length != 0} - next: generate_cookie - next: return_session_length_not_found - -generate_cookie: - call: http.post - args: - url: "[#SERVICE_TIM]/jwt/custom-jwt-generate" - body: - JWTName: "customJwtCookie" - expirationInMinutes: ${session_result.response.body[0].value} - content: ${results.response.body[0]} - result: cookie_result - next: assign_cookie - -assign_cookie: - assign: - setCookie: - customJwtCookie: ${cookie_result.response.body.token} - Domain: "[#DOMAIN]" - Secure: true - HttpOnly: true - SameSite: "Lax" - next: return_value - -return_value: - headers: - Set-Cookie: ${setCookie} - return: ${cookie_result.response.body.token} - next: end - -return_session_length_not_found: - status: 400 - return: "Could not fetch session length" - next: end - -return_user_not_found: - status: 400 - return: "User Not Found" - next: end - -return_incorrect_request: - status: 400 - return: "Required parameter(s) missing" - next: end diff --git a/DSL/Ruuter.public/services/POST/csv.yml b/DSL/Ruuter.public/services/POST/csv.yml deleted file mode 100644 index 52e1f177..00000000 --- a/DSL/Ruuter.public/services/POST/csv.yml +++ /dev/null @@ -1,50 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CSV'" - method: post - accepts: json - returns: data - namespace: service - allowlist: - body: - - field: data - type: string - description: "Body field 'data'" - - field: del - type: string - description: "Body field 'del'" - - field: qul - type: string - description: "Body field 'qul'" - -check_for_required_parameters: - switch: - - condition: ${incoming.body == null} - next: return_incorrect_request - next: get_csv - -get_csv: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/get-csv" - headers: - type: 'csv' - body: - data: ${incoming.body.data} - del: ${incoming.body.del} - qul: ${incoming.body.qul} - layout: false - result: result - -return_value: - wrapper: false - headers: - Content-disposition: "attachment;filename=result.csv" - return: ${result.response.body.response} - next: end - -return_incorrect_request: - status: 400 - return: 'missing parameters' - next: end diff --git a/DSL/Ruuter.public/services/POST/dates/calculate-difference.yml b/DSL/Ruuter.public/services/POST/dates/calculate-difference.yml deleted file mode 100644 index 2c5afd4e..00000000 --- a/DSL/Ruuter.public/services/POST/dates/calculate-difference.yml +++ /dev/null @@ -1,82 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CALCULATE-DIFFERENCE'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: startDate - type: string - description: "Body field 'startDate'" - - field: endDate - type: string - description: "Body field 'endDate'" - params: - - field: outputType - type: string - description: "Parameter 'outputType'" - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: return_incorrect_request - next: check_for_required_parameters - -check_for_required_parameters: - switch: - - condition: ${incoming.body.startDate == null} - next: return_incorrect_request - next: extract_request_data - -extract_request_data: - assign: - outputType: ${incoming.params.outputType ?? 'days'} - startDate: ${incoming.body.startDate} - endDate: ${incoming.body.endDate ?? new Date().toISOString()} - -check_is_end_date_greater_than_start_date: - switch: - - condition: ${new Date(endDate) < new Date(startDate)} - next: return_incorrect_date - -check_is_output_type_valid: - switch: - - condition: ${outputType !== null && !['years','months','hours','days','minutes', 'seconds'].includes(outputType)} - next: return_incorrect_output_type - -calculate_difference: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/calculate-date-difference" - headers: - type: "json" - body: - startDate: ${startDate} - endDate: ${endDate} - outputType: ${outputType} - layout: false - result: result - next: return_value - -return_value: - status: 200 - return: ${result.response.body} - next: end - -return_incorrect_date: - status: 400 - return: "Start date can not be greater than the end date/ today" - next: end - -return_incorrect_output_type: - status: 400 - return: "Output type must be: years, months, hours, days, minutes or seconds" - next: end - -return_incorrect_request: - status: 400 - return: "Start date is required" - next: end diff --git a/DSL/Ruuter.public/services/POST/endpoints/common.yml b/DSL/Ruuter.public/services/POST/endpoints/common.yml deleted file mode 100644 index 611faca7..00000000 --- a/DSL/Ruuter.public/services/POST/endpoints/common.yml +++ /dev/null @@ -1,48 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'Common'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: pagination - type: string - description: "Body field 'pagination'" - - field: page - type: string - description: "Body field 'page'" - - field: pageSize - type: string - description: "Body field 'pageSize'" - - field: sorting - type: string - description: "Body field 'sorting'" - - field: search - type: string - description: "Body field 'search'" - -extract_request_data: - assign: - pagination: ${incoming.body.pagination} - page: ${incoming.body.page} - pageSize: ${incoming.body.pageSize} - sorting: ${incoming.body.sorting} - search: ${incoming.body.search} - -get_common_endpoints: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/get_common_endpoints" - body: - pagination: ${pagination} - page: ${page} - page_size: ${pageSize} - sorting: ${sorting} - search: ${search} - result: res - -return_result: - return: ${res.response.body} diff --git a/DSL/Ruuter.public/services/POST/file/rename.yml b/DSL/Ruuter.public/services/POST/file/rename.yml deleted file mode 100644 index c0e7b1c6..00000000 --- a/DSL/Ruuter.public/services/POST/file/rename.yml +++ /dev/null @@ -1,42 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'RENAME'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: current_path - type: string - description: "Body field 'current_path'" - - field: new_path - type: string - description: "Body field 'new_path'" - -check_for_body: - switch: - - condition: ${incoming.body == null || incoming.body.current_path == null || incoming.body.new_path == null} - next: return_incorrect_request - -rename_file: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/rename" - headers: - type: 'json' - body: - current_path: ${incoming.body.current_path} - new_path: ${incoming.body.new_path} - layout: false - result: result - -return_value: - status: 200 - return: ${result.response.body} - next: end - -return_incorrect_request: - status: 400 - return: "Start date is required" diff --git a/DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml b/DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml deleted file mode 100644 index eabaf3f8..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/RBAC-mock.yml +++ /dev/null @@ -1,50 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'RBAC-MOCK'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: allowedRoles - type: object - description: "Body field 'allowedRoles'" - - field: userId - type: string - description: "Body field 'userId'" - - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: missing_parameter - -extract_request_data: - assign: - userId: ${incoming.body.userId} - allowedRoles: ${incoming.body.allowedRoles.sort()} - -check_for_required_parameters: - switch: - - condition: ${userId === null || allowedRoles === null} - next: missing_parameter - next: fetch_user_roles_from_db - -fetch_user_roles_from_db: - call: reflect.mock - args: - response: - isAllowed: TRUE - result: result - -return_value: - status: 200 - return: "${result.response.body}" - next: end - -missing_parameter: - status: 400 - return: "userId, allowedRoles - missing" - next: end diff --git a/DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml b/DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml deleted file mode 100644 index 261e2273..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/dates/calculate-difference.yml +++ /dev/null @@ -1,136 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CALCULATE-DIFFERENCE'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: startDate - type: string - description: "Body field 'startDate'" - - field: endDate - type: string - description: "Body field 'endDate'" - params: - - field: outputType - type: string - description: "Parameter 'outputType'" - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: return_incorrect_request - next: check_for_required_parameters - -check_for_required_parameters: - switch: - - condition: ${incoming.body.startDate == null} - next: return_incorrect_request - next: extract_request_data - -extract_request_data: - assign: - outputType: ${incoming.params.outputType ?? 'days'} - startDate: ${incoming.body.startDate} - endDate: ${incoming.body.endDate ?? new Date().toISOString()} - -check_is_end_date_greater_than_start_date: - switch: - - condition: ${new Date(endDate) < new Date(startDate)} - next: return_incorrect_date - -check_is_output_type_valid: - switch: - - condition: ${outputType !== null && !['years','months','hours','days','minutes', 'seconds'].includes(outputType)} - next: return_incorrect_output_type - -calculate_difference: - switch: - - condition: ${outputType === 'years'} - next: calculate_difference_in_years - - condition: ${outputType === 'months'} - next: calculate_difference_in_months - - condition: ${outputType === 'hours'} - next: calculate_difference_in_hours - - condition: ${outputType === 'minutes'} - next: calculate_difference_in_minutes - - condition: ${outputType === 'seconds'} - next: calculate_difference_in_seconds - next: calculate_difference_in_days - -calculate_difference_in_years: - call: reflect.mock - args: - response: { - result: 0 - } - result: result - next: return_value - -calculate_difference_in_months: - call: reflect.mock - args: - response: { - result: 11 - } - result: result - next: return_value - -calculate_difference_in_days: - call: reflect.mock - args: - response: { - result: 1 - } - result: result - next: return_value - -calculate_difference_in_hours: - call: reflect.mock - args: - response: { - result: 24 - } - result: result - next: return_value - -calculate_difference_in_minutes: - call: reflect.mock - args: - response: { - result: 59 - } - result: result - next: return_value - -calculate_difference_in_seconds: - call: reflect.mock - args: - response: { - result: 201 - } - result: result - next: return_value - -return_value: - status: 200 - return: ${result.response.body} - next: end - -return_incorrect_date: - status: 400 - return: "Start date can not be greater than the end date/ today" - next: end - -return_incorrect_output_type: - status: 400 - return: "Output type must be: years, months, hours, days, minutes or seconds" - next: end - -return_incorrect_request: - status: 400 - return: "Start date is required" - next: end diff --git a/DSL/Ruuter.public/services/POST/mocks/service-settings.yml b/DSL/Ruuter.public/services/POST/mocks/service-settings.yml deleted file mode 100644 index c22da07f..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/service-settings.yml +++ /dev/null @@ -1,19 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SERVICE-SETTINGS'" - method: post - accepts: json - returns: json - namespace: service - -call_mock: - call: reflect.mock - args: - response: - maxInputTry: 4 - result: mock_res - -return_result: - wrapper: false - return: ${mock_res.response.body} diff --git a/DSL/Ruuter.public/services/POST/mocks/services/add.yml b/DSL/Ruuter.public/services/POST/mocks/services/add.yml deleted file mode 100644 index 3b1e46c2..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/services/add.yml +++ /dev/null @@ -1,44 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ADD'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: name - type: string - description: "Body field 'name'" - - field: description - type: string - description: "Body field 'description'" - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: return_incorrect_request - next: check_for_required_parameters - -check_for_required_parameters: - switch: - - condition: ${incoming.body.name == null || incoming.body.description == null} - next: return_incorrect_request - next: service_add - -service_add: - call: reflect.mock - args: - response: {} - result: createdService - -return_value: - status: 201 - wrapper: FALSE - return: "" - next: end - -return_incorrect_request: - status: 400 - return: "Required parameter(s) missing" diff --git a/DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml b/DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml deleted file mode 100644 index e8ea3f87..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/services/open-api-spec-mock.yml +++ /dev/null @@ -1,17 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'OPEN-API-SPEC-MOCK'" - method: post - accepts: json - returns: json - namespace: service - -get_message: - call: http.get - args: - url: https://petstore3.swagger.io/api/v3/openapi.json - result: res - -return_value: - return: ${res.response.body} diff --git a/DSL/Ruuter.public/services/POST/mocks/user-info.yml b/DSL/Ruuter.public/services/POST/mocks/user-info.yml deleted file mode 100644 index ac0b322a..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/user-info.yml +++ /dev/null @@ -1,52 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'USER-INFO'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: isTokenExpired - type: boolean - description: "Body field 'isTokenExpired'" - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: return_user_info_mock - next: do_decision - -do_decision: - switch: - - condition: ${incoming.body.isTokenExpired === true} - next: return_unauthorized - next: return_user_info_mock - -return_unauthorized: - status: 401 - return: "Unauthorized" - next: end - -return_user_info_mock: - call: reflect.mock - args: - response: - sub: "" - firstName: "MARY ÄNN" - idCode: "EE60001019906" - displayName: "MARY ÄNN" - iss: "test.buerokratt.ee" - exp: 1670250948 - login: "EE60001019906" - iat: 1670243748 - jti: "e14a5084-3b30-4a55-8720-c2ee22f43c2c" - authorities: [ - "ROLE_ADMINISTRATOR" - ] - result: reflected_request - next: return_value - -return_value: - return: ${reflected_request.response} diff --git a/DSL/Ruuter.public/services/POST/mocks/validation-mock.yml b/DSL/Ruuter.public/services/POST/mocks/validation-mock.yml deleted file mode 100644 index fb8cb4a7..00000000 --- a/DSL/Ruuter.public/services/POST/mocks/validation-mock.yml +++ /dev/null @@ -1,26 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'VALIDATION-MOCK'" - method: post - accepts: json - returns: json - namespace: service - -call_mock: - call: reflect.mock - args: - response: - project: "Bürokratt" - website: "www.kratid.ee" - result: mock_res - -call_template: - template: "[#SERVICE_PROJECT_LAYER]/validation-template" - requestType: templates - body: - response: ${mock_res.response} - result: templateResult - -return_result: - return: ${templateResult} diff --git a/DSL/Ruuter.public/services/POST/rasa/rules/add.yml b/DSL/Ruuter.public/services/POST/rasa/rules/add.yml deleted file mode 100644 index c41581ab..00000000 --- a/DSL/Ruuter.public/services/POST/rasa/rules/add.yml +++ /dev/null @@ -1,129 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ADD'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: data - type: object - description: "Body field 'data'" - headers: - - field: cookie - type: string - description: "Cookie field" - -assign_values: - assign: - body: ${incoming.body.data} - -validateRules: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/validate/validate-stories-rules" - body: - story: ${body} - category: "rules" - result: validateRulesResult - -validateRulesCheck: - switch: - - condition: ${validateRulesResult.response.body.result == true} - next: getRuleNames - next: returnDuplicateIntentOrEntity - -getRuleNames: - call: http.get - args: - url: "[#SERVICE_RUUTER]/rasa/rule-names" - headers: - cookie: ${incoming.headers.cookie} - result: ruleResult - -validateRuleName: - switch: - - condition: ${ruleResult.response.body.response.names == null} - next: getFileLocations - - condition: ${!ruleResult.response.body.response.names.includes(body.id)} - next: getFileLocations - next: returnStoryExists - -getFileLocations: - call: http.get - args: - url: "[#SERVICE_RUUTER]/internal/return-file-locations" - headers: - cookie: ${incoming.headers.cookie} - result: fileLocations - -getRulesFile: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/read-file" - body: - file_path: ${fileLocations.response.body.response.rules_location} - result: ruleFile - -convertYamlToJson: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/yaml_to_json" - body: - file: ${ruleFile.response.body.file} - result: rulesData - -mergeRules: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/merge" - body: - array1: ${rulesData.response.body.rules ?? []} - array2: ${[body]} - iteratee: "rule" - result: mergedRules - -convertJsonToYaml: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/json-to-yaml-stories" - headers: - content-type: "application/json" - body: - rules: ${mergedRules.response.body.array} - result: rulesYaml - -saveRulesFile: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: ${fileLocations.response.body.response.rules_location} - content: ${rulesYaml.response.body.json} - result: fileResult - next: updateOpenSearch - -updateOpenSearch: - call: http.post - args: - url: "[#SERVICES_PIPELINE]/bulk/rules/rule" - body: - input: ${rulesYaml.response.body.json} - result: updateSearchResult - next: returnSuccess - -returnSuccess: - return: "Rule added" - next: end - -returnRuleExists: - return: "Rule exists" - status: 409 - next: end - -returnDuplicateIntentOrEntity: - return: "Rule may not have duplicate consecutive intents or entities" - status: 406 - next: end diff --git a/DSL/Ruuter.public/services/POST/saveJsonToYml.yml b/DSL/Ruuter.public/services/POST/saveJsonToYml.yml deleted file mode 100644 index 4305cb28..00000000 --- a/DSL/Ruuter.public/services/POST/saveJsonToYml.yml +++ /dev/null @@ -1,39 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SAVEJSONTOYML'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: result - type: string - description: "Body field 'result'" - params: - - field: location - type: string - description: "Parameter 'location'" - -toYml: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data" - body: - data: ${incoming.body.result} - result: r - -saveFile: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: ${incoming.params.location} - content: ${r.response.body.yaml} - result: fileResult - -saved_seccessfully: - reloadDsl: true - return: "" - next: end diff --git a/DSL/Ruuter.public/services/POST/service-by-id.yml b/DSL/Ruuter.public/services/POST/service-by-id.yml deleted file mode 100644 index 45fd1b03..00000000 --- a/DSL/Ruuter.public/services/POST/service-by-id.yml +++ /dev/null @@ -1,90 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SERVICE-BY-ID'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - header: - - field: cookie - type: string - description: "Cookie field" - body: - - field: id - type: string - description: "Body field 'id'" - - field: search - type: string - description: "Body field 'search'" - -extract_request_data: - assign: - id: ${incoming.body.id} - search: ${incoming.body.search} - -get_user_info: - call: http.post - args: - url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" - contentType: plaintext - headers: - cookie: ${incoming.headers.cookie} - plaintext: "customJwtCookie" - result: res - -check_user_info_response: - switch: - - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} - next: assignIdCode - next: return_unauthorized - -assignIdCode: - assign: - idCode: ${res.response.body.idCode} - -get_service_by_id: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-service-by-id" - body: - id: ${id} - result: service_results - -get_endpoints_by_service_id: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/get_endpoints_by_service_id" - body: - id: ${id} - user_id_code: ${idCode} - search: ${search} - result: endpoints_results - -prepare_results: - assign: - results: - id: ${service_results.response.body[0].id} - name: ${service_results.response.body[0].name} - description: ${service_results.response.body[0].description} - slot: ${service_results.response.body[0].slot} - examples: ${service_results.response.body[0].examples} - entities: ${service_results.response.body[0].entities} - state: ${service_results.response.body[0].state} - type: ${service_results.response.body[0].type} - isCommon: ${service_results.response.body[0].isCommon} - structure: ${service_results.response.body[0].structure} - endpoints: ${endpoints_results.response.body} - serviceId: ${service_results.response.body[0].serviceId} - -return_ok: - status: 200 - wrapper: false - return: ${results} - next: end - -return_unauthorized: - status: 401 - return: "unauthorized" - next: end diff --git a/DSL/Ruuter.public/services/POST/service-settings.yml b/DSL/Ruuter.public/services/POST/service-settings.yml deleted file mode 100644 index af9021c4..00000000 --- a/DSL/Ruuter.public/services/POST/service-settings.yml +++ /dev/null @@ -1,29 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SERVICE-SETTINGS'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: name - type: string - description: "Body field 'name'" - - field: value - type: string - description: "Body field 'value'" - -updateSettings: - call: http.post - args: - url: "[#SERVICE_RESQL]/update-settings" - body: - name: ${incoming.body.name} - value: ${incoming.body.value} - result: getResult - -returnSuccess: - status: 200 - return: 'ok' diff --git a/DSL/Ruuter.public/services/POST/services.yml b/DSL/Ruuter.public/services/POST/services.yml deleted file mode 100644 index 8188fac2..00000000 --- a/DSL/Ruuter.public/services/POST/services.yml +++ /dev/null @@ -1,43 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SERVICES'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: page - type: number - description: "Body field 'page'" - - field: page_size - type: number - description: "Body field 'page_size'" - - field: sorting - type: string - description: "Body field 'sorting'" - - field: is_common - type: boolean - description: "Body field 'is_common'" - -get_services_list: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-services-list" - body: - page: ${incoming.body.page} - page_size: ${incoming.body.page_size} - sorting: ${incoming.body.sorting} - is_common: ${incoming.body.is_common} - limit: 400 - result: services_res - -assign_services_result: - assign: - services: ${services_res.response.body} - -return_ok: - status: 200 - return: ${[services]} - next: end diff --git a/DSL/Ruuter.public/services/POST/services/active/.guard b/DSL/Ruuter.public/services/POST/services/active/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/POST/services/active/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml b/DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml deleted file mode 100644 index ff07aba5..00000000 --- a/DSL/Ruuter.public/services/POST/services/active/Broneeringu_kinnitus.yml +++ /dev/null @@ -1,65 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: Teenuse test, mis kinnitab kasutaja broneeringu ja tagastab - sisestatud väärtuse. - method: post - accepts: json - returns: json - namespace: service - allowList: - body: - - field: chatId - type: string - description: The chat ID for the message - - field: authorId - type: string - description: The author ID for the message - - field: input - type: object - description: The Input from the user -prepare: - assign: - chatId: ${incoming.body.chatId} - authorId: ${incoming.body.authorId} - input: ${incoming.body.input} - buttons: [] - res: - result: "" - next: assign_1 -assign_1: - assign: - entity: ${incoming.body.input[0]} - next: sõnum_kliendile_1 -sõnum_kliendile_1: - assign: - res: - result: "Teie broneering on registreeritud. Kohtumiseni! Entity: ${entity}" - next: teenuse_lõpetamine_1 -teenuse_lõpetamine_1: - template: "[#SERVICE_PROJECT_LAYER]/end-conversation" - requestType: templates - body: - message: "" - result: teenuse_lõpetamine_1_result - next: format_messages -format_messages: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" - headers: - type: json - body: - data: - botMessages: ${[res]} - chatId: ${chatId ?? ''} - authorId: ${authorId ?? ''} - authorFirstName: "" - authorLastName: "" - authorTimestamp: ${new Date().toISOString()} - created: ${new Date().toISOString()} - buttons: ${buttons ?? []} - result: formatMessage - next: service-end -service-end: - return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml b/DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml deleted file mode 100644 index 18b213d7..00000000 --- a/DSL/Ruuter.public/services/POST/services/active/Kalastusloa_uuendamise_teade.yml +++ /dev/null @@ -1,60 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: Teenuse test, mis teavitab kasutajat, et tema kalastusluba vajab uuendamist. - method: post - accepts: json - returns: json - namespace: service - allowList: - body: - - field: chatId - type: string - description: The chat ID for the message - - field: authorId - type: string - description: The author ID for the message - - field: input - type: object - description: The Input from the user -prepare: - assign: - chatId: ${incoming.body.chatId} - authorId: ${incoming.body.authorId} - input: ${incoming.body.input} - buttons: [] - res: - result: "" - next: send_message_to_client_1 -send_message_to_client_1: - assign: - res: - result: Su kalastusluba vajab uuendamist! - next: end_service_1 -end_service_1: - template: "[#SERVICE_PROJECT_LAYER]/end-conversation" - requestType: templates - body: - message: "" - result: end_service_1_result - next: format_messages -format_messages: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" - headers: - type: json - body: - data: - botMessages: ${[res]} - chatId: ${chatId ?? ''} - authorId: ${authorId ?? ''} - authorFirstName: "" - authorLastName: "" - authorTimestamp: ${new Date().toISOString()} - created: ${new Date().toISOString()} - buttons: ${buttons ?? []} - result: formatMessage - next: service-end -service-end: - return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml b/DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml deleted file mode 100644 index df123c09..00000000 --- a/DSL/Ruuter.public/services/POST/services/active/Koolivaheajad.yml +++ /dev/null @@ -1,63 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: Kasutaja soovib infot koolivaheaegade kohta antud õppeaastal. - method: post - accepts: json - returns: json - namespace: service - allowList: - body: - - field: chatId - type: string - description: The chat ID for the message - - field: authorId - type: string - description: The author ID for the message - - field: input - type: object - description: The Input from the user -prepare: - assign: - chatId: ${incoming.body.chatId} - authorId: ${incoming.body.authorId} - input: ${incoming.body.input} - buttons: [] - res: - result: "" - next: sõnum_kliendile_1 -sõnum_kliendile_1: - assign: - res: - result: E 20. oktoober 2025 - P 26. oktoober 2025 Sügisvaheaeg E 22. detsember - 2025 - P 11. jaanuar 2026 Jõuluvaheaeg E 23. veebruar 2026 - P 01. märts - 2026 Talvevaheaeg E 13. aprill 2026 - P 19. aprill 2026 Kevadvaheaeg K - 17. juuni 2026 - E 31. august 2026 Suvevaheaeg - next: teenuse_lõpetamine_1 -teenuse_lõpetamine_1: - template: "[#SERVICE_PROJECT_LAYER]/end-conversation" - requestType: templates - body: - message: "" - result: teenuse_lõpetamine_1_result - next: format_messages -format_messages: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" - headers: - type: json - body: - data: - botMessages: ${[res]} - chatId: ${chatId ?? ''} - authorId: ${authorId ?? ''} - authorFirstName: "" - authorLastName: "" - authorTimestamp: ${new Date().toISOString()} - created: ${new Date().toISOString()} - buttons: ${buttons ?? []} - result: formatMessage - next: service-end -service-end: - return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml b/DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml deleted file mode 100644 index 67373c3d..00000000 --- a/DSL/Ruuter.public/services/POST/services/active/Lihtne_test_teenus.yml +++ /dev/null @@ -1,61 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: Lihtne testteenus, mis küsib kasutajalt valikvastustega küsimusi ja - suunab vastavalt vastusele järgmisse sammu. - method: post - accepts: json - returns: json - namespace: service - allowList: - body: - - field: chatId - type: string - description: The chat ID for the message - - field: authorId - type: string - description: The author ID for the message - - field: input - type: object - description: The Input from the user -prepare: - assign: - chatId: ${incoming.body.chatId} - authorId: ${incoming.body.authorId} - input: ${incoming.body.input} - buttons: [] - res: - result: "" - next: multi_choice_question_1 -multi_choice_question_1: - assign: - buttons: - - id: "1" - title: Jah - payload: "#service, /POST/services/active/lihtne_teenus_test_mcq_1_0" - - id: "2" - title: Ei - payload: "#service, /POST/services/active/lihtne_teenus_test_mcq_1_1" - res: - result: lithsa teenuse küsims - next: format_messages -format_messages: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" - headers: - type: json - body: - data: - botMessages: ${[res]} - chatId: ${chatId ?? ''} - authorId: ${authorId ?? ''} - authorFirstName: "" - authorLastName: "" - authorTimestamp: ${new Date().toISOString()} - created: ${new Date().toISOString()} - buttons: ${buttons ?? []} - result: formatMessage - next: service-end -service-end: - return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml b/DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml deleted file mode 100644 index 0f858468..00000000 --- a/DSL/Ruuter.public/services/POST/services/active/customer_feedback.yml +++ /dev/null @@ -1,82 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: Description placeholder for 'customer_feedback' - method: post - accepts: json - returns: json - namespace: service - allowList: - body: - - field: chatId - type: string - description: The chat ID for the message - - field: authorId - type: string - description: The author ID for the message - - field: input - type: object - description: The Input from the user -prepare: - assign: - chatId: ${incoming.body.chatId} - authorId: ${incoming.body.authorId} - input: ${incoming.body.input} - buttons: [] - res: - result: "" - next: assign_1 -assign_1: - assign: - customer_rating: ${incoming.body.input[0]} - next: condition_1 -condition_1: - switch: - - condition: ${customer_rating > 3} - next: send_message_to_client_1 - next: send_message_to_client_2 -send_message_to_client_1: - assign: - res: - result: Thank you for your positive feedback with ${customer_rating} - next: end_service_1 -send_message_to_client_2: - assign: - res: - result: Thank you for your feedback with rating ${customer_rating} . and we are - trying to improve the system - next: end_service_2 -end_service_1: - template: "[#SERVICE_PROJECT_LAYER]/end-conversation" - requestType: templates - body: - message: "" - result: end_service_1_result - next: format_messages -end_service_2: - template: "[#SERVICE_PROJECT_LAYER]/end-conversation" - requestType: templates - body: - message: "" - result: end_service_2_result - next: format_messages -format_messages: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" - headers: - type: json - body: - data: - botMessages: ${[res]} - chatId: ${chatId ?? ''} - authorId: ${authorId ?? ''} - authorFirstName: "" - authorLastName: "" - authorTimestamp: ${new Date().toISOString()} - created: ${new Date().toISOString()} - buttons: ${buttons ?? []} - result: formatMessage - next: service-end -service-end: - return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/add.yml b/DSL/Ruuter.public/services/POST/services/add.yml deleted file mode 100644 index a9a81ec4..00000000 --- a/DSL/Ruuter.public/services/POST/services/add.yml +++ /dev/null @@ -1,177 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ADD'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - header: - - field: cookie - type: string - description: "Cookie field" - body: - - field: content - type: string - description: "Body field 'content'" - - field: description - type: string - description: "Body field 'description'" - - field: entities - type: object - description: "Body field 'Entities'" - - field: examples - type: object - description: "Body field 'Examples'" - - field: isCommon - type: boolean - description: "Body field 'isCommon'" - - field: name - type: string - description: "Body field 'name'" - - field: serviceId - type: string - description: "Body field 'serviceId'" - - field: structure - type: object - description: "Body field 'structure'" - - field: type - type: string - description: "Body field 'type'" - - field: updateServiceDb - type: boolean - description: "Body field 'updateServiceDb'" - - field: state - type: string - description: "Body field 'state'" - -check_for_required_parameters: - switch: - - condition: ${incoming.body.name == null || incoming.body.description == null || incoming.body.type == null || incoming.body.content == null || incoming.body.serviceId == null || incoming.body.isCommon == null || incoming.body.structure == null} - next: return_incorrect_request - next: extract_request_data - -extract_request_data: - assign: - name: ${incoming.body.name} - description: ${incoming.body.description} - slot: ${""} - entities: ${incoming.body.entities} - examples: ${incoming.body.examples} - type: ${incoming.body.type.toUpperCase()} - content: ${incoming.body.content} - serviceId: ${incoming.body.serviceId} - isCommon: ${incoming.body.isCommon} - structure: ${incoming.body.structure} - state: ${incoming.body.state} - next: check_if_update_service_db - -check_if_update_service_db: - switch: - - condition: ${incoming.body.updateServiceDb === true} - next: check_if_name_exists - next: delete_all_mcq_files - -check_if_name_exists: - call: http.post - args: - url: "[#SERVICE_RESQL]/services/check_name_exist" - body: - name: ${name} - result: name_exists_res - next: check_name_exists_result - -check_name_exists_result: - switch: - - condition: ${name_exists_res.response.body[0].nameExists} - next: return_name_already_exists - next: service_add - -service_add: - call: http.post - args: - url: "[#SERVICE_RESQL]/add" - body: - name: ${name} - description: ${description} - slot: ${slot} - entities: ${entities} - examples: ${examples} - ruuter_type: ${type} - service_id: ${serviceId} - is_common: ${isCommon} - state: ${state} - structure: ${structure} - result: createdService - next: convert_json_content_to_yml - -delete_all_mcq_files: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" - body: - path: "[#RUUTER_SERVICES_PATH]/${type}/services/draft" - keyword: "${name}_" - result: deleteRes - -convert_json_content_to_yml: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data" - body: - data: ${content} - result: ymlResult - -check_for_type: - switch: - - condition: ${type === 'GET'} - next: add_get_dsl - next: add_post_dsl - -add_get_dsl: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: "[#RUUTER_SERVICES_GET_PATH]/draft/${name}.tmp" - content: ${ymlResult.response.body.yaml} - result: results - next: check_result - -add_post_dsl: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: "[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp" - content: ${ymlResult.response.body.yaml} - result: results - next: check_result - -check_result: - switch: - - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} - next: return_ok - next: return_bad_request - -return_ok: - reloadDsl: true - status: 200 - return: ${results.response.body.message} - next: end - -return_bad_request: - status: 400 - return: ${results.response.body.message} - next: end - -return_incorrect_request: - status: 400 - return: "Required parameter(s) missing" - next: end - -return_name_already_exists: - status: 409 - return: "Service name already exists" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/create-endpoint.yml b/DSL/Ruuter.public/services/POST/services/create-endpoint.yml deleted file mode 100644 index 2c73fd45..00000000 --- a/DSL/Ruuter.public/services/POST/services/create-endpoint.yml +++ /dev/null @@ -1,46 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Creates a new endpoint" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: endpointId - type: string - description: "Endpoint UUID" - - field: name - type: string - description: "Endpoint name" - - field: type - type: string - description: "Endpoint type" - - field: isCommon - type: boolean - description: "Endpoint common status" - - field: serviceId - type: string - description: "Service UUID" - - field: definitions - type: object - description: "Endpoint definitions" - -create_endpoint: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/create_endpoint" - body: - endpointId: ${incoming.body.endpointId} - name: ${incoming.body.name} - type: ${incoming.body.type} - isCommon: ${incoming.body.isCommon} - serviceId: ${incoming.body.serviceId ?? ''} - definitions: ${incoming.body.definitions} - result: res - -return_ok: - status: 200 - return: "Endpoint created" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/delete-endpoint.yml b/DSL/Ruuter.public/services/POST/services/delete-endpoint.yml deleted file mode 100644 index f585495e..00000000 --- a/DSL/Ruuter.public/services/POST/services/delete-endpoint.yml +++ /dev/null @@ -1,34 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Deletes an endpoint" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: id - type: string - description: "Endpoint UUID" - -delete_endpoint: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/delete_endpoint" - body: - id: ${incoming.body.id} - result: res - -remove_from_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/remove_endpoint_from_preferences" - body: - endpoint_id: ${incoming.body.id} - result: preferences_res - -return_ok: - status: 200 - return: "Endpoint deleted" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/delete.yml b/DSL/Ruuter.public/services/POST/services/delete.yml deleted file mode 100644 index c9a7bef5..00000000 --- a/DSL/Ruuter.public/services/POST/services/delete.yml +++ /dev/null @@ -1,155 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'DELETE'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - headers: - - field: cookie - type: string - description: "Cookie field" - body: - - field: id - type: string - description: "Body field 'id'" - - field: type - type: string - description: "Body field 'type'" - -check_for_required_parameters: - switch: - - condition: ${incoming.body.id == null || incoming.body.type == null} - next: return_incorrect_request - next: extract_request_data - -extract_request_data: - assign: - id: ${incoming.body.id} - ruuter_type: ${incoming.body.type.toUpperCase()} - next: get_service_name - -get_service_name: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-service-name-by-id" - body: - id: ${id} - result: name_res - next: get_current_status - -get_current_status: - call: http.post - args: - url: "[#SERVICE_RESQL]/status" - body: - id: ${id} - result: status_res - next: check_status - -check_status: - switch: - - condition: ${status_res.response.body[0].currentState === 'active'} - next: return_cannot_delete_active_service - - condition: ${status_res.response.body[0].currentState === 'ready'} - next: assign_draft_path - next: assign_old_path - -assign_old_path: - assign: - old_file_status_path: ${status_res.response.body[0].currentState} - next: delete_service - -assign_draft_path: - assign: - old_file_status_path: "draft" - next: delete_service - -delete_service: - call: http.post - args: - url: "[#SERVICE_RESQL]/delete-service" - body: - id: ${id} - result: res - next: check_service_file_exists - -check_service_file_exists: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/exists" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name_res.response.body[0].name}.tmp" - result: service_file_exists - next: validate_service_file_exists - -validate_service_file_exists: - switch: - - condition: ${!!service_file_exists.response.body} - next: delete_deactivated_service - next: delete_endpoints_by_service_id - -delete_deactivated_service: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/delete" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name_res.response.body[0].name}.tmp" - result: results - next: check_result - -check_result: - switch: - - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} - next: delete_endpoints_by_service_id - next: return_bad_request - -delete_endpoints_by_service_id: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/delete_endpoints_by_service_id" - body: - serviceId: ${id} - result: delete_endpoint_results - next: remove_service_endpoints_from_preferences - -remove_service_endpoints_from_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/remove_service_endpoints_from_preferences" - body: - serviceId: ${id} - result: remove_preferences_results - next: delete_mcq_files - -delete_mcq_files: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" - body: - path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" - keyword: "${name_res.response.body[0].name}_" - result: deleted_mcq - -return_ok: - reloadDsl: true - status: 200 - return: "Service Deleted Successfully" - next: end - -return_bad_request: - status: 400 - return: ${results.response.body.message} - next: end - -return_incorrect_request: - status: 400 - return: "Required parameter(s) missing" - next: end - -return_cannot_delete_active_service: - status: 400 - return: "Cannot delete active service" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml b/DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml deleted file mode 100644 index b38fcd43..00000000 --- a/DSL/Ruuter.public/services/POST/services/domain-intent-service-link.yml +++ /dev/null @@ -1,157 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'domain-intent-service-link'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: intent - type: string - description: "Body field 'intent'" - - field: serviceName - type: string - description: "Body field 'serviceName'" - - field: serviceMethod - type: string - description: "Body field 'serviceMethod'" - - field: serviceSlot - type: string - description: "Slot input" - - field: type - type: string - description: "Body field 'type'" - header: - - field: cookie - type: string - description: "Cookie field" - -extract_request_data: - assign: - serviceName: "${incoming.body.serviceName || ''}" - serviceMethod: "${incoming.body.serviceMethod || 'POST'}" - serviceSlot: ", ({${incoming.body.serviceSlot ? incoming.body.serviceSlot : ''}})" - intent: "${incoming.body.intent}" - type: "${incoming.body.type}" - service_path: "#service, /${serviceMethod}/services/active/${serviceName + (incoming.body.serviceSlot ? serviceSlot : '')}" - -get_file_locations: - call: http.get - args: - url: "[#SERVICE_RUUTER]/internal/return-file-locations" - headers: - cookie: ${incoming.headers.cookie} - result: fileLocations - next: get_domain_file - -get_domain_file: - call: http.get - args: - url: "[#SERVICE_RUUTER]/internal/domain-file" - headers: - cookie: ${incoming.headers.cookie} - result: domainData - next: assign_domain_file_data - -assign_domain_file_data: - assign: - domain_data_json: ${domainData.response.body.response} - next: check_if_intent_exists - -check_if_intent_exists: - switch: - - condition: ${domain_data_json.intents.includes(intent)} - next: update_existing_domain_response - next: return_intent_does_not_exist - -update_existing_domain_response: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/domain/update-existing-response" - body: - json: ${domain_data_json.responses} - searchKey: ${intent} - newKey: ${"utter_" + intent} - newKeyValue: '${type === "Add" ? service_path : "UNKNOWN"}' - deleteOldValue: false - createIfAbsent: true - result: updatedResponses - next: check_for_type - -check_for_type: - switch: - - condition: ${type === "Add"} - next: assignRuleData - next: convert_domain_json_to_yaml - -assignRuleData: - assign: - data: - rule: "rule${intent}" - steps: [ - { - intent: "${intent}", - }, - { - action: "utter_${intent}", - }, - ] - next: add_rule - -add_rule: - call: http.post - args: - url: "[#SERVICE_RUUTER]/rasa/rules/add" - headers: - cookie: ${incoming.headers.cookie} - body: - data: ${data} - result: add_rule_res - next: convert_domain_json_to_yaml - -convert_domain_json_to_yaml: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_domain" - body: - version: ${domain_data_json.version} - session_config: ${domain_data_json.session_config} - intents: ${domain_data_json.intents} - entities: ${domain_data_json.entities} - slots: ${domain_data_json.slots} - forms: ${domain_data_json.forms} - actions: ${domain_data_json.actions} - responses: ${updatedResponses.response.body} - result: domainYaml - next: resave_domain_file - -resave_domain_file: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: ${fileLocations.response.body.response.domain_location} - content: ${domainYaml.response.body.json} - result: fileResult - next: updateOpenSearch - -updateOpenSearch: - call: http.post - args: - url: "[#SERVICES_PIPELINE]/bulk/domain" - body: - input: ${domainYaml.response.body.json} - result: updateSearchResult - next: return_result - -return_result: - status: 200 - return: "Connection request sent successfully" - next: end - -return_intent_does_not_exist: - status: 400 - return: "Intent does not exists" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/draft/.guard b/DSL/Ruuter.public/services/POST/services/draft/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/POST/services/draft/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/POST/services/draft/test.tmp b/DSL/Ruuter.public/services/POST/services/draft/test.tmp deleted file mode 100644 index 2625647c..00000000 --- a/DSL/Ruuter.public/services/POST/services/draft/test.tmp +++ /dev/null @@ -1,48 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: Description placeholder for 'test' - method: post - accepts: json - returns: json - namespace: service - allowList: - body: - - field: chatId - type: string - description: The chat ID for the message - - field: authorId - type: string - description: The author ID for the message - - field: input - type: object - description: The Input from the user -prepare: - assign: - chatId: ${incoming.body.chatId} - authorId: ${incoming.body.authorId} - input: ${incoming.body.input} - buttons: [] - res: - result: "" - next: format_messages -format_messages: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/bot_responses_to_messages" - headers: - type: json - body: - data: - botMessages: ${[res]} - chatId: ${chatId ?? ''} - authorId: ${authorId ?? ''} - authorFirstName: "" - authorLastName: "" - authorTimestamp: ${new Date().toISOString()} - created: ${new Date().toISOString()} - buttons: ${buttons ?? []} - result: formatMessage - next: service-end -service-end: - return: ${formatMessage.response.body ?? ''} diff --git a/DSL/Ruuter.public/services/POST/services/edit.yml b/DSL/Ruuter.public/services/POST/services/edit.yml deleted file mode 100644 index 57563146..00000000 --- a/DSL/Ruuter.public/services/POST/services/edit.yml +++ /dev/null @@ -1,381 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'EDIT'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: content - type: string - description: "Body field 'content'" - - field: description - type: string - description: "Body field 'description'" - - field: isCommon - type: boolean - description: "Body field 'isCommon'" - - field: entities - type: object - description: "Body field 'Entities'" - - field: examples - type: object - description: "Body field 'Examples'" - - field: name - type: string - description: "Body field 'name'" - - field: structure - type: object - description: "Body field 'structure'" - - field: type - type: string - description: "Body field 'type'" - - field: updateServiceDb - type: boolean - description: "Body field 'updateServiceDb'" - - field: state - type: string - description: "Body field 'state'" - params: - - field: id - type: string - description: "Parameter 'id'" - headers: - - field: cookie - type: string - description: "Cookie field" - -extract_request_data: - assign: - id: ${incoming.params.id} - name: ${incoming.body.name} - description: ${incoming.body.description} - isCommon: ${incoming.body.isCommon} - slot: ${""} - entities: ${incoming.body.entities} - examples: ${incoming.body.examples} - type: ${incoming.body.type} - content: ${incoming.body.content} - structure: ${incoming.body.structure} - updateServiceDb: ${incoming.body.updateServiceDb} - state: ${incoming.body.state} - -check_for_update_service_db: - switch: - - condition: ${incoming.body.updateServiceDb === true} - next: get_service - next: check_for_content - -get_service: - call: http.post - args: - url: "[#SERVICE_RESQL]/services/get_services_by_ids" - body: - serviceIds: "${id}" - result: get_service_result - -check_if_name_is_the_same: - switch: - - condition: ${get_service_result.response.body[0].name === name} - next: delete_all_mcq_files - next: check_if_name_exists - -check_if_name_exists: - call: http.post - args: - url: "[#SERVICE_RESQL]/services/check_name_exist" - body: - name: ${name} - result: name_exists_res - next: check_name_exists_result - -check_name_exists_result: - switch: - - condition: ${name_exists_res.response.body[0].nameExists} - next: return_name_already_exists - next: delete_all_mcq_files - -delete_all_mcq_files: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/delete-all-that-starts-with" - body: - path: "[#RUUTER_SERVICES_PATH]/${type}/services/draft" - keyword: "${name}_" - result: deleteRes - next: check_for_content - -check_for_content: - switch: - - condition: ${content === null} - next: check_for_required_parameters - next: convert_json_content_to_yml - -convert_json_content_to_yml: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data" - body: - data: ${content} - result: ymlResult - -check_for_type: - switch: - - condition: ${type === 'GET'} - next: add_get_dsl - next: add_post_dsl - -add_get_dsl: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: "[#RUUTER_SERVICES_GET_PATH]/draft/${name}.tmp" - content: ${ymlResult.response.body.yaml} - result: results - next: check_for_required_parameters - -add_post_dsl: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: "[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp" - content: ${ymlResult.response.body.yaml} - result: results - next: check_for_required_parameters - -check_for_required_parameters: - switch: - - condition: ${id === null || name === null || description === null} - next: return_incorrect_request - - condition: ${type === null} - next: return_incorrect_request - -upper_case_type: - assign: - type: ${type.toUpperCase()} - -check_type: - switch: - - condition: ${type !== 'GET' && type !== 'POST'} - next: return_incorrect_request - -check_if_update_service_db: - switch: - - condition: ${incoming.body.updateServiceDb === true} - next: get_service_by_id - next: return_ok - -get_service_by_id: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-service-by-id" - body: - id: ${id} - result: old_service_result - -assign_values: - assign: - old_name: ${old_service_result.response.body[0].name} - old_structure: ${old_service_result.response.body[0].structure} - old_state: ${old_service_result.response.body[0].state} - service_type: ${old_service_result.response.body[0].type} - -check_new_structure: - switch: - - condition: ${structure === null} - next: use_old_structure - - condition: ${structure !== null} - next: use_new_structure - -use_new_structure: - assign: - new_structure: ${structure} - next: rename_dsl - -use_old_structure: - assign: - new_structure: ${old_structure.value} - next: rename_dsl - -rename_dsl: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${type}/[#RUUTER_SERVICES_DIR_PATH]/${old_state}/${old_name}.tmp" - new_path: "[#RUUTER_SERVICES_PATH]/${type}/[#RUUTER_SERVICES_DIR_PATH]/${old_state}/${name}.tmp" - result: results - -service_edit: - call: http.post - args: - url: "[#SERVICE_RESQL]/edit" - body: - id: ${id} - name: ${name} - description: ${description} - is_common: ${isCommon ?? false} - slot: ${slot} - examples: ${examples} - entities: ${entities} - structure: ${new_structure} - state: ${state ?? 'draft'} - result: editedService - -check for_state: - switch: - - condition: ${state === 'draft'} - next: check_remove_blob_then_draft - - condition: ${state === 'active'} - next: check_if_service_data_exists - next: check_remove_blob_then_ok - -check_remove_blob_then_draft: - switch: - - condition: ${old_state === 'active'} - next: delete_from_storage_edit - next: change_state_to_draft - -check_remove_blob_then_ok: - switch: - - condition: ${old_state === 'active'} - next: delete_from_storage_edit - next: return_ok - -delete_from_storage_edit: - call: http.delete - args: - url: "[#STORAGE_FERRY]/v1/files/delete" - body: - files: - - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" - container: "[#STORAGE_FERRY_CONTAINER]" - fileName: "${old_name}.json" - result: ferry_delete_result - next: check_if_azure_configured_after_delete_edit - -check_if_azure_configured_after_delete_edit: - switch: - - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} - next: trigger_azure_indexer_after_delete_edit - next: log_azure_not_configured_after_delete_edit - -log_azure_not_configured_after_delete_edit: - log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" - next: after_delete_from_storage_edit - -trigger_azure_indexer_after_delete_edit: - call: http.post - args: - url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" - headers: - api-key: "[#AZURE_SEARCH_API_KEY]" - Content-Type: "application/json" - result: azure_indexer_result - next: after_delete_from_storage_edit - -after_delete_from_storage_edit: - switch: - - condition: ${state === 'draft'} - next: change_state_to_draft - next: return_ok - -change_state_to_draft: - call: http.post - args: - url: "[#SERVICE_RUUTER]/services/status" - headers: - cookie: ${incoming.headers.cookie} - body: - id: ${id} - state: "draft" - type: ${service_type ?? 'POST'} - result: changeStateResult - next: return_ok - -check_if_service_data_exists: - switch: - - condition: ${old_service_result !== undefined && old_service_result !== null} - next: generate_service_json_from_existing - next: get_service_data_for_json - -get_service_data_for_json: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-service-by-id" - body: - id: ${id} - result: service_data_result - next: generate_service_json - -generate_service_json_from_existing: - assign: - service_data_result: ${old_service_result} - next: generate_service_json - -generate_service_json: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/service_declaration" - headers: - type: 'json' - body: - name: ${name} - description: ${service_data_result.response.body[0].description} - examples: ${service_data_result.response.body[0].examples} - entities: ${service_data_result.response.body[0].entities} - result: service_json_result - next: replace_in_storage - -replace_in_storage: - call: http.post - args: - url: "[#STORAGE_FERRY]/v1/files/create" - body: - files: - - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" - container: "[#STORAGE_FERRY_CONTAINER]" - fileName: "${name}.json" - content: ${JSON.stringify(service_json_result.response.body)} - result: ferry_upload_result - next: check_if_azure_configured - -check_if_azure_configured: - switch: - - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} - next: trigger_azure_indexer - next: log_azure_not_configured - -log_azure_not_configured: - log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" - next: return_ok - -trigger_azure_indexer: - call: http.post - args: - url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" - headers: - api-key: "[#AZURE_SEARCH_API_KEY]" - Content-Type: "application/json" - result: azure_indexer_result - next: return_ok - -return_ok: - reloadDsl: true - status: 200 - return: "Edited Successfully" - next: end - -return_incorrect_request: - status: 400 - return: "Required parameter(s) missing" - next: end - -return_name_already_exists: - status: 409 - return: "Service name already exists" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml b/DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml deleted file mode 100644 index a6280794..00000000 --- a/DSL/Ruuter.public/services/POST/services/endpoint-url-validation.yml +++ /dev/null @@ -1,32 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ENDPOINT-URL-VALIDATION'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: type - type: string - description: "Body field 'type'" - - field: url - type: string - description: "Body field 'url'" - -extract_request_data: - assign: - url: ${incoming.body.url} - type: ${incoming.body.type} - -call_template: - template: "[#SERVICE_PROJECT_LAYER]/validation-template" - requestType: templates - body: - response: ${url} - type: ${type} - result: templateResult - -return_result: - return: ${templateResult} diff --git a/DSL/Ruuter.public/services/POST/services/import-services.yml b/DSL/Ruuter.public/services/POST/services/import-services.yml deleted file mode 100644 index 89a51644..00000000 --- a/DSL/Ruuter.public/services/POST/services/import-services.yml +++ /dev/null @@ -1,71 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'IMPORT-SERVICES'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: services - type: object - description: "Body field 'services'" - - field: timezone - type: string - description: "Body field 'timezone'" - -extract_request_data: - assign: - services: ${incoming.body.services ?? []} - names: ${services.map(s => s.fileName).join(",") ?? []} - timezone: ${incoming.body.timezone} - -get_import_names: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-import-names" - body: - names: ${names} - timezone: ${timezone} - result: import_names_res - -assign_imported_names: - assign: - imported_names: ${import_names_res.response.body[0].names.split(",")} - services: "$=services.map((s, i) => ({ ...s, fileName: imported_names[i] }))=" - file_names: ${services.map(s => s.fileName)} - -insert_services: - call: http.post - args: - url: "[#SERVICE_RESQL]/add-services" - body: - names: ${file_names} - structures: ${services.map(s => s.flowData)} - result: insert_services_res - -convert_json_content_to_yml: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/conversion/json_to_yaml_data_multiple" - body: - data: ${services.map(s => s.content)} - result: ymls_res - -prepare_files: - assign: - file_paths: "$=file_names.map(name => `[#RUUTER_SERVICES_POST_PATH]/draft/${name}.tmp`)=" - yaml_contents: ${ymls_res.response.body.yamls} - -add_dsls: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create_multiple" - body: - file_paths: ${file_paths} - contents: ${yaml_contents} - result: add_dsls_res - -return_result: - return: "Services imported successfully" diff --git a/DSL/Ruuter.public/services/POST/services/inactive/.guard b/DSL/Ruuter.public/services/POST/services/inactive/.guard deleted file mode 100644 index 64435377..00000000 --- a/DSL/Ruuter.public/services/POST/services/inactive/.guard +++ /dev/null @@ -1,4 +0,0 @@ -guard_allow_all: - return: "success" - status: 200 - next: end diff --git a/DSL/Ruuter.public/services/POST/services/open-api-spec.yml b/DSL/Ruuter.public/services/POST/services/open-api-spec.yml deleted file mode 100644 index 3dcc2c85..00000000 --- a/DSL/Ruuter.public/services/POST/services/open-api-spec.yml +++ /dev/null @@ -1,34 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'OPEN-API-SPEC'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: url - type: string - description: "Body field 'url'" - -check_for_required_parameters: - switch: - - condition: ${incoming.body == null || incoming.body.url == null} - next: return_incorrect_request - next: get_spec - -get_spec: - call: http.get - args: - url: ${incoming.body.url} - result: result - -return_value: - return: ${result.response.body} - next: end - -return_incorrect_request: - status: 400 - return: "missing parameters" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/requests/explain.yml b/DSL/Ruuter.public/services/POST/services/requests/explain.yml deleted file mode 100644 index e5fce84d..00000000 --- a/DSL/Ruuter.public/services/POST/services/requests/explain.yml +++ /dev/null @@ -1,95 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'EXPLAIN'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: requests - type: object - description: "request object containing multiple requests" - -check_for_requests: - switch: - - condition: ${incoming.body.requests == null} - next: missing_requests - next: initialize_responses - -initialize_responses: - assign: - requests: ${incoming.body.requests} - responses: [] - index: 0 - next: process_next_request - -process_next_request: - switch: - - condition: ${index < requests.length} - next: assign_current_request - next: return_responses - -assign_current_request: - assign: - current_request: ${requests[index]} - next: check_method - -check_method: - switch: - - condition: ${current_request.method.toLowerCase() == 'post'} - next: request_explain_post - next: request_explain_get - -request_explain_get: - call: http.get - args: - url: ${current_request.url} - headers: - Content-Type: "application/json" - result: res - next: assign_result - -request_explain_post: - call: http.post - args: - url: ${current_request.url} - headers: - Content-Type: "application/json" - body: - data: ${current_request.body} - result: res - next: assign_result - -assign_result: - assign: - result_res: ${[res.response.body]} - next: check_responses_list - -check_responses_list: - switch: - - condition: ${responses.length === 0} - next: append_first_response - next: append_response - -append_first_response: - assign: - responses: ${[responses, ...result_res]} - index: ${index + 1} - next: process_next_request - -append_response: - assign: - responses: ${[...responses, ...result_res]} - index: ${index + 1} - next: process_next_request - -return_responses: - return: ${responses.splice(1 , responses.length - 1)} - next: end - -missing_requests: - status: 400 - return: "required requests were not provided" - next: end \ No newline at end of file diff --git a/DSL/Ruuter.public/services/POST/services/resql/add.yml b/DSL/Ruuter.public/services/POST/services/resql/add.yml deleted file mode 100644 index b936d8fe..00000000 --- a/DSL/Ruuter.public/services/POST/services/resql/add.yml +++ /dev/null @@ -1,62 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'ADD'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: name - type: string - description: "Body field 'name'" - - field: sql - type: string - description: "Body field 'sql'" - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: missing_parameter - -extract_request_data: - assign: - name: ${incoming.body.name} - sql: ${incoming.body.sql} - -check_for_required_parameters: - switch: - - condition: ${name == null || sql == null} - next: missing_parameter - next: add_resql - -missing_parameter: - status: 400 - return: "required parameters were not provided" - next: end - -add_resql: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/create" - body: - file_path: "/Resql/services/${name}.sql" - content: ${sql} - result: results - -check_result: - switch: - - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} - next: return_ok - next: return_bad_request - -return_ok: - status: 200 - return: ${results.response.body.message} - next: end - -return_bad_request: - status: 400 - return: ${results.response.body.message} - next: end diff --git a/DSL/Ruuter.public/services/POST/services/status.yml b/DSL/Ruuter.public/services/POST/services/status.yml deleted file mode 100644 index 788aad71..00000000 --- a/DSL/Ruuter.public/services/POST/services/status.yml +++ /dev/null @@ -1,303 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'STATUS'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: id - type: string - description: "Body field 'id'" - - field: state - type: string - description: "Body field 'state'" - - field: type - type: string - description: "Body field 'type'" - headers: - - field: cookie - type: string - description: "Cookie field" - -extract_request_data: - assign: - id: ${incoming.body.id} - new_state: ${incoming.body.state} - ruuter_type: ${incoming.body.type} - -check_for_required_parameters: - switch: - - condition: ${id === null || new_state === null || ruuter_type === null} - next: return_incorrect_request - - condition: ${new_state === "ready"} - next: set_plain_status - next: get_current_status - -get_current_status: - call: http.post - args: - url: "[#SERVICE_RESQL]/status" - body: - id: ${id} - result: status_res - next: assign_old_status_and_path - -assign_old_status_and_path: - assign: - old_file_status_path: "${status_res.response.body[0].currentState === 'ready' ? 'draft' : status_res.response.body[0].currentState}" - old_file_end: "${status_res.response.body[0].currentState !== 'active' ? '.tmp' : '.yml'}" - next: check_status - -check_status: - switch: - - condition: ${new_state === "draft"} - next: set_status - - condition: ${status_res.response.body[0].currentState === new_state} - next: return_same_state_update - next: set_status - -set_status: - call: http.post - args: - url: "[#SERVICE_RESQL]/set-status" - body: - id: ${id} - new_state: ${new_state} - result: res - next: get_status_name - -set_plain_status: - call: http.post - args: - url: "[#SERVICE_RESQL]/set-status" - body: - id: ${id} - new_state: ${new_state} - result: draft_res - next: return_ok - -get_status_name: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-service-name-by-id" - body: - id: ${id} - result: name_res - next: assign_values - -assign_values: - assign: - name: ${name_res.response.body[0].name} - service_name: "service_${name_res.response.body[0].name}" - next: check_file_exists - -check_file_exists: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/exists" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" - result: service_file_exists_result - next: validate_file_exists - -validate_file_exists: - switch: - - condition: ${!!service_file_exists_result.response.body} - next: check_for_status - next: return_service_file_missing - -check_for_status: - switch: - - condition: ${new_state === "active"} - next: activate_service - - condition: ${new_state === "draft"} - next: draft_service - next: deactivate_service - -activate_service: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" - new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/active/${name}.yml" - result: activate_service_result - next: get_service_data_for_json - -get_service_data_for_json: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-service-by-id" - body: - id: ${id} - result: service_data_result - next: generate_service_json - -generate_service_json: - call: http.post - args: - url: "[#SERVICE_DMAPPER_HBS]/service_declaration" - headers: - type: 'json' - body: - name: ${name} - description: ${service_data_result.response.body[0].description} - examples: ${service_data_result.response.body[0].examples} - entities: ${service_data_result.response.body[0].entities} - result: service_json_result - next: create_in_storage - -create_in_storage: - call: http.post - args: - url: "[#STORAGE_FERRY]/v1/files/create" - body: - files: - - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" - container: "[#STORAGE_FERRY_CONTAINER]" - fileName: "${name}.json" - content: ${JSON.stringify(service_json_result.response.body)} - result: ferry_upload_result - next: check_if_azure_configured_after_create - -check_if_azure_configured_after_create: - switch: - - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} - next: trigger_azure_indexer_after_create - next: log_azure_not_configured_after_create - -log_azure_not_configured_after_create: - log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" - next: activate_all_mcq_services - -trigger_azure_indexer_after_create: - call: http.post - args: - url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" - headers: - api-key: "[#AZURE_SEARCH_API_KEY]" - Content-Type: "application/json" - result: azure_indexer_result - next: activate_all_mcq_services - -activate_all_mcq_services: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move-all-that-starts-with" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" - new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/active" - keyword: "${name}_" - format: "yml" - result: active_move_results - next: return_ok - -deactivate_service: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" - new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/inactive/${name}.tmp" - result: deactivate_service_result - next: delete_from_storage - -delete_from_storage: - call: http.delete - args: - url: "[#STORAGE_FERRY]/v1/files/delete" - body: - files: - - storageAccountId: "[#STORAGE_FERRY_ACCOUNT_ID]" - container: "[#STORAGE_FERRY_CONTAINER]" - fileName: "${name}.json" - result: ferry_delete_result - next: check_if_azure_configured_after_delete - -check_if_azure_configured_after_delete: - switch: - - condition: ${"[#AZURE_SEARCH_SERVICE_NAME]" !== "" && "[#AZURE_SEARCH_INDEXER_NAME]" !== "" && "[#AZURE_SEARCH_API_KEY]" !== ""} - next: trigger_azure_indexer_after_delete - next: log_azure_not_configured_after_delete - -log_azure_not_configured_after_delete: - log: "Warning! Azure Search configuration not found. Skipping Azure indexer trigger. Please configure AZURE_SEARCH_SERVICE_NAME, AZURE_SEARCH_INDEXER_NAME, and AZURE_SEARCH_API_KEY in constants.ini" - next: dactivate_all_mcq_services - -trigger_azure_indexer_after_delete: - call: http.post - args: - url: "https://[#AZURE_SEARCH_SERVICE_NAME].search.windows.net/indexers/[#AZURE_SEARCH_INDEXER_NAME]/run?api-version=2024-07-01" - headers: - api-key: "[#AZURE_SEARCH_API_KEY]" - Content-Type: "application/json" - result: azure_indexer_result - next: dactivate_all_mcq_services - -dactivate_all_mcq_services: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move-all-that-starts-with" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" - new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/inactive" - keyword: "${name}_" - format: "tmp" - result: inactive_move_results - next: return_ok - -draft_service: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}/${name + old_file_end}" - new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/draft/${name}.tmp" - result: draft_service_result - next: draft_all_mcq_services - -draft_all_mcq_services: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/file-manager/move-all-that-starts-with" - body: - file_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/${old_file_status_path}" - new_path: "[#RUUTER_SERVICES_PATH]/${ruuter_type}/[#RUUTER_SERVICES_DIR_PATH]/draft" - keyword: "${name}_" - format: "tmp" - result: inactive_move_results - next: return_ok - -return_ok: - reloadDsl: true - status: 200 - return: "Status Changed Successfully" - next: end - -return_bad_request: - status: 400 - return: ${err_result.response.body.message} - next: end - -return_incorrect_request: - status: 400 - return: "Required parameter(s) missing" - next: end - -return_same_state_update: - status: 200 - return: "Service is already in this state" - next: end - -return_service_file_missing: - status: 500 - return: "Service file to update is missing" - next: end - -return_intent_does_not_exist: - status: 400 - return: "does not exists" - next: end diff --git a/DSL/Ruuter.public/services/POST/services/update-endpoint.yml b/DSL/Ruuter.public/services/POST/services/update-endpoint.yml deleted file mode 100644 index 9228079d..00000000 --- a/DSL/Ruuter.public/services/POST/services/update-endpoint.yml +++ /dev/null @@ -1,61 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Updates an existing endpoint" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: name - type: string - description: "Endpoint name" - - field: type - type: string - description: "Endpoint type" - - field: isCommon - type: boolean - description: "Endpoint common status" - - field: serviceId - type: string - description: "Service UUID" - - field: definitions - type: object - description: "Endpoint definitions" - params: - - field: id - type: string - description: "Endpoint UUID" - -extract_request_data: - assign: - id: ${incoming.params.id} - -check_for_type: - switch: - - condition: ${id == null} - next: return_no_type_error - -update_endpoint: - call: http.post - args: - url: "[#SERVICE_RESQL]/endpoints/update_endpoint" - body: - endpointId: ${id} - name: ${incoming.body.name} - type: ${incoming.body.type} - isCommon: ${incoming.body.isCommon} - serviceId: ${incoming.body.serviceId ?? ''} - definitions: ${incoming.body.definitions} - result: res - -return_ok: - status: 200 - return: "Endpoint updated" - next: end - -return_no_type_error: - status: 400 - return: "Please provide an endpoint ID" - next: end diff --git a/DSL/Ruuter.public/services/POST/steps/preferences.yml b/DSL/Ruuter.public/services/POST/steps/preferences.yml deleted file mode 100644 index 61f72722..00000000 --- a/DSL/Ruuter.public/services/POST/steps/preferences.yml +++ /dev/null @@ -1,68 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'PREFERENCES'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: steps - type: string - description: "Body field 'steps'" - - field: endpoints - type: string - description: "Body field 'endpoints'" - -extractRequestData: - assign: - steps: ${incoming.body.steps.join(",")} - endpoints: ${incoming.body.endpoints.join(",")} - -get_user_info: - call: http.post - args: - url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" - contentType: plaintext - headers: - cookie: ${incoming.headers.cookie} - plaintext: "customJwtCookie" - result: res - -check_user_info_response: - switch: - - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} - next: assignIdCode - next: return_unauthorized - -assignIdCode: - assign: - idCode: ${res.response.body.idCode} - -update_user_step_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/update-user-step-preferences" - body: - steps: "{${steps}}" - endpoints: "{${endpoints}}" - user_id_code: ${idCode} - result: update_preferences_res - -get_user_step_preferences: - call: http.post - args: - url: "[#SERVICE_RESQL]/get-user-step-preferences" - body: - user_id_code: ${idCode} - result: preferences - -return_preferences: - return: ${preferences.response.body[0]} - next: end - -return_unauthorized: - status: 401 - return: "unauthorized" - next: end diff --git a/DSL/Ruuter.public/services/POST/user-info.yml b/DSL/Ruuter.public/services/POST/user-info.yml deleted file mode 100644 index e6309aa5..00000000 --- a/DSL/Ruuter.public/services/POST/user-info.yml +++ /dev/null @@ -1,16 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'USER-INFO'" - method: post - accepts: json - returns: json - namespace: service - -get_tara_info: - template: "[#SERVICE_PROJECT_LAYER]/tara" - requestType: templates - result: TARA - -return_authorized: - return: ${TARA.response.body} diff --git a/DSL/Ruuter.public/services/TEMPLATES/RBAC.yml b/DSL/Ruuter.public/services/TEMPLATES/RBAC.yml deleted file mode 100644 index fbe0f033..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/RBAC.yml +++ /dev/null @@ -1,51 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'RBAC'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: allowedRoles - type: object - description: "Body field 'allowedRoles'" - - field: userId - type: string - description: "Body field 'userId'" - -check_for_body: - switch: - - condition: ${incoming.body == null} - next: missing_parameter - -extract_request_data: - assign: - userId: ${incoming.body.userId} - allowedRoles: ${incoming.body.allowedRoles.sort()} - -check_for_required_parameters: - switch: - - condition: ${userId === null || allowedRoles === null} - next: missing_parameter - next: fetch_user_roles_from_db - -fetch_user_roles_from_db: - call: http.post - args: - url: "[#SERVICE_RESQL_USERS]:[#SERVICE_RESQL_USERS_PORT]/is-user-roles-allowed" - body: - userId: ${userId} - allowedRoles: ${allowedRoles} - result: result - -return_value: - status: 200 - return: "${result.response.body[0]}" - next: end - -missing_parameter: - status: 400 - return: "userId, allowedRoles - missing" - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml b/DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml deleted file mode 100644 index 25641137..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/check-user-authority.yml +++ /dev/null @@ -1,50 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CHECK-USER-AUTHORITY'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - headers: - - field: cookie - type: string - description: "Cookie field" - -get_cookie_info: - call: http.post - args: - url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" - contentType: plaintext - headers: - cookie: ${incoming.headers.cookie} - plaintext: "customJwtCookie" - result: res - next: check_cookie_info_response - -check_cookie_info_response: - switch: - - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} - next: check_user_authority - next: return_bad_request - -check_user_authority: - switch: - - condition: ${res.response.body.authorities.includes("ROLE_ADMINISTRATOR") || res.response.body.authorities.includes("ROLE_SERVICE_MANAGER")} - next: return_authorized - next: return_unauthorized - -return_authorized: - return: ${res.response.body} - next: end - -return_unauthorized: - status: 200 - return: false - next: end - -return_bad_request: - status: 400 - return: false - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/client-input.yml b/DSL/Ruuter.public/services/TEMPLATES/client-input.yml deleted file mode 100644 index 1d635eae..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/client-input.yml +++ /dev/null @@ -1,19 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'CLIENT-INPUT'" - method: post - accepts: json - returns: json - namespace: service - -# TODO: replace with correct request to get user input -request_client_input: - call: reflect.mock - args: - response: - input: "Yes" - result: clientInput - -return_value: - return: ${clientInput.response.body} diff --git a/DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml b/DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml deleted file mode 100644 index 175f028e..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/direct-to-cs.yml +++ /dev/null @@ -1,42 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'DIRECT-TO-CS'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: message - type: string - description: "Body field 'message'" - -# Direct to customer support -check_for_body: - switch: - - condition: ${incoming.body == null || incoming.body.message == null || incoming.body.message == ""} - next: missing_body_parameter - next: extract_request_data - -extract_request_data: - assign: - message: ${incoming.body.message} - -# TODO: do the actual request -send_message_to_client: - call: reflect.mock - args: - response: - status: 'OK' - message: "Teid suunatakse klienditeenindusse" - result: result - -return_value: - return: ${result.response.body} - next: end - -missing_body_parameter: - status: 400 - return: 'message - missing' - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml b/DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml deleted file mode 100644 index 43dbf6be..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/end-conversation.yml +++ /dev/null @@ -1,42 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'END-CONVERSATION'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: message - type: string - description: "Body field 'message'" - -# End conversation -check_for_body: - switch: - - condition: ${incoming.body == null || incoming.body.message == null || incoming.body.message == ""} - next: missing_body_parameter - next: extract_request_data - -extract_request_data: - assign: - message: ${incoming.body.message} - -# TODO: do the actual request -send_message_to_client: - call: reflect.mock - args: - response: - status: 'OK' - message: "Teenus on lõpetatud" - result: result - -return_value: - return: ${result.response.body} - next: end - -missing_body_parameter: - status: 400 - return: 'message - missing' - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/file-generate.yml b/DSL/Ruuter.public/services/TEMPLATES/file-generate.yml deleted file mode 100644 index 4eb9f423..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/file-generate.yml +++ /dev/null @@ -1,45 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'FILE-GENERATE'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: fileContent - type: string - description: "Body field 'fileContent'" - - field: fileName - type: string - description: "Body field 'fileName'" - -check_for_body: - switch: - - condition: ${incoming.body == null || incoming.body.fileName == null || incoming.body.fileContent == null} - next: missing_body_parameters - next: extract_request_data - -extract_request_data: - assign: - fileName: ${incoming.body.fileName} - fileContent: ${incoming.body.fileContent} - -generate_pdf_file: - call: http.post - args: - url: "[#SERVICE_DMAPPER]/js/generate/pdf" - body: - filename: ${fileName} - template: ${fileContent} - result: result - -return: - return: ${result.response.body} - next: end - -missing_body_parameters: - status: 400 - return: "fileName, fileContent - missing" - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/file-signing.yml b/DSL/Ruuter.public/services/TEMPLATES/file-signing.yml deleted file mode 100644 index 4d2571c5..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/file-signing.yml +++ /dev/null @@ -1,35 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'FILE-SIGNING'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: country - type: string - description: "Body field 'country'" - - field: personIdentifier - type: string - description: "Body field 'personIdentifier'" - - field: phoneNr - type: string - description: "Body field 'phoneNr'" - - field: type - type: string - description: "Body field 'type'" - -siga_template_request: - template: siga - requestType: templates - body: - type: ${incoming.body.type} - personIdentifier: ${incoming.body.personIdentifier} - country: ${incoming.body.country} - phoneNr: ${incoming.body.phoneNr} - result: result - -return_result: - return: ${result.response.body} diff --git a/DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml b/DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml deleted file mode 100644 index 9435e427..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/open-webpage.yml +++ /dev/null @@ -1,44 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'OPEN-WEBPAGE'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: link - type: string - description: "Body field 'link'" - - field: linkText - type: string - description: "Body field 'linkText'" - -check_for_body: - switch: - - condition: ${incoming.body == null || incoming.body.link == null || incoming.body.link == "" || incoming.body.linkText == null || incoming.body.linkText == ""} - next: missing_body_parameter - next: extract_request_data - -extract_request_data: - assign: - link: ${incoming.body.link} - linkText: ${incoming.body.linkText} - -send_link_to_client: - call: reflect.mock - args: - response: - status: "OK" - link: Link Text - result: result - -return_value: - return: ${result.response.body} - next: end - -missing_body_parameter: - status: 400 - return: "link, linkText - both or one of these fields are missing" - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml b/DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml deleted file mode 100644 index 0db2430f..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/send-message-to-client.yml +++ /dev/null @@ -1,42 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SEND-MESSAGE-TO-CLIENT'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: message - type: string - description: "Body field 'message'" - -# Message to client element -check_for_body: - switch: - - condition: ${incoming.body == null || incoming.body.message == null || incoming.body.message == ""} - next: missing_body_parameter - next: extract_request_data - -extract_request_data: - assign: - message: ${incoming.body.message} - -# TODO: do the actual request -send_message_to_client: - call: reflect.mock - args: - response: - status: 'OK' - message: "Hello, Muki" - result: result - -return_value: - return: ${result.response.body} - next: end - -missing_body_parameter: - status: 400 - return: 'message - missing' - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/siga.yml b/DSL/Ruuter.public/services/TEMPLATES/siga.yml deleted file mode 100644 index adb8984b..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/siga.yml +++ /dev/null @@ -1,132 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'SIGA'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: containerType - type: string - description: "Body field 'containerType'" - - field: country - type: string - description: "Body field 'country'" - - field: file - type: string - description: "Body field 'file'" - - field: phoneNumber - type: string - description: "Body field 'phoneNumber'" - - field: type - type: string - description: "Body field 'type'" - -extract_request_data: - assign: - file: ${incoming.body.file} - signType: ${incoming.body.type} - country: ${incoming.body.country} - phoneNumber: ${incoming.body.phoneNumber} - containerType: ${incoming.body.containerType} - next: get_tara_info - -get_tara_info: - template: tara - requestType: templates - result: tara_res - next: extract_tara_data - -extract_tara_data: - assign: - identifier: ${tara_res.response.body.idCode} - next: check_for_container_type - -check_for_container_type: - switch: - - condition: ${containerType === "ASIC".toLowerCase()} - next: create_asic_container - - condition: ${containerType === "HASHCODE".toLowerCase()} - next: create_hashcode_container - next: missing_container_type - -create_asic_container: - call: http.post - args: - url: "[#SERVICE_SIGA]/create-container" - contentType: formdata - body: - file:file[0]:uploadedFile.pdf: ${file} - return: container_res - next: check_if_sign_type_missing - -create_hashcode_container: - call: http.post - args: - url: "[#SERVICE_SIGA]/create-hashcode-container" - contentType: formdata - body: - file:file[0]:uploadedFile.pdf: ${file} - return: container_res - next: check_if_sign_type_missing - -check_if_sign_type_missing: - switch: - - condition: ${signType === null} - next: missing_sign_type - next: check_for_sign_type - -check_for_sign_type: - switch: - - condition: ${signType === "smart_id"} - next: sign_via_smart_id - - condition: ${signType === "mobile_sign"} - next: sign_via_mobile - next: missing_sign_type - -sign_via_smart_id: - call: http.post - args: - url: "[#SERVICE_SIGA]/smartid-signing" - body: - containerId: ${container_res.response.body.id} - containerType: ${containerType.toUpperCase()} - personIdentifier: ${identifier} - country: ${country} - return: res - next: end - -sign_via_mobile: - call: http.post - args: - url: "[#SERVICE_SIGA]/mobile-signing" - body: - containerId: ${container_res.response.body.id} - containerType: ${containerType.toUpperCase()} - phoneNr: ${phoneNumber} - personIdentifier: ${identifier} - country: ${country} - return: res - next: end - -missing_smart_id_params: - status: 400 - return: "Id, country - missing" - next: end - -missing_mobile_sign_params: - status: 400 - return: "Phone number, country - missing" - next: end - -missing_sign_type: - status: 400 - return: "Sign type is missing" - next: end - -missing_container_type: - status: 400 - return: "Container type is missing" - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/tara.yml b/DSL/Ruuter.public/services/TEMPLATES/tara.yml deleted file mode 100644 index 28ac86dc..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/tara.yml +++ /dev/null @@ -1,51 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'TARA'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - headers: - - field: cookie - type: string - description: "Cookie field" - - -check_for_body: - switch: - - condition: ${incoming.headers == null || incoming.headers.cookie == null} - next: missing_cookie - next: get_cookie_info - -get_cookie_info: - call: http.post - args: - url: "[#SERVICE_TIM]/jwt/custom-jwt-userinfo" - contentType: plaintext - headers: - cookie: ${incoming.headers.cookie} - plaintext: "customJwtCookie" - result: res - next: check_cookie_info_response - -check_cookie_info_response: - switch: - - condition: ${200 <= res.response.statusCodeValue && res.response.statusCodeValue < 300} - next: return_auth_result - next: return_bad_request - -return_auth_result: - return: ${res.response.body} - next: end - -return_bad_request: - status: 400 - return: false - next: end - -missing_cookie: - status: 401 - return: "no authentication cookie" - next: end diff --git a/DSL/Ruuter.public/services/TEMPLATES/validation-template.yml b/DSL/Ruuter.public/services/TEMPLATES/validation-template.yml deleted file mode 100644 index 63f21c88..00000000 --- a/DSL/Ruuter.public/services/TEMPLATES/validation-template.yml +++ /dev/null @@ -1,56 +0,0 @@ -declaration: - call: declare - version: 0.1 - description: "Decription placeholder for 'VALIDATION-TEMPLATE'" - method: post - accepts: json - returns: json - namespace: service - allowlist: - body: - - field: response - type: string - description: "Body field 'response'" - - field: type - type: string - description: "Body field 'type'" - -assign_step: - assign: - res: ${incoming.body.response} - type: ${incoming.body.type} - next: check_for_type - -check_for_type: - switch: - - condition: ${type.toLowerCase() === 'get'} - next: validate_get_request - next: validate_post_request - -validate_get_request: - call: http.get - args: - url: ${res} - result: results - next: validate_status_code - -validate_post_request: - call: http.post - args: - url: ${res} - result: results - next: validate_status_code - -validate_status_code: - switch: - - condition: ${200 <= results.response.statusCodeValue && results.response.statusCodeValue < 300} - next: return_true - next: return_false - -return_true: - return: true - next: end - -return_false: - return: false - next: end diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index 545300c8..ffda9507 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -37,7 +37,7 @@ RUUTER_BASE_URL = "http://ruuter-private:8086" """Base URL for Ruuter private service endpoints.""" -RUUTER_SERVICE_BASE_URL = "http://ruuter-public:8086/services" +RUUTER_SERVICE_BASE_URL = "http://ruuter:8086/services" """Base URL for Ruuter public service endpoints (active services).""" RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" From d3e149473c7ca58f343e65e022b550cf12d92d4a Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Thu, 12 Mar 2026 14:39:45 +0530 Subject: [PATCH 060/105] fixed issues --- src/tool_classifier/context_analyzer.py | 168 ++- .../workflows/context_workflow.py | 47 +- tests/conftest.py | 9 + tests/test_context_analyzer.py | 979 ++++++++++++++++++ tests/test_context_workflow.py | 698 +++++++++++++ tests/test_context_workflow_integration.py | 851 +++++++++++++++ 6 files changed, 2728 insertions(+), 24 deletions(-) create mode 100644 tests/test_context_analyzer.py create mode 100644 tests/test_context_workflow.py create mode 100644 tests/test_context_workflow_integration.py diff --git a/src/tool_classifier/context_analyzer.py b/src/tool_classifier/context_analyzer.py index 4572aefd..3584683a 100644 --- a/src/tool_classifier/context_analyzer.py +++ b/src/tool_classifier/context_analyzer.py @@ -207,7 +207,6 @@ def __init__(self, llm_manager: Any) -> None: # noqa: ANN401 # 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 - self._stream_predictor: Optional[Any] = None logger.info("Context analyzer initialized") def _format_conversation_history( @@ -357,6 +356,111 @@ async def detect_context( ) 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, @@ -365,30 +469,39 @@ async def stream_context_response( """ Phase 2 (streaming): Stream a generated answer using DSPy native streaming. - Uses ContextResponseGenerationSignature with DSPy's streamify() so tokens - are yielded in real time — same mechanism as ResponseGeneratorAgent.stream_response(). + 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 + 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(): - if self._stream_predictor is None: - answer_listener = StreamListener(signature_field_name="answer") - self._stream_predictor = dspy.streamify( - dspy.Predict(ContextResponseGenerationSignature), - stream_listeners=[answer_listener], - ) - output_stream = self._stream_predictor( + # 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, ) @@ -402,11 +515,11 @@ async def stream_context_response( 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 "" - if not stream_started: - logger.warning( - "Context streaming finished but no 'answer' tokens received." - ) except GeneratorExit: raise except Exception as e: @@ -421,6 +534,31 @@ async def stream_context_response( 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, diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index 8d69675c..0aa7fb20 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -1,6 +1,6 @@ """Context workflow executor - Layer 2: Conversation history and greetings.""" -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Optional, cast import time import dspy from loguru import logger @@ -77,10 +77,19 @@ async def _detect( time_metric: Dict[str, float], costs_metric: Dict[str, Dict[str, Any]], ) -> Optional[ContextDetectionResult]: - """Phase 1: run context detection. Returns ContextDetectionResult or None on error.""" + """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( + ( + result, + cost, + ) = await self.context_analyzer.detect_context_with_summary_fallback( query=message, conversation_history=history ) time_metric["context.detection"] = time.time() - start @@ -267,12 +276,29 @@ async def execute_async( 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 + # 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} " @@ -286,6 +312,7 @@ async def execute_async( 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, @@ -298,6 +325,7 @@ async def execute_async( 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 ) @@ -306,6 +334,7 @@ async def execute_async( 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( diff --git a/tests/conftest.py b/tests/conftest.py index d1633b76..e26acfc9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,3 +6,12 @@ # 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/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..1362a72d --- /dev/null +++ b/tests/test_context_workflow.py @@ -0,0 +1,698 @@ +"""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 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", + 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", + 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..bca2af2e --- /dev/null +++ b/tests/test_context_workflow_integration.py @@ -0,0 +1,851 @@ +"""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", + 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", + 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", + 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", + 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", + 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", + 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", + ), + ] + + 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) + 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 From c2ef115fb05fc507b7cfcd9558d930877e6a05c4 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 13 Mar 2026 09:50:10 +0530 Subject: [PATCH 061/105] delete unnessary files --- new.txt | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 new.txt diff --git a/new.txt b/new.txt deleted file mode 100644 index 9e7525fb..00000000 --- a/new.txt +++ /dev/null @@ -1,38 +0,0 @@ -1️⃣ Broneeringu kinnitus (Booking Confirmation) - -Estonian → English - -Kas minu broneering on kinnitatud? -→ Is my booking confirmed? - -Palun kinnita minu broneering. -→ Please confirm my booking. - -Kas broneering sai edukalt tehtud? -→ Was the booking successfully made? - -2️⃣ Kalastusloa uuendamise teade (Fishing License Renewal) - -Estonian → English - -Kas minu kalastusluba tuleb uuendada? -→ Do I need to renew my fishing license? - -Millal mu kalastusluba aegub? -→ When does my fishing license expire? - -Kas mu kalastusluba on veel kehtiv? -→ Is my fishing license still valid? - -3️⃣ Koolivaheajad (School Holidays) - -Estonian → English - -Millal on järgmine koolivaheaeg? -→ When is the next school holiday? - -Kas sa saad öelda selle aasta koolivaheajad? -→ Can you tell me the school holidays for this year? - -Millal algab suvevaheaeg? -→ When does the summer holiday start? \ No newline at end of file From 97f6f1ad7d0573c71c5912e6dae6f04c2540f529 Mon Sep 17 00:00:00 2001 From: nuwangeek Date: Fri, 13 Mar 2026 10:57:54 +0530 Subject: [PATCH 062/105] added requested changes --- tests/test_context_workflow.py | 20 +++++---- tests/test_context_workflow_integration.py | 51 +++++++++++++++++----- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/tests/test_context_workflow.py b/tests/test_context_workflow.py index 1362a72d..9a6d7e7d 100644 --- a/tests/test_context_workflow.py +++ b/tests/test_context_workflow.py @@ -7,7 +7,7 @@ from src.tool_classifier.workflows.context_workflow import ContextWorkflowExecutor from src.tool_classifier.context_analyzer import ContextDetectionResult -from models.request_models import ( +from src.models.request_models import ( OrchestrationRequest, OrchestrationResponse, ConversationItem, @@ -113,12 +113,13 @@ async def test_execute_async_greeting_estonian( with patch.object( context_workflow.context_analyzer, - "detect_context", - return_value=( + "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 @@ -155,12 +156,13 @@ async def test_execute_async_greeting_english( with patch.object( context_workflow.context_analyzer, - "detect_context", - return_value=( + "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 diff --git a/tests/test_context_workflow_integration.py b/tests/test_context_workflow_integration.py index bca2af2e..a11a7f48 100644 --- a/tests/test_context_workflow_integration.py +++ b/tests/test_context_workflow_integration.py @@ -253,7 +253,7 @@ async def test_greeting_end_to_end_non_streaming( request = _make_request("Hello!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -295,7 +295,7 @@ async def test_estonian_greeting_end_to_end( request = _make_request("Tere!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -329,7 +329,7 @@ async def test_goodbye_end_to_end(self, classifier: ToolClassifier) -> None: request = _make_request("Goodbye!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -366,7 +366,7 @@ async def test_thanks_end_to_end(self, classifier: ToolClassifier) -> None: request = _make_request("Thank you!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -421,7 +421,7 @@ async def test_context_answer_end_to_end(self, classifier: ToolClassifier) -> No with ( patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -613,7 +613,7 @@ async def test_streaming_greeting_end_to_end( request = _make_request("Hello!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -666,6 +666,10 @@ async def test_streaming_context_answer_end_to_end( ), ] + 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(), @@ -677,13 +681,36 @@ async def test_streaming_context_answer_end_to_end( ) request = _make_request("When is the deadline?", history=history) - stream = await classifier.route_to_workflow( - classification=classification, - request=request, - is_streaming=True, - ) + 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] + chunks = [chunk async for chunk in stream] assert len(chunks) >= 2 last_payload = json.loads(chunks[-1][6:-2]) From 4c063eca4c81e37755a6dc71c1af105cc9683b53 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:00:21 +0530 Subject: [PATCH 063/105] Context based response generation workflow (#327) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * update time tracking * added time tracking and reloacate input guardrail before toolclassifiier * fixed issue * fixed issue * added hybrid search for the service detection * update tool classifier * fixing merge conflicts * fixed issue * optimize first user query response generation time * fixed pr reviewed issues * context based response generation flow * fixed pr review suggested issues * fixed issues --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md | 323 +++++ src/llm_orchestration_service.py | 5 + src/llm_orchestration_service_api.py | 2 +- src/llm_orchestrator_config/stream_config.py | 7 +- src/tool_classifier/classifier.py | 15 +- src/tool_classifier/constants.py | 6 +- src/tool_classifier/context_analyzer.py | 1038 +++++++++++++++++ src/tool_classifier/greeting_constants.py | 40 + .../workflows/context_workflow.py | 387 +++++- src/tool_classifier/workflows/rag_workflow.py | 48 +- .../workflows/service_workflow.py | 17 + src/utils/rate_limiter.py | 138 ++- tests/conftest.py | 9 + tests/test_context_analyzer.py | 979 ++++++++++++++++ tests/test_context_workflow.py | 698 +++++++++++ tests/test_context_workflow_integration.py | 851 ++++++++++++++ 16 files changed, 4427 insertions(+), 136 deletions(-) create mode 100644 docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md create mode 100644 src/tool_classifier/context_analyzer.py create mode 100644 src/tool_classifier/greeting_constants.py create mode 100644 tests/test_context_analyzer.py create mode 100644 tests/test_context_workflow.py create mode 100644 tests/test_context_workflow_integration.py 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/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 7f7432fc..78899870 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -639,11 +639,13 @@ async def stream_orchestration_response( ) # 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, ) + time_metric["classifier.classify"] = time.time() - start_time logger.info( f"[{request.chatId}] [{stream_ctx.stream_id}] Classification: {classification.workflow.value} " @@ -652,11 +654,14 @@ async def stream_orchestration_response( # Route to appropriate workflow (streaming) # route_to_workflow returns AsyncIterator[str] when is_streaming=True + 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 diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 0e9b1273..110c2991 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -71,7 +71,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 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: 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/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index f18ef3ec..1ada8940 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -57,9 +57,9 @@ class ToolClassifier: def __init__( self, - llm_manager: Any, - orchestration_service: Any, - ): + llm_manager: Any, # noqa: ANN401 + orchestration_service: Any, # noqa: ANN401 + ) -> None: """ Initialize tool classifier with required dependencies. @@ -88,6 +88,7 @@ def __init__( ) self.context_workflow = ContextWorkflowExecutor( llm_manager=llm_manager, + orchestration_service=orchestration_service, ) self.rag_workflow = RAGWorkflowExecutor( orchestration_service=orchestration_service, @@ -622,7 +623,7 @@ def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: async def _execute_with_fallback_async( self, - workflow: Any, + workflow: Any, # noqa: ANN401 request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, @@ -696,11 +697,11 @@ async def _execute_with_fallback_async( if rag_result is not None: return rag_result else: - raise RuntimeError("RAG workflow returned None unexpectedly") + raise RuntimeError("RAG workflow returned None unexpectedly") from e async def _execute_with_fallback_streaming( self, - workflow: Any, + workflow: Any, # noqa: ANN401 request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, @@ -782,4 +783,4 @@ async def _execute_with_fallback_streaming( async for chunk in streaming_result: yield chunk else: - raise RuntimeError("RAG workflow returned None unexpectedly") + raise RuntimeError("RAG workflow returned None unexpectedly") from e diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index 65f30332..d839e2cf 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -70,13 +70,15 @@ DENSE_SEARCH_TOP_K = 3 """Number of top results from dense-only search for relevance scoring.""" -DENSE_MIN_THRESHOLD = 0.38 +# DENSE_MIN_THRESHOLD = 0.38 +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.40 +# DENSE_HIGH_CONFIDENCE_THRESHOLD = 0.40 +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.""" diff --git a/src/tool_classifier/context_analyzer.py b/src/tool_classifier/context_analyzer.py new file mode 100644 index 00000000..3584683a --- /dev/null +++ b/src/tool_classifier/context_analyzer.py @@ -0,0 +1,1038 @@ +"""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 src.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 or extract answers from history + + 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 + + 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 + + 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." + ) + + +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/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/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py index dc23e8bf..0aa7fb20 100644 --- a/src/tool_classifier/workflows/context_workflow.py +++ b/src/tool_classifier/workflows/context_workflow.py @@ -1,10 +1,22 @@ """Context workflow executor - Layer 2: Conversation history and greetings.""" -from typing import Any, AsyncIterator, Dict, Optional +from typing import Any, AsyncIterator, Dict, Optional, cast +import time +import dspy from loguru import logger from 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): @@ -12,24 +24,231 @@ class ContextWorkflowExecutor(BaseWorkflow): Handles greetings and conversation history queries (Layer 2). Detects: - - Greetings: "Hello", "Thanks", "Goodbye" + - 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. - Status: SKELETON - Returns None (fallback to RAG) - TODO: Implement greeting/context detection, answer extraction, guardrails + 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: Any): + 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 - logger.info("Context workflow executor initialized (skeleton)") + 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 = 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, @@ -38,26 +257,84 @@ async def execute_async( time_metric: Optional[Dict[str, float]] = None, ) -> Optional[OrchestrationResponse]: """ - Execute context workflow in non-streaming mode. + Execute context workflow in non-streaming mode (two-phase). - TODO: Check greeting (LLM) → generate response, OR check history (last 10 turns) - → extract answer → validate with guardrails. Return None if cannot answer. - - Args: - request: Orchestration request with user query and history - context: Metadata with is_greeting, can_answer_from_history flags - time_metric: Optional timing dictionary for future timing tracking + 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 with context-based answer or None to fallback + OrchestrationResponse or None to fallback to RAG """ - logger.debug( - f"[{request.chatId}] Context workflow execute_async called " - f"(not implemented - returning None)" + 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 = {} - # TODO: Implement context workflow logic here - # For now, return None to trigger fallback to next layer (RAG) + 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( @@ -67,24 +344,66 @@ async def execute_streaming( time_metric: Optional[Dict[str, float]] = None, ) -> Optional[AsyncIterator[str]]: """ - Execute context workflow in streaming mode. + Execute context workflow in streaming mode (two-phase). - TODO: Get answer (greeting/history) → validate BEFORE streaming → chunk and - yield as SSE. Return None if cannot answer. - - Args: - request: Orchestration request with user query and history - context: Metadata with is_greeting, can_answer_from_history flags - time_metric: Optional timing dictionary for future timing tracking + 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 + AsyncIterator yielding SSE strings or None to fallback to RAG """ - logger.debug( - f"[{request.chatId}] Context workflow execute_streaming called " - f"(not implemented - returning None)" + 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}" ) - # TODO: Implement context streaming logic here - # For now, return None to trigger fallback to next layer (RAG) + 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/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py index b5da35b1..9c983ced 100644 --- a/src/tool_classifier/workflows/rag_workflow.py +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -64,7 +64,7 @@ async def execute_async( Args: request: Orchestration request with user query - context: Unused (RAG doesn't need classification metadata) + context: May contain pre-initialized "components" to avoid duplicate init time_metric: Optional timing dictionary from parent (for unified tracking) Returns: @@ -79,8 +79,12 @@ async def execute_async( if time_metric is None: time_metric = {} - # Initialize service components - components = self.orchestration_service._initialize_service_components(request) + # 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( @@ -105,6 +109,11 @@ async def execute_streaming( """ 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) @@ -118,7 +127,7 @@ async def execute_streaming( Args: request: Orchestration request with user query - context: Unused (RAG doesn't need classification metadata) + context: May contain pre-initialized "components" and "stream_ctx" time_metric: Optional timing dictionary from parent (for unified tracking) Returns: @@ -143,8 +152,7 @@ async def execute_streaming( # Get stream context from context if provided, otherwise create minimal tracking stream_ctx = context.get("stream_ctx") if stream_ctx is None: - # Create minimal stream context when called via tool classifier - # In production flow, this is provided by stream_orchestration_response + class MinimalStreamContext: """Minimal stream context for RAG workflow when called directly.""" @@ -154,25 +162,29 @@ def __init__(self, chat_id: str) -> None: self.bot_generator = None def mark_completed(self) -> None: - """No-op: Tracking handled by orchestration service.""" + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context pass def mark_cancelled(self) -> None: - """No-op: Tracking handled by orchestration service.""" + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context pass def mark_error(self, error_id: str) -> None: - """No-op: Tracking handled by orchestration service.""" + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context pass stream_ctx = MinimalStreamContext(request.chatId) - # Delegate to core RAG pipeline (bypasses classifier to avoid recursion) - async for sse_chunk in self.orchestration_service._stream_rag_pipeline( - request=request, - components=components, - stream_ctx=stream_ctx, - costs_metric=costs_metric, - time_metric=time_metric, - ): - yield sse_chunk + # 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=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 index bb72f785..78825502 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -6,6 +6,7 @@ import httpx from loguru import logger +from src.guardrails.nemo_rails_adapter import NeMoRailsAdapter from src.utils.cost_utils import get_lm_usage_since from models.request_models import ( @@ -73,6 +74,22 @@ def log_costs(self, costs_metric: Dict[str, Dict[str, Any]]) -> None: """ ... + 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: OrchestrationResponse, + request: OrchestrationRequest, + costs_metric: Dict[str, Dict[str, Any]], + ) -> OrchestrationResponse: + """Apply output guardrails to the generated response.""" + ... + class ServiceWorkflowExecutor(BaseWorkflow): """Executes external service calls via Ruuter endpoints (Layer 1).""" diff --git a/src/utils/rate_limiter.py b/src/utils/rate_limiter.py index 4b88d9d7..d86829f8 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, ): """ 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/tests/conftest.py b/tests/conftest.py index d1633b76..e26acfc9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,3 +6,12 @@ # 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/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..1362a72d --- /dev/null +++ b/tests/test_context_workflow.py @@ -0,0 +1,698 @@ +"""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 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", + 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", + 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..bca2af2e --- /dev/null +++ b/tests/test_context_workflow_integration.py @@ -0,0 +1,851 @@ +"""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", + 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", + 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", + 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", + 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", + 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", + 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", + ), + ] + + 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) + 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 From 498d1c62f6bde76406d99be88c5b77d4131f83ae Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:54:21 +0200 Subject: [PATCH 064/105] Create ci-build-image-llm-orchestration-service.yml --- ...-build-image-llm-orchestration-service.yml | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/ci-build-image-llm-orchestration-service.yml 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 From 6a900ecb0bb03ec2b3145ca36174b70b4231c818 Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:55:22 +0200 Subject: [PATCH 065/105] Create env.llm_orchestration_service --- env.llm_orchestration_service | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 env.llm_orchestration_service diff --git a/env.llm_orchestration_service b/env.llm_orchestration_service new file mode 100644 index 00000000..c488b0a2 --- /dev/null +++ b/env.llm_orchestration_service @@ -0,0 +1,4 @@ +RELEASE=test +VERSION=1 +BUILD=1 +FIX=0 From 90b5ce851db7779a1efcc9b2f62c9540471add89 Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:59:18 +0200 Subject: [PATCH 066/105] Rename env.llm_orchestration_service to .env.llm_orchestration_service --- env.llm_orchestration_service => .env.llm_orchestration_service | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename env.llm_orchestration_service => .env.llm_orchestration_service (100%) diff --git a/env.llm_orchestration_service b/.env.llm_orchestration_service similarity index 100% rename from env.llm_orchestration_service rename to .env.llm_orchestration_service From d5b4e2f47fdefb4bb3d2a653118455c7a86413b8 Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:03:14 +0200 Subject: [PATCH 067/105] Update .env.gui --- .env.gui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.gui b/.env.gui index c488b0a2..3d4b260e 100644 --- a/.env.gui +++ b/.env.gui @@ -1,4 +1,4 @@ -RELEASE=test +RELEASE=gui VERSION=1 BUILD=1 FIX=0 From 4662c82706d37660dd9c33cce06da1ea09df1767 Mon Sep 17 00:00:00 2001 From: KlviG <78801020+KlviG@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:03:36 +0200 Subject: [PATCH 068/105] Update .env.llm_orchestration_service --- .env.llm_orchestration_service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.llm_orchestration_service b/.env.llm_orchestration_service index c488b0a2..5700e71c 100644 --- a/.env.llm_orchestration_service +++ b/.env.llm_orchestration_service @@ -1,4 +1,4 @@ -RELEASE=test +RELEASE=orchestration VERSION=1 BUILD=1 FIX=0 From f88903b27d0e88a1490e3869a8f7c08b548bdea2 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:26:04 +0530 Subject: [PATCH 069/105] Llm service integration (#329) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * update time tracking * added time tracking and reloacate input guardrail before toolclassifiier * fixed issue * fixed issue * added hybrid search for the service detection * update tool classifier * fixing merge conflicts * fixed issue * optimize first user query response generation time * fixed pr reviewed issues * service integration * context based response generation flow * fixed pr review suggested issues * removed service project layer * fixed issues * delete unnessary files * added requested changes * fixed issue --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- .../hbs/bot_responses_to_messages.handlebars | 14 + DSL/DMapper/rag-search/lib/helpers.js | 29 ++ constants.ini | 5 +- docs/HYBRID_SEARCH_CLASSIFICATION.md | 59 +++- docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md | 262 ++++++++------ src/tool_classifier/classifier.py | 8 +- src/tool_classifier/constants.py | 3 + .../workflows/service_workflow.py | 331 +++++++----------- tests/data/classification_test_queries.json | 266 ++++++++++++++ tests/test_context_workflow.py | 20 +- tests/test_context_workflow_integration.py | 51 ++- 11 files changed, 686 insertions(+), 362 deletions(-) create mode 100644 DSL/DMapper/rag-search/hbs/bot_responses_to_messages.handlebars create mode 100644 tests/data/classification_test_queries.json 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/constants.ini b/constants.ini index 63172d15..af507306 100644 --- a/constants.ini +++ b/constants.ini @@ -9,4 +9,7 @@ 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-public:8086/services +SERVICE_DMAPPER_HBS=http://data-mapper:3000/hbs/rag-search +SERVICE_PROJECT_LAYER=services \ No newline at end of file diff --git a/docs/HYBRID_SEARCH_CLASSIFICATION.md b/docs/HYBRID_SEARCH_CLASSIFICATION.md index 18c512ae..1de3f7f5 100644 --- a/docs/HYBRID_SEARCH_CLASSIFICATION.md +++ b/docs/HYBRID_SEARCH_CLASSIFICATION.md @@ -53,7 +53,8 @@ The system has two phases: | `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` | `EnrichedService` data model | +| `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 @@ -78,8 +79,8 @@ Service "Valuutakursid" → 4 Qdrant points dense: 3072-dim embedding of this exact text sparse: BM25 vector → {euro: 1.0, gbp: 1.0, kurss: 1.0, ...} - Point 3 (summary): "Valuutakursid - Kasutaja soovib infot..." - dense: 3072-dim embedding of name + description + LLM context + 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 ``` @@ -101,9 +102,12 @@ Service "Valuutakursid" → 4 Qdrant points ```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 → hashed to index in [0, VOCAB_SIZE), value = term frequency +# 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, ...]) ``` @@ -146,7 +150,7 @@ service_enrichment.sh │ ├─ Generate dense embedding (text-embedding-3-large) │ └─ Generate sparse vector (BM25 term hashing) │ - ├─ Step 3: Summary point (name + description + LLM context): + ├─ Step 3: Summary point (name + description + examples + entities + LLM context): │ ├─ Generate dense embedding │ └─ Generate sparse vector │ @@ -155,6 +159,17 @@ service_enrichment.sh └─ 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: @@ -186,12 +201,12 @@ POST /collections/intent_collections/points/query { "query": [0.023, -0.041, ...], # 3072-dim dense vector "using": "dense", - "limit": 6, + "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). +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. @@ -203,6 +218,7 @@ 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": [ @@ -215,6 +231,10 @@ POST /collections/intent_collections/points/query } ``` +> **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 ``` @@ -251,6 +271,7 @@ Dense: Valuutakursid (cosine=0.5511), gap=0.2371 → 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 @@ -285,17 +306,17 @@ SERVICE (Layer 1) → CONTEXT (Layer 2) → RAG (Layer 3) → OOD (Layer 4 | Path | Intent Detection | Entity Extraction | |------|-----------------|-------------------| | HIGH-CONFIDENCE | On 1 service (matched) | Yes — from LLM output | -| AMBIGUOUS | On 2-3 candidates | Yes — if LLM matches | +| 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` receives: +The DSPy `IntentDetectionModule` uses `dspy.Predict` (direct prediction) and receives: - User query -- Candidate services (formatted as JSON) -- Conversation history (last 3 turns) +- 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 @@ -336,6 +357,18 @@ Entities dict → ordered array matching service schema: # 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 @@ -387,7 +420,3 @@ Based on empirical testing with 42 Estonian queries (20 SERVICE, 22 RAG): - **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. - -### Current Limitations - -- **Step 7 (Ruuter service call) is not yet implemented.** The service workflow currently returns a debug response with service metadata (endpoint URL, HTTP method, extracted entities) instead of calling the actual Ruuter service endpoint. See the `TODO: STEP 7` comments in `src/tool_classifier/workflows/service_workflow.py`. diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md index 398299a8..ac92abb2 100644 --- a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -59,32 +59,45 @@ Handle queries that require calling external services/APIs: ### 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. Service Discovery - ↓ -2. Service Selection (Semantic Search or LLM-based) - ↓ -3. Intent Detection (DSPy LLM Call) - ↓ -4. Entity Extraction (From LLM Output) - ↓ -5. Entity Validation (Against Service Schema) - ↓ -6. Entity Transformation (Dict → Ordered Array) - ↓ -7. Service Call (TODO: Ruuter endpoint invocation) +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) ``` --- -## 1. Service Discovery +## Service Discovery (Legacy Path) ### Method: `_call_service_discovery()` Calls Ruuter public endpoint to fetch available services: ```python -GET /rag-search/get-services-from-llm +GET {RAG_SEARCH_RUUTER_PUBLIC}/services/get-services +# Default: http://ruuter-public:8086/rag-search/services/get-services ``` **Response Structure:** @@ -122,16 +135,14 @@ if service_count <= 10: elif service_count > 10: # Many services → Use semantic search to narrow down - services = await _semantic_search_services(query, top_k=5) + services = await _semantic_search_services(query, top_k=10) ``` --- -## 2. Service Selection +## Semantic Search (When Many Services) -### Semantic Search (When Many Services) - -**Method:** `_semantic_search_services()` +### Method: `_semantic_search_services()` Uses Qdrant vector database to find relevant services: @@ -142,8 +153,8 @@ embedding = orchestration_service.create_embeddings_for_indexer([query]) # 2. Search Qdrant collection search_payload = { "vector": query_embedding, - "limit": 5, # Top 5 services - "score_threshold": 0.4, # Minimum similarity + "limit": 10, # Top 10 services (SEMANTIC_SEARCH_TOP_K) + "score_threshold": 0.2, # Minimum similarity (SEMANTIC_SEARCH_THRESHOLD) "with_payload": True } @@ -157,7 +168,7 @@ response = qdrant_client.post( --- -## 3. Intent Detection (LLM-Based) +## Intent Detection (LLM-Based) ### Method: `_detect_service_intent()` @@ -189,23 +200,24 @@ services_formatted = [ "name": "Currency Conversion", "description": "Convert EUR to other currencies", "required_entities": ["target_currency"], - "examples": ["How much is EUR in USD?", "Convert EUR to JPY"] + "examples": ["How much is EUR in USD?", "Convert EUR to JPY"] # Top 3 examples } ] # 2. Prepare conversation context (last 3 turns) conversation_context = """ -user: Hello -assistant: Hi! How can I help? -user: How much is 100 EUR in USD? +end_user: Hello +backoffice_user: Hi! How can I help? +end_user: How much is 100 EUR in USD? """ -# 3. Call DSPy module -intent_result = intent_detector.forward( - user_query="How much is 100 EUR in USD?", - services=services_formatted, - conversation_history=conversation_history -) +# 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 @@ -226,8 +238,8 @@ The LLM returns structured JSON: ### Confidence Threshold ```python -if confidence < 0.7: - # Low confidence → Service workflow returns None → Fallback to RAG +if matched_service_id is None or confidence < 0.7: + # Low confidence → Service workflow returns None → Fallback to Context/RAG return None ``` @@ -251,7 +263,7 @@ costs_metric["intent_detection"] = usage_info --- -## 4. Entity Extraction +## Entity Extraction ### From LLM Output @@ -299,7 +311,7 @@ Entities are extracted as **key-value pairs** where: --- -## 5. Entity Validation +## Entity Validation ### Method: `_validate_entities()` @@ -367,7 +379,7 @@ validation_errors = ["Entity 'target_currency' has empty value"] --- -## 6. Entity Transformation +## Entity Transformation ### Method: `_transform_entities_to_array()` @@ -397,18 +409,14 @@ entities_array = ["USD", "EUR", "100"] ```python def _transform_entities_to_array( + self, entities_dict: Dict[str, str], entity_order: List[str] ) -> List[str]: """Transform entity dict to ordered array.""" - ordered_array = [] - - for entity_key in entity_order: - # Get value from dict, or empty string if missing - value = entities_dict.get(entity_key, "") - ordered_array.append(value) - - return ordered_array + if not entity_order: + return [] + return [entities_dict.get(key, "") for key in entity_order] ``` ### Example @@ -435,40 +443,62 @@ def _transform_entities_to_array( --- -## 7. Service Call (TODO: Step 7) +## Service Call (Step 7 — Implemented) ### Endpoint Construction ```python -endpoint_url = f"{RUUTER_BASE_URL}/services/active{service_name}" -# Example: "http://ruuter:8080/services/active/currency-conversion" -# (Note: service_name from service metadata, e.g., "/currency-conversion") +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 (Planned) +### Payload Construction ```python payload = { + "chatId": chat_id, + "authorId": author_id, "input": entities_array, # ["USD", "EUR", "100"] - "authorId": request.authorId, - "chatId": request.chatId } ``` -### HTTP Call (Planned) +### HTTP Call ```python -# Non-streaming -response = await httpx.post( - endpoint_url, - json=payload, - timeout=5.0 -) +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 -async with httpx.stream("POST", endpoint_url, json=payload) as stream: - async for line in stream.aiter_lines(): - yield orchestration_service.format_sse(chat_id, line) +### 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) ``` --- @@ -483,28 +513,14 @@ async with httpx.stream("POST", endpoint_url, json=payload) as stream: ### Step-by-Step Execution -#### 1. Service Discovery -```json -{ - "service_count": 5, - "services": [ - { - "serviceId": "currency_conversion_eur", - "name": "Currency Conversion (EUR)", - "entities": ["target_currency"], - "examples": ["How much is EUR in USD?"] - } - ] -} -``` - -#### 2. Service Selection +#### 1. Classification (Hybrid Search) ```python -# Few services (5 <= 10) → Use all for intent detection -services = discovery_result["services"] +# Dense search finds best service match +# cosine=0.5511, gap=0.2371 +# → HIGH-CONFIDENCE path (needs_llm_confirmation=False) ``` -#### 3. Intent Detection (LLM Call) +#### 2. Intent Detection (LLM Call on matched service only) ```json { "matched_service_id": "currency_conversion_eur", @@ -516,12 +532,12 @@ services = discovery_result["services"] } ``` -#### 4. Entity Extraction +#### 3. Entity Extraction ```python entities_dict = {"target_currency": "THB"} ``` -#### 5. Entity Validation +#### 4. Entity Validation ```python validation_result = { "is_valid": True, @@ -531,7 +547,7 @@ validation_result = { } ``` -#### 6. Entity Transformation +#### 5. Entity Transformation ```python # Schema: ["target_currency"] # Dict: {"target_currency": "THB"} @@ -539,14 +555,17 @@ validation_result = { entities_array = ["THB"] ``` -#### 7. Service Call (TODO) +#### 6. Service Call ```python -# Planned implementation -response = await call_service( - url="http://ruuter:8080/currency/convert", - method="POST", - payload={"input": ["THB"], "chatId": "..."} +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 ``` --- @@ -580,16 +599,16 @@ LLM USAGE COSTS BREAKDOWN: ### When Service Workflow Returns None ```python -# Scenario 1: No service match (confidence < 0.7) -if not intent_result or intent_result.get("confidence", 0) < 0.7: +# 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 validation failed -if not validated_service: +# Scenario 2: Service metadata extraction failed +if not service_metadata: return None # Fallback to CONTEXT layer -# Scenario 3: No services discovered -if not services: +# Scenario 3: Service endpoint call failed +if service_content is None: return None # Fallback to CONTEXT layer ``` @@ -607,22 +626,31 @@ Query: "What is VAT?" ## Configuration Constants ```python -# Service discovery -RUUTER_BASE_URL = "http://ruuter.public:8080" -SERVICE_DISCOVERY_TIMEOUT = 5.0 # seconds +# 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 = 20 # Max services to pass to LLM +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 = "services_collection" -SEMANTIC_SEARCH_TOP_K = 5 # Top 5 relevant services -SEMANTIC_SEARCH_THRESHOLD = 0.4 # Minimum similarity score -QDRANT_TIMEOUT = 2.0 # seconds +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 -# Intent detection -INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed +# 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 ``` --- @@ -639,11 +667,13 @@ INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed - Schema defines canonical order - Missing entities → empty strings -### 3. **Two-Stage Service Selection** -- Few services (≤10): Pass all to LLM -- Many services (>10): Semantic search first +### 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) @@ -653,8 +683,14 @@ INTENT_CONFIDENCE_THRESHOLD = 0.7 # Minimum confidence to proceed - 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 **LLM-based intent detection** to match user queries to external services, extract entities, validate them against service schemas, and prepare them for service invocation—all while maintaining comprehensive cost tracking and seamless integration with the broader RAG pipeline. +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/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py index 1ada8940..ff683f6f 100644 --- a/src/tool_classifier/classifier.py +++ b/src/tool_classifier/classifier.py @@ -57,8 +57,8 @@ class ToolClassifier: def __init__( self, - llm_manager: Any, # noqa: ANN401 - orchestration_service: Any, # noqa: ANN401 + llm_manager: Any, + orchestration_service: Any, ) -> None: """ Initialize tool classifier with required dependencies. @@ -623,7 +623,7 @@ def _get_workflow_executor(self, workflow_type: WorkflowType) -> Any: async def _execute_with_fallback_async( self, - workflow: Any, # noqa: ANN401 + workflow: Any, request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, @@ -701,7 +701,7 @@ async def _execute_with_fallback_async( async def _execute_with_fallback_streaming( self, - workflow: Any, # noqa: ANN401 + workflow: Any, request: OrchestrationRequest, context: Dict[str, Any], start_layer: WorkflowType, diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py index d839e2cf..ffda9507 100644 --- a/src/tool_classifier/constants.py +++ b/src/tool_classifier/constants.py @@ -37,6 +37,9 @@ 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 public service endpoints (active services).""" + RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" """Public Ruuter endpoint for RAG search service discovery.""" diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py index 78825502..5e3f62dc 100644 --- a/src/tool_classifier/workflows/service_workflow.py +++ b/src/tool_classifier/workflows/service_workflow.py @@ -21,9 +21,10 @@ QDRANT_PORT, QDRANT_TIMEOUT, RAG_SEARCH_RUUTER_PUBLIC, - RUUTER_BASE_URL, + RUUTER_SERVICE_BASE_URL, SEMANTIC_SEARCH_THRESHOLD, SEMANTIC_SEARCH_TOP_K, + SERVICE_CALL_TIMEOUT, SERVICE_COUNT_THRESHOLD, SERVICE_DISCOVERY_TIMEOUT, ) @@ -122,7 +123,6 @@ async def _semantic_search_services( return None try: - # Generate embedding using orchestration service embedding_result = self.orchestration_service.create_embeddings_for_indexer( texts=[query], environment=request.environment, @@ -137,12 +137,10 @@ async def _semantic_search_services( query_embedding = embeddings[0] - # Create Qdrant client with proper resource cleanup via context manager qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" async with httpx.AsyncClient( base_url=qdrant_url, timeout=QDRANT_TIMEOUT ) as client: - # Verify collection exists and has data try: collection_info = await client.get( f"/collections/{QDRANT_COLLECTION}" @@ -156,7 +154,6 @@ async def _semantic_search_services( except Exception as e: logger.warning(f"[{chat_id}] Could not verify collection: {e}") - # Search Qdrant collection search_payload = { "vector": query_embedding, "limit": top_k, @@ -184,7 +181,6 @@ async def _semantic_search_services( ) return None - # Transform Qdrant results to service format services: List[Dict[str, Any]] = [] for point in points: payload = point.get("payload", {}) @@ -197,8 +193,6 @@ async def _semantic_search_services( "description": payload.get("description"), "examples": payload.get("examples", []), "entities": payload.get("entities", []), - # Note: endpoint not stored in intent_collections, - # will be resolved via database lookup if needed "similarity_score": score, } services.append(service) @@ -251,30 +245,24 @@ async def _detect_service_intent( - usage_info: Cost and token usage information """ try: - # Ensure DSPy is configured with LLMManager if self.llm_manager: self.llm_manager.ensure_global_config() else: logger.error(f"[{chat_id}] LLM Manager not available") return None, {} - # Capture history length before LLM call for cost tracking lm = dspy.settings.lm history_length_before = ( len(lm.history) if lm and hasattr(lm, "history") else 0 ) - # Create DSPy module intent_module = IntentDetectionModule() - - # Convert conversation history to dict format history_dicts = [ {"authorRole": msg.authorRole, "message": msg.message} for msg in conversation_history if hasattr(msg, "authorRole") and hasattr(msg, "message") ] - # Call DSPy forward with task-local config with self.llm_manager.use_task_local(): intent_result = intent_module.forward( user_query=user_query, @@ -282,7 +270,6 @@ async def _detect_service_intent( conversation_history=history_dicts, ) - # Extract usage information after LLM call usage_info = get_lm_usage_since(history_length_before) return intent_result, usage_info @@ -359,31 +346,20 @@ def _extract_service_metadata( self, context: Dict[str, Any], chat_id: str ) -> Optional[Dict[str, Any]]: """Extract service and entity metadata from context.""" - # Check if service_id exists service_id = context.get("service_id") if not service_id: logger.error(f"[{chat_id}] Missing service_id in context") return None - # Check if service_data exists service_data = context.get("service_data") if not service_data: logger.error(f"[{chat_id}] Missing service_data in context") return None - # Extract entities dict from context (LLM extracted) entities_dict = context.get("entities", {}) - - # Extract entity schema from service_data (expected order) - entity_schema = service_data.get("entities", []) - if entity_schema is None: - entity_schema = [] - - # Extract service name + entity_schema = service_data.get("entities", []) or [] service_name = service_data.get("name", service_id) - - # Extract HTTP method (ruuter_type) - defaults to GET if not specified - ruuter_type = service_data.get("ruuter_type", "GET") + ruuter_type = service_data.get("ruuter_type", "POST") return { "service_id": service_id, @@ -434,10 +410,7 @@ def _validate_entities( if entity_key not in service_schema: extra_entities.append(entity_key) - # Determine overall validity - # We consider it valid even with missing entities (will send empty strings) - # Let the external service validate required parameters - is_valid = True # Always true - we proceed with partial entities + is_valid = True return { "is_valid": is_valid, @@ -452,29 +425,98 @@ def _transform_entities_to_array( """Transform entity dictionary to ordered array based on service schema.""" if not entity_order: return [] - - # Transform to ordered array, filling missing with empty strings return [entities_dict.get(key, "") for key in entity_order] + _INVISIBLE_CHAR_TABLE = str.maketrans( + "", "", "\u2060\u200b\u200c\u200d\ufeff\u00ad\u200e\u200f" + ) + def _construct_service_endpoint(self, service_name: str, chat_id: str) -> str: """Construct the full service endpoint URL for Ruuter.""" - return f"{RUUTER_BASE_URL}/services/active{service_name}" + clean_name = ( + service_name.strip().translate(self._INVISIBLE_CHAR_TABLE).replace(" ", "_") + ) + return f"{RUUTER_SERVICE_BASE_URL}/services/active/{clean_name}" - def _format_debug_response( + async def _call_service_endpoint( self, - service_name: str, endpoint_url: str, http_method: str, entities_array: List[str], - ) -> str: - """Format debug information for testing (temporary before Step 7 implementation).""" - entities_str = ", ".join(f'"{e}"' for e in entities_array) - return ( - f" Service Validated: {service_name}\n" - f" Endpoint URL: {endpoint_url}\n" - f" HTTP Method: {http_method}\n" - f" Extracted Entities: [{entities_str}]\n\n" - ) + chat_id: str, + author_id: str, + ) -> Optional[str]: + """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: + Service response content string, 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"] + + # DMapper returns a JSON array; each item has a "content" field + if isinstance(data, list) and len(data) > 0: + content = data[0].get("content", "") + if content: + logger.info( + f"[{chat_id}] Service endpoint returned content " + f"({len(content)} chars)" + ) + return content + + logger.warning( + f"[{chat_id}] Service response missing 'content' field" + ) + return None + + 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, @@ -494,16 +536,13 @@ async def _log_request_details( chat_id = request.chatId logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") - # Service Discovery discovery_result = await self._call_service_discovery(chat_id) if discovery_result: - # Extract data from nested response structure response_data = discovery_result.get("response", {}) use_semantic = response_data.get("use_semantic_search", False) service_count = response_data.get("service_count", 0) - # Handle service_count if it's a string or NaN if isinstance(service_count, str): try: service_count = int(service_count) @@ -512,12 +551,10 @@ async def _log_request_details( services_from_ruuter = response_data.get("services", []) - # Use semantic search if count > threshold if service_count > SERVICE_COUNT_THRESHOLD: use_semantic = True if use_semantic: - # Use semantic search to find relevant services services = await self._semantic_search_services( query=request.message, request=request, @@ -584,34 +621,25 @@ async def execute_async( chat_id = request.chatId - # Create costs tracking dictionary (follows RAG workflow pattern) costs_metric: Dict[str, Dict[str, Any]] = {} - # Use parent time_metric or create new one if time_metric is None: time_metric = {} - # Check if classifier provided hybrid search metadata needs_llm_confirmation = context.get("needs_llm_confirmation") if needs_llm_confirmation is False: - # HIGH CONFIDENCE PATH: Classifier matched a service with high confidence - # Skip service discovery — use hybrid search match directly - matched_service_id = context.get("matched_service_id") 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 (non-streaming): " - f"{matched_service_name} (cosine_score={cosine_score:.4f}) - " - f"skipping discovery" + f"[{chat_id}] High-confidence service match: " + f"{matched_service_name} (score={cosine_score:.4f})" ) - # Get service details from top_results (already retrieved by classifier) top_results = context.get("top_results", []) if top_results: matched = top_results[0] - # Run entity extraction via LLM (DSPy) for this single service start_time = time.time() await self._process_intent_detection( services=[matched], @@ -622,24 +650,15 @@ async def execute_async( ) time_metric["service.intent_detection"] = time.time() - start_time - # Ensure service_data is populated from hybrid match - # _process_intent_detection may not set it if DSPy returns - # a different service_id format, so we populate it explicitly if not context.get("service_data"): context["service_id"] = matched.get("service_id") context["service_data"] = matched - logger.info( - f"[{chat_id}] Populated service_data from hybrid match: " - f"{matched.get('name')}" - ) elif needs_llm_confirmation is True: - # AMBIGUOUS PATH: Multiple services scored similarly - # Run LLM intent detection only on candidate services (not all services) top_results = context.get("top_results", []) logger.info( - f"[{chat_id}] AMBIGUOUS SERVICE MATCH (non-streaming): " - f"running LLM intent detection on {len(top_results)} candidates" + f"[{chat_id}] Ambiguous match: " + f"running intent detection on {len(top_results)} candidates" ) start_time = time.time() @@ -654,44 +673,26 @@ async def execute_async( time_metric["service.intent_detection"] = time.time() - start_time else: - # LEGACY PATH: No hybrid search metadata (classifier disabled or error) - # Full service discovery + intent detection (original behavior) 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 - # Check if service was detected and validated if not context.get("service_id"): - logger.info( - f"[{chat_id}] No service detected or validated - " - f"returning None to fallback to next layer" - ) + logger.info(f"[{chat_id}] No service matched, falling back") return None - # Entity Transformation & Validation - logger.info(f"[{chat_id}] Entity Transformation:") - - # Step 1: Extract service metadata from context start_time = time.time() service_metadata = self._extract_service_metadata(context, chat_id) if not service_metadata: - logger.error( - f"[{chat_id}] - Metadata extraction failed - " - f"returning None to fallback" - ) return None - logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") - logger.info( - f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" - ) logger.info( - f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + f"[{chat_id}] Service: {service_metadata['service_name']}, " + f"entities: {service_metadata['entities_dict']}" ) - # Step 2: Validate entities against schema validation_result = self._validate_entities( extracted_entities=service_metadata["entities_dict"], service_schema=service_metadata["entity_schema"], @@ -700,28 +701,11 @@ async def execute_async( ) time_metric["service.entity_validation"] = time.time() - start_time - logger.info( - f"[{chat_id}] - Validation status: " - f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" - ) - if validation_result["missing_entities"]: logger.warning( - f"[{chat_id}] - Missing entities (will send empty strings): " - f"{validation_result['missing_entities']}" - ) - - if validation_result["extra_entities"]: - logger.info( - f"[{chat_id}] - Extra entities (ignored): " - f"{validation_result['extra_entities']}" + f"[{chat_id}] Missing entities: {validation_result['missing_entities']}" ) - if validation_result["validation_errors"]: - for error in validation_result["validation_errors"]: - logger.warning(f"[{chat_id}] - Validation warning: {error}") - - # Step 3: Transform entities dict to ordered array entities_array = self._transform_entities_to_array( entities_dict=service_metadata["entities_dict"], entity_order=service_metadata["entity_schema"], @@ -730,46 +714,36 @@ async def execute_async( context["entities_array"] = entities_array context["validation_result"] = validation_result - # Construct service endpoint URL endpoint_url = self._construct_service_endpoint( service_name=service_metadata["service_name"], chat_id=chat_id ) - context["endpoint_url"] = endpoint_url context["http_method"] = service_metadata["ruuter_type"] - logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") - - # TODO: STEP 7 - Call Ruuter service endpoint and return response - # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} - # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT - # 3. Parse Ruuter response and extract result - # 4. Return OrchestrationResponse with actual service result - # 5. Handle errors (timeout, HTTP errors, malformed JSON) - - # STEP 6: Return debug response (temporary until Step 7 - Ruuter call implemented) - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) - debug_content = self._format_debug_response( - service_name=service_metadata["service_name"], + start_time = time.time() + service_content = 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 - logger.info(f"[{chat_id}] Returning debug response (Step 7 pending)") - - # Log costs after service workflow completes (follows RAG workflow pattern) if self.orchestration_service: self.orchestration_service.log_costs(costs_metric) + if service_content is None: + logger.warning(f"[{chat_id}] Service endpoint call failed, falling back") + return None + return OrchestrationResponse( chatId=request.chatId, llmServiceActive=True, questionOutOfLLMScope=False, inputGuardFailed=False, - content=debug_content, + content=service_content, ) - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) async def execute_streaming( self, @@ -789,23 +763,19 @@ async def execute_streaming( chat_id = request.chatId - # Create costs tracking dictionary (follows RAG workflow pattern) costs_metric: Dict[str, Dict[str, Any]] = {} - # Use parent time_metric or create new one if time_metric is None: time_metric = {} - # Check if classifier provided hybrid search metadata needs_llm_confirmation = context.get("needs_llm_confirmation") if needs_llm_confirmation is False: - # HIGH CONFIDENCE PATH: Skip discovery, use matched service 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 (streaming): " - f"{matched_service_name} (cosine_score={cosine_score:.4f})" + f"[{chat_id}] High-confidence service match: " + f"{matched_service_name} (score={cosine_score:.4f})" ) top_results = context.get("top_results", []) @@ -822,21 +792,15 @@ async def execute_streaming( ) time_metric["service.intent_detection"] = time.time() - start_time - # Ensure service_data is populated from hybrid match if not context.get("service_data"): context["service_id"] = matched.get("service_id") context["service_data"] = matched - logger.info( - f"[{chat_id}] Populated service_data from hybrid match: " - f"{matched.get('name')}" - ) elif needs_llm_confirmation is True: - # AMBIGUOUS PATH: Run LLM intent detection on candidates top_results = context.get("top_results", []) logger.info( - f"[{chat_id}] AMBIGUOUS SERVICE MATCH (streaming): " - f"{len(top_results)} candidates" + f"[{chat_id}] Ambiguous match: " + f"running intent detection on {len(top_results)} candidates" ) start_time = time.time() @@ -851,42 +815,25 @@ async def execute_streaming( time_metric["service.intent_detection"] = time.time() - start_time else: - # LEGACY PATH: Full service discovery (original behavior) 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 - # Check if service was detected and validated if not context.get("service_id"): - logger.info( - f"[{chat_id}] No service detected or validated - " - f"returning None to fallback to next layer" - ) + logger.info(f"[{chat_id}] No service matched, falling back") return None - # Entity Transformation & Validation - logger.info(f"[{chat_id}] Entity Transformation:") - - # Step 1: Extract service metadata from context service_metadata = self._extract_service_metadata(context, chat_id) if not service_metadata: - logger.error( - f"[{chat_id}] - Metadata extraction failed - " - f"returning None to fallback" - ) return None - logger.info(f"[{chat_id}] - Service: {service_metadata['service_name']}") logger.info( - f"[{chat_id}] - Schema entities: {service_metadata['entity_schema']}" - ) - logger.info( - f"[{chat_id}] - Extracted entities: {service_metadata['entities_dict']}" + f"[{chat_id}] Service: {service_metadata['service_name']}, " + f"entities: {service_metadata['entities_dict']}" ) - # Step 2: Validate entities against schema validation_result = self._validate_entities( extracted_entities=service_metadata["entities_dict"], service_schema=service_metadata["entity_schema"], @@ -894,28 +841,11 @@ async def execute_streaming( chat_id=chat_id, ) - logger.info( - f"[{chat_id}] - Validation status: " - f"{'PASSED ✓' if validation_result['is_valid'] else 'FAILED ✗'}" - ) - if validation_result["missing_entities"]: logger.warning( - f"[{chat_id}] - Missing entities (will send empty strings): " - f"{validation_result['missing_entities']}" + f"[{chat_id}] Missing entities: {validation_result['missing_entities']}" ) - if validation_result["extra_entities"]: - logger.info( - f"[{chat_id}] - Extra entities (ignored): " - f"{validation_result['extra_entities']}" - ) - - if validation_result["validation_errors"]: - for error in validation_result["validation_errors"]: - logger.warning(f"[{chat_id}] - Validation warning: {error}") - - # Step 3: Transform entities dict to ordered array entities_array = self._transform_entities_to_array( entities_dict=service_metadata["entities_dict"], entity_order=service_metadata["entity_schema"], @@ -924,47 +854,32 @@ async def execute_streaming( context["entities_array"] = entities_array context["validation_result"] = validation_result - # Construct service endpoint URL endpoint_url = self._construct_service_endpoint( service_name=service_metadata["service_name"], chat_id=chat_id ) - context["endpoint_url"] = endpoint_url context["http_method"] = service_metadata["ruuter_type"] - logger.info(f"[{chat_id}] Service prepared: {endpoint_url}") - - # TODO: STEP 7 - Call Ruuter service endpoint and stream response - # 1. Build payload: {"input": entities_array, "authorId": request.authorId, "chatId": request.chatId} - # 2. Call endpoint using http_method (POST/GET) with SERVICE_CALL_TIMEOUT - # 3. Parse Ruuter response and extract result - # 4. Format result as SSE and yield chunks - # 5. Handle errors (timeout, HTTP errors, malformed JSON) - - # STEP 6: Return debug response as async iterator (temporary until Step 7) - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (START) - debug_content = self._format_debug_response( - service_name=service_metadata["service_name"], + service_content = 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, ) - logger.info(f"[{chat_id}] Streaming debug response (Step 7 pending)") + if service_content 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") - # Store reference for closure (helps type checker) orchestration_service = self.orchestration_service - async def debug_stream() -> AsyncIterator[str]: - yield orchestration_service.format_sse(chat_id, debug_content) + async def service_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse(chat_id, service_content) yield orchestration_service.format_sse(chat_id, "END") - - # Log costs after streaming completes (follows RAG workflow pattern) - # Must be inside generator because costs are accumulated during streaming orchestration_service.log_costs(costs_metric) - return debug_stream() - # REMOVE THIS BLOCK AFTER STEP 7 IMPLEMENTATION (END) + return service_stream() 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/test_context_workflow.py b/tests/test_context_workflow.py index 1362a72d..9a6d7e7d 100644 --- a/tests/test_context_workflow.py +++ b/tests/test_context_workflow.py @@ -7,7 +7,7 @@ from src.tool_classifier.workflows.context_workflow import ContextWorkflowExecutor from src.tool_classifier.context_analyzer import ContextDetectionResult -from models.request_models import ( +from src.models.request_models import ( OrchestrationRequest, OrchestrationResponse, ConversationItem, @@ -113,12 +113,13 @@ async def test_execute_async_greeting_estonian( with patch.object( context_workflow.context_analyzer, - "detect_context", - return_value=( + "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 @@ -155,12 +156,13 @@ async def test_execute_async_greeting_english( with patch.object( context_workflow.context_analyzer, - "detect_context", - return_value=( + "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 diff --git a/tests/test_context_workflow_integration.py b/tests/test_context_workflow_integration.py index bca2af2e..a11a7f48 100644 --- a/tests/test_context_workflow_integration.py +++ b/tests/test_context_workflow_integration.py @@ -253,7 +253,7 @@ async def test_greeting_end_to_end_non_streaming( request = _make_request("Hello!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -295,7 +295,7 @@ async def test_estonian_greeting_end_to_end( request = _make_request("Tere!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -329,7 +329,7 @@ async def test_goodbye_end_to_end(self, classifier: ToolClassifier) -> None: request = _make_request("Goodbye!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -366,7 +366,7 @@ async def test_thanks_end_to_end(self, classifier: ToolClassifier) -> None: request = _make_request("Thank you!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -421,7 +421,7 @@ async def test_context_answer_end_to_end(self, classifier: ToolClassifier) -> No with ( patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -613,7 +613,7 @@ async def test_streaming_greeting_end_to_end( request = _make_request("Hello!") with patch.object( classifier.context_workflow.context_analyzer, - "detect_context", + "detect_context_with_summary_fallback", new_callable=AsyncMock, return_value=( ContextDetectionResult( @@ -666,6 +666,10 @@ async def test_streaming_context_answer_end_to_end( ), ] + 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(), @@ -677,13 +681,36 @@ async def test_streaming_context_answer_end_to_end( ) request = _make_request("When is the deadline?", history=history) - stream = await classifier.route_to_workflow( - classification=classification, - request=request, - is_streaming=True, - ) + 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] + chunks = [chunk async for chunk in stream] assert len(chunks) >= 2 last_payload = json.loads(chunks[-1][6:-2]) From 59e0e4992f27d014d03ddc2a793227061a427c8d Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:44:55 +0530 Subject: [PATCH 070/105] fixed the test production UI issue (#366) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * update time tracking * added time tracking and reloacate input guardrail before toolclassifiier * fixed issue * fixed issue * added hybrid search for the service detection * update tool classifier * fixing merge conflicts * fixed issue * optimize first user query response generation time * fixed pr reviewed issues * service integration * context based response generation flow * fixed pr review suggested issues * removed service project layer * fixed issues * delete unnessary files * added requested changes * fixed streaming error * fixed issue --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- docker-compose-ec2.yml | 3 ++- docker-compose.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docker-compose-ec2.yml b/docker-compose-ec2.yml index cc48c1c9..7df19e33 100644 --- a/docker-compose-ec2.yml +++ b/docker-compose-ec2.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 0a825082..48bcbaa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 From 06a90a1411a2a6630a585bd2a7e5f9ed9be7ceaa Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:44:16 +0530 Subject: [PATCH 071/105] Fixed issue in langfuse deployment using Kubernetes (#373) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * update time tracking * added time tracking and reloacate input guardrail before toolclassifiier * fixed issue * fixed issue * added hybrid search for the service detection * update tool classifier * fixing merge conflicts * fixed issue * optimize first user query response generation time * fixed pr reviewed issues * service integration * context based response generation flow * fixed pr review suggested issues * removed service project layer * fixed issues * delete unnessary files * added requested changes * added seperate db for langfuse * fixed issue * partially completed langfuse deployment issue * Add Helm chart for RAG Module with database and service configurations --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- DSL/Liquibase/langfuse-init/init-langfuse.sql | 4 + ....timestamp-1773932542024-ee7096644c66a.mjs | 77 ++++++++++++ docker-compose-ec2.yml | 13 +- docker-compose-test.yml | 16 +-- docker-compose.yml | 13 +- env.example | 4 +- kubernetes/CONTAINER_REGISTRY_SETUP.md | 35 ++++++ kubernetes/Chart.lock | 84 +++++++++++++ kubernetes/Chart.yaml | 116 ++++++++++++++++++ kubernetes/LANGFUSE_SETUP.md | 59 +++++++++ .../deployment-byk-langfuse-web.yaml | 35 ++++-- kubernetes/charts/database/Chart.lock | 6 + kubernetes/charts/database/Chart.yaml | 2 +- .../charts/database/templates/configmap.yaml | 16 +++ .../database/templates/statefulset.yaml | 10 ++ kubernetes/charts/database/values.yaml | 7 ++ kubernetes/dashboard-admin.yaml | 18 +++ kubernetes/values.yaml | 87 +++++++++++++ migrate.sh | 2 +- tests/integration_tests/conftest.py | 4 +- 20 files changed, 574 insertions(+), 34 deletions(-) create mode 100644 DSL/Liquibase/langfuse-init/init-langfuse.sql create mode 100644 GUI/vite.config.ts.timestamp-1773932542024-ee7096644c66a.mjs create mode 100644 kubernetes/CONTAINER_REGISTRY_SETUP.md create mode 100644 kubernetes/Chart.lock create mode 100644 kubernetes/Chart.yaml create mode 100644 kubernetes/LANGFUSE_SETUP.md create mode 100644 kubernetes/charts/database/Chart.lock create mode 100644 kubernetes/charts/database/templates/configmap.yaml create mode 100644 kubernetes/dashboard-admin.yaml create mode 100644 kubernetes/values.yaml 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/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/docker-compose-ec2.yml b/docker-compose-ec2.yml index 7df19e33..f3bde2fd 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 @@ -302,7 +302,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 @@ -369,7 +369,7 @@ services: restart: always depends_on: - langfuse-worker - - rag_search_db + - rag-search-db ports: - 3005:3000 env_file: @@ -463,8 +463,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: @@ -482,6 +482,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 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 48bcbaa3..1c487ef2 100644 --- a/docker-compose.yml +++ b/docker-compose.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 @@ -250,7 +250,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 @@ -317,7 +317,7 @@ services: restart: always depends_on: - langfuse-worker - - rag_search_db + - rag-search-db ports: - 3005:3000 env_file: @@ -411,8 +411,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: @@ -430,6 +430,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 diff --git a/env.example b/env.example index 105b9f8e..9bbc857d 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 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/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml index 403e253f..18d14804 100644 --- a/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml +++ b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml @@ -17,6 +17,31 @@ spec: 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 }}" @@ -25,22 +50,16 @@ spec: - name: http containerPort: {{ .Values.service.targetPort }} 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 }} + port: {{ .Values.service.targetPort }} initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} periodSeconds: {{ .Values.healthcheck.periodSeconds }} timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} @@ -48,7 +67,7 @@ spec: readinessProbe: httpGet: path: /api/public/health - port: {{ .Values.service.port }} + port: {{ .Values.service.targetPort }} initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} periodSeconds: {{ .Values.healthcheck.periodSeconds }} timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} 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 index 9612978c..df3256a4 100644 --- a/kubernetes/charts/database/Chart.yaml +++ b/kubernetes/charts/database/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: database description: PostgreSQL databases for RAG Module using pure PostgreSQL type: application -version: 0.2.0 +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/statefulset.yaml b/kubernetes/charts/database/templates/statefulset.yaml index 4ff65816..02194da9 100644 --- a/kubernetes/charts/database/templates/statefulset.yaml +++ b/kubernetes/charts/database/templates/statefulset.yaml @@ -51,6 +51,16 @@ spec: 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 diff --git a/kubernetes/charts/database/values.yaml b/kubernetes/charts/database/values.yaml index 8c43f0f5..f8ecba92 100644 --- a/kubernetes/charts/database/values.yaml +++ b/kubernetes/charts/database/values.yaml @@ -5,6 +5,12 @@ databases: password: "{{ ragSearchDB.password }}" db: rag-search 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 }}" @@ -23,3 +29,4 @@ persistence: storageClass: "" # specify your own accessModes: ["ReadWriteOnce"] + 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..84f2ccf7 --- /dev/null +++ b/kubernetes/values.yaml @@ -0,0 +1,87 @@ +# Global configuration for RAG Module +global: + domain: "rag-module.local" + namespace: "rag-module" + storageClass: "local-path" + +# Individual service configurations +database: + enabled: true + +TIM-database: + enabled: false # tim-postgresql is now managed by the database chart + +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.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/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 333771a2..9a348b23 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -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", ], From 2b0b3fc133cc3f8395e01cb2fde79a376b13973f Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:46:40 +0530 Subject: [PATCH 072/105] Verified Input Sanitizer Safety for #service Payloads and added buttons Field to OrchestrationResponse and TestOrchestrationResponse (#367) * remove unwanted file * updated changes * fixed requested changes * fixed issue * service workflow implementation without calling service endpoints * fixed requested changes * fixed issues * protocol related requested changes * fixed requested changes * update time tracking * added time tracking and reloacate input guardrail before toolclassifiier * fixed issue * fixed issue * added hybrid search for the service detection * update tool classifier * fixing merge conflicts * fixed issue * optimize first user query response generation time * fixed pr reviewed issues * service integration * context based response generation flow * fixed pr review suggested issues * removed service project layer * fixed issues * delete unnessary files * added requested changes * validate input sanitizer is compatible with mcq prefixes * updated OrchestrationResponse to support buttons field * removed md file * Enhance orchestration logging and update response models for choice buttons - Improved logging for button presence and count in orchestration requests. - Introduced ChoiceButton model for better structure in orchestration responses. - Updated OrchestrationResponse and TestOrchestrationResponse to use ChoiceButton. - Adjusted input sanitizer to unescape HTML before stripping tags for better security. - Added unit test to ensure entity-encoded script tags are handled correctly. --------- Co-authored-by: Thiru Dinesh <56014038+Thirunayan22@users.noreply.github.com> --- src/llm_orchestration_service_api.py | 12 ++- src/models/request_models.py | 18 ++++ src/utils/input_sanitizer.py | 5 +- tests/test_input_sanitizer.py | 125 +++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 tests/test_input_sanitizer.py diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index 110c2991..12cb0ed9 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -281,6 +281,12 @@ async def orchestrate_llm_request( # Process the 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 @@ -364,6 +370,10 @@ async def test_orchestrate_llm_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}" ) @@ -375,9 +385,9 @@ async 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}" ) diff --git a/src/models/request_models.py b/src/models/request_models.py index 689c68c3..c6c58ebc 100644 --- a/src/models/request_models.py +++ b/src/models/request_models.py @@ -138,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.""" @@ -150,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 @@ -261,6 +275,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/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/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