From 31aae9e1b5d25fb4cdcd00b581196dd6583114a2 Mon Sep 17 00:00:00 2001 From: alwaysalearner123 Date: Tue, 11 Aug 2026 17:01:17 +0530 Subject: [PATCH] chore: restructure repository for agentic RAG and rename docs-agent-mcp to kagent-feast-mcp --- .github/workflows/oke-cicd.yaml | 472 +++--- .github/workflows/tests.yml | 148 +- .gitignore | 454 ++--- README.md | 1510 ++++++++--------- frontend/README.md | 106 +- .../README.md | 0 .../charts/gateway-guardrails/Chart.yaml | 0 .../charts/gateway-guardrails/README.md | 0 .../gateway-guardrails/templates/_helpers.tpl | 0 .../gateway-guardrails/templates/gateway.yaml | 0 .../templates/jwt-auth.yaml | 0 .../templates/mesh-authz.yaml | 0 .../templates/ratelimit-global.yaml | 0 .../templates/ratelimit-perip.yaml | 0 .../templates/session-issuer.yaml | 140 +- .../gateway-guardrails/templates/tls.yaml | 0 .../templates/virtualservice.yaml | 0 .../charts/gateway-guardrails/values.yaml | 0 .../manifests/istio-ratelimit/README.md | 0 .../local-ratelimit-global.yaml | 0 .../perip-global-ratelimit.yaml | 0 .../perip-ratelimit-service.yaml | 0 .../manifests/istio-tls/cluster-issuer.yaml | 0 .../manifests/istio-tls/ingress-class.yaml | 0 .../istio-tls/kagent-gateway-tls.yaml | 0 .../istio/allow-kubeflow-to-milvus.yaml | 0 .../manifests/istio/allow-milvus-etcd.yaml | 0 .../manifests/istio/allow-milvus-minio.yaml | 0 .../istio/allow-milvus-standalone.yaml | 0 .../manifests/kagent/setup.yaml | 0 .../manifests/mcp-server/mcp-server.yaml | 0 .../manifests/vllm/kserve-qwen.yaml | 0 .../mcp-server/Dockerfile | 0 .../mcp-server/embeddings_client.py | 0 .../mcp-server/rag_collections.py | 0 .../mcp-server/requirements.txt | 0 .../mcp-server/server.py | 0 .../mcp-server/smoke_tools.py | 0 .../pipelines/Dockerfile.pipeline | 0 .../pipelines/README.md | 0 .../pipelines/code-pipeline.py | 0 .../pipelines/code_utils.py | 0 .../pipelines/incremental-pipeline.py | 0 .../pipelines/issues-pipeline.py | 0 .../pipelines/issues_utils.py | 0 .../pipelines/kubeflow-pipeline.py | 0 .../pipelines/requirements.txt | 0 .../pipelines/submit_run.py | 0 .../pipelines/utils.py | 0 .../session-issuer/Dockerfile | 0 .../session-issuer/issuer_core.py | 0 .../session-issuer/main.py | 0 .../session-issuer/requirements.txt | 0 .../terraform/.gitignore | 0 .../terraform/.terraform.lock.hcl | 0 .../terraform/embeddings.tf | 0 .../terraform/gateway_guardrails.tf | 0 .../terraform/kagent.tf | 0 .../terraform/knative.tf | 0 .../terraform/kubeflow_pipelines.tf | 0 .../terraform/milvus.tf | 0 .../terraform/namespaces.tf | 0 .../terraform/providers.tf | 0 .../terraform/terraform.tfvars.example | 0 .../terraform/variables.tf | 0 .../server-https/Dockerfile | 0 .../server-https/app.py | 0 .../server-https/deployment.yaml | 0 .../server-https/requirements.txt | 0 .../server-https/service.yaml | 0 .../server-https/virtualservice.yaml | 0 {legacy => legacy-servers}/server/Dockerfile | 0 {legacy => legacy-servers}/server/app.py | 0 .../server/deployment.yaml | 0 .../server/requirements.txt | 0 .../server/service.yaml | 0 pyproject.toml | 40 +- tests/test_code_utils.py | 332 ++-- tests/test_issues_pipeline.py | 618 +++---- tests/test_mcp_server.py | 988 +++++------ tests/test_mcp_smoke_tools.py | 44 +- tests/test_pipeline_utils.py | 498 +++--- tests/test_session_issuer.py | 280 +-- 83 files changed, 2815 insertions(+), 2815 deletions(-) rename {docs-agent-mcp => kagent-feast-mcp}/README.md (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/Chart.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/README.md (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/_helpers.tpl (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/gateway.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/jwt-auth.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/mesh-authz.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/ratelimit-global.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/ratelimit-perip.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/session-issuer.yaml (93%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/tls.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/templates/virtualservice.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/charts/gateway-guardrails/values.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-ratelimit/README.md (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-ratelimit/local-ratelimit-global.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-ratelimit/perip-global-ratelimit.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-ratelimit/perip-ratelimit-service.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-tls/cluster-issuer.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-tls/ingress-class.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio-tls/kagent-gateway-tls.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio/allow-kubeflow-to-milvus.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio/allow-milvus-etcd.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio/allow-milvus-minio.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/istio/allow-milvus-standalone.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/kagent/setup.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/mcp-server/mcp-server.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/manifests/vllm/kserve-qwen.yaml (100%) rename {docs-agent-mcp => kagent-feast-mcp}/mcp-server/Dockerfile (100%) rename {docs-agent-mcp => kagent-feast-mcp}/mcp-server/embeddings_client.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/mcp-server/rag_collections.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/mcp-server/requirements.txt (100%) rename {docs-agent-mcp => kagent-feast-mcp}/mcp-server/server.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/mcp-server/smoke_tools.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/Dockerfile.pipeline (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/README.md (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/code-pipeline.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/code_utils.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/incremental-pipeline.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/issues-pipeline.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/issues_utils.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/kubeflow-pipeline.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/requirements.txt (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/submit_run.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/pipelines/utils.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/session-issuer/Dockerfile (100%) rename {docs-agent-mcp => kagent-feast-mcp}/session-issuer/issuer_core.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/session-issuer/main.py (100%) rename {docs-agent-mcp => kagent-feast-mcp}/session-issuer/requirements.txt (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/.gitignore (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/.terraform.lock.hcl (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/embeddings.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/gateway_guardrails.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/kagent.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/knative.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/kubeflow_pipelines.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/milvus.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/namespaces.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/providers.tf (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/terraform.tfvars.example (100%) rename {docs-agent-mcp => kagent-feast-mcp}/terraform/variables.tf (100%) rename {legacy => legacy-servers}/server-https/Dockerfile (100%) rename {legacy => legacy-servers}/server-https/app.py (100%) rename {legacy => legacy-servers}/server-https/deployment.yaml (100%) rename {legacy => legacy-servers}/server-https/requirements.txt (100%) rename {legacy => legacy-servers}/server-https/service.yaml (100%) rename {legacy => legacy-servers}/server-https/virtualservice.yaml (100%) rename {legacy => legacy-servers}/server/Dockerfile (100%) rename {legacy => legacy-servers}/server/app.py (100%) rename {legacy => legacy-servers}/server/deployment.yaml (100%) rename {legacy => legacy-servers}/server/requirements.txt (100%) rename {legacy => legacy-servers}/server/service.yaml (100%) diff --git a/.github/workflows/oke-cicd.yaml b/.github/workflows/oke-cicd.yaml index 0e156c9..9a9592f 100644 --- a/.github/workflows/oke-cicd.yaml +++ b/.github/workflows/oke-cicd.yaml @@ -1,236 +1,236 @@ -# Test + optional OKE CD. PRs and main always run compile/test/lint. -# Deploy job runs only when repository variable ENABLE_OKE_DEPLOY=true (set on operator forks). -name: Build, Test, and Deploy to OKE - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - inputs: - deploy_kserve: - description: Deploy/update KServe Qwen (single GPU — recycles revision) - type: boolean - default: false - -env: - REGISTRY: ghcr.io - IMAGE_NAME: mcp-kubeflow-docs - K8S_NAMESPACE: docs-agent - OKE_CLUSTER_OCID: ${{ secrets.OKE_CLUSTER_OCID }} - -jobs: - test-and-compile: - name: Compile and Test - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install Dependencies - run: | - python -m pip install --upgrade pip - pip install -r docs-agent-mcp/mcp-server/requirements.txt - pip install -r requirements-test.txt - pip install ruff==0.15.9 kfp kfp-kubernetes - - - name: Compile docs RAG pipeline - working-directory: docs-agent-mcp/pipelines - run: | - python kubeflow-pipeline.py - python issues-pipeline.py - python code-pipeline.py - - - name: Ruff lint - run: ruff check docs-agent-mcp/mcp-server docs-agent-mcp/session-issuer tests docs-agent-mcp/pipelines - - - name: Compile MCP server - run: python -m py_compile docs-agent-mcp/mcp-server/*.py docs-agent-mcp/session-issuer/*.py - - - name: Run Unit Tests - run: pytest -v --tb=short - - build-and-deploy: - name: Build, Push, and Deploy to OKE - needs: test-and-compile - # Off by default in kubeflow/docs-agent (no OKE secrets). Fork operators set repo variable ENABLE_OKE_DEPLOY=true. - if: | - (github.event_name == 'workflow_dispatch' || github.event_name == 'push') && - vars.ENABLE_OKE_DEPLOY == 'true' - runs-on: ubuntu-latest - # Secrets are stored under Settings → Environments → kubeflow (not repo secrets) - environment: kubeflow - permissions: - contents: read - packages: write - env: - # oracle-actions/configure-kubectl-oke expects OCI_CLI_* env vars - OCI_CLI_USER: ${{ secrets.OCI_USER_OCID }} - OCI_CLI_TENANCY: ${{ secrets.OCI_TENANCY_OCID }} - OCI_CLI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }} - OCI_CLI_KEY_CONTENT: ${{ secrets.OCI_KEY_FILE }} - OCI_CLI_REGION: ${{ secrets.OCI_REGION }} - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Validate deploy secrets - env: - GHCR_PULL_TOKEN: ${{ secrets.GHCR_TOKEN || secrets.GHCR_PULL_TOKEN || secrets.Github_Pat }} - OCI_USER_OCID: ${{ secrets.OCI_USER_OCID }} - OCI_TENANCY_OCID: ${{ secrets.OCI_TENANCY_OCID }} - OCI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }} - OCI_KEY_FILE: ${{ secrets.OCI_KEY_FILE }} - OCI_REGION: ${{ secrets.OCI_REGION }} - OKE_CLUSTER_OCID: ${{ secrets.OKE_CLUSTER_OCID }} - run: | - set -euo pipefail - missing=() - for name in OCI_USER_OCID OCI_TENANCY_OCID OCI_FINGERPRINT OCI_KEY_FILE OCI_REGION OKE_CLUSTER_OCID; do - if [ -z "${!name:-}" ]; then - missing+=("$name") - fi - done - if [ -z "${GHCR_PULL_TOKEN:-}" ]; then - missing+=("GHCR_TOKEN or GHCR_PULL_TOKEN or Github_Pat (needs read:packages for cluster image pull)") - fi - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::Missing required GitHub Actions secrets in environment 'kubeflow':" - printf ' - %s\n' "${missing[@]}" - echo "Add them under Settings → Environments → kubeflow → Environment secrets." - exit 1 - fi - - - name: Set image coordinates - env: - GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} - run: | - owner_lc="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" - ghcr_user="${GHCR_USERNAME:-$owner_lc}" - echo "IMAGE_REPO=ghcr.io/${owner_lc}/${{ env.IMAGE_NAME }}" >> "$GITHUB_ENV" - echo "GHCR_USERNAME=${ghcr_user}" >> "$GITHUB_ENV" - echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_ENV" - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and Push MCP Image - uses: docker/build-push-action@v5 - with: - context: ./docs-agent-mcp/mcp-server - file: ./docs-agent-mcp/mcp-server/Dockerfile - push: true - tags: | - ${{ env.IMAGE_REPO }}:${{ env.tag }} - - - name: Configure kubectl for OKE - uses: oracle-actions/configure-kubectl-oke@v1.5.0 - with: - cluster: ${{ secrets.OKE_CLUSTER_OCID }} - - - name: Deploy to OKE - env: - FULL_IMAGE: ${{ env.IMAGE_REPO }}:${{ env.tag }} - GHCR_PULL_TOKEN: ${{ secrets.GHCR_TOKEN || secrets.GHCR_PULL_TOKEN || secrets.Github_Pat }} - GHCR_USERNAME: ${{ env.GHCR_USERNAME }} - run: | - set -euo pipefail - - kubectl create namespace "${{ env.K8S_NAMESPACE }}" --dry-run=client -o yaml | kubectl apply -f - - - kubectl create secret docker-registry ghcrsecret \ - --namespace "${{ env.K8S_NAMESPACE }}" \ - --docker-server="${{ env.REGISTRY }}" \ - --docker-username="${GHCR_USERNAME}" \ - --docker-password="${GHCR_PULL_TOKEN}" \ - --docker-email="${GHCR_USERNAME}@users.noreply.github.com" \ - --dry-run=client -o yaml | kubectl apply -f - - - # Replace pinned manifest image with the commit-scoped tag built above. - sed "s|ghcr.io/kubeflow/mcp-kubeflow-docs:v0.1.0|${FULL_IMAGE}|g" \ - docs-agent-mcp/manifests/mcp-server/mcp-server.yaml | kubectl apply -f - - kubectl set image deployment/mcp-kubeflow-docs \ - mcp-server="${FULL_IMAGE}" \ - -n "${{ env.K8S_NAMESPACE }}" || true - kubectl patch deployment/mcp-kubeflow-docs -n "${{ env.K8S_NAMESPACE }}" --type=merge \ - -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcrsecret"}]}}}}' - kubectl rollout status deployment/mcp-kubeflow-docs \ - -n "${{ env.K8S_NAMESPACE }}" --timeout=600s - - kubectl apply -f docs-agent-mcp/manifests/kagent/setup.yaml - - DEPLOY_KSERVE=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.deploy_kserve }}" = "true" ]; then - DEPLOY_KSERVE=true - elif [ "${{ github.event_name }}" = "push" ]; then - if git diff --name-only --diff-filter=M "${{ github.event.before }}" "${{ github.sha }}" \ - | grep -qE '^docs-agent-mcp/manifests/vllm/kserve-qwen\.yaml$'; then - DEPLOY_KSERVE=true - fi - fi - - if [ "${DEPLOY_KSERVE}" = "true" ]; then - echo "Removing legacy docs-agent GPU InferenceService (frees GPU for ml-infra/qwen-llm)" - kubectl delete inferenceservice/qwen -n "${{ env.K8S_NAMESPACE }}" --ignore-not-found=true - kubectl delete svc/qwen-llm -n "${{ env.K8S_NAMESPACE }}" --ignore-not-found=true - echo "Applying ml-infra Qwen KServe manifests" - kubectl apply -f docs-agent-mcp/manifests/vllm/kserve-qwen.yaml - kubectl rollout status deployment -n ml-infra -l serving.kserve.io/inferenceservice=qwen-llm --timeout=600s 2>/dev/null || true - else - echo "Skipping Qwen GPU deploy (no vllm manifest change; use workflow_dispatch to force)" - fi - - - name: Wait for ml-infra dependencies - run: | - set -euo pipefail - kubectl wait --for=condition=Ready inferenceservice/embeddings-service \ - -n ml-infra --timeout=600s - # Milvus CR reports Healthy in .status.status, not a Ready condition. - for i in $(seq 1 60); do - status="$(kubectl get milvus milvus -n ml-infra -o jsonpath='{.status.status}' 2>/dev/null || true)" - if [ "${status}" = "Healthy" ]; then - echo "milvus status=${status}" - break - fi - echo "waiting for milvus (status=${status:-unknown}) attempt ${i}/60" - sleep 10 - done - kubectl get milvus -n ml-infra - test "$(kubectl get milvus milvus -n ml-infra -o jsonpath='{.status.status}')" = "Healthy" - - - name: Smoke test MCP tools and embeddings (ml-infra) - run: | - set -euo pipefail - kubectl rollout status deployment/mcp-kubeflow-docs \ - -n "${{ env.K8S_NAMESPACE }}" --timeout=120s - kubectl exec -n "${{ env.K8S_NAMESPACE }}" deploy/mcp-kubeflow-docs -- \ - python3 /app/smoke_tools.py - kubectl get agent,modelconfig -n "${{ env.K8S_NAMESPACE }}" - - - name: Smoke test Qwen (non-blocking) - continue-on-error: true - run: | - set -euo pipefail - POD="$(kubectl get pods -n ml-infra -l serving.kserve.io/inferenceservice=qwen-llm \ - --field-selector=status.phase=Running \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" - if [ -z "${POD}" ]; then - echo "No running qwen-llm pod in ml-infra — skipping LLM smoke test" - exit 0 - fi - kubectl exec -n ml-infra "${POD}" -c kserve-container -- python3 -c " - import urllib.request, json - p=json.dumps({'model':'qwen2.5-7B','messages':[{'role':'user','content':'ping'}],'max_tokens':8}).encode() - r=urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8080/openai/v1/chat/completions',data=p,headers={'Content-Type':'application/json'}), timeout=60) - print('kserve ok', r.status) - " +# Test + optional OKE CD. PRs and main always run compile/test/lint. +# Deploy job runs only when repository variable ENABLE_OKE_DEPLOY=true (set on operator forks). +name: Build, Test, and Deploy to OKE + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + inputs: + deploy_kserve: + description: Deploy/update KServe Qwen (single GPU — recycles revision) + type: boolean + default: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: mcp-kubeflow-docs + K8S_NAMESPACE: docs-agent + OKE_CLUSTER_OCID: ${{ secrets.OKE_CLUSTER_OCID }} + +jobs: + test-and-compile: + name: Compile and Test + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r kagent-feast-mcp/mcp-server/requirements.txt + pip install -r requirements-test.txt + pip install ruff==0.15.9 kfp kfp-kubernetes + + - name: Compile docs RAG pipeline + working-directory: kagent-feast-mcp/pipelines + run: | + python kubeflow-pipeline.py + python issues-pipeline.py + python code-pipeline.py + + - name: Ruff lint + run: ruff check kagent-feast-mcp/mcp-server kagent-feast-mcp/session-issuer tests kagent-feast-mcp/pipelines + + - name: Compile MCP server + run: python -m py_compile kagent-feast-mcp/mcp-server/*.py kagent-feast-mcp/session-issuer/*.py + + - name: Run Unit Tests + run: pytest -v --tb=short + + build-and-deploy: + name: Build, Push, and Deploy to OKE + needs: test-and-compile + # Off by default in kubeflow/docs-agent (no OKE secrets). Fork operators set repo variable ENABLE_OKE_DEPLOY=true. + if: | + (github.event_name == 'workflow_dispatch' || github.event_name == 'push') && + vars.ENABLE_OKE_DEPLOY == 'true' + runs-on: ubuntu-latest + # Secrets are stored under Settings → Environments → kubeflow (not repo secrets) + environment: kubeflow + permissions: + contents: read + packages: write + env: + # oracle-actions/configure-kubectl-oke expects OCI_CLI_* env vars + OCI_CLI_USER: ${{ secrets.OCI_USER_OCID }} + OCI_CLI_TENANCY: ${{ secrets.OCI_TENANCY_OCID }} + OCI_CLI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }} + OCI_CLI_KEY_CONTENT: ${{ secrets.OCI_KEY_FILE }} + OCI_CLI_REGION: ${{ secrets.OCI_REGION }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Validate deploy secrets + env: + GHCR_PULL_TOKEN: ${{ secrets.GHCR_TOKEN || secrets.GHCR_PULL_TOKEN || secrets.Github_Pat }} + OCI_USER_OCID: ${{ secrets.OCI_USER_OCID }} + OCI_TENANCY_OCID: ${{ secrets.OCI_TENANCY_OCID }} + OCI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }} + OCI_KEY_FILE: ${{ secrets.OCI_KEY_FILE }} + OCI_REGION: ${{ secrets.OCI_REGION }} + OKE_CLUSTER_OCID: ${{ secrets.OKE_CLUSTER_OCID }} + run: | + set -euo pipefail + missing=() + for name in OCI_USER_OCID OCI_TENANCY_OCID OCI_FINGERPRINT OCI_KEY_FILE OCI_REGION OKE_CLUSTER_OCID; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ -z "${GHCR_PULL_TOKEN:-}" ]; then + missing+=("GHCR_TOKEN or GHCR_PULL_TOKEN or Github_Pat (needs read:packages for cluster image pull)") + fi + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::Missing required GitHub Actions secrets in environment 'kubeflow':" + printf ' - %s\n' "${missing[@]}" + echo "Add them under Settings → Environments → kubeflow → Environment secrets." + exit 1 + fi + + - name: Set image coordinates + env: + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + run: | + owner_lc="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')" + ghcr_user="${GHCR_USERNAME:-$owner_lc}" + echo "IMAGE_REPO=ghcr.io/${owner_lc}/${{ env.IMAGE_NAME }}" >> "$GITHUB_ENV" + echo "GHCR_USERNAME=${ghcr_user}" >> "$GITHUB_ENV" + echo "tag=${GITHUB_SHA::7}" >> "$GITHUB_ENV" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and Push MCP Image + uses: docker/build-push-action@v5 + with: + context: ./kagent-feast-mcp/mcp-server + file: ./kagent-feast-mcp/mcp-server/Dockerfile + push: true + tags: | + ${{ env.IMAGE_REPO }}:${{ env.tag }} + + - name: Configure kubectl for OKE + uses: oracle-actions/configure-kubectl-oke@v1.5.0 + with: + cluster: ${{ secrets.OKE_CLUSTER_OCID }} + + - name: Deploy to OKE + env: + FULL_IMAGE: ${{ env.IMAGE_REPO }}:${{ env.tag }} + GHCR_PULL_TOKEN: ${{ secrets.GHCR_TOKEN || secrets.GHCR_PULL_TOKEN || secrets.Github_Pat }} + GHCR_USERNAME: ${{ env.GHCR_USERNAME }} + run: | + set -euo pipefail + + kubectl create namespace "${{ env.K8S_NAMESPACE }}" --dry-run=client -o yaml | kubectl apply -f - + + kubectl create secret docker-registry ghcrsecret \ + --namespace "${{ env.K8S_NAMESPACE }}" \ + --docker-server="${{ env.REGISTRY }}" \ + --docker-username="${GHCR_USERNAME}" \ + --docker-password="${GHCR_PULL_TOKEN}" \ + --docker-email="${GHCR_USERNAME}@users.noreply.github.com" \ + --dry-run=client -o yaml | kubectl apply -f - + + # Replace pinned manifest image with the commit-scoped tag built above. + sed "s|ghcr.io/kubeflow/mcp-kubeflow-docs:v0.1.0|${FULL_IMAGE}|g" \ + kagent-feast-mcp/manifests/mcp-server/mcp-server.yaml | kubectl apply -f - + kubectl set image deployment/mcp-kubeflow-docs \ + mcp-server="${FULL_IMAGE}" \ + -n "${{ env.K8S_NAMESPACE }}" || true + kubectl patch deployment/mcp-kubeflow-docs -n "${{ env.K8S_NAMESPACE }}" --type=merge \ + -p '{"spec":{"template":{"spec":{"imagePullSecrets":[{"name":"ghcrsecret"}]}}}}' + kubectl rollout status deployment/mcp-kubeflow-docs \ + -n "${{ env.K8S_NAMESPACE }}" --timeout=600s + + kubectl apply -f kagent-feast-mcp/manifests/kagent/setup.yaml + + DEPLOY_KSERVE=false + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.deploy_kserve }}" = "true" ]; then + DEPLOY_KSERVE=true + elif [ "${{ github.event_name }}" = "push" ]; then + if git diff --name-only --diff-filter=M "${{ github.event.before }}" "${{ github.sha }}" \ + | grep -qE '^kagent-feast-mcp/manifests/vllm/kserve-qwen\.yaml$'; then + DEPLOY_KSERVE=true + fi + fi + + if [ "${DEPLOY_KSERVE}" = "true" ]; then + echo "Removing legacy docs-agent GPU InferenceService (frees GPU for ml-infra/qwen-llm)" + kubectl delete inferenceservice/qwen -n "${{ env.K8S_NAMESPACE }}" --ignore-not-found=true + kubectl delete svc/qwen-llm -n "${{ env.K8S_NAMESPACE }}" --ignore-not-found=true + echo "Applying ml-infra Qwen KServe manifests" + kubectl apply -f kagent-feast-mcp/manifests/vllm/kserve-qwen.yaml + kubectl rollout status deployment -n ml-infra -l serving.kserve.io/inferenceservice=qwen-llm --timeout=600s 2>/dev/null || true + else + echo "Skipping Qwen GPU deploy (no vllm manifest change; use workflow_dispatch to force)" + fi + + - name: Wait for ml-infra dependencies + run: | + set -euo pipefail + kubectl wait --for=condition=Ready inferenceservice/embeddings-service \ + -n ml-infra --timeout=600s + # Milvus CR reports Healthy in .status.status, not a Ready condition. + for i in $(seq 1 60); do + status="$(kubectl get milvus milvus -n ml-infra -o jsonpath='{.status.status}' 2>/dev/null || true)" + if [ "${status}" = "Healthy" ]; then + echo "milvus status=${status}" + break + fi + echo "waiting for milvus (status=${status:-unknown}) attempt ${i}/60" + sleep 10 + done + kubectl get milvus -n ml-infra + test "$(kubectl get milvus milvus -n ml-infra -o jsonpath='{.status.status}')" = "Healthy" + + - name: Smoke test MCP tools and embeddings (ml-infra) + run: | + set -euo pipefail + kubectl rollout status deployment/mcp-kubeflow-docs \ + -n "${{ env.K8S_NAMESPACE }}" --timeout=120s + kubectl exec -n "${{ env.K8S_NAMESPACE }}" deploy/mcp-kubeflow-docs -- \ + python3 /app/smoke_tools.py + kubectl get agent,modelconfig -n "${{ env.K8S_NAMESPACE }}" + + - name: Smoke test Qwen (non-blocking) + continue-on-error: true + run: | + set -euo pipefail + POD="$(kubectl get pods -n ml-infra -l serving.kserve.io/inferenceservice=qwen-llm \ + --field-selector=status.phase=Running \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" + if [ -z "${POD}" ]; then + echo "No running qwen-llm pod in ml-infra — skipping LLM smoke test" + exit 0 + fi + kubectl exec -n ml-infra "${POD}" -c kserve-container -- python3 -c " + import urllib.request, json + p=json.dumps({'model':'qwen2.5-7B','messages':[{'role':'user','content':'ping'}],'max_tokens':8}).encode() + r=urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:8080/openai/v1/chat/completions',data=p,headers={'Content-Type':'application/json'}), timeout=60) + print('kserve ok', r.status) + " diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 66a8835..24fb956 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,74 +1,74 @@ -name: PR Safety - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint: - name: Lint and format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - cache: pip - - - name: Install lint tools - run: | - python -m pip install --upgrade pip - python -m pip install ruff==0.15.9 - - - name: Run ruff lint - run: ruff check . - - - name: Check formatting - run: ruff format --check docs-agent-mcp/mcp-server tests - - compile: - name: Python compile check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Compile Python sources - run: | - python -m compileall \ - docs-agent-mcp/pipelines \ - docs-agent-mcp/mcp-server \ - legacy/server \ - legacy/server-https \ - scripts \ - tests - - test: - name: Pytest - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - cache: 'pip' - cache-dependency-path: requirements-test.txt - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-test.txt - - - name: Run tests - run: pytest -v --tb=short +name: PR Safety + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint and format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install lint tools + run: | + python -m pip install --upgrade pip + python -m pip install ruff==0.15.9 + + - name: Run ruff lint + run: ruff check . + + - name: Check formatting + run: ruff format --check kagent-feast-mcp/mcp-server tests + + compile: + name: Python compile check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Compile Python sources + run: | + python -m compileall \ + kagent-feast-mcp/pipelines \ + kagent-feast-mcp/mcp-server \ + legacy/server \ + legacy/server-https \ + scripts \ + tests + + test: + name: Pytest + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: 'pip' + cache-dependency-path: requirements-test.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Run tests + run: pytest -v --tb=short diff --git a/.gitignore b/.gitignore index 6496072..3e83a79 100644 --- a/.gitignore +++ b/.gitignore @@ -1,227 +1,227 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ -my_env/ -temp/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Claude Code -.claude/ - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# KFP -test/ - -# Feast artifacts -feast_repo/data/ -feast_repo/registry.db - -# Compiled pipeline YAML -docs-agent-mcp/pipelines/*.yaml -!docs-agent-mcp/pipelines/README.md - -# Local cluster / kubectl notes (optional; keep untracked) -Arch.md -kube.md*.swp +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +my_env/ +temp/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Claude Code +.claude/ + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# KFP +test/ + +# Feast artifacts +feast_repo/data/ +feast_repo/registry.db + +# Compiled pipeline YAML +kagent-feast-mcp/pipelines/*.yaml +!kagent-feast-mcp/pipelines/README.md + +# Local cluster / kubectl notes (optional; keep untracked) +Arch.md +kube.md*.swp diff --git a/README.md b/README.md index 276dfc4..adac1ac 100644 --- a/README.md +++ b/README.md @@ -1,756 +1,756 @@ -# Kubeflow Documentation AI Assistant - -**Author**: Santhosh Toorpu - -[![KEP-867](https://img.shields.io/badge/KEP-867-Documentation%20AI%20Assistant-blue)](https://github.com/kubeflow/community/issues/867) - -The official LLM implementation of the Kubeflow Documentation Assistant powered by Retrieval-Augmented Generation (RAG). This repository provides a comprehensive solution for Kubeflow users to search across documentation and get accurate, contextual answers to their queries. - -## Table of Contents - -- [Overview](#overview) -- [Architecture](#architecture) -- [Prerequisites](#prerequisites) -- [Installation](#installation) - - [Milvus Vector Database](#milvus-vector-database) - - [KServe Inference Service](#kserve-inference-service) - - [Kubeflow Pipelines](#kubeflow-pipelines) - - [API Server](#api-server) -- [Usage](#usage) -- [Configuration](#configuration) -- [Troubleshooting](#troubleshooting) -- [Contributing](#contributing) - -## Repository layout - -| Path | Purpose | -|------|---------| -| `docs-agent-mcp/` | MCP server, Kagent manifests, RAG pipelines, and Terraform platform stack | -| `legacy/` | Historical FastAPI servers, older manifests, and Feast-era pipeline copies | -| `frontend/` | Docs site chatbot assets (`docs_scripts/`, `docs_styles/`) | -| `.github/workflows/` | CI/CD (`oke-cicd.yaml` builds MCP, runs tests, deploys to OKE) | - -## Overview - -### Why This Project Exists - -Kubeflow users often struggle to find relevant information across the extensive documentation scattered across different services, components, and repositories. The traditional search approach lacks context and often returns irrelevant results. This documentation assistant addresses these challenges by: - -- **Semantic Search**: Understanding the intent behind queries rather than just keyword matching -- **Contextual Responses**: Providing answers based on the most relevant documentation chunks -- **Real-time Processing**: Enabling instant responses through streaming APIs -- **Scalable Architecture**: Leveraging Kubernetes for automatic scaling and resource management - -### Key Features - -- 🔍 **Intelligent Search**: Semantic search across Kubeflow documentation -- 🤖 **AI-Powered Responses**: Contextual answers using Llama 3.1-8B model -- ⚡ **Real-time Streaming**: WebSocket and HTTP streaming support -- 🔧 **Tool Calling**: Automatic documentation lookup when needed -- 📊 **Vector Database**: Milvus for efficient similarity search -- 🚀 **Kubernetes Native**: Built for cloud-native environments -- 🔄 **Automated ETL**: Kubeflow Pipelines for data processing - -## Architecture - -### High-Level Architecture - -![High-Level Architecture](assets/indexing.svg) - -### Data Flow - -![Data Flow](assets/querying.svg) - -## Prerequisites - -- Kubernetes cluster (1.20+) -- Helm 3.x -- Kubeflow Pipelines -- GPU nodes (for LLM inference) -- SSL certificate (for HTTPS API) - -## Installation - -### Milvus Vector Database - -#### What is Milvus? - -Milvus is an open-source vector database designed for AI applications. It provides: - -- **High Performance**: Optimized for vector similarity search -- **Scalability**: Horizontal scaling capabilities -- **Multiple Index Types**: Support for various vector indexing algorithms -- **Cloud Native**: Built for Kubernetes environments -- **Multiple APIs**: REST, gRPC, and Python SDK support - -#### Installation Steps - -1. **Add Helm Repository**: - ```bash - helm repo add milvus https://milvus-io.github.io/milvus-helm/ - helm repo update - ``` - -2. **Install Milvus**: - ```bash - helm upgrade --install my-release zilliztech/milvus -n docs-agent \ - --set cluster.enabled=false \ - --set standalone.enabled=true \ - --set etcd.replicaCount=1 \ - --set etcd.persistence.enabled=false \ - --set minio.mode=standalone \ - --set minio.replicas=1 \ - --set pulsar.enabled=false \ - --set pulsarv3.enabled=false \ - --set standalone.podAnnotations."sidecar\.istio\.io/inject"="false" - ``` - -#### Configuration Rationale - -- **Standalone Mode**: Single-node deployment for development/testing -- **Single etcd Replica**: Reduced resource usage with `etcd.persistence.enabled=false` -- **Standalone MinIO**: Single MinIO instance for object storage -- **Disabled Pulsar**: Not needed for standalone deployment -- **Istio Sidecar Injection**: Disabled to avoid networking issues - -3. **Test Connection**: - ```python - from pymilvus import connections - connections.connect("default", host="my-release-milvus.docs-agent.svc.cluster.local", port="19530") - print("Connected to Milvus successfully!") - ``` - -4. **External Access** (if needed for different clusters): - ```bash - kubectl expose service my-release-milvus \ - --name milvus-external \ - --type=NodePort \ - --port=19530 - ``` - -### KServe Inference Service - -The LLM inference is handled by KServe with vLLM backend for high-performance serving. - -#### Serving Runtime Configuration - -```yaml -# manifests/serving-runtime.yaml -apiVersion: serving.kserve.io/v1alpha1 -kind: ServingRuntime -metadata: - name: llm-runtime - namespace: docs-agent -spec: - supportedModelFormats: - - name: huggingface - version: "1" - autoSelect: true - containers: - - name: kserve-container - image: kserve/huggingfaceserver:latest-gpu - command: ["python", "-m", "huggingfaceserver"] - resources: - requests: - cpu: "4" - memory: "16Gi" - nvidia.com/gpu: "1" - limits: - cpu: "6" - memory: "24Gi" - nvidia.com/gpu: "1" -``` - -#### Inference Service Configuration - -```yaml -# manifests/inference-service.yaml -apiVersion: serving.kserve.io/v1beta1 -kind: InferenceService -metadata: - name: llama - namespace: docs-agent -spec: - predictor: - model: - modelFormat: - name: huggingface - version: "1" - runtime: llm-runtime - args: - - --model_name=llama3.1-8B - - --model_id=RedHatAI/Llama-3.1-8B-Instruct - - --backend=vllm - - --max-model-len=32768 - - --gpu-memory-utilization=0.90 - - --enable-auto-tool-choice - - --tool-call-parser=llama3_json - - --enable-tool-call-parser - env: - - name: HF_TOKEN - valueFrom: - secretKeyRef: - name: huggingface-secret - key: token - - name: CUDA_VISIBLE_DEVICES - value: "0" - resources: - requests: - cpu: "4" - memory: "16Gi" - nvidia.com/gpu: "1" - limits: - cpu: "6" - memory: "24Gi" - nvidia.com/gpu: "1" -``` - -#### Key Configuration Points - -- **Tool Calling**: Enabled with `--enable-auto-tool-choice` and `--enable-tool-call-parser` -- **Custom Template**: vLLM supports custom templates for different model formats -- **Resource Allocation**: GPU memory utilization set to 90% for optimal performance -- **HuggingFace Token**: Required for accessing the model - -**Connection Details**: -```python -KSERVE_URL = os.getenv("KSERVE_URL", "http://llama.docs-agent.svc.cluster.local/openai/v1/chat/completions") -MODEL = os.getenv("MODEL", "llama3.1-8B") -``` - -For more details, refer to [KServe documentation](https://kserve.github.io/website/) and [vLLM documentation](https://docs.vllm.ai/). - -### Kubeflow Pipelines - -The ETL (Extract, Transform, Load) process is implemented as a Kubeflow Pipeline for automated, scalable data processing. - -#### Why Kubeflow Pipelines? - -- **Infrastructure Management**: Kubernetes handles all infrastructure automatically -- **Scalability**: Auto-scaling based on workload demands -- **Reproducibility**: Version-controlled pipeline definitions -- **Integration**: Seamless integration with other Kubeflow components -- **CI/CD Ready**: Can be triggered via GitHub Actions or other automation tools - -#### Pipeline Components - -The pipeline consists of three main phases: - -##### 1. Repository Fetching - -```python -@dsl.component( - base_image="python:3.9", - packages_to_install=["requests", "beautifulsoup4"] -) -def download_github_directory( - repo_owner: str, - repo_name: str, - directory_path: str, - github_token: str, - github_data: dsl.Output[dsl.Dataset] -): - # Fetches documentation files from GitHub repositories - # Supports .md and .html files - # Handles authentication and recursive directory traversal -``` - -##### 2. Text Chunking and Embedding - -```python -@dsl.component( - base_image="pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime", - packages_to_install=["sentence-transformers", "langchain"] -) -def chunk_and_embed( - github_data: dsl.Input[dsl.Dataset], - repo_name: str, - base_url: str, - chunk_size: int, - chunk_overlap: int, - embedded_data: dsl.Output[dsl.Dataset] -): - # Processes text with aggressive cleaning - # Creates embeddings using sentence-transformers - # Handles chunking with configurable overlap -``` - -##### 3. Vector Database Storage - -```python -@dsl.component( - base_image="python:3.9", - packages_to_install=["pymilvus", "numpy"] -) -def store_milvus( - embedded_data: dsl.Input[dsl.Dataset], - milvus_host: str, - milvus_port: str, - collection_name: str -): - # Creates Milvus collection with proper schema - # Inserts vectors in batches for efficiency - # Creates indexes for optimal search performance -``` - -#### RBAC Configuration - -For Kubeflow Pipelines to access Milvus, proper RBAC permissions are required: - -```bash -# Create role for Milvus access -kubectl create role milvus-access \ - --namespace docs-agent \ - --verb=get,list,watch \ - --resource=services,endpoints - -# Bind role to KFP service account -kubectl create rolebinding kfp-to-milvus-editor \ - --namespace docs-agent \ - --role=milvus-access \ - --serviceaccount=kubeflow:default-editor -``` - -**Note**: Without these permissions, you'll encounter RBAC errors during the embedding phase. - -#### Future Improvements - -A better improvement would be using the embedding model as a service where users could call the service instead of installing heavy sentence transformers package every time. This would: - -- Reduce pipeline execution time -- Lower resource requirements -- Enable better caching and optimization -- Improve scalability - -### API Server - -Two API implementations are provided for different use cases: - -#### WebSocket API (`server/app.py`) - -**Use Case**: Real-time chat applications, interactive interfaces - -**Features**: -- Bidirectional communication -- Real-time streaming responses -- Tool call execution with live updates -- Connection management and error handling - -**Key Components**: -```python -async def handle_websocket(websocket, path): - """Handle WebSocket connections with tool calling support""" - # Manages connection lifecycle - # Handles message routing and tool execution - # Provides real-time streaming responses - -async def stream_llm_response(payload, websocket, citations_collector): - """Stream LLM responses with tool call handling""" - # Processes streaming responses from KServe - # Manages tool call accumulation and execution - # Handles follow-up requests after tool execution -``` - -#### HTTPS API (`server-https/app.py`) - -**Use Case**: RESTful integrations, server-to-server communication, web applications - -**Key Features**: -- **Dual Response Modes**: Both streaming (Server-Sent Events) and non-streaming JSON responses -- **RAG Integration**: Automatic tool calling for Kubeflow documentation search -- **CORS Support**: Full cross-origin resource sharing for web applications -- **FastAPI Framework**: Automatic OpenAPI documentation and type validation -- **Production Ready**: Health checks, error handling, and Kubernetes integration -- **Citation Management**: Automatic collection and deduplication of source citations - -**API Endpoints**: - -**Main Chat Endpoint**: -```python -@app.post("/chat") -async def chat(request: ChatRequest): - """Main chat endpoint with RAG capabilities""" - # Supports both streaming and non-streaming responses - # Handles tool calling and citation collection - # Returns structured JSON responses -``` - -**Health Check Endpoint**: -```python -@app.get("/health") -async def health_check(): - """Health check for Kubernetes probes""" - # Essential for production deployments - # Used by readiness and liveness probes -``` - -**Request/Response Models**: -```python -class ChatRequest(BaseModel): - message: str - stream: Optional[bool] = True # Default to streaming - -# Streaming Response (SSE) -data: {"type": "content", "content": "response text"} -data: {"type": "tool_result", "tool_name": "search_kubeflow_docs", "content": "search results"} -data: {"type": "citations", "citations": ["url1", "url2"]} -data: {"type": "done"} - -# Non-streaming Response -{ - "response": "Complete response text", - "citations": ["url1", "url2"] # or null if no citations -} -``` - -**Advanced Features**: - -- **Intelligent Tool Calling**: Automatically determines when to search documentation based on query context -- **Streaming Tool Execution**: Real-time tool call execution with live updates -- **Citation Tracking**: Automatic collection and deduplication of source URLs -- **Error Handling**: Comprehensive error handling with detailed error messages -- **CORS Configuration**: Full CORS support for web application integration -- **Resource Management**: Proper connection pooling and cleanup for Milvus and KServe - -#### SSL Certificate Requirements - -**Critical**: Both APIs require SSL certificates from a trusted Certificate Authority. Without proper SSL certificates, browsers will block WebSocket connections and HTTPS requests. - -## Usage - -### Starting the Services - -1. **Deploy Milvus and KServe** (as described above) - -2. **Run the Pipeline**: - ```bash - python docs-agent-mcp/pipelines/kubeflow-pipeline.py - ``` - -3. **Start the API Server**: - ```bash - # WebSocket API - python server/app.py - - # HTTPS API - python server-https/app.py - ``` - -### API Usage Examples - -#### WebSocket API - -```javascript -const ws = new WebSocket('wss://your-domain.com:8000'); - -ws.onmessage = function(event) { - const data = JSON.parse(event.data); - switch(data.type) { - case 'content': - // Handle streaming content - break; - case 'citations': - // Handle citations - break; - case 'done': - // Handle completion - break; - } -}; - -ws.send(JSON.stringify({ - message: "How do I create a Kubeflow pipeline?" -})); -``` - -#### HTTPS API - -**Streaming Request (Server-Sent Events)**: -```bash -curl -X POST "https://your-domain.com/chat" \ - -H "Content-Type: application/json" \ - -H "Accept: text/event-stream" \ - -d '{"message": "What is KServe?", "stream": true}' -``` - -**Non-streaming Request (JSON Response)**: -```bash -curl -X POST "https://your-domain.com/chat" \ - -H "Content-Type: application/json" \ - -d '{"message": "What is KServe?", "stream": false}' -``` - -**JavaScript Integration Example**: -```javascript -// Streaming request -const response = await fetch('https://your-domain.com/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream' - }, - body: JSON.stringify({ - message: 'How do I create a Kubeflow pipeline?', - stream: true - }) -}); - -const reader = response.body.getReader(); -const decoder = new TextDecoder(); - -while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = JSON.parse(line.slice(6)); - switch(data.type) { - case 'content': - console.log('Content:', data.content); - break; - case 'tool_result': - console.log('Tool:', data.tool_name, data.content); - break; - case 'citations': - console.log('Citations:', data.citations); - break; - case 'done': - console.log('Response complete'); - break; - } - } - } -} -``` - -**Python Integration Example**: -```python -import requests -import json - -# Non-streaming request -response = requests.post( - 'https://your-domain.com/chat', - json={ - 'message': 'What is KServe?', - 'stream': False - } -) - -data = response.json() -print(f"Response: {data['response']}") -if data.get('citations'): - print(f"Sources: {data['citations']}") -``` - -## Configuration - -### Environment Variables - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
VariableDefaultDescription
KSERVE_URLhttp://llama.docs-agent.svc.cluster.local/openai/v1/chat/completionsKServe endpoint URL
MODELllama3.1-8BModel name
PORT8000API server port
MILVUS_HOSTmy-release-milvus.docs-agent.svc.cluster.localMilvus host
MILVUS_PORT19530Milvus port
MILVUS_COLLECTIONkubeflow_docsMilvus collection name
EMBEDDING_MODELsentence-transformers/all-mpnet-base-v2Embedding model
- -### Pipeline Parameters - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ParameterDefaultDescription
repo_ownerkubeflowGitHub repository owner
repo_namewebsiteGitHub repository name
directory_pathcontent/enDocumentation directory path
chunk_size1000Text chunk size for embedding
chunk_overlap100Overlap between chunks
base_urlhttps://www.kubeflow.org/docsBase URL for citations
milvus_hostmilvus-standalone-final.docs-agent.svc.cluster.localMilvus host (used by kubeflow-pipeline.py and incremental-pipeline.py)
- -## Chat History and Persistence - -Currently, the system uses browser local storage for chat history management to: - -- **Reduce Server Overhead**: No server-side storage requirements -- **Improve Performance**: Client-side handling of chat state -- **Ensure Privacy**: Data stays on the user's device - -### Future Enhancements - -- **Chat History Summarization**: Implement conversation summarization to prevent token overflow -- **Persistent Storage**: Optional server-side chat history storage -- **Multi-session Support**: Support for multiple concurrent chat sessions - -## Troubleshooting - -### Common Issues - -1. **RBAC Errors**: Ensure proper service account permissions are set -2. **SSL Certificate Issues**: Verify certificate validity and browser trust -3. **GPU Resource Constraints**: Check GPU availability and memory allocation -4. **Milvus Connection**: Verify network connectivity and service discovery - -### Debug Commands - -```bash -# Check Milvus status -kubectl get pods -n docs-agent | grep milvus - -# Check KServe status -kubectl get inferenceservice -n docs-agent - -# Check API server logs -kubectl logs -f deployment/docs-assistant-api - -# Test Milvus connection -python -c "from pymilvus import connections; connections.connect('default', host='your-milvus-host', port='19530'); print('Connected!')" -``` - -## Contributing - -We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.md) for details. - -### Development Setup - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests if applicable -5. Submit a pull request - -## License - -This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. - -## Acknowledgments - -### Mentors -- [Francisco Javier Arceo](https://www.linkedin.com/in/franciscojavierarceo/) - Project mentor and guidance -- [Chase Christensen](https://www.linkedin.com/in/chase-c-695463162/) - Project mentor and technical support - -### Organizations -- [Google Summer of Code (GSoC)](https://summerofcode.withgoogle.com/) for providing this incredible opportunity -- [Red Hat AI](https://www.redhat.com/en/topics/ai) for providing the Llama 3.1-8B model -- [Hugging Face](https://huggingface.co/) for the model hosting and sentence transformers library -- [Oracle Cloud Infrastructure (OCI)](https://www.oracle.com/cloud/) for providing cloud resources and infrastructure - -### Open Source Community -- [Kubeflow Community](https://github.com/kubeflow/community) for the KEP-867 proposal -- [Milvus](https://milvus.io/) for the vector database -- [KServe](https://kserve.github.io/website/) for model serving -- [vLLM](https://github.com/vllm-project/vllm) for high-performance LLM inference - ---- - -## Modern Infrastructure & CI/CD (Kagent & MCP) - -The project has evolved to utilize the Model Context Protocol (MCP) and **kagent** to route queries intelligently. The infrastructure is heavily automated using Terraform and GitHub Actions. - -### Terraform (`docs-agent-mcp/terraform/`) -We use Terraform for declarative, reproducible cluster infrastructure on OKE. -* **`variables.tf`**: Single source of truth for component versions (Knative, Istio, KServe, etc.) and namespace names. -* **`namespaces.tf`**: Manages the `ml-infra` and `docs-agent` namespaces. -* **`knative.tf`**: Installs `cert-manager`, Knative Serving (Core & CRDs), Istio base/istiod, and KServe, applying crucial ConfigMap patches for scheduling. -* **`kubeflow_pipelines.tf`**: Deploys Kubeflow Pipelines (Standalone) into the `kubeflow` namespace without Istio sidecars (to prevent routing conflicts), persisting `fsGroup` patches for SeaweedFS. -* **`milvus.tf`**: Uses the Milvus Operator to deploy a lightweight Milvus Standalone instance strictly scheduled on CPU nodes, reserving GPU nodes purely for LLM inference. -* **`kagent.tf`**: Installs official `kagent-crds` and the `kagent` controller via OCI Helm charts, with bundled agents cleanly disabled. -* **`gateway_guardrails.tf`**: Installs the local `charts/gateway-guardrails` Helm chart — the single source of truth for the public edge: Istio Gateway + TLS, CORS-locked VirtualService, rate limits, and the zero-trust AuthorizationPolicies that explicitly allow internal cluster traffic where needed (e.g., KFP Pipeline to Milvus, Kagent to Milvus). Replaces the former inline `istio_policies.tf` / `kagent_ingress.tf`. - -### Gateway Guardrails Helm Chart (`docs-agent-mcp/charts/gateway-guardrails/`) -Guardrails protecting the publicly shared chatbot, all tunable via `values.yaml`: -* **Rate limiting**: 60 req/min global token bucket on the chatbot HTTPS listener (EnvoyFilter), plus an optional per-client-IP layer (Envoy ratelimit service + Redis, off until the ingress preserves client IPs). -* **CORS lockdown**: only the published widget origin may embed the chatbot; a 30s route timeout caps hung LLM calls. -* **Mesh policies**: the RAG-stack AuthorizationPolicies (Milvus, LLM, MCP, embeddings), previously raw YAML heredocs inside Terraform. - -### Pipeline Optimizations (`docs-agent-mcp/pipelines/`) -The ingestion pipeline was rewritten to maximize efficiency and avoid Kubernetes ephemeral storage eviction: -* **Feast Removal**: The pipeline now writes embeddings directly to Milvus using `pymilvus`, dramatically lowering complexity. -* **Custom Base Image (`Dockerfile.pipeline`)**: We bake the massive PyTorch library and the Hugging Face `all-mpnet-base-v2` model directly into a custom Docker image. This reduces runtime disk usage from 5.5GB to zero, fixing OKE pod eviction errors, and preventing Hugging Face API rate limits. - -### GitHub Actions CI/CD (`.github/workflows/`) -| Workflow | When it runs | Purpose | -|----------|----------------|---------| -| **`oke-cicd.yaml`** | Every PR and push to `main` | Compile pipelines, ruff, pytest; build/push MCP image to GHCR and optional OKE deploy when repo var `ENABLE_OKE_DEPLOY=true` | -| **`tests.yml`** | Every PR and push to `main` | Ruff lint/format + pytest (no cluster) | - +# Kubeflow Documentation AI Assistant + +**Author**: Santhosh Toorpu + +[![KEP-867](https://img.shields.io/badge/KEP-867-Documentation%20AI%20Assistant-blue)](https://github.com/kubeflow/community/issues/867) + +The official LLM implementation of the Kubeflow Documentation Assistant powered by Retrieval-Augmented Generation (RAG). This repository provides a comprehensive solution for Kubeflow users to search across documentation and get accurate, contextual answers to their queries. + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [Milvus Vector Database](#milvus-vector-database) + - [KServe Inference Service](#kserve-inference-service) + - [Kubeflow Pipelines](#kubeflow-pipelines) + - [API Server](#api-server) +- [Usage](#usage) +- [Configuration](#configuration) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) + +## Repository layout + +| Path | Purpose | +|------|---------| +| `kagent-feast-mcp/` | MCP server, Kagent manifests, RAG pipelines, and Terraform platform stack | +| `legacy/` | Historical FastAPI servers, older manifests, and Feast-era pipeline copies | +| `frontend/` | Docs site chatbot assets (`docs_scripts/`, `docs_styles/`) | +| `.github/workflows/` | CI/CD (`oke-cicd.yaml` builds MCP, runs tests, deploys to OKE) | + +## Overview + +### Why This Project Exists + +Kubeflow users often struggle to find relevant information across the extensive documentation scattered across different services, components, and repositories. The traditional search approach lacks context and often returns irrelevant results. This documentation assistant addresses these challenges by: + +- **Semantic Search**: Understanding the intent behind queries rather than just keyword matching +- **Contextual Responses**: Providing answers based on the most relevant documentation chunks +- **Real-time Processing**: Enabling instant responses through streaming APIs +- **Scalable Architecture**: Leveraging Kubernetes for automatic scaling and resource management + +### Key Features + +- 🔍 **Intelligent Search**: Semantic search across Kubeflow documentation +- 🤖 **AI-Powered Responses**: Contextual answers using Llama 3.1-8B model +- ⚡ **Real-time Streaming**: WebSocket and HTTP streaming support +- 🔧 **Tool Calling**: Automatic documentation lookup when needed +- 📊 **Vector Database**: Milvus for efficient similarity search +- 🚀 **Kubernetes Native**: Built for cloud-native environments +- 🔄 **Automated ETL**: Kubeflow Pipelines for data processing + +## Architecture + +### High-Level Architecture + +![High-Level Architecture](assets/indexing.svg) + +### Data Flow + +![Data Flow](assets/querying.svg) + +## Prerequisites + +- Kubernetes cluster (1.20+) +- Helm 3.x +- Kubeflow Pipelines +- GPU nodes (for LLM inference) +- SSL certificate (for HTTPS API) + +## Installation + +### Milvus Vector Database + +#### What is Milvus? + +Milvus is an open-source vector database designed for AI applications. It provides: + +- **High Performance**: Optimized for vector similarity search +- **Scalability**: Horizontal scaling capabilities +- **Multiple Index Types**: Support for various vector indexing algorithms +- **Cloud Native**: Built for Kubernetes environments +- **Multiple APIs**: REST, gRPC, and Python SDK support + +#### Installation Steps + +1. **Add Helm Repository**: + ```bash + helm repo add milvus https://milvus-io.github.io/milvus-helm/ + helm repo update + ``` + +2. **Install Milvus**: + ```bash + helm upgrade --install my-release zilliztech/milvus -n docs-agent \ + --set cluster.enabled=false \ + --set standalone.enabled=true \ + --set etcd.replicaCount=1 \ + --set etcd.persistence.enabled=false \ + --set minio.mode=standalone \ + --set minio.replicas=1 \ + --set pulsar.enabled=false \ + --set pulsarv3.enabled=false \ + --set standalone.podAnnotations."sidecar\.istio\.io/inject"="false" + ``` + +#### Configuration Rationale + +- **Standalone Mode**: Single-node deployment for development/testing +- **Single etcd Replica**: Reduced resource usage with `etcd.persistence.enabled=false` +- **Standalone MinIO**: Single MinIO instance for object storage +- **Disabled Pulsar**: Not needed for standalone deployment +- **Istio Sidecar Injection**: Disabled to avoid networking issues + +3. **Test Connection**: + ```python + from pymilvus import connections + connections.connect("default", host="my-release-milvus.docs-agent.svc.cluster.local", port="19530") + print("Connected to Milvus successfully!") + ``` + +4. **External Access** (if needed for different clusters): + ```bash + kubectl expose service my-release-milvus \ + --name milvus-external \ + --type=NodePort \ + --port=19530 + ``` + +### KServe Inference Service + +The LLM inference is handled by KServe with vLLM backend for high-performance serving. + +#### Serving Runtime Configuration + +```yaml +# manifests/serving-runtime.yaml +apiVersion: serving.kserve.io/v1alpha1 +kind: ServingRuntime +metadata: + name: llm-runtime + namespace: docs-agent +spec: + supportedModelFormats: + - name: huggingface + version: "1" + autoSelect: true + containers: + - name: kserve-container + image: kserve/huggingfaceserver:latest-gpu + command: ["python", "-m", "huggingfaceserver"] + resources: + requests: + cpu: "4" + memory: "16Gi" + nvidia.com/gpu: "1" + limits: + cpu: "6" + memory: "24Gi" + nvidia.com/gpu: "1" +``` + +#### Inference Service Configuration + +```yaml +# manifests/inference-service.yaml +apiVersion: serving.kserve.io/v1beta1 +kind: InferenceService +metadata: + name: llama + namespace: docs-agent +spec: + predictor: + model: + modelFormat: + name: huggingface + version: "1" + runtime: llm-runtime + args: + - --model_name=llama3.1-8B + - --model_id=RedHatAI/Llama-3.1-8B-Instruct + - --backend=vllm + - --max-model-len=32768 + - --gpu-memory-utilization=0.90 + - --enable-auto-tool-choice + - --tool-call-parser=llama3_json + - --enable-tool-call-parser + env: + - name: HF_TOKEN + valueFrom: + secretKeyRef: + name: huggingface-secret + key: token + - name: CUDA_VISIBLE_DEVICES + value: "0" + resources: + requests: + cpu: "4" + memory: "16Gi" + nvidia.com/gpu: "1" + limits: + cpu: "6" + memory: "24Gi" + nvidia.com/gpu: "1" +``` + +#### Key Configuration Points + +- **Tool Calling**: Enabled with `--enable-auto-tool-choice` and `--enable-tool-call-parser` +- **Custom Template**: vLLM supports custom templates for different model formats +- **Resource Allocation**: GPU memory utilization set to 90% for optimal performance +- **HuggingFace Token**: Required for accessing the model + +**Connection Details**: +```python +KSERVE_URL = os.getenv("KSERVE_URL", "http://llama.docs-agent.svc.cluster.local/openai/v1/chat/completions") +MODEL = os.getenv("MODEL", "llama3.1-8B") +``` + +For more details, refer to [KServe documentation](https://kserve.github.io/website/) and [vLLM documentation](https://docs.vllm.ai/). + +### Kubeflow Pipelines + +The ETL (Extract, Transform, Load) process is implemented as a Kubeflow Pipeline for automated, scalable data processing. + +#### Why Kubeflow Pipelines? + +- **Infrastructure Management**: Kubernetes handles all infrastructure automatically +- **Scalability**: Auto-scaling based on workload demands +- **Reproducibility**: Version-controlled pipeline definitions +- **Integration**: Seamless integration with other Kubeflow components +- **CI/CD Ready**: Can be triggered via GitHub Actions or other automation tools + +#### Pipeline Components + +The pipeline consists of three main phases: + +##### 1. Repository Fetching + +```python +@dsl.component( + base_image="python:3.9", + packages_to_install=["requests", "beautifulsoup4"] +) +def download_github_directory( + repo_owner: str, + repo_name: str, + directory_path: str, + github_token: str, + github_data: dsl.Output[dsl.Dataset] +): + # Fetches documentation files from GitHub repositories + # Supports .md and .html files + # Handles authentication and recursive directory traversal +``` + +##### 2. Text Chunking and Embedding + +```python +@dsl.component( + base_image="pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime", + packages_to_install=["sentence-transformers", "langchain"] +) +def chunk_and_embed( + github_data: dsl.Input[dsl.Dataset], + repo_name: str, + base_url: str, + chunk_size: int, + chunk_overlap: int, + embedded_data: dsl.Output[dsl.Dataset] +): + # Processes text with aggressive cleaning + # Creates embeddings using sentence-transformers + # Handles chunking with configurable overlap +``` + +##### 3. Vector Database Storage + +```python +@dsl.component( + base_image="python:3.9", + packages_to_install=["pymilvus", "numpy"] +) +def store_milvus( + embedded_data: dsl.Input[dsl.Dataset], + milvus_host: str, + milvus_port: str, + collection_name: str +): + # Creates Milvus collection with proper schema + # Inserts vectors in batches for efficiency + # Creates indexes for optimal search performance +``` + +#### RBAC Configuration + +For Kubeflow Pipelines to access Milvus, proper RBAC permissions are required: + +```bash +# Create role for Milvus access +kubectl create role milvus-access \ + --namespace docs-agent \ + --verb=get,list,watch \ + --resource=services,endpoints + +# Bind role to KFP service account +kubectl create rolebinding kfp-to-milvus-editor \ + --namespace docs-agent \ + --role=milvus-access \ + --serviceaccount=kubeflow:default-editor +``` + +**Note**: Without these permissions, you'll encounter RBAC errors during the embedding phase. + +#### Future Improvements + +A better improvement would be using the embedding model as a service where users could call the service instead of installing heavy sentence transformers package every time. This would: + +- Reduce pipeline execution time +- Lower resource requirements +- Enable better caching and optimization +- Improve scalability + +### API Server + +Two API implementations are provided for different use cases: + +#### WebSocket API (`server/app.py`) + +**Use Case**: Real-time chat applications, interactive interfaces + +**Features**: +- Bidirectional communication +- Real-time streaming responses +- Tool call execution with live updates +- Connection management and error handling + +**Key Components**: +```python +async def handle_websocket(websocket, path): + """Handle WebSocket connections with tool calling support""" + # Manages connection lifecycle + # Handles message routing and tool execution + # Provides real-time streaming responses + +async def stream_llm_response(payload, websocket, citations_collector): + """Stream LLM responses with tool call handling""" + # Processes streaming responses from KServe + # Manages tool call accumulation and execution + # Handles follow-up requests after tool execution +``` + +#### HTTPS API (`server-https/app.py`) + +**Use Case**: RESTful integrations, server-to-server communication, web applications + +**Key Features**: +- **Dual Response Modes**: Both streaming (Server-Sent Events) and non-streaming JSON responses +- **RAG Integration**: Automatic tool calling for Kubeflow documentation search +- **CORS Support**: Full cross-origin resource sharing for web applications +- **FastAPI Framework**: Automatic OpenAPI documentation and type validation +- **Production Ready**: Health checks, error handling, and Kubernetes integration +- **Citation Management**: Automatic collection and deduplication of source citations + +**API Endpoints**: + +**Main Chat Endpoint**: +```python +@app.post("/chat") +async def chat(request: ChatRequest): + """Main chat endpoint with RAG capabilities""" + # Supports both streaming and non-streaming responses + # Handles tool calling and citation collection + # Returns structured JSON responses +``` + +**Health Check Endpoint**: +```python +@app.get("/health") +async def health_check(): + """Health check for Kubernetes probes""" + # Essential for production deployments + # Used by readiness and liveness probes +``` + +**Request/Response Models**: +```python +class ChatRequest(BaseModel): + message: str + stream: Optional[bool] = True # Default to streaming + +# Streaming Response (SSE) +data: {"type": "content", "content": "response text"} +data: {"type": "tool_result", "tool_name": "search_kubeflow_docs", "content": "search results"} +data: {"type": "citations", "citations": ["url1", "url2"]} +data: {"type": "done"} + +# Non-streaming Response +{ + "response": "Complete response text", + "citations": ["url1", "url2"] # or null if no citations +} +``` + +**Advanced Features**: + +- **Intelligent Tool Calling**: Automatically determines when to search documentation based on query context +- **Streaming Tool Execution**: Real-time tool call execution with live updates +- **Citation Tracking**: Automatic collection and deduplication of source URLs +- **Error Handling**: Comprehensive error handling with detailed error messages +- **CORS Configuration**: Full CORS support for web application integration +- **Resource Management**: Proper connection pooling and cleanup for Milvus and KServe + +#### SSL Certificate Requirements + +**Critical**: Both APIs require SSL certificates from a trusted Certificate Authority. Without proper SSL certificates, browsers will block WebSocket connections and HTTPS requests. + +## Usage + +### Starting the Services + +1. **Deploy Milvus and KServe** (as described above) + +2. **Run the Pipeline**: + ```bash + python kagent-feast-mcp/pipelines/kubeflow-pipeline.py + ``` + +3. **Start the API Server**: + ```bash + # WebSocket API + python server/app.py + + # HTTPS API + python server-https/app.py + ``` + +### API Usage Examples + +#### WebSocket API + +```javascript +const ws = new WebSocket('wss://your-domain.com:8000'); + +ws.onmessage = function(event) { + const data = JSON.parse(event.data); + switch(data.type) { + case 'content': + // Handle streaming content + break; + case 'citations': + // Handle citations + break; + case 'done': + // Handle completion + break; + } +}; + +ws.send(JSON.stringify({ + message: "How do I create a Kubeflow pipeline?" +})); +``` + +#### HTTPS API + +**Streaming Request (Server-Sent Events)**: +```bash +curl -X POST "https://your-domain.com/chat" \ + -H "Content-Type: application/json" \ + -H "Accept: text/event-stream" \ + -d '{"message": "What is KServe?", "stream": true}' +``` + +**Non-streaming Request (JSON Response)**: +```bash +curl -X POST "https://your-domain.com/chat" \ + -H "Content-Type: application/json" \ + -d '{"message": "What is KServe?", "stream": false}' +``` + +**JavaScript Integration Example**: +```javascript +// Streaming request +const response = await fetch('https://your-domain.com/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify({ + message: 'How do I create a Kubeflow pipeline?', + stream: true + }) +}); + +const reader = response.body.getReader(); +const decoder = new TextDecoder(); + +while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = JSON.parse(line.slice(6)); + switch(data.type) { + case 'content': + console.log('Content:', data.content); + break; + case 'tool_result': + console.log('Tool:', data.tool_name, data.content); + break; + case 'citations': + console.log('Citations:', data.citations); + break; + case 'done': + console.log('Response complete'); + break; + } + } + } +} +``` + +**Python Integration Example**: +```python +import requests +import json + +# Non-streaming request +response = requests.post( + 'https://your-domain.com/chat', + json={ + 'message': 'What is KServe?', + 'stream': False + } +) + +data = response.json() +print(f"Response: {data['response']}") +if data.get('citations'): + print(f"Sources: {data['citations']}") +``` + +## Configuration + +### Environment Variables + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDefaultDescription
KSERVE_URLhttp://llama.docs-agent.svc.cluster.local/openai/v1/chat/completionsKServe endpoint URL
MODELllama3.1-8BModel name
PORT8000API server port
MILVUS_HOSTmy-release-milvus.docs-agent.svc.cluster.localMilvus host
MILVUS_PORT19530Milvus port
MILVUS_COLLECTIONkubeflow_docsMilvus collection name
EMBEDDING_MODELsentence-transformers/all-mpnet-base-v2Embedding model
+ +### Pipeline Parameters + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterDefaultDescription
repo_ownerkubeflowGitHub repository owner
repo_namewebsiteGitHub repository name
directory_pathcontent/enDocumentation directory path
chunk_size1000Text chunk size for embedding
chunk_overlap100Overlap between chunks
base_urlhttps://www.kubeflow.org/docsBase URL for citations
milvus_hostmilvus-standalone-final.docs-agent.svc.cluster.localMilvus host (used by kubeflow-pipeline.py and incremental-pipeline.py)
+ +## Chat History and Persistence + +Currently, the system uses browser local storage for chat history management to: + +- **Reduce Server Overhead**: No server-side storage requirements +- **Improve Performance**: Client-side handling of chat state +- **Ensure Privacy**: Data stays on the user's device + +### Future Enhancements + +- **Chat History Summarization**: Implement conversation summarization to prevent token overflow +- **Persistent Storage**: Optional server-side chat history storage +- **Multi-session Support**: Support for multiple concurrent chat sessions + +## Troubleshooting + +### Common Issues + +1. **RBAC Errors**: Ensure proper service account permissions are set +2. **SSL Certificate Issues**: Verify certificate validity and browser trust +3. **GPU Resource Constraints**: Check GPU availability and memory allocation +4. **Milvus Connection**: Verify network connectivity and service discovery + +### Debug Commands + +```bash +# Check Milvus status +kubectl get pods -n docs-agent | grep milvus + +# Check KServe status +kubectl get inferenceservice -n docs-agent + +# Check API server logs +kubectl logs -f deployment/docs-assistant-api + +# Test Milvus connection +python -c "from pymilvus import connections; connections.connect('default', host='your-milvus-host', port='19530'); print('Connected!')" +``` + +## Contributing + +We welcome contributions! Please see our [contributing guidelines](CONTRIBUTING.md) for details. + +### Development Setup + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +### Mentors +- [Francisco Javier Arceo](https://www.linkedin.com/in/franciscojavierarceo/) - Project mentor and guidance +- [Chase Christensen](https://www.linkedin.com/in/chase-c-695463162/) - Project mentor and technical support + +### Organizations +- [Google Summer of Code (GSoC)](https://summerofcode.withgoogle.com/) for providing this incredible opportunity +- [Red Hat AI](https://www.redhat.com/en/topics/ai) for providing the Llama 3.1-8B model +- [Hugging Face](https://huggingface.co/) for the model hosting and sentence transformers library +- [Oracle Cloud Infrastructure (OCI)](https://www.oracle.com/cloud/) for providing cloud resources and infrastructure + +### Open Source Community +- [Kubeflow Community](https://github.com/kubeflow/community) for the KEP-867 proposal +- [Milvus](https://milvus.io/) for the vector database +- [KServe](https://kserve.github.io/website/) for model serving +- [vLLM](https://github.com/vllm-project/vllm) for high-performance LLM inference + +--- + +## Modern Infrastructure & CI/CD (Kagent & MCP) + +The project has evolved to utilize the Model Context Protocol (MCP) and **kagent** to route queries intelligently. The infrastructure is heavily automated using Terraform and GitHub Actions. + +### Terraform (`kagent-feast-mcp/terraform/`) +We use Terraform for declarative, reproducible cluster infrastructure on OKE. +* **`variables.tf`**: Single source of truth for component versions (Knative, Istio, KServe, etc.) and namespace names. +* **`namespaces.tf`**: Manages the `ml-infra` and `docs-agent` namespaces. +* **`knative.tf`**: Installs `cert-manager`, Knative Serving (Core & CRDs), Istio base/istiod, and KServe, applying crucial ConfigMap patches for scheduling. +* **`kubeflow_pipelines.tf`**: Deploys Kubeflow Pipelines (Standalone) into the `kubeflow` namespace without Istio sidecars (to prevent routing conflicts), persisting `fsGroup` patches for SeaweedFS. +* **`milvus.tf`**: Uses the Milvus Operator to deploy a lightweight Milvus Standalone instance strictly scheduled on CPU nodes, reserving GPU nodes purely for LLM inference. +* **`kagent.tf`**: Installs official `kagent-crds` and the `kagent` controller via OCI Helm charts, with bundled agents cleanly disabled. +* **`gateway_guardrails.tf`**: Installs the local `charts/gateway-guardrails` Helm chart — the single source of truth for the public edge: Istio Gateway + TLS, CORS-locked VirtualService, rate limits, and the zero-trust AuthorizationPolicies that explicitly allow internal cluster traffic where needed (e.g., KFP Pipeline to Milvus, Kagent to Milvus). Replaces the former inline `istio_policies.tf` / `kagent_ingress.tf`. + +### Gateway Guardrails Helm Chart (`kagent-feast-mcp/charts/gateway-guardrails/`) +Guardrails protecting the publicly shared chatbot, all tunable via `values.yaml`: +* **Rate limiting**: 60 req/min global token bucket on the chatbot HTTPS listener (EnvoyFilter), plus an optional per-client-IP layer (Envoy ratelimit service + Redis, off until the ingress preserves client IPs). +* **CORS lockdown**: only the published widget origin may embed the chatbot; a 30s route timeout caps hung LLM calls. +* **Mesh policies**: the RAG-stack AuthorizationPolicies (Milvus, LLM, MCP, embeddings), previously raw YAML heredocs inside Terraform. + +### Pipeline Optimizations (`kagent-feast-mcp/pipelines/`) +The ingestion pipeline was rewritten to maximize efficiency and avoid Kubernetes ephemeral storage eviction: +* **Feast Removal**: The pipeline now writes embeddings directly to Milvus using `pymilvus`, dramatically lowering complexity. +* **Custom Base Image (`Dockerfile.pipeline`)**: We bake the massive PyTorch library and the Hugging Face `all-mpnet-base-v2` model directly into a custom Docker image. This reduces runtime disk usage from 5.5GB to zero, fixing OKE pod eviction errors, and preventing Hugging Face API rate limits. + +### GitHub Actions CI/CD (`.github/workflows/`) +| Workflow | When it runs | Purpose | +|----------|----------------|---------| +| **`oke-cicd.yaml`** | Every PR and push to `main` | Compile pipelines, ruff, pytest; build/push MCP image to GHCR and optional OKE deploy when repo var `ENABLE_OKE_DEPLOY=true` | +| **`tests.yml`** | Every PR and push to `main` | Ruff lint/format + pytest (no cluster) | + Operator forks set `ENABLE_OKE_DEPLOY=true` and configure the `kubeflow` GitHub Environment (OCI + GHCR secrets) for cluster deploy and `smoke_tools.py` validation. \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 43b0eed..e8810ac 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,53 +1,53 @@ -# Kubeflow website chatbot (Kagent A2A) - -Static assets for the docs-site chat widget. The bot talks to **Kagent** over JSON-RPC `message/stream` (not the legacy REST API). - -## Configure the agent URL - -Set one of these before the widget loads: - -```html - - -``` - -Or on the script tag: - -```html - -``` - -Or base URL only (path is appended automatically): - -```html - -``` - -## Vercel demo page - -Host `docs_scripts/` and `docs_styles/` on Vercel and set `KUBEFLOW_DOCS_AGENT_URL` to your cluster endpoint: - -- **LoadBalancer (current OKE setup):** `kagent-ui-lb` external IP + `/a2a/docs-agent/kubeflow-docs-agent` (use HTTPS ingress if the Vercel site is `https://` to avoid mixed-content blocking). -- **Istio ingress (optional Terraform):** set `enable_kagent_ingress = true` and `kagent_domain_name` in `docs-agent-mcp/terraform/`, then point DNS and use `https://your-domain/...`. - -CORS: browser calls require the agent host to allow your site's origin. The Istio ingress only allows the exact origins listed in `kagent_cors_allow_origins` (terraform) / `routing.cors.allowOrigins` (gateway-guardrails Helm chart) — add your deployment's origin there; the LB path depends on Kagent's own CORS settings. - -Session auth: when `sessionAuth.enforce=true` is set on the gateway-guardrails chart, the widget must first `POST /api/session` to get a short-lived bearer token and send it as `Authorization: Bearer ` on every A2A call (re-fetch on 401). - -## Milvus collections (MCP tools) - -| Tool | Collection | -|------|------------| -| Docs | `kubeflow_docs` | -| Issues | `issues_rag` | -| Code | `code_rag` | +# Kubeflow website chatbot (Kagent A2A) + +Static assets for the docs-site chat widget. The bot talks to **Kagent** over JSON-RPC `message/stream` (not the legacy REST API). + +## Configure the agent URL + +Set one of these before the widget loads: + +```html + + +``` + +Or on the script tag: + +```html + +``` + +Or base URL only (path is appended automatically): + +```html + +``` + +## Vercel demo page + +Host `docs_scripts/` and `docs_styles/` on Vercel and set `KUBEFLOW_DOCS_AGENT_URL` to your cluster endpoint: + +- **LoadBalancer (current OKE setup):** `kagent-ui-lb` external IP + `/a2a/docs-agent/kubeflow-docs-agent` (use HTTPS ingress if the Vercel site is `https://` to avoid mixed-content blocking). +- **Istio ingress (optional Terraform):** set `enable_kagent_ingress = true` and `kagent_domain_name` in `kagent-feast-mcp/terraform/`, then point DNS and use `https://your-domain/...`. + +CORS: browser calls require the agent host to allow your site's origin. The Istio ingress only allows the exact origins listed in `kagent_cors_allow_origins` (terraform) / `routing.cors.allowOrigins` (gateway-guardrails Helm chart) — add your deployment's origin there; the LB path depends on Kagent's own CORS settings. + +Session auth: when `sessionAuth.enforce=true` is set on the gateway-guardrails chart, the widget must first `POST /api/session` to get a short-lived bearer token and send it as `Authorization: Bearer ` on every A2A call (re-fetch on 401). + +## Milvus collections (MCP tools) + +| Tool | Collection | +|------|------------| +| Docs | `kubeflow_docs` | +| Issues | `issues_rag` | +| Code | `code_rag` | diff --git a/docs-agent-mcp/README.md b/kagent-feast-mcp/README.md similarity index 100% rename from docs-agent-mcp/README.md rename to kagent-feast-mcp/README.md diff --git a/docs-agent-mcp/charts/gateway-guardrails/Chart.yaml b/kagent-feast-mcp/charts/gateway-guardrails/Chart.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/Chart.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/Chart.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/README.md b/kagent-feast-mcp/charts/gateway-guardrails/README.md similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/README.md rename to kagent-feast-mcp/charts/gateway-guardrails/README.md diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/_helpers.tpl b/kagent-feast-mcp/charts/gateway-guardrails/templates/_helpers.tpl similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/_helpers.tpl rename to kagent-feast-mcp/charts/gateway-guardrails/templates/_helpers.tpl diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/gateway.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/gateway.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/gateway.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/gateway.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/jwt-auth.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/jwt-auth.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/jwt-auth.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/jwt-auth.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/mesh-authz.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/mesh-authz.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/mesh-authz.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/mesh-authz.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/ratelimit-global.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/ratelimit-global.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/ratelimit-global.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/ratelimit-global.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/ratelimit-perip.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/ratelimit-perip.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/ratelimit-perip.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/ratelimit-perip.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/session-issuer.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/session-issuer.yaml similarity index 93% rename from docs-agent-mcp/charts/gateway-guardrails/templates/session-issuer.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/session-issuer.yaml index 7ae5298..1ed7434 100644 --- a/docs-agent-mcp/charts/gateway-guardrails/templates/session-issuer.yaml +++ b/kagent-feast-mcp/charts/gateway-guardrails/templates/session-issuer.yaml @@ -1,70 +1,70 @@ -{{- if .Values.sessionAuth.enabled }} -# Anonymous session-token issuer (docs-agent-mcp/session-issuer/). -# Mints short-lived RS256 JWTs; Istio validates them at the ingress gateway. -apiVersion: apps/v1 -kind: Deployment -metadata: - name: session-issuer - namespace: {{ .Values.namespaces.docsAgent }} - labels: - app: session-issuer - {{- include "gateway-guardrails.labels" . | nindent 4 }} -spec: - replicas: 1 - selector: - matchLabels: - app: session-issuer - template: - metadata: - labels: - app: session-issuer - spec: - containers: - - name: session-issuer - image: {{ .Values.sessionAuth.image }} - ports: - - containerPort: 8000 - env: - - name: SESSION_ISSUER - value: {{ .Values.sessionAuth.issuer | quote }} - - name: SESSION_TTL_SECONDS - value: {{ .Values.sessionAuth.ttlSeconds | quote }} - - name: PRIVATE_KEY_PATH - value: /keys/private.pem - volumeMounts: - - name: signing-key - mountPath: /keys - readOnly: true - readinessProbe: - httpGet: - path: /healthz - port: 8000 - initialDelaySeconds: 3 - periodSeconds: 10 - livenessProbe: - httpGet: - path: /healthz - port: 8000 - initialDelaySeconds: 10 - periodSeconds: 30 - resources: - {{- toYaml .Values.sessionAuth.resources | nindent 12 }} - volumes: - - name: signing-key - secret: - secretName: {{ .Values.sessionAuth.keySecretName }} ---- -apiVersion: v1 -kind: Service -metadata: - name: session-issuer - namespace: {{ .Values.namespaces.docsAgent }} - labels: - {{- include "gateway-guardrails.labels" . | nindent 4 }} -spec: - selector: - app: session-issuer - ports: - - port: 8000 - targetPort: 8000 -{{- end }} +{{- if .Values.sessionAuth.enabled }} +# Anonymous session-token issuer (kagent-feast-mcp/session-issuer/). +# Mints short-lived RS256 JWTs; Istio validates them at the ingress gateway. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: session-issuer + namespace: {{ .Values.namespaces.docsAgent }} + labels: + app: session-issuer + {{- include "gateway-guardrails.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: session-issuer + template: + metadata: + labels: + app: session-issuer + spec: + containers: + - name: session-issuer + image: {{ .Values.sessionAuth.image }} + ports: + - containerPort: 8000 + env: + - name: SESSION_ISSUER + value: {{ .Values.sessionAuth.issuer | quote }} + - name: SESSION_TTL_SECONDS + value: {{ .Values.sessionAuth.ttlSeconds | quote }} + - name: PRIVATE_KEY_PATH + value: /keys/private.pem + volumeMounts: + - name: signing-key + mountPath: /keys + readOnly: true + readinessProbe: + httpGet: + path: /healthz + port: 8000 + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + resources: + {{- toYaml .Values.sessionAuth.resources | nindent 12 }} + volumes: + - name: signing-key + secret: + secretName: {{ .Values.sessionAuth.keySecretName }} +--- +apiVersion: v1 +kind: Service +metadata: + name: session-issuer + namespace: {{ .Values.namespaces.docsAgent }} + labels: + {{- include "gateway-guardrails.labels" . | nindent 4 }} +spec: + selector: + app: session-issuer + ports: + - port: 8000 + targetPort: 8000 +{{- end }} diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/tls.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/tls.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/tls.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/tls.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/templates/virtualservice.yaml b/kagent-feast-mcp/charts/gateway-guardrails/templates/virtualservice.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/templates/virtualservice.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/templates/virtualservice.yaml diff --git a/docs-agent-mcp/charts/gateway-guardrails/values.yaml b/kagent-feast-mcp/charts/gateway-guardrails/values.yaml similarity index 100% rename from docs-agent-mcp/charts/gateway-guardrails/values.yaml rename to kagent-feast-mcp/charts/gateway-guardrails/values.yaml diff --git a/docs-agent-mcp/manifests/istio-ratelimit/README.md b/kagent-feast-mcp/manifests/istio-ratelimit/README.md similarity index 100% rename from docs-agent-mcp/manifests/istio-ratelimit/README.md rename to kagent-feast-mcp/manifests/istio-ratelimit/README.md diff --git a/docs-agent-mcp/manifests/istio-ratelimit/local-ratelimit-global.yaml b/kagent-feast-mcp/manifests/istio-ratelimit/local-ratelimit-global.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio-ratelimit/local-ratelimit-global.yaml rename to kagent-feast-mcp/manifests/istio-ratelimit/local-ratelimit-global.yaml diff --git a/docs-agent-mcp/manifests/istio-ratelimit/perip-global-ratelimit.yaml b/kagent-feast-mcp/manifests/istio-ratelimit/perip-global-ratelimit.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio-ratelimit/perip-global-ratelimit.yaml rename to kagent-feast-mcp/manifests/istio-ratelimit/perip-global-ratelimit.yaml diff --git a/docs-agent-mcp/manifests/istio-ratelimit/perip-ratelimit-service.yaml b/kagent-feast-mcp/manifests/istio-ratelimit/perip-ratelimit-service.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio-ratelimit/perip-ratelimit-service.yaml rename to kagent-feast-mcp/manifests/istio-ratelimit/perip-ratelimit-service.yaml diff --git a/docs-agent-mcp/manifests/istio-tls/cluster-issuer.yaml b/kagent-feast-mcp/manifests/istio-tls/cluster-issuer.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio-tls/cluster-issuer.yaml rename to kagent-feast-mcp/manifests/istio-tls/cluster-issuer.yaml diff --git a/docs-agent-mcp/manifests/istio-tls/ingress-class.yaml b/kagent-feast-mcp/manifests/istio-tls/ingress-class.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio-tls/ingress-class.yaml rename to kagent-feast-mcp/manifests/istio-tls/ingress-class.yaml diff --git a/docs-agent-mcp/manifests/istio-tls/kagent-gateway-tls.yaml b/kagent-feast-mcp/manifests/istio-tls/kagent-gateway-tls.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio-tls/kagent-gateway-tls.yaml rename to kagent-feast-mcp/manifests/istio-tls/kagent-gateway-tls.yaml diff --git a/docs-agent-mcp/manifests/istio/allow-kubeflow-to-milvus.yaml b/kagent-feast-mcp/manifests/istio/allow-kubeflow-to-milvus.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio/allow-kubeflow-to-milvus.yaml rename to kagent-feast-mcp/manifests/istio/allow-kubeflow-to-milvus.yaml diff --git a/docs-agent-mcp/manifests/istio/allow-milvus-etcd.yaml b/kagent-feast-mcp/manifests/istio/allow-milvus-etcd.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio/allow-milvus-etcd.yaml rename to kagent-feast-mcp/manifests/istio/allow-milvus-etcd.yaml diff --git a/docs-agent-mcp/manifests/istio/allow-milvus-minio.yaml b/kagent-feast-mcp/manifests/istio/allow-milvus-minio.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio/allow-milvus-minio.yaml rename to kagent-feast-mcp/manifests/istio/allow-milvus-minio.yaml diff --git a/docs-agent-mcp/manifests/istio/allow-milvus-standalone.yaml b/kagent-feast-mcp/manifests/istio/allow-milvus-standalone.yaml similarity index 100% rename from docs-agent-mcp/manifests/istio/allow-milvus-standalone.yaml rename to kagent-feast-mcp/manifests/istio/allow-milvus-standalone.yaml diff --git a/docs-agent-mcp/manifests/kagent/setup.yaml b/kagent-feast-mcp/manifests/kagent/setup.yaml similarity index 100% rename from docs-agent-mcp/manifests/kagent/setup.yaml rename to kagent-feast-mcp/manifests/kagent/setup.yaml diff --git a/docs-agent-mcp/manifests/mcp-server/mcp-server.yaml b/kagent-feast-mcp/manifests/mcp-server/mcp-server.yaml similarity index 100% rename from docs-agent-mcp/manifests/mcp-server/mcp-server.yaml rename to kagent-feast-mcp/manifests/mcp-server/mcp-server.yaml diff --git a/docs-agent-mcp/manifests/vllm/kserve-qwen.yaml b/kagent-feast-mcp/manifests/vllm/kserve-qwen.yaml similarity index 100% rename from docs-agent-mcp/manifests/vllm/kserve-qwen.yaml rename to kagent-feast-mcp/manifests/vllm/kserve-qwen.yaml diff --git a/docs-agent-mcp/mcp-server/Dockerfile b/kagent-feast-mcp/mcp-server/Dockerfile similarity index 100% rename from docs-agent-mcp/mcp-server/Dockerfile rename to kagent-feast-mcp/mcp-server/Dockerfile diff --git a/docs-agent-mcp/mcp-server/embeddings_client.py b/kagent-feast-mcp/mcp-server/embeddings_client.py similarity index 100% rename from docs-agent-mcp/mcp-server/embeddings_client.py rename to kagent-feast-mcp/mcp-server/embeddings_client.py diff --git a/docs-agent-mcp/mcp-server/rag_collections.py b/kagent-feast-mcp/mcp-server/rag_collections.py similarity index 100% rename from docs-agent-mcp/mcp-server/rag_collections.py rename to kagent-feast-mcp/mcp-server/rag_collections.py diff --git a/docs-agent-mcp/mcp-server/requirements.txt b/kagent-feast-mcp/mcp-server/requirements.txt similarity index 100% rename from docs-agent-mcp/mcp-server/requirements.txt rename to kagent-feast-mcp/mcp-server/requirements.txt diff --git a/docs-agent-mcp/mcp-server/server.py b/kagent-feast-mcp/mcp-server/server.py similarity index 100% rename from docs-agent-mcp/mcp-server/server.py rename to kagent-feast-mcp/mcp-server/server.py diff --git a/docs-agent-mcp/mcp-server/smoke_tools.py b/kagent-feast-mcp/mcp-server/smoke_tools.py similarity index 100% rename from docs-agent-mcp/mcp-server/smoke_tools.py rename to kagent-feast-mcp/mcp-server/smoke_tools.py diff --git a/docs-agent-mcp/pipelines/Dockerfile.pipeline b/kagent-feast-mcp/pipelines/Dockerfile.pipeline similarity index 100% rename from docs-agent-mcp/pipelines/Dockerfile.pipeline rename to kagent-feast-mcp/pipelines/Dockerfile.pipeline diff --git a/docs-agent-mcp/pipelines/README.md b/kagent-feast-mcp/pipelines/README.md similarity index 100% rename from docs-agent-mcp/pipelines/README.md rename to kagent-feast-mcp/pipelines/README.md diff --git a/docs-agent-mcp/pipelines/code-pipeline.py b/kagent-feast-mcp/pipelines/code-pipeline.py similarity index 100% rename from docs-agent-mcp/pipelines/code-pipeline.py rename to kagent-feast-mcp/pipelines/code-pipeline.py diff --git a/docs-agent-mcp/pipelines/code_utils.py b/kagent-feast-mcp/pipelines/code_utils.py similarity index 100% rename from docs-agent-mcp/pipelines/code_utils.py rename to kagent-feast-mcp/pipelines/code_utils.py diff --git a/docs-agent-mcp/pipelines/incremental-pipeline.py b/kagent-feast-mcp/pipelines/incremental-pipeline.py similarity index 100% rename from docs-agent-mcp/pipelines/incremental-pipeline.py rename to kagent-feast-mcp/pipelines/incremental-pipeline.py diff --git a/docs-agent-mcp/pipelines/issues-pipeline.py b/kagent-feast-mcp/pipelines/issues-pipeline.py similarity index 100% rename from docs-agent-mcp/pipelines/issues-pipeline.py rename to kagent-feast-mcp/pipelines/issues-pipeline.py diff --git a/docs-agent-mcp/pipelines/issues_utils.py b/kagent-feast-mcp/pipelines/issues_utils.py similarity index 100% rename from docs-agent-mcp/pipelines/issues_utils.py rename to kagent-feast-mcp/pipelines/issues_utils.py diff --git a/docs-agent-mcp/pipelines/kubeflow-pipeline.py b/kagent-feast-mcp/pipelines/kubeflow-pipeline.py similarity index 100% rename from docs-agent-mcp/pipelines/kubeflow-pipeline.py rename to kagent-feast-mcp/pipelines/kubeflow-pipeline.py diff --git a/docs-agent-mcp/pipelines/requirements.txt b/kagent-feast-mcp/pipelines/requirements.txt similarity index 100% rename from docs-agent-mcp/pipelines/requirements.txt rename to kagent-feast-mcp/pipelines/requirements.txt diff --git a/docs-agent-mcp/pipelines/submit_run.py b/kagent-feast-mcp/pipelines/submit_run.py similarity index 100% rename from docs-agent-mcp/pipelines/submit_run.py rename to kagent-feast-mcp/pipelines/submit_run.py diff --git a/docs-agent-mcp/pipelines/utils.py b/kagent-feast-mcp/pipelines/utils.py similarity index 100% rename from docs-agent-mcp/pipelines/utils.py rename to kagent-feast-mcp/pipelines/utils.py diff --git a/docs-agent-mcp/session-issuer/Dockerfile b/kagent-feast-mcp/session-issuer/Dockerfile similarity index 100% rename from docs-agent-mcp/session-issuer/Dockerfile rename to kagent-feast-mcp/session-issuer/Dockerfile diff --git a/docs-agent-mcp/session-issuer/issuer_core.py b/kagent-feast-mcp/session-issuer/issuer_core.py similarity index 100% rename from docs-agent-mcp/session-issuer/issuer_core.py rename to kagent-feast-mcp/session-issuer/issuer_core.py diff --git a/docs-agent-mcp/session-issuer/main.py b/kagent-feast-mcp/session-issuer/main.py similarity index 100% rename from docs-agent-mcp/session-issuer/main.py rename to kagent-feast-mcp/session-issuer/main.py diff --git a/docs-agent-mcp/session-issuer/requirements.txt b/kagent-feast-mcp/session-issuer/requirements.txt similarity index 100% rename from docs-agent-mcp/session-issuer/requirements.txt rename to kagent-feast-mcp/session-issuer/requirements.txt diff --git a/docs-agent-mcp/terraform/.gitignore b/kagent-feast-mcp/terraform/.gitignore similarity index 100% rename from docs-agent-mcp/terraform/.gitignore rename to kagent-feast-mcp/terraform/.gitignore diff --git a/docs-agent-mcp/terraform/.terraform.lock.hcl b/kagent-feast-mcp/terraform/.terraform.lock.hcl similarity index 100% rename from docs-agent-mcp/terraform/.terraform.lock.hcl rename to kagent-feast-mcp/terraform/.terraform.lock.hcl diff --git a/docs-agent-mcp/terraform/embeddings.tf b/kagent-feast-mcp/terraform/embeddings.tf similarity index 100% rename from docs-agent-mcp/terraform/embeddings.tf rename to kagent-feast-mcp/terraform/embeddings.tf diff --git a/docs-agent-mcp/terraform/gateway_guardrails.tf b/kagent-feast-mcp/terraform/gateway_guardrails.tf similarity index 100% rename from docs-agent-mcp/terraform/gateway_guardrails.tf rename to kagent-feast-mcp/terraform/gateway_guardrails.tf diff --git a/docs-agent-mcp/terraform/kagent.tf b/kagent-feast-mcp/terraform/kagent.tf similarity index 100% rename from docs-agent-mcp/terraform/kagent.tf rename to kagent-feast-mcp/terraform/kagent.tf diff --git a/docs-agent-mcp/terraform/knative.tf b/kagent-feast-mcp/terraform/knative.tf similarity index 100% rename from docs-agent-mcp/terraform/knative.tf rename to kagent-feast-mcp/terraform/knative.tf diff --git a/docs-agent-mcp/terraform/kubeflow_pipelines.tf b/kagent-feast-mcp/terraform/kubeflow_pipelines.tf similarity index 100% rename from docs-agent-mcp/terraform/kubeflow_pipelines.tf rename to kagent-feast-mcp/terraform/kubeflow_pipelines.tf diff --git a/docs-agent-mcp/terraform/milvus.tf b/kagent-feast-mcp/terraform/milvus.tf similarity index 100% rename from docs-agent-mcp/terraform/milvus.tf rename to kagent-feast-mcp/terraform/milvus.tf diff --git a/docs-agent-mcp/terraform/namespaces.tf b/kagent-feast-mcp/terraform/namespaces.tf similarity index 100% rename from docs-agent-mcp/terraform/namespaces.tf rename to kagent-feast-mcp/terraform/namespaces.tf diff --git a/docs-agent-mcp/terraform/providers.tf b/kagent-feast-mcp/terraform/providers.tf similarity index 100% rename from docs-agent-mcp/terraform/providers.tf rename to kagent-feast-mcp/terraform/providers.tf diff --git a/docs-agent-mcp/terraform/terraform.tfvars.example b/kagent-feast-mcp/terraform/terraform.tfvars.example similarity index 100% rename from docs-agent-mcp/terraform/terraform.tfvars.example rename to kagent-feast-mcp/terraform/terraform.tfvars.example diff --git a/docs-agent-mcp/terraform/variables.tf b/kagent-feast-mcp/terraform/variables.tf similarity index 100% rename from docs-agent-mcp/terraform/variables.tf rename to kagent-feast-mcp/terraform/variables.tf diff --git a/legacy/server-https/Dockerfile b/legacy-servers/server-https/Dockerfile similarity index 100% rename from legacy/server-https/Dockerfile rename to legacy-servers/server-https/Dockerfile diff --git a/legacy/server-https/app.py b/legacy-servers/server-https/app.py similarity index 100% rename from legacy/server-https/app.py rename to legacy-servers/server-https/app.py diff --git a/legacy/server-https/deployment.yaml b/legacy-servers/server-https/deployment.yaml similarity index 100% rename from legacy/server-https/deployment.yaml rename to legacy-servers/server-https/deployment.yaml diff --git a/legacy/server-https/requirements.txt b/legacy-servers/server-https/requirements.txt similarity index 100% rename from legacy/server-https/requirements.txt rename to legacy-servers/server-https/requirements.txt diff --git a/legacy/server-https/service.yaml b/legacy-servers/server-https/service.yaml similarity index 100% rename from legacy/server-https/service.yaml rename to legacy-servers/server-https/service.yaml diff --git a/legacy/server-https/virtualservice.yaml b/legacy-servers/server-https/virtualservice.yaml similarity index 100% rename from legacy/server-https/virtualservice.yaml rename to legacy-servers/server-https/virtualservice.yaml diff --git a/legacy/server/Dockerfile b/legacy-servers/server/Dockerfile similarity index 100% rename from legacy/server/Dockerfile rename to legacy-servers/server/Dockerfile diff --git a/legacy/server/app.py b/legacy-servers/server/app.py similarity index 100% rename from legacy/server/app.py rename to legacy-servers/server/app.py diff --git a/legacy/server/deployment.yaml b/legacy-servers/server/deployment.yaml similarity index 100% rename from legacy/server/deployment.yaml rename to legacy-servers/server/deployment.yaml diff --git a/legacy/server/requirements.txt b/legacy-servers/server/requirements.txt similarity index 100% rename from legacy/server/requirements.txt rename to legacy-servers/server/requirements.txt diff --git a/legacy/server/service.yaml b/legacy-servers/server/service.yaml similarity index 100% rename from legacy/server/service.yaml rename to legacy-servers/server/service.yaml diff --git a/pyproject.toml b/pyproject.toml index c2ca346..55081a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,20 @@ -[tool.ruff] -target-version = "py39" -line-length = 120 -exclude = ["legacy"] - -[tool.ruff.lint] -# Pinned explicitly rather than relying on ruff's defaults: ruff 0.16 widened -# the default rule set (import sorting, flake8-datetimez, flake8-blind-except, -# ...) and turned a green main red on the next CI run. These four groups are -# the set this repo has always linted against; the per-file-ignores below are -# expressed in terms of them. -select = ["E4", "E7", "E9", "F"] - -[tool.ruff.lint.per-file-ignores] -"docs-agent-mcp/pipelines/*.py" = ["F403", "F405", "F841", "E741"] -"scripts/index_real_issues.py" = ["F401", "E741"] -"legacy/server/app.py" = ["F401"] -"tests/test_code_utils.py" = ["E402"] -"tests/test_issues_pipeline.py" = ["E402"] -"tests/test_pipeline_utils.py" = ["E402"] +[tool.ruff] +target-version = "py39" +line-length = 120 +exclude = ["legacy"] + +[tool.ruff.lint] +# Pinned explicitly rather than relying on ruff's defaults: ruff 0.16 widened +# the default rule set (import sorting, flake8-datetimez, flake8-blind-except, +# ...) and turned a green main red on the next CI run. These four groups are +# the set this repo has always linted against; the per-file-ignores below are +# expressed in terms of them. +select = ["E4", "E7", "E9", "F"] + +[tool.ruff.lint.per-file-ignores] +"kagent-feast-mcp/pipelines/*.py" = ["F403", "F405", "F841", "E741"] +"scripts/index_real_issues.py" = ["F401", "E741"] +"legacy/server/app.py" = ["F401"] +"tests/test_code_utils.py" = ["E402"] +"tests/test_issues_pipeline.py" = ["E402"] +"tests/test_pipeline_utils.py" = ["E402"] diff --git a/tests/test_code_utils.py b/tests/test_code_utils.py index 981bd02..22efc86 100644 --- a/tests/test_code_utils.py +++ b/tests/test_code_utils.py @@ -1,166 +1,166 @@ -"""Tests for code and manifest chunking utilities.""" - -import sys -from pathlib import Path - - -PIPELINES_DIR = Path(__file__).parent.parent / "docs-agent-mcp" / "pipelines" -sys.path.insert(0, str(PIPELINES_DIR)) - -from code_utils import chunk_code_file, parse_json_file, parse_python_ast, parse_yaml_documents - - -class TestParseYamlDocuments: - """Tests for Kubernetes YAML-aware chunking.""" - - def test_extracts_metadata_from_multi_document_yaml(self): - content = """apiVersion: apps/v1 -kind: Deployment -metadata: - name: ml-pipeline - namespace: kubeflow ---- -apiVersion: v1 -kind: Service -metadata: - name: ml-pipeline -""" - - chunks = parse_yaml_documents(content, "apps/pipeline/deployment.yaml") - - assert len(chunks) == 2 - assert chunks[0]["resource_kind"] == "Deployment" - assert chunks[0]["resource_name"] == "ml-pipeline" - assert chunks[0]["resource_namespace"] == "kubeflow" - assert chunks[0]["file_type"] == "yaml" - assert chunks[1]["resource_kind"] == "Service" - - def test_marks_kustomization_files(self): - content = """apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - deployment.yaml -""" - - chunks = parse_yaml_documents(content, "apps/pipeline/kustomization.yaml") - - assert len(chunks) == 1 - assert chunks[0]["file_type"] == "kustomize" - assert chunks[0]["resource_kind"] == "Kustomization" - - def test_invalid_yaml_falls_back_to_text_chunk(self): - content = """apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Values.name }} -data: - value: [unterminated -""" - - chunks = parse_yaml_documents(content, "templates/configmap.yaml") - - assert len(chunks) == 1 - assert chunks[0]["content"] == content.strip() - assert chunks[0]["resource_kind"] == "" - assert chunks[0]["file_type"] == "yaml" - - -class TestParsePythonAst: - """Tests for Python AST-aware chunking.""" - - def test_extracts_header_classes_functions_and_async_functions(self): - content = '''"""Module docs.""" -import os - -CONSTANT = "value" - -@decorator -def build_pipeline(name): - return name - -class PipelineCompiler: - def compile(self): - return True - -async def run_pipeline(): - return "done" -''' - - chunks = parse_python_ast(content, "sdk/compiler.py") - - kinds_and_names = [(chunk["resource_kind"], chunk["resource_name"]) for chunk in chunks] - assert kinds_and_names == [ - ("module_header", "compiler.py"), - ("function", "build_pipeline"), - ("class", "PipelineCompiler"), - ("async_function", "run_pipeline"), - ] - assert chunks[1]["content"].startswith("@decorator") - assert chunks[1]["file_type"] == "python" - - def test_returns_module_chunk_when_no_top_level_defs(self): - content = "PIPELINE_ROOT = '/tmp/pipeline'\nDEFAULT_TIMEOUT = 30\n" - - chunks = parse_python_ast(content, "settings.py") - - assert len(chunks) == 1 - assert chunks[0]["resource_kind"] == "module" - assert chunks[0]["resource_name"] == "settings.py" - - def test_syntax_error_returns_whole_file(self): - content = "def broken(:\n pass\n" - - chunks = parse_python_ast(content, "broken.py") - - assert len(chunks) == 1 - assert chunks[0]["content"] == content - assert chunks[0]["resource_kind"] == "" - assert chunks[0]["file_type"] == "python" - - -class TestChunkCodeFile: - """Tests for file type routing and oversized chunk behavior.""" - - def test_json_file_is_indexed_as_single_chunk(self): - content = '{"name": "docs-agent", "private": true}' - - chunks = parse_json_file(content, "package.json") - - assert chunks == [ - { - "content": content, - "resource_kind": "", - "resource_name": "package.json", - "resource_namespace": "", - "file_type": "json", - } - ] - - def test_generic_file_uses_text_fallback(self): - content = "FROM python:3.11-slim\nRUN echo hello\n" - - chunks = chunk_code_file(content, "Dockerfile") - - assert len(chunks) == 1 - assert chunks[0]["resource_name"] == "Dockerfile" - assert chunks[0]["file_type"] == "text" - - def test_oversized_yaml_subchunks_preserve_metadata(self): - content = ( - "apiVersion: v1\n" - "kind: ConfigMap\n" - "metadata:\n" - " name: large-config\n" - " namespace: kubeflow\n" - "data:\n" - f" body: {'value ' * 120}\n" - ) - - chunks = chunk_code_file(content, "manifests/configmap.yaml", chunk_size=120, chunk_overlap=10) - - assert len(chunks) > 1 - for chunk in chunks: - assert chunk["resource_kind"] == "ConfigMap" - assert chunk["resource_name"] == "large-config" - assert chunk["resource_namespace"] == "kubeflow" - assert chunk["file_type"] == "yaml" +"""Tests for code and manifest chunking utilities.""" + +import sys +from pathlib import Path + + +PIPELINES_DIR = Path(__file__).parent.parent / "kagent-feast-mcp" / "pipelines" +sys.path.insert(0, str(PIPELINES_DIR)) + +from code_utils import chunk_code_file, parse_json_file, parse_python_ast, parse_yaml_documents + + +class TestParseYamlDocuments: + """Tests for Kubernetes YAML-aware chunking.""" + + def test_extracts_metadata_from_multi_document_yaml(self): + content = """apiVersion: apps/v1 +kind: Deployment +metadata: + name: ml-pipeline + namespace: kubeflow +--- +apiVersion: v1 +kind: Service +metadata: + name: ml-pipeline +""" + + chunks = parse_yaml_documents(content, "apps/pipeline/deployment.yaml") + + assert len(chunks) == 2 + assert chunks[0]["resource_kind"] == "Deployment" + assert chunks[0]["resource_name"] == "ml-pipeline" + assert chunks[0]["resource_namespace"] == "kubeflow" + assert chunks[0]["file_type"] == "yaml" + assert chunks[1]["resource_kind"] == "Service" + + def test_marks_kustomization_files(self): + content = """apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml +""" + + chunks = parse_yaml_documents(content, "apps/pipeline/kustomization.yaml") + + assert len(chunks) == 1 + assert chunks[0]["file_type"] == "kustomize" + assert chunks[0]["resource_kind"] == "Kustomization" + + def test_invalid_yaml_falls_back_to_text_chunk(self): + content = """apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.name }} +data: + value: [unterminated +""" + + chunks = parse_yaml_documents(content, "templates/configmap.yaml") + + assert len(chunks) == 1 + assert chunks[0]["content"] == content.strip() + assert chunks[0]["resource_kind"] == "" + assert chunks[0]["file_type"] == "yaml" + + +class TestParsePythonAst: + """Tests for Python AST-aware chunking.""" + + def test_extracts_header_classes_functions_and_async_functions(self): + content = '''"""Module docs.""" +import os + +CONSTANT = "value" + +@decorator +def build_pipeline(name): + return name + +class PipelineCompiler: + def compile(self): + return True + +async def run_pipeline(): + return "done" +''' + + chunks = parse_python_ast(content, "sdk/compiler.py") + + kinds_and_names = [(chunk["resource_kind"], chunk["resource_name"]) for chunk in chunks] + assert kinds_and_names == [ + ("module_header", "compiler.py"), + ("function", "build_pipeline"), + ("class", "PipelineCompiler"), + ("async_function", "run_pipeline"), + ] + assert chunks[1]["content"].startswith("@decorator") + assert chunks[1]["file_type"] == "python" + + def test_returns_module_chunk_when_no_top_level_defs(self): + content = "PIPELINE_ROOT = '/tmp/pipeline'\nDEFAULT_TIMEOUT = 30\n" + + chunks = parse_python_ast(content, "settings.py") + + assert len(chunks) == 1 + assert chunks[0]["resource_kind"] == "module" + assert chunks[0]["resource_name"] == "settings.py" + + def test_syntax_error_returns_whole_file(self): + content = "def broken(:\n pass\n" + + chunks = parse_python_ast(content, "broken.py") + + assert len(chunks) == 1 + assert chunks[0]["content"] == content + assert chunks[0]["resource_kind"] == "" + assert chunks[0]["file_type"] == "python" + + +class TestChunkCodeFile: + """Tests for file type routing and oversized chunk behavior.""" + + def test_json_file_is_indexed_as_single_chunk(self): + content = '{"name": "docs-agent", "private": true}' + + chunks = parse_json_file(content, "package.json") + + assert chunks == [ + { + "content": content, + "resource_kind": "", + "resource_name": "package.json", + "resource_namespace": "", + "file_type": "json", + } + ] + + def test_generic_file_uses_text_fallback(self): + content = "FROM python:3.11-slim\nRUN echo hello\n" + + chunks = chunk_code_file(content, "Dockerfile") + + assert len(chunks) == 1 + assert chunks[0]["resource_name"] == "Dockerfile" + assert chunks[0]["file_type"] == "text" + + def test_oversized_yaml_subchunks_preserve_metadata(self): + content = ( + "apiVersion: v1\n" + "kind: ConfigMap\n" + "metadata:\n" + " name: large-config\n" + " namespace: kubeflow\n" + "data:\n" + f" body: {'value ' * 120}\n" + ) + + chunks = chunk_code_file(content, "manifests/configmap.yaml", chunk_size=120, chunk_overlap=10) + + assert len(chunks) > 1 + for chunk in chunks: + assert chunk["resource_kind"] == "ConfigMap" + assert chunk["resource_name"] == "large-config" + assert chunk["resource_namespace"] == "kubeflow" + assert chunk["file_type"] == "yaml" diff --git a/tests/test_issues_pipeline.py b/tests/test_issues_pipeline.py index caa8ad5..023de8e 100644 --- a/tests/test_issues_pipeline.py +++ b/tests/test_issues_pipeline.py @@ -1,309 +1,309 @@ -"""Tests for GitHub issues pipeline utilities (docs-agent-mcp/pipelines/issues_utils.py). - -Tests the pure-Python metadata parsing and chunking logic extracted -from the chunk_and_embed_issues KFP component for testability. -""" - -import sys -from pathlib import Path - -PIPELINES_DIR = Path(__file__).parent.parent / "docs-agent-mcp" / "pipelines" -sys.path.insert(0, str(PIPELINES_DIR)) - -from issues_utils import ( - build_issue_record, - build_metadata_prefix, - format_issue_markdown, - has_structured_metadata, - issue_content_segments, - parse_issue_metadata, - resolve_issue_metadata, - split_issue_into_chunks, - split_issue_record_into_chunks, -) - - -# --- Sample content fixtures --- - -SAMPLE_ISSUE_CONTENT = """# KServe model not loading - -**Repository:** kubeflow/kubeflow -**Issue:** #42 -**URL:** https://github.com/kubeflow/kubeflow/issues/42 -**Labels:** kind/bug, area/kserve -**State:** open -**Created:** 2026-01-15 -**Updated:** 2026-01-20 - -The model fails to load when using GPU. I've tried multiple configurations -but keep getting OOM errors. - ---- -**Comment by @alice** (2026-01-16): -Have you tried setting memory limits in your InferenceService spec? - ---- -**Comment by @bob** (2026-01-17): -Fixed by upgrading KServe to v0.12. The GPU memory allocation was improved.""" - -SHORT_ISSUE_CONTENT = """# Typo in docs - -**Repository:** kubeflow/website -**Issue:** #99 -**URL:** https://github.com/kubeflow/website/issues/99 -**Labels:** kind/docs -**State:** closed -**Created:** 2026-02-01 -**Updated:** 2026-02-02 - -There is a typo on the installation page.""" - -NO_LABELS_CONTENT = """# Feature request - -**Repository:** kubeflow/pipelines -**Issue:** #500 -**URL:** https://github.com/kubeflow/pipelines/issues/500 -**Labels:** -**State:** open -**Created:** 2026-03-01 -**Updated:** 2026-03-10 - -Please add support for caching.""" - -SAMPLE_STRUCTURED_RECORD = build_issue_record( - repo_name="kubeflow/kubeflow", - repo_short_name="kubeflow", - issue_number=42, - title="KServe model not loading", - url="https://github.com/kubeflow/kubeflow/issues/42", - labels="kind/bug, area/kserve", - state="open", - created_at="2026-01-15", - updated_at="2026-01-20", - body=("The model fails to load when using GPU. I've tried multiple configurations\nbut keep getting OOM errors."), - comments=[ - { - "author": "alice", - "created_at": "2026-01-16", - "body": "Have you tried setting memory limits in your InferenceService spec?", - }, - { - "author": "bob", - "created_at": "2026-01-17", - "body": "Fixed by upgrading KServe to v0.12. The GPU memory allocation was improved.", - }, - ], -) - - -class TestParseIssueMetadata: - """Tests for parse_issue_metadata (legacy markdown regex path).""" - - def test_extracts_title(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - assert meta["title"] == "KServe model not loading" - - def test_extracts_repo_name(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - assert meta["repo_name"] == "kubeflow/kubeflow" - - def test_extracts_issue_number(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - assert meta["issue_number"] == 42 - - def test_extracts_state(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - assert meta["issue_state"] == "open" - - def test_extracts_labels(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - assert meta["issue_labels"] == "kind/bug, area/kserve" - - def test_extracts_citation_url(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - assert meta["citation_url"] == "https://github.com/kubeflow/kubeflow/issues/42" - - def test_handles_empty_labels(self): - meta = parse_issue_metadata(NO_LABELS_CONTENT) - assert meta["issue_labels"] == "" - - def test_handles_closed_state(self): - meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) - assert meta["issue_state"] == "closed" - - def test_returns_zero_for_missing_number(self): - meta = parse_issue_metadata("No metadata here") - assert meta["issue_number"] == 0 - - def test_returns_empty_for_missing_fields(self): - meta = parse_issue_metadata("Just plain text") - assert meta["title"] == "" - assert meta["repo_name"] == "" - assert meta["citation_url"] == "" - - -class TestStructuredIssueRecord: - """Tests for machine-readable download_github_issues JSONL records.""" - - def test_build_issue_record_includes_markdown_and_structured_fields(self): - record = SAMPLE_STRUCTURED_RECORD - assert record["content"].startswith("# KServe model not loading") - assert "**Repository:** kubeflow/kubeflow" in record["content"] - assert record["title"] == "KServe model not loading" - assert record["repo_name"] == "kubeflow/kubeflow" - assert record["issue_number"] == 42 - assert record["issue_state"] == "open" - assert record["issue_labels"] == "kind/bug, area/kserve" - assert record["url"] == "https://github.com/kubeflow/kubeflow/issues/42" - assert record["body"].startswith("The model fails") - assert len(record["comments"]) == 2 - assert record["comments"][0]["author"] == "alice" - - def test_format_issue_markdown_matches_legacy_layout(self): - md = format_issue_markdown( - title="Typo in docs", - repo_name="kubeflow/website", - issue_number=99, - url="https://github.com/kubeflow/website/issues/99", - labels="kind/docs", - state="closed", - created_at="2026-02-01", - updated_at="2026-02-02", - body="There is a typo on the installation page.", - comments=[], - ) - assert md == SHORT_ISSUE_CONTENT - - def test_has_structured_metadata(self): - assert has_structured_metadata(SAMPLE_STRUCTURED_RECORD) is True - assert has_structured_metadata({"content": SAMPLE_ISSUE_CONTENT}) is False - - def test_resolve_prefers_structured_fields_over_markdown(self): - # Intentionally corrupt content; structured fields must win - record = dict(SAMPLE_STRUCTURED_RECORD) - record["content"] = "garbage that would fail regex parsing" - meta = resolve_issue_metadata(record) - assert meta["title"] == "KServe model not loading" - assert meta["issue_number"] == 42 - assert meta["citation_url"] == "https://github.com/kubeflow/kubeflow/issues/42" - - def test_resolve_falls_back_to_markdown_regex(self): - legacy = {"content": SAMPLE_ISSUE_CONTENT, "url": "ignored-when-parsed-from-md"} - meta = resolve_issue_metadata(legacy) - assert meta["title"] == "KServe model not loading" - assert meta["issue_number"] == 42 - assert meta["citation_url"] == "https://github.com/kubeflow/kubeflow/issues/42" - - def test_resolve_warns_when_legacy_parse_empty(self, capsys): - meta = resolve_issue_metadata({"content": "no metadata here"}) - assert meta["issue_number"] == 0 - captured = capsys.readouterr() - assert "WARNING: Failed to parse GitHub issue metadata" in captured.out - - def test_issue_content_segments_from_structured_comments(self): - segments = issue_content_segments(SAMPLE_STRUCTURED_RECORD) - assert len(segments) == 3 # header+body, comment1, comment2 - assert "KServe model not loading" in segments[0] - assert "Comment by @alice" in segments[1] - assert "Comment by @bob" in segments[2] - - def test_split_issue_record_uses_structured_segments(self): - meta = resolve_issue_metadata(SAMPLE_STRUCTURED_RECORD) - prefix = build_metadata_prefix(meta) - chunks = split_issue_record_into_chunks(SAMPLE_STRUCTURED_RECORD, prefix, chunk_size=300) - assert len(chunks) > 1 - for chunk in chunks: - assert chunk.startswith("[Issue #42]") - - -class TestBuildMetadataPrefix: - """Tests for build_metadata_prefix.""" - - def test_includes_issue_number_and_title(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - assert "[Issue #42]" in prefix - assert "KServe model not loading" in prefix - - def test_includes_repo(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - assert "Repo: kubeflow/kubeflow" in prefix - - def test_includes_state(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - assert "State: open" in prefix - - def test_includes_labels(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - assert "Labels: kind/bug, area/kserve" in prefix - - def test_omits_empty_labels(self): - meta = parse_issue_metadata(NO_LABELS_CONTENT) - prefix = build_metadata_prefix(meta) - assert "Labels:" not in prefix - - def test_ends_with_double_newline(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - assert prefix.endswith("\n\n") - - -class TestSplitIssueIntoChunks: - """Tests for split_issue_into_chunks.""" - - def test_short_issue_single_chunk(self): - meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - chunks = split_issue_into_chunks(SHORT_ISSUE_CONTENT, prefix, chunk_size=2000) - assert len(chunks) == 1 - - def test_short_issue_has_prefix(self): - meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - chunks = split_issue_into_chunks(SHORT_ISSUE_CONTENT, prefix, chunk_size=2000) - assert chunks[0].startswith("[Issue #99]") - - def test_long_issue_splits_at_comment_boundaries(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - # Use a small chunk_size to force splitting - chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=300) - assert len(chunks) > 1 - - def test_every_chunk_has_prefix(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=300) - for chunk in chunks: - assert chunk.startswith("[Issue #42]") - - def test_no_empty_chunks(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=300) - for chunk in chunks: - # Each chunk should have content beyond just the prefix - assert len(chunk) > len(prefix) - - def test_issue_with_no_comments(self): - meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - chunks = split_issue_into_chunks(SHORT_ISSUE_CONTENT, prefix, chunk_size=2000) - assert len(chunks) == 1 - assert "typo" in chunks[0].lower() - - def test_respects_chunk_size(self): - meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) - prefix = build_metadata_prefix(meta) - chunk_size = 400 - chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=chunk_size) - for chunk in chunks: - # Allow some tolerance for the text splitter - assert len(chunk) <= chunk_size + 100 - - def test_handles_empty_content(self): - chunks = split_issue_into_chunks("", "prefix\n\n", chunk_size=1000) - assert len(chunks) == 1 +"""Tests for GitHub issues pipeline utilities (kagent-feast-mcp/pipelines/issues_utils.py). + +Tests the pure-Python metadata parsing and chunking logic extracted +from the chunk_and_embed_issues KFP component for testability. +""" + +import sys +from pathlib import Path + +PIPELINES_DIR = Path(__file__).parent.parent / "kagent-feast-mcp" / "pipelines" +sys.path.insert(0, str(PIPELINES_DIR)) + +from issues_utils import ( + build_issue_record, + build_metadata_prefix, + format_issue_markdown, + has_structured_metadata, + issue_content_segments, + parse_issue_metadata, + resolve_issue_metadata, + split_issue_into_chunks, + split_issue_record_into_chunks, +) + + +# --- Sample content fixtures --- + +SAMPLE_ISSUE_CONTENT = """# KServe model not loading + +**Repository:** kubeflow/kubeflow +**Issue:** #42 +**URL:** https://github.com/kubeflow/kubeflow/issues/42 +**Labels:** kind/bug, area/kserve +**State:** open +**Created:** 2026-01-15 +**Updated:** 2026-01-20 + +The model fails to load when using GPU. I've tried multiple configurations +but keep getting OOM errors. + +--- +**Comment by @alice** (2026-01-16): +Have you tried setting memory limits in your InferenceService spec? + +--- +**Comment by @bob** (2026-01-17): +Fixed by upgrading KServe to v0.12. The GPU memory allocation was improved.""" + +SHORT_ISSUE_CONTENT = """# Typo in docs + +**Repository:** kubeflow/website +**Issue:** #99 +**URL:** https://github.com/kubeflow/website/issues/99 +**Labels:** kind/docs +**State:** closed +**Created:** 2026-02-01 +**Updated:** 2026-02-02 + +There is a typo on the installation page.""" + +NO_LABELS_CONTENT = """# Feature request + +**Repository:** kubeflow/pipelines +**Issue:** #500 +**URL:** https://github.com/kubeflow/pipelines/issues/500 +**Labels:** +**State:** open +**Created:** 2026-03-01 +**Updated:** 2026-03-10 + +Please add support for caching.""" + +SAMPLE_STRUCTURED_RECORD = build_issue_record( + repo_name="kubeflow/kubeflow", + repo_short_name="kubeflow", + issue_number=42, + title="KServe model not loading", + url="https://github.com/kubeflow/kubeflow/issues/42", + labels="kind/bug, area/kserve", + state="open", + created_at="2026-01-15", + updated_at="2026-01-20", + body=("The model fails to load when using GPU. I've tried multiple configurations\nbut keep getting OOM errors."), + comments=[ + { + "author": "alice", + "created_at": "2026-01-16", + "body": "Have you tried setting memory limits in your InferenceService spec?", + }, + { + "author": "bob", + "created_at": "2026-01-17", + "body": "Fixed by upgrading KServe to v0.12. The GPU memory allocation was improved.", + }, + ], +) + + +class TestParseIssueMetadata: + """Tests for parse_issue_metadata (legacy markdown regex path).""" + + def test_extracts_title(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + assert meta["title"] == "KServe model not loading" + + def test_extracts_repo_name(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + assert meta["repo_name"] == "kubeflow/kubeflow" + + def test_extracts_issue_number(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + assert meta["issue_number"] == 42 + + def test_extracts_state(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + assert meta["issue_state"] == "open" + + def test_extracts_labels(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + assert meta["issue_labels"] == "kind/bug, area/kserve" + + def test_extracts_citation_url(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + assert meta["citation_url"] == "https://github.com/kubeflow/kubeflow/issues/42" + + def test_handles_empty_labels(self): + meta = parse_issue_metadata(NO_LABELS_CONTENT) + assert meta["issue_labels"] == "" + + def test_handles_closed_state(self): + meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) + assert meta["issue_state"] == "closed" + + def test_returns_zero_for_missing_number(self): + meta = parse_issue_metadata("No metadata here") + assert meta["issue_number"] == 0 + + def test_returns_empty_for_missing_fields(self): + meta = parse_issue_metadata("Just plain text") + assert meta["title"] == "" + assert meta["repo_name"] == "" + assert meta["citation_url"] == "" + + +class TestStructuredIssueRecord: + """Tests for machine-readable download_github_issues JSONL records.""" + + def test_build_issue_record_includes_markdown_and_structured_fields(self): + record = SAMPLE_STRUCTURED_RECORD + assert record["content"].startswith("# KServe model not loading") + assert "**Repository:** kubeflow/kubeflow" in record["content"] + assert record["title"] == "KServe model not loading" + assert record["repo_name"] == "kubeflow/kubeflow" + assert record["issue_number"] == 42 + assert record["issue_state"] == "open" + assert record["issue_labels"] == "kind/bug, area/kserve" + assert record["url"] == "https://github.com/kubeflow/kubeflow/issues/42" + assert record["body"].startswith("The model fails") + assert len(record["comments"]) == 2 + assert record["comments"][0]["author"] == "alice" + + def test_format_issue_markdown_matches_legacy_layout(self): + md = format_issue_markdown( + title="Typo in docs", + repo_name="kubeflow/website", + issue_number=99, + url="https://github.com/kubeflow/website/issues/99", + labels="kind/docs", + state="closed", + created_at="2026-02-01", + updated_at="2026-02-02", + body="There is a typo on the installation page.", + comments=[], + ) + assert md == SHORT_ISSUE_CONTENT + + def test_has_structured_metadata(self): + assert has_structured_metadata(SAMPLE_STRUCTURED_RECORD) is True + assert has_structured_metadata({"content": SAMPLE_ISSUE_CONTENT}) is False + + def test_resolve_prefers_structured_fields_over_markdown(self): + # Intentionally corrupt content; structured fields must win + record = dict(SAMPLE_STRUCTURED_RECORD) + record["content"] = "garbage that would fail regex parsing" + meta = resolve_issue_metadata(record) + assert meta["title"] == "KServe model not loading" + assert meta["issue_number"] == 42 + assert meta["citation_url"] == "https://github.com/kubeflow/kubeflow/issues/42" + + def test_resolve_falls_back_to_markdown_regex(self): + legacy = {"content": SAMPLE_ISSUE_CONTENT, "url": "ignored-when-parsed-from-md"} + meta = resolve_issue_metadata(legacy) + assert meta["title"] == "KServe model not loading" + assert meta["issue_number"] == 42 + assert meta["citation_url"] == "https://github.com/kubeflow/kubeflow/issues/42" + + def test_resolve_warns_when_legacy_parse_empty(self, capsys): + meta = resolve_issue_metadata({"content": "no metadata here"}) + assert meta["issue_number"] == 0 + captured = capsys.readouterr() + assert "WARNING: Failed to parse GitHub issue metadata" in captured.out + + def test_issue_content_segments_from_structured_comments(self): + segments = issue_content_segments(SAMPLE_STRUCTURED_RECORD) + assert len(segments) == 3 # header+body, comment1, comment2 + assert "KServe model not loading" in segments[0] + assert "Comment by @alice" in segments[1] + assert "Comment by @bob" in segments[2] + + def test_split_issue_record_uses_structured_segments(self): + meta = resolve_issue_metadata(SAMPLE_STRUCTURED_RECORD) + prefix = build_metadata_prefix(meta) + chunks = split_issue_record_into_chunks(SAMPLE_STRUCTURED_RECORD, prefix, chunk_size=300) + assert len(chunks) > 1 + for chunk in chunks: + assert chunk.startswith("[Issue #42]") + + +class TestBuildMetadataPrefix: + """Tests for build_metadata_prefix.""" + + def test_includes_issue_number_and_title(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + assert "[Issue #42]" in prefix + assert "KServe model not loading" in prefix + + def test_includes_repo(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + assert "Repo: kubeflow/kubeflow" in prefix + + def test_includes_state(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + assert "State: open" in prefix + + def test_includes_labels(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + assert "Labels: kind/bug, area/kserve" in prefix + + def test_omits_empty_labels(self): + meta = parse_issue_metadata(NO_LABELS_CONTENT) + prefix = build_metadata_prefix(meta) + assert "Labels:" not in prefix + + def test_ends_with_double_newline(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + assert prefix.endswith("\n\n") + + +class TestSplitIssueIntoChunks: + """Tests for split_issue_into_chunks.""" + + def test_short_issue_single_chunk(self): + meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + chunks = split_issue_into_chunks(SHORT_ISSUE_CONTENT, prefix, chunk_size=2000) + assert len(chunks) == 1 + + def test_short_issue_has_prefix(self): + meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + chunks = split_issue_into_chunks(SHORT_ISSUE_CONTENT, prefix, chunk_size=2000) + assert chunks[0].startswith("[Issue #99]") + + def test_long_issue_splits_at_comment_boundaries(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + # Use a small chunk_size to force splitting + chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=300) + assert len(chunks) > 1 + + def test_every_chunk_has_prefix(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=300) + for chunk in chunks: + assert chunk.startswith("[Issue #42]") + + def test_no_empty_chunks(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=300) + for chunk in chunks: + # Each chunk should have content beyond just the prefix + assert len(chunk) > len(prefix) + + def test_issue_with_no_comments(self): + meta = parse_issue_metadata(SHORT_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + chunks = split_issue_into_chunks(SHORT_ISSUE_CONTENT, prefix, chunk_size=2000) + assert len(chunks) == 1 + assert "typo" in chunks[0].lower() + + def test_respects_chunk_size(self): + meta = parse_issue_metadata(SAMPLE_ISSUE_CONTENT) + prefix = build_metadata_prefix(meta) + chunk_size = 400 + chunks = split_issue_into_chunks(SAMPLE_ISSUE_CONTENT, prefix, chunk_size=chunk_size) + for chunk in chunks: + # Allow some tolerance for the text splitter + assert len(chunk) <= chunk_size + 100 + + def test_handles_empty_content(self): + chunks = split_issue_into_chunks("", "prefix\n\n", chunk_size=1000) + assert len(chunks) == 1 diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 8fa2af1..c32a5bc 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1,494 +1,494 @@ -"""Tests for the MCP server (docs-agent-mcp/mcp-server/server.py). - -Mocks pymilvus and embeddings HTTP calls — no in-process sentence-transformers. -""" - -import sys -import importlib.util -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -MCP_SERVER_DIR = Path(__file__).parent.parent / "docs-agent-mcp" / "mcp-server" -MCP_SERVER_PATH = MCP_SERVER_DIR / "server.py" - -sys.modules.setdefault("pymilvus", MagicMock()) - -sys.path.insert(0, str(MCP_SERVER_DIR)) -spec = importlib.util.spec_from_file_location("docs_agent_mcp_server", MCP_SERVER_PATH) -server = importlib.util.module_from_spec(spec) -sys.modules["docs_agent_mcp_server"] = server -spec.loader.exec_module(server) - - -@pytest.fixture(autouse=True) -def reset_server_globals(): - """Reset server globals before each test so state doesn't leak.""" - original_client = server.client - original_password = server.MILVUS_PASSWORD - server.MILVUS_PASSWORD = "test-password" - yield - server.client = original_client - server.MILVUS_PASSWORD = original_password - - -@pytest.fixture -def inject_mocks(mock_milvus_client): - """Inject mock Milvus client and fixed query embedding.""" - server.client = mock_milvus_client - fake_vector = [0.0] * 768 - with patch.object(server, "embed_query", return_value=fake_vector) as embed_mock: - yield mock_milvus_client, embed_mock - - -class TestInit: - """Tests for the _init() lazy initialization function.""" - - def test_init_requires_milvus_password(self): - server.client = None - server.MILVUS_PASSWORD = "" - with pytest.raises(RuntimeError, match="MILVUS_PASSWORD"): - server._init() - - def test_init_creates_client_when_none(self): - server.client = None - server.MILVUS_PASSWORD = "secret" - mock_mc_class = MagicMock(return_value=MagicMock()) - server.MilvusClient = mock_mc_class - - server._init() - - mock_mc_class.assert_called_once_with( - uri=server.MILVUS_URI, - user=server.MILVUS_USER, - password="secret", - ) - - def test_init_is_idempotent(self): - server.client = None - server.MILVUS_PASSWORD = "secret" - mock_mc_class = MagicMock(return_value=MagicMock()) - server.MilvusClient = mock_mc_class - - server._init() - server._init() - - mock_mc_class.assert_called_once() - - -class TestSearchKubeflowDocs: - """Tests for the search_kubeflow_docs MCP tool.""" - - def test_returns_no_results_message_when_empty(self, inject_mocks): - """Should return 'No results found' when Milvus returns empty.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - result = server.search_kubeflow_docs("test query") - - assert result == "No results found for your query." - - def test_returns_formatted_results(self, inject_mocks, sample_milvus_hits): - """Should return markdown-formatted results with scores and citations.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_milvus_hits - - result = server.search_kubeflow_docs("KServe") - - assert "Result 1" in result - assert "Result 2" in result - assert "0.9234" in result - assert "https://www.kubeflow.org/docs/kserve/" in result - assert "KServe provides serverless inference" in result - - def test_includes_file_path_in_results(self, inject_mocks, sample_milvus_hits): - """Result should include the file path from Milvus.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_milvus_hits - - result = server.search_kubeflow_docs("KServe") - - assert "content/en/docs/kserve/overview.md" in result - - def test_respects_top_k_parameter(self, inject_mocks): - """top_k should be passed through to Milvus client.search limit.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_docs("test", top_k=3) - - assert mock_client.search.call_args.kwargs["limit"] == 3 - - def test_calls_embeddings_service_for_query(self, inject_mocks): - mock_client, embed_mock = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_docs("KServe setup guide") - - embed_mock.assert_called_once() - assert embed_mock.call_args[0][0] == "KServe setup guide" - - def test_passes_embedding_to_milvus(self, inject_mocks): - mock_client, embed_mock = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_docs("test") - - data = mock_client.search.call_args.kwargs["data"] - assert len(data) == 1 - assert len(data[0]) == 768 - - def test_requests_correct_output_fields(self, inject_mocks): - """Should request content_text, citation_url, and file_path from Milvus.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_docs("test") - - output_fields = mock_client.search.call_args.kwargs["output_fields"] - assert "content_text" in output_fields - assert "citation_url" in output_fields - assert "file_path" in output_fields - - def test_searches_correct_collection(self, inject_mocks): - """Should search the configured COLLECTION_NAME.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_docs("test") - - assert mock_client.search.call_args.kwargs["collection_name"] == server.COLLECTION_NAME - - def test_handles_missing_entity_fields_gracefully(self, inject_mocks): - """Should handle results where entity fields are missing without crashing.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [ - [ - { - "id": 1, - "distance": 0.5, - "entity": {}, # no fields - } - ] - ] - - result = server.search_kubeflow_docs("test") - - assert "Result 1" in result - assert "0.5000" in result - - def test_results_separated_by_divider(self, inject_mocks, sample_milvus_hits): - """Multiple results should be separated by --- dividers.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_milvus_hits - - result = server.search_kubeflow_docs("test") - - assert "\n---\n" in result - - def test_default_top_k_is_five(self, inject_mocks): - """Default top_k should be 5 when not specified.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_docs("test") - - assert mock_client.search.call_args.kwargs["limit"] == 5 - - -class TestSearchCollection: - """Tests for the _search_collection shared helper.""" - - def test_returns_empty_list_when_no_results(self, inject_mocks): - """Should return empty list when Milvus returns no hits.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - result = server._search_collection( - collection_name="test_col", - query="test", - top_k=5, - output_fields=["content_text", "citation_url"], - ) - assert result == [] - - def test_passes_filter_expr_to_milvus(self, inject_mocks): - """Should pass filter expression to Milvus search when provided.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server._search_collection( - collection_name="test_col", - query="test", - top_k=5, - output_fields=["content_text", "citation_url"], - filter_expr='repo_name == "kubeflow/kubeflow"', - ) - - assert mock_client.search.call_args.kwargs["filter"] == 'repo_name == "kubeflow/kubeflow"' - - def test_omits_filter_when_empty(self, inject_mocks): - """Should not include filter key when filter_expr is empty.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server._search_collection( - collection_name="test_col", - query="test", - top_k=5, - output_fields=["content_text", "citation_url"], - filter_expr="", - ) - - assert "filter" not in mock_client.search.call_args.kwargs - - def test_returns_raw_hits_with_entity_data(self, inject_mocks): - """Should return raw Milvus hits with entity data intact.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [ - [ - { - "id": 1, - "distance": 0.9, - "entity": { - "content_text": "Test content", - "citation_url": "https://example.com", - "issue_number": 42, - }, - } - ] - ] - - result = server._search_collection( - collection_name="test_col", - query="test", - top_k=5, - output_fields=["content_text", "citation_url", "issue_number"], - ) - - assert len(result) == 1 - assert result[0]["entity"]["issue_number"] == 42 - assert result[0]["entity"]["content_text"] == "Test content" - assert result[0]["distance"] == 0.9 - - -class TestSearchGithubIssues: - """Tests for the search_github_issues MCP tool.""" - - def test_returns_no_results_when_empty(self, inject_mocks): - """Should return 'No issues found' when no issues match.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - result = server.search_github_issues("GPU OOM error") - assert result == "No issues found for your query." - - def test_returns_formatted_results(self, inject_mocks, sample_issues_milvus_hits): - """Should return formatted results with issue-specific fields.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_issues_milvus_hits - - result = server.search_github_issues("KServe model loading") - - assert "Result 1" in result - assert "0.8912" in result - assert "github.com/kubeflow/kubeflow/issues/42" in result - assert "KServe model not loading" in result - - def test_includes_issue_number(self, inject_mocks, sample_issues_milvus_hits): - """Should include issue number in formatted output.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_issues_milvus_hits - - result = server.search_github_issues("test") - assert "**Issue:** #42" in result - - def test_includes_issue_labels(self, inject_mocks, sample_issues_milvus_hits): - """Should include issue_labels in formatted output.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_issues_milvus_hits - - result = server.search_github_issues("test") - assert "kind/bug, area/kserve" in result - - def test_filters_by_repo(self, inject_mocks): - """Should construct repo filter expression.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_github_issues("test", repo="kubeflow/pipelines") - - filter_val = mock_client.search.call_args.kwargs.get("filter", "") - assert 'repo_name == "kubeflow/pipelines"' in filter_val - - def test_filters_by_state(self, inject_mocks): - """Should construct state filter expression.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_github_issues("test", state="open") - - filter_val = mock_client.search.call_args.kwargs.get("filter", "") - assert 'issue_state == "open"' in filter_val - - def test_filters_by_repo_and_state(self, inject_mocks): - """Should combine repo and state filters with 'and'.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_github_issues("test", repo="kubeflow/kubeflow", state="closed") - - filter_val = mock_client.search.call_args.kwargs["filter"] - assert "repo_name" in filter_val - assert "issue_state" in filter_val - assert " and " in filter_val - - def test_no_filter_when_params_empty(self, inject_mocks): - """Should not include filter when repo and state are empty.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_github_issues("test") - - assert "filter" not in mock_client.search.call_args.kwargs - - def test_searches_issues_collection(self, inject_mocks): - """Should search the ISSUES_COLLECTION_NAME.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_github_issues("test") - - assert mock_client.search.call_args.kwargs["collection_name"] == server.ISSUES_COLLECTION_NAME - - def test_default_top_k_is_five(self, inject_mocks): - """Default top_k should be 5.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_github_issues("test") - - assert mock_client.search.call_args.kwargs["limit"] == 5 - - @pytest.mark.parametrize( - ("field_name", "kwargs"), - [ - ("repo", {"repo": 'kubeflow/pipelines" or issue_state == "open'}), - ("state", {"state": 'open" or repo_name == "kubeflow/kubeflow'}), - ], - ) - def test_rejects_unsafe_filter_values(self, inject_mocks, field_name, kwargs): - """User-controlled issue filters should not be interpolated unchecked.""" - mock_client, _ = inject_mocks - - with pytest.raises(ValueError, match=f"Invalid {field_name} filter value"): - server.search_github_issues("test", **kwargs) - - mock_client.search.assert_not_called() - - -class TestSearchKubeflowCode: - """Tests for the search_kubeflow_code MCP tool.""" - - def test_returns_no_results_when_empty(self, inject_mocks): - """Should return 'No code results found' when code search is empty.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - result = server.search_kubeflow_code("deployment") - - assert result == "No code results found for your query." - - def test_returns_formatted_code_results(self, inject_mocks, sample_code_milvus_hits): - """Should return code results with resource metadata and fenced content.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_code_milvus_hits - - result = server.search_kubeflow_code("pipeline deployment") - - assert "### Result 1 (score: 0.8123)" in result - assert "https://github.com/kubeflow/manifests/blob/main/apps/pipeline/deployment.yaml" in result - assert "**File:** apps/pipeline/deployment.yaml" in result - assert "**Resource:** Deployment `ml-pipeline` (namespace: kubeflow)" in result - assert "**Type:** yaml" in result - assert "```\napiVersion: apps/v1\nkind: Deployment" in result - - def test_results_separated_by_divider(self, inject_mocks, sample_code_milvus_hits): - """Multiple code results should be separated by markdown dividers.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = sample_code_milvus_hits - - result = server.search_kubeflow_code("test") - - assert "\n---\n" in result - - def test_searches_code_collection(self, inject_mocks): - """Should search the CODE_COLLECTION_NAME.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_code("test") - - assert mock_client.search.call_args.kwargs["collection_name"] == server.CODE_COLLECTION_NAME - - def test_default_top_k_is_five(self, inject_mocks): - """Default top_k should be 5.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_code("test") - - assert mock_client.search.call_args.kwargs["limit"] == 5 - - def test_respects_top_k_parameter(self, inject_mocks): - """top_k should be passed through to Milvus client.search limit.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_code("test", top_k=2) - - assert mock_client.search.call_args.kwargs["limit"] == 2 - - def test_requests_code_output_fields(self, inject_mocks): - """Should request code-specific output fields from Milvus.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_code("test") - - output_fields = mock_client.search.call_args.kwargs["output_fields"] - assert "content_text" in output_fields - assert "citation_url" in output_fields - assert "file_path" in output_fields - assert "resource_kind" in output_fields - assert "resource_name" in output_fields - assert "resource_namespace" in output_fields - assert "file_type" in output_fields - - def test_filters_by_resource_kind(self, inject_mocks): - """Should construct a resource_kind filter expression.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_code("test", resource_kind="Deployment") - - assert mock_client.search.call_args.kwargs["filter"] == "resource_kind == 'Deployment'" - - def test_no_filter_when_resource_kind_empty(self, inject_mocks): - """Should not include filter when resource_kind is empty.""" - mock_client, _ = inject_mocks - mock_client.search.return_value = [[]] - - server.search_kubeflow_code("test") - - assert "filter" not in mock_client.search.call_args.kwargs - - def test_rejects_unsafe_resource_kind_filter(self, inject_mocks): - """resource_kind should not allow expression injection.""" - mock_client, _ = inject_mocks - - with pytest.raises(ValueError, match="Invalid resource_kind filter value"): - server.search_kubeflow_code("test", resource_kind="Deployment' or file_type == 'python") - - mock_client.search.assert_not_called() +"""Tests for the MCP server (kagent-feast-mcp/mcp-server/server.py). + +Mocks pymilvus and embeddings HTTP calls — no in-process sentence-transformers. +""" + +import sys +import importlib.util +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +MCP_SERVER_DIR = Path(__file__).parent.parent / "kagent-feast-mcp" / "mcp-server" +MCP_SERVER_PATH = MCP_SERVER_DIR / "server.py" + +sys.modules.setdefault("pymilvus", MagicMock()) + +sys.path.insert(0, str(MCP_SERVER_DIR)) +spec = importlib.util.spec_from_file_location("docs_agent_mcp_server", MCP_SERVER_PATH) +server = importlib.util.module_from_spec(spec) +sys.modules["docs_agent_mcp_server"] = server +spec.loader.exec_module(server) + + +@pytest.fixture(autouse=True) +def reset_server_globals(): + """Reset server globals before each test so state doesn't leak.""" + original_client = server.client + original_password = server.MILVUS_PASSWORD + server.MILVUS_PASSWORD = "test-password" + yield + server.client = original_client + server.MILVUS_PASSWORD = original_password + + +@pytest.fixture +def inject_mocks(mock_milvus_client): + """Inject mock Milvus client and fixed query embedding.""" + server.client = mock_milvus_client + fake_vector = [0.0] * 768 + with patch.object(server, "embed_query", return_value=fake_vector) as embed_mock: + yield mock_milvus_client, embed_mock + + +class TestInit: + """Tests for the _init() lazy initialization function.""" + + def test_init_requires_milvus_password(self): + server.client = None + server.MILVUS_PASSWORD = "" + with pytest.raises(RuntimeError, match="MILVUS_PASSWORD"): + server._init() + + def test_init_creates_client_when_none(self): + server.client = None + server.MILVUS_PASSWORD = "secret" + mock_mc_class = MagicMock(return_value=MagicMock()) + server.MilvusClient = mock_mc_class + + server._init() + + mock_mc_class.assert_called_once_with( + uri=server.MILVUS_URI, + user=server.MILVUS_USER, + password="secret", + ) + + def test_init_is_idempotent(self): + server.client = None + server.MILVUS_PASSWORD = "secret" + mock_mc_class = MagicMock(return_value=MagicMock()) + server.MilvusClient = mock_mc_class + + server._init() + server._init() + + mock_mc_class.assert_called_once() + + +class TestSearchKubeflowDocs: + """Tests for the search_kubeflow_docs MCP tool.""" + + def test_returns_no_results_message_when_empty(self, inject_mocks): + """Should return 'No results found' when Milvus returns empty.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + result = server.search_kubeflow_docs("test query") + + assert result == "No results found for your query." + + def test_returns_formatted_results(self, inject_mocks, sample_milvus_hits): + """Should return markdown-formatted results with scores and citations.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_milvus_hits + + result = server.search_kubeflow_docs("KServe") + + assert "Result 1" in result + assert "Result 2" in result + assert "0.9234" in result + assert "https://www.kubeflow.org/docs/kserve/" in result + assert "KServe provides serverless inference" in result + + def test_includes_file_path_in_results(self, inject_mocks, sample_milvus_hits): + """Result should include the file path from Milvus.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_milvus_hits + + result = server.search_kubeflow_docs("KServe") + + assert "content/en/docs/kserve/overview.md" in result + + def test_respects_top_k_parameter(self, inject_mocks): + """top_k should be passed through to Milvus client.search limit.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_docs("test", top_k=3) + + assert mock_client.search.call_args.kwargs["limit"] == 3 + + def test_calls_embeddings_service_for_query(self, inject_mocks): + mock_client, embed_mock = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_docs("KServe setup guide") + + embed_mock.assert_called_once() + assert embed_mock.call_args[0][0] == "KServe setup guide" + + def test_passes_embedding_to_milvus(self, inject_mocks): + mock_client, embed_mock = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_docs("test") + + data = mock_client.search.call_args.kwargs["data"] + assert len(data) == 1 + assert len(data[0]) == 768 + + def test_requests_correct_output_fields(self, inject_mocks): + """Should request content_text, citation_url, and file_path from Milvus.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_docs("test") + + output_fields = mock_client.search.call_args.kwargs["output_fields"] + assert "content_text" in output_fields + assert "citation_url" in output_fields + assert "file_path" in output_fields + + def test_searches_correct_collection(self, inject_mocks): + """Should search the configured COLLECTION_NAME.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_docs("test") + + assert mock_client.search.call_args.kwargs["collection_name"] == server.COLLECTION_NAME + + def test_handles_missing_entity_fields_gracefully(self, inject_mocks): + """Should handle results where entity fields are missing without crashing.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [ + [ + { + "id": 1, + "distance": 0.5, + "entity": {}, # no fields + } + ] + ] + + result = server.search_kubeflow_docs("test") + + assert "Result 1" in result + assert "0.5000" in result + + def test_results_separated_by_divider(self, inject_mocks, sample_milvus_hits): + """Multiple results should be separated by --- dividers.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_milvus_hits + + result = server.search_kubeflow_docs("test") + + assert "\n---\n" in result + + def test_default_top_k_is_five(self, inject_mocks): + """Default top_k should be 5 when not specified.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_docs("test") + + assert mock_client.search.call_args.kwargs["limit"] == 5 + + +class TestSearchCollection: + """Tests for the _search_collection shared helper.""" + + def test_returns_empty_list_when_no_results(self, inject_mocks): + """Should return empty list when Milvus returns no hits.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + result = server._search_collection( + collection_name="test_col", + query="test", + top_k=5, + output_fields=["content_text", "citation_url"], + ) + assert result == [] + + def test_passes_filter_expr_to_milvus(self, inject_mocks): + """Should pass filter expression to Milvus search when provided.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server._search_collection( + collection_name="test_col", + query="test", + top_k=5, + output_fields=["content_text", "citation_url"], + filter_expr='repo_name == "kubeflow/kubeflow"', + ) + + assert mock_client.search.call_args.kwargs["filter"] == 'repo_name == "kubeflow/kubeflow"' + + def test_omits_filter_when_empty(self, inject_mocks): + """Should not include filter key when filter_expr is empty.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server._search_collection( + collection_name="test_col", + query="test", + top_k=5, + output_fields=["content_text", "citation_url"], + filter_expr="", + ) + + assert "filter" not in mock_client.search.call_args.kwargs + + def test_returns_raw_hits_with_entity_data(self, inject_mocks): + """Should return raw Milvus hits with entity data intact.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [ + [ + { + "id": 1, + "distance": 0.9, + "entity": { + "content_text": "Test content", + "citation_url": "https://example.com", + "issue_number": 42, + }, + } + ] + ] + + result = server._search_collection( + collection_name="test_col", + query="test", + top_k=5, + output_fields=["content_text", "citation_url", "issue_number"], + ) + + assert len(result) == 1 + assert result[0]["entity"]["issue_number"] == 42 + assert result[0]["entity"]["content_text"] == "Test content" + assert result[0]["distance"] == 0.9 + + +class TestSearchGithubIssues: + """Tests for the search_github_issues MCP tool.""" + + def test_returns_no_results_when_empty(self, inject_mocks): + """Should return 'No issues found' when no issues match.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + result = server.search_github_issues("GPU OOM error") + assert result == "No issues found for your query." + + def test_returns_formatted_results(self, inject_mocks, sample_issues_milvus_hits): + """Should return formatted results with issue-specific fields.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_issues_milvus_hits + + result = server.search_github_issues("KServe model loading") + + assert "Result 1" in result + assert "0.8912" in result + assert "github.com/kubeflow/kubeflow/issues/42" in result + assert "KServe model not loading" in result + + def test_includes_issue_number(self, inject_mocks, sample_issues_milvus_hits): + """Should include issue number in formatted output.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_issues_milvus_hits + + result = server.search_github_issues("test") + assert "**Issue:** #42" in result + + def test_includes_issue_labels(self, inject_mocks, sample_issues_milvus_hits): + """Should include issue_labels in formatted output.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_issues_milvus_hits + + result = server.search_github_issues("test") + assert "kind/bug, area/kserve" in result + + def test_filters_by_repo(self, inject_mocks): + """Should construct repo filter expression.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_github_issues("test", repo="kubeflow/pipelines") + + filter_val = mock_client.search.call_args.kwargs.get("filter", "") + assert 'repo_name == "kubeflow/pipelines"' in filter_val + + def test_filters_by_state(self, inject_mocks): + """Should construct state filter expression.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_github_issues("test", state="open") + + filter_val = mock_client.search.call_args.kwargs.get("filter", "") + assert 'issue_state == "open"' in filter_val + + def test_filters_by_repo_and_state(self, inject_mocks): + """Should combine repo and state filters with 'and'.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_github_issues("test", repo="kubeflow/kubeflow", state="closed") + + filter_val = mock_client.search.call_args.kwargs["filter"] + assert "repo_name" in filter_val + assert "issue_state" in filter_val + assert " and " in filter_val + + def test_no_filter_when_params_empty(self, inject_mocks): + """Should not include filter when repo and state are empty.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_github_issues("test") + + assert "filter" not in mock_client.search.call_args.kwargs + + def test_searches_issues_collection(self, inject_mocks): + """Should search the ISSUES_COLLECTION_NAME.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_github_issues("test") + + assert mock_client.search.call_args.kwargs["collection_name"] == server.ISSUES_COLLECTION_NAME + + def test_default_top_k_is_five(self, inject_mocks): + """Default top_k should be 5.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_github_issues("test") + + assert mock_client.search.call_args.kwargs["limit"] == 5 + + @pytest.mark.parametrize( + ("field_name", "kwargs"), + [ + ("repo", {"repo": 'kubeflow/pipelines" or issue_state == "open'}), + ("state", {"state": 'open" or repo_name == "kubeflow/kubeflow'}), + ], + ) + def test_rejects_unsafe_filter_values(self, inject_mocks, field_name, kwargs): + """User-controlled issue filters should not be interpolated unchecked.""" + mock_client, _ = inject_mocks + + with pytest.raises(ValueError, match=f"Invalid {field_name} filter value"): + server.search_github_issues("test", **kwargs) + + mock_client.search.assert_not_called() + + +class TestSearchKubeflowCode: + """Tests for the search_kubeflow_code MCP tool.""" + + def test_returns_no_results_when_empty(self, inject_mocks): + """Should return 'No code results found' when code search is empty.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + result = server.search_kubeflow_code("deployment") + + assert result == "No code results found for your query." + + def test_returns_formatted_code_results(self, inject_mocks, sample_code_milvus_hits): + """Should return code results with resource metadata and fenced content.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_code_milvus_hits + + result = server.search_kubeflow_code("pipeline deployment") + + assert "### Result 1 (score: 0.8123)" in result + assert "https://github.com/kubeflow/manifests/blob/main/apps/pipeline/deployment.yaml" in result + assert "**File:** apps/pipeline/deployment.yaml" in result + assert "**Resource:** Deployment `ml-pipeline` (namespace: kubeflow)" in result + assert "**Type:** yaml" in result + assert "```\napiVersion: apps/v1\nkind: Deployment" in result + + def test_results_separated_by_divider(self, inject_mocks, sample_code_milvus_hits): + """Multiple code results should be separated by markdown dividers.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = sample_code_milvus_hits + + result = server.search_kubeflow_code("test") + + assert "\n---\n" in result + + def test_searches_code_collection(self, inject_mocks): + """Should search the CODE_COLLECTION_NAME.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_code("test") + + assert mock_client.search.call_args.kwargs["collection_name"] == server.CODE_COLLECTION_NAME + + def test_default_top_k_is_five(self, inject_mocks): + """Default top_k should be 5.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_code("test") + + assert mock_client.search.call_args.kwargs["limit"] == 5 + + def test_respects_top_k_parameter(self, inject_mocks): + """top_k should be passed through to Milvus client.search limit.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_code("test", top_k=2) + + assert mock_client.search.call_args.kwargs["limit"] == 2 + + def test_requests_code_output_fields(self, inject_mocks): + """Should request code-specific output fields from Milvus.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_code("test") + + output_fields = mock_client.search.call_args.kwargs["output_fields"] + assert "content_text" in output_fields + assert "citation_url" in output_fields + assert "file_path" in output_fields + assert "resource_kind" in output_fields + assert "resource_name" in output_fields + assert "resource_namespace" in output_fields + assert "file_type" in output_fields + + def test_filters_by_resource_kind(self, inject_mocks): + """Should construct a resource_kind filter expression.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_code("test", resource_kind="Deployment") + + assert mock_client.search.call_args.kwargs["filter"] == "resource_kind == 'Deployment'" + + def test_no_filter_when_resource_kind_empty(self, inject_mocks): + """Should not include filter when resource_kind is empty.""" + mock_client, _ = inject_mocks + mock_client.search.return_value = [[]] + + server.search_kubeflow_code("test") + + assert "filter" not in mock_client.search.call_args.kwargs + + def test_rejects_unsafe_resource_kind_filter(self, inject_mocks): + """resource_kind should not allow expression injection.""" + mock_client, _ = inject_mocks + + with pytest.raises(ValueError, match="Invalid resource_kind filter value"): + server.search_kubeflow_code("test", resource_kind="Deployment' or file_type == 'python") + + mock_client.search.assert_not_called() diff --git a/tests/test_mcp_smoke_tools.py b/tests/test_mcp_smoke_tools.py index 6f290d6..8c54c82 100644 --- a/tests/test_mcp_smoke_tools.py +++ b/tests/test_mcp_smoke_tools.py @@ -1,22 +1,22 @@ -"""Tests for MCP smoke helper (SSE parsing).""" - -import json -import sys -from pathlib import Path - -MCP_DIR = Path(__file__).parent.parent / "docs-agent-mcp" / "mcp-server" -sys.path.insert(0, str(MCP_DIR)) - -import smoke_tools # noqa: E402 - - -def test_parse_sse_json_from_data_lines(): - body = 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}}\n\n' - data = smoke_tools._parse_sse_json(body) - assert data["result"]["content"][0]["text"] == "ok" - - -def test_parse_plain_json_body(): - body = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}) - data = smoke_tools._parse_sse_json(body) - assert data["result"]["ok"] is True +"""Tests for MCP smoke helper (SSE parsing).""" + +import json +import sys +from pathlib import Path + +MCP_DIR = Path(__file__).parent.parent / "kagent-feast-mcp" / "mcp-server" +sys.path.insert(0, str(MCP_DIR)) + +import smoke_tools # noqa: E402 + + +def test_parse_sse_json_from_data_lines(): + body = 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}}\n\n' + data = smoke_tools._parse_sse_json(body) + assert data["result"]["content"][0]["text"] == "ok" + + +def test_parse_plain_json_body(): + body = json.dumps({"jsonrpc": "2.0", "id": 1, "result": {"ok": True}}) + data = smoke_tools._parse_sse_json(body) + assert data["result"]["ok"] is True diff --git a/tests/test_pipeline_utils.py b/tests/test_pipeline_utils.py index de73906..f5e6325 100644 --- a/tests/test_pipeline_utils.py +++ b/tests/test_pipeline_utils.py @@ -1,249 +1,249 @@ -"""Tests for pipeline utility functions and component logic.""" - -import sys -from pathlib import Path - -# Add pipelines directory to path -PIPELINES_DIR = Path(__file__).parent.parent / "docs-agent-mcp" / "pipelines" -sys.path.insert(0, str(PIPELINES_DIR)) - -from utils import clean_content, embed_texts, resolve_github_token, truncate_for_tei - - -class TestResolveGithubToken: - """Tests for resolve_github_token.""" - - def test_prefers_explicit_parameter(self, monkeypatch): - monkeypatch.setenv("Github_Pat", "env-pat") - monkeypatch.setenv("GITHUB_TOKEN", "fallback") - assert resolve_github_token("param-pat") == "param-pat" - - def test_falls_back_to_github_pat_env(self, monkeypatch): - monkeypatch.setenv("Github_Pat", "env-pat") - monkeypatch.setenv("GITHUB_TOKEN", "fallback") - assert resolve_github_token("") == "env-pat" - - def test_falls_back_to_github_token_env(self, monkeypatch): - monkeypatch.delenv("Github_Pat", raising=False) - monkeypatch.setenv("GITHUB_TOKEN", "fallback") - assert resolve_github_token("") == "fallback" - - def test_returns_empty_when_unset(self, monkeypatch): - monkeypatch.delenv("Github_Pat", raising=False) - monkeypatch.delenv("GITHUB_TOKEN", raising=False) - assert resolve_github_token("") == "" - assert resolve_github_token(" ") == "" - - -class TestCleanContent: - """Tests for the clean_content utility function.""" - - def test_removes_yaml_frontmatter(self): - """Should remove --- delimited YAML frontmatter.""" - content = "---\ntitle: Test\ndate: 2026-01-01\n---\n\nActual content here." - result = clean_content(content) - assert "title:" not in result - assert "Actual content here." in result - - def test_removes_toml_frontmatter(self): - """Should remove +++ delimited TOML frontmatter.""" - content = "+++\ntitle = 'Test'\n+++\n\nActual content here." - result = clean_content(content) - assert "title =" not in result - assert "Actual content here." in result - - def test_removes_hugo_template_syntax(self): - """Should remove {{ ... }} Hugo template expressions.""" - content = 'Install with {{ .Get "name" }} command. Then run it.' - result = clean_content(content) - assert "{{" not in result - assert "Install with" in result - assert "command" in result - - def test_removes_html_comments(self): - """Should remove HTML comments.""" - content = "Before After" - result = clean_content(content) - assert "this is a comment" not in result - assert "Before" in result - assert "After" in result - - def test_removes_html_tags(self): - """Should remove HTML tags but keep their text content.""" - content = "

Hello world

" - result = clean_content(content) - assert "" not in result - assert "Hello" in result - assert "world" in result - - def test_removes_navigation_artifacts(self): - """Should remove navigation/menu text artifacts.""" - content = "Get Started with Kubeflow. Home Menu Navigation overview." - result = clean_content(content) - assert "Get Started" not in result - assert "Menu" not in result - assert "Navigation" not in result - assert "Kubeflow" in result - - def test_removes_urls(self): - """Should remove HTTP/HTTPS URLs.""" - content = "Visit https://kubeflow.org/docs for more info." - result = clean_content(content) - assert "https://kubeflow.org" not in result - assert "Visit" in result - assert "for more info." in result - - def test_converts_markdown_links_to_text(self): - """Should convert [text](url) markdown links to just text.""" - content = "See [the documentation](https://kubeflow.org/docs) for details." - result = clean_content(content) - assert "the documentation" in result - assert "(https://kubeflow.org/docs)" not in result - - def test_normalizes_whitespace(self): - """Should collapse multiple spaces into single space.""" - content = "Hello world test" - result = clean_content(content) - assert " " not in result - assert "Hello world test" in result - - def test_strips_leading_trailing_whitespace(self): - """Should strip leading and trailing whitespace.""" - content = " Hello world " - result = clean_content(content) - assert result == "Hello world" - - def test_returns_empty_for_frontmatter_only(self): - """Content that is only frontmatter should return empty string.""" - content = "---\ntitle: Nothing\ndate: 2026-01-01\n---" - result = clean_content(content) - assert result == "" - - def test_preserves_meaningful_content(self): - """Should not remove meaningful documentation content.""" - content = ( - "Kubeflow Pipelines is a platform for building and deploying " - "portable, scalable ML workflows based on Docker containers." - ) - result = clean_content(content) - assert "Kubeflow Pipelines" in result - assert "ML workflows" in result - assert "Docker containers" in result - - def test_handles_multiline_html_comment(self): - """Should remove HTML comments that span multiple lines.""" - content = "Before\n\nAfter" - result = clean_content(content) - assert "multi-line comment" not in result - assert "Before" in result - assert "After" in result - - def test_handles_multiline_hugo_template(self): - """Should remove Hugo templates that span multiple lines.""" - content = "Before {{ if .IsHome }}\nstuff\n{{ end }} After" - result = clean_content(content) - assert "{{ if" not in result - assert "{{ end }}" not in result - - def test_real_world_kubeflow_doc_snippet(self): - """Test with a realistic Kubeflow documentation snippet.""" - content = """--- -title: KServe Overview -description: Overview of KServe -weight: 1 ---- - - - -
- -## What is KServe? - -[KServe](https://kserve.github.io/website/) enables serverless inference -on Kubernetes. Visit https://kubeflow.org/docs/external-add-ons/kserve/ -for more details. - -{{ partial "section-index.html" . }} - -
-""" - result = clean_content(content) - assert "title:" not in result - assert "auto-generated" not in result - assert "section-index" not in result - assert "KServe" in result - assert "serverless inference" in result - assert "Kubernetes" in result - - -class TestCleanContentEdgeCases: - """Edge cases for content cleaning.""" - - def test_empty_string(self): - """Should handle empty string input.""" - assert clean_content("") == "" - - def test_whitespace_only(self): - """Should handle whitespace-only input.""" - assert clean_content(" \n\n \t ") == "" - - def test_no_cleaning_needed(self): - """Plain text should pass through with minimal changes.""" - content = "Simple plain text with no special formatting." - result = clean_content(content) - assert result == content - - def test_nested_html_tags(self): - """Should handle deeply nested HTML tags.""" - content = "
Deep content
" - result = clean_content(content) - assert "Deep content" in result - assert "<" not in result - - -class TestTruncateForTei: - def test_truncates_long_text(self): - long = "word " * 500 - assert len(truncate_for_tei(long)) == 1000 - - -class TestEmbedTexts: - def test_batches_requests(self, monkeypatch): - calls = [] - - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return [[0.1, 0.2], [0.3, 0.4]] - - def fake_post(url, json, headers, timeout): - calls.append((url, len(json["inputs"]), [len(t) for t in json["inputs"]])) - return FakeResponse() - - monkeypatch.setattr("utils.requests.post", fake_post) - - vectors = embed_texts(["a", "b"], "http://embeddings/embed", batch_size=2) - assert len(vectors) == 2 - assert calls[0][1] == 2 - - def test_truncates_inputs_for_tei(self, monkeypatch): - sent = [] - - class FakeResponse: - def raise_for_status(self): - return None - - def json(self): - return [[0.1]] - - def fake_post(url, json, headers, timeout): - sent.extend(json["inputs"]) - return FakeResponse() - - monkeypatch.setattr("utils.requests.post", fake_post) - long = "x" * 5000 - embed_texts([long], "http://embeddings/embed", batch_size=1) - assert len(sent[0]) == 1000 +"""Tests for pipeline utility functions and component logic.""" + +import sys +from pathlib import Path + +# Add pipelines directory to path +PIPELINES_DIR = Path(__file__).parent.parent / "kagent-feast-mcp" / "pipelines" +sys.path.insert(0, str(PIPELINES_DIR)) + +from utils import clean_content, embed_texts, resolve_github_token, truncate_for_tei + + +class TestResolveGithubToken: + """Tests for resolve_github_token.""" + + def test_prefers_explicit_parameter(self, monkeypatch): + monkeypatch.setenv("Github_Pat", "env-pat") + monkeypatch.setenv("GITHUB_TOKEN", "fallback") + assert resolve_github_token("param-pat") == "param-pat" + + def test_falls_back_to_github_pat_env(self, monkeypatch): + monkeypatch.setenv("Github_Pat", "env-pat") + monkeypatch.setenv("GITHUB_TOKEN", "fallback") + assert resolve_github_token("") == "env-pat" + + def test_falls_back_to_github_token_env(self, monkeypatch): + monkeypatch.delenv("Github_Pat", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", "fallback") + assert resolve_github_token("") == "fallback" + + def test_returns_empty_when_unset(self, monkeypatch): + monkeypatch.delenv("Github_Pat", raising=False) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + assert resolve_github_token("") == "" + assert resolve_github_token(" ") == "" + + +class TestCleanContent: + """Tests for the clean_content utility function.""" + + def test_removes_yaml_frontmatter(self): + """Should remove --- delimited YAML frontmatter.""" + content = "---\ntitle: Test\ndate: 2026-01-01\n---\n\nActual content here." + result = clean_content(content) + assert "title:" not in result + assert "Actual content here." in result + + def test_removes_toml_frontmatter(self): + """Should remove +++ delimited TOML frontmatter.""" + content = "+++\ntitle = 'Test'\n+++\n\nActual content here." + result = clean_content(content) + assert "title =" not in result + assert "Actual content here." in result + + def test_removes_hugo_template_syntax(self): + """Should remove {{ ... }} Hugo template expressions.""" + content = 'Install with {{ .Get "name" }} command. Then run it.' + result = clean_content(content) + assert "{{" not in result + assert "Install with" in result + assert "command" in result + + def test_removes_html_comments(self): + """Should remove HTML comments.""" + content = "Before After" + result = clean_content(content) + assert "this is a comment" not in result + assert "Before" in result + assert "After" in result + + def test_removes_html_tags(self): + """Should remove HTML tags but keep their text content.""" + content = "

Hello world

" + result = clean_content(content) + assert "" not in result + assert "Hello" in result + assert "world" in result + + def test_removes_navigation_artifacts(self): + """Should remove navigation/menu text artifacts.""" + content = "Get Started with Kubeflow. Home Menu Navigation overview." + result = clean_content(content) + assert "Get Started" not in result + assert "Menu" not in result + assert "Navigation" not in result + assert "Kubeflow" in result + + def test_removes_urls(self): + """Should remove HTTP/HTTPS URLs.""" + content = "Visit https://kubeflow.org/docs for more info." + result = clean_content(content) + assert "https://kubeflow.org" not in result + assert "Visit" in result + assert "for more info." in result + + def test_converts_markdown_links_to_text(self): + """Should convert [text](url) markdown links to just text.""" + content = "See [the documentation](https://kubeflow.org/docs) for details." + result = clean_content(content) + assert "the documentation" in result + assert "(https://kubeflow.org/docs)" not in result + + def test_normalizes_whitespace(self): + """Should collapse multiple spaces into single space.""" + content = "Hello world test" + result = clean_content(content) + assert " " not in result + assert "Hello world test" in result + + def test_strips_leading_trailing_whitespace(self): + """Should strip leading and trailing whitespace.""" + content = " Hello world " + result = clean_content(content) + assert result == "Hello world" + + def test_returns_empty_for_frontmatter_only(self): + """Content that is only frontmatter should return empty string.""" + content = "---\ntitle: Nothing\ndate: 2026-01-01\n---" + result = clean_content(content) + assert result == "" + + def test_preserves_meaningful_content(self): + """Should not remove meaningful documentation content.""" + content = ( + "Kubeflow Pipelines is a platform for building and deploying " + "portable, scalable ML workflows based on Docker containers." + ) + result = clean_content(content) + assert "Kubeflow Pipelines" in result + assert "ML workflows" in result + assert "Docker containers" in result + + def test_handles_multiline_html_comment(self): + """Should remove HTML comments that span multiple lines.""" + content = "Before\n\nAfter" + result = clean_content(content) + assert "multi-line comment" not in result + assert "Before" in result + assert "After" in result + + def test_handles_multiline_hugo_template(self): + """Should remove Hugo templates that span multiple lines.""" + content = "Before {{ if .IsHome }}\nstuff\n{{ end }} After" + result = clean_content(content) + assert "{{ if" not in result + assert "{{ end }}" not in result + + def test_real_world_kubeflow_doc_snippet(self): + """Test with a realistic Kubeflow documentation snippet.""" + content = """--- +title: KServe Overview +description: Overview of KServe +weight: 1 +--- + + + +
+ +## What is KServe? + +[KServe](https://kserve.github.io/website/) enables serverless inference +on Kubernetes. Visit https://kubeflow.org/docs/external-add-ons/kserve/ +for more details. + +{{ partial "section-index.html" . }} + +
+""" + result = clean_content(content) + assert "title:" not in result + assert "auto-generated" not in result + assert "section-index" not in result + assert "KServe" in result + assert "serverless inference" in result + assert "Kubernetes" in result + + +class TestCleanContentEdgeCases: + """Edge cases for content cleaning.""" + + def test_empty_string(self): + """Should handle empty string input.""" + assert clean_content("") == "" + + def test_whitespace_only(self): + """Should handle whitespace-only input.""" + assert clean_content(" \n\n \t ") == "" + + def test_no_cleaning_needed(self): + """Plain text should pass through with minimal changes.""" + content = "Simple plain text with no special formatting." + result = clean_content(content) + assert result == content + + def test_nested_html_tags(self): + """Should handle deeply nested HTML tags.""" + content = "
Deep content
" + result = clean_content(content) + assert "Deep content" in result + assert "<" not in result + + +class TestTruncateForTei: + def test_truncates_long_text(self): + long = "word " * 500 + assert len(truncate_for_tei(long)) == 1000 + + +class TestEmbedTexts: + def test_batches_requests(self, monkeypatch): + calls = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return [[0.1, 0.2], [0.3, 0.4]] + + def fake_post(url, json, headers, timeout): + calls.append((url, len(json["inputs"]), [len(t) for t in json["inputs"]])) + return FakeResponse() + + monkeypatch.setattr("utils.requests.post", fake_post) + + vectors = embed_texts(["a", "b"], "http://embeddings/embed", batch_size=2) + assert len(vectors) == 2 + assert calls[0][1] == 2 + + def test_truncates_inputs_for_tei(self, monkeypatch): + sent = [] + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return [[0.1]] + + def fake_post(url, json, headers, timeout): + sent.extend(json["inputs"]) + return FakeResponse() + + monkeypatch.setattr("utils.requests.post", fake_post) + long = "x" * 5000 + embed_texts([long], "http://embeddings/embed", batch_size=1) + assert len(sent[0]) == 1000 diff --git a/tests/test_session_issuer.py b/tests/test_session_issuer.py index 22746ab..7f15000 100644 --- a/tests/test_session_issuer.py +++ b/tests/test_session_issuer.py @@ -1,140 +1,140 @@ -"""Tests for the session issuer core (docs-agent-mcp/session-issuer/issuer_core.py). - -Pure-unit: no FastAPI/uvicorn imports, no network. Verifies the JWT -mint -> JWKS -> verify roundtrip that Istio RequestAuthentication performs -at the ingress gateway. -""" - -import base64 -import importlib.util -import time -from pathlib import Path - -import jwt -import pytest - -ISSUER_DIR = Path(__file__).parent.parent / "docs-agent-mcp" / "session-issuer" -ISSUER_CORE_PATH = ISSUER_DIR / "issuer_core.py" - -spec = importlib.util.spec_from_file_location("issuer_core", ISSUER_CORE_PATH) -issuer_core = importlib.util.module_from_spec(spec) -spec.loader.exec_module(issuer_core) - - -@pytest.fixture(scope="module") -def private_key(): - return issuer_core.load_or_generate_private_key(path=None) - - -@pytest.fixture(scope="module") -def kid(private_key): - return issuer_core.compute_kid(private_key) - - -@pytest.mark.unit -class TestMintAndVerify: - def test_token_verifies_against_public_key(self, private_key, kid): - """The roundtrip Istio performs: verify RS256 signature + expiry.""" - resp = issuer_core.mint_token(private_key, kid) - claims = jwt.decode( - resp["access_token"], - private_key.public_key(), - algorithms=["RS256"], - options={"require": ["iss", "sub", "iat", "exp"]}, - ) - assert claims["iss"] == issuer_core.DEFAULT_ISSUER - assert claims["sub"].startswith("session-") - - def test_token_response_shape(self, private_key, kid): - resp = issuer_core.mint_token(private_key, kid, ttl_seconds=60) - assert resp["token_type"] == "Bearer" - assert resp["expires_in"] == 60 - - def test_expiry_honors_ttl(self, private_key, kid): - resp = issuer_core.mint_token(private_key, kid, ttl_seconds=120) - claims = jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) - assert claims["exp"] - claims["iat"] == 120 - assert abs(claims["iat"] - time.time()) < 10 - - def test_expired_token_rejected(self, private_key, kid): - resp = issuer_core.mint_token(private_key, kid, ttl_seconds=-10) - with pytest.raises(jwt.ExpiredSignatureError): - jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) - - def test_sessions_are_unique(self, private_key, kid): - subs = set() - for _ in range(5): - resp = issuer_core.mint_token(private_key, kid) - claims = jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) - subs.add(claims["sub"]) - assert len(subs) == 5 - - def test_wrong_key_rejected(self, private_key, kid): - """A token signed by another key must not verify (forged tokens).""" - other_key = issuer_core.load_or_generate_private_key(path=None) - resp = issuer_core.mint_token(other_key, kid) - with pytest.raises(jwt.InvalidSignatureError): - jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) - - def test_kid_in_header(self, private_key, kid): - resp = issuer_core.mint_token(private_key, kid) - header = jwt.get_unverified_header(resp["access_token"]) - assert header["kid"] == kid - assert header["alg"] == "RS256" - - -@pytest.mark.unit -class TestJwks: - def test_jwks_shape(self, private_key, kid): - jwks = issuer_core.build_jwks(private_key, kid) - assert len(jwks["keys"]) == 1 - key = jwks["keys"][0] - assert key["kty"] == "RSA" - assert key["alg"] == "RS256" - assert key["use"] == "sig" - assert key["kid"] == kid - - def test_jwks_b64url_no_padding(self, private_key, kid): - """RFC 7518: base64url without padding; Envoy's JWKS parser is strict.""" - key = issuer_core.build_jwks(private_key, kid)["keys"][0] - for field in ("n", "e"): - assert "=" not in key[field] - assert "+" not in key[field] - assert "/" not in key[field] - - def test_jwks_verifies_token(self, private_key, kid): - """Full path: reconstruct the public key from JWKS and verify a token.""" - jwks = issuer_core.build_jwks(private_key, kid) - public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks["keys"][0]) - resp = issuer_core.mint_token(private_key, kid) - claims = jwt.decode(resp["access_token"], public_key, algorithms=["RS256"]) - assert claims["iss"] == issuer_core.DEFAULT_ISSUER - - def test_e_is_65537(self, private_key, kid): - key = issuer_core.build_jwks(private_key, kid)["keys"][0] - e = int.from_bytes(base64.urlsafe_b64decode(key["e"] + "=="), "big") - assert e == 65537 - - -@pytest.mark.unit -class TestKeyLoading: - def test_stable_kid(self, private_key): - assert issuer_core.compute_kid(private_key) == issuer_core.compute_kid(private_key) - - def test_load_from_pem(self, private_key, tmp_path): - """Key round-trips through PEM (the Secret mount path in-cluster).""" - from cryptography.hazmat.primitives import serialization - - pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - key_file = tmp_path / "private.pem" - key_file.write_bytes(pem) - loaded = issuer_core.load_or_generate_private_key(str(key_file)) - assert issuer_core.compute_kid(loaded) == issuer_core.compute_kid(private_key) - - def test_missing_path_generates_ephemeral(self, tmp_path): - key = issuer_core.load_or_generate_private_key(str(tmp_path / "nope.pem")) - assert key.key_size == 2048 +"""Tests for the session issuer core (kagent-feast-mcp/session-issuer/issuer_core.py). + +Pure-unit: no FastAPI/uvicorn imports, no network. Verifies the JWT +mint -> JWKS -> verify roundtrip that Istio RequestAuthentication performs +at the ingress gateway. +""" + +import base64 +import importlib.util +import time +from pathlib import Path + +import jwt +import pytest + +ISSUER_DIR = Path(__file__).parent.parent / "kagent-feast-mcp" / "session-issuer" +ISSUER_CORE_PATH = ISSUER_DIR / "issuer_core.py" + +spec = importlib.util.spec_from_file_location("issuer_core", ISSUER_CORE_PATH) +issuer_core = importlib.util.module_from_spec(spec) +spec.loader.exec_module(issuer_core) + + +@pytest.fixture(scope="module") +def private_key(): + return issuer_core.load_or_generate_private_key(path=None) + + +@pytest.fixture(scope="module") +def kid(private_key): + return issuer_core.compute_kid(private_key) + + +@pytest.mark.unit +class TestMintAndVerify: + def test_token_verifies_against_public_key(self, private_key, kid): + """The roundtrip Istio performs: verify RS256 signature + expiry.""" + resp = issuer_core.mint_token(private_key, kid) + claims = jwt.decode( + resp["access_token"], + private_key.public_key(), + algorithms=["RS256"], + options={"require": ["iss", "sub", "iat", "exp"]}, + ) + assert claims["iss"] == issuer_core.DEFAULT_ISSUER + assert claims["sub"].startswith("session-") + + def test_token_response_shape(self, private_key, kid): + resp = issuer_core.mint_token(private_key, kid, ttl_seconds=60) + assert resp["token_type"] == "Bearer" + assert resp["expires_in"] == 60 + + def test_expiry_honors_ttl(self, private_key, kid): + resp = issuer_core.mint_token(private_key, kid, ttl_seconds=120) + claims = jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) + assert claims["exp"] - claims["iat"] == 120 + assert abs(claims["iat"] - time.time()) < 10 + + def test_expired_token_rejected(self, private_key, kid): + resp = issuer_core.mint_token(private_key, kid, ttl_seconds=-10) + with pytest.raises(jwt.ExpiredSignatureError): + jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) + + def test_sessions_are_unique(self, private_key, kid): + subs = set() + for _ in range(5): + resp = issuer_core.mint_token(private_key, kid) + claims = jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) + subs.add(claims["sub"]) + assert len(subs) == 5 + + def test_wrong_key_rejected(self, private_key, kid): + """A token signed by another key must not verify (forged tokens).""" + other_key = issuer_core.load_or_generate_private_key(path=None) + resp = issuer_core.mint_token(other_key, kid) + with pytest.raises(jwt.InvalidSignatureError): + jwt.decode(resp["access_token"], private_key.public_key(), algorithms=["RS256"]) + + def test_kid_in_header(self, private_key, kid): + resp = issuer_core.mint_token(private_key, kid) + header = jwt.get_unverified_header(resp["access_token"]) + assert header["kid"] == kid + assert header["alg"] == "RS256" + + +@pytest.mark.unit +class TestJwks: + def test_jwks_shape(self, private_key, kid): + jwks = issuer_core.build_jwks(private_key, kid) + assert len(jwks["keys"]) == 1 + key = jwks["keys"][0] + assert key["kty"] == "RSA" + assert key["alg"] == "RS256" + assert key["use"] == "sig" + assert key["kid"] == kid + + def test_jwks_b64url_no_padding(self, private_key, kid): + """RFC 7518: base64url without padding; Envoy's JWKS parser is strict.""" + key = issuer_core.build_jwks(private_key, kid)["keys"][0] + for field in ("n", "e"): + assert "=" not in key[field] + assert "+" not in key[field] + assert "/" not in key[field] + + def test_jwks_verifies_token(self, private_key, kid): + """Full path: reconstruct the public key from JWKS and verify a token.""" + jwks = issuer_core.build_jwks(private_key, kid) + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks["keys"][0]) + resp = issuer_core.mint_token(private_key, kid) + claims = jwt.decode(resp["access_token"], public_key, algorithms=["RS256"]) + assert claims["iss"] == issuer_core.DEFAULT_ISSUER + + def test_e_is_65537(self, private_key, kid): + key = issuer_core.build_jwks(private_key, kid)["keys"][0] + e = int.from_bytes(base64.urlsafe_b64decode(key["e"] + "=="), "big") + assert e == 65537 + + +@pytest.mark.unit +class TestKeyLoading: + def test_stable_kid(self, private_key): + assert issuer_core.compute_kid(private_key) == issuer_core.compute_kid(private_key) + + def test_load_from_pem(self, private_key, tmp_path): + """Key round-trips through PEM (the Secret mount path in-cluster).""" + from cryptography.hazmat.primitives import serialization + + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + key_file = tmp_path / "private.pem" + key_file.write_bytes(pem) + loaded = issuer_core.load_or_generate_private_key(str(key_file)) + assert issuer_core.compute_kid(loaded) == issuer_core.compute_kid(private_key) + + def test_missing_path_generates_ephemeral(self, tmp_path): + key = issuer_core.load_or_generate_private_key(str(tmp_path / "nope.pem")) + assert key.key_size == 2048