Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ services:
- SPRING_DATASOURCE_PASSWORD=reports_password
- OLLAMA_BASE_URL=http://ollama:11434
- FEEDBACK_SERVICE_URL=http://feedback-service:8080
volumes:
- py_genai_helper_data:/code/vector-store
expose:
- 5000
labels:
Expand Down Expand Up @@ -512,6 +514,7 @@ volumes:
prometheus_data:
grafana_data:
loki_data:
py_genai_helper_data:

networks:
proxy:
Expand Down
15 changes: 15 additions & 0 deletions infra/helm/team-devoops/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ spec:
labels:
{{- include "team-devoops.selectorLabels" (dict "name" $name) | nindent 8 }}
spec:
{{- if $svc.persistence }}
securityContext:
fsGroup: {{ $svc.persistence.fsGroup }}
{{- end }}
{{- with $root.Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
Expand All @@ -29,6 +33,11 @@ spec:
imagePullPolicy: {{ $root.Values.global.imagePullPolicy }}
ports:
- containerPort: {{ $svc.port }}
{{- if $svc.persistence }}
volumeMounts:
- name: data
mountPath: {{ $svc.persistence.mountPath }}
{{- end }}
{{- $env := $svc.env | default dict }}
{{- if $env }}
env:
Expand Down Expand Up @@ -82,5 +91,11 @@ spec:
{{- end }}
resources:
{{- toYaml ($svc.resources | default $root.Values.resources) | nindent 12 }}
{{- if $svc.persistence }}
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ $name }}-data
{{- end }}
---
{{- end }}
19 changes: 19 additions & 0 deletions infra/helm/team-devoops/templates/pvc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{{- range $name, $svc := .Values.services }}
{{- if $svc.persistence }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ $name }}-data
labels:
{{- include "team-devoops.labels" (dict "name" $name "root" $) | nindent 4 }}
spec:
accessModes: ["ReadWriteOnce"]
{{- if $.Values.global.storageClassName }}
storageClassName: {{ $.Values.global.storageClassName }}
{{- end }}
resources:
requests:
storage: {{ $svc.persistence.size }}
---
{{- end }}
{{- end }}
48 changes: 39 additions & 9 deletions infra/helm/team-devoops/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,14 @@ monitoring:
requests:
cpu: 50m
memory: 128Mi
# Actual usage is ~166Mi in practice (higher than first estimated) -- the earlier
# 200Mi trim left almost no headroom, so it's bumped back up here.
# Documented peak usage is ~166Mi (the earlier 200Mi trim against that left "almost no
# headroom" and had to be reverted -- see history). Live `kubectl top` right now shows only
# ~70Mi, but that's an idle snapshot, not the peak. Trimmed from 280Mi to 240Mi: still ~74Mi
# above the documented 166Mi peak, clearly more headroom than the rejected 200Mi/34Mi attempt,
# while freeing some of the namespace's shared 6Gi quota for py-genai-helper.
limits:
cpu: 100m
memory: 280Mi
memory: 240Mi
# Same out-of-band ConfigMap treatment as prometheus above (infra/loki/loki-config.yaml
# is the same file docker-compose uses).
loki:
Expand All @@ -115,10 +118,13 @@ monitoring:
requests:
cpu: 50m
memory: 128Mi
# Actual usage is ~153Mi; trimmed to make room for ollama (see prometheus above).
# Documented peak usage is ~153Mi (previously trimmed to make room for ollama, see prometheus
# above). Live `kubectl top` right now shows only ~65Mi, but that's an idle snapshot, not the
# peak. Trimmed from 220Mi to 200Mi: keeps ~47Mi above the documented peak -- more than
# grafana's rejected 34Mi-headroom attempt above, so deliberately not pushed any lower.
limits:
cpu: 150m
memory: 220Mi
memory: 200Mi
# Unlike loki/prometheus, alloy-config.alloy is Kubernetes-specific (loki.source.kubernetes,
# not loki.source.docker) so it isn't shared with docker-compose -- it lives in this
# chart's files/ and is embedded via .Files.Get like the Keycloak realm config, not
Expand Down Expand Up @@ -260,6 +266,13 @@ resources:
# image/tag -> optional: override the default ghcr image/tag for this service
# env -> extra literal environment variables
# envFromSecret -> inject all keys of an existing Secret as env
# persistence -> optional: mount a PVC (RWO) into the container.
# mountPath, size (PVC storage request), fsGroup (pod-level
# securityContext.fsGroup, needed when the image runs as
# non-root -- see loki/grafana/prometheus deployments for the
# same reasoning). Only safe with a single replica and a
# rolling strategy that fully terminates the old pod first
# (see .Values.strategy below), which is the case here.
# ---------------------------------------------------------------------------
services:
organization-service:
Expand Down Expand Up @@ -356,15 +369,32 @@ services:
KEYCLOAK_ISSUER_URL: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops"
KEYCLOAK_JWKS_URL: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs"
OLLAMA_BASE_URL: "http://ollama:11434"
# Persists the Chroma vector store (see services/py-genai-helper/rag.py) across pod
# restarts/reschedules. fsGroup 1000 matches the Dockerfile's `adduser` app user
# (confirmed uid/gid 1000 on python:3.12.9-slim-bookworm), which the CSI-provisioned
# PVC would otherwise mount root-owned and unwritable to that user.
persistence:
mountPath: /code/vector-store
size: 1Gi
fsGroup: 1000
resources:
requests:
cpu: 100m
memory: 256Mi
# Actual usage is ~135Mi; trimmed a bit (still ~3x actual) to make room for ollama
# (see monitoring.prometheus above).
memory: 350Mi
# The Chroma/onnxruntime RAG stack (see rag.py) alone -- before Flask/gunicorn/SQLAlchemy/auth
# are even loaded -- profiled at ~205Mi during a cold index build in a local container run, so
# the old 400Mi (sized for FAISS-era ~135Mi actual usage) is no longer enough headroom. Can't
# just raise it freely though: the namespace's ResourceQuota caps limits.memory at 6Gi total,
# and every other service's limit already commits ~5650Mi of that (verified live via
# `kubectl get resourcequota` + summing pod limits), leaving at most ~494Mi of quota room for
# this service -- a first attempt at 512Mi exceeded it, so the rollout couldn't schedule a
# single pod, hit progressDeadlineSeconds, and got auto-rolled-back by CD. 450Mi fits with a
# little quota slack left; re-check actual usage with `kubectl top` after this deploys, and if
# it needs to go higher, another service's limit has to come down first (same trimming pattern
# already used for prometheus/loki/grafana/alloy above).
limits:
cpu: 150m
memory: 400Mi
memory: 450Mi
web-client:
path: /
port: 8080
Expand Down
1 change: 1 addition & 0 deletions services/py-genai-helper/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ __pycache__/
.env
.venv/
venv/
vector-store/
*.egg-info/
.git/
.gitignore
Expand Down
1 change: 1 addition & 0 deletions services/py-genai-helper/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ __pycache__/
instance/
*.log
include/
vector-store/
19 changes: 12 additions & 7 deletions services/py-genai-helper/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Stage 1: builder
FROM python:3.12.9-alpine3.21 AS builder
FROM python:3.12.9-slim-bookworm AS builder
WORKDIR /code

