Spaces:
Build error
Build error
github-actions commited on
Commit ·
d545f81
1
Parent(s): 70bd8f5
Sync from GitHub
Browse files- .dockerignore +31 -0
- .gitattributes +0 -35
- .github/workflows/hugging_face_sync.yml +32 -0
- .github/workflows/tests.yml +48 -0
- .gitignore +30 -0
- DOCKER_BUILD_INFO.md +94 -0
- Dockerfile +47 -0
- README.md +20 -11
- TEST_SUMMARY.md +208 -0
- XENO Uganda_KnowlegeBase_V1.json → XENO%20Uganda_KnowlegeBase_V1.json +0 -0
- app.py +37 -77
- docker-compose.yml +27 -0
- newnewfile.py +0 -1
- pytest.ini +5 -0
- requirements.txt +7 -2
- run_tests.py +38 -0
- setup.cfg +28 -0
- src/__init__.py +3 -0
- src/config.py +48 -0
- src/intent_classifier.py +107 -0
- src/knowledge_base.py +69 -0
- src/logger.py +165 -0
- src/memory.py +88 -0
- src/response_generator.py +67 -0
- src/utils.py +66 -0
- src/vector_store.py +175 -0
- tests/README.md +119 -0
- tests/__init__.py +3 -0
- tests/conftest.py +117 -0
- tests/test_intent_classifier.py +169 -0
- tests/test_knowledge_base.py +183 -0
- tests/test_logger.py +272 -0
- tests/test_memory.py +195 -0
- tests/test_response_generator.py +200 -0
- tests/test_utils.py +108 -0
- tests/test_vector_store.py +243 -0
.dockerignore
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker ignore file - exclude unnecessary files from Docker context
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
.Python
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.coverage
|
| 9 |
+
htmlcov/
|
| 10 |
+
*.egg-info/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
| 13 |
+
.git/
|
| 14 |
+
.gitignore
|
| 15 |
+
.env
|
| 16 |
+
.env.local
|
| 17 |
+
.vscode/
|
| 18 |
+
.idea/
|
| 19 |
+
*.log
|
| 20 |
+
*.db
|
| 21 |
+
.DS_Store
|
| 22 |
+
node_modules/
|
| 23 |
+
.venv/
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
*.swp
|
| 27 |
+
*.swo
|
| 28 |
+
*~
|
| 29 |
+
.github/
|
| 30 |
+
README.md
|
| 31 |
+
TEST_SUMMARY.md
|
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.github/workflows/hugging_face_sync.yml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
|
| 8 |
+
jobs:
|
| 9 |
+
sync:
|
| 10 |
+
runs-on: ubuntu-latest
|
| 11 |
+
|
| 12 |
+
steps:
|
| 13 |
+
- name: Checkout GitHub repo
|
| 14 |
+
uses: actions/checkout@v3
|
| 15 |
+
|
| 16 |
+
- name: Clone Hugging Face Space
|
| 17 |
+
run: |
|
| 18 |
+
git clone https://mukiibi:${{ secrets.HF_TOKEN_RON }}@huggingface.co/spaces/Sebunya/AskXeno hf_space
|
| 19 |
+
|
| 20 |
+
- name: Sync files
|
| 21 |
+
run: |
|
| 22 |
+
rsync -av --delete --exclude ".git" ./ hf_space/ --ignore-errors --exclude "hf_space"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
- name: Commit & push
|
| 26 |
+
run: |
|
| 27 |
+
cd hf_space
|
| 28 |
+
git config user.email "actions@github.com"
|
| 29 |
+
git config user.name "github-actions"
|
| 30 |
+
git add .
|
| 31 |
+
git commit -m "Sync from GitHub" || echo "No changes to commit"
|
| 32 |
+
git push origin main
|
.github/workflows/tests.yml
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Run Tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [ main, develop ]
|
| 6 |
+
pull_request:
|
| 7 |
+
branches: [ main, develop ]
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
test:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
|
| 13 |
+
strategy:
|
| 14 |
+
matrix:
|
| 15 |
+
python-version: ['3.11']
|
| 16 |
+
|
| 17 |
+
steps:
|
| 18 |
+
- uses: actions/checkout@v3
|
| 19 |
+
|
| 20 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 21 |
+
uses: actions/setup-python@v4
|
| 22 |
+
with:
|
| 23 |
+
python-version: ${{ matrix.python-version }}
|
| 24 |
+
|
| 25 |
+
- name: Cache dependencies
|
| 26 |
+
uses: actions/cache@v3
|
| 27 |
+
with:
|
| 28 |
+
path: ~/.cache/pip
|
| 29 |
+
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
| 30 |
+
restore-keys: |
|
| 31 |
+
${{ runner.os }}-pip-
|
| 32 |
+
|
| 33 |
+
- name: Install dependencies
|
| 34 |
+
run: |
|
| 35 |
+
python -m pip install --upgrade pip
|
| 36 |
+
pip install -r requirements.txt
|
| 37 |
+
|
| 38 |
+
- name: Run tests with coverage
|
| 39 |
+
run: |
|
| 40 |
+
pytest --cov=src --cov-report=xml --cov-report=term-missing
|
| 41 |
+
|
| 42 |
+
- name: Upload coverage to Codecov
|
| 43 |
+
uses: codecov/codecov-action@v3
|
| 44 |
+
with:
|
| 45 |
+
file: ./coverage.xml
|
| 46 |
+
flags: unittests
|
| 47 |
+
name: codecov-umbrella
|
| 48 |
+
fail_ci_if_error: false
|
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ignore environment variable files
|
| 2 |
+
.env
|
| 3 |
+
.venv/
|
| 4 |
+
|
| 5 |
+
# Ignore application log files
|
| 6 |
+
app.log
|
| 7 |
+
|
| 8 |
+
# Ignore compiled Python files
|
| 9 |
+
__pycache__/
|
| 10 |
+
|
| 11 |
+
# Ignore test coverage reports
|
| 12 |
+
htmlcov/
|
| 13 |
+
.coverage
|
| 14 |
+
coverage.xml
|
| 15 |
+
.pytest_cache/
|
| 16 |
+
|
| 17 |
+
# Ignore test artifacts
|
| 18 |
+
.tox/
|
| 19 |
+
.cache/
|
| 20 |
+
*.pyc
|
| 21 |
+
*.pyo
|
| 22 |
+
*.pyc
|
| 23 |
+
|
| 24 |
+
# Ignore database files
|
| 25 |
+
*.db
|
| 26 |
+
|
| 27 |
+
# Ignore binary files
|
| 28 |
+
*.txt
|
| 29 |
+
*.bin
|
| 30 |
+
*.exe
|
DOCKER_BUILD_INFO.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Docker Build Instructions
|
| 2 |
+
|
| 3 |
+
## Dockerfile Optimizations
|
| 4 |
+
|
| 5 |
+
```dockerfile
|
| 6 |
+
ENV PIP_DEFAULT_TIMEOUT=100 \
|
| 7 |
+
PIP_RETRIES=5
|
| 8 |
+
|
| 9 |
+
RUN pip install --upgrade pip setuptools wheel && \
|
| 10 |
+
pip install --no-cache-dir --default-timeout=100 -r requirements.txt
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
## Build Progress
|
| 14 |
+
|
| 15 |
+
The Docker build is currently in progress. Here's what it's doing:
|
| 16 |
+
|
| 17 |
+
1. ✅ Loading base Python 3.10-slim image
|
| 18 |
+
2. ✅ Setting working directory and environment variables
|
| 19 |
+
3. ⏳ Installing system dependencies (build-essential, curl)
|
| 20 |
+
4. ⏳ Installing Python dependencies (this may take 10-15 minutes due to large packages like PyTorch ~900MB)
|
| 21 |
+
5. Copying project files
|
| 22 |
+
6. Creating necessary directories
|
| 23 |
+
7. Exposing port 7860
|
| 24 |
+
8. Setting health check
|
| 25 |
+
|
| 26 |
+
## How to Use
|
| 27 |
+
|
| 28 |
+
### Start the Docker Container
|
| 29 |
+
|
| 30 |
+
Once the build completes, start with:
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
docker-compose up -d
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### Access the Application
|
| 37 |
+
|
| 38 |
+
- Open browser to `http://localhost:7860`
|
| 39 |
+
|
| 40 |
+
### View Logs
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
docker-compose logs -f xeno-bot
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### Stop the Container
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
docker-compose down
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### Build Directly Without docker-compose
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
docker build -t xeno-bot:latest .
|
| 56 |
+
docker run -p 7860:7860 \
|
| 57 |
+
-e GEMINI_API_KEY="your-api-key" \
|
| 58 |
+
-e GOOGLE_SHEETS_CREDENTIALS='{"type": "service_account", ...}' \
|
| 59 |
+
xeno-bot:latest
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Environment Variables Required
|
| 63 |
+
|
| 64 |
+
The container needs these environment variables set in `.env`:
|
| 65 |
+
|
| 66 |
+
```
|
| 67 |
+
GEMINI_API_KEY=your-google-gemini-api-key
|
| 68 |
+
GOOGLE_SHEETS_CREDENTIALS={"type": "service_account", "project_id": "...", ...}
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
See `.env.example` for template.
|
| 72 |
+
|
| 73 |
+
## Performance Notes
|
| 74 |
+
|
| 75 |
+
- **Initial build time**: 10-20 minutes (downloading and installing ~900MB PyTorch library)
|
| 76 |
+
- **Subsequent builds**: Faster due to Docker layer caching
|
| 77 |
+
- **Container startup**: ~30-60 seconds for first run, ~5-10 seconds after that
|
| 78 |
+
- **Memory requirement**: 2GB minimum recommended (PyTorch + Gradio + ChromaDB)
|
| 79 |
+
|
| 80 |
+
## Troubleshooting
|
| 81 |
+
|
| 82 |
+
If the build still times out:
|
| 83 |
+
1. Increase `PIP_DEFAULT_TIMEOUT` further in Dockerfile
|
| 84 |
+
2. Check your network connection
|
| 85 |
+
3. Try building again (Docker will use cached layers)
|
| 86 |
+
4. Consider using a build cache: `DOCKER_BUILDKIT=1 docker build ...`
|
| 87 |
+
|
| 88 |
+
## Files Modified
|
| 89 |
+
|
| 90 |
+
- `Dockerfile` - Optimized for reliability
|
| 91 |
+
- `docker-compose.yml` - Removed obsolete version attribute
|
| 92 |
+
- `app.py` - Added missing `import os`
|
| 93 |
+
- `.dockerignore` - Excludes unnecessary files from build context
|
| 94 |
+
- `.env.example` - Template for environment variables
|
Dockerfile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use official Python runtime as base image
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Set environment variables
|
| 8 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 9 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 10 |
+
GRADIO_SERVER_NAME="0.0.0.0" \
|
| 11 |
+
GRADIO_SERVER_PORT=7860 \
|
| 12 |
+
PIP_DEFAULT_TIMEOUT=100 \
|
| 13 |
+
PIP_RETRIES=5
|
| 14 |
+
|
| 15 |
+
# Install system dependencies
|
| 16 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 17 |
+
build-essential \
|
| 18 |
+
curl \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# Copy requirements first for better layer caching
|
| 22 |
+
COPY requirements.txt .
|
| 23 |
+
|
| 24 |
+
# Upgrade pip and install dependencies with retries
|
| 25 |
+
RUN pip install --upgrade pip setuptools wheel && \
|
| 26 |
+
pip install --no-cache-dir --default-timeout=100 --prefer-binary \
|
| 27 |
+
torch==2.3.1+cpu --index-url https://download.pytorch.org/whl/cpu && \
|
| 28 |
+
pip install --no-cache-dir --default-timeout=100 --prefer-binary \
|
| 29 |
+
-r requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
|
| 30 |
+
|
| 31 |
+
# Copy project files
|
| 32 |
+
COPY app.py .
|
| 33 |
+
COPY src/ ./src/
|
| 34 |
+
COPY XENO_Uganda_KnowledgeBase_Advisory.json ./
|
| 35 |
+
|
| 36 |
+
# Create necessary directories
|
| 37 |
+
RUN mkdir -p /tmp/xeno_db
|
| 38 |
+
|
| 39 |
+
# Expose Gradio port
|
| 40 |
+
EXPOSE 7860
|
| 41 |
+
|
| 42 |
+
# Health check
|
| 43 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 44 |
+
CMD curl -f http://localhost:7860 || exit 1
|
| 45 |
+
|
| 46 |
+
# Run the application
|
| 47 |
+
CMD ["python", "app.py"]
|
README.md
CHANGED
|
@@ -9,39 +9,52 @@ app_file: app.py
|
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
-
ASKXENO - AI-Powered XENO Support Assistant
|
| 13 |
-
Overview
|
| 14 |
ASKXENO is an AI-powered customer support assistant designed to provide accurate and timely responses to queries about XENO financial services. Built with a Retrieval-Augmented Generation (RAG) pipeline, it leverages a knowledge base, intent classification, and conversation memory to deliver professional and context-aware responses. The application is deployed as a Hugging Face Space and includes performance tracking for response time analysis.
|
| 15 |
Features
|
| 16 |
|
| 17 |
Natural Language Query Handling: Responds to user questions about XENO services, including account management, transactions, platform features, and general information.
|
|
|
|
| 18 |
Intent Classification: Detects simple intents (e.g., greetings, thanks, goodbyes) for quick, tailored responses without querying the knowledge base.
|
| 19 |
RAG Pipeline: Uses a ChromaDB vector store and Google Gemini embeddings to retrieve relevant information from a JSON-based XENO knowledge base.
|
| 20 |
Conversation Memory: Maintains chat history using LangGraph's SqliteSaver for context-aware responses.
|
|
|
|
| 21 |
Performance Tracking: Logs response times for each processing step (e.g., intent classification, retrieval, LLM generation) to a Google Sheet for analysis.
|
|
|
|
| 22 |
Gradio UI: Provides an interactive, user-friendly interface with session tracking and a chatbot-style conversation display.
|
|
|
|
| 23 |
Error Handling: Includes robust logging to handle and record errors, with fallback local file logging if Google Sheets fails.
|
| 24 |
|
| 25 |
-
Tech Stack
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
Python Libraries:
|
| 28 |
gradio: For the web-based user interface.
|
|
|
|
| 29 |
pandas: For handling the JSON knowledge base.
|
|
|
|
| 30 |
sentence_transformers: For similarity calculations.
|
|
|
|
| 31 |
google.generativeai: For embeddings and LLM responses (Gemini API).
|
|
|
|
| 32 |
chromadb: For vector storage and retrieval.
|
|
|
|
| 33 |
langchain_chroma: For integrating ChromaDB with LangChain.
|
|
|
|
| 34 |
gspread: For logging to Google Sheets.
|
|
|
|
| 35 |
langgraph: For conversation memory management.
|
|
|
|
| 36 |
torch: For tensor operations in similarity calculations.
|
| 37 |
|
| 38 |
|
| 39 |
-
Database:
|
| 40 |
-
SQLite for conversation memory (xeno_memory.db).
|
| 41 |
ChromaDB for persistent vector storage (/tmp/xeno_db).
|
| 42 |
|
| 43 |
|
| 44 |
-
External Services:
|
| 45 |
Google Sheets for logging responses and timing data.
|
| 46 |
Google Gemini API for embeddings and text generation.
|
| 47 |
|
|
@@ -50,10 +63,6 @@ Google Gemini API for embeddings and text generation.
|
|
| 50 |
Setup and Installation
|
| 51 |
This project is designed to run in a Hugging Face Space. To set it up locally or in a similar environment, follow these steps:
|
| 52 |
|
| 53 |
-
Clone the Repository (if not running in Hugging Face Space):
|
| 54 |
-
git clone <repository-url>
|
| 55 |
-
cd <repository-directory>
|
| 56 |
-
|
| 57 |
|
| 58 |
Install Dependencies:Ensure Python 3.8+ is installed, then install the required packages:
|
| 59 |
pip install -r requirements.txt
|
|
|
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# ASKXENO - AI-Powered XENO Support Assistant
|
| 13 |
+
## Overview
|
| 14 |
ASKXENO is an AI-powered customer support assistant designed to provide accurate and timely responses to queries about XENO financial services. Built with a Retrieval-Augmented Generation (RAG) pipeline, it leverages a knowledge base, intent classification, and conversation memory to deliver professional and context-aware responses. The application is deployed as a Hugging Face Space and includes performance tracking for response time analysis.
|
| 15 |
Features
|
| 16 |
|
| 17 |
Natural Language Query Handling: Responds to user questions about XENO services, including account management, transactions, platform features, and general information.
|
| 18 |
+
|
| 19 |
Intent Classification: Detects simple intents (e.g., greetings, thanks, goodbyes) for quick, tailored responses without querying the knowledge base.
|
| 20 |
RAG Pipeline: Uses a ChromaDB vector store and Google Gemini embeddings to retrieve relevant information from a JSON-based XENO knowledge base.
|
| 21 |
Conversation Memory: Maintains chat history using LangGraph's SqliteSaver for context-aware responses.
|
| 22 |
+
|
| 23 |
Performance Tracking: Logs response times for each processing step (e.g., intent classification, retrieval, LLM generation) to a Google Sheet for analysis.
|
| 24 |
+
|
| 25 |
Gradio UI: Provides an interactive, user-friendly interface with session tracking and a chatbot-style conversation display.
|
| 26 |
+
|
| 27 |
Error Handling: Includes robust logging to handle and record errors, with fallback local file logging if Google Sheets fails.
|
| 28 |
|
| 29 |
+
## Tech Stack
|
| 30 |
+
|
| 31 |
+
**Python Libraries:**
|
| 32 |
|
|
|
|
| 33 |
gradio: For the web-based user interface.
|
| 34 |
+
|
| 35 |
pandas: For handling the JSON knowledge base.
|
| 36 |
+
|
| 37 |
sentence_transformers: For similarity calculations.
|
| 38 |
+
|
| 39 |
google.generativeai: For embeddings and LLM responses (Gemini API).
|
| 40 |
+
|
| 41 |
chromadb: For vector storage and retrieval.
|
| 42 |
+
|
| 43 |
langchain_chroma: For integrating ChromaDB with LangChain.
|
| 44 |
+
|
| 45 |
gspread: For logging to Google Sheets.
|
| 46 |
+
|
| 47 |
langgraph: For conversation memory management.
|
| 48 |
+
|
| 49 |
torch: For tensor operations in similarity calculations.
|
| 50 |
|
| 51 |
|
| 52 |
+
## Database:
|
| 53 |
+
SQLite for conversation memory (xeno_memory.db). [currently iys implemented in google sheets]
|
| 54 |
ChromaDB for persistent vector storage (/tmp/xeno_db).
|
| 55 |
|
| 56 |
|
| 57 |
+
## External Services:
|
| 58 |
Google Sheets for logging responses and timing data.
|
| 59 |
Google Gemini API for embeddings and text generation.
|
| 60 |
|
|
|
|
| 63 |
Setup and Installation
|
| 64 |
This project is designed to run in a Hugging Face Space. To set it up locally or in a similar environment, follow these steps:
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
Install Dependencies:Ensure Python 3.8+ is installed, then install the required packages:
|
| 68 |
pip install -r requirements.txt
|
TEST_SUMMARY.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# XENO Bot - Test Suite Summary
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Comprehensive unit test suite created for the XENO Bot application with **67 passing tests** and **88% code coverage**.
|
| 6 |
+
|
| 7 |
+
## Test Statistics
|
| 8 |
+
|
| 9 |
+
- **Total Tests**: 67
|
| 10 |
+
- **All Passing**: ✅ 100%
|
| 11 |
+
- **Overall Coverage**: 88%
|
| 12 |
+
- **Test Files**: 7
|
| 13 |
+
- **Modules Tested**: 7
|
| 14 |
+
|
| 15 |
+
## Module Coverage
|
| 16 |
+
|
| 17 |
+
| Module | Statements | Tested | Coverage | Missing Lines |
|
| 18 |
+
|--------|------------|--------|----------|---------------|
|
| 19 |
+
| config.py | 22 | 21 | 95% | 11 |
|
| 20 |
+
| intent_classifier.py | 24 | 24 | **100%** | - |
|
| 21 |
+
| knowledge_base.py | 20 | 20 | **100%** | - |
|
| 22 |
+
| logger.py | 58 | 52 | 90% | 28, 56-66, 92 |
|
| 23 |
+
| memory.py | 30 | 28 | 93% | 31, 69 |
|
| 24 |
+
| response_generator.py | 23 | 22 | 96% | 27 |
|
| 25 |
+
| utils.py | 35 | 32 | 91% | 21-23 |
|
| 26 |
+
| vector_store.py | 65 | 46 | 71% | 30-57, 76, 117, 148 |
|
| 27 |
+
|
| 28 |
+
## Test Files
|
| 29 |
+
|
| 30 |
+
### 1. test_utils.py (8 tests)
|
| 31 |
+
Tests the `PipelineTimer` class for timing pipeline execution.
|
| 32 |
+
|
| 33 |
+
**Tests:**
|
| 34 |
+
- Timer initialization
|
| 35 |
+
- Reset functionality
|
| 36 |
+
- Context manager timing
|
| 37 |
+
- Multiple step timing
|
| 38 |
+
- Total time calculation
|
| 39 |
+
- Timing summary generation
|
| 40 |
+
- Current step tracking
|
| 41 |
+
- Exception handling
|
| 42 |
+
|
| 43 |
+
### 2. test_intent_classifier.py (12 tests)
|
| 44 |
+
Tests the `IntentClassifier` class for user intent classification.
|
| 45 |
+
|
| 46 |
+
**Tests:**
|
| 47 |
+
- Classification of greetings, thanks, goodbye, and queries
|
| 48 |
+
- Case insensitivity
|
| 49 |
+
- Timer integration
|
| 50 |
+
- Simple intent detection
|
| 51 |
+
- Dynamic intent addition
|
| 52 |
+
- Response variety
|
| 53 |
+
- Empty and mixed messages
|
| 54 |
+
|
| 55 |
+
### 3. test_knowledge_base.py (8 tests)
|
| 56 |
+
Tests knowledge base loading and document preparation.
|
| 57 |
+
|
| 58 |
+
**Tests:**
|
| 59 |
+
- JSON file loading
|
| 60 |
+
- Null content filtering
|
| 61 |
+
- Document preparation
|
| 62 |
+
- Metadata structure
|
| 63 |
+
- Missing field handling
|
| 64 |
+
- Empty knowledge base
|
| 65 |
+
- Document text formatting
|
| 66 |
+
|
| 67 |
+
### 4. test_memory.py (9 tests)
|
| 68 |
+
Tests LangGraph memory operations with SQLite.
|
| 69 |
+
|
| 70 |
+
**Tests:**
|
| 71 |
+
- Session config creation
|
| 72 |
+
- Memory update and retrieval
|
| 73 |
+
- Empty checkpoint handling
|
| 74 |
+
- Timer integration
|
| 75 |
+
- Checkpoint structure validation
|
| 76 |
+
|
| 77 |
+
### 5. test_response_generator.py (10 tests)
|
| 78 |
+
Tests LLM response generation functionality.
|
| 79 |
+
|
| 80 |
+
**Tests:**
|
| 81 |
+
- Chat history formatting
|
| 82 |
+
- Response generation
|
| 83 |
+
- Prompt structure
|
| 84 |
+
- System prompt inclusion
|
| 85 |
+
- Timer integration
|
| 86 |
+
- Empty history handling
|
| 87 |
+
- Text stripping
|
| 88 |
+
|
| 89 |
+
### 6. test_logger.py (10 tests)
|
| 90 |
+
Tests Google Sheets logging functionality.
|
| 91 |
+
|
| 92 |
+
**Tests:**
|
| 93 |
+
- Response logging
|
| 94 |
+
- Timing data logging
|
| 95 |
+
- Error handling and fallback
|
| 96 |
+
- Empty/single knowledge pairs
|
| 97 |
+
- Long question truncation
|
| 98 |
+
- Missing step times
|
| 99 |
+
|
| 100 |
+
### 7. test_vector_store.py (10 tests)
|
| 101 |
+
Tests ChromaDB vector store operations.
|
| 102 |
+
|
| 103 |
+
**Tests:**
|
| 104 |
+
- Embedding generation
|
| 105 |
+
- Similarity calculation
|
| 106 |
+
- Context processing
|
| 107 |
+
- Multiple documents
|
| 108 |
+
- Result limiting
|
| 109 |
+
- Missing metadata handling
|
| 110 |
+
- Timer integration
|
| 111 |
+
|
| 112 |
+
## Running Tests
|
| 113 |
+
|
| 114 |
+
### Quick Start
|
| 115 |
+
```bash
|
| 116 |
+
# Install test dependencies
|
| 117 |
+
pip install pytest pytest-cov pytest-mock
|
| 118 |
+
|
| 119 |
+
# Run all tests
|
| 120 |
+
pytest
|
| 121 |
+
|
| 122 |
+
# Run with coverage
|
| 123 |
+
pytest --cov=src --cov-report=html
|
| 124 |
+
|
| 125 |
+
# Run specific test file
|
| 126 |
+
pytest tests/test_utils.py
|
| 127 |
+
|
| 128 |
+
# Run with verbose output
|
| 129 |
+
pytest -v
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
### Using the Test Runner
|
| 133 |
+
```bash
|
| 134 |
+
python run_tests.py
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## CI/CD Integration
|
| 138 |
+
|
| 139 |
+
GitHub Actions workflow created at `.github/workflows/tests.yml`
|
| 140 |
+
|
| 141 |
+
**Features:**
|
| 142 |
+
- Runs on push and pull requests
|
| 143 |
+
- Tests against Python 3.9, 3.10, and 3.11
|
| 144 |
+
- Generates coverage reports
|
| 145 |
+
- Uploads to Codecov
|
| 146 |
+
|
| 147 |
+
## Test Configuration
|
| 148 |
+
|
| 149 |
+
### pytest.ini
|
| 150 |
+
- Defines test discovery patterns
|
| 151 |
+
- Sets test collection rules
|
| 152 |
+
|
| 153 |
+
### setup.cfg
|
| 154 |
+
- Coverage configuration
|
| 155 |
+
- Exclude patterns for coverage
|
| 156 |
+
- Report formatting
|
| 157 |
+
|
| 158 |
+
### conftest.py
|
| 159 |
+
- Shared fixtures and mocks
|
| 160 |
+
- Environment setup
|
| 161 |
+
- Mock external services (Google Sheets, Google AI, ChromaDB)
|
| 162 |
+
|
| 163 |
+
## Best Practices Implemented
|
| 164 |
+
|
| 165 |
+
1. **Isolation**: Tests don't depend on external services
|
| 166 |
+
2. **Mocking**: External APIs and databases are mocked
|
| 167 |
+
3. **Coverage**: High coverage across all critical modules
|
| 168 |
+
4. **Documentation**: Clear test descriptions and docstrings
|
| 169 |
+
5. **Structure**: Consistent test organization
|
| 170 |
+
6. **CI/CD Ready**: Automated testing pipeline configured
|
| 171 |
+
|
| 172 |
+
## Mock Strategy
|
| 173 |
+
|
| 174 |
+
To avoid dependencies on external services during testing:
|
| 175 |
+
|
| 176 |
+
- **Google Generative AI**: Mocked at import time
|
| 177 |
+
- **Google Sheets**: Mocked gspread and oauth2 modules
|
| 178 |
+
- **ChromaDB**: Mocked PersistentClient
|
| 179 |
+
- **SQLite**: Mocked connections for memory tests
|
| 180 |
+
|
| 181 |
+
## Future Improvements
|
| 182 |
+
|
| 183 |
+
1. Increase vector_store.py coverage (currently 71%)
|
| 184 |
+
2. Add integration tests for full pipeline
|
| 185 |
+
3. Add performance benchmarking tests
|
| 186 |
+
4. Add tests for Gradio interface (app.py)
|
| 187 |
+
5. Add stress tests for concurrent requests
|
| 188 |
+
|
| 189 |
+
## Contributing
|
| 190 |
+
|
| 191 |
+
When adding new features:
|
| 192 |
+
|
| 193 |
+
1. Write tests first (TDD approach)
|
| 194 |
+
2. Ensure tests pass locally
|
| 195 |
+
3. Check coverage doesn't drop below 85%
|
| 196 |
+
4. Update this documentation
|
| 197 |
+
|
| 198 |
+
## Test Execution Time
|
| 199 |
+
|
| 200 |
+
- **Average runtime**: ~8-17 seconds
|
| 201 |
+
- **Fastest**: ~2 seconds (utils + intent_classifier only)
|
| 202 |
+
- **With coverage**: ~16-17 seconds
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
**Generated**: December 10, 2025
|
| 207 |
+
**Test Framework**: pytest 9.0.2
|
| 208 |
+
**Python Version**: 3.13.9
|
XENO Uganda_KnowlegeBase_V1.json → XENO%20Uganda_KnowlegeBase_V1.json
RENAMED
|
File without changes
|
app.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
import os
|
|
|
|
| 3 |
import gradio as gr
|
| 4 |
import pandas as pd
|
| 5 |
import torch
|
|
@@ -19,64 +23,24 @@ from typing import Dict, List, Tuple
|
|
| 19 |
import time
|
| 20 |
from contextlib import contextmanager
|
| 21 |
import threading # <--- Added for non-blocking feedback logging
|
| 22 |
-
|
| 23 |
import logging
|
| 24 |
import traceback
|
| 25 |
-
import sys
|
| 26 |
|
| 27 |
-
#
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
)
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
if issubclass(exc_type, KeyboardInterrupt):
|
| 36 |
-
return
|
| 37 |
-
logging.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
|
| 38 |
-
|
| 39 |
-
sys.excepthook = log_exception
|
| 40 |
-
logging.info("App started successfully.")
|
| 41 |
-
|
| 42 |
-
# ===== Time Tracking Class =====
|
| 43 |
-
class PipelineTimer:
|
| 44 |
-
def __init__(self):
|
| 45 |
-
self.reset()
|
| 46 |
-
|
| 47 |
-
def reset(self):
|
| 48 |
-
"""Reset all timing data for a new request"""
|
| 49 |
-
self.start_time = time.time()
|
| 50 |
-
self.step_times = {}
|
| 51 |
-
self.step_start = None
|
| 52 |
-
self.current_step = None
|
| 53 |
-
|
| 54 |
-
@contextmanager
|
| 55 |
-
def time_step(self, step_name: str):
|
| 56 |
-
"""Context manager to time a specific step"""
|
| 57 |
-
step_start = time.time()
|
| 58 |
-
self.current_step = step_name
|
| 59 |
-
try:
|
| 60 |
-
yield
|
| 61 |
-
finally:
|
| 62 |
-
step_end = time.time()
|
| 63 |
-
self.step_times[step_name] = round((step_end - step_start) * 1000, 2) # Convert to milliseconds
|
| 64 |
-
self.current_step = None
|
| 65 |
-
|
| 66 |
-
def get_total_time(self):
|
| 67 |
-
"""Get total elapsed time since reset"""
|
| 68 |
-
return round((time.time() - self.start_time) * 1000, 2)
|
| 69 |
-
|
| 70 |
-
def get_timing_summary(self):
|
| 71 |
-
"""Get a summary of all timing data"""
|
| 72 |
-
total_time = self.get_total_time()
|
| 73 |
-
return {
|
| 74 |
-
'total_time_ms': total_time,
|
| 75 |
-
'step_times': self.step_times,
|
| 76 |
-
'timestamp': datetime.now().isoformat()
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
# Initialize global timer
|
| 80 |
timer = PipelineTimer()
|
| 81 |
|
| 82 |
# === Configuration ===
|
|
@@ -410,11 +374,11 @@ def get_context_and_answer(message, history, session_id="default"):
|
|
| 410 |
notes = []
|
| 411 |
|
| 412 |
try:
|
| 413 |
-
|
|
|
|
| 414 |
|
| 415 |
# Step 1: Intent Classification
|
| 416 |
-
|
| 417 |
-
intent, direct_response = intent_classifier.classify_intent(message)
|
| 418 |
|
| 419 |
# Step 2: Memory Retrieval
|
| 420 |
chat_history = retrieve_memory(config)
|
|
@@ -437,21 +401,9 @@ def get_context_and_answer(message, history, session_id="default"):
|
|
| 437 |
queried_results = retriever.invoke(message)
|
| 438 |
|
| 439 |
# Step 4: Embedding Generation
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
content=message,
|
| 444 |
-
task_type="retrieval_query"
|
| 445 |
-
)['embedding']
|
| 446 |
-
|
| 447 |
-
doc_embeddings = [
|
| 448 |
-
genai.embed_content(
|
| 449 |
-
model=embedding_model,
|
| 450 |
-
content=doc.page_content,
|
| 451 |
-
task_type="retrieval_document"
|
| 452 |
-
)['embedding']
|
| 453 |
-
for doc in queried_results
|
| 454 |
-
]
|
| 455 |
|
| 456 |
# Step 5: Similarity Calculation
|
| 457 |
with timer.time_step("similarity_calculation"):
|
|
@@ -461,7 +413,7 @@ def get_context_and_answer(message, history, session_id="default"):
|
|
| 461 |
)[0].tolist()
|
| 462 |
max_score = max(cosine_scores) if cosine_scores else 0
|
| 463 |
|
| 464 |
-
if max_score <
|
| 465 |
answer = "I'm sorry, I couldn't find specific information for your question. Could you try rephrasing it, or contact XENO support directly?"
|
| 466 |
notes.append(f"Low similarity score: {max_score:.3f}")
|
| 467 |
else:
|
|
@@ -484,8 +436,7 @@ def get_context_and_answer(message, history, session_id="default"):
|
|
| 484 |
update_memory(config, message, answer)
|
| 485 |
|
| 486 |
# Step 9: Response Logging
|
| 487 |
-
|
| 488 |
-
log_response(message, answer, source_ids, knowledge_pairs, session_id)
|
| 489 |
|
| 490 |
# Log timing data
|
| 491 |
timing_summary = timer.get_timing_summary()
|
|
@@ -515,8 +466,9 @@ def get_context_and_answer(message, history, session_id="default"):
|
|
| 515 |
|
| 516 |
return "I apologize, but I encountered an error processing your request. Please try again."
|
| 517 |
|
|
|
|
| 518 |
# === Enhanced Gradio UI ===
|
| 519 |
-
def respond(message, history, session_id):
|
| 520 |
"""Gradio's main response function"""
|
| 521 |
if not session_id:
|
| 522 |
session_id = str(uuid.uuid4())
|
|
@@ -526,7 +478,9 @@ def respond(message, history, session_id):
|
|
| 526 |
|
| 527 |
return "", history
|
| 528 |
|
|
|
|
| 529 |
def create_interface():
|
|
|
|
| 530 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
| 531 |
gr.Markdown("""
|
| 532 |
# ASKXENO
|
|
@@ -593,6 +547,12 @@ def create_interface():
|
|
| 593 |
|
| 594 |
return demo
|
| 595 |
|
|
|
|
| 596 |
if __name__ == "__main__":
|
| 597 |
iface = create_interface()
|
| 598 |
-
iface.launch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
XENO Bot - AI-powered customer service assistant
|
| 3 |
+
Main application file with Gradio interface
|
| 4 |
+
"""
|
| 5 |
import os
|
| 6 |
+
import uuid
|
| 7 |
import gradio as gr
|
| 8 |
import pandas as pd
|
| 9 |
import torch
|
|
|
|
| 23 |
import time
|
| 24 |
from contextlib import contextmanager
|
| 25 |
import threading # <--- Added for non-blocking feedback logging
|
|
|
|
| 26 |
import logging
|
| 27 |
import traceback
|
|
|
|
| 28 |
|
| 29 |
+
# Import custom modules
|
| 30 |
+
from src.utils import PipelineTimer
|
| 31 |
+
from src.config import SIMILARITY_THRESHOLD, SERVER_NAME, SERVER_PORT
|
| 32 |
+
from src.memory import create_session_config, update_memory, retrieve_memory
|
| 33 |
+
from src.intent_classifier import IntentClassifier
|
| 34 |
+
from src.vector_store import (
|
| 35 |
+
initialize_vector_store,
|
| 36 |
+
generate_embeddings,
|
| 37 |
+
calculate_similarity,
|
| 38 |
+
process_context
|
| 39 |
)
|
| 40 |
+
from src.response_generator import generate_xeno_response
|
| 41 |
+
from src.logger import log_response, log_timing_data
|
| 42 |
|
| 43 |
+
# Initialize components
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
timer = PipelineTimer()
|
| 45 |
|
| 46 |
# === Configuration ===
|
|
|
|
| 374 |
notes = []
|
| 375 |
|
| 376 |
try:
|
| 377 |
+
# Create session config
|
| 378 |
+
config = create_session_config(session_id)
|
| 379 |
|
| 380 |
# Step 1: Intent Classification
|
| 381 |
+
intent, direct_response = intent_classifier.classify_intent(message)
|
|
|
|
| 382 |
|
| 383 |
# Step 2: Memory Retrieval
|
| 384 |
chat_history = retrieve_memory(config)
|
|
|
|
| 401 |
queried_results = retriever.invoke(message)
|
| 402 |
|
| 403 |
# Step 4: Embedding Generation
|
| 404 |
+
query_embedding, doc_embeddings = generate_embeddings(
|
| 405 |
+
message, queried_results, timer
|
| 406 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
|
| 408 |
# Step 5: Similarity Calculation
|
| 409 |
with timer.time_step("similarity_calculation"):
|
|
|
|
| 413 |
)[0].tolist()
|
| 414 |
max_score = max(cosine_scores) if cosine_scores else 0
|
| 415 |
|
| 416 |
+
if max_score < SIMILARITY_THRESHOLD:
|
| 417 |
answer = "I'm sorry, I couldn't find specific information for your question. Could you try rephrasing it, or contact XENO support directly?"
|
| 418 |
notes.append(f"Low similarity score: {max_score:.3f}")
|
| 419 |
else:
|
|
|
|
| 436 |
update_memory(config, message, answer)
|
| 437 |
|
| 438 |
# Step 9: Response Logging
|
| 439 |
+
log_response(message, answer, source_ids, knowledge_pairs, session_id)
|
|
|
|
| 440 |
|
| 441 |
# Log timing data
|
| 442 |
timing_summary = timer.get_timing_summary()
|
|
|
|
| 466 |
|
| 467 |
return "I apologize, but I encountered an error processing your request. Please try again."
|
| 468 |
|
| 469 |
+
|
| 470 |
# === Enhanced Gradio UI ===
|
| 471 |
+
def respond(message: str, history: List, session_id: str):
|
| 472 |
"""Gradio's main response function"""
|
| 473 |
if not session_id:
|
| 474 |
session_id = str(uuid.uuid4())
|
|
|
|
| 478 |
|
| 479 |
return "", history
|
| 480 |
|
| 481 |
+
|
| 482 |
def create_interface():
|
| 483 |
+
"""Create Gradio interface"""
|
| 484 |
with gr.Blocks(theme=gr.themes.Soft()) as demo:
|
| 485 |
gr.Markdown("""
|
| 486 |
# ASKXENO
|
|
|
|
| 547 |
|
| 548 |
return demo
|
| 549 |
|
| 550 |
+
|
| 551 |
if __name__ == "__main__":
|
| 552 |
iface = create_interface()
|
| 553 |
+
iface.launch(
|
| 554 |
+
share=False,
|
| 555 |
+
server_name=SERVER_NAME,
|
| 556 |
+
server_port=SERVER_PORT,
|
| 557 |
+
ssr_mode=False
|
| 558 |
+
)
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
xeno-bot:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
dockerfile: Dockerfile
|
| 6 |
+
ports:
|
| 7 |
+
- "7860:7860"
|
| 8 |
+
environment:
|
| 9 |
+
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
| 10 |
+
- GOOGLE_SHEETS_CREDENTIALS=${GOOGLE_SHEETS_CREDENTIALS}
|
| 11 |
+
volumes:
|
| 12 |
+
- xeno_db:/tmp/xeno_db
|
| 13 |
+
- chroma_cache:/root/.cache/chroma
|
| 14 |
+
- ./xeno_memory.db:/app/xeno_memory.db
|
| 15 |
+
restart: unless-stopped
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD", "python", "-c", "import sys, urllib.request; sys.exit(0) if urllib.request.urlopen('http://localhost:7860').status == 200 else sys.exit(1)"]
|
| 18 |
+
interval: 30s
|
| 19 |
+
timeout: 10s
|
| 20 |
+
retries: 3
|
| 21 |
+
start_period: 5s
|
| 22 |
+
|
| 23 |
+
volumes:
|
| 24 |
+
xeno_db:
|
| 25 |
+
driver: local
|
| 26 |
+
chroma_cache:
|
| 27 |
+
driver: local
|
newnewfile.py
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
print('hello')
|
|
|
|
|
|
pytest.ini
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
python_classes = Test*
|
| 5 |
+
python_functions = test_*
|
requirements.txt
CHANGED
|
@@ -2,7 +2,7 @@ huggingface_hub==0.25.2
|
|
| 2 |
gradio
|
| 3 |
pydantic==2.10.6
|
| 4 |
pandas
|
| 5 |
-
torch
|
| 6 |
numpy
|
| 7 |
sentence-transformers
|
| 8 |
google-generativeai
|
|
@@ -13,4 +13,9 @@ langchain-chroma
|
|
| 13 |
gspread
|
| 14 |
google-auth
|
| 15 |
|
| 16 |
-
python-dateutil
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
gradio
|
| 3 |
pydantic==2.10.6
|
| 4 |
pandas
|
| 5 |
+
torch==2.3.1+cpu
|
| 6 |
numpy
|
| 7 |
sentence-transformers
|
| 8 |
google-generativeai
|
|
|
|
| 13 |
gspread
|
| 14 |
google-auth
|
| 15 |
|
| 16 |
+
python-dateutil
|
| 17 |
+
|
| 18 |
+
# Testing dependencies
|
| 19 |
+
pytest>=7.0.0
|
| 20 |
+
pytest-cov>=4.0.0
|
| 21 |
+
pytest-mock>=3.10.0
|
run_tests.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test runner script for XENO Bot
|
| 3 |
+
Run this script to execute all tests with coverage reporting
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
import subprocess
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def run_tests():
|
| 10 |
+
"""Run all tests with coverage"""
|
| 11 |
+
print("=" * 70)
|
| 12 |
+
print("Running XENO Bot Unit Tests")
|
| 13 |
+
print("=" * 70)
|
| 14 |
+
|
| 15 |
+
# Run pytest with coverage
|
| 16 |
+
cmd = [
|
| 17 |
+
sys.executable, "-m", "pytest",
|
| 18 |
+
"--cov=src",
|
| 19 |
+
"--cov-report=term-missing",
|
| 20 |
+
"--cov-report=html",
|
| 21 |
+
"-v"
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
result = subprocess.run(cmd)
|
| 25 |
+
|
| 26 |
+
print("\n" + "=" * 70)
|
| 27 |
+
if result.returncode == 0:
|
| 28 |
+
print("✓ All tests passed!")
|
| 29 |
+
print("Coverage report generated in htmlcov/index.html")
|
| 30 |
+
else:
|
| 31 |
+
print("✗ Some tests failed!")
|
| 32 |
+
print("=" * 70)
|
| 33 |
+
|
| 34 |
+
return result.returncode
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
sys.exit(run_tests())
|
setup.cfg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[tool:pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
python_classes = Test*
|
| 5 |
+
python_functions = test_*
|
| 6 |
+
addopts =
|
| 7 |
+
-v
|
| 8 |
+
--tb=short
|
| 9 |
+
--strict-markers
|
| 10 |
+
--disable-warnings
|
| 11 |
+
|
| 12 |
+
# Coverage options
|
| 13 |
+
[coverage:run]
|
| 14 |
+
source = src
|
| 15 |
+
omit =
|
| 16 |
+
*/tests/*
|
| 17 |
+
*/test_*
|
| 18 |
+
*/__pycache__/*
|
| 19 |
+
|
| 20 |
+
[coverage:report]
|
| 21 |
+
exclude_lines =
|
| 22 |
+
pragma: no cover
|
| 23 |
+
def __repr__
|
| 24 |
+
raise AssertionError
|
| 25 |
+
raise NotImplementedError
|
| 26 |
+
if __name__ == .__main__.:
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
@abstractmethod
|
src/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
XENO Bot source package
|
| 3 |
+
"""
|
src/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module for XENO Bot
|
| 3 |
+
Handles environment variables and application settings
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import google.generativeai as genai
|
| 7 |
+
|
| 8 |
+
# === API Configuration ===
|
| 9 |
+
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY")
|
| 10 |
+
if not GEMINI_API_KEY:
|
| 11 |
+
raise ValueError("GEMINI_API_KEY environment variable not set.")
|
| 12 |
+
|
| 13 |
+
genai.configure(api_key=GEMINI_API_KEY)
|
| 14 |
+
|
| 15 |
+
# === Model Configuration ===
|
| 16 |
+
EMBEDDING_MODEL = "models/embedding-001"
|
| 17 |
+
LLM_MODEL_NAME = "models/gemma-3-4b-it"
|
| 18 |
+
|
| 19 |
+
# === Database Configuration ===
|
| 20 |
+
COLLECTION_NAME = "xeno_collection"
|
| 21 |
+
CHROMA_DB_PATH = "/tmp/xeno_db"
|
| 22 |
+
SQLITE_DB_PATH = "xeno_memory.db"
|
| 23 |
+
|
| 24 |
+
# === Knowledge Base Configuration ===
|
| 25 |
+
KNOWLEDGE_BASE_PATH = "XENO_Uganda_KnowledgeBase_Advisory.json"
|
| 26 |
+
|
| 27 |
+
# === Google Sheets Configuration ===
|
| 28 |
+
GOOGLE_SHEETS_CREDENTIALS_ENV = "GOOGLE_SHEETS_CREDENTIALS"
|
| 29 |
+
SPREADSHEET_NAME = "Response_Log"
|
| 30 |
+
RESPONSE_SHEET_INDEX = 0 # sheet1
|
| 31 |
+
TIMING_SHEET_NAME = "Timing_Log"
|
| 32 |
+
|
| 33 |
+
# === RAG Configuration ===
|
| 34 |
+
RAG_TOP_K = 4
|
| 35 |
+
RAG_MAX_RESULTS = 2
|
| 36 |
+
SIMILARITY_THRESHOLD = 0.4
|
| 37 |
+
|
| 38 |
+
# === Server Configuration ===
|
| 39 |
+
SERVER_NAME = "0.0.0.0"
|
| 40 |
+
SERVER_PORT = 7860
|
| 41 |
+
|
| 42 |
+
# === Prompt Configuration ===
|
| 43 |
+
SYSTEM_PROMPT = """You are a friendly XENO Support Assistant, an AI-powered helpful and professional customer service representative.
|
| 44 |
+
Use only the information provided in the knowledge base context to answer user queries.
|
| 45 |
+
Do not hallucinate. If context doesn't contain relevant info, say so in a calm polite manner by saying I'm sorry, I can't assist with that.
|
| 46 |
+
Only use context that is clearly relevant to the user's question.
|
| 47 |
+
For greetings like "hi" or "hello", respond politely without using the context.
|
| 48 |
+
remember previous conversations."""
|
src/intent_classifier.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Intent Classification module for XENO Bot
|
| 3 |
+
Handles classification of user intents (greetings, thanks, goodbye, queries)
|
| 4 |
+
"""
|
| 5 |
+
import re
|
| 6 |
+
import random
|
| 7 |
+
from typing import Tuple, List
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class IntentClassifier:
|
| 11 |
+
"""Classifies user intents and provides appropriate responses"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.intent_patterns = {
|
| 15 |
+
'greeting': {
|
| 16 |
+
'patterns': [
|
| 17 |
+
r'\b(hi|hello|hey|good morning|good afternoon|good evening|greetings)\b',
|
| 18 |
+
r'^(hi|hello|hey)[\s!.]*$',
|
| 19 |
+
r'\b(how are you|how do you do)\b'
|
| 20 |
+
],
|
| 21 |
+
'responses': [
|
| 22 |
+
"Hello! I'm XENO Assistant. How can I help you with XENO financial services today?",
|
| 23 |
+
"Hi there! I'm here to assist you with any questions about XENO services. What can I help you with?",
|
| 24 |
+
"Good day! Welcome to XENO Support. How may I assist you today?"
|
| 25 |
+
]
|
| 26 |
+
},
|
| 27 |
+
'thanks': {
|
| 28 |
+
'patterns': [
|
| 29 |
+
r'\b(thank you|thanks|thank u|thx|appreciate|grateful)\b',
|
| 30 |
+
r'^(thanks|thank you)[\s!.]*$',
|
| 31 |
+
r'\b(much appreciated|thanks a lot|thank you so much)\b'
|
| 32 |
+
],
|
| 33 |
+
'responses': [
|
| 34 |
+
"You're welcome! Is there anything else I can help you with regarding XENO services?",
|
| 35 |
+
"Happy to help! Feel free to ask if you have any other questions about XENO.",
|
| 36 |
+
"Glad I could assist you! Let me know if you need help with anything else."
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
'goodbye': {
|
| 40 |
+
'patterns': [
|
| 41 |
+
r'\b(bye|goodbye|see you|farewell|take care|have a good day)\b',
|
| 42 |
+
r'^(bye|goodbye)[\s!.]*$',
|
| 43 |
+
r'\b(talk to you later|see you later|until next time)\b'
|
| 44 |
+
],
|
| 45 |
+
'responses': [
|
| 46 |
+
"Goodbye! Thank you for using XENO services. Have a great day!",
|
| 47 |
+
"Take care! Feel free to return anytime you need help with XENO services.",
|
| 48 |
+
"Have a wonderful day! Don't hesitate to reach out if you need assistance with XENO."
|
| 49 |
+
]
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
def classify_intent(self, message: str, timer=None) -> Tuple[str, str]:
|
| 54 |
+
"""
|
| 55 |
+
Classify the intent of a user message
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
message: User's message
|
| 59 |
+
timer: Optional timer object for tracking
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
Tuple of (intent_name, response_text)
|
| 63 |
+
"""
|
| 64 |
+
if timer:
|
| 65 |
+
with timer.time_step("intent_classification"):
|
| 66 |
+
return self._classify_intent_impl(message)
|
| 67 |
+
else:
|
| 68 |
+
return self._classify_intent_impl(message)
|
| 69 |
+
|
| 70 |
+
def _classify_intent_impl(self, message: str) -> Tuple[str, str]:
|
| 71 |
+
"""Internal implementation of intent classification"""
|
| 72 |
+
message_lower = message.lower().strip()
|
| 73 |
+
|
| 74 |
+
for intent_name, intent_data in self.intent_patterns.items():
|
| 75 |
+
for pattern in intent_data['patterns']:
|
| 76 |
+
if re.search(pattern, message_lower, re.IGNORECASE):
|
| 77 |
+
response = random.choice(intent_data['responses'])
|
| 78 |
+
return intent_name, response
|
| 79 |
+
|
| 80 |
+
return 'query', ''
|
| 81 |
+
|
| 82 |
+
def is_simple_intent(self, intent: str) -> bool:
|
| 83 |
+
"""
|
| 84 |
+
Check if the intent is a simple one that doesn't require RAG
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
intent: Intent name
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
True if simple intent, False otherwise
|
| 91 |
+
"""
|
| 92 |
+
simple_intents = ['greeting', 'thanks']
|
| 93 |
+
return intent in simple_intents
|
| 94 |
+
|
| 95 |
+
def add_intent(self, intent_name: str, patterns: List[str], responses: List[str]):
|
| 96 |
+
"""
|
| 97 |
+
Add a new intent to the classifier
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
intent_name: Name of the intent
|
| 101 |
+
patterns: List of regex patterns to match
|
| 102 |
+
responses: List of possible responses
|
| 103 |
+
"""
|
| 104 |
+
self.intent_patterns[intent_name] = {
|
| 105 |
+
'patterns': patterns,
|
| 106 |
+
'responses': responses
|
| 107 |
+
}
|
src/knowledge_base.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Knowledge Base module for XENO Bot
|
| 3 |
+
Handles loading and preparing knowledge base data
|
| 4 |
+
"""
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from typing import List, Dict, Tuple, Any
|
| 7 |
+
from src.config import KNOWLEDGE_BASE_PATH
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_knowledge_base(filepath: str = KNOWLEDGE_BASE_PATH) -> pd.DataFrame:
|
| 11 |
+
"""
|
| 12 |
+
Load knowledge base from JSON file
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
filepath: Path to the knowledge base JSON file
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
DataFrame with knowledge base data
|
| 19 |
+
"""
|
| 20 |
+
df = pd.read_json(filepath)
|
| 21 |
+
df.dropna(subset=['Content'], inplace=True)
|
| 22 |
+
return df
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def prepare_documents(data: List[Dict[str, Any]]) -> Tuple[List[str], List[Dict], List[str]]:
|
| 26 |
+
"""
|
| 27 |
+
Prepare documents for vector store
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
data: List of knowledge base entries
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Tuple of (documents, metadatas, ids)
|
| 34 |
+
"""
|
| 35 |
+
documents, metadatas, ids = [], [], []
|
| 36 |
+
|
| 37 |
+
for item in data:
|
| 38 |
+
# Create document text with question and answer
|
| 39 |
+
document_text = f"Question: {item['Question']}\nAnswer: {item['Content']}"
|
| 40 |
+
documents.append(document_text)
|
| 41 |
+
|
| 42 |
+
# Create metadata
|
| 43 |
+
metadata = {
|
| 44 |
+
"question": item["Question"],
|
| 45 |
+
"content": item["Content"],
|
| 46 |
+
"section": item.get("Section", ""),
|
| 47 |
+
"source": item.get("Source", ""),
|
| 48 |
+
"owner": item.get("Owner", ""),
|
| 49 |
+
"tag": item.get("Tag", ""),
|
| 50 |
+
"id": item["ID"]
|
| 51 |
+
}
|
| 52 |
+
metadatas.append(metadata)
|
| 53 |
+
|
| 54 |
+
# Add ID
|
| 55 |
+
ids.append(item["ID"])
|
| 56 |
+
|
| 57 |
+
return documents, metadatas, ids
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def get_knowledge_base_data() -> Tuple[List[str], List[Dict], List[str]]:
|
| 61 |
+
"""
|
| 62 |
+
Load and prepare knowledge base data
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Tuple of (documents, metadatas, ids)
|
| 66 |
+
"""
|
| 67 |
+
df = load_knowledge_base()
|
| 68 |
+
data_list = df.to_dict('records')
|
| 69 |
+
return prepare_documents(data_list)
|
src/logger.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Logging module for XENO Bot
|
| 3 |
+
Handles Google Sheets logging for responses and timing data
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import List, Tuple, Dict, Optional
|
| 9 |
+
import gspread
|
| 10 |
+
from google.oauth2.service_account import Credentials
|
| 11 |
+
from src.config import (
|
| 12 |
+
GOOGLE_SHEETS_CREDENTIALS_ENV,
|
| 13 |
+
SPREADSHEET_NAME,
|
| 14 |
+
RESPONSE_SHEET_INDEX,
|
| 15 |
+
TIMING_SHEET_NAME
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def get_google_sheets_credentials() -> Credentials:
|
| 20 |
+
"""
|
| 21 |
+
Get Google Sheets credentials from environment variable
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
Google Sheets credentials object
|
| 25 |
+
"""
|
| 26 |
+
credentials_json = os.environ.get(GOOGLE_SHEETS_CREDENTIALS_ENV)
|
| 27 |
+
if not credentials_json:
|
| 28 |
+
raise ValueError(f"{GOOGLE_SHEETS_CREDENTIALS_ENV} environment variable not set.")
|
| 29 |
+
|
| 30 |
+
credentials_dict = json.loads(credentials_json)
|
| 31 |
+
scope = [
|
| 32 |
+
"https://spreadsheets.google.com/feeds",
|
| 33 |
+
"https://www.googleapis.com/auth/drive"
|
| 34 |
+
]
|
| 35 |
+
creds = Credentials.from_service_account_info(credentials_dict, scopes=scope)
|
| 36 |
+
|
| 37 |
+
return creds
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def initialize_sheets():
|
| 41 |
+
"""
|
| 42 |
+
Initialize Google Sheets client and get sheets
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Tuple of (response_sheet, timing_sheet)
|
| 46 |
+
"""
|
| 47 |
+
client_gspread = gspread.authorize(get_google_sheets_credentials())
|
| 48 |
+
spreadsheet = client_gspread.open(SPREADSHEET_NAME)
|
| 49 |
+
|
| 50 |
+
# Get response sheet
|
| 51 |
+
response_sheet = spreadsheet.get_worksheet(RESPONSE_SHEET_INDEX)
|
| 52 |
+
|
| 53 |
+
# Get or create timing sheet
|
| 54 |
+
try:
|
| 55 |
+
timing_sheet = spreadsheet.worksheet(TIMING_SHEET_NAME)
|
| 56 |
+
except:
|
| 57 |
+
# Create timing sheet if it doesn't exist
|
| 58 |
+
timing_sheet = spreadsheet.add_worksheet(title=TIMING_SHEET_NAME, rows="1000", cols="15")
|
| 59 |
+
# Add headers
|
| 60 |
+
headers = [
|
| 61 |
+
"Timestamp", "Session_ID", "Question", "Total_Time_MS",
|
| 62 |
+
"Intent_Classification_MS", "Memory_Retrieval_MS", "RAG_Retrieval_MS",
|
| 63 |
+
"Embedding_Generation_MS", "Similarity_Calculation_MS", "Context_Processing_MS",
|
| 64 |
+
"LLM_Generation_MS", "Memory_Update_MS", "Logging_MS", "Error_Step", "Notes"
|
| 65 |
+
]
|
| 66 |
+
timing_sheet.append_row(headers)
|
| 67 |
+
|
| 68 |
+
return response_sheet, timing_sheet
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# Initialize sheets
|
| 72 |
+
response_sheet, timing_sheet = initialize_sheets()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def log_response(question: str, answer: str, source_ids: str,
|
| 76 |
+
knowledge_pairs: List[Tuple[str, str]], session_id: str, timer=None):
|
| 77 |
+
"""
|
| 78 |
+
Log response to Google Sheets
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
question: User's question
|
| 82 |
+
answer: Generated answer
|
| 83 |
+
source_ids: Source IDs used
|
| 84 |
+
knowledge_pairs: Knowledge base Q&A pairs used
|
| 85 |
+
session_id: Session identifier
|
| 86 |
+
timer: Optional timer object for tracking
|
| 87 |
+
"""
|
| 88 |
+
if timer:
|
| 89 |
+
with timer.time_step("response_logging"):
|
| 90 |
+
_log_response_impl(question, answer, source_ids, knowledge_pairs, session_id)
|
| 91 |
+
else:
|
| 92 |
+
_log_response_impl(question, answer, source_ids, knowledge_pairs, session_id)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _log_response_impl(question: str, answer: str, source_ids: str,
|
| 96 |
+
knowledge_pairs: List[Tuple[str, str]], session_id: str):
|
| 97 |
+
"""Internal implementation of response logging"""
|
| 98 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 99 |
+
|
| 100 |
+
# Extract knowledge pairs
|
| 101 |
+
knowledge_question_1 = knowledge_pairs[0][0] if len(knowledge_pairs) > 0 else "N/A"
|
| 102 |
+
knowledge_answer_1 = knowledge_pairs[0][1] if len(knowledge_pairs) > 0 else "N/A"
|
| 103 |
+
knowledge_question_2 = knowledge_pairs[1][0] if len(knowledge_pairs) > 1 else "N/A"
|
| 104 |
+
knowledge_answer_2 = knowledge_pairs[1][1] if len(knowledge_pairs) > 1 else "N/A"
|
| 105 |
+
|
| 106 |
+
row = [
|
| 107 |
+
timestamp, session_id, question, answer, source_ids,
|
| 108 |
+
knowledge_question_1, knowledge_answer_1,
|
| 109 |
+
knowledge_question_2, knowledge_answer_2
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
response_sheet.append_row(row)
|
| 114 |
+
print(f"Logged response: {question} | Source IDs: {source_ids}")
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f"Failed to log to Google Sheet: {e}")
|
| 117 |
+
# Fallback to local file
|
| 118 |
+
with open("/tmp/response_log.txt", "a") as f:
|
| 119 |
+
f.write(f"{timestamp},{question},{answer},{source_ids},{knowledge_question_1},{knowledge_answer_1},{knowledge_question_2},{knowledge_answer_2}\n")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def log_timing_data(question: str, session_id: str, timing_summary: Dict,
|
| 123 |
+
error_step: Optional[str] = None, notes: Optional[str] = None):
|
| 124 |
+
"""
|
| 125 |
+
Log timing data to Google Sheets
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
question: User's question
|
| 129 |
+
session_id: Session identifier
|
| 130 |
+
timing_summary: Timing summary dictionary
|
| 131 |
+
error_step: Step where error occurred (if any)
|
| 132 |
+
notes: Additional notes
|
| 133 |
+
"""
|
| 134 |
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 135 |
+
step_times = timing_summary['step_times']
|
| 136 |
+
|
| 137 |
+
# Truncate long questions
|
| 138 |
+
truncated_question = question[:100] + "..." if len(question) > 100 else question
|
| 139 |
+
|
| 140 |
+
row = [
|
| 141 |
+
timestamp,
|
| 142 |
+
session_id,
|
| 143 |
+
truncated_question,
|
| 144 |
+
timing_summary['total_time_ms'],
|
| 145 |
+
step_times.get('intent_classification', 0),
|
| 146 |
+
step_times.get('memory_retrieval', 0),
|
| 147 |
+
step_times.get('rag_retrieval', 0),
|
| 148 |
+
step_times.get('embedding_generation', 0),
|
| 149 |
+
step_times.get('similarity_calculation', 0),
|
| 150 |
+
step_times.get('context_processing', 0),
|
| 151 |
+
step_times.get('llm_generation', 0),
|
| 152 |
+
step_times.get('memory_update', 0),
|
| 153 |
+
step_times.get('response_logging', 0),
|
| 154 |
+
error_step or "",
|
| 155 |
+
notes or ""
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
timing_sheet.append_row(row)
|
| 160 |
+
print(f"Logged timing data: Total {timing_summary['total_time_ms']}ms")
|
| 161 |
+
except Exception as e:
|
| 162 |
+
print(f"Failed to log timing data: {e}")
|
| 163 |
+
# Fallback to local file
|
| 164 |
+
with open("/tmp/timing_log.txt", "a") as f:
|
| 165 |
+
f.write(f"{timestamp},{session_id},{question},{timing_summary}\n")
|
src/memory.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Memory module for XENO Bot
|
| 3 |
+
Handles LangGraph memory operations using SQLite
|
| 4 |
+
"""
|
| 5 |
+
import uuid
|
| 6 |
+
import sqlite3
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import List, Dict, Any
|
| 9 |
+
from langgraph.checkpoint.sqlite import SqliteSaver
|
| 10 |
+
from src.config import SQLITE_DB_PATH
|
| 11 |
+
|
| 12 |
+
# === LangGraph Memory Setup ===
|
| 13 |
+
conn = sqlite3.connect(SQLITE_DB_PATH, check_same_thread=False)
|
| 14 |
+
memory = SqliteSaver(conn=conn)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def update_memory(config: Dict[str, Any], user_message: str, assistant_message: str, timer=None):
|
| 18 |
+
"""
|
| 19 |
+
Update memory with new messages
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
config: Configuration dictionary with thread_id
|
| 23 |
+
user_message: User's message
|
| 24 |
+
assistant_message: Assistant's response
|
| 25 |
+
timer: Optional timer object for tracking
|
| 26 |
+
"""
|
| 27 |
+
if timer:
|
| 28 |
+
with timer.time_step("memory_update"):
|
| 29 |
+
_update_memory_impl(config, user_message, assistant_message)
|
| 30 |
+
else:
|
| 31 |
+
_update_memory_impl(config, user_message, assistant_message)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _update_memory_impl(config: Dict[str, Any], user_message: str, assistant_message: str):
|
| 35 |
+
"""Internal implementation of memory update"""
|
| 36 |
+
full_checkpoint = memory.get(config) or {}
|
| 37 |
+
messages = full_checkpoint.get("channel_values", {}).get("messages", [])
|
| 38 |
+
|
| 39 |
+
messages.append({"role": "user", "content": user_message})
|
| 40 |
+
messages.append({"role": "assistant", "content": assistant_message})
|
| 41 |
+
|
| 42 |
+
checkpoint_to_save = {
|
| 43 |
+
"v": 1,
|
| 44 |
+
"id": str(uuid.uuid4()),
|
| 45 |
+
"ts": datetime.now().isoformat(),
|
| 46 |
+
"channel_values": {"messages": messages},
|
| 47 |
+
"channel_versions": {},
|
| 48 |
+
"versions_seen": {},
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
memory.put(config, checkpoint_to_save, {}, {})
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def retrieve_memory(config: Dict[str, Any], timer=None) -> List[Dict[str, str]]:
|
| 55 |
+
"""
|
| 56 |
+
Retrieve memory messages for a session
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
config: Configuration dictionary with thread_id
|
| 60 |
+
timer: Optional timer object for tracking
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
List of message dictionaries
|
| 64 |
+
"""
|
| 65 |
+
if timer:
|
| 66 |
+
with timer.time_step("memory_retrieval"):
|
| 67 |
+
return _retrieve_memory_impl(config)
|
| 68 |
+
else:
|
| 69 |
+
return _retrieve_memory_impl(config)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _retrieve_memory_impl(config: Dict[str, Any]) -> List[Dict[str, str]]:
|
| 73 |
+
"""Internal implementation of memory retrieval"""
|
| 74 |
+
full_checkpoint = memory.get(config) or {}
|
| 75 |
+
return full_checkpoint.get("channel_values", {}).get("messages", [])
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def create_session_config(session_id: str = "default") -> Dict[str, Any]:
|
| 79 |
+
"""
|
| 80 |
+
Create a configuration dictionary for a session
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
session_id: Unique session identifier
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Configuration dictionary
|
| 87 |
+
"""
|
| 88 |
+
return {"configurable": {"thread_id": str(session_id), "checkpoint_ns": ""}}
|
src/response_generator.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Response Generation module for XENO Bot
|
| 3 |
+
Handles LLM response generation
|
| 4 |
+
"""
|
| 5 |
+
import google.generativeai as genai
|
| 6 |
+
from typing import List, Dict
|
| 7 |
+
from src.config import LLM_MODEL_NAME, SYSTEM_PROMPT
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def generate_xeno_response(context: str, question: str, chat_history: List[Dict[str, str]], timer=None) -> str:
|
| 11 |
+
"""
|
| 12 |
+
Generate a response using the LLM
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
context: Formatted context from knowledge base
|
| 16 |
+
question: User's question
|
| 17 |
+
chat_history: List of previous messages
|
| 18 |
+
timer: Optional timer object for tracking
|
| 19 |
+
|
| 20 |
+
Returns:
|
| 21 |
+
Generated response text
|
| 22 |
+
"""
|
| 23 |
+
if timer:
|
| 24 |
+
with timer.time_step("llm_generation"):
|
| 25 |
+
return _generate_response_impl(context, question, chat_history)
|
| 26 |
+
else:
|
| 27 |
+
return _generate_response_impl(context, question, chat_history)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _generate_response_impl(context: str, question: str, chat_history: List[Dict[str, str]]) -> str:
|
| 31 |
+
"""Internal implementation of response generation"""
|
| 32 |
+
model = genai.GenerativeModel(LLM_MODEL_NAME)
|
| 33 |
+
|
| 34 |
+
# Format chat history
|
| 35 |
+
formatted_history = "\n".join(
|
| 36 |
+
[f"{msg['role'].capitalize()}: {msg['content']}" for msg in chat_history]
|
| 37 |
+
) if chat_history else "None"
|
| 38 |
+
|
| 39 |
+
# Build prompt
|
| 40 |
+
prompt = f"{SYSTEM_PROMPT}\n### HISTORY ###\n{formatted_history}\n### CONTEXT ###\n{context}\n### QUESTION ###\n{question}"
|
| 41 |
+
|
| 42 |
+
# Generate response
|
| 43 |
+
response = model.generate_content(prompt)
|
| 44 |
+
|
| 45 |
+
return response.text.strip()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def format_chat_history(messages: List[Dict[str, str]]) -> str:
|
| 49 |
+
"""
|
| 50 |
+
Format chat history for display or logging
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
messages: List of message dictionaries with 'role' and 'content'
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Formatted string representation of chat history
|
| 57 |
+
"""
|
| 58 |
+
if not messages:
|
| 59 |
+
return "No previous conversation"
|
| 60 |
+
|
| 61 |
+
formatted = []
|
| 62 |
+
for msg in messages:
|
| 63 |
+
role = msg.get('role', 'unknown').capitalize()
|
| 64 |
+
content = msg.get('content', '')
|
| 65 |
+
formatted.append(f"{role}: {content}")
|
| 66 |
+
|
| 67 |
+
return "\n".join(formatted)
|
src/utils.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utilities module for XENO Bot
|
| 3 |
+
Handles logging and timing functionality
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import sys
|
| 7 |
+
import time
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Dict
|
| 11 |
+
|
| 12 |
+
# ===== Configure Logging =====
|
| 13 |
+
logging.basicConfig(
|
| 14 |
+
filename="app.log",
|
| 15 |
+
level=logging.INFO,
|
| 16 |
+
format="%(asctime)s - %(levelname)s - %(message)s"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def log_exception(exc_type, exc_value, exc_traceback):
|
| 20 |
+
"""Log uncaught exceptions"""
|
| 21 |
+
if issubclass(exc_type, KeyboardInterrupt):
|
| 22 |
+
return
|
| 23 |
+
logging.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
|
| 24 |
+
|
| 25 |
+
sys.excepthook = log_exception
|
| 26 |
+
logging.info("App started successfully.")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ===== Time Tracking Class =====
|
| 30 |
+
class PipelineTimer:
|
| 31 |
+
"""Timer for tracking pipeline execution steps"""
|
| 32 |
+
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self.reset()
|
| 35 |
+
|
| 36 |
+
def reset(self):
|
| 37 |
+
"""Reset all timing data for a new request"""
|
| 38 |
+
self.start_time = time.time()
|
| 39 |
+
self.step_times = {}
|
| 40 |
+
self.step_start = None
|
| 41 |
+
self.current_step = None
|
| 42 |
+
|
| 43 |
+
@contextmanager
|
| 44 |
+
def time_step(self, step_name: str):
|
| 45 |
+
"""Context manager to time a specific step"""
|
| 46 |
+
step_start = time.time()
|
| 47 |
+
self.current_step = step_name
|
| 48 |
+
try:
|
| 49 |
+
yield
|
| 50 |
+
finally:
|
| 51 |
+
step_end = time.time()
|
| 52 |
+
self.step_times[step_name] = round((step_end - step_start) * 1000, 2) # Convert to milliseconds
|
| 53 |
+
self.current_step = None
|
| 54 |
+
|
| 55 |
+
def get_total_time(self):
|
| 56 |
+
"""Get total elapsed time since reset"""
|
| 57 |
+
return round((time.time() - self.start_time) * 1000, 2)
|
| 58 |
+
|
| 59 |
+
def get_timing_summary(self) -> Dict:
|
| 60 |
+
"""Get a summary of all timing data"""
|
| 61 |
+
total_time = self.get_total_time()
|
| 62 |
+
return {
|
| 63 |
+
'total_time_ms': total_time,
|
| 64 |
+
'step_times': self.step_times,
|
| 65 |
+
'timestamp': datetime.now().isoformat()
|
| 66 |
+
}
|
src/vector_store.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vector Store module for XENO Bot
|
| 3 |
+
Handles ChromaDB vector store operations
|
| 4 |
+
"""
|
| 5 |
+
import chromadb
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
from langchain_chroma import Chroma
|
| 9 |
+
from sentence_transformers import util
|
| 10 |
+
from typing import List, Tuple, Any
|
| 11 |
+
import google.generativeai as genai
|
| 12 |
+
from src.config import (
|
| 13 |
+
COLLECTION_NAME,
|
| 14 |
+
CHROMA_DB_PATH,
|
| 15 |
+
RAG_TOP_K,
|
| 16 |
+
RAG_MAX_RESULTS,
|
| 17 |
+
EMBEDDING_MODEL
|
| 18 |
+
)
|
| 19 |
+
from src.knowledge_base import get_knowledge_base_data
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def initialize_vector_store() -> Tuple[chromadb.Collection, Chroma, Any]:
|
| 23 |
+
"""
|
| 24 |
+
Initialize ChromaDB vector store
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
Tuple of (collection, vector_store, retriever)
|
| 28 |
+
"""
|
| 29 |
+
# Get knowledge base data
|
| 30 |
+
documents, metadatas, ids = get_knowledge_base_data()
|
| 31 |
+
|
| 32 |
+
# Initialize ChromaDB client
|
| 33 |
+
try:
|
| 34 |
+
client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
|
| 35 |
+
|
| 36 |
+
# Try to get existing collection
|
| 37 |
+
try:
|
| 38 |
+
collection = client.get_collection(name=COLLECTION_NAME)
|
| 39 |
+
print(f"Loaded existing ChromaDB collection: {COLLECTION_NAME}")
|
| 40 |
+
except:
|
| 41 |
+
# Create new collection if it doesn't exist
|
| 42 |
+
print(f"Creating new ChromaDB collection: {COLLECTION_NAME}")
|
| 43 |
+
collection = client.create_collection(name=COLLECTION_NAME)
|
| 44 |
+
collection.add(documents=documents, metadatas=metadatas, ids=ids)
|
| 45 |
+
|
| 46 |
+
# Create vector store and retriever
|
| 47 |
+
vector_store = Chroma(client=client, collection_name=COLLECTION_NAME)
|
| 48 |
+
retriever = vector_store.as_retriever(
|
| 49 |
+
search_type="similarity",
|
| 50 |
+
search_kwargs={"k": RAG_TOP_K}
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return collection, vector_store, retriever
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"Failed to initialize ChromaDB: {e}")
|
| 57 |
+
raise
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def generate_embeddings(query: str, documents: List[Any], timer=None) -> Tuple[List[float], List[List[float]]]:
|
| 61 |
+
"""
|
| 62 |
+
Generate embeddings for query and documents
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
query: User query
|
| 66 |
+
documents: List of retrieved documents
|
| 67 |
+
timer: Optional timer object for tracking
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Tuple of (query_embedding, doc_embeddings)
|
| 71 |
+
"""
|
| 72 |
+
if timer:
|
| 73 |
+
with timer.time_step("embedding_generation"):
|
| 74 |
+
return _generate_embeddings_impl(query, documents)
|
| 75 |
+
else:
|
| 76 |
+
return _generate_embeddings_impl(query, documents)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _generate_embeddings_impl(query: str, documents: List[Any]) -> Tuple[List[float], List[List[float]]]:
|
| 80 |
+
"""Internal implementation of embedding generation"""
|
| 81 |
+
# Generate query embedding
|
| 82 |
+
query_embedding = genai.embed_content(
|
| 83 |
+
model=EMBEDDING_MODEL,
|
| 84 |
+
content=query,
|
| 85 |
+
task_type="retrieval_query"
|
| 86 |
+
)['embedding']
|
| 87 |
+
|
| 88 |
+
# Generate document embeddings
|
| 89 |
+
doc_embeddings = [
|
| 90 |
+
genai.embed_content(
|
| 91 |
+
model=EMBEDDING_MODEL,
|
| 92 |
+
content=doc.page_content,
|
| 93 |
+
task_type="retrieval_document"
|
| 94 |
+
)['embedding']
|
| 95 |
+
for doc in documents
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
return query_embedding, doc_embeddings
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def calculate_similarity(query_embedding: List[float], doc_embeddings: List[List[float]], timer=None) -> List[float]:
|
| 102 |
+
"""
|
| 103 |
+
Calculate cosine similarity between query and documents
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
query_embedding: Query embedding vector
|
| 107 |
+
doc_embeddings: List of document embedding vectors
|
| 108 |
+
timer: Optional timer object for tracking
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
List of cosine similarity scores
|
| 112 |
+
"""
|
| 113 |
+
if timer:
|
| 114 |
+
with timer.time_step("similarity_calculation"):
|
| 115 |
+
return _calculate_similarity_impl(query_embedding, doc_embeddings)
|
| 116 |
+
else:
|
| 117 |
+
return _calculate_similarity_impl(query_embedding, doc_embeddings)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _calculate_similarity_impl(query_embedding: List[float], doc_embeddings: List[List[float]]) -> List[float]:
|
| 121 |
+
"""Internal implementation of similarity calculation"""
|
| 122 |
+
cosine_scores = util.cos_sim(
|
| 123 |
+
torch.tensor(query_embedding).float(),
|
| 124 |
+
torch.tensor(doc_embeddings).float()
|
| 125 |
+
)[0].tolist()
|
| 126 |
+
|
| 127 |
+
return cosine_scores
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def process_context(results: List[Any], cosine_scores: List[float],
|
| 131 |
+
max_results: int = RAG_MAX_RESULTS, timer=None) -> Tuple[str, List[str], List[Tuple[str, str]]]:
|
| 132 |
+
"""
|
| 133 |
+
Process retrieved context and format for LLM
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
results: List of retrieved documents
|
| 137 |
+
cosine_scores: List of similarity scores
|
| 138 |
+
max_results: Maximum number of results to include
|
| 139 |
+
timer: Optional timer object for tracking
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Tuple of (formatted_context, source_ids, knowledge_pairs)
|
| 143 |
+
"""
|
| 144 |
+
if timer:
|
| 145 |
+
with timer.time_step("context_processing"):
|
| 146 |
+
return _process_context_impl(results, cosine_scores, max_results)
|
| 147 |
+
else:
|
| 148 |
+
return _process_context_impl(results, cosine_scores, max_results)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _process_context_impl(results: List[Any], cosine_scores: List[float],
|
| 152 |
+
max_results: int) -> Tuple[str, List[str], List[Tuple[str, str]]]:
|
| 153 |
+
"""Internal implementation of context processing"""
|
| 154 |
+
sorted_indices = np.argsort(cosine_scores)[::-1][:max_results]
|
| 155 |
+
|
| 156 |
+
formatted_context = ""
|
| 157 |
+
source_ids = []
|
| 158 |
+
knowledge_pairs = []
|
| 159 |
+
|
| 160 |
+
for i, idx in enumerate(sorted_indices, 1):
|
| 161 |
+
result = results[idx]
|
| 162 |
+
score = cosine_scores[idx]
|
| 163 |
+
|
| 164 |
+
question = result.metadata.get('question', 'N/A')
|
| 165 |
+
answer = result.metadata.get('content', 'N/A')
|
| 166 |
+
|
| 167 |
+
formatted_context += f"Knowledge Entry {i}:\n"
|
| 168 |
+
formatted_context += f"Q: {question}\n"
|
| 169 |
+
formatted_context += f"A: {answer}\n"
|
| 170 |
+
formatted_context += "-" * 40 + "\n"
|
| 171 |
+
|
| 172 |
+
source_ids.append(result.metadata.get('id', 'N/A'))
|
| 173 |
+
knowledge_pairs.append((question, answer))
|
| 174 |
+
|
| 175 |
+
return formatted_context, source_ids, knowledge_pairs
|
tests/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# XENO Bot - Unit Tests
|
| 2 |
+
|
| 3 |
+
This directory contains comprehensive unit tests for the XENO Bot application.
|
| 4 |
+
|
| 5 |
+
## Test Coverage
|
| 6 |
+
|
| 7 |
+
The test suite covers the following modules:
|
| 8 |
+
|
| 9 |
+
1. **test_utils.py** - Tests for PipelineTimer and logging utilities
|
| 10 |
+
2. **test_intent_classifier.py** - Tests for intent classification (greetings, thanks, goodbye, queries)
|
| 11 |
+
3. **test_knowledge_base.py** - Tests for knowledge base loading and document preparation
|
| 12 |
+
4. **test_memory.py** - Tests for LangGraph memory operations (SQLite-based)
|
| 13 |
+
5. **test_response_generator.py** - Tests for LLM response generation
|
| 14 |
+
6. **test_vector_store.py** - Tests for ChromaDB vector store operations
|
| 15 |
+
7. **test_logger.py** - Tests for Google Sheets logging functionality
|
| 16 |
+
|
| 17 |
+
## Running Tests
|
| 18 |
+
|
| 19 |
+
### Install Test Dependencies
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
pip install -r requirements.txt
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
### Run All Tests
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
pytest
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
### Run Specific Test File
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
pytest tests/test_utils.py
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
### Run with Coverage Report
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
pytest --cov=src --cov-report=html
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
This will generate an HTML coverage report in the `htmlcov/` directory.
|
| 44 |
+
|
| 45 |
+
### Run with Verbose Output
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
pytest -v
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### Run Specific Test
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
pytest tests/test_intent_classifier.py::TestIntentClassifier::test_classify_greeting
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
## Test Structure
|
| 58 |
+
|
| 59 |
+
Each test file follows a consistent structure:
|
| 60 |
+
|
| 61 |
+
- **setUp()**: Initialize test fixtures and mock data
|
| 62 |
+
- **tearDown()**: Clean up after tests (if needed)
|
| 63 |
+
- **test_***: Individual test methods
|
| 64 |
+
|
| 65 |
+
## Mocking
|
| 66 |
+
|
| 67 |
+
Tests use Python's `unittest.mock` to:
|
| 68 |
+
- Mock external API calls (Google Generative AI)
|
| 69 |
+
- Mock database connections (ChromaDB, SQLite)
|
| 70 |
+
- Mock Google Sheets operations
|
| 71 |
+
- Isolate units under test
|
| 72 |
+
|
| 73 |
+
## Best Practices
|
| 74 |
+
|
| 75 |
+
- Tests are isolated and don't depend on external services
|
| 76 |
+
- Each test focuses on a single behavior
|
| 77 |
+
- Mock objects are used to simulate dependencies
|
| 78 |
+
- Tests include both positive and negative scenarios
|
| 79 |
+
- Edge cases are covered (empty inputs, errors, etc.)
|
| 80 |
+
|
| 81 |
+
## Continuous Integration
|
| 82 |
+
|
| 83 |
+
To integrate with CI/CD pipelines, add to your workflow:
|
| 84 |
+
|
| 85 |
+
```yaml
|
| 86 |
+
- name: Run tests
|
| 87 |
+
run: |
|
| 88 |
+
pip install -r requirements.txt
|
| 89 |
+
pytest --cov=src --cov-report=xml
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
## Test Configuration
|
| 93 |
+
|
| 94 |
+
Test configuration is defined in:
|
| 95 |
+
- `pytest.ini` - Pytest configuration
|
| 96 |
+
- `setup.cfg` - Additional pytest and coverage settings
|
| 97 |
+
|
| 98 |
+
## Writing New Tests
|
| 99 |
+
|
| 100 |
+
When adding new functionality:
|
| 101 |
+
|
| 102 |
+
1. Create a test file in `tests/` following the naming convention `test_<module>.py`
|
| 103 |
+
2. Create test class inheriting from `unittest.TestCase`
|
| 104 |
+
3. Add test methods starting with `test_`
|
| 105 |
+
4. Use mocks for external dependencies
|
| 106 |
+
5. Run tests to ensure they pass
|
| 107 |
+
|
| 108 |
+
Example:
|
| 109 |
+
|
| 110 |
+
```python
|
| 111 |
+
import unittest
|
| 112 |
+
from unittest.mock import patch
|
| 113 |
+
from src.my_module import my_function
|
| 114 |
+
|
| 115 |
+
class TestMyModule(unittest.TestCase):
|
| 116 |
+
def test_my_function(self):
|
| 117 |
+
result = my_function("test")
|
| 118 |
+
self.assertEqual(result, expected_value)
|
| 119 |
+
```
|
tests/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for XENO Bot
|
| 3 |
+
"""
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pytest configuration file
|
| 3 |
+
Sets up test environment and fixtures
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
import pytest
|
| 8 |
+
from unittest.mock import Mock, MagicMock, patch, PropertyMock
|
| 9 |
+
|
| 10 |
+
# Add src to path
|
| 11 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
| 12 |
+
|
| 13 |
+
# Set mock environment variables before importing any modules
|
| 14 |
+
os.environ.setdefault('GEMINI_API_KEY', 'test-api-key-12345')
|
| 15 |
+
|
| 16 |
+
# Mock Google Sheets credentials
|
| 17 |
+
mock_credentials = {
|
| 18 |
+
"type": "service_account",
|
| 19 |
+
"project_id": "test-project",
|
| 20 |
+
"private_key_id": "test-key-id",
|
| 21 |
+
"private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n",
|
| 22 |
+
"client_email": "test@test.iam.gserviceaccount.com",
|
| 23 |
+
"client_id": "12345",
|
| 24 |
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
| 25 |
+
"token_uri": "https://oauth2.googleapis.com/token",
|
| 26 |
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
| 27 |
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test"
|
| 28 |
+
}
|
| 29 |
+
import json
|
| 30 |
+
os.environ.setdefault('GOOGLE_SHEETS_CREDENTIALS', json.dumps(mock_credentials))
|
| 31 |
+
|
| 32 |
+
# Mock google.oauth2 and gspread modules before src.logger imports them
|
| 33 |
+
mock_credentials_class = MagicMock()
|
| 34 |
+
mock_creds_instance = MagicMock()
|
| 35 |
+
mock_credentials_class.from_service_account_info = Mock(return_value=mock_creds_instance)
|
| 36 |
+
|
| 37 |
+
mock_oauth2 = MagicMock()
|
| 38 |
+
mock_oauth2.service_account.Credentials = mock_credentials_class
|
| 39 |
+
sys.modules['google.oauth2'] = mock_oauth2
|
| 40 |
+
sys.modules['google.oauth2.service_account'] = mock_oauth2.service_account
|
| 41 |
+
|
| 42 |
+
mock_gspread = MagicMock()
|
| 43 |
+
mock_spreadsheet = MagicMock()
|
| 44 |
+
mock_worksheet = MagicMock()
|
| 45 |
+
mock_worksheet.append_row = Mock()
|
| 46 |
+
mock_spreadsheet.get_worksheet = Mock(return_value=mock_worksheet)
|
| 47 |
+
mock_spreadsheet.worksheet = Mock(return_value=mock_worksheet)
|
| 48 |
+
mock_spreadsheet.add_worksheet = Mock(return_value=mock_worksheet)
|
| 49 |
+
mock_client = MagicMock()
|
| 50 |
+
mock_client.open = Mock(return_value=mock_spreadsheet)
|
| 51 |
+
mock_gspread.authorize = Mock(return_value=mock_client)
|
| 52 |
+
sys.modules['gspread'] = mock_gspread
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.fixture(autouse=True)
|
| 56 |
+
def mock_google_sheets():
|
| 57 |
+
"""Mock Google Sheets to avoid actual connections during testing"""
|
| 58 |
+
with patch('src.logger.response_sheet') as mock_response, \
|
| 59 |
+
patch('src.logger.timing_sheet') as mock_timing:
|
| 60 |
+
mock_response.append_row = Mock()
|
| 61 |
+
mock_timing.append_row = Mock()
|
| 62 |
+
yield mock_response, mock_timing
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@pytest.fixture
|
| 66 |
+
def mock_genai():
|
| 67 |
+
"""Mock Google Generative AI"""
|
| 68 |
+
with patch('google.generativeai.configure') as mock_config, \
|
| 69 |
+
patch('google.generativeai.GenerativeModel') as mock_model, \
|
| 70 |
+
patch('google.generativeai.embed_content') as mock_embed:
|
| 71 |
+
yield {
|
| 72 |
+
'configure': mock_config,
|
| 73 |
+
'model': mock_model,
|
| 74 |
+
'embed': mock_embed
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.fixture
|
| 79 |
+
def mock_chromadb():
|
| 80 |
+
"""Mock ChromaDB client"""
|
| 81 |
+
with patch('chromadb.PersistentClient') as mock_client:
|
| 82 |
+
mock_collection = Mock()
|
| 83 |
+
mock_client.return_value.get_collection.return_value = mock_collection
|
| 84 |
+
yield mock_client
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.fixture
|
| 88 |
+
def mock_sqlite():
|
| 89 |
+
"""Mock SQLite connections for memory"""
|
| 90 |
+
with patch('sqlite3.connect') as mock_connect:
|
| 91 |
+
mock_conn = Mock()
|
| 92 |
+
mock_connect.return_value = mock_conn
|
| 93 |
+
yield mock_conn
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@pytest.fixture
|
| 97 |
+
def sample_documents():
|
| 98 |
+
"""Provide sample documents for testing"""
|
| 99 |
+
doc1 = Mock()
|
| 100 |
+
doc1.page_content = "Question: How do I create an account?\nAnswer: Visit our website."
|
| 101 |
+
doc1.metadata = {
|
| 102 |
+
'id': 'KB001',
|
| 103 |
+
'question': 'How do I create an account?',
|
| 104 |
+
'content': 'Visit our website.',
|
| 105 |
+
'section': 'Account Management'
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
doc2 = Mock()
|
| 109 |
+
doc2.page_content = "Question: What are the fees?\nAnswer: 1% per transaction."
|
| 110 |
+
doc2.metadata = {
|
| 111 |
+
'id': 'KB002',
|
| 112 |
+
'question': 'What are the fees?',
|
| 113 |
+
'content': '1% per transaction.',
|
| 114 |
+
'section': 'Fees'
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
return [doc1, doc2]
|
tests/test_intent_classifier.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for intent_classifier module
|
| 3 |
+
Tests the IntentClassifier class
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
from unittest.mock import Mock
|
| 7 |
+
from src.intent_classifier import IntentClassifier
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestIntentClassifier(unittest.TestCase):
|
| 11 |
+
"""Test cases for IntentClassifier class"""
|
| 12 |
+
|
| 13 |
+
def setUp(self):
|
| 14 |
+
"""Set up test fixtures"""
|
| 15 |
+
self.classifier = IntentClassifier()
|
| 16 |
+
|
| 17 |
+
def test_initialization(self):
|
| 18 |
+
"""Test classifier initialization"""
|
| 19 |
+
self.assertIsNotNone(self.classifier.intent_patterns)
|
| 20 |
+
self.assertIn('greeting', self.classifier.intent_patterns)
|
| 21 |
+
self.assertIn('thanks', self.classifier.intent_patterns)
|
| 22 |
+
self.assertIn('goodbye', self.classifier.intent_patterns)
|
| 23 |
+
|
| 24 |
+
def test_classify_greeting(self):
|
| 25 |
+
"""Test classification of greeting messages"""
|
| 26 |
+
test_cases = [
|
| 27 |
+
"hi",
|
| 28 |
+
"hello",
|
| 29 |
+
"Hey there",
|
| 30 |
+
"good morning",
|
| 31 |
+
"Good afternoon!",
|
| 32 |
+
"how are you"
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
for message in test_cases:
|
| 36 |
+
intent, response = self.classifier.classify_intent(message)
|
| 37 |
+
self.assertEqual(intent, 'greeting', f"Failed for message: {message}")
|
| 38 |
+
self.assertIsInstance(response, str)
|
| 39 |
+
self.assertGreater(len(response), 0)
|
| 40 |
+
|
| 41 |
+
def test_classify_thanks(self):
|
| 42 |
+
"""Test classification of thank you messages"""
|
| 43 |
+
test_cases = [
|
| 44 |
+
"thank you",
|
| 45 |
+
"thanks",
|
| 46 |
+
"thank u",
|
| 47 |
+
"thx",
|
| 48 |
+
"I appreciate it",
|
| 49 |
+
"thanks a lot",
|
| 50 |
+
"thank you so much"
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
for message in test_cases:
|
| 54 |
+
intent, response = self.classifier.classify_intent(message)
|
| 55 |
+
self.assertEqual(intent, 'thanks', f"Failed for message: {message}")
|
| 56 |
+
self.assertIsInstance(response, str)
|
| 57 |
+
self.assertGreater(len(response), 0)
|
| 58 |
+
|
| 59 |
+
def test_classify_goodbye(self):
|
| 60 |
+
"""Test classification of goodbye messages"""
|
| 61 |
+
test_cases = [
|
| 62 |
+
"bye",
|
| 63 |
+
"goodbye",
|
| 64 |
+
"see you",
|
| 65 |
+
"farewell",
|
| 66 |
+
"take care",
|
| 67 |
+
"have a good day",
|
| 68 |
+
"talk to you later"
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
for message in test_cases:
|
| 72 |
+
intent, response = self.classifier.classify_intent(message)
|
| 73 |
+
self.assertEqual(intent, 'goodbye', f"Failed for message: {message}")
|
| 74 |
+
self.assertIsInstance(response, str)
|
| 75 |
+
self.assertGreater(len(response), 0)
|
| 76 |
+
|
| 77 |
+
def test_classify_query(self):
|
| 78 |
+
"""Test classification of query messages"""
|
| 79 |
+
test_cases = [
|
| 80 |
+
"How do I open an account?",
|
| 81 |
+
"What are the transaction fees?",
|
| 82 |
+
"Can you help me with my balance?",
|
| 83 |
+
"Tell me about XENO services"
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
for message in test_cases:
|
| 87 |
+
intent, response = self.classifier.classify_intent(message)
|
| 88 |
+
self.assertEqual(intent, 'query', f"Failed for message: {message}")
|
| 89 |
+
self.assertEqual(response, '')
|
| 90 |
+
|
| 91 |
+
def test_case_insensitivity(self):
|
| 92 |
+
"""Test that classification is case insensitive"""
|
| 93 |
+
messages = [
|
| 94 |
+
("HI", 'greeting'),
|
| 95 |
+
("THANK YOU", 'thanks'),
|
| 96 |
+
("BYE", 'goodbye'),
|
| 97 |
+
("Hi There", 'greeting')
|
| 98 |
+
]
|
| 99 |
+
|
| 100 |
+
for message, expected_intent in messages:
|
| 101 |
+
intent, _ = self.classifier.classify_intent(message)
|
| 102 |
+
self.assertEqual(intent, expected_intent)
|
| 103 |
+
|
| 104 |
+
def test_with_timer(self):
|
| 105 |
+
"""Test classification with timer object"""
|
| 106 |
+
mock_timer = Mock()
|
| 107 |
+
mock_timer.time_step = Mock()
|
| 108 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 109 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 110 |
+
|
| 111 |
+
intent, response = self.classifier.classify_intent("hello", timer=mock_timer)
|
| 112 |
+
|
| 113 |
+
self.assertEqual(intent, 'greeting')
|
| 114 |
+
mock_timer.time_step.assert_called_once_with("intent_classification")
|
| 115 |
+
|
| 116 |
+
def test_is_simple_intent(self):
|
| 117 |
+
"""Test is_simple_intent method"""
|
| 118 |
+
self.assertTrue(self.classifier.is_simple_intent('greeting'))
|
| 119 |
+
self.assertTrue(self.classifier.is_simple_intent('thanks'))
|
| 120 |
+
self.assertFalse(self.classifier.is_simple_intent('goodbye'))
|
| 121 |
+
self.assertFalse(self.classifier.is_simple_intent('query'))
|
| 122 |
+
|
| 123 |
+
def test_add_intent(self):
|
| 124 |
+
"""Test adding a new intent"""
|
| 125 |
+
patterns = [r'\b(test|testing)\b']
|
| 126 |
+
responses = ["This is a test response"]
|
| 127 |
+
|
| 128 |
+
self.classifier.add_intent('test_intent', patterns, responses)
|
| 129 |
+
|
| 130 |
+
# Verify intent was added
|
| 131 |
+
self.assertIn('test_intent', self.classifier.intent_patterns)
|
| 132 |
+
self.assertEqual(self.classifier.intent_patterns['test_intent']['patterns'], patterns)
|
| 133 |
+
self.assertEqual(self.classifier.intent_patterns['test_intent']['responses'], responses)
|
| 134 |
+
|
| 135 |
+
# Test classification with new intent
|
| 136 |
+
intent, response = self.classifier.classify_intent("testing")
|
| 137 |
+
self.assertEqual(intent, 'test_intent')
|
| 138 |
+
self.assertEqual(response, "This is a test response")
|
| 139 |
+
|
| 140 |
+
def test_response_variety(self):
|
| 141 |
+
"""Test that responses vary (random selection)"""
|
| 142 |
+
# Multiple calls might return different responses
|
| 143 |
+
responses = set()
|
| 144 |
+
for _ in range(20):
|
| 145 |
+
_, response = self.classifier.classify_intent("hello")
|
| 146 |
+
responses.add(response)
|
| 147 |
+
|
| 148 |
+
# Should have at least 1 response (could be more if random varies)
|
| 149 |
+
self.assertGreater(len(responses), 0)
|
| 150 |
+
|
| 151 |
+
def test_empty_message(self):
|
| 152 |
+
"""Test classification of empty or whitespace messages"""
|
| 153 |
+
test_cases = ["", " ", "\n", "\t"]
|
| 154 |
+
|
| 155 |
+
for message in test_cases:
|
| 156 |
+
intent, response = self.classifier.classify_intent(message)
|
| 157 |
+
self.assertEqual(intent, 'query')
|
| 158 |
+
self.assertEqual(response, '')
|
| 159 |
+
|
| 160 |
+
def test_mixed_intent_message(self):
|
| 161 |
+
"""Test messages that might match multiple patterns"""
|
| 162 |
+
# "hi thank you" should match greeting (first match wins)
|
| 163 |
+
intent, response = self.classifier.classify_intent("hi thank you")
|
| 164 |
+
# Should match the first pattern it encounters
|
| 165 |
+
self.assertIn(intent, ['greeting', 'thanks'])
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == '__main__':
|
| 169 |
+
unittest.main()
|
tests/test_knowledge_base.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for knowledge_base module
|
| 3 |
+
Tests knowledge base loading and preparation
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import json
|
| 8 |
+
import tempfile
|
| 9 |
+
import os
|
| 10 |
+
from unittest.mock import patch, Mock
|
| 11 |
+
from src.knowledge_base import (
|
| 12 |
+
load_knowledge_base,
|
| 13 |
+
prepare_documents,
|
| 14 |
+
get_knowledge_base_data
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TestKnowledgeBase(unittest.TestCase):
|
| 19 |
+
"""Test cases for knowledge_base module"""
|
| 20 |
+
|
| 21 |
+
def setUp(self):
|
| 22 |
+
"""Set up test fixtures"""
|
| 23 |
+
# Create sample knowledge base data
|
| 24 |
+
self.sample_data = [
|
| 25 |
+
{
|
| 26 |
+
"ID": "KB001",
|
| 27 |
+
"Question": "How do I create an account?",
|
| 28 |
+
"Content": "You can create an account by visiting our website.",
|
| 29 |
+
"Section": "Account Management",
|
| 30 |
+
"Source": "Website",
|
| 31 |
+
"Owner": "Support Team",
|
| 32 |
+
"Tag": "account"
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"ID": "KB002",
|
| 36 |
+
"Question": "What are the fees?",
|
| 37 |
+
"Content": "Our transaction fees are 1% per transaction.",
|
| 38 |
+
"Section": "Fees",
|
| 39 |
+
"Source": "Documentation",
|
| 40 |
+
"Owner": "Finance Team",
|
| 41 |
+
"Tag": "fees"
|
| 42 |
+
}
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
# Create temporary JSON file
|
| 46 |
+
self.temp_file = tempfile.NamedTemporaryFile(
|
| 47 |
+
mode='w',
|
| 48 |
+
delete=False,
|
| 49 |
+
suffix='.json'
|
| 50 |
+
)
|
| 51 |
+
json.dump(self.sample_data, self.temp_file)
|
| 52 |
+
self.temp_file.close()
|
| 53 |
+
|
| 54 |
+
def tearDown(self):
|
| 55 |
+
"""Clean up test fixtures"""
|
| 56 |
+
if os.path.exists(self.temp_file.name):
|
| 57 |
+
os.unlink(self.temp_file.name)
|
| 58 |
+
|
| 59 |
+
def test_load_knowledge_base(self):
|
| 60 |
+
"""Test loading knowledge base from JSON file"""
|
| 61 |
+
df = load_knowledge_base(self.temp_file.name)
|
| 62 |
+
|
| 63 |
+
# Check DataFrame structure
|
| 64 |
+
self.assertIsInstance(df, pd.DataFrame)
|
| 65 |
+
self.assertEqual(len(df), 2)
|
| 66 |
+
self.assertIn('ID', df.columns)
|
| 67 |
+
self.assertIn('Question', df.columns)
|
| 68 |
+
self.assertIn('Content', df.columns)
|
| 69 |
+
|
| 70 |
+
def test_load_knowledge_base_drops_null_content(self):
|
| 71 |
+
"""Test that rows with null Content are dropped"""
|
| 72 |
+
data_with_null = self.sample_data + [
|
| 73 |
+
{
|
| 74 |
+
"ID": "KB003",
|
| 75 |
+
"Question": "Test question?",
|
| 76 |
+
"Content": None,
|
| 77 |
+
"Section": "Test"
|
| 78 |
+
}
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
temp_file_null = tempfile.NamedTemporaryFile(
|
| 82 |
+
mode='w',
|
| 83 |
+
delete=False,
|
| 84 |
+
suffix='.json'
|
| 85 |
+
)
|
| 86 |
+
json.dump(data_with_null, temp_file_null)
|
| 87 |
+
temp_file_null.close()
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
df = load_knowledge_base(temp_file_null.name)
|
| 91 |
+
# Should only have 2 rows (null Content row dropped)
|
| 92 |
+
self.assertEqual(len(df), 2)
|
| 93 |
+
finally:
|
| 94 |
+
os.unlink(temp_file_null.name)
|
| 95 |
+
|
| 96 |
+
def test_prepare_documents(self):
|
| 97 |
+
"""Test preparing documents for vector store"""
|
| 98 |
+
documents, metadatas, ids = prepare_documents(self.sample_data)
|
| 99 |
+
|
| 100 |
+
# Check lengths match
|
| 101 |
+
self.assertEqual(len(documents), 2)
|
| 102 |
+
self.assertEqual(len(metadatas), 2)
|
| 103 |
+
self.assertEqual(len(ids), 2)
|
| 104 |
+
|
| 105 |
+
# Check document format
|
| 106 |
+
self.assertIn("Question:", documents[0])
|
| 107 |
+
self.assertIn("Answer:", documents[0])
|
| 108 |
+
self.assertIn("How do I create an account?", documents[0])
|
| 109 |
+
|
| 110 |
+
# Check metadata structure
|
| 111 |
+
self.assertEqual(metadatas[0]['id'], 'KB001')
|
| 112 |
+
self.assertEqual(metadatas[0]['question'], 'How do I create an account?')
|
| 113 |
+
self.assertEqual(metadatas[0]['section'], 'Account Management')
|
| 114 |
+
|
| 115 |
+
# Check IDs
|
| 116 |
+
self.assertEqual(ids[0], 'KB001')
|
| 117 |
+
self.assertEqual(ids[1], 'KB002')
|
| 118 |
+
|
| 119 |
+
def test_prepare_documents_with_missing_fields(self):
|
| 120 |
+
"""Test preparing documents with missing optional fields"""
|
| 121 |
+
data_minimal = [
|
| 122 |
+
{
|
| 123 |
+
"ID": "KB001",
|
| 124 |
+
"Question": "Test question?",
|
| 125 |
+
"Content": "Test answer."
|
| 126 |
+
}
|
| 127 |
+
]
|
| 128 |
+
|
| 129 |
+
documents, metadatas, ids = prepare_documents(data_minimal)
|
| 130 |
+
|
| 131 |
+
# Should still work with defaults
|
| 132 |
+
self.assertEqual(len(documents), 1)
|
| 133 |
+
self.assertEqual(metadatas[0]['section'], '')
|
| 134 |
+
self.assertEqual(metadatas[0]['source'], '')
|
| 135 |
+
self.assertEqual(metadatas[0]['owner'], '')
|
| 136 |
+
self.assertEqual(metadatas[0]['tag'], '')
|
| 137 |
+
|
| 138 |
+
@patch('src.knowledge_base.load_knowledge_base')
|
| 139 |
+
def test_get_knowledge_base_data(self, mock_load):
|
| 140 |
+
"""Test get_knowledge_base_data function"""
|
| 141 |
+
# Mock the load_knowledge_base function
|
| 142 |
+
mock_df = pd.DataFrame(self.sample_data)
|
| 143 |
+
mock_load.return_value = mock_df
|
| 144 |
+
|
| 145 |
+
documents, metadatas, ids = get_knowledge_base_data()
|
| 146 |
+
|
| 147 |
+
# Verify load was called
|
| 148 |
+
mock_load.assert_called_once()
|
| 149 |
+
|
| 150 |
+
# Verify output
|
| 151 |
+
self.assertEqual(len(documents), 2)
|
| 152 |
+
self.assertEqual(len(metadatas), 2)
|
| 153 |
+
self.assertEqual(len(ids), 2)
|
| 154 |
+
|
| 155 |
+
def test_document_text_format(self):
|
| 156 |
+
"""Test that document text is properly formatted"""
|
| 157 |
+
documents, _, _ = prepare_documents(self.sample_data)
|
| 158 |
+
|
| 159 |
+
# Check first document format
|
| 160 |
+
expected_format = "Question: How do I create an account?\nAnswer: You can create an account by visiting our website."
|
| 161 |
+
self.assertEqual(documents[0], expected_format)
|
| 162 |
+
|
| 163 |
+
def test_empty_knowledge_base(self):
|
| 164 |
+
"""Test handling of empty knowledge base"""
|
| 165 |
+
empty_data = []
|
| 166 |
+
documents, metadatas, ids = prepare_documents(empty_data)
|
| 167 |
+
|
| 168 |
+
self.assertEqual(len(documents), 0)
|
| 169 |
+
self.assertEqual(len(metadatas), 0)
|
| 170 |
+
self.assertEqual(len(ids), 0)
|
| 171 |
+
|
| 172 |
+
def test_metadata_completeness(self):
|
| 173 |
+
"""Test that all metadata fields are present"""
|
| 174 |
+
_, metadatas, _ = prepare_documents(self.sample_data)
|
| 175 |
+
|
| 176 |
+
required_fields = ['question', 'content', 'section', 'source', 'owner', 'tag', 'id']
|
| 177 |
+
for metadata in metadatas:
|
| 178 |
+
for field in required_fields:
|
| 179 |
+
self.assertIn(field, metadata)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
if __name__ == '__main__':
|
| 183 |
+
unittest.main()
|
tests/test_logger.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for logger module
|
| 3 |
+
Tests Google Sheets logging functionality
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from unittest.mock import patch, Mock, MagicMock
|
| 8 |
+
from src.logger import (
|
| 9 |
+
log_response,
|
| 10 |
+
log_timing_data,
|
| 11 |
+
_log_response_impl
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestLogger(unittest.TestCase):
|
| 16 |
+
"""Test cases for logger module"""
|
| 17 |
+
|
| 18 |
+
def setUp(self):
|
| 19 |
+
"""Set up test fixtures"""
|
| 20 |
+
self.question = "How do I create an account?"
|
| 21 |
+
self.answer = "You can create an account by visiting our website."
|
| 22 |
+
self.source_ids = "KB001, KB002"
|
| 23 |
+
self.knowledge_pairs = [
|
| 24 |
+
("Question 1?", "Answer 1."),
|
| 25 |
+
("Question 2?", "Answer 2.")
|
| 26 |
+
]
|
| 27 |
+
self.session_id = "test_session_123"
|
| 28 |
+
|
| 29 |
+
@patch('src.logger.response_sheet')
|
| 30 |
+
def test_log_response_impl(self, mock_sheet):
|
| 31 |
+
"""Test internal response logging implementation"""
|
| 32 |
+
_log_response_impl(
|
| 33 |
+
self.question,
|
| 34 |
+
self.answer,
|
| 35 |
+
self.source_ids,
|
| 36 |
+
self.knowledge_pairs,
|
| 37 |
+
self.session_id
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Verify append_row was called
|
| 41 |
+
mock_sheet.append_row.assert_called_once()
|
| 42 |
+
|
| 43 |
+
# Check the row data
|
| 44 |
+
call_args = mock_sheet.append_row.call_args
|
| 45 |
+
row = call_args[0][0]
|
| 46 |
+
|
| 47 |
+
# Verify row structure
|
| 48 |
+
self.assertEqual(len(row), 9) # timestamp, session_id, question, answer, source_ids, 4 knowledge fields
|
| 49 |
+
self.assertEqual(row[1], self.session_id)
|
| 50 |
+
self.assertEqual(row[2], self.question)
|
| 51 |
+
self.assertEqual(row[3], self.answer)
|
| 52 |
+
self.assertEqual(row[4], self.source_ids)
|
| 53 |
+
self.assertEqual(row[5], "Question 1?")
|
| 54 |
+
self.assertEqual(row[6], "Answer 1.")
|
| 55 |
+
self.assertEqual(row[7], "Question 2?")
|
| 56 |
+
self.assertEqual(row[8], "Answer 2.")
|
| 57 |
+
|
| 58 |
+
@patch('src.logger.response_sheet')
|
| 59 |
+
def test_log_response_with_timer(self, mock_sheet):
|
| 60 |
+
"""Test log_response with timer"""
|
| 61 |
+
mock_timer = Mock()
|
| 62 |
+
mock_timer.time_step = MagicMock()
|
| 63 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 64 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 65 |
+
|
| 66 |
+
log_response(
|
| 67 |
+
self.question,
|
| 68 |
+
self.answer,
|
| 69 |
+
self.source_ids,
|
| 70 |
+
self.knowledge_pairs,
|
| 71 |
+
self.session_id,
|
| 72 |
+
timer=mock_timer
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Verify timer was used
|
| 76 |
+
mock_timer.time_step.assert_called_once_with("response_logging")
|
| 77 |
+
|
| 78 |
+
@patch('src.logger.response_sheet')
|
| 79 |
+
def test_log_response_empty_knowledge_pairs(self, mock_sheet):
|
| 80 |
+
"""Test logging with empty knowledge pairs"""
|
| 81 |
+
_log_response_impl(
|
| 82 |
+
self.question,
|
| 83 |
+
self.answer,
|
| 84 |
+
self.source_ids,
|
| 85 |
+
[],
|
| 86 |
+
self.session_id
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Should still work
|
| 90 |
+
mock_sheet.append_row.assert_called_once()
|
| 91 |
+
|
| 92 |
+
# Check that N/A is used for missing pairs
|
| 93 |
+
row = mock_sheet.append_row.call_args[0][0]
|
| 94 |
+
self.assertEqual(row[5], "N/A")
|
| 95 |
+
self.assertEqual(row[6], "N/A")
|
| 96 |
+
|
| 97 |
+
@patch('src.logger.response_sheet')
|
| 98 |
+
def test_log_response_single_knowledge_pair(self, mock_sheet):
|
| 99 |
+
"""Test logging with single knowledge pair"""
|
| 100 |
+
single_pair = [("Single question?", "Single answer.")]
|
| 101 |
+
|
| 102 |
+
_log_response_impl(
|
| 103 |
+
self.question,
|
| 104 |
+
self.answer,
|
| 105 |
+
self.source_ids,
|
| 106 |
+
single_pair,
|
| 107 |
+
self.session_id
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
row = mock_sheet.append_row.call_args[0][0]
|
| 111 |
+
|
| 112 |
+
# First pair should be present
|
| 113 |
+
self.assertEqual(row[5], "Single question?")
|
| 114 |
+
self.assertEqual(row[6], "Single answer.")
|
| 115 |
+
|
| 116 |
+
# Second pair should be N/A
|
| 117 |
+
self.assertEqual(row[7], "N/A")
|
| 118 |
+
self.assertEqual(row[8], "N/A")
|
| 119 |
+
|
| 120 |
+
@patch('src.logger.response_sheet')
|
| 121 |
+
@patch('builtins.open', create=True)
|
| 122 |
+
def test_log_response_fallback_on_error(self, mock_open, mock_sheet):
|
| 123 |
+
"""Test fallback to file logging on error"""
|
| 124 |
+
# Make append_row raise an exception
|
| 125 |
+
mock_sheet.append_row.side_effect = Exception("Connection error")
|
| 126 |
+
|
| 127 |
+
# Mock file operations
|
| 128 |
+
mock_file = MagicMock()
|
| 129 |
+
mock_open.return_value.__enter__.return_value = mock_file
|
| 130 |
+
|
| 131 |
+
# Should not raise exception
|
| 132 |
+
_log_response_impl(
|
| 133 |
+
self.question,
|
| 134 |
+
self.answer,
|
| 135 |
+
self.source_ids,
|
| 136 |
+
self.knowledge_pairs,
|
| 137 |
+
self.session_id
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Verify fallback file was opened
|
| 141 |
+
mock_open.assert_called_once_with("/tmp/response_log.txt", "a")
|
| 142 |
+
mock_file.write.assert_called_once()
|
| 143 |
+
|
| 144 |
+
@patch('src.logger.timing_sheet')
|
| 145 |
+
def test_log_timing_data(self, mock_sheet):
|
| 146 |
+
"""Test timing data logging"""
|
| 147 |
+
timing_summary = {
|
| 148 |
+
'total_time_ms': 1500,
|
| 149 |
+
'step_times': {
|
| 150 |
+
'intent_classification': 50,
|
| 151 |
+
'memory_retrieval': 100,
|
| 152 |
+
'rag_retrieval': 200,
|
| 153 |
+
'embedding_generation': 300,
|
| 154 |
+
'similarity_calculation': 150,
|
| 155 |
+
'context_processing': 100,
|
| 156 |
+
'llm_generation': 500,
|
| 157 |
+
'memory_update': 50,
|
| 158 |
+
'response_logging': 50
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
log_timing_data(
|
| 163 |
+
self.question,
|
| 164 |
+
self.session_id,
|
| 165 |
+
timing_summary,
|
| 166 |
+
error_step=None,
|
| 167 |
+
notes="Test note"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Verify append_row was called
|
| 171 |
+
mock_sheet.append_row.assert_called_once()
|
| 172 |
+
|
| 173 |
+
# Check row structure
|
| 174 |
+
row = mock_sheet.append_row.call_args[0][0]
|
| 175 |
+
|
| 176 |
+
# Should have 15 fields
|
| 177 |
+
self.assertEqual(len(row), 15)
|
| 178 |
+
self.assertEqual(row[1], self.session_id)
|
| 179 |
+
self.assertEqual(row[3], 1500) # total_time_ms
|
| 180 |
+
self.assertEqual(row[4], 50) # intent_classification
|
| 181 |
+
self.assertEqual(row[5], 100) # memory_retrieval
|
| 182 |
+
self.assertEqual(row[14], "Test note") # notes
|
| 183 |
+
|
| 184 |
+
@patch('src.logger.timing_sheet')
|
| 185 |
+
def test_log_timing_data_with_error(self, mock_sheet):
|
| 186 |
+
"""Test timing data logging with error"""
|
| 187 |
+
timing_summary = {
|
| 188 |
+
'total_time_ms': 500,
|
| 189 |
+
'step_times': {
|
| 190 |
+
'intent_classification': 50
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
log_timing_data(
|
| 195 |
+
self.question,
|
| 196 |
+
self.session_id,
|
| 197 |
+
timing_summary,
|
| 198 |
+
error_step="rag_retrieval",
|
| 199 |
+
notes="Error occurred"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
row = mock_sheet.append_row.call_args[0][0]
|
| 203 |
+
|
| 204 |
+
# Check error_step is logged
|
| 205 |
+
self.assertEqual(row[13], "rag_retrieval")
|
| 206 |
+
self.assertEqual(row[14], "Error occurred")
|
| 207 |
+
|
| 208 |
+
@patch('src.logger.timing_sheet')
|
| 209 |
+
def test_log_timing_data_missing_steps(self, mock_sheet):
|
| 210 |
+
"""Test timing data with missing step times"""
|
| 211 |
+
timing_summary = {
|
| 212 |
+
'total_time_ms': 100,
|
| 213 |
+
'step_times': {
|
| 214 |
+
'intent_classification': 100
|
| 215 |
+
# Other steps missing
|
| 216 |
+
}
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
log_timing_data(
|
| 220 |
+
self.question,
|
| 221 |
+
self.session_id,
|
| 222 |
+
timing_summary
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
row = mock_sheet.append_row.call_args[0][0]
|
| 226 |
+
|
| 227 |
+
# Missing steps should default to 0
|
| 228 |
+
self.assertEqual(row[5], 0) # memory_retrieval
|
| 229 |
+
self.assertEqual(row[6], 0) # rag_retrieval
|
| 230 |
+
|
| 231 |
+
@patch('src.logger.timing_sheet')
|
| 232 |
+
def test_log_timing_data_long_question(self, mock_sheet):
|
| 233 |
+
"""Test timing data logging with long question (truncation)"""
|
| 234 |
+
long_question = "A" * 150 # 150 characters
|
| 235 |
+
|
| 236 |
+
timing_summary = {
|
| 237 |
+
'total_time_ms': 100,
|
| 238 |
+
'step_times': {}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
log_timing_data(
|
| 242 |
+
long_question,
|
| 243 |
+
self.session_id,
|
| 244 |
+
timing_summary
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
row = mock_sheet.append_row.call_args[0][0]
|
| 248 |
+
|
| 249 |
+
# Question should be truncated to 103 chars (100 + "...")
|
| 250 |
+
self.assertEqual(len(row[2]), 103)
|
| 251 |
+
self.assertTrue(row[2].endswith("..."))
|
| 252 |
+
|
| 253 |
+
@patch('src.logger.timing_sheet')
|
| 254 |
+
@patch('builtins.open', create=True)
|
| 255 |
+
def test_log_timing_data_fallback_on_error(self, mock_open, mock_sheet):
|
| 256 |
+
"""Test fallback to file logging for timing data on error"""
|
| 257 |
+
mock_sheet.append_row.side_effect = Exception("Connection error")
|
| 258 |
+
|
| 259 |
+
mock_file = MagicMock()
|
| 260 |
+
mock_open.return_value.__enter__.return_value = mock_file
|
| 261 |
+
|
| 262 |
+
timing_summary = {'total_time_ms': 100, 'step_times': {}}
|
| 263 |
+
|
| 264 |
+
log_timing_data(self.question, self.session_id, timing_summary)
|
| 265 |
+
|
| 266 |
+
# Verify fallback file was opened
|
| 267 |
+
mock_open.assert_called_once_with("/tmp/timing_log.txt", "a")
|
| 268 |
+
mock_file.write.assert_called_once()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
if __name__ == '__main__':
|
| 272 |
+
unittest.main()
|
tests/test_memory.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for memory module
|
| 3 |
+
Tests LangGraph memory operations
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
import os
|
| 7 |
+
import sqlite3
|
| 8 |
+
import tempfile
|
| 9 |
+
from unittest.mock import patch, Mock, MagicMock
|
| 10 |
+
from src.memory import (
|
| 11 |
+
update_memory,
|
| 12 |
+
retrieve_memory,
|
| 13 |
+
create_session_config,
|
| 14 |
+
_update_memory_impl,
|
| 15 |
+
_retrieve_memory_impl
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestMemory(unittest.TestCase):
|
| 20 |
+
"""Test cases for memory module"""
|
| 21 |
+
|
| 22 |
+
def setUp(self):
|
| 23 |
+
"""Set up test fixtures"""
|
| 24 |
+
self.test_config = {
|
| 25 |
+
"configurable": {
|
| 26 |
+
"thread_id": "test_session_123",
|
| 27 |
+
"checkpoint_ns": ""
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
def test_create_session_config(self):
|
| 32 |
+
"""Test creating session config"""
|
| 33 |
+
session_id = "test_session_456"
|
| 34 |
+
config = create_session_config(session_id)
|
| 35 |
+
|
| 36 |
+
# Check structure
|
| 37 |
+
self.assertIn("configurable", config)
|
| 38 |
+
self.assertEqual(config["configurable"]["thread_id"], session_id)
|
| 39 |
+
self.assertEqual(config["configurable"]["checkpoint_ns"], "")
|
| 40 |
+
|
| 41 |
+
def test_create_session_config_default(self):
|
| 42 |
+
"""Test creating session config with default ID"""
|
| 43 |
+
config = create_session_config()
|
| 44 |
+
|
| 45 |
+
# Check structure
|
| 46 |
+
self.assertIn("configurable", config)
|
| 47 |
+
self.assertEqual(config["configurable"]["thread_id"], "default")
|
| 48 |
+
|
| 49 |
+
@patch('src.memory.memory')
|
| 50 |
+
def test_update_memory_impl(self, mock_memory):
|
| 51 |
+
"""Test internal memory update implementation"""
|
| 52 |
+
# Mock memory.get to return existing checkpoint
|
| 53 |
+
mock_checkpoint = {
|
| 54 |
+
"channel_values": {
|
| 55 |
+
"messages": [
|
| 56 |
+
{"role": "user", "content": "Previous question"},
|
| 57 |
+
{"role": "assistant", "content": "Previous answer"}
|
| 58 |
+
]
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
mock_memory.get.return_value = mock_checkpoint
|
| 62 |
+
|
| 63 |
+
user_message = "New question"
|
| 64 |
+
assistant_message = "New answer"
|
| 65 |
+
|
| 66 |
+
_update_memory_impl(self.test_config, user_message, assistant_message)
|
| 67 |
+
|
| 68 |
+
# Verify memory.get was called
|
| 69 |
+
mock_memory.get.assert_called_once_with(self.test_config)
|
| 70 |
+
|
| 71 |
+
# Verify memory.put was called
|
| 72 |
+
mock_memory.put.assert_called_once()
|
| 73 |
+
|
| 74 |
+
# Check the checkpoint that was saved
|
| 75 |
+
call_args = mock_memory.put.call_args
|
| 76 |
+
saved_checkpoint = call_args[0][1]
|
| 77 |
+
|
| 78 |
+
# Verify messages were appended
|
| 79 |
+
messages = saved_checkpoint["channel_values"]["messages"]
|
| 80 |
+
self.assertEqual(len(messages), 4) # 2 existing + 2 new
|
| 81 |
+
self.assertEqual(messages[-2]["role"], "user")
|
| 82 |
+
self.assertEqual(messages[-2]["content"], user_message)
|
| 83 |
+
self.assertEqual(messages[-1]["role"], "assistant")
|
| 84 |
+
self.assertEqual(messages[-1]["content"], assistant_message)
|
| 85 |
+
|
| 86 |
+
@patch('src.memory.memory')
|
| 87 |
+
def test_update_memory_empty_checkpoint(self, mock_memory):
|
| 88 |
+
"""Test updating memory with empty checkpoint"""
|
| 89 |
+
# Mock memory.get to return None
|
| 90 |
+
mock_memory.get.return_value = None
|
| 91 |
+
|
| 92 |
+
user_message = "First question"
|
| 93 |
+
assistant_message = "First answer"
|
| 94 |
+
|
| 95 |
+
_update_memory_impl(self.test_config, user_message, assistant_message)
|
| 96 |
+
|
| 97 |
+
# Verify memory.put was called
|
| 98 |
+
mock_memory.put.assert_called_once()
|
| 99 |
+
|
| 100 |
+
# Check the checkpoint
|
| 101 |
+
call_args = mock_memory.put.call_args
|
| 102 |
+
saved_checkpoint = call_args[0][1]
|
| 103 |
+
messages = saved_checkpoint["channel_values"]["messages"]
|
| 104 |
+
|
| 105 |
+
# Should have 2 messages
|
| 106 |
+
self.assertEqual(len(messages), 2)
|
| 107 |
+
self.assertEqual(messages[0]["role"], "user")
|
| 108 |
+
self.assertEqual(messages[1]["role"], "assistant")
|
| 109 |
+
|
| 110 |
+
@patch('src.memory.memory')
|
| 111 |
+
def test_update_memory_with_timer(self, mock_memory):
|
| 112 |
+
"""Test update_memory with timer"""
|
| 113 |
+
mock_memory.get.return_value = {}
|
| 114 |
+
mock_timer = Mock()
|
| 115 |
+
mock_timer.time_step = MagicMock()
|
| 116 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 117 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 118 |
+
|
| 119 |
+
update_memory(self.test_config, "Test", "Answer", timer=mock_timer)
|
| 120 |
+
|
| 121 |
+
# Verify timer was used
|
| 122 |
+
mock_timer.time_step.assert_called_once_with("memory_update")
|
| 123 |
+
|
| 124 |
+
@patch('src.memory.memory')
|
| 125 |
+
def test_retrieve_memory_impl(self, mock_memory):
|
| 126 |
+
"""Test internal memory retrieval implementation"""
|
| 127 |
+
# Mock memory.get to return checkpoint with messages
|
| 128 |
+
mock_checkpoint = {
|
| 129 |
+
"channel_values": {
|
| 130 |
+
"messages": [
|
| 131 |
+
{"role": "user", "content": "Question 1"},
|
| 132 |
+
{"role": "assistant", "content": "Answer 1"},
|
| 133 |
+
{"role": "user", "content": "Question 2"},
|
| 134 |
+
{"role": "assistant", "content": "Answer 2"}
|
| 135 |
+
]
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
mock_memory.get.return_value = mock_checkpoint
|
| 139 |
+
|
| 140 |
+
messages = _retrieve_memory_impl(self.test_config)
|
| 141 |
+
|
| 142 |
+
# Verify memory.get was called
|
| 143 |
+
mock_memory.get.assert_called_once_with(self.test_config)
|
| 144 |
+
|
| 145 |
+
# Verify messages were retrieved
|
| 146 |
+
self.assertEqual(len(messages), 4)
|
| 147 |
+
self.assertEqual(messages[0]["content"], "Question 1")
|
| 148 |
+
|
| 149 |
+
@patch('src.memory.memory')
|
| 150 |
+
def test_retrieve_memory_empty(self, mock_memory):
|
| 151 |
+
"""Test retrieving memory when empty"""
|
| 152 |
+
# Mock memory.get to return None
|
| 153 |
+
mock_memory.get.return_value = None
|
| 154 |
+
|
| 155 |
+
messages = _retrieve_memory_impl(self.test_config)
|
| 156 |
+
|
| 157 |
+
# Should return empty list
|
| 158 |
+
self.assertEqual(messages, [])
|
| 159 |
+
|
| 160 |
+
@patch('src.memory.memory')
|
| 161 |
+
def test_retrieve_memory_with_timer(self, mock_memory):
|
| 162 |
+
"""Test retrieve_memory with timer"""
|
| 163 |
+
mock_memory.get.return_value = {}
|
| 164 |
+
mock_timer = Mock()
|
| 165 |
+
mock_timer.time_step = MagicMock()
|
| 166 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 167 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 168 |
+
|
| 169 |
+
retrieve_memory(self.test_config, timer=mock_timer)
|
| 170 |
+
|
| 171 |
+
# Verify timer was used
|
| 172 |
+
mock_timer.time_step.assert_called_once_with("memory_retrieval")
|
| 173 |
+
|
| 174 |
+
@patch('src.memory.memory')
|
| 175 |
+
def test_checkpoint_structure(self, mock_memory):
|
| 176 |
+
"""Test that checkpoint has correct structure"""
|
| 177 |
+
mock_memory.get.return_value = None
|
| 178 |
+
|
| 179 |
+
_update_memory_impl(self.test_config, "Test", "Answer")
|
| 180 |
+
|
| 181 |
+
call_args = mock_memory.put.call_args
|
| 182 |
+
checkpoint = call_args[0][1]
|
| 183 |
+
|
| 184 |
+
# Verify checkpoint structure
|
| 185 |
+
self.assertIn("v", checkpoint)
|
| 186 |
+
self.assertIn("id", checkpoint)
|
| 187 |
+
self.assertIn("ts", checkpoint)
|
| 188 |
+
self.assertIn("channel_values", checkpoint)
|
| 189 |
+
self.assertIn("channel_versions", checkpoint)
|
| 190 |
+
self.assertIn("versions_seen", checkpoint)
|
| 191 |
+
self.assertEqual(checkpoint["v"], 1)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
if __name__ == '__main__':
|
| 195 |
+
unittest.main()
|
tests/test_response_generator.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for response_generator module
|
| 3 |
+
Tests LLM response generation functionality
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
from unittest.mock import patch, Mock, MagicMock
|
| 7 |
+
from src.response_generator import (
|
| 8 |
+
generate_xeno_response,
|
| 9 |
+
format_chat_history,
|
| 10 |
+
_generate_response_impl
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class TestResponseGenerator(unittest.TestCase):
|
| 15 |
+
"""Test cases for response_generator module"""
|
| 16 |
+
|
| 17 |
+
def setUp(self):
|
| 18 |
+
"""Set up test fixtures"""
|
| 19 |
+
self.context = """Knowledge Entry 1:
|
| 20 |
+
Q: How do I create an account?
|
| 21 |
+
A: Visit our website and click Sign Up.
|
| 22 |
+
----------------------------------------"""
|
| 23 |
+
|
| 24 |
+
self.question = "How can I create an account?"
|
| 25 |
+
|
| 26 |
+
self.chat_history = [
|
| 27 |
+
{"role": "user", "content": "Hello"},
|
| 28 |
+
{"role": "assistant", "content": "Hi! How can I help you?"}
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
def test_format_chat_history(self):
|
| 32 |
+
"""Test formatting chat history"""
|
| 33 |
+
formatted = format_chat_history(self.chat_history)
|
| 34 |
+
|
| 35 |
+
# Check format
|
| 36 |
+
self.assertIn("User: Hello", formatted)
|
| 37 |
+
self.assertIn("Assistant: Hi! How can I help you?", formatted)
|
| 38 |
+
self.assertIn("\n", formatted)
|
| 39 |
+
|
| 40 |
+
def test_format_chat_history_empty(self):
|
| 41 |
+
"""Test formatting empty chat history"""
|
| 42 |
+
formatted = format_chat_history([])
|
| 43 |
+
self.assertEqual(formatted, "No previous conversation")
|
| 44 |
+
|
| 45 |
+
def test_format_chat_history_single_message(self):
|
| 46 |
+
"""Test formatting single message"""
|
| 47 |
+
history = [{"role": "user", "content": "Hello"}]
|
| 48 |
+
formatted = format_chat_history(history)
|
| 49 |
+
self.assertEqual(formatted, "User: Hello")
|
| 50 |
+
|
| 51 |
+
def test_format_chat_history_missing_fields(self):
|
| 52 |
+
"""Test formatting with missing fields"""
|
| 53 |
+
history = [
|
| 54 |
+
{"role": "user"}, # Missing content
|
| 55 |
+
{"content": "Test"} # Missing role
|
| 56 |
+
]
|
| 57 |
+
formatted = format_chat_history(history)
|
| 58 |
+
self.assertIn("User:", formatted)
|
| 59 |
+
self.assertIn("Unknown:", formatted)
|
| 60 |
+
|
| 61 |
+
@patch('src.response_generator.genai.GenerativeModel')
|
| 62 |
+
def test_generate_response_impl(self, mock_model_class):
|
| 63 |
+
"""Test internal response generation implementation"""
|
| 64 |
+
# Mock the model and response
|
| 65 |
+
mock_model = Mock()
|
| 66 |
+
mock_response = Mock()
|
| 67 |
+
mock_response.text = "You can create an account by visiting our website."
|
| 68 |
+
mock_model.generate_content.return_value = mock_response
|
| 69 |
+
mock_model_class.return_value = mock_model
|
| 70 |
+
|
| 71 |
+
response = _generate_response_impl(
|
| 72 |
+
self.context,
|
| 73 |
+
self.question,
|
| 74 |
+
self.chat_history
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Verify model was initialized with correct model name
|
| 78 |
+
mock_model_class.assert_called_once()
|
| 79 |
+
|
| 80 |
+
# Verify generate_content was called
|
| 81 |
+
mock_model.generate_content.assert_called_once()
|
| 82 |
+
|
| 83 |
+
# Check response
|
| 84 |
+
self.assertEqual(response, "You can create an account by visiting our website.")
|
| 85 |
+
|
| 86 |
+
@patch('src.response_generator.genai.GenerativeModel')
|
| 87 |
+
def test_generate_response_with_empty_history(self, mock_model_class):
|
| 88 |
+
"""Test generating response with empty history"""
|
| 89 |
+
mock_model = Mock()
|
| 90 |
+
mock_response = Mock()
|
| 91 |
+
mock_response.text = "Test response"
|
| 92 |
+
mock_model.generate_content.return_value = mock_response
|
| 93 |
+
mock_model_class.return_value = mock_model
|
| 94 |
+
|
| 95 |
+
response = _generate_response_impl(
|
| 96 |
+
self.context,
|
| 97 |
+
self.question,
|
| 98 |
+
[]
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Verify it still works
|
| 102 |
+
self.assertEqual(response, "Test response")
|
| 103 |
+
|
| 104 |
+
# Check that "None" was used for history in prompt
|
| 105 |
+
call_args = mock_model.generate_content.call_args
|
| 106 |
+
prompt = call_args[0][0]
|
| 107 |
+
self.assertIn("None", prompt)
|
| 108 |
+
|
| 109 |
+
@patch('src.response_generator.genai.GenerativeModel')
|
| 110 |
+
def test_prompt_structure(self, mock_model_class):
|
| 111 |
+
"""Test that prompt includes all necessary components"""
|
| 112 |
+
mock_model = Mock()
|
| 113 |
+
mock_response = Mock()
|
| 114 |
+
mock_response.text = "Test response"
|
| 115 |
+
mock_model.generate_content.return_value = mock_response
|
| 116 |
+
mock_model_class.return_value = mock_model
|
| 117 |
+
|
| 118 |
+
_generate_response_impl(
|
| 119 |
+
self.context,
|
| 120 |
+
self.question,
|
| 121 |
+
self.chat_history
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Get the prompt that was sent
|
| 125 |
+
call_args = mock_model.generate_content.call_args
|
| 126 |
+
prompt = call_args[0][0]
|
| 127 |
+
|
| 128 |
+
# Verify prompt structure
|
| 129 |
+
self.assertIn("HISTORY", prompt)
|
| 130 |
+
self.assertIn("CONTEXT", prompt)
|
| 131 |
+
self.assertIn("QUESTION", prompt)
|
| 132 |
+
self.assertIn(self.context, prompt)
|
| 133 |
+
self.assertIn(self.question, prompt)
|
| 134 |
+
|
| 135 |
+
@patch('src.response_generator.genai.GenerativeModel')
|
| 136 |
+
def test_generate_xeno_response_with_timer(self, mock_model_class):
|
| 137 |
+
"""Test generate_xeno_response with timer"""
|
| 138 |
+
mock_model = Mock()
|
| 139 |
+
mock_response = Mock()
|
| 140 |
+
mock_response.text = "Test response"
|
| 141 |
+
mock_model.generate_content.return_value = mock_response
|
| 142 |
+
mock_model_class.return_value = mock_model
|
| 143 |
+
|
| 144 |
+
mock_timer = Mock()
|
| 145 |
+
mock_timer.time_step = MagicMock()
|
| 146 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 147 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 148 |
+
|
| 149 |
+
response = generate_xeno_response(
|
| 150 |
+
self.context,
|
| 151 |
+
self.question,
|
| 152 |
+
self.chat_history,
|
| 153 |
+
timer=mock_timer
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Verify timer was used
|
| 157 |
+
mock_timer.time_step.assert_called_once_with("llm_generation")
|
| 158 |
+
|
| 159 |
+
# Verify response
|
| 160 |
+
self.assertEqual(response, "Test response")
|
| 161 |
+
|
| 162 |
+
@patch('src.response_generator.genai.GenerativeModel')
|
| 163 |
+
def test_response_text_stripping(self, mock_model_class):
|
| 164 |
+
"""Test that response text is stripped of whitespace"""
|
| 165 |
+
mock_model = Mock()
|
| 166 |
+
mock_response = Mock()
|
| 167 |
+
mock_response.text = " Test response with spaces \n"
|
| 168 |
+
mock_model.generate_content.return_value = mock_response
|
| 169 |
+
mock_model_class.return_value = mock_model
|
| 170 |
+
|
| 171 |
+
response = _generate_response_impl(
|
| 172 |
+
self.context,
|
| 173 |
+
self.question,
|
| 174 |
+
[]
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# Should be stripped
|
| 178 |
+
self.assertEqual(response, "Test response with spaces")
|
| 179 |
+
|
| 180 |
+
@patch('src.response_generator.genai.GenerativeModel')
|
| 181 |
+
def test_system_prompt_inclusion(self, mock_model_class):
|
| 182 |
+
"""Test that system prompt is included in generated prompt"""
|
| 183 |
+
mock_model = Mock()
|
| 184 |
+
mock_response = Mock()
|
| 185 |
+
mock_response.text = "Test"
|
| 186 |
+
mock_model.generate_content.return_value = mock_response
|
| 187 |
+
mock_model_class.return_value = mock_model
|
| 188 |
+
|
| 189 |
+
_generate_response_impl(self.context, self.question, [])
|
| 190 |
+
|
| 191 |
+
# Get the prompt
|
| 192 |
+
call_args = mock_model.generate_content.call_args
|
| 193 |
+
prompt = call_args[0][0]
|
| 194 |
+
|
| 195 |
+
# Should contain system prompt text
|
| 196 |
+
self.assertIn("XENO Support Assistant", prompt)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
if __name__ == '__main__':
|
| 200 |
+
unittest.main()
|
tests/test_utils.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for utils module
|
| 3 |
+
Tests the PipelineTimer class
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
import time
|
| 7 |
+
from src.utils import PipelineTimer
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestPipelineTimer(unittest.TestCase):
|
| 11 |
+
"""Test cases for PipelineTimer class"""
|
| 12 |
+
|
| 13 |
+
def setUp(self):
|
| 14 |
+
"""Set up test fixtures"""
|
| 15 |
+
self.timer = PipelineTimer()
|
| 16 |
+
|
| 17 |
+
def test_initialization(self):
|
| 18 |
+
"""Test timer initialization"""
|
| 19 |
+
self.assertIsNotNone(self.timer.start_time)
|
| 20 |
+
self.assertEqual(self.timer.step_times, {})
|
| 21 |
+
self.assertIsNone(self.timer.step_start)
|
| 22 |
+
self.assertIsNone(self.timer.current_step)
|
| 23 |
+
|
| 24 |
+
def test_reset(self):
|
| 25 |
+
"""Test timer reset functionality"""
|
| 26 |
+
# Add some data
|
| 27 |
+
self.timer.step_times = {'test': 100}
|
| 28 |
+
self.timer.current_step = 'test'
|
| 29 |
+
|
| 30 |
+
# Reset
|
| 31 |
+
self.timer.reset()
|
| 32 |
+
|
| 33 |
+
# Verify reset
|
| 34 |
+
self.assertEqual(self.timer.step_times, {})
|
| 35 |
+
self.assertIsNone(self.timer.current_step)
|
| 36 |
+
|
| 37 |
+
def test_time_step_context_manager(self):
|
| 38 |
+
"""Test timing a step using context manager"""
|
| 39 |
+
with self.timer.time_step('test_step'):
|
| 40 |
+
time.sleep(0.1) # Sleep for 100ms
|
| 41 |
+
|
| 42 |
+
# Check that step was timed
|
| 43 |
+
self.assertIn('test_step', self.timer.step_times)
|
| 44 |
+
# Should be approximately 100ms (allowing some variance)
|
| 45 |
+
self.assertGreater(self.timer.step_times['test_step'], 90)
|
| 46 |
+
self.assertLess(self.timer.step_times['test_step'], 150)
|
| 47 |
+
|
| 48 |
+
def test_multiple_steps(self):
|
| 49 |
+
"""Test timing multiple steps"""
|
| 50 |
+
with self.timer.time_step('step1'):
|
| 51 |
+
time.sleep(0.05)
|
| 52 |
+
|
| 53 |
+
with self.timer.time_step('step2'):
|
| 54 |
+
time.sleep(0.05)
|
| 55 |
+
|
| 56 |
+
# Both steps should be recorded
|
| 57 |
+
self.assertIn('step1', self.timer.step_times)
|
| 58 |
+
self.assertIn('step2', self.timer.step_times)
|
| 59 |
+
self.assertEqual(len(self.timer.step_times), 2)
|
| 60 |
+
|
| 61 |
+
def test_get_total_time(self):
|
| 62 |
+
"""Test getting total elapsed time"""
|
| 63 |
+
time.sleep(0.1)
|
| 64 |
+
total_time = self.timer.get_total_time()
|
| 65 |
+
|
| 66 |
+
# Should be at least 100ms
|
| 67 |
+
self.assertGreater(total_time, 90)
|
| 68 |
+
|
| 69 |
+
def test_get_timing_summary(self):
|
| 70 |
+
"""Test getting timing summary"""
|
| 71 |
+
with self.timer.time_step('step1'):
|
| 72 |
+
time.sleep(0.05)
|
| 73 |
+
|
| 74 |
+
summary = self.timer.get_timing_summary()
|
| 75 |
+
|
| 76 |
+
# Check summary structure
|
| 77 |
+
self.assertIn('total_time_ms', summary)
|
| 78 |
+
self.assertIn('step_times', summary)
|
| 79 |
+
self.assertIn('timestamp', summary)
|
| 80 |
+
self.assertIn('step1', summary['step_times'])
|
| 81 |
+
|
| 82 |
+
def test_current_step_tracking(self):
|
| 83 |
+
"""Test that current_step is tracked correctly"""
|
| 84 |
+
self.assertIsNone(self.timer.current_step)
|
| 85 |
+
|
| 86 |
+
with self.timer.time_step('test_step'):
|
| 87 |
+
# During execution, current_step should be set
|
| 88 |
+
self.assertEqual(self.timer.current_step, 'test_step')
|
| 89 |
+
|
| 90 |
+
# After execution, current_step should be None
|
| 91 |
+
self.assertIsNone(self.timer.current_step)
|
| 92 |
+
|
| 93 |
+
def test_exception_handling_in_timer(self):
|
| 94 |
+
"""Test that timer handles exceptions properly"""
|
| 95 |
+
try:
|
| 96 |
+
with self.timer.time_step('error_step'):
|
| 97 |
+
raise ValueError("Test error")
|
| 98 |
+
except ValueError:
|
| 99 |
+
pass
|
| 100 |
+
|
| 101 |
+
# Step should still be recorded even if exception occurred
|
| 102 |
+
self.assertIn('error_step', self.timer.step_times)
|
| 103 |
+
# current_step should be None after context manager exits
|
| 104 |
+
self.assertIsNone(self.timer.current_step)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
if __name__ == '__main__':
|
| 108 |
+
unittest.main()
|
tests/test_vector_store.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for vector_store module
|
| 3 |
+
Tests ChromaDB vector store operations
|
| 4 |
+
"""
|
| 5 |
+
import unittest
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
from unittest.mock import patch, Mock, MagicMock
|
| 9 |
+
from src.vector_store import (
|
| 10 |
+
generate_embeddings,
|
| 11 |
+
calculate_similarity,
|
| 12 |
+
process_context,
|
| 13 |
+
_generate_embeddings_impl,
|
| 14 |
+
_calculate_similarity_impl,
|
| 15 |
+
_process_context_impl
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TestVectorStore(unittest.TestCase):
|
| 20 |
+
"""Test cases for vector_store module"""
|
| 21 |
+
|
| 22 |
+
def setUp(self):
|
| 23 |
+
"""Set up test fixtures"""
|
| 24 |
+
# Mock document
|
| 25 |
+
self.mock_doc = Mock()
|
| 26 |
+
self.mock_doc.page_content = "Test document content"
|
| 27 |
+
self.mock_doc.metadata = {
|
| 28 |
+
'id': 'KB001',
|
| 29 |
+
'question': 'Test question?',
|
| 30 |
+
'content': 'Test answer.',
|
| 31 |
+
'section': 'Test'
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
self.mock_documents = [self.mock_doc]
|
| 35 |
+
|
| 36 |
+
@patch('src.vector_store.genai.embed_content')
|
| 37 |
+
def test_generate_embeddings_impl(self, mock_embed):
|
| 38 |
+
"""Test internal embedding generation implementation"""
|
| 39 |
+
# Mock embeddings
|
| 40 |
+
mock_embed.side_effect = [
|
| 41 |
+
{'embedding': [0.1, 0.2, 0.3]}, # Query embedding
|
| 42 |
+
{'embedding': [0.2, 0.3, 0.4]} # Doc embedding
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
query = "Test query"
|
| 46 |
+
query_emb, doc_embs = _generate_embeddings_impl(query, self.mock_documents)
|
| 47 |
+
|
| 48 |
+
# Verify embed_content was called correctly
|
| 49 |
+
self.assertEqual(mock_embed.call_count, 2)
|
| 50 |
+
|
| 51 |
+
# Check query embedding call
|
| 52 |
+
first_call = mock_embed.call_args_list[0]
|
| 53 |
+
self.assertEqual(first_call[1]['content'], query)
|
| 54 |
+
self.assertEqual(first_call[1]['task_type'], 'retrieval_query')
|
| 55 |
+
|
| 56 |
+
# Check doc embedding call
|
| 57 |
+
second_call = mock_embed.call_args_list[1]
|
| 58 |
+
self.assertEqual(second_call[1]['content'], self.mock_doc.page_content)
|
| 59 |
+
self.assertEqual(second_call[1]['task_type'], 'retrieval_document')
|
| 60 |
+
|
| 61 |
+
# Verify embeddings
|
| 62 |
+
self.assertEqual(query_emb, [0.1, 0.2, 0.3])
|
| 63 |
+
self.assertEqual(len(doc_embs), 1)
|
| 64 |
+
self.assertEqual(doc_embs[0], [0.2, 0.3, 0.4])
|
| 65 |
+
|
| 66 |
+
@patch('src.vector_store.genai.embed_content')
|
| 67 |
+
def test_generate_embeddings_with_timer(self, mock_embed):
|
| 68 |
+
"""Test embedding generation with timer"""
|
| 69 |
+
mock_embed.side_effect = [
|
| 70 |
+
{'embedding': [0.1, 0.2, 0.3]},
|
| 71 |
+
{'embedding': [0.2, 0.3, 0.4]}
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
mock_timer = Mock()
|
| 75 |
+
mock_timer.time_step = MagicMock()
|
| 76 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 77 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 78 |
+
|
| 79 |
+
generate_embeddings("Test", self.mock_documents, timer=mock_timer)
|
| 80 |
+
|
| 81 |
+
# Verify timer was used
|
| 82 |
+
mock_timer.time_step.assert_called_once_with("embedding_generation")
|
| 83 |
+
|
| 84 |
+
@patch('src.vector_store.genai.embed_content')
|
| 85 |
+
def test_generate_embeddings_multiple_docs(self, mock_embed):
|
| 86 |
+
"""Test embedding generation with multiple documents"""
|
| 87 |
+
# Create multiple mock documents
|
| 88 |
+
mock_doc2 = Mock()
|
| 89 |
+
mock_doc2.page_content = "Second document"
|
| 90 |
+
docs = [self.mock_doc, mock_doc2]
|
| 91 |
+
|
| 92 |
+
# Mock embeddings
|
| 93 |
+
mock_embed.side_effect = [
|
| 94 |
+
{'embedding': [0.1, 0.2, 0.3]}, # Query
|
| 95 |
+
{'embedding': [0.2, 0.3, 0.4]}, # Doc 1
|
| 96 |
+
{'embedding': [0.3, 0.4, 0.5]} # Doc 2
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
query_emb, doc_embs = _generate_embeddings_impl("Test", docs)
|
| 100 |
+
|
| 101 |
+
# Should have 2 doc embeddings
|
| 102 |
+
self.assertEqual(len(doc_embs), 2)
|
| 103 |
+
self.assertEqual(mock_embed.call_count, 3)
|
| 104 |
+
|
| 105 |
+
def test_calculate_similarity_impl(self):
|
| 106 |
+
"""Test internal similarity calculation implementation"""
|
| 107 |
+
query_embedding = [1.0, 0.0, 0.0]
|
| 108 |
+
doc_embeddings = [
|
| 109 |
+
[1.0, 0.0, 0.0], # Same as query - score should be ~1.0
|
| 110 |
+
[0.0, 1.0, 0.0], # Orthogonal - score should be ~0.0
|
| 111 |
+
[0.5, 0.5, 0.0] # Partial similarity
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
scores = _calculate_similarity_impl(query_embedding, doc_embeddings)
|
| 115 |
+
|
| 116 |
+
# Check scores
|
| 117 |
+
self.assertEqual(len(scores), 3)
|
| 118 |
+
self.assertAlmostEqual(scores[0], 1.0, places=5)
|
| 119 |
+
self.assertAlmostEqual(scores[1], 0.0, places=5)
|
| 120 |
+
self.assertGreater(scores[2], 0.0)
|
| 121 |
+
self.assertLess(scores[2], 1.0)
|
| 122 |
+
|
| 123 |
+
def test_calculate_similarity_with_timer(self):
|
| 124 |
+
"""Test similarity calculation with timer"""
|
| 125 |
+
mock_timer = Mock()
|
| 126 |
+
mock_timer.time_step = MagicMock()
|
| 127 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 128 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 129 |
+
|
| 130 |
+
query_emb = [1.0, 0.0, 0.0]
|
| 131 |
+
doc_embs = [[1.0, 0.0, 0.0]]
|
| 132 |
+
|
| 133 |
+
calculate_similarity(query_emb, doc_embs, timer=mock_timer)
|
| 134 |
+
|
| 135 |
+
# Verify timer was used
|
| 136 |
+
mock_timer.time_step.assert_called_once_with("similarity_calculation")
|
| 137 |
+
|
| 138 |
+
def test_process_context_impl(self):
|
| 139 |
+
"""Test internal context processing implementation"""
|
| 140 |
+
# Create mock results with metadata
|
| 141 |
+
results = []
|
| 142 |
+
for i in range(3):
|
| 143 |
+
mock_result = Mock()
|
| 144 |
+
mock_result.metadata = {
|
| 145 |
+
'id': f'KB00{i+1}',
|
| 146 |
+
'question': f'Question {i+1}?',
|
| 147 |
+
'content': f'Answer {i+1}.'
|
| 148 |
+
}
|
| 149 |
+
results.append(mock_result)
|
| 150 |
+
|
| 151 |
+
# Cosine scores (sorted: 0.9, 0.7, 0.5)
|
| 152 |
+
cosine_scores = [0.7, 0.5, 0.9]
|
| 153 |
+
|
| 154 |
+
context, source_ids, knowledge_pairs = _process_context_impl(
|
| 155 |
+
results, cosine_scores, max_results=2
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# Should return top 2 results
|
| 159 |
+
self.assertEqual(len(source_ids), 2)
|
| 160 |
+
self.assertEqual(len(knowledge_pairs), 2)
|
| 161 |
+
|
| 162 |
+
# Check that highest score (0.9, index 2) is first
|
| 163 |
+
self.assertEqual(source_ids[0], 'KB003')
|
| 164 |
+
self.assertEqual(knowledge_pairs[0][0], 'Question 3?')
|
| 165 |
+
|
| 166 |
+
# Check formatted context
|
| 167 |
+
self.assertIn("Knowledge Entry 1:", context)
|
| 168 |
+
self.assertIn("Knowledge Entry 2:", context)
|
| 169 |
+
self.assertIn("Q: Question 3?", context)
|
| 170 |
+
self.assertIn("A: Answer 3.", context)
|
| 171 |
+
|
| 172 |
+
def test_process_context_with_timer(self):
|
| 173 |
+
"""Test context processing with timer"""
|
| 174 |
+
mock_result = Mock()
|
| 175 |
+
mock_result.metadata = {'id': 'KB001', 'question': 'Q?', 'content': 'A.'}
|
| 176 |
+
|
| 177 |
+
mock_timer = Mock()
|
| 178 |
+
mock_timer.time_step = MagicMock()
|
| 179 |
+
mock_timer.time_step.return_value.__enter__ = Mock()
|
| 180 |
+
mock_timer.time_step.return_value.__exit__ = Mock()
|
| 181 |
+
|
| 182 |
+
process_context([mock_result], [0.9], timer=mock_timer)
|
| 183 |
+
|
| 184 |
+
# Verify timer was used
|
| 185 |
+
mock_timer.time_step.assert_called_once_with("context_processing")
|
| 186 |
+
|
| 187 |
+
def test_process_context_max_results(self):
|
| 188 |
+
"""Test that max_results parameter limits output"""
|
| 189 |
+
# Create 5 mock results
|
| 190 |
+
results = []
|
| 191 |
+
for i in range(5):
|
| 192 |
+
mock_result = Mock()
|
| 193 |
+
mock_result.metadata = {
|
| 194 |
+
'id': f'KB00{i}',
|
| 195 |
+
'question': f'Q{i}?',
|
| 196 |
+
'content': f'A{i}.'
|
| 197 |
+
}
|
| 198 |
+
results.append(mock_result)
|
| 199 |
+
|
| 200 |
+
scores = [0.9, 0.8, 0.7, 0.6, 0.5]
|
| 201 |
+
|
| 202 |
+
# Request only 3 results
|
| 203 |
+
context, source_ids, knowledge_pairs = _process_context_impl(
|
| 204 |
+
results, scores, max_results=3
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# Should only return 3
|
| 208 |
+
self.assertEqual(len(source_ids), 3)
|
| 209 |
+
self.assertEqual(len(knowledge_pairs), 3)
|
| 210 |
+
|
| 211 |
+
def test_process_context_formatting(self):
|
| 212 |
+
"""Test context formatting details"""
|
| 213 |
+
mock_result = Mock()
|
| 214 |
+
mock_result.metadata = {
|
| 215 |
+
'id': 'KB001',
|
| 216 |
+
'question': 'Test question?',
|
| 217 |
+
'content': 'Test answer.'
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
context, _, _ = _process_context_impl([mock_result], [0.9], max_results=1)
|
| 221 |
+
|
| 222 |
+
# Check formatting
|
| 223 |
+
self.assertIn("Knowledge Entry 1:", context)
|
| 224 |
+
self.assertIn("Q: Test question?", context)
|
| 225 |
+
self.assertIn("A: Test answer.", context)
|
| 226 |
+
self.assertIn("-" * 40, context)
|
| 227 |
+
|
| 228 |
+
def test_process_context_missing_metadata(self):
|
| 229 |
+
"""Test context processing with missing metadata fields"""
|
| 230 |
+
mock_result = Mock()
|
| 231 |
+
mock_result.metadata = {} # No metadata
|
| 232 |
+
|
| 233 |
+
context, source_ids, knowledge_pairs = _process_context_impl(
|
| 234 |
+
[mock_result], [0.9], max_results=1
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Should handle missing fields with N/A
|
| 238 |
+
self.assertIn("N/A", context)
|
| 239 |
+
self.assertEqual(source_ids[0], "N/A")
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
if __name__ == '__main__':
|
| 243 |
+
unittest.main()
|