# Installs gcc and other dependencies (postgresql-dev provides libpq + pg_config for psycopg2)
RUN apk add --no-cache gcc musl-dev linux-headers postgresql-dev
# Installs gcc and other dependencies (libpq-dev provides libpq + pg_config for psycopg2)
RUN apt-get update && apt-get install --no-install-recommends -y gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*

# Install dependencies
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

# Stage 2: run
FROM python:3.12.9-alpine3.21
FROM python:3.12.9-slim-bookworm
WORKDIR /code

# Runtime shared library for psycopg2 (libpq)
RUN apk add --no-cache libpq
# Runtime shared library for psycopg2 (libpq) and wget for the healthcheck
RUN apt-get update && apt-get install --no-install-recommends -y libpq5 wget \
&& rm -rf /var/lib/apt/lists/*

# Copy installed python packages from builder stage
COPY --from=builder /install /usr/local
Expand All @@ -23,7 +25,10 @@ COPY --from=builder /install /usr/local
COPY . .

# Switch user for increased security
RUN adduser -D user
RUN adduser --disabled-password --gecos "" user
# The persisted vector store (see rag.py) is written at runtime, so the app user needs write
# access to it; everything else stays read-only to that user.
RUN mkdir -p vector-store && chown -R user:user vector-store
Comment on lines +28 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for a volume mount targeting vector-store for py-genai-helper
rg -n -A5 -B5 'py-genai-helper' infra/docker-compose.yml
fd -e yaml -e yml . infra | xargs rg -n -i 'vector-store'

Repository: AET-DevOps26/team-devoops

Length of output: 2281


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the helper code and deployment manifests
git ls-files | rg '^(services/py-genai-helper/|infra/|.*docker-compose.*|.*compose.*|.*k8s.*|.*helm.*)'

printf '\n## vector-store references\n'
rg -n --hidden -S 'vector-store|_VECTOR_STORE|rag\.py|py-genai-helper' services/py-genai-helper infra . || true

printf '\n## compose files mentioning py-genai-helper\n'
rg -n -A8 -B8 'py-genai-helper' infra/**/docker-compose*.y*ml ./**/docker-compose*.y*ml 2>/dev/null || true

printf '\n## k8s/other manifest references to vector-store or volumeMounts\n'
rg -n -A6 -B6 'vector-store|volumeMounts|volumes:' infra services/py-genai-helper 2>/dev/null || true

Repository: AET-DevOps26/team-devoops

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## Helm deployment for py-genai-helper\n'
sed -n '1,220p' infra/helm/team-devoops/templates/deployment.yaml

printf '\n## Helm values for py-genai-helper\n'
sed -n '320,390p' infra/helm/team-devoops/values.yaml

printf '\n## Search for helper-specific PVC/volume mounts\n'
rg -n -A4 -B4 'py-genai-helper|vector-store|volumeMounts|persistentVolumeClaim|emptyDir|hostPath' infra/helm/team-devoops/templates infra/helm/team-devoops/values.yaml

Repository: AET-DevOps26/team-devoops

Length of output: 18388


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,60p' services/py-genai-helper/Dockerfile

Repository: AET-DevOps26/team-devoops

Length of output: 1606


Mount /code/vector-store on a persistent volume. The image creates the directory, but rag.py stores the persisted Chroma data under /code/vector-store, and neither infra/docker-compose.yml nor the Helm deployment mounts anything there. Every container recreation will drop the index and force a rebuild.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/py-genai-helper/Dockerfile` around lines 28 - 31, The persisted
Chroma store used by rag.py is still ephemeral because /code/vector-store is
only created in the Dockerfile and not backed by a volume. Update the runtime
configuration to mount /code/vector-store on a persistent volume in both
infra/docker-compose.yml and the Helm deployment, and keep the existing
ownership setup for the user in the Dockerfile so the app can write to the
mounted directory.

USER user

# Set environment variables
Expand Down
10 changes: 10 additions & 0 deletions services/py-genai-helper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,13 @@ feedback service's own visibility rules decide which entries feed the report. Th
(OpenAI or Ollama, per `LLM_PROVIDER`/`uselocal` — see Local LLM above) writes the report text,
which is then persisted to the `reports` schema. Configure the feedback service location via
`FEEDBACK_SERVICE_URL` (default `http://feedback-service:8080`).

## Knowledge base (RAG)

The PDFs in `file-storage/` are chunked, embedded, and indexed with Chroma (see `rag.py`) so report
prompts can be augmented with relevant excerpts. The collection is persisted to `vector-store/<provider>/`
(one subdirectory per embedding provider, since an OpenAI-embedded collection isn't valid to query with
Ollama embeddings and vice versa) so the embedding step — a real API/model cost — only happens once
per provider rather than on every process/worker start. A manifest of the source PDFs' filenames and
modification times is stored alongside the collection; if `file-storage/` changes, the manifest no longer
matches and the collection is rebuilt automatically.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed services/py-genai-helper/file-storage/faust.pdf
Binary file not shown.
76 changes: 68 additions & 8 deletions services/py-genai-helper/rag.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import json
from functools import lru_cache
from pathlib import Path

import chromadb
from dotenv import load_dotenv
from langchain_chroma import Chroma
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from prometheus_client import Counter, Histogram

Expand All @@ -15,35 +19,91 @@
RAG_QUERY_DURATION = Histogram("genai_rag_query_duration_seconds", "RAG query duration in seconds", ["provider"])

_FILE_STORAGE = Path(__file__).parent / "file-storage"
# Persisted Chroma collections, one subdirectory per embedding provider (see _load_pdfs). Embedding
# a PDF corpus costs real API calls/time, so the index is written to disk once and reloaded by every
# later process (e.g. every gunicorn worker) instead of re-embedding on each of them.
_VECTOR_STORE = Path(__file__).parent / "vector-store"
_MANIFEST_NAME = "manifest.json"


def _load_pdfs(use_local: bool) -> FAISS | None:
def _manifest(pdf_files: list[Path], model: str) -> dict[str, int | str]:
"""Fingerprint of the PDFs and embedding model an index was built from, to detect a stale
on-disk index (also catches an EMBEDDING_MODEL change, not just a file-storage/ change --
otherwise a differently-dimensioned model would load a collection it can't query)."""
manifest: dict[str, int | str] = {path.name: path.stat().st_mtime_ns for path in pdf_files}
manifest["_model"] = model
return manifest


def _load_persisted(provider: str, manifest: dict[str, int | str], embeddings: Embeddings) -> Chroma | None:
"""Load the on-disk collection for `provider`, or None if missing/stale/unreadable."""
store_dir = _VECTOR_STORE / provider
manifest_file = store_dir / _MANIFEST_NAME
if not manifest_file.exists():
return None
try:
if json.loads(manifest_file.read_text()) != manifest:
return None
client = chromadb.PersistentClient(path=str(store_dir))
return Chroma(collection_name=provider, embedding_function=embeddings, client=client)
except Exception:
# Corrupt or incompatible on-disk collection (e.g. written by a different Chroma/langchain
# version) — fall back to rebuilding rather than taking the service down.
return None


def _persist(provider: str, manifest: dict[str, int | str], docs: list[Document], embeddings: Embeddings) -> Chroma:
store_dir = _VECTOR_STORE / provider
store_dir.mkdir(parents=True, exist_ok=True)
# chromadb caches one PersistentClient per path in-process, so deleting/recreating store_dir
# out from under an already-cached client leaves it holding handles to the removed files
# (surfacing as "attempt to write a readonly database"). Go through the same cached client to
# drop the old collection instead, so a stale manifest (file-storage/ changed) rebuilds cleanly.
client = chromadb.PersistentClient(path=str(store_dir))
try:
client.delete_collection(provider)
except Exception:
pass # first build for this provider — nothing to delete yet
vector_store = Chroma.from_documents(docs, embedding=embeddings, collection_name=provider, client=client)
(store_dir / _MANIFEST_NAME).write_text(json.dumps(manifest))
return vector_store


def _load_pdfs(use_local: bool) -> Chroma | None:
pdf_files = list(_FILE_STORAGE.glob("*.pdf"))
if not pdf_files:
return None

provider = "ollama" if use_local else "openai"
embeddings = get_embeddings(use_local)
manifest = _manifest(pdf_files, embeddings.model)

vector_store = _load_persisted(provider, manifest, embeddings)
if vector_store is not None:
return vector_store

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
docs = []
for path in pdf_files:
loader = PyPDFLoader(str(path))
docs.extend(loader.load_and_split(splitter))

return FAISS.from_documents(docs, embedding=get_embeddings(use_local))
return _persist(provider, manifest, docs, embeddings)


@lru_cache(maxsize=2)
def _get_vector_store(use_local: bool) -> FAISS | None:
"""Build (once per provider) and cache the FAISS index over the PDFs in file-storage/.
def _get_vector_store(use_local: bool) -> Chroma | None:
"""Build (once per provider) and cache the Chroma collection over the PDFs in file-storage/.

Cached per provider rather than globally: an index built from OpenAI embeddings isn't valid to
query with Ollama embeddings (and vice versa) — different model, different vector space.
Cached per provider rather than globally: a collection built from OpenAI embeddings isn't valid
to query with Ollama embeddings (and vice versa) — different model, different vector space.
Deferred until first use rather than built at import time, so importing this module (directly,
or transitively via ``reports``) doesn't require API credentials or make network calls.
"""
return _load_pdfs(use_local)


def retrieve_context(query: str, use_local: bool | None = None, k: int = 3) -> list[str]:
def retrieve_context(query: str, use_local: bool | None = None, k: int = 5) -> list[str]:
"""Return the text of the k knowledge-base chunks most relevant to the query.

Returns an empty list if no PDFs are configured in file-storage/. ``use_local`` selects the
Expand Down
Loading
Loading