Spaces:
Sleeping
Sleeping
Tripagra commited on
Commit ·
b33a861
0
Parent(s):
first commit
Browse files- .env.example +23 -0
- .gitignore +54 -0
- .kiro/specs/smart-escalation-api/.config.kiro +1 -0
- .kiro/specs/smart-escalation-api/design.md +750 -0
- .kiro/specs/smart-escalation-api/requirements.md +144 -0
- .kiro/specs/smart-escalation-api/tasks.md +242 -0
- API_TEST_RESULTS.md +148 -0
- README.md +301 -0
- data/articles/.gitkeep +1 -0
- data/articles/billing.md +37 -0
- data/articles/getting-started.md +22 -0
- data/articles/integrations.md +33 -0
- data/articles/permissions.md +31 -0
- data/articles/troubleshooting.md +35 -0
- list_models.py +14 -0
- requirements.txt +23 -0
- src/__init__.py +1 -0
- src/chunking.py +150 -0
- src/config.py +49 -0
- src/escalation.py +239 -0
- src/llm_client.py +234 -0
- src/main.py +267 -0
- src/rag.py +219 -0
- test_api_manual.py +47 -0
- test_rag_checkpoint.py +202 -0
- tests/__init__.py +1 -0
- tests/integration/__init__.py +1 -0
- tests/property/__init__.py +1 -0
- tests/property/test_chunking_properties.py +231 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_chunking.py +179 -0
.env.example
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# LLM API Configuration
|
| 2 |
+
# Google Gemini API
|
| 3 |
+
GOOGLE_API_KEY=AIzaSyBEcuYN5-xzT_kU44WL8IvQfIPIW6CaKxs
|
| 4 |
+
|
| 5 |
+
# Embedding Model Configuration
|
| 6 |
+
EMBEDDING_MODEL=all-MiniLM-L6-v2
|
| 7 |
+
|
| 8 |
+
# Escalation Logic Configuration
|
| 9 |
+
RELEVANCE_THRESHOLD=0.5
|
| 10 |
+
TOP_K_CHUNKS=3
|
| 11 |
+
|
| 12 |
+
# Chunking Configuration
|
| 13 |
+
CHUNK_SIZE=500
|
| 14 |
+
CHUNK_OVERLAP=50
|
| 15 |
+
|
| 16 |
+
# LLM Configuration
|
| 17 |
+
LLM_MODEL=gemini-2.5-flash
|
| 18 |
+
# Other options: gemini-2.5-pro, gemini-flash-latest
|
| 19 |
+
LLM_TEMPERATURE=0.3
|
| 20 |
+
|
| 21 |
+
# Server Configuration (optional)
|
| 22 |
+
# PORT=8000
|
| 23 |
+
# HOST=0.0.0.0
|
.gitignore
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
ENV/
|
| 10 |
+
build/
|
| 11 |
+
develop-eggs/
|
| 12 |
+
dist/
|
| 13 |
+
downloads/
|
| 14 |
+
eggs/
|
| 15 |
+
.eggs/
|
| 16 |
+
lib/
|
| 17 |
+
lib64/
|
| 18 |
+
parts/
|
| 19 |
+
sdist/
|
| 20 |
+
var/
|
| 21 |
+
wheels/
|
| 22 |
+
*.egg-info/
|
| 23 |
+
.installed.cfg
|
| 24 |
+
*.egg
|
| 25 |
+
|
| 26 |
+
# Environment variables
|
| 27 |
+
.env
|
| 28 |
+
.env.local
|
| 29 |
+
|
| 30 |
+
# IDE
|
| 31 |
+
.vscode/
|
| 32 |
+
.idea/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
*~
|
| 36 |
+
|
| 37 |
+
# Testing
|
| 38 |
+
.pytest_cache/
|
| 39 |
+
.coverage
|
| 40 |
+
htmlcov/
|
| 41 |
+
.hypothesis/
|
| 42 |
+
|
| 43 |
+
# OS
|
| 44 |
+
.DS_Store
|
| 45 |
+
Thumbs.db
|
| 46 |
+
|
| 47 |
+
# Logs
|
| 48 |
+
*.log
|
| 49 |
+
|
| 50 |
+
# Frontend
|
| 51 |
+
frontend/node_modules/
|
| 52 |
+
frontend/dist/
|
| 53 |
+
frontend/.env
|
| 54 |
+
frontend/.env.local
|
.kiro/specs/smart-escalation-api/.config.kiro
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"specId": "90ea9d74-8680-4a15-8227-fea1dfad3026", "workflowType": "requirements-first", "specType": "feature"}
|
.kiro/specs/smart-escalation-api/design.md
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Design Document: Smart Escalation API
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
The Smart Escalation API is a lightweight RAG-based customer support system designed for 2-3.5 hour implementation. The system answers customer questions about TaskFlow using retrieval-augmented generation over 5 help articles, with intelligent escalation to human agents when confidence is low.
|
| 6 |
+
|
| 7 |
+
### Core Design Principles
|
| 8 |
+
|
| 9 |
+
1. **Simplicity First**: In-memory vector store, single API endpoint, minimal dependencies
|
| 10 |
+
2. **Escalation Over Uncertainty**: Conservative approach prioritizes accuracy over coverage
|
| 11 |
+
3. **Clear Separation**: Distinct modules for retrieval, generation, and escalation logic
|
| 12 |
+
4. **Free Hosting Compatible**: No external databases, deployable to Render/Railway/Vercel
|
| 13 |
+
|
| 14 |
+
### Key Components
|
| 15 |
+
|
| 16 |
+
- **FastAPI Backend**: Single POST endpoint handling question processing
|
| 17 |
+
- **RAG System**: In-memory FAISS vector store with sentence-transformers embeddings
|
| 18 |
+
- **Escalation Engine**: Rule-based decision logic using relevance scores and LLM confidence signals
|
| 19 |
+
- **React Chat UI**: Minimal interface for customer interaction
|
| 20 |
+
|
| 21 |
+
## Architecture
|
| 22 |
+
|
| 23 |
+
### System Architecture
|
| 24 |
+
|
| 25 |
+
```mermaid
|
| 26 |
+
graph TB
|
| 27 |
+
Customer[Customer] --> ChatUI[React Chat UI]
|
| 28 |
+
ChatUI --> API[FastAPI Endpoint]
|
| 29 |
+
API --> Orchestrator[Request Orchestrator]
|
| 30 |
+
Orchestrator --> RAG[RAG System]
|
| 31 |
+
RAG --> Embedder[Embedding Model]
|
| 32 |
+
RAG --> VectorStore[FAISS Vector Store]
|
| 33 |
+
Orchestrator --> Escalation[Escalation Engine]
|
| 34 |
+
Escalation --> LLM[LLM API]
|
| 35 |
+
Escalation --> Response[Response Builder]
|
| 36 |
+
Response --> API
|
| 37 |
+
|
| 38 |
+
HelpArticles[(5 Help Articles)] -.->|Load on startup| VectorStore
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
### Request Flow
|
| 42 |
+
|
| 43 |
+
1. **Question Submission**: Customer submits question via Chat UI
|
| 44 |
+
2. **Embedding Generation**: Question is embedded using sentence-transformers
|
| 45 |
+
3. **Retrieval**: Top-k chunks retrieved from FAISS vector store
|
| 46 |
+
4. **Escalation Decision**: Engine evaluates relevance scores and context quality
|
| 47 |
+
5. **Response Generation**:
|
| 48 |
+
- If confident: LLM generates grounded answer
|
| 49 |
+
- If uncertain: Return escalation message
|
| 50 |
+
6. **Response Delivery**: JSON response with answer/escalation + confidence explanation
|
| 51 |
+
|
| 52 |
+
### Technology Stack
|
| 53 |
+
|
| 54 |
+
| Component | Technology | Rationale |
|
| 55 |
+
|-----------|-----------|-----------|
|
| 56 |
+
| Backend Framework | FastAPI | Fast, async, automatic OpenAPI docs |
|
| 57 |
+
| Embeddings | sentence-transformers (all-MiniLM-L6-v2) | Lightweight, no API costs, good quality |
|
| 58 |
+
| Vector Store | FAISS (in-memory) | Fast similarity search, no external DB |
|
| 59 |
+
| LLM | Google Gemini API | Free tier available, high-quality generation |
|
| 60 |
+
| Frontend | React + Vite | Fast setup, minimal boilerplate |
|
| 61 |
+
| Deployment | Render/Railway | Free tier, easy Python deployment |
|
| 62 |
+
|
| 63 |
+
## Components and Interfaces
|
| 64 |
+
|
| 65 |
+
### 1. FastAPI Application (`main.py`)
|
| 66 |
+
|
| 67 |
+
**Responsibilities**:
|
| 68 |
+
- Expose POST `/ask` endpoint
|
| 69 |
+
- Handle CORS configuration
|
| 70 |
+
- Coordinate request processing
|
| 71 |
+
- Return structured JSON responses
|
| 72 |
+
|
| 73 |
+
**Interface**:
|
| 74 |
+
```python
|
| 75 |
+
@app.post("/ask")
|
| 76 |
+
async def ask_question(request: QuestionRequest) -> QuestionResponse:
|
| 77 |
+
"""
|
| 78 |
+
Process customer question and return answer or escalation.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
request: QuestionRequest with 'question' field
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
QuestionResponse with response_type, message, confidence_explanation
|
| 85 |
+
"""
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
**Models**:
|
| 89 |
+
```python
|
| 90 |
+
class QuestionRequest(BaseModel):
|
| 91 |
+
question: str
|
| 92 |
+
|
| 93 |
+
class QuestionResponse(BaseModel):
|
| 94 |
+
response_type: Literal["answer", "escalation"]
|
| 95 |
+
message: str
|
| 96 |
+
confidence_explanation: str
|
| 97 |
+
sources: Optional[List[str]] = None
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### 2. RAG System (`rag.py`)
|
| 101 |
+
|
| 102 |
+
**Responsibilities**:
|
| 103 |
+
- Load and chunk help articles on startup
|
| 104 |
+
- Generate embeddings for chunks and questions
|
| 105 |
+
- Perform similarity search
|
| 106 |
+
- Return top-k relevant chunks with scores
|
| 107 |
+
|
| 108 |
+
**Interface**:
|
| 109 |
+
```python
|
| 110 |
+
class RAGSystem:
|
| 111 |
+
def __init__(self, articles_dir: str, chunk_size: int = 500, chunk_overlap: int = 50):
|
| 112 |
+
"""Initialize RAG system with help articles."""
|
| 113 |
+
|
| 114 |
+
def retrieve(self, question: str, top_k: int = 3) -> List[RetrievedChunk]:
|
| 115 |
+
"""
|
| 116 |
+
Retrieve most relevant chunks for question.
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
question: Customer question
|
| 120 |
+
top_k: Number of chunks to retrieve
|
| 121 |
+
|
| 122 |
+
Returns:
|
| 123 |
+
List of RetrievedChunk with content, score, source
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
class RetrievedChunk:
|
| 127 |
+
content: str
|
| 128 |
+
score: float # Cosine similarity score
|
| 129 |
+
source: str # Help article filename
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
**Chunking Strategy**:
|
| 133 |
+
- Fixed-size chunks with overlap (500 chars, 50 char overlap)
|
| 134 |
+
- Preserves sentence boundaries where possible
|
| 135 |
+
- Metadata tracks source article for each chunk
|
| 136 |
+
|
| 137 |
+
### 3. Escalation Engine (`escalation.py`)
|
| 138 |
+
|
| 139 |
+
**Responsibilities**:
|
| 140 |
+
- Evaluate retrieval quality (relevance scores)
|
| 141 |
+
- Decide whether to answer or escalate
|
| 142 |
+
- Generate confidence explanations
|
| 143 |
+
- Coordinate LLM calls when answering
|
| 144 |
+
|
| 145 |
+
**Interface**:
|
| 146 |
+
```python
|
| 147 |
+
class EscalationEngine:
|
| 148 |
+
def __init__(self, llm_client: LLMClient, relevance_threshold: float = 0.5):
|
| 149 |
+
"""Initialize escalation engine with LLM client and thresholds."""
|
| 150 |
+
|
| 151 |
+
def process_question(
|
| 152 |
+
self,
|
| 153 |
+
question: str,
|
| 154 |
+
retrieved_chunks: List[RetrievedChunk]
|
| 155 |
+
) -> EscalationDecision:
|
| 156 |
+
"""
|
| 157 |
+
Decide whether to answer or escalate.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
question: Customer question
|
| 161 |
+
retrieved_chunks: Retrieved context from RAG
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
EscalationDecision with action, message, explanation
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
class EscalationDecision:
|
| 168 |
+
action: Literal["answer", "escalate"]
|
| 169 |
+
message: str
|
| 170 |
+
confidence_explanation: str
|
| 171 |
+
sources: Optional[List[str]] = None
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
**Decision Logic**:
|
| 175 |
+
```python
|
| 176 |
+
def should_escalate(chunks: List[RetrievedChunk]) -> bool:
|
| 177 |
+
"""
|
| 178 |
+
Escalate if:
|
| 179 |
+
1. Best chunk score < 0.5 (low relevance)
|
| 180 |
+
2. All chunks from same article (narrow coverage)
|
| 181 |
+
3. LLM returns uncertainty signal in response
|
| 182 |
+
"""
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
### 4. LLM Client (`llm_client.py`)
|
| 186 |
+
|
| 187 |
+
**Responsibilities**:
|
| 188 |
+
- Abstract LLM API calls (Google Gemini)
|
| 189 |
+
- Format prompts with retrieved context
|
| 190 |
+
- Parse LLM responses
|
| 191 |
+
- Handle API errors gracefully
|
| 192 |
+
|
| 193 |
+
**Interface**:
|
| 194 |
+
```python
|
| 195 |
+
class LLMClient:
|
| 196 |
+
def generate_answer(
|
| 197 |
+
self,
|
| 198 |
+
question: str,
|
| 199 |
+
context_chunks: List[str]
|
| 200 |
+
) -> LLMResponse:
|
| 201 |
+
"""
|
| 202 |
+
Generate answer using LLM with retrieved context.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
question: Customer question
|
| 206 |
+
context_chunks: Retrieved help article chunks
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
LLMResponse with answer text and confidence signal
|
| 210 |
+
"""
|
| 211 |
+
|
| 212 |
+
class LLMResponse:
|
| 213 |
+
answer: str
|
| 214 |
+
uncertain: bool # True if LLM signals uncertainty
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
**Prompt Template**:
|
| 218 |
+
```
|
| 219 |
+
You are a friendly customer support agent for TaskFlow, a project management SaaS.
|
| 220 |
+
|
| 221 |
+
Answer the customer's question using ONLY the information provided in the help articles below.
|
| 222 |
+
|
| 223 |
+
IMPORTANT RULES:
|
| 224 |
+
- Use a friendly, helpful tone
|
| 225 |
+
- Ground your answer in the provided context
|
| 226 |
+
- If the context doesn't contain enough information to answer confidently, respond with: "I'm not certain about this. Let me connect you with a human agent."
|
| 227 |
+
- Do not make up or infer information not present in the context
|
| 228 |
+
|
| 229 |
+
HELP ARTICLE CONTEXT:
|
| 230 |
+
{context_chunks}
|
| 231 |
+
|
| 232 |
+
CUSTOMER QUESTION:
|
| 233 |
+
{question}
|
| 234 |
+
|
| 235 |
+
YOUR ANSWER:
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### 5. React Chat UI (`frontend/`)
|
| 239 |
+
|
| 240 |
+
**Responsibilities**:
|
| 241 |
+
- Render chat interface
|
| 242 |
+
- Submit questions to API
|
| 243 |
+
- Display answers and escalations
|
| 244 |
+
- Show confidence explanations
|
| 245 |
+
|
| 246 |
+
**Key Components**:
|
| 247 |
+
```typescript
|
| 248 |
+
interface Message {
|
| 249 |
+
role: 'user' | 'assistant';
|
| 250 |
+
content: string;
|
| 251 |
+
confidenceExplanation?: string;
|
| 252 |
+
responseType?: 'answer' | 'escalation';
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function ChatInterface() {
|
| 256 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 257 |
+
const [input, setInput] = useState('');
|
| 258 |
+
const [loading, setLoading] = useState(false);
|
| 259 |
+
|
| 260 |
+
const handleSubmit = async () => {
|
| 261 |
+
// POST to /ask endpoint
|
| 262 |
+
// Update messages with response
|
| 263 |
+
};
|
| 264 |
+
}
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
## Data Models
|
| 268 |
+
|
| 269 |
+
### Help Article Structure
|
| 270 |
+
|
| 271 |
+
```python
|
| 272 |
+
class HelpArticle:
|
| 273 |
+
"""Represents a single help article."""
|
| 274 |
+
filename: str
|
| 275 |
+
title: str
|
| 276 |
+
content: str
|
| 277 |
+
chunks: List[str] # Generated during preprocessing
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
**Article Files** (stored as markdown in `data/articles/`):
|
| 281 |
+
1. `getting-started.md` - TaskFlow basics, account setup, first project
|
| 282 |
+
2. `billing.md` - Subscription plans, payment methods, billing cycles
|
| 283 |
+
3. `integrations.md` - Slack, GitHub, Jira integrations
|
| 284 |
+
4. `permissions.md` - Team roles, access control, admin settings
|
| 285 |
+
5. `troubleshooting.md` - Common errors, login issues, performance tips
|
| 286 |
+
|
| 287 |
+
### Vector Store Schema
|
| 288 |
+
|
| 289 |
+
```python
|
| 290 |
+
# FAISS index stores:
|
| 291 |
+
# - embeddings: np.ndarray of shape (n_chunks, embedding_dim)
|
| 292 |
+
# - metadata: List[ChunkMetadata]
|
| 293 |
+
|
| 294 |
+
class ChunkMetadata:
|
| 295 |
+
chunk_id: int
|
| 296 |
+
source_article: str
|
| 297 |
+
chunk_text: str
|
| 298 |
+
start_char: int # Position in original article
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
### API Request/Response Models
|
| 302 |
+
|
| 303 |
+
**Request**:
|
| 304 |
+
```json
|
| 305 |
+
{
|
| 306 |
+
"question": "How do I add a team member to my project?"
|
| 307 |
+
}
|
| 308 |
+
```
|
| 309 |
+
|
| 310 |
+
**Response (Answer)**:
|
| 311 |
+
```json
|
| 312 |
+
{
|
| 313 |
+
"response_type": "answer",
|
| 314 |
+
"message": "To add a team member to your project in TaskFlow, go to Project Settings > Team Members and click 'Invite Member'. You can set their role as Viewer, Editor, or Admin depending on the permissions you want to grant.",
|
| 315 |
+
"confidence_explanation": "High confidence - retrieved relevant content from 'Managing Team Permissions' article with similarity score 0.82",
|
| 316 |
+
"sources": ["permissions.md"]
|
| 317 |
+
}
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
**Response (Escalation)**:
|
| 321 |
+
```json
|
| 322 |
+
{
|
| 323 |
+
"response_type": "escalation",
|
| 324 |
+
"message": "I'm not certain I can answer this accurately. Let me connect you with a human support agent who can help.",
|
| 325 |
+
"confidence_explanation": "Low confidence - best retrieval score was 0.42, indicating question may be outside help article scope",
|
| 326 |
+
"sources": null
|
| 327 |
+
}
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
## Correctness Properties
|
| 331 |
+
|
| 332 |
+
*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
### Property 1: Invalid Request Handling
|
| 336 |
+
|
| 337 |
+
*For any* malformed or invalid request (missing required fields, wrong data types, malformed JSON), the API SHALL return a 400 status code with an error message.
|
| 338 |
+
|
| 339 |
+
**Validates: Requirements 1.4**
|
| 340 |
+
|
| 341 |
+
### Property 2: Top-K Retrieval Behavior
|
| 342 |
+
|
| 343 |
+
*For any* customer question and any positive integer k, the RAG system SHALL return exactly k chunks (or fewer if total chunks < k) ordered by descending relevance score.
|
| 344 |
+
|
| 345 |
+
**Validates: Requirements 2.4**
|
| 346 |
+
|
| 347 |
+
### Property 3: Chunking Semantic Coherence
|
| 348 |
+
|
| 349 |
+
*For any* help article text, the chunking strategy SHALL NOT split words mid-character, SHALL maintain the specified overlap between consecutive chunks, and SHALL preserve complete tokens.
|
| 350 |
+
|
| 351 |
+
**Validates: Requirements 2.6**
|
| 352 |
+
|
| 353 |
+
### Property 4: Escalation on Low Confidence
|
| 354 |
+
|
| 355 |
+
*For any* question where retrieved chunks have maximum relevance score below the threshold OR the LLM signals uncertainty, the escalation logic SHALL return an escalation response rather than an answer.
|
| 356 |
+
|
| 357 |
+
**Validates: Requirements 3.5, 4.2, 4.4**
|
| 358 |
+
|
| 359 |
+
### Property 5: Response Schema Consistency
|
| 360 |
+
|
| 361 |
+
*For any* valid customer question, the API response SHALL conform to the defined JSON schema with required fields (response_type, message, confidence_explanation) having correct types, and response_type SHALL be either "answer" or "escalation".
|
| 362 |
+
|
| 363 |
+
**Validates: Requirements 5.1, 6.1, 6.2, 6.3, 6.4, 6.5**
|
| 364 |
+
|
| 365 |
+
### Property 6: Prompt Construction Completeness
|
| 366 |
+
|
| 367 |
+
*For any* customer question and any set of retrieved chunks, the constructed LLM prompt SHALL include both the complete question text and all retrieved chunk contents.
|
| 368 |
+
|
| 369 |
+
**Validates: Requirements 9.4, 9.5**
|
| 370 |
+
|
| 371 |
+
## Error Handling
|
| 372 |
+
|
| 373 |
+
### API-Level Errors
|
| 374 |
+
|
| 375 |
+
**Invalid Request Handling**:
|
| 376 |
+
- Missing `question` field → 400 with message "Missing required field: question"
|
| 377 |
+
- Empty question string → 400 with message "Question cannot be empty"
|
| 378 |
+
- Malformed JSON → 400 with message "Invalid JSON format"
|
| 379 |
+
- Request timeout (>10s) → 504 with message "Request timeout"
|
| 380 |
+
|
| 381 |
+
**LLM API Errors**:
|
| 382 |
+
- API key invalid → 500 with message "Configuration error"
|
| 383 |
+
- Rate limit exceeded → 429 with message "Service temporarily unavailable, please try again"
|
| 384 |
+
- LLM API timeout → Fallback to escalation with explanation "Unable to generate response, connecting you with human agent"
|
| 385 |
+
- Network errors → Retry once, then escalate
|
| 386 |
+
|
| 387 |
+
**Embedding Errors**:
|
| 388 |
+
- Embedding generation fails → Log error, return 500 with message "Unable to process question"
|
| 389 |
+
- Model not loaded → Fail fast on startup with clear error message
|
| 390 |
+
|
| 391 |
+
### Graceful Degradation
|
| 392 |
+
|
| 393 |
+
**Retrieval Failures**:
|
| 394 |
+
- No chunks retrieved (empty vector store) → Automatic escalation
|
| 395 |
+
- All relevance scores = 0 → Automatic escalation
|
| 396 |
+
- Vector store corruption → Fail fast with error, require restart
|
| 397 |
+
|
| 398 |
+
**Response Generation**:
|
| 399 |
+
- LLM returns empty response → Treat as uncertainty signal, escalate
|
| 400 |
+
- LLM response exceeds length limit → Truncate with "..." and include full response in logs
|
| 401 |
+
- Confidence explanation generation fails → Use default: "Unable to generate explanation"
|
| 402 |
+
|
| 403 |
+
### Error Logging
|
| 404 |
+
|
| 405 |
+
All errors SHALL be logged with:
|
| 406 |
+
- Timestamp
|
| 407 |
+
- Error type and message
|
| 408 |
+
- Request context (question, retrieved chunks if available)
|
| 409 |
+
- Stack trace for 500-level errors
|
| 410 |
+
|
| 411 |
+
## Testing Strategy
|
| 412 |
+
|
| 413 |
+
### Overview
|
| 414 |
+
|
| 415 |
+
The Smart Escalation API requires a **hybrid testing approach** combining unit tests, integration tests, and property-based tests. Given the system's reliance on external services (LLM APIs, embedding models) and non-deterministic behavior, property-based testing is appropriate only for the pure logic components.
|
| 416 |
+
|
| 417 |
+
### Unit Tests
|
| 418 |
+
|
| 419 |
+
**Focus Areas**:
|
| 420 |
+
- Chunking logic (text splitting, overlap calculation)
|
| 421 |
+
- Escalation decision rules (threshold comparisons)
|
| 422 |
+
- Response formatting (JSON construction)
|
| 423 |
+
- Prompt template construction
|
| 424 |
+
- Error handling paths
|
| 425 |
+
|
| 426 |
+
**Example Test Cases**:
|
| 427 |
+
```python
|
| 428 |
+
def test_chunking_preserves_sentences():
|
| 429 |
+
"""Verify chunks don't split mid-sentence."""
|
| 430 |
+
article = "First sentence. Second sentence. Third sentence."
|
| 431 |
+
chunks = chunk_text(article, chunk_size=20, overlap=5)
|
| 432 |
+
for chunk in chunks:
|
| 433 |
+
assert not chunk.endswith(" ") # No trailing spaces
|
| 434 |
+
assert chunk[0].isupper() or chunk[0] in "0123456789" # Starts properly
|
| 435 |
+
|
| 436 |
+
def test_escalation_on_low_score():
|
| 437 |
+
"""Verify escalation when relevance below threshold."""
|
| 438 |
+
chunks = [RetrievedChunk(content="...", score=0.3, source="test.md")]
|
| 439 |
+
decision = escalation_engine.process_question("test", chunks)
|
| 440 |
+
assert decision.action == "escalate"
|
| 441 |
+
|
| 442 |
+
def test_response_schema():
|
| 443 |
+
"""Verify response has required fields."""
|
| 444 |
+
response = api_client.post("/ask", json={"question": "test"})
|
| 445 |
+
assert "response_type" in response.json()
|
| 446 |
+
assert "message" in response.json()
|
| 447 |
+
assert "confidence_explanation" in response.json()
|
| 448 |
+
```
|
| 449 |
+
|
| 450 |
+
### Property-Based Tests
|
| 451 |
+
|
| 452 |
+
**Testing Library**: Hypothesis (Python)
|
| 453 |
+
|
| 454 |
+
**Configuration**: Minimum 100 iterations per property test
|
| 455 |
+
|
| 456 |
+
**Property Test Cases**:
|
| 457 |
+
|
| 458 |
+
```python
|
| 459 |
+
from hypothesis import given, strategies as st
|
| 460 |
+
|
| 461 |
+
@given(st.text(min_size=1, max_size=5000))
|
| 462 |
+
def test_property_chunking_no_word_splits(article_text):
|
| 463 |
+
"""
|
| 464 |
+
Property 3: Chunking Semantic Coherence
|
| 465 |
+
For any article text, chunks should not split words.
|
| 466 |
+
|
| 467 |
+
Feature: smart-escalation-api, Property 3: Chunking preserves semantic coherence
|
| 468 |
+
"""
|
| 469 |
+
chunks = chunk_text(article_text, chunk_size=500, overlap=50)
|
| 470 |
+
for chunk in chunks:
|
| 471 |
+
# No chunks should end with partial words (non-whitespace followed by cut)
|
| 472 |
+
if len(chunk) == 500: # Full-size chunk
|
| 473 |
+
assert chunk[-1].isspace() or chunk[-1] in ".,!?;:"
|
| 474 |
+
|
| 475 |
+
@given(st.integers(min_value=1, max_value=10))
|
| 476 |
+
def test_property_topk_retrieval(k):
|
| 477 |
+
"""
|
| 478 |
+
Property 2: Top-K Retrieval Behavior
|
| 479 |
+
For any k value, retrieval returns exactly k chunks (or fewer if not enough chunks exist).
|
| 480 |
+
|
| 481 |
+
Feature: smart-escalation-api, Property 2: Top-k retrieval returns exactly k chunks
|
| 482 |
+
"""
|
| 483 |
+
question = "How do I reset my password?"
|
| 484 |
+
chunks = rag_system.retrieve(question, top_k=k)
|
| 485 |
+
assert len(chunks) <= k
|
| 486 |
+
# Verify descending order
|
| 487 |
+
scores = [c.score for c in chunks]
|
| 488 |
+
assert scores == sorted(scores, reverse=True)
|
| 489 |
+
|
| 490 |
+
@given(st.floats(min_value=0.0, max_value=0.49))
|
| 491 |
+
def test_property_escalation_on_low_confidence(max_score):
|
| 492 |
+
"""
|
| 493 |
+
Property 4: Escalation on Low Confidence
|
| 494 |
+
For any retrieval with max score below threshold (0.5), escalation should occur.
|
| 495 |
+
|
| 496 |
+
Feature: smart-escalation-api, Property 4: Escalation triggered by low confidence
|
| 497 |
+
"""
|
| 498 |
+
chunks = [
|
| 499 |
+
RetrievedChunk(content="test", score=max_score, source="test.md")
|
| 500 |
+
]
|
| 501 |
+
decision = escalation_engine.process_question("test question", chunks)
|
| 502 |
+
assert decision.action == "escalate"
|
| 503 |
+
|
| 504 |
+
@given(st.text(min_size=1, max_size=500))
|
| 505 |
+
def test_property_response_schema(question):
|
| 506 |
+
"""
|
| 507 |
+
Property 5: Response Schema Consistency
|
| 508 |
+
For any valid question, response conforms to schema.
|
| 509 |
+
|
| 510 |
+
Feature: smart-escalation-api, Property 5: Response schema consistency
|
| 511 |
+
"""
|
| 512 |
+
response = api_client.post("/ask", json={"question": question})
|
| 513 |
+
data = response.json()
|
| 514 |
+
assert "response_type" in data
|
| 515 |
+
assert data["response_type"] in ["answer", "escalation"]
|
| 516 |
+
assert "message" in data
|
| 517 |
+
assert isinstance(data["message"], str)
|
| 518 |
+
assert "confidence_explanation" in data
|
| 519 |
+
assert isinstance(data["confidence_explanation"], str)
|
| 520 |
+
|
| 521 |
+
@given(
|
| 522 |
+
st.text(min_size=1, max_size=200),
|
| 523 |
+
st.lists(st.text(min_size=10, max_size=500), min_size=1, max_size=5)
|
| 524 |
+
)
|
| 525 |
+
def test_property_prompt_construction(question, chunks):
|
| 526 |
+
"""
|
| 527 |
+
Property 6: Prompt Construction Completeness
|
| 528 |
+
For any question and chunks, prompt includes both.
|
| 529 |
+
|
| 530 |
+
Feature: smart-escalation-api, Property 6: Prompt includes context and question
|
| 531 |
+
"""
|
| 532 |
+
prompt = llm_client.build_prompt(question, chunks)
|
| 533 |
+
assert question in prompt
|
| 534 |
+
for chunk in chunks:
|
| 535 |
+
assert chunk in prompt
|
| 536 |
+
```
|
| 537 |
+
|
| 538 |
+
### Integration Tests
|
| 539 |
+
|
| 540 |
+
**Focus Areas**:
|
| 541 |
+
- End-to-end API flow (question → response)
|
| 542 |
+
- LLM API integration (with mocked responses)
|
| 543 |
+
- Embedding model integration
|
| 544 |
+
- CORS configuration
|
| 545 |
+
- Error handling with external services
|
| 546 |
+
|
| 547 |
+
**Example Test Cases**:
|
| 548 |
+
```python
|
| 549 |
+
def test_integration_answer_flow():
|
| 550 |
+
"""Test full flow for answerable question."""
|
| 551 |
+
response = api_client.post("/ask", json={
|
| 552 |
+
"question": "How do I create a new project?"
|
| 553 |
+
})
|
| 554 |
+
assert response.status_code == 200
|
| 555 |
+
data = response.json()
|
| 556 |
+
assert data["response_type"] in ["answer", "escalation"]
|
| 557 |
+
|
| 558 |
+
def test_integration_cors_headers():
|
| 559 |
+
"""Verify CORS headers present."""
|
| 560 |
+
response = api_client.options("/ask")
|
| 561 |
+
assert "Access-Control-Allow-Origin" in response.headers
|
| 562 |
+
|
| 563 |
+
@mock.patch('llm_client.openai_client')
|
| 564 |
+
def test_integration_llm_timeout(mock_llm):
|
| 565 |
+
"""Verify graceful handling of LLM timeout."""
|
| 566 |
+
mock_llm.side_effect = TimeoutError()
|
| 567 |
+
response = api_client.post("/ask", json={"question": "test"})
|
| 568 |
+
data = response.json()
|
| 569 |
+
assert data["response_type"] == "escalation"
|
| 570 |
+
```
|
| 571 |
+
|
| 572 |
+
### Frontend Tests
|
| 573 |
+
|
| 574 |
+
**Testing Library**: React Testing Library + Vitest
|
| 575 |
+
|
| 576 |
+
**Focus Areas**:
|
| 577 |
+
- Component rendering
|
| 578 |
+
- User interactions (input, submit)
|
| 579 |
+
- API response display
|
| 580 |
+
- Loading states
|
| 581 |
+
|
| 582 |
+
**Example Test Cases**:
|
| 583 |
+
```typescript
|
| 584 |
+
test('displays user question in chat history', async () => {
|
| 585 |
+
render(<ChatInterface />);
|
| 586 |
+
const input = screen.getByRole('textbox');
|
| 587 |
+
const submitButton = screen.getByRole('button', { name: /send/i });
|
| 588 |
+
|
| 589 |
+
await userEvent.type(input, 'How do I reset my password?');
|
| 590 |
+
await userEvent.click(submitButton);
|
| 591 |
+
|
| 592 |
+
expect(screen.getByText('How do I reset my password?')).toBeInTheDocument();
|
| 593 |
+
});
|
| 594 |
+
|
| 595 |
+
test('shows loading state during API call', async () => {
|
| 596 |
+
render(<ChatInterface />);
|
| 597 |
+
const input = screen.getByRole('textbox');
|
| 598 |
+
const submitButton = screen.getByRole('button', { name: /send/i });
|
| 599 |
+
|
| 600 |
+
await userEvent.type(input, 'test question');
|
| 601 |
+
await userEvent.click(submitButton);
|
| 602 |
+
|
| 603 |
+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
| 604 |
+
});
|
| 605 |
+
```
|
| 606 |
+
|
| 607 |
+
### Test Coverage Goals
|
| 608 |
+
|
| 609 |
+
- **Unit Tests**: 80%+ coverage of pure logic functions
|
| 610 |
+
- **Property Tests**: All 6 correctness properties implemented
|
| 611 |
+
- **Integration Tests**: All API endpoints and error paths
|
| 612 |
+
- **Frontend Tests**: All user interactions and state changes
|
| 613 |
+
|
| 614 |
+
### Manual Testing Checklist
|
| 615 |
+
|
| 616 |
+
- [ ] Deploy to Render/Railway and verify functionality
|
| 617 |
+
- [ ] Test with various question types (in-scope, out-of-scope, edge cases)
|
| 618 |
+
- [ ] Verify LLM responses have appropriate tone
|
| 619 |
+
- [ ] Check confidence explanations are helpful
|
| 620 |
+
- [ ] Test error scenarios (invalid API key, network issues)
|
| 621 |
+
- [ ] Verify UI is functional on desktop and mobile browsers
|
| 622 |
+
|
| 623 |
+
## Implementation Notes
|
| 624 |
+
|
| 625 |
+
### Development Phases
|
| 626 |
+
|
| 627 |
+
**Phase 1: Core RAG System (1 hour)**
|
| 628 |
+
- Implement article loading and chunking
|
| 629 |
+
- Set up FAISS vector store
|
| 630 |
+
- Implement embedding generation
|
| 631 |
+
- Test retrieval with sample questions
|
| 632 |
+
|
| 633 |
+
**Phase 2: API and Escalation Logic (1 hour)**
|
| 634 |
+
- Create FastAPI application
|
| 635 |
+
- Implement escalation engine
|
| 636 |
+
- Integrate LLM client
|
| 637 |
+
- Add error handling
|
| 638 |
+
|
| 639 |
+
**Phase 3: Frontend (0.5-1 hour)**
|
| 640 |
+
- Create React chat interface
|
| 641 |
+
- Implement API integration
|
| 642 |
+
- Add loading states
|
| 643 |
+
- Basic styling
|
| 644 |
+
|
| 645 |
+
**Phase 4: Testing and Deployment (0.5 hour)**
|
| 646 |
+
- Write unit and property tests
|
| 647 |
+
- Deploy to hosting platform
|
| 648 |
+
- End-to-end testing
|
| 649 |
+
|
| 650 |
+
### Configuration Management
|
| 651 |
+
|
| 652 |
+
**Environment Variables**:
|
| 653 |
+
```bash
|
| 654 |
+
# Required
|
| 655 |
+
GOOGLE_API_KEY=AIza...
|
| 656 |
+
EMBEDDING_MODEL=all-MiniLM-L6-v2
|
| 657 |
+
RELEVANCE_THRESHOLD=0.5
|
| 658 |
+
|
| 659 |
+
# Optional
|
| 660 |
+
TOP_K_CHUNKS=3
|
| 661 |
+
CHUNK_SIZE=500
|
| 662 |
+
CHUNK_OVERLAP=50
|
| 663 |
+
LLM_MODEL=gemini-1.5-flash
|
| 664 |
+
LLM_TEMPERATURE=0.3
|
| 665 |
+
```
|
| 666 |
+
|
| 667 |
+
**Configuration File** (`config.py`):
|
| 668 |
+
```python
|
| 669 |
+
from pydantic_settings import BaseSettings
|
| 670 |
+
|
| 671 |
+
class Settings(BaseSettings):
|
| 672 |
+
google_api_key: str
|
| 673 |
+
embedding_model: str = "all-MiniLM-L6-v2"
|
| 674 |
+
relevance_threshold: float = 0.5
|
| 675 |
+
top_k_chunks: int = 3
|
| 676 |
+
chunk_size: int = 500
|
| 677 |
+
chunk_overlap: int = 50
|
| 678 |
+
llm_model: str = "gemini-1.5-flash"
|
| 679 |
+
llm_temperature: float = 0.3
|
| 680 |
+
|
| 681 |
+
class Config:
|
| 682 |
+
env_file = ".env"
|
| 683 |
+
```
|
| 684 |
+
|
| 685 |
+
### Performance Considerations
|
| 686 |
+
|
| 687 |
+
**Startup Time**:
|
| 688 |
+
- Load articles and build vector store on startup (~2-3 seconds)
|
| 689 |
+
- Cache embeddings to avoid recomputation
|
| 690 |
+
- Use lazy loading for embedding model
|
| 691 |
+
|
| 692 |
+
**Response Time**:
|
| 693 |
+
- Target: <5 seconds for 95th percentile
|
| 694 |
+
- Embedding generation: ~100ms
|
| 695 |
+
- Vector search: ~50ms
|
| 696 |
+
- LLM API call: 2-4 seconds (main bottleneck)
|
| 697 |
+
- Total: ~3-5 seconds
|
| 698 |
+
|
| 699 |
+
**Memory Usage**:
|
| 700 |
+
- FAISS index: ~10-20 MB for 5 articles
|
| 701 |
+
- Embedding model: ~100 MB
|
| 702 |
+
- Total: ~150 MB (well within free tier limits)
|
| 703 |
+
|
| 704 |
+
### Security Considerations
|
| 705 |
+
|
| 706 |
+
**API Key Management**:
|
| 707 |
+
- Store LLM API keys in environment variables
|
| 708 |
+
- Never commit keys to version control
|
| 709 |
+
- Use `.env` file for local development
|
| 710 |
+
- Use platform secrets for deployment
|
| 711 |
+
|
| 712 |
+
**Input Validation**:
|
| 713 |
+
- Sanitize user input to prevent injection attacks
|
| 714 |
+
- Limit question length (max 500 characters)
|
| 715 |
+
- Rate limiting (if needed): 10 requests/minute per IP
|
| 716 |
+
|
| 717 |
+
**CORS Configuration**:
|
| 718 |
+
- Allow only specific frontend origins in production
|
| 719 |
+
- Use wildcard (`*`) only for development
|
| 720 |
+
|
| 721 |
+
### Deployment Instructions
|
| 722 |
+
|
| 723 |
+
**Render Deployment**:
|
| 724 |
+
1. Create new Web Service
|
| 725 |
+
2. Connect GitHub repository
|
| 726 |
+
3. Set build command: `pip install -r requirements.txt`
|
| 727 |
+
4. Set start command: `uvicorn main:app --host 0.0.0.0 --port $PORT`
|
| 728 |
+
5. Add environment variables (API keys)
|
| 729 |
+
6. Deploy
|
| 730 |
+
|
| 731 |
+
**Frontend Deployment** (Vercel):
|
| 732 |
+
1. Create new project
|
| 733 |
+
2. Connect GitHub repository
|
| 734 |
+
3. Set framework preset: React
|
| 735 |
+
4. Set build command: `npm run build`
|
| 736 |
+
5. Set output directory: `dist`
|
| 737 |
+
6. Add environment variable: `VITE_API_URL=<backend-url>`
|
| 738 |
+
7. Deploy
|
| 739 |
+
|
| 740 |
+
### Future Enhancements
|
| 741 |
+
|
| 742 |
+
**Potential Improvements** (out of scope for initial implementation):
|
| 743 |
+
- Conversation history tracking
|
| 744 |
+
- Multi-turn dialogue support
|
| 745 |
+
- Feedback collection (thumbs up/down)
|
| 746 |
+
- Analytics dashboard for escalation rates
|
| 747 |
+
- A/B testing different escalation thresholds
|
| 748 |
+
- Persistent vector store (PostgreSQL with pgvector)
|
| 749 |
+
- Streaming LLM responses
|
| 750 |
+
- Multi-language support
|
.kiro/specs/smart-escalation-api/requirements.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Requirements Document
|
| 2 |
+
|
| 3 |
+
## Introduction
|
| 4 |
+
|
| 5 |
+
The Smart Escalation API is an AI-powered L1 customer support system that intelligently answers customer questions about TaskFlow (a fictional project management SaaS) or escalates to human agents when uncertain. The system uses RAG-based retrieval over 5 help articles to ground responses and includes a minimal React chat UI for interaction.
|
| 6 |
+
|
| 7 |
+
## Glossary
|
| 8 |
+
|
| 9 |
+
- **API**: The FastAPI backend service that processes customer questions
|
| 10 |
+
- **RAG_System**: The Retrieval-Augmented Generation system that searches help articles using embeddings and vector store
|
| 11 |
+
- **LLM**: The Large Language Model (Claude, OpenAI, etc.) that generates responses
|
| 12 |
+
- **Escalation_Logic**: The decision-making component that determines whether to answer or escalate
|
| 13 |
+
- **Chat_UI**: The React-based frontend interface for customer interaction
|
| 14 |
+
- **Help_Article**: One of 5 TaskFlow documentation articles stored in the knowledge base
|
| 15 |
+
- **Confidence_Indicator**: An explanation of why the system answered or escalated
|
| 16 |
+
- **Vector_Store**: In-memory storage for document embeddings used in retrieval
|
| 17 |
+
- **Chunk**: A segment of a Help_Article used for embedding and retrieval
|
| 18 |
+
|
| 19 |
+
## Requirements
|
| 20 |
+
|
| 21 |
+
### Requirement 1: API Endpoint
|
| 22 |
+
|
| 23 |
+
**User Story:** As a customer, I want to submit questions through an API endpoint, so that I can receive support answers or escalation messages.
|
| 24 |
+
|
| 25 |
+
#### Acceptance Criteria
|
| 26 |
+
|
| 27 |
+
1. THE API SHALL expose a single POST endpoint that accepts customer questions
|
| 28 |
+
2. WHEN a customer question is received, THE API SHALL return a JSON response within 10 seconds
|
| 29 |
+
3. THE API SHALL include CORS headers to allow requests from the Chat_UI
|
| 30 |
+
4. WHEN the API receives an invalid request, THE API SHALL return a 400 status code with an error message
|
| 31 |
+
5. THE API SHALL be deployable to free hosting platforms (Render, Railway, or Vercel)
|
| 32 |
+
|
| 33 |
+
### Requirement 2: RAG-Based Retrieval
|
| 34 |
+
|
| 35 |
+
**User Story:** As the system, I want to retrieve relevant help article content, so that I can ground responses in accurate documentation.
|
| 36 |
+
|
| 37 |
+
#### Acceptance Criteria
|
| 38 |
+
|
| 39 |
+
1. THE RAG_System SHALL load 5 Help_Articles about TaskFlow on startup
|
| 40 |
+
2. THE RAG_System SHALL split Help_Articles into Chunks for embedding
|
| 41 |
+
3. WHEN a customer question is received, THE RAG_System SHALL generate an embedding for the question
|
| 42 |
+
4. THE RAG_System SHALL retrieve the top-k most relevant Chunks from the Vector_Store
|
| 43 |
+
5. THE RAG_System SHALL use an in-memory Vector_Store (no external database required)
|
| 44 |
+
6. FOR ALL Help_Articles, the chunking strategy SHALL preserve semantic coherence
|
| 45 |
+
|
| 46 |
+
### Requirement 3: Answer Generation
|
| 47 |
+
|
| 48 |
+
**User Story:** As a customer, I want accurate answers grounded in help articles, so that I can resolve my issues quickly.
|
| 49 |
+
|
| 50 |
+
#### Acceptance Criteria
|
| 51 |
+
|
| 52 |
+
1. WHEN the Escalation_Logic determines confidence is sufficient, THE LLM SHALL generate an answer using retrieved Chunks
|
| 53 |
+
2. THE LLM SHALL use a friendly customer support tone in all responses
|
| 54 |
+
3. THE LLM SHALL ground all answers in the retrieved Help_Article content
|
| 55 |
+
4. THE LLM SHALL NOT hallucinate information not present in the Help_Articles
|
| 56 |
+
5. WHEN no relevant information is found, THE Escalation_Logic SHALL trigger escalation
|
| 57 |
+
|
| 58 |
+
### Requirement 4: Escalation Decision
|
| 59 |
+
|
| 60 |
+
**User Story:** As a support manager, I want the system to escalate uncertain questions to human agents, so that customers receive accurate help.
|
| 61 |
+
|
| 62 |
+
#### Acceptance Criteria
|
| 63 |
+
|
| 64 |
+
1. THE Escalation_Logic SHALL decide whether to answer or escalate for each customer question
|
| 65 |
+
2. WHEN confidence is low, THE Escalation_Logic SHALL return an escalation message
|
| 66 |
+
3. WHEN the question is outside the scope of Help_Articles, THE Escalation_Logic SHALL escalate
|
| 67 |
+
4. WHEN retrieved Chunks have low relevance scores, THE Escalation_Logic SHALL escalate
|
| 68 |
+
5. THE Escalation_Logic SHALL prioritize escalation over providing uncertain answers
|
| 69 |
+
|
| 70 |
+
### Requirement 5: Confidence Indicator
|
| 71 |
+
|
| 72 |
+
**User Story:** As a support manager, I want to understand why the system answered or escalated, so that I can evaluate system performance.
|
| 73 |
+
|
| 74 |
+
#### Acceptance Criteria
|
| 75 |
+
|
| 76 |
+
1. THE API SHALL include a Confidence_Indicator in every response
|
| 77 |
+
2. THE Confidence_Indicator SHALL explain the reasoning behind the answer or escalation decision
|
| 78 |
+
3. WHEN answering, THE Confidence_Indicator SHALL reference the relevance of retrieved content
|
| 79 |
+
4. WHEN escalating, THE Confidence_Indicator SHALL explain why confidence was insufficient
|
| 80 |
+
5. THE Confidence_Indicator SHALL be human-readable and concise
|
| 81 |
+
|
| 82 |
+
### Requirement 6: Response Format
|
| 83 |
+
|
| 84 |
+
**User Story:** As a frontend developer, I want a consistent JSON response format, so that I can reliably display results in the Chat_UI.
|
| 85 |
+
|
| 86 |
+
#### Acceptance Criteria
|
| 87 |
+
|
| 88 |
+
1. THE API SHALL return responses with a consistent JSON schema
|
| 89 |
+
2. THE API SHALL include a response_type field indicating "answer" or "escalation"
|
| 90 |
+
3. THE API SHALL include a message field containing the answer or escalation text
|
| 91 |
+
4. THE API SHALL include a confidence_explanation field with the Confidence_Indicator
|
| 92 |
+
5. WHEN answering, THE API SHALL optionally include source references to Help_Articles
|
| 93 |
+
|
| 94 |
+
### Requirement 7: Chat UI
|
| 95 |
+
|
| 96 |
+
**User Story:** As a customer, I want a simple chat interface, so that I can interact with the support system.
|
| 97 |
+
|
| 98 |
+
#### Acceptance Criteria
|
| 99 |
+
|
| 100 |
+
1. THE Chat_UI SHALL provide a text input field for customer questions
|
| 101 |
+
2. WHEN a customer submits a question, THE Chat_UI SHALL display the question in the chat history
|
| 102 |
+
3. THE Chat_UI SHALL display API responses (answers or escalations) in the chat history
|
| 103 |
+
4. THE Chat_UI SHALL indicate loading state while waiting for API responses
|
| 104 |
+
5. THE Chat_UI SHALL be functional and deployable (visual polish is not required)
|
| 105 |
+
6. THE Chat_UI SHALL display the Confidence_Indicator for each response
|
| 106 |
+
|
| 107 |
+
### Requirement 8: Help Articles Knowledge Base
|
| 108 |
+
|
| 109 |
+
**User Story:** As the system, I want to load TaskFlow help articles, so that I can answer customer questions accurately.
|
| 110 |
+
|
| 111 |
+
#### Acceptance Criteria
|
| 112 |
+
|
| 113 |
+
1. THE RAG_System SHALL load 5 Help_Articles covering TaskFlow topics
|
| 114 |
+
2. THE Help_Articles SHALL include "Getting Started with TaskFlow"
|
| 115 |
+
3. THE Help_Articles SHALL include "Billing and Subscription Plans"
|
| 116 |
+
4. THE Help_Articles SHALL include "Integrations"
|
| 117 |
+
5. THE Help_Articles SHALL include "Managing Team Permissions"
|
| 118 |
+
6. THE Help_Articles SHALL include "Troubleshooting Common Issues"
|
| 119 |
+
7. THE Help_Articles SHALL be stored in a format accessible to the RAG_System
|
| 120 |
+
|
| 121 |
+
### Requirement 9: Prompt Design
|
| 122 |
+
|
| 123 |
+
**User Story:** As a developer, I want well-designed LLM prompts, so that the system generates high-quality responses with appropriate guardrails.
|
| 124 |
+
|
| 125 |
+
#### Acceptance Criteria
|
| 126 |
+
|
| 127 |
+
1. THE LLM SHALL receive instructions to use a friendly customer support tone
|
| 128 |
+
2. THE LLM SHALL receive instructions to ground answers only in provided context
|
| 129 |
+
3. THE LLM SHALL receive instructions to avoid hallucination
|
| 130 |
+
4. THE LLM SHALL receive the retrieved Chunks as context
|
| 131 |
+
5. THE LLM SHALL receive the customer question
|
| 132 |
+
6. THE LLM SHALL be instructed on when to indicate uncertainty
|
| 133 |
+
|
| 134 |
+
### Requirement 10: Code Quality
|
| 135 |
+
|
| 136 |
+
**User Story:** As a developer, I want clean and maintainable code, so that the system is easy to understand and extend.
|
| 137 |
+
|
| 138 |
+
#### Acceptance Criteria
|
| 139 |
+
|
| 140 |
+
1. THE API SHALL use clear function and variable names
|
| 141 |
+
2. THE API SHALL include comments explaining complex logic
|
| 142 |
+
3. THE API SHALL separate concerns (retrieval, generation, escalation logic)
|
| 143 |
+
4. THE API SHALL include a README with setup and deployment instructions
|
| 144 |
+
5. THE API SHALL use type hints (Python) or TypeScript types for clarity
|
.kiro/specs/smart-escalation-api/tasks.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Implementation Plan: Smart Escalation API
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
This implementation plan follows a 4-phase approach to build a RAG-based customer support system with intelligent escalation. The system uses FastAPI, FAISS vector store, sentence-transformers embeddings, and an LLM API to answer customer questions about TaskFlow or escalate to human agents when uncertain.
|
| 6 |
+
|
| 7 |
+
**Total Estimated Time**: 2-3.5 hours
|
| 8 |
+
|
| 9 |
+
**Key Focus**: Escalation logic is the most critical component. Property-based testing ensures correctness of core behaviors.
|
| 10 |
+
|
| 11 |
+
## Tasks
|
| 12 |
+
|
| 13 |
+
### Phase 1: Core RAG System (1 hour)
|
| 14 |
+
|
| 15 |
+
- [x] 1. Set up project structure and dependencies
|
| 16 |
+
- Create project directory structure (`data/articles/`, `src/`, `tests/`)
|
| 17 |
+
- Create `requirements.txt` with FastAPI, FAISS, sentence-transformers, pytest, hypothesis
|
| 18 |
+
- Create `.env.example` file with required environment variables
|
| 19 |
+
- Create `README.md` with setup instructions
|
| 20 |
+
- _Requirements: 10.4_
|
| 21 |
+
|
| 22 |
+
- [x] 2. Create help article knowledge base
|
| 23 |
+
- [x] 2.1 Write 5 TaskFlow help articles as markdown files
|
| 24 |
+
- Create `data/articles/getting-started.md` (TaskFlow basics, account setup, first project)
|
| 25 |
+
- Create `data/articles/billing.md` (subscription plans, payment methods, billing cycles)
|
| 26 |
+
- Create `data/articles/integrations.md` (Slack, GitHub, Jira integrations)
|
| 27 |
+
- Create `data/articles/permissions.md` (team roles, access control, admin settings)
|
| 28 |
+
- Create `data/articles/troubleshooting.md` (common errors, login issues, performance tips)
|
| 29 |
+
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7_
|
| 30 |
+
|
| 31 |
+
- [x] 3. Implement article chunking logic
|
| 32 |
+
- [x] 3.1 Create `src/chunking.py` with text chunking function
|
| 33 |
+
- Implement fixed-size chunking with overlap (500 chars, 50 char overlap)
|
| 34 |
+
- Preserve sentence boundaries where possible
|
| 35 |
+
- Track source article metadata for each chunk
|
| 36 |
+
- _Requirements: 2.2, 2.6_
|
| 37 |
+
|
| 38 |
+
- [x] 3.2 Write property test for chunking semantic coherence
|
| 39 |
+
- **Property 3: Chunking Semantic Coherence**
|
| 40 |
+
- **Validates: Requirements 2.6**
|
| 41 |
+
- Test that chunks don't split words mid-character
|
| 42 |
+
- Test that overlap is maintained between consecutive chunks
|
| 43 |
+
- Use Hypothesis to generate random article texts
|
| 44 |
+
|
| 45 |
+
- [x] 4. Implement RAG system with FAISS vector store
|
| 46 |
+
- [x] 4.1 Create `src/rag.py` with RAGSystem class
|
| 47 |
+
- Implement article loading from `data/articles/` directory
|
| 48 |
+
- Initialize sentence-transformers embedding model (all-MiniLM-L6-v2)
|
| 49 |
+
- Generate embeddings for all chunks
|
| 50 |
+
- Build in-memory FAISS index
|
| 51 |
+
- Implement `retrieve()` method for similarity search
|
| 52 |
+
- _Requirements: 2.1, 2.3, 2.4, 2.5_
|
| 53 |
+
|
| 54 |
+
- [ ]* 4.2 Write property test for top-k retrieval behavior
|
| 55 |
+
- **Property 2: Top-K Retrieval Behavior**
|
| 56 |
+
- **Validates: Requirements 2.4**
|
| 57 |
+
- Test that retrieval returns exactly k chunks (or fewer if total < k)
|
| 58 |
+
- Test that chunks are ordered by descending relevance score
|
| 59 |
+
- Use Hypothesis to generate different k values
|
| 60 |
+
|
| 61 |
+
- [x] 5. Checkpoint - Test RAG system with sample questions
|
| 62 |
+
- Ensure RAG system loads articles and retrieves relevant chunks, ask the user if questions arise.
|
| 63 |
+
|
| 64 |
+
### Phase 2: API and Escalation Logic (1 hour)
|
| 65 |
+
|
| 66 |
+
- [x] 6. Implement LLM client
|
| 67 |
+
- [x] 6.1 Create `src/llm_client.py` with LLMClient class
|
| 68 |
+
- Implement Google Gemini API integration
|
| 69 |
+
- Create prompt template with customer support instructions
|
| 70 |
+
- Implement `generate_answer()` method
|
| 71 |
+
- Parse LLM responses for uncertainty signals
|
| 72 |
+
- Add error handling for API timeouts and rate limits
|
| 73 |
+
- _Requirements: 3.1, 3.2, 3.3, 3.4, 9.1, 9.2, 9.3, 9.4, 9.5, 9.6_
|
| 74 |
+
|
| 75 |
+
- [ ]* 6.2 Write property test for prompt construction completeness
|
| 76 |
+
- **Property 6: Prompt Construction Completeness**
|
| 77 |
+
- **Validates: Requirements 9.4, 9.5**
|
| 78 |
+
- Test that constructed prompts include complete question text
|
| 79 |
+
- Test that all retrieved chunk contents are included in prompt
|
| 80 |
+
- Use Hypothesis to generate random questions and chunk lists
|
| 81 |
+
|
| 82 |
+
- [x] 7. Implement escalation engine
|
| 83 |
+
- [x] 7.1 Create `src/escalation.py` with EscalationEngine class
|
| 84 |
+
- Implement relevance threshold checking (default 0.5)
|
| 85 |
+
- Implement escalation decision logic
|
| 86 |
+
- Generate confidence explanations for answers and escalations
|
| 87 |
+
- Coordinate LLM calls when answering
|
| 88 |
+
- _Requirements: 3.5, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.2, 5.3, 5.4, 5.5_
|
| 89 |
+
|
| 90 |
+
- [ ]* 7.2 Write property test for escalation on low confidence
|
| 91 |
+
- **Property 4: Escalation on Low Confidence**
|
| 92 |
+
- **Validates: Requirements 3.5, 4.2, 4.4**
|
| 93 |
+
- Test that escalation occurs when max relevance score < threshold
|
| 94 |
+
- Test that escalation occurs when LLM signals uncertainty
|
| 95 |
+
- Use Hypothesis to generate relevance scores below threshold
|
| 96 |
+
|
| 97 |
+
- [x] 8. Create FastAPI application
|
| 98 |
+
- [x] 8.1 Create `src/main.py` with FastAPI app
|
| 99 |
+
- Define Pydantic models (QuestionRequest, QuestionResponse)
|
| 100 |
+
- Implement POST `/ask` endpoint
|
| 101 |
+
- Configure CORS for frontend access
|
| 102 |
+
- Wire together RAG system, escalation engine, and LLM client
|
| 103 |
+
- Add request validation and error handling
|
| 104 |
+
- _Requirements: 1.1, 1.2, 1.3, 1.4, 6.1, 6.2, 6.3, 6.4, 6.5_
|
| 105 |
+
|
| 106 |
+
- [ ]* 8.2 Write property test for invalid request handling
|
| 107 |
+
- **Property 1: Invalid Request Handling**
|
| 108 |
+
- **Validates: Requirements 1.4**
|
| 109 |
+
- Test that malformed requests return 400 status code
|
| 110 |
+
- Test that missing required fields return error messages
|
| 111 |
+
- Use Hypothesis to generate invalid request payloads
|
| 112 |
+
|
| 113 |
+
- [ ]* 8.3 Write property test for response schema consistency
|
| 114 |
+
- **Property 5: Response Schema Consistency**
|
| 115 |
+
- **Validates: Requirements 5.1, 6.1, 6.2, 6.3, 6.4, 6.5**
|
| 116 |
+
- Test that all responses conform to JSON schema
|
| 117 |
+
- Test that response_type is either "answer" or "escalation"
|
| 118 |
+
- Test that required fields have correct types
|
| 119 |
+
- Use Hypothesis to generate random valid questions
|
| 120 |
+
|
| 121 |
+
- [x] 9. Implement configuration management
|
| 122 |
+
- [x] 9.1 Create `src/config.py` with Pydantic settings
|
| 123 |
+
- Define Settings class with environment variables
|
| 124 |
+
- Set default values for thresholds and parameters
|
| 125 |
+
- Load configuration from `.env` file
|
| 126 |
+
- _Requirements: 10.5_
|
| 127 |
+
|
| 128 |
+
- [x] 10. Checkpoint - Test API end-to-end
|
| 129 |
+
- Ensure API accepts questions and returns valid responses, ask the user if questions arise.
|
| 130 |
+
|
| 131 |
+
### Phase 3: Frontend (0.5-1 hour)
|
| 132 |
+
|
| 133 |
+
- [ ] 11. Set up React frontend with Vite
|
| 134 |
+
- [ ] 11.1 Create React project structure
|
| 135 |
+
- Initialize Vite project with React and TypeScript
|
| 136 |
+
- Install dependencies (axios for API calls)
|
| 137 |
+
- Create `frontend/.env.example` with API URL
|
| 138 |
+
- _Requirements: 7.5_
|
| 139 |
+
|
| 140 |
+
- [ ] 12. Implement chat interface
|
| 141 |
+
- [ ] 12.1 Create ChatInterface component
|
| 142 |
+
- Implement message state management (user and assistant messages)
|
| 143 |
+
- Create text input field for customer questions
|
| 144 |
+
- Create submit button with loading state
|
| 145 |
+
- Display chat history with user questions and API responses
|
| 146 |
+
- Show confidence explanations for each response
|
| 147 |
+
- Differentiate between answer and escalation response types
|
| 148 |
+
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.6_
|
| 149 |
+
|
| 150 |
+
- [ ]* 12.2 Write unit tests for ChatInterface component
|
| 151 |
+
- Test that user questions appear in chat history
|
| 152 |
+
- Test loading state during API calls
|
| 153 |
+
- Test display of API responses
|
| 154 |
+
- Use React Testing Library and Vitest
|
| 155 |
+
|
| 156 |
+
- [ ] 13. Integrate frontend with backend API
|
| 157 |
+
- [ ] 13.1 Implement API client
|
| 158 |
+
- Create API service function to POST questions to `/ask` endpoint
|
| 159 |
+
- Handle API errors gracefully
|
| 160 |
+
- Configure CORS and API URL from environment variables
|
| 161 |
+
- _Requirements: 1.3_
|
| 162 |
+
|
| 163 |
+
- [ ] 14. Add basic styling
|
| 164 |
+
- [ ] 14.1 Style chat interface
|
| 165 |
+
- Add minimal CSS for chat layout
|
| 166 |
+
- Style message bubbles (user vs assistant)
|
| 167 |
+
- Add loading indicator
|
| 168 |
+
- Ensure mobile responsiveness
|
| 169 |
+
- _Requirements: 7.5_
|
| 170 |
+
|
| 171 |
+
### Phase 4: Testing and Deployment (0.5 hour)
|
| 172 |
+
|
| 173 |
+
- [ ] 15. Write unit tests for core components
|
| 174 |
+
- [ ]* 15.1 Write unit tests for chunking logic
|
| 175 |
+
- Test chunk size and overlap calculations
|
| 176 |
+
- Test sentence boundary preservation
|
| 177 |
+
- _Requirements: 2.2, 2.6_
|
| 178 |
+
|
| 179 |
+
- [ ]* 15.2 Write unit tests for escalation decision rules
|
| 180 |
+
- Test threshold comparisons
|
| 181 |
+
- Test confidence explanation generation
|
| 182 |
+
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
|
| 183 |
+
|
| 184 |
+
- [ ]* 15.3 Write unit tests for response formatting
|
| 185 |
+
- Test JSON construction
|
| 186 |
+
- Test source reference inclusion
|
| 187 |
+
- _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5_
|
| 188 |
+
|
| 189 |
+
- [ ]* 16. Write integration tests
|
| 190 |
+
- [ ]* 16.1 Test end-to-end API flow
|
| 191 |
+
- Test answerable questions return answer responses
|
| 192 |
+
- Test out-of-scope questions trigger escalation
|
| 193 |
+
- Mock LLM API responses
|
| 194 |
+
- _Requirements: 1.1, 1.2_
|
| 195 |
+
|
| 196 |
+
- [ ]* 16.2 Test error handling
|
| 197 |
+
- Test LLM API timeout handling
|
| 198 |
+
- Test invalid API key handling
|
| 199 |
+
- Test network error handling
|
| 200 |
+
- _Requirements: 1.4_
|
| 201 |
+
|
| 202 |
+
- [ ]* 16.3 Test CORS configuration
|
| 203 |
+
- Verify CORS headers are present
|
| 204 |
+
- Test OPTIONS requests
|
| 205 |
+
- _Requirements: 1.3_
|
| 206 |
+
|
| 207 |
+
- [ ] 17. Create deployment documentation
|
| 208 |
+
- [ ] 17.1 Update README with deployment instructions
|
| 209 |
+
- Document Render/Railway deployment steps for backend
|
| 210 |
+
- Document Vercel deployment steps for frontend
|
| 211 |
+
- Include environment variable configuration
|
| 212 |
+
- Add troubleshooting section
|
| 213 |
+
- _Requirements: 1.5, 10.4_
|
| 214 |
+
|
| 215 |
+
- [ ] 18. Deploy to hosting platforms
|
| 216 |
+
- [ ] 18.1 Deploy backend to Render or Railway
|
| 217 |
+
- Create web service
|
| 218 |
+
- Configure build and start commands
|
| 219 |
+
- Set environment variables (API keys, thresholds)
|
| 220 |
+
- Verify deployment and test with sample requests
|
| 221 |
+
- _Requirements: 1.5_
|
| 222 |
+
|
| 223 |
+
- [ ] 18.2 Deploy frontend to Vercel
|
| 224 |
+
- Create new project
|
| 225 |
+
- Configure build settings
|
| 226 |
+
- Set API URL environment variable
|
| 227 |
+
- Verify deployment and test chat interface
|
| 228 |
+
- _Requirements: 1.5, 7.5_
|
| 229 |
+
|
| 230 |
+
- [ ] 19. Final checkpoint - End-to-end testing
|
| 231 |
+
- Test deployed application with various question types (in-scope, out-of-scope, edge cases), verify LLM responses have appropriate tone, check confidence explanations are helpful, ask the user if questions arise.
|
| 232 |
+
|
| 233 |
+
## Notes
|
| 234 |
+
|
| 235 |
+
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
| 236 |
+
- Each task references specific requirements for traceability
|
| 237 |
+
- Property tests validate the 6 correctness properties defined in the design
|
| 238 |
+
- Unit tests validate specific examples and edge cases
|
| 239 |
+
- Checkpoints ensure incremental validation at key milestones
|
| 240 |
+
- Focus on escalation logic as the most critical component
|
| 241 |
+
- Use Python for backend (FastAPI, FAISS, sentence-transformers)
|
| 242 |
+
- Use TypeScript for frontend (React, Vite)
|
API_TEST_RESULTS.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API End-to-End Test Results
|
| 2 |
+
|
| 3 |
+
## Test Summary
|
| 4 |
+
|
| 5 |
+
**Date**: Checkpoint Task 10
|
| 6 |
+
**Status**: ✅ PASSED
|
| 7 |
+
**API Endpoint**: http://localhost:8000
|
| 8 |
+
|
| 9 |
+
## Tests Performed
|
| 10 |
+
|
| 11 |
+
### 1. Health Check Endpoint ✅
|
| 12 |
+
**Request**: `GET /health`
|
| 13 |
+
**Response**:
|
| 14 |
+
```json
|
| 15 |
+
{
|
| 16 |
+
"status": "healthy",
|
| 17 |
+
"rag_system": {
|
| 18 |
+
"total_chunks": 11,
|
| 19 |
+
"total_articles": 5
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
```
|
| 23 |
+
**Result**: PASSED - API is healthy and RAG system initialized correctly
|
| 24 |
+
|
| 25 |
+
### 2. Root Endpoint ✅
|
| 26 |
+
**Request**: `GET /`
|
| 27 |
+
**Response**:
|
| 28 |
+
```json
|
| 29 |
+
{
|
| 30 |
+
"name": "Smart Escalation API",
|
| 31 |
+
"version": "1.0.0",
|
| 32 |
+
"status": "operational",
|
| 33 |
+
"endpoints": {
|
| 34 |
+
"ask": "/ask (POST)"
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
```
|
| 38 |
+
**Result**: PASSED - Root endpoint returns API information
|
| 39 |
+
|
| 40 |
+
### 3. Valid Question - Billing ✅
|
| 41 |
+
**Request**: `POST /ask`
|
| 42 |
+
```json
|
| 43 |
+
{
|
| 44 |
+
"question": "What subscription plans are available?"
|
| 45 |
+
}
|
| 46 |
+
```
|
| 47 |
+
**Response**:
|
| 48 |
+
```json
|
| 49 |
+
{
|
| 50 |
+
"response_type": "answer",
|
| 51 |
+
"message": "Hello there!\n\nTaskFlow offers three subscription plans:\n\n* **Free Plan**\n* **Pro Plan**\n* **Enterprise Plan**\n\nLet me know if you have any other questions!",
|
| 52 |
+
"confidence_explanation": "High confidence - retrieved relevant content from 2 help articles with best similarity score 0.68",
|
| 53 |
+
"sources": ["billing.md", "integrations.md"]
|
| 54 |
+
}
|
| 55 |
+
```
|
| 56 |
+
**Result**: PASSED - API returns answer with high confidence
|
| 57 |
+
|
| 58 |
+
### 4. Valid Question - Integrations ✅
|
| 59 |
+
**Request**: `POST /ask`
|
| 60 |
+
```json
|
| 61 |
+
{
|
| 62 |
+
"question": "How do I integrate with Slack?"
|
| 63 |
+
}
|
| 64 |
+
```
|
| 65 |
+
**Response**:
|
| 66 |
+
```json
|
| 67 |
+
{
|
| 68 |
+
"response_type": "answer",
|
| 69 |
+
"message": "Hello there! I'd be happy to help you with that.\n\nTo integrate with Slack:\n\n1. Go to Settings > Integrations.\n2. Click \"Connect\" next to Slack.\n3. Follow the authorization prompts.\n\nPlease note that integrations, including Slack, are available on Pro and Enterprise plans only.\n\nLet me know if you have any other questions!",
|
| 70 |
+
"confidence_explanation": "High confidence - retrieved relevant content from 2 help articles with best similarity score 0.67",
|
| 71 |
+
"sources": ["integrations.md", "troubleshooting.md"]
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
**Result**: PASSED - API returns detailed answer with sources
|
| 75 |
+
|
| 76 |
+
### 5. Out-of-Scope Question ✅
|
| 77 |
+
**Request**: `POST /ask`
|
| 78 |
+
```json
|
| 79 |
+
{
|
| 80 |
+
"question": "What is the weather like today?"
|
| 81 |
+
}
|
| 82 |
+
```
|
| 83 |
+
**Response**:
|
| 84 |
+
```json
|
| 85 |
+
{
|
| 86 |
+
"response_type": "escalation",
|
| 87 |
+
"message": "I'm not certain I can answer this accurately. Let me connect you with a human support agent who can help.",
|
| 88 |
+
"confidence_explanation": "Best retrieval score was 0.05, below confidence threshold of 0.5",
|
| 89 |
+
"sources": null
|
| 90 |
+
}
|
| 91 |
+
```
|
| 92 |
+
**Result**: PASSED - API correctly escalates when question is out of scope
|
| 93 |
+
|
| 94 |
+
### 6. Invalid Request - Empty Question ✅
|
| 95 |
+
**Request**: `POST /ask`
|
| 96 |
+
```json
|
| 97 |
+
{
|
| 98 |
+
"question": ""
|
| 99 |
+
}
|
| 100 |
+
```
|
| 101 |
+
**Response**: HTTP 422
|
| 102 |
+
```json
|
| 103 |
+
{
|
| 104 |
+
"detail": [{
|
| 105 |
+
"type": "string_too_short",
|
| 106 |
+
"loc": ["body", "question"],
|
| 107 |
+
"msg": "String should have at least 1 character",
|
| 108 |
+
"input": "",
|
| 109 |
+
"ctx": {"min_length": 1}
|
| 110 |
+
}]
|
| 111 |
+
}
|
| 112 |
+
```
|
| 113 |
+
**Result**: PASSED - API validates input and returns appropriate error
|
| 114 |
+
|
| 115 |
+
## Issues Found and Fixed
|
| 116 |
+
|
| 117 |
+
### Issue 1: Response Type Mismatch
|
| 118 |
+
**Problem**: EscalationDecision used `"escalate"` but API expected `"escalation"`
|
| 119 |
+
**Fix**: Updated all instances in `src/escalation.py` to use `"escalation"`
|
| 120 |
+
**Status**: ✅ FIXED
|
| 121 |
+
|
| 122 |
+
### Issue 2: Incorrect Model Name
|
| 123 |
+
**Problem**: Configuration used `gemini-1.5-flash` which doesn't exist
|
| 124 |
+
**Fix**: Updated to `gemini-2.5-flash` in `.env` and `.env.example`
|
| 125 |
+
**Status**: ✅ FIXED
|
| 126 |
+
|
| 127 |
+
## System Configuration
|
| 128 |
+
|
| 129 |
+
- **Embedding Model**: all-MiniLM-L6-v2
|
| 130 |
+
- **LLM Model**: gemini-2.5-flash
|
| 131 |
+
- **Relevance Threshold**: 0.5
|
| 132 |
+
- **Top-K Retrieval**: 3
|
| 133 |
+
- **Total Articles**: 5
|
| 134 |
+
- **Total Chunks**: 11
|
| 135 |
+
|
| 136 |
+
## Conclusion
|
| 137 |
+
|
| 138 |
+
✅ **All tests passed successfully!**
|
| 139 |
+
|
| 140 |
+
The API is functioning correctly with:
|
| 141 |
+
- Proper request validation
|
| 142 |
+
- Accurate answer generation for in-scope questions
|
| 143 |
+
- Intelligent escalation for out-of-scope questions
|
| 144 |
+
- Appropriate error handling
|
| 145 |
+
- Correct response schema
|
| 146 |
+
- Working health check endpoints
|
| 147 |
+
|
| 148 |
+
The system is ready to proceed to Phase 3 (Frontend implementation).
|
README.md
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Smart Escalation API
|
| 2 |
+
|
| 3 |
+
An AI-powered L1 customer support system that intelligently answers customer questions about TaskFlow (a fictional project management SaaS) or escalates to human agents when uncertain. The system uses RAG-based retrieval over help articles to ground responses.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **RAG-Based Retrieval**: Uses sentence-transformers and FAISS for semantic search over help articles
|
| 8 |
+
- **Intelligent Escalation**: Automatically escalates to human agents when confidence is low
|
| 9 |
+
- **FastAPI Backend**: Fast, async API with automatic OpenAPI documentation
|
| 10 |
+
- **React Chat UI**: Minimal, functional chat interface for customer interaction
|
| 11 |
+
- **Free Hosting Compatible**: No external databases required, deployable to free tiers
|
| 12 |
+
|
| 13 |
+
## Architecture
|
| 14 |
+
|
| 15 |
+
- **Backend**: FastAPI + FAISS (in-memory vector store) + sentence-transformers
|
| 16 |
+
- **LLM**: Google Gemini API (gemini-1.5-flash)
|
| 17 |
+
- **Frontend**: React + Vite + TypeScript
|
| 18 |
+
- **Deployment**: Render/Railway (backend) + Vercel (frontend)
|
| 19 |
+
|
| 20 |
+
## Quick Start
|
| 21 |
+
|
| 22 |
+
### Prerequisites
|
| 23 |
+
|
| 24 |
+
- Python 3.9+
|
| 25 |
+
- Node.js 18+ (for frontend)
|
| 26 |
+
- Google Gemini API key
|
| 27 |
+
|
| 28 |
+
### Backend Setup
|
| 29 |
+
|
| 30 |
+
1. **Clone the repository**
|
| 31 |
+
```bash
|
| 32 |
+
git clone <repository-url>
|
| 33 |
+
cd smart-escalation-api
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
2. **Create virtual environment**
|
| 37 |
+
```bash
|
| 38 |
+
python -m venv venv
|
| 39 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
3. **Install dependencies**
|
| 43 |
+
```bash
|
| 44 |
+
pip install -r requirements.txt
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
4. **Configure environment variables**
|
| 48 |
+
```bash
|
| 49 |
+
cp .env.example .env
|
| 50 |
+
# Edit .env and add your API key
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
5. **Run the server**
|
| 54 |
+
```bash
|
| 55 |
+
uvicorn src.main:app --reload
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
The API will be available at `http://localhost:8000`
|
| 59 |
+
|
| 60 |
+
API documentation: `http://localhost:8000/docs`
|
| 61 |
+
|
| 62 |
+
### Frontend Setup
|
| 63 |
+
|
| 64 |
+
1. **Navigate to frontend directory**
|
| 65 |
+
```bash
|
| 66 |
+
cd frontend
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
2. **Install dependencies**
|
| 70 |
+
```bash
|
| 71 |
+
npm install
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
3. **Configure environment**
|
| 75 |
+
```bash
|
| 76 |
+
cp .env.example .env
|
| 77 |
+
# Edit .env and set VITE_API_URL to your backend URL
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
4. **Run development server**
|
| 81 |
+
```bash
|
| 82 |
+
npm run dev
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
The UI will be available at `http://localhost:5173`
|
| 86 |
+
|
| 87 |
+
## Project Structure
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
smart-escalation-api/
|
| 91 |
+
├── data/
|
| 92 |
+
│ └── articles/ # Help article knowledge base (markdown files)
|
| 93 |
+
├── src/
|
| 94 |
+
│ ├── main.py # FastAPI application
|
| 95 |
+
│ ├── rag.py # RAG system with FAISS vector store
|
| 96 |
+
│ ├── escalation.py # Escalation decision engine
|
| 97 |
+
│ ├── llm_client.py # LLM API client (Google Gemini)
|
| 98 |
+
│ ├── chunking.py # Text chunking logic
|
| 99 |
+
│ └── config.py # Configuration management
|
| 100 |
+
├── tests/
|
| 101 |
+
│ ├── unit/ # Unit tests
|
| 102 |
+
│ ├── integration/ # Integration tests
|
| 103 |
+
│ └── property/ # Property-based tests (Hypothesis)
|
| 104 |
+
├── frontend/
|
| 105 |
+
│ └── src/ # React chat interface
|
| 106 |
+
├── requirements.txt # Python dependencies
|
| 107 |
+
├── .env.example # Environment variable template
|
| 108 |
+
└── README.md # This file
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## API Usage
|
| 112 |
+
|
| 113 |
+
### POST /ask
|
| 114 |
+
|
| 115 |
+
Submit a customer question and receive an answer or escalation.
|
| 116 |
+
|
| 117 |
+
**Request:**
|
| 118 |
+
```json
|
| 119 |
+
{
|
| 120 |
+
"question": "How do I add a team member to my project?"
|
| 121 |
+
}
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
**Response (Answer):**
|
| 125 |
+
```json
|
| 126 |
+
{
|
| 127 |
+
"response_type": "answer",
|
| 128 |
+
"message": "To add a team member to your project in TaskFlow, go to Project Settings > Team Members and click 'Invite Member'. You can set their role as Viewer, Editor, or Admin.",
|
| 129 |
+
"confidence_explanation": "High confidence - retrieved relevant content from 'Managing Team Permissions' article with similarity score 0.82",
|
| 130 |
+
"sources": ["permissions.md"]
|
| 131 |
+
}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
**Response (Escalation):**
|
| 135 |
+
```json
|
| 136 |
+
{
|
| 137 |
+
"response_type": "escalation",
|
| 138 |
+
"message": "I'm not certain I can answer this accurately. Let me connect you with a human support agent who can help.",
|
| 139 |
+
"confidence_explanation": "Low confidence - best retrieval score was 0.42, indicating question may be outside help article scope",
|
| 140 |
+
"sources": null
|
| 141 |
+
}
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
## Configuration
|
| 145 |
+
|
| 146 |
+
### Environment Variables
|
| 147 |
+
|
| 148 |
+
| Variable | Description | Default |
|
| 149 |
+
|----------|-------------|---------|
|
| 150 |
+
| `GOOGLE_API_KEY` | Google Gemini API key (required) | - |
|
| 151 |
+
| `EMBEDDING_MODEL` | Sentence-transformers model name | `all-MiniLM-L6-v2` |
|
| 152 |
+
| `RELEVANCE_THRESHOLD` | Minimum similarity score to answer (0-1) | `0.5` |
|
| 153 |
+
| `TOP_K_CHUNKS` | Number of chunks to retrieve | `3` |
|
| 154 |
+
| `CHUNK_SIZE` | Characters per chunk | `500` |
|
| 155 |
+
| `CHUNK_OVERLAP` | Overlap between chunks | `50` |
|
| 156 |
+
| `LLM_MODEL` | LLM model name | `gemini-1.5-flash` |
|
| 157 |
+
| `LLM_TEMPERATURE` | LLM temperature (0-1) | `0.3` |
|
| 158 |
+
|
| 159 |
+
### Escalation Logic
|
| 160 |
+
|
| 161 |
+
The system escalates to human agents when:
|
| 162 |
+
- Best chunk relevance score < `RELEVANCE_THRESHOLD` (default 0.5)
|
| 163 |
+
- LLM signals uncertainty in its response
|
| 164 |
+
- No relevant chunks are retrieved
|
| 165 |
+
|
| 166 |
+
## Deployment
|
| 167 |
+
|
| 168 |
+
### Backend Deployment (Render/Railway)
|
| 169 |
+
|
| 170 |
+
#### Render
|
| 171 |
+
|
| 172 |
+
1. Create a new Web Service on [Render](https://render.com)
|
| 173 |
+
2. Connect your GitHub repository
|
| 174 |
+
3. Configure build settings:
|
| 175 |
+
- **Build Command**: `pip install -r requirements.txt`
|
| 176 |
+
- **Start Command**: `uvicorn src.main:app --host 0.0.0.0 --port $PORT`
|
| 177 |
+
4. Add environment variables:
|
| 178 |
+
- `GOOGLE_API_KEY`
|
| 179 |
+
- Other configuration variables as needed
|
| 180 |
+
5. Deploy
|
| 181 |
+
|
| 182 |
+
#### Railway
|
| 183 |
+
|
| 184 |
+
1. Create a new project on [Railway](https://railway.app)
|
| 185 |
+
2. Connect your GitHub repository
|
| 186 |
+
3. Add environment variables in the Variables tab
|
| 187 |
+
4. Railway will auto-detect Python and deploy
|
| 188 |
+
5. Set start command if needed: `uvicorn src.main:app --host 0.0.0.0 --port $PORT`
|
| 189 |
+
|
| 190 |
+
### Frontend Deployment (Vercel)
|
| 191 |
+
|
| 192 |
+
1. Create a new project on [Vercel](https://vercel.com)
|
| 193 |
+
2. Connect your GitHub repository
|
| 194 |
+
3. Configure build settings:
|
| 195 |
+
- **Framework Preset**: Vite
|
| 196 |
+
- **Root Directory**: `frontend`
|
| 197 |
+
- **Build Command**: `npm run build`
|
| 198 |
+
- **Output Directory**: `dist`
|
| 199 |
+
4. Add environment variable:
|
| 200 |
+
- `VITE_API_URL`: Your backend URL (e.g., `https://your-app.onrender.com`)
|
| 201 |
+
5. Deploy
|
| 202 |
+
|
| 203 |
+
## Testing
|
| 204 |
+
|
| 205 |
+
### Run Unit Tests
|
| 206 |
+
```bash
|
| 207 |
+
pytest tests/unit -v
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
### Run Property-Based Tests
|
| 211 |
+
```bash
|
| 212 |
+
pytest tests/property -v
|
| 213 |
+
```
|
| 214 |
+
|
| 215 |
+
### Run Integration Tests
|
| 216 |
+
```bash
|
| 217 |
+
pytest tests/integration -v
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### Run All Tests
|
| 221 |
+
```bash
|
| 222 |
+
pytest -v
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
## Help Articles
|
| 226 |
+
|
| 227 |
+
The system includes 5 help articles about TaskFlow:
|
| 228 |
+
|
| 229 |
+
1. **Getting Started** - TaskFlow basics, account setup, first project
|
| 230 |
+
2. **Billing and Subscriptions** - Plans, payment methods, billing cycles
|
| 231 |
+
3. **Integrations** - Slack, GitHub, Jira integrations
|
| 232 |
+
4. **Team Permissions** - Roles, access control, admin settings
|
| 233 |
+
5. **Troubleshooting** - Common errors, login issues, performance tips
|
| 234 |
+
|
| 235 |
+
Articles are stored as markdown files in `data/articles/`.
|
| 236 |
+
|
| 237 |
+
## Development
|
| 238 |
+
|
| 239 |
+
### Adding New Help Articles
|
| 240 |
+
|
| 241 |
+
1. Create a new markdown file in `data/articles/`
|
| 242 |
+
2. Restart the server to rebuild the vector store
|
| 243 |
+
3. Test retrieval with relevant questions
|
| 244 |
+
|
| 245 |
+
### Adjusting Escalation Threshold
|
| 246 |
+
|
| 247 |
+
Edit `RELEVANCE_THRESHOLD` in `.env`:
|
| 248 |
+
- Lower values (e.g., 0.3): Answer more questions, risk lower quality
|
| 249 |
+
- Higher values (e.g., 0.7): Escalate more often, ensure high quality
|
| 250 |
+
|
| 251 |
+
### Switching LLM Models
|
| 252 |
+
|
| 253 |
+
**Gemini Flash (faster, cheaper):**
|
| 254 |
+
```bash
|
| 255 |
+
GOOGLE_API_KEY=AIza...
|
| 256 |
+
LLM_MODEL=gemini-1.5-flash
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
**Gemini Pro (more capable):**
|
| 260 |
+
```bash
|
| 261 |
+
GOOGLE_API_KEY=AIza...
|
| 262 |
+
LLM_MODEL=gemini-1.5-pro
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
## Troubleshooting
|
| 266 |
+
|
| 267 |
+
### "Module not found" errors
|
| 268 |
+
```bash
|
| 269 |
+
# Ensure you're in the virtual environment
|
| 270 |
+
source venv/bin/activate
|
| 271 |
+
pip install -r requirements.txt
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
### "API key not found" errors
|
| 275 |
+
```bash
|
| 276 |
+
# Check .env file exists and contains valid API key
|
| 277 |
+
cat .env
|
| 278 |
+
```
|
| 279 |
+
|
| 280 |
+
### Slow first request
|
| 281 |
+
The first request loads the embedding model (~100MB), which takes 2-3 seconds. Subsequent requests are fast.
|
| 282 |
+
|
| 283 |
+
### CORS errors in frontend
|
| 284 |
+
Ensure `VITE_API_URL` in frontend `.env` matches your backend URL exactly (no trailing slash).
|
| 285 |
+
|
| 286 |
+
## Performance
|
| 287 |
+
|
| 288 |
+
- **Startup time**: 2-3 seconds (loading articles and embedding model)
|
| 289 |
+
- **Response time**: 3-5 seconds (95th percentile)
|
| 290 |
+
- Embedding generation: ~100ms
|
| 291 |
+
- Vector search: ~50ms
|
| 292 |
+
- LLM API call: 2-4 seconds
|
| 293 |
+
- **Memory usage**: ~150MB (embedding model + FAISS index)
|
| 294 |
+
|
| 295 |
+
## License
|
| 296 |
+
|
| 297 |
+
MIT
|
| 298 |
+
|
| 299 |
+
## Support
|
| 300 |
+
|
| 301 |
+
For questions or issues, please open a GitHub issue or contact the development team.
|
data/articles/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# This directory will contain help article markdown files
|
data/articles/billing.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Billing and Subscription Plans
|
| 2 |
+
|
| 3 |
+
## Available Plans
|
| 4 |
+
|
| 5 |
+
TaskFlow offers three subscription tiers:
|
| 6 |
+
|
| 7 |
+
### Free Plan
|
| 8 |
+
- Up to 3 projects
|
| 9 |
+
- 10 team members
|
| 10 |
+
- 1GB of file storage
|
| 11 |
+
|
| 12 |
+
### Pro Plan - $12/user/month
|
| 13 |
+
- Unlimited projects
|
| 14 |
+
- 50 team members
|
| 15 |
+
- 50 GB storage
|
| 16 |
+
- Priority email support
|
| 17 |
+
|
| 18 |
+
### Enterprise Plan - Custom Pricing
|
| 19 |
+
- Everything in Pro
|
| 20 |
+
- SSO (Single Sign-On)
|
| 21 |
+
- Audit logs
|
| 22 |
+
- Dedicated account manager
|
| 23 |
+
- Custom integrations
|
| 24 |
+
|
| 25 |
+
## Managing Your Subscription
|
| 26 |
+
|
| 27 |
+
You can upgrade or downgrade at any time from Settings > Billing.
|
| 28 |
+
|
| 29 |
+
### Upgrading
|
| 30 |
+
When you upgrade mid-cycle, you'll be charged a prorated amount.
|
| 31 |
+
|
| 32 |
+
### Downgrading
|
| 33 |
+
Downgrades take effect at the next billing cycle.
|
| 34 |
+
|
| 35 |
+
## Refund Policy
|
| 36 |
+
|
| 37 |
+
Refunds are available within 7 days of an upgrade if you haven't used any Pro-only features.
|
data/articles/getting-started.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Getting Started with TaskFlow
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
TaskFlow is a project management tool designed for small and mid-size teams. After signing up, you'll land on your Dashboard, which shows all active projects.
|
| 6 |
+
|
| 7 |
+
## Creating Your First Project
|
| 8 |
+
|
| 9 |
+
To create your first project:
|
| 10 |
+
|
| 11 |
+
1. Click the "+ New Project" button in the top-left corner
|
| 12 |
+
2. Give it a name and description
|
| 13 |
+
3. Invite team members by email
|
| 14 |
+
|
| 15 |
+
## Understanding Projects and Tasks
|
| 16 |
+
|
| 17 |
+
Each project contains Boards, and each Board contains Tasks. You can drag and drop tasks between columns (To Do, In Progress, Done) to track progress.
|
| 18 |
+
|
| 19 |
+
## Team Size Limits
|
| 20 |
+
|
| 21 |
+
- **Free plan**: Up to 10 team members
|
| 22 |
+
- **Pro plan**: Up to 50 team members
|
data/articles/integrations.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Integrations
|
| 2 |
+
|
| 3 |
+
## Available Integrations
|
| 4 |
+
|
| 5 |
+
TaskFlow integrates with the following platforms:
|
| 6 |
+
- Slack
|
| 7 |
+
- Google Calendar
|
| 8 |
+
- GitHub
|
| 9 |
+
- Zapier
|
| 10 |
+
|
| 11 |
+
**Note**: Integrations are available on Pro and Enterprise plans only.
|
| 12 |
+
|
| 13 |
+
## Connecting an Integration
|
| 14 |
+
|
| 15 |
+
To connect an integration:
|
| 16 |
+
|
| 17 |
+
1. Go to Settings > Integrations
|
| 18 |
+
2. Click "Connect" next to the app you want
|
| 19 |
+
3. Follow the authorization prompts
|
| 20 |
+
|
| 21 |
+
## Integration Features
|
| 22 |
+
|
| 23 |
+
### Slack
|
| 24 |
+
Sends task notifications to a channel of your choice.
|
| 25 |
+
|
| 26 |
+
### Google Calendar
|
| 27 |
+
Syncs task due dates to your calendar.
|
| 28 |
+
|
| 29 |
+
### GitHub
|
| 30 |
+
Links pull requests to tasks. When a PR is merged, the linked task automatically moves to "Done."
|
| 31 |
+
|
| 32 |
+
### Zapier
|
| 33 |
+
Allows you to build custom automations with 3,000+ apps.
|
data/articles/permissions.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Managing Team Permissions
|
| 2 |
+
|
| 3 |
+
## Permission Roles
|
| 4 |
+
|
| 5 |
+
TaskFlow has three permission roles:
|
| 6 |
+
|
| 7 |
+
### Owner
|
| 8 |
+
- Full control including billing and account deletion
|
| 9 |
+
- Only one Owner per workspace
|
| 10 |
+
- Can transfer ownership under Settings > Team > Transfer Ownership
|
| 11 |
+
|
| 12 |
+
### Admin
|
| 13 |
+
- Create and manage projects
|
| 14 |
+
- Invite members
|
| 15 |
+
- Configure integrations
|
| 16 |
+
- Cannot manage billing or delete account
|
| 17 |
+
|
| 18 |
+
### Member
|
| 19 |
+
- Create and edit tasks within assigned projects
|
| 20 |
+
- Cannot manage billing or integrations
|
| 21 |
+
- Cannot manage team members
|
| 22 |
+
|
| 23 |
+
## Changing Roles
|
| 24 |
+
|
| 25 |
+
To change someone's role:
|
| 26 |
+
|
| 27 |
+
1. Go to Settings > Team
|
| 28 |
+
2. Click the three-dot menu next to their name
|
| 29 |
+
3. Select "Change Role"
|
| 30 |
+
|
| 31 |
+
**Note**: Only Owners can promote someone to Admin.
|
data/articles/troubleshooting.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Troubleshooting Common Issues
|
| 2 |
+
|
| 3 |
+
## Tasks Not Syncing
|
| 4 |
+
|
| 5 |
+
Try the following steps:
|
| 6 |
+
|
| 7 |
+
1. Refresh your browser or clear your cache
|
| 8 |
+
2. Check your internet connection
|
| 9 |
+
3. Ensure you're on the latest version of TaskFlow
|
| 10 |
+
4. If using the mobile app, force-close and reopen it
|
| 11 |
+
|
| 12 |
+
## Can't Invite Team Members
|
| 13 |
+
|
| 14 |
+
Check the following:
|
| 15 |
+
|
| 16 |
+
- Verify you haven't hit your plan's member limit (Free: 10, Pro: 50)
|
| 17 |
+
- Ensure the email address is correct
|
| 18 |
+
- Confirm the email hasn't already been invited
|
| 19 |
+
|
| 20 |
+
## Integration Not Working
|
| 21 |
+
|
| 22 |
+
To resolve integration issues:
|
| 23 |
+
|
| 24 |
+
1. Disconnect and reconnect the integration from Settings > Integrations
|
| 25 |
+
2. Verify you've authorized TaskFlow in the third-party app's settings
|
| 26 |
+
|
| 27 |
+
## Forgot Password
|
| 28 |
+
|
| 29 |
+
To reset your password:
|
| 30 |
+
|
| 31 |
+
1. Click "Forgot Password" on the login screen
|
| 32 |
+
2. You'll receive a reset link within 5 minutes
|
| 33 |
+
3. Check your spam folder if it doesn't arrive
|
| 34 |
+
|
| 35 |
+
**Note**: Reset links expire after 24 hours.
|
list_models.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""List available Gemini models."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
import google.generativeai as genai
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
|
| 10 |
+
|
| 11 |
+
print("Available Gemini models:")
|
| 12 |
+
for model in genai.list_models():
|
| 13 |
+
if 'generateContent' in model.supported_generation_methods:
|
| 14 |
+
print(f" - {model.name}")
|
requirements.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FastAPI and server dependencies
|
| 2 |
+
fastapi==0.104.1
|
| 3 |
+
uvicorn[standard]==0.24.0
|
| 4 |
+
pydantic==2.5.0
|
| 5 |
+
pydantic-settings==2.1.0
|
| 6 |
+
python-dotenv==1.0.0
|
| 7 |
+
|
| 8 |
+
# RAG and ML dependencies
|
| 9 |
+
sentence-transformers==2.2.2
|
| 10 |
+
faiss-cpu==1.7.4
|
| 11 |
+
numpy==1.24.3
|
| 12 |
+
|
| 13 |
+
# LLM API clients
|
| 14 |
+
google-generativeai==0.3.2
|
| 15 |
+
|
| 16 |
+
# Testing dependencies
|
| 17 |
+
pytest==7.4.3
|
| 18 |
+
pytest-asyncio==0.21.1
|
| 19 |
+
hypothesis==6.92.1
|
| 20 |
+
httpx==0.25.2
|
| 21 |
+
|
| 22 |
+
# CORS support
|
| 23 |
+
python-multipart==0.0.6
|
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Smart Escalation API - RAG-based customer support system."""
|
src/chunking.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Text chunking module for the Smart Escalation API.
|
| 3 |
+
|
| 4 |
+
This module provides functionality to split help articles into fixed-size chunks
|
| 5 |
+
with overlap while preserving sentence boundaries and tracking source metadata.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import List, Dict, Any
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def chunk_text(
|
| 13 |
+
text: str,
|
| 14 |
+
source_article: str,
|
| 15 |
+
chunk_size: int = 500,
|
| 16 |
+
chunk_overlap: int = 50
|
| 17 |
+
) -> List[Dict[str, Any]]:
|
| 18 |
+
"""
|
| 19 |
+
Split text into fixed-size chunks with overlap, preserving sentence boundaries.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
text: The article text to chunk
|
| 23 |
+
source_article: The filename or identifier of the source article
|
| 24 |
+
chunk_size: Maximum size of each chunk in characters (default: 500)
|
| 25 |
+
chunk_overlap: Number of characters to overlap between chunks (default: 50)
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
List of chunk dictionaries, each containing:
|
| 29 |
+
- content: The chunk text
|
| 30 |
+
- source: The source article identifier
|
| 31 |
+
- position: The starting character position in the original text
|
| 32 |
+
- chunk_id: Sequential chunk identifier
|
| 33 |
+
|
| 34 |
+
Example:
|
| 35 |
+
>>> chunks = chunk_text("First sentence. Second sentence.", "test.md")
|
| 36 |
+
>>> chunks[0]['content']
|
| 37 |
+
'First sentence. Second sentence.'
|
| 38 |
+
>>> chunks[0]['source']
|
| 39 |
+
'test.md'
|
| 40 |
+
"""
|
| 41 |
+
if not text or not text.strip():
|
| 42 |
+
return []
|
| 43 |
+
|
| 44 |
+
# Normalize whitespace
|
| 45 |
+
text = text.strip()
|
| 46 |
+
|
| 47 |
+
chunks = []
|
| 48 |
+
chunk_id = 0
|
| 49 |
+
start_pos = 0
|
| 50 |
+
|
| 51 |
+
while start_pos < len(text):
|
| 52 |
+
# Calculate end position for this chunk
|
| 53 |
+
end_pos = start_pos + chunk_size
|
| 54 |
+
|
| 55 |
+
# If this is the last chunk or we're at the end, take everything remaining
|
| 56 |
+
if end_pos >= len(text):
|
| 57 |
+
chunk_content = text[start_pos:].strip()
|
| 58 |
+
if chunk_content:
|
| 59 |
+
chunks.append({
|
| 60 |
+
'content': chunk_content,
|
| 61 |
+
'source': source_article,
|
| 62 |
+
'position': start_pos,
|
| 63 |
+
'chunk_id': chunk_id
|
| 64 |
+
})
|
| 65 |
+
break
|
| 66 |
+
|
| 67 |
+
# Try to find a sentence boundary near the end position
|
| 68 |
+
chunk_content = text[start_pos:end_pos]
|
| 69 |
+
|
| 70 |
+
# Look for sentence-ending punctuation followed by space or end
|
| 71 |
+
# Search backwards from the end to find the last complete sentence
|
| 72 |
+
sentence_end = _find_sentence_boundary(chunk_content)
|
| 73 |
+
|
| 74 |
+
if sentence_end > 0:
|
| 75 |
+
# Found a sentence boundary, use it
|
| 76 |
+
chunk_content = text[start_pos:start_pos + sentence_end].strip()
|
| 77 |
+
actual_end = start_pos + sentence_end
|
| 78 |
+
else:
|
| 79 |
+
# No sentence boundary found, try to break at word boundary
|
| 80 |
+
word_boundary = _find_word_boundary(chunk_content)
|
| 81 |
+
if word_boundary > 0:
|
| 82 |
+
chunk_content = text[start_pos:start_pos + word_boundary].strip()
|
| 83 |
+
actual_end = start_pos + word_boundary
|
| 84 |
+
else:
|
| 85 |
+
# Last resort: use the full chunk_size
|
| 86 |
+
chunk_content = chunk_content.strip()
|
| 87 |
+
actual_end = end_pos
|
| 88 |
+
|
| 89 |
+
if chunk_content:
|
| 90 |
+
chunks.append({
|
| 91 |
+
'content': chunk_content,
|
| 92 |
+
'source': source_article,
|
| 93 |
+
'position': start_pos,
|
| 94 |
+
'chunk_id': chunk_id
|
| 95 |
+
})
|
| 96 |
+
chunk_id += 1
|
| 97 |
+
|
| 98 |
+
# Move start position forward, accounting for overlap
|
| 99 |
+
# Ensure we move forward by at least 1 character to avoid infinite loops
|
| 100 |
+
next_start = max(actual_end - chunk_overlap, start_pos + 1)
|
| 101 |
+
start_pos = next_start
|
| 102 |
+
|
| 103 |
+
return chunks
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _find_sentence_boundary(text: str) -> int:
|
| 107 |
+
"""
|
| 108 |
+
Find the position of the last sentence boundary in the text.
|
| 109 |
+
|
| 110 |
+
A sentence boundary is defined as sentence-ending punctuation
|
| 111 |
+
(. ! ?) followed by whitespace or end of text.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
text: The text to search
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
Position after the sentence-ending punctuation, or 0 if not found
|
| 118 |
+
"""
|
| 119 |
+
# Pattern: sentence-ending punctuation followed by space or end
|
| 120 |
+
# Search from the end backwards
|
| 121 |
+
pattern = r'[.!?](?=\s|$)'
|
| 122 |
+
matches = list(re.finditer(pattern, text))
|
| 123 |
+
|
| 124 |
+
if matches:
|
| 125 |
+
# Return position after the punctuation
|
| 126 |
+
last_match = matches[-1]
|
| 127 |
+
return last_match.end()
|
| 128 |
+
|
| 129 |
+
return 0
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _find_word_boundary(text: str) -> int:
|
| 133 |
+
"""
|
| 134 |
+
Find the position of the last word boundary in the text.
|
| 135 |
+
|
| 136 |
+
A word boundary is defined as whitespace between words.
|
| 137 |
+
|
| 138 |
+
Args:
|
| 139 |
+
text: The text to search
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
Position after the last whitespace, or 0 if not found
|
| 143 |
+
"""
|
| 144 |
+
# Search backwards for whitespace
|
| 145 |
+
for i in range(len(text) - 1, -1, -1):
|
| 146 |
+
if text[i].isspace():
|
| 147 |
+
# Return position after the whitespace
|
| 148 |
+
return i + 1
|
| 149 |
+
|
| 150 |
+
return 0
|
src/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration management for Smart Escalation API.
|
| 3 |
+
|
| 4 |
+
This module defines the Settings class using Pydantic for environment variable
|
| 5 |
+
management with validation and default values.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pydantic_settings import BaseSettings
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
"""
|
| 14 |
+
Application settings loaded from environment variables.
|
| 15 |
+
|
| 16 |
+
All settings can be overridden via environment variables or .env file.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
# LLM API Configuration
|
| 20 |
+
google_api_key: str
|
| 21 |
+
|
| 22 |
+
# Embedding Model Configuration
|
| 23 |
+
embedding_model: str = "all-MiniLM-L6-v2"
|
| 24 |
+
|
| 25 |
+
# Escalation Logic Configuration
|
| 26 |
+
relevance_threshold: float = 0.5
|
| 27 |
+
top_k_chunks: int = 3
|
| 28 |
+
|
| 29 |
+
# Chunking Configuration
|
| 30 |
+
chunk_size: int = 500
|
| 31 |
+
chunk_overlap: int = 50
|
| 32 |
+
|
| 33 |
+
# LLM Configuration
|
| 34 |
+
llm_model: str = "gemini-1.5-flash"
|
| 35 |
+
llm_temperature: float = 0.3
|
| 36 |
+
|
| 37 |
+
# Server Configuration
|
| 38 |
+
port: int = 8000
|
| 39 |
+
host: str = "0.0.0.0"
|
| 40 |
+
|
| 41 |
+
class Config:
|
| 42 |
+
"""Pydantic configuration."""
|
| 43 |
+
env_file = ".env"
|
| 44 |
+
env_file_encoding = "utf-8"
|
| 45 |
+
case_sensitive = False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Global settings instance
|
| 49 |
+
settings = Settings()
|
src/escalation.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Escalation Engine for the Smart Escalation API.
|
| 3 |
+
|
| 4 |
+
This module provides the EscalationEngine class that:
|
| 5 |
+
- Evaluates retrieval quality using relevance scores
|
| 6 |
+
- Decides whether to answer or escalate to human agents
|
| 7 |
+
- Generates confidence explanations for decisions
|
| 8 |
+
- Coordinates LLM calls when answering questions
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import List, Literal, Optional
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
from src.llm_client import LLMClient, LLMResponse
|
| 15 |
+
from src.rag import RetrievedChunk
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class EscalationDecision:
|
| 20 |
+
"""Decision result from the escalation engine."""
|
| 21 |
+
action: Literal["answer", "escalation"]
|
| 22 |
+
message: str
|
| 23 |
+
confidence_explanation: str
|
| 24 |
+
sources: Optional[List[str]] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class EscalationEngine:
|
| 28 |
+
"""
|
| 29 |
+
Engine that decides whether to answer customer questions or escalate to human agents.
|
| 30 |
+
|
| 31 |
+
The engine uses multiple signals to make escalation decisions:
|
| 32 |
+
- Relevance scores from retrieval
|
| 33 |
+
- LLM uncertainty signals
|
| 34 |
+
- Coverage diversity of retrieved chunks
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
llm_client: LLMClient,
|
| 40 |
+
relevance_threshold: float = 0.5,
|
| 41 |
+
min_chunks_for_answer: int = 1
|
| 42 |
+
):
|
| 43 |
+
"""
|
| 44 |
+
Initialize the escalation engine.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
llm_client: LLM client for generating answers
|
| 48 |
+
relevance_threshold: Minimum relevance score to consider answering (default: 0.5)
|
| 49 |
+
min_chunks_for_answer: Minimum number of chunks needed to attempt answering
|
| 50 |
+
"""
|
| 51 |
+
self.llm_client = llm_client
|
| 52 |
+
self.relevance_threshold = relevance_threshold
|
| 53 |
+
self.min_chunks_for_answer = min_chunks_for_answer
|
| 54 |
+
|
| 55 |
+
def _should_escalate_based_on_retrieval(
|
| 56 |
+
self,
|
| 57 |
+
retrieved_chunks: List[RetrievedChunk]
|
| 58 |
+
) -> tuple[bool, str]:
|
| 59 |
+
"""
|
| 60 |
+
Determine if escalation is needed based on retrieval quality.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
retrieved_chunks: Chunks retrieved from RAG system
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
Tuple of (should_escalate, reason)
|
| 67 |
+
"""
|
| 68 |
+
# No chunks retrieved
|
| 69 |
+
if not retrieved_chunks:
|
| 70 |
+
return True, "No relevant information found in help articles"
|
| 71 |
+
|
| 72 |
+
# Insufficient chunks
|
| 73 |
+
if len(retrieved_chunks) < self.min_chunks_for_answer:
|
| 74 |
+
return True, f"Only {len(retrieved_chunks)} relevant chunk(s) found, insufficient for confident answer"
|
| 75 |
+
|
| 76 |
+
# Check best relevance score
|
| 77 |
+
max_score = max(chunk.score for chunk in retrieved_chunks)
|
| 78 |
+
if max_score < self.relevance_threshold:
|
| 79 |
+
return True, f"Best retrieval score was {max_score:.2f}, below confidence threshold of {self.relevance_threshold}"
|
| 80 |
+
|
| 81 |
+
# All checks passed
|
| 82 |
+
return False, ""
|
| 83 |
+
|
| 84 |
+
def _generate_confidence_explanation(
|
| 85 |
+
self,
|
| 86 |
+
action: Literal["answer", "escalation"],
|
| 87 |
+
retrieved_chunks: List[RetrievedChunk],
|
| 88 |
+
llm_uncertain: bool = False,
|
| 89 |
+
retrieval_reason: str = ""
|
| 90 |
+
) -> str:
|
| 91 |
+
"""
|
| 92 |
+
Generate human-readable confidence explanation.
|
| 93 |
+
|
| 94 |
+
Args:
|
| 95 |
+
action: Whether answering or escalating
|
| 96 |
+
retrieved_chunks: Retrieved chunks from RAG
|
| 97 |
+
llm_uncertain: Whether LLM signaled uncertainty
|
| 98 |
+
retrieval_reason: Reason for retrieval-based escalation
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Confidence explanation string
|
| 102 |
+
"""
|
| 103 |
+
if action == "escalation":
|
| 104 |
+
if llm_uncertain:
|
| 105 |
+
return "LLM indicated uncertainty in answering based on available context"
|
| 106 |
+
elif retrieval_reason:
|
| 107 |
+
return retrieval_reason
|
| 108 |
+
else:
|
| 109 |
+
return "Insufficient confidence to provide accurate answer"
|
| 110 |
+
|
| 111 |
+
# For answers, provide positive confidence signal
|
| 112 |
+
if not retrieved_chunks:
|
| 113 |
+
return "Answer generated with available context"
|
| 114 |
+
|
| 115 |
+
max_score = max(chunk.score for chunk in retrieved_chunks)
|
| 116 |
+
sources = list(set(chunk.source for chunk in retrieved_chunks))
|
| 117 |
+
|
| 118 |
+
if len(sources) == 1:
|
| 119 |
+
return f"High confidence - retrieved relevant content from '{sources[0]}' with similarity score {max_score:.2f}"
|
| 120 |
+
else:
|
| 121 |
+
return f"High confidence - retrieved relevant content from {len(sources)} help articles with best similarity score {max_score:.2f}"
|
| 122 |
+
|
| 123 |
+
def _extract_sources(self, retrieved_chunks: List[RetrievedChunk]) -> List[str]:
|
| 124 |
+
"""
|
| 125 |
+
Extract unique source article names from retrieved chunks.
|
| 126 |
+
|
| 127 |
+
Args:
|
| 128 |
+
retrieved_chunks: Retrieved chunks from RAG
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
List of unique source filenames
|
| 132 |
+
"""
|
| 133 |
+
if not retrieved_chunks:
|
| 134 |
+
return []
|
| 135 |
+
|
| 136 |
+
sources = list(set(chunk.source for chunk in retrieved_chunks))
|
| 137 |
+
return sorted(sources)
|
| 138 |
+
|
| 139 |
+
def process_question(
|
| 140 |
+
self,
|
| 141 |
+
question: str,
|
| 142 |
+
retrieved_chunks: List[RetrievedChunk]
|
| 143 |
+
) -> EscalationDecision:
|
| 144 |
+
"""
|
| 145 |
+
Process a customer question and decide whether to answer or escalate.
|
| 146 |
+
|
| 147 |
+
This is the main entry point for the escalation engine. It:
|
| 148 |
+
1. Evaluates retrieval quality
|
| 149 |
+
2. If quality is sufficient, calls LLM to generate answer
|
| 150 |
+
3. Checks LLM response for uncertainty signals
|
| 151 |
+
4. Returns decision with confidence explanation
|
| 152 |
+
|
| 153 |
+
Args:
|
| 154 |
+
question: Customer question
|
| 155 |
+
retrieved_chunks: Retrieved chunks from RAG system
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
EscalationDecision with action, message, and explanation
|
| 159 |
+
|
| 160 |
+
Example:
|
| 161 |
+
>>> engine = EscalationEngine(llm_client)
|
| 162 |
+
>>> decision = engine.process_question("How do I reset password?", chunks)
|
| 163 |
+
>>> decision.action
|
| 164 |
+
'answer'
|
| 165 |
+
>>> decision.message
|
| 166 |
+
'To reset your password, go to...'
|
| 167 |
+
"""
|
| 168 |
+
# First check: Should we escalate based on retrieval quality?
|
| 169 |
+
should_escalate, retrieval_reason = self._should_escalate_based_on_retrieval(
|
| 170 |
+
retrieved_chunks
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
if should_escalate:
|
| 174 |
+
# Escalate due to poor retrieval
|
| 175 |
+
return EscalationDecision(
|
| 176 |
+
action="escalation",
|
| 177 |
+
message="I'm not certain I can answer this accurately. Let me connect you with a human support agent who can help.",
|
| 178 |
+
confidence_explanation=self._generate_confidence_explanation(
|
| 179 |
+
action="escalation",
|
| 180 |
+
retrieved_chunks=retrieved_chunks,
|
| 181 |
+
retrieval_reason=retrieval_reason
|
| 182 |
+
),
|
| 183 |
+
sources=None
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Retrieval quality is sufficient, attempt to generate answer
|
| 187 |
+
try:
|
| 188 |
+
# Extract chunk contents for LLM
|
| 189 |
+
chunk_contents = [chunk.content for chunk in retrieved_chunks]
|
| 190 |
+
|
| 191 |
+
# Generate answer using LLM
|
| 192 |
+
llm_response: LLMResponse = self.llm_client.generate_answer(
|
| 193 |
+
question=question,
|
| 194 |
+
context_chunks=chunk_contents
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
# Second check: Did LLM signal uncertainty?
|
| 198 |
+
if llm_response.uncertain:
|
| 199 |
+
# Escalate due to LLM uncertainty
|
| 200 |
+
return EscalationDecision(
|
| 201 |
+
action="escalation",
|
| 202 |
+
message="I'm not certain I can answer this accurately. Let me connect you with a human support agent who can help.",
|
| 203 |
+
confidence_explanation=self._generate_confidence_explanation(
|
| 204 |
+
action="escalation",
|
| 205 |
+
retrieved_chunks=retrieved_chunks,
|
| 206 |
+
llm_uncertain=True
|
| 207 |
+
),
|
| 208 |
+
sources=None
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
# All checks passed - return answer
|
| 212 |
+
return EscalationDecision(
|
| 213 |
+
action="answer",
|
| 214 |
+
message=llm_response.answer,
|
| 215 |
+
confidence_explanation=self._generate_confidence_explanation(
|
| 216 |
+
action="answer",
|
| 217 |
+
retrieved_chunks=retrieved_chunks
|
| 218 |
+
),
|
| 219 |
+
sources=self._extract_sources(retrieved_chunks)
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
except Exception as e:
|
| 223 |
+
# If LLM call fails, escalate gracefully
|
| 224 |
+
error_msg = str(e)
|
| 225 |
+
|
| 226 |
+
# Provide specific error context in explanation
|
| 227 |
+
if "timeout" in error_msg.lower():
|
| 228 |
+
explanation = "Unable to generate response due to timeout, escalating to human agent"
|
| 229 |
+
elif "rate limit" in error_msg.lower():
|
| 230 |
+
explanation = "Service temporarily busy, escalating to human agent"
|
| 231 |
+
else:
|
| 232 |
+
explanation = "Unable to generate response, escalating to human agent"
|
| 233 |
+
|
| 234 |
+
return EscalationDecision(
|
| 235 |
+
action="escalation",
|
| 236 |
+
message="I'm having trouble processing your question right now. Let me connect you with a human support agent who can help.",
|
| 237 |
+
confidence_explanation=explanation,
|
| 238 |
+
sources=None
|
| 239 |
+
)
|
src/llm_client.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM Client for generating customer support responses using Google Gemini API.
|
| 3 |
+
|
| 4 |
+
This module provides the LLMClient class that handles:
|
| 5 |
+
- Google Gemini API integration
|
| 6 |
+
- Prompt construction with customer support instructions
|
| 7 |
+
- Response generation with uncertainty detection
|
| 8 |
+
- Error handling for API timeouts and rate limits
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import time
|
| 13 |
+
from typing import List, Optional
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
import google.generativeai as genai
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class LLMResponse:
|
| 21 |
+
"""Response from LLM generation."""
|
| 22 |
+
answer: str
|
| 23 |
+
uncertain: bool # True if LLM signals uncertainty
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class LLMClient:
|
| 27 |
+
"""Client for interacting with Google Gemini API to generate support responses."""
|
| 28 |
+
|
| 29 |
+
# Uncertainty signals that indicate the LLM is not confident
|
| 30 |
+
UNCERTAINTY_SIGNALS = [
|
| 31 |
+
"i'm not certain",
|
| 32 |
+
"i'm not sure",
|
| 33 |
+
"i don't have enough information",
|
| 34 |
+
"let me connect you with",
|
| 35 |
+
"human agent",
|
| 36 |
+
"i cannot answer",
|
| 37 |
+
"i'm unable to",
|
| 38 |
+
"not enough information",
|
| 39 |
+
"outside my knowledge",
|
| 40 |
+
"i don't know"
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
api_key: Optional[str] = None,
|
| 46 |
+
model_name: str = "gemini-1.5-flash",
|
| 47 |
+
temperature: float = 0.3,
|
| 48 |
+
max_retries: int = 2,
|
| 49 |
+
timeout: int = 30
|
| 50 |
+
):
|
| 51 |
+
"""
|
| 52 |
+
Initialize LLM client with Google Gemini API.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
api_key: Google API key (defaults to GOOGLE_API_KEY env var)
|
| 56 |
+
model_name: Gemini model to use
|
| 57 |
+
temperature: Sampling temperature (0.0-1.0, lower = more deterministic)
|
| 58 |
+
max_retries: Maximum number of retry attempts for failed requests
|
| 59 |
+
timeout: Request timeout in seconds
|
| 60 |
+
"""
|
| 61 |
+
self.api_key = api_key or os.getenv("GOOGLE_API_KEY")
|
| 62 |
+
if not self.api_key:
|
| 63 |
+
raise ValueError("Google API key not provided. Set GOOGLE_API_KEY environment variable.")
|
| 64 |
+
|
| 65 |
+
self.model_name = model_name
|
| 66 |
+
self.temperature = temperature
|
| 67 |
+
self.max_retries = max_retries
|
| 68 |
+
self.timeout = timeout
|
| 69 |
+
|
| 70 |
+
# Configure the API
|
| 71 |
+
genai.configure(api_key=self.api_key)
|
| 72 |
+
|
| 73 |
+
# Initialize the model
|
| 74 |
+
self.model = genai.GenerativeModel(
|
| 75 |
+
model_name=self.model_name,
|
| 76 |
+
generation_config={
|
| 77 |
+
"temperature": self.temperature,
|
| 78 |
+
"top_p": 0.95,
|
| 79 |
+
"top_k": 40,
|
| 80 |
+
"max_output_tokens": 1024,
|
| 81 |
+
}
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
def build_prompt(self, question: str, context_chunks: List[str]) -> str:
|
| 85 |
+
"""
|
| 86 |
+
Build prompt with customer support instructions and context.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
question: Customer question
|
| 90 |
+
context_chunks: Retrieved help article chunks
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
Formatted prompt string
|
| 94 |
+
"""
|
| 95 |
+
# Format context chunks
|
| 96 |
+
context_text = "\n\n".join([
|
| 97 |
+
f"[Context {i+1}]\n{chunk}"
|
| 98 |
+
for i, chunk in enumerate(context_chunks)
|
| 99 |
+
])
|
| 100 |
+
|
| 101 |
+
prompt = f"""You are a friendly customer support agent for TaskFlow, a project management SaaS.
|
| 102 |
+
|
| 103 |
+
Answer the customer's question using ONLY the information provided in the help articles below.
|
| 104 |
+
|
| 105 |
+
IMPORTANT RULES:
|
| 106 |
+
- Use a friendly, helpful tone
|
| 107 |
+
- Ground your answer in the provided context
|
| 108 |
+
- If the context doesn't contain enough information to answer confidently, respond with: "I'm not certain about this. Let me connect you with a human agent."
|
| 109 |
+
- Do not make up or infer information not present in the context
|
| 110 |
+
- Be concise and direct in your response
|
| 111 |
+
|
| 112 |
+
HELP ARTICLE CONTEXT:
|
| 113 |
+
{context_text}
|
| 114 |
+
|
| 115 |
+
CUSTOMER QUESTION:
|
| 116 |
+
{question}
|
| 117 |
+
|
| 118 |
+
YOUR ANSWER:"""
|
| 119 |
+
|
| 120 |
+
return prompt
|
| 121 |
+
|
| 122 |
+
def _detect_uncertainty(self, response_text: str) -> bool:
|
| 123 |
+
"""
|
| 124 |
+
Detect if LLM response contains uncertainty signals.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
response_text: Generated response text
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
True if uncertainty detected, False otherwise
|
| 131 |
+
"""
|
| 132 |
+
response_lower = response_text.lower()
|
| 133 |
+
return any(signal in response_lower for signal in self.UNCERTAINTY_SIGNALS)
|
| 134 |
+
|
| 135 |
+
def generate_answer(
|
| 136 |
+
self,
|
| 137 |
+
question: str,
|
| 138 |
+
context_chunks: List[str]
|
| 139 |
+
) -> LLMResponse:
|
| 140 |
+
"""
|
| 141 |
+
Generate answer using LLM with retrieved context.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
question: Customer question
|
| 145 |
+
context_chunks: Retrieved help article chunks
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
LLMResponse with answer text and uncertainty flag
|
| 149 |
+
|
| 150 |
+
Raises:
|
| 151 |
+
ValueError: If question or context is empty
|
| 152 |
+
RuntimeError: If API call fails after retries
|
| 153 |
+
"""
|
| 154 |
+
if not question or not question.strip():
|
| 155 |
+
raise ValueError("Question cannot be empty")
|
| 156 |
+
|
| 157 |
+
if not context_chunks:
|
| 158 |
+
raise ValueError("Context chunks cannot be empty")
|
| 159 |
+
|
| 160 |
+
# Build the prompt
|
| 161 |
+
prompt = self.build_prompt(question, context_chunks)
|
| 162 |
+
|
| 163 |
+
# Attempt generation with retries
|
| 164 |
+
last_error = None
|
| 165 |
+
for attempt in range(self.max_retries + 1):
|
| 166 |
+
try:
|
| 167 |
+
# Generate response
|
| 168 |
+
response = self.model.generate_content(
|
| 169 |
+
prompt,
|
| 170 |
+
request_options={"timeout": self.timeout}
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Extract text from response
|
| 174 |
+
if not response.text:
|
| 175 |
+
# Empty response indicates uncertainty
|
| 176 |
+
return LLMResponse(
|
| 177 |
+
answer="I'm not certain about this. Let me connect you with a human agent.",
|
| 178 |
+
uncertain=True
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
answer_text = response.text.strip()
|
| 182 |
+
|
| 183 |
+
# Detect uncertainty in response
|
| 184 |
+
uncertain = self._detect_uncertainty(answer_text)
|
| 185 |
+
|
| 186 |
+
return LLMResponse(
|
| 187 |
+
answer=answer_text,
|
| 188 |
+
uncertain=uncertain
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
last_error = e
|
| 193 |
+
error_msg = str(e).lower()
|
| 194 |
+
|
| 195 |
+
# Check for rate limiting
|
| 196 |
+
if "429" in error_msg or "quota" in error_msg or "rate limit" in error_msg:
|
| 197 |
+
if attempt < self.max_retries:
|
| 198 |
+
# Exponential backoff for rate limits
|
| 199 |
+
wait_time = 2 ** attempt
|
| 200 |
+
time.sleep(wait_time)
|
| 201 |
+
continue
|
| 202 |
+
else:
|
| 203 |
+
raise RuntimeError(
|
| 204 |
+
"Rate limit exceeded. Please try again later."
|
| 205 |
+
) from e
|
| 206 |
+
|
| 207 |
+
# Check for timeout
|
| 208 |
+
if "timeout" in error_msg:
|
| 209 |
+
if attempt < self.max_retries:
|
| 210 |
+
continue
|
| 211 |
+
else:
|
| 212 |
+
raise RuntimeError(
|
| 213 |
+
"Request timed out. Please try again."
|
| 214 |
+
) from e
|
| 215 |
+
|
| 216 |
+
# Check for invalid API key
|
| 217 |
+
if "api key" in error_msg or "authentication" in error_msg or "401" in error_msg:
|
| 218 |
+
raise RuntimeError(
|
| 219 |
+
"Invalid API key. Please check your configuration."
|
| 220 |
+
) from e
|
| 221 |
+
|
| 222 |
+
# For other errors, retry if attempts remain
|
| 223 |
+
if attempt < self.max_retries:
|
| 224 |
+
time.sleep(1)
|
| 225 |
+
continue
|
| 226 |
+
else:
|
| 227 |
+
raise RuntimeError(
|
| 228 |
+
f"Failed to generate response: {str(e)}"
|
| 229 |
+
) from e
|
| 230 |
+
|
| 231 |
+
# Should not reach here, but handle gracefully
|
| 232 |
+
raise RuntimeError(
|
| 233 |
+
f"Failed to generate response after {self.max_retries + 1} attempts: {str(last_error)}"
|
| 234 |
+
)
|
src/main.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI application for the Smart Escalation API.
|
| 3 |
+
|
| 4 |
+
This module provides the main API endpoint for processing customer questions
|
| 5 |
+
and returning answers or escalation messages.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
from typing import Literal, Optional, List
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, HTTPException, status
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from pydantic import BaseModel, Field, validator
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
from src.rag import RAGSystem
|
| 18 |
+
from src.llm_client import LLMClient
|
| 19 |
+
from src.escalation import EscalationEngine
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# Load environment variables
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Pydantic models for request/response
|
| 27 |
+
class QuestionRequest(BaseModel):
|
| 28 |
+
"""Request model for customer questions."""
|
| 29 |
+
question: str = Field(
|
| 30 |
+
...,
|
| 31 |
+
min_length=1,
|
| 32 |
+
max_length=500,
|
| 33 |
+
description="Customer question (1-500 characters)"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
@validator('question')
|
| 37 |
+
def question_not_empty(cls, v):
|
| 38 |
+
"""Validate that question is not just whitespace."""
|
| 39 |
+
if not v or not v.strip():
|
| 40 |
+
raise ValueError("Question cannot be empty or whitespace only")
|
| 41 |
+
return v.strip()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class QuestionResponse(BaseModel):
|
| 45 |
+
"""Response model for API answers."""
|
| 46 |
+
response_type: Literal["answer", "escalation"] = Field(
|
| 47 |
+
...,
|
| 48 |
+
description="Type of response: 'answer' or 'escalation'"
|
| 49 |
+
)
|
| 50 |
+
message: str = Field(
|
| 51 |
+
...,
|
| 52 |
+
description="Answer text or escalation message"
|
| 53 |
+
)
|
| 54 |
+
confidence_explanation: str = Field(
|
| 55 |
+
...,
|
| 56 |
+
description="Explanation of why the system answered or escalated"
|
| 57 |
+
)
|
| 58 |
+
sources: Optional[List[str]] = Field(
|
| 59 |
+
None,
|
| 60 |
+
description="List of source help articles (only for answers)"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Global instances (initialized on startup)
|
| 65 |
+
rag_system: Optional[RAGSystem] = None
|
| 66 |
+
escalation_engine: Optional[EscalationEngine] = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@asynccontextmanager
|
| 70 |
+
async def lifespan(app: FastAPI):
|
| 71 |
+
"""
|
| 72 |
+
Lifespan context manager for startup and shutdown events.
|
| 73 |
+
|
| 74 |
+
Initializes RAG system, LLM client, and escalation engine on startup.
|
| 75 |
+
"""
|
| 76 |
+
global rag_system, escalation_engine
|
| 77 |
+
|
| 78 |
+
print("Starting Smart Escalation API...")
|
| 79 |
+
|
| 80 |
+
# Load configuration from environment
|
| 81 |
+
articles_dir = os.getenv("ARTICLES_DIR", "data/articles")
|
| 82 |
+
embedding_model = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2")
|
| 83 |
+
relevance_threshold = float(os.getenv("RELEVANCE_THRESHOLD", "0.5"))
|
| 84 |
+
top_k_chunks = int(os.getenv("TOP_K_CHUNKS", "3"))
|
| 85 |
+
chunk_size = int(os.getenv("CHUNK_SIZE", "500"))
|
| 86 |
+
chunk_overlap = int(os.getenv("CHUNK_OVERLAP", "50"))
|
| 87 |
+
llm_model = os.getenv("LLM_MODEL", "gemini-1.5-flash")
|
| 88 |
+
llm_temperature = float(os.getenv("LLM_TEMPERATURE", "0.3"))
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
# Initialize RAG system
|
| 92 |
+
print(f"Initializing RAG system with articles from {articles_dir}...")
|
| 93 |
+
rag_system = RAGSystem(
|
| 94 |
+
articles_dir=articles_dir,
|
| 95 |
+
embedding_model=embedding_model,
|
| 96 |
+
chunk_size=chunk_size,
|
| 97 |
+
chunk_overlap=chunk_overlap
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Initialize LLM client
|
| 101 |
+
print(f"Initializing LLM client with model {llm_model}...")
|
| 102 |
+
llm_client = LLMClient(
|
| 103 |
+
model_name=llm_model,
|
| 104 |
+
temperature=llm_temperature
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Initialize escalation engine
|
| 108 |
+
print("Initializing escalation engine...")
|
| 109 |
+
escalation_engine = EscalationEngine(
|
| 110 |
+
llm_client=llm_client,
|
| 111 |
+
relevance_threshold=relevance_threshold
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Store top_k for use in endpoint
|
| 115 |
+
app.state.top_k_chunks = top_k_chunks
|
| 116 |
+
|
| 117 |
+
print("✓ Smart Escalation API ready!")
|
| 118 |
+
print(f" - {rag_system.get_stats()['total_chunks']} chunks from {rag_system.get_stats()['total_articles']} articles")
|
| 119 |
+
print(f" - Relevance threshold: {relevance_threshold}")
|
| 120 |
+
print(f" - Top-k retrieval: {top_k_chunks}")
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f"✗ Failed to initialize API: {str(e)}")
|
| 124 |
+
raise
|
| 125 |
+
|
| 126 |
+
yield
|
| 127 |
+
|
| 128 |
+
# Cleanup (if needed)
|
| 129 |
+
print("Shutting down Smart Escalation API...")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Create FastAPI app
|
| 133 |
+
app = FastAPI(
|
| 134 |
+
title="Smart Escalation API",
|
| 135 |
+
description="AI-powered L1 customer support system with intelligent escalation",
|
| 136 |
+
version="1.0.0",
|
| 137 |
+
lifespan=lifespan
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# Configure CORS
|
| 142 |
+
app.add_middleware(
|
| 143 |
+
CORSMiddleware,
|
| 144 |
+
allow_origins=os.getenv("CORS_ORIGINS", "*").split(","),
|
| 145 |
+
allow_credentials=True,
|
| 146 |
+
allow_methods=["*"],
|
| 147 |
+
allow_headers=["*"],
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@app.get("/")
|
| 152 |
+
async def root():
|
| 153 |
+
"""Root endpoint with API information."""
|
| 154 |
+
return {
|
| 155 |
+
"name": "Smart Escalation API",
|
| 156 |
+
"version": "1.0.0",
|
| 157 |
+
"status": "operational",
|
| 158 |
+
"endpoints": {
|
| 159 |
+
"ask": "/ask (POST)"
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@app.get("/health")
|
| 165 |
+
async def health_check():
|
| 166 |
+
"""Health check endpoint."""
|
| 167 |
+
if rag_system is None or escalation_engine is None:
|
| 168 |
+
raise HTTPException(
|
| 169 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 170 |
+
detail="System not initialized"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
stats = rag_system.get_stats()
|
| 174 |
+
return {
|
| 175 |
+
"status": "healthy",
|
| 176 |
+
"rag_system": {
|
| 177 |
+
"total_chunks": stats["total_chunks"],
|
| 178 |
+
"total_articles": stats["total_articles"]
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@app.post("/ask", response_model=QuestionResponse)
|
| 184 |
+
async def ask_question(request: QuestionRequest) -> QuestionResponse:
|
| 185 |
+
"""
|
| 186 |
+
Process customer question and return answer or escalation.
|
| 187 |
+
|
| 188 |
+
This endpoint:
|
| 189 |
+
1. Retrieves relevant help article chunks using RAG
|
| 190 |
+
2. Evaluates retrieval quality
|
| 191 |
+
3. Generates answer using LLM or escalates to human agent
|
| 192 |
+
4. Returns structured response with confidence explanation
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
request: QuestionRequest with customer question
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
QuestionResponse with answer/escalation and confidence explanation
|
| 199 |
+
|
| 200 |
+
Raises:
|
| 201 |
+
HTTPException: 400 for invalid requests, 500 for server errors
|
| 202 |
+
"""
|
| 203 |
+
# Validate system is initialized
|
| 204 |
+
if rag_system is None or escalation_engine is None:
|
| 205 |
+
raise HTTPException(
|
| 206 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 207 |
+
detail="System not initialized. Please try again later."
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
try:
|
| 211 |
+
# Get top_k from app state
|
| 212 |
+
top_k = app.state.top_k_chunks
|
| 213 |
+
|
| 214 |
+
# Step 1: Retrieve relevant chunks
|
| 215 |
+
retrieved_chunks = rag_system.retrieve(
|
| 216 |
+
question=request.question,
|
| 217 |
+
top_k=top_k
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Step 2: Process question through escalation engine
|
| 221 |
+
decision = escalation_engine.process_question(
|
| 222 |
+
question=request.question,
|
| 223 |
+
retrieved_chunks=retrieved_chunks
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Step 3: Build and return response
|
| 227 |
+
return QuestionResponse(
|
| 228 |
+
response_type=decision.action,
|
| 229 |
+
message=decision.message,
|
| 230 |
+
confidence_explanation=decision.confidence_explanation,
|
| 231 |
+
sources=decision.sources
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
except ValueError as e:
|
| 235 |
+
# Handle validation errors
|
| 236 |
+
raise HTTPException(
|
| 237 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 238 |
+
detail=f"Invalid request: {str(e)}"
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
# Handle unexpected errors
|
| 243 |
+
print(f"Error processing question: {str(e)}")
|
| 244 |
+
raise HTTPException(
|
| 245 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 246 |
+
detail="An error occurred while processing your question. Please try again."
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# Error handlers
|
| 251 |
+
@app.exception_handler(404)
|
| 252 |
+
async def not_found_handler(request, exc):
|
| 253 |
+
"""Handle 404 errors."""
|
| 254 |
+
return {
|
| 255 |
+
"error": "Not Found",
|
| 256 |
+
"message": "The requested endpoint does not exist",
|
| 257 |
+
"available_endpoints": ["/", "/health", "/ask"]
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@app.exception_handler(500)
|
| 262 |
+
async def internal_error_handler(request, exc):
|
| 263 |
+
"""Handle 500 errors."""
|
| 264 |
+
return {
|
| 265 |
+
"error": "Internal Server Error",
|
| 266 |
+
"message": "An unexpected error occurred. Please try again later."
|
| 267 |
+
}
|
src/rag.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RAG (Retrieval-Augmented Generation) system for the Smart Escalation API.
|
| 3 |
+
|
| 4 |
+
This module provides functionality to load help articles, generate embeddings,
|
| 5 |
+
build a FAISS vector store, and perform similarity search for customer questions.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import List, Dict, Any, Optional
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
import os
|
| 11 |
+
import numpy as np
|
| 12 |
+
from sentence_transformers import SentenceTransformer
|
| 13 |
+
import faiss
|
| 14 |
+
|
| 15 |
+
from src.chunking import chunk_text
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class RetrievedChunk:
|
| 20 |
+
"""Represents a chunk retrieved from the vector store."""
|
| 21 |
+
content: str
|
| 22 |
+
score: float # Cosine similarity score
|
| 23 |
+
source: str # Help article filename
|
| 24 |
+
chunk_id: int
|
| 25 |
+
position: int
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class RAGSystem:
|
| 29 |
+
"""
|
| 30 |
+
RAG system that loads help articles, generates embeddings, and performs similarity search.
|
| 31 |
+
|
| 32 |
+
The system uses sentence-transformers for embeddings and FAISS for efficient
|
| 33 |
+
similarity search over article chunks.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
articles_dir: str = "data/articles",
|
| 39 |
+
embedding_model: str = "all-MiniLM-L6-v2",
|
| 40 |
+
chunk_size: int = 500,
|
| 41 |
+
chunk_overlap: int = 50
|
| 42 |
+
):
|
| 43 |
+
"""
|
| 44 |
+
Initialize the RAG system.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
articles_dir: Directory containing help article markdown files
|
| 48 |
+
embedding_model: Name of the sentence-transformers model to use
|
| 49 |
+
chunk_size: Maximum size of each chunk in characters
|
| 50 |
+
chunk_overlap: Number of characters to overlap between chunks
|
| 51 |
+
"""
|
| 52 |
+
self.articles_dir = articles_dir
|
| 53 |
+
self.chunk_size = chunk_size
|
| 54 |
+
self.chunk_overlap = chunk_overlap
|
| 55 |
+
|
| 56 |
+
# Initialize embedding model
|
| 57 |
+
print(f"Loading embedding model: {embedding_model}...")
|
| 58 |
+
self.embedding_model = SentenceTransformer(embedding_model)
|
| 59 |
+
self.embedding_dim = self.embedding_model.get_embedding_dimension()
|
| 60 |
+
|
| 61 |
+
# Storage for chunks and metadata
|
| 62 |
+
self.chunks: List[Dict[str, Any]] = []
|
| 63 |
+
self.index: Optional[faiss.Index] = None
|
| 64 |
+
|
| 65 |
+
# Load articles and build index
|
| 66 |
+
self._load_articles()
|
| 67 |
+
self._build_index()
|
| 68 |
+
|
| 69 |
+
print(f"RAG system initialized with {len(self.chunks)} chunks")
|
| 70 |
+
|
| 71 |
+
def _load_articles(self) -> None:
|
| 72 |
+
"""
|
| 73 |
+
Load all markdown files from the articles directory and chunk them.
|
| 74 |
+
|
| 75 |
+
Reads all .md files from the articles directory, chunks each article,
|
| 76 |
+
and stores the chunks with metadata.
|
| 77 |
+
"""
|
| 78 |
+
if not os.path.exists(self.articles_dir):
|
| 79 |
+
raise FileNotFoundError(f"Articles directory not found: {self.articles_dir}")
|
| 80 |
+
|
| 81 |
+
article_files = [
|
| 82 |
+
f for f in os.listdir(self.articles_dir)
|
| 83 |
+
if f.endswith('.md') and f != '.gitkeep'
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
if not article_files:
|
| 87 |
+
raise ValueError(f"No markdown files found in {self.articles_dir}")
|
| 88 |
+
|
| 89 |
+
print(f"Loading {len(article_files)} articles from {self.articles_dir}...")
|
| 90 |
+
|
| 91 |
+
for filename in sorted(article_files):
|
| 92 |
+
filepath = os.path.join(self.articles_dir, filename)
|
| 93 |
+
|
| 94 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 95 |
+
content = f.read()
|
| 96 |
+
|
| 97 |
+
# Chunk the article
|
| 98 |
+
article_chunks = chunk_text(
|
| 99 |
+
text=content,
|
| 100 |
+
source_article=filename,
|
| 101 |
+
chunk_size=self.chunk_size,
|
| 102 |
+
chunk_overlap=self.chunk_overlap
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
self.chunks.extend(article_chunks)
|
| 106 |
+
print(f" Loaded {filename}: {len(article_chunks)} chunks")
|
| 107 |
+
|
| 108 |
+
def _build_index(self) -> None:
|
| 109 |
+
"""
|
| 110 |
+
Generate embeddings for all chunks and build FAISS index.
|
| 111 |
+
|
| 112 |
+
Creates embeddings for each chunk using the sentence-transformers model
|
| 113 |
+
and builds an in-memory FAISS index for efficient similarity search.
|
| 114 |
+
"""
|
| 115 |
+
if not self.chunks:
|
| 116 |
+
raise ValueError("No chunks available to build index")
|
| 117 |
+
|
| 118 |
+
print("Generating embeddings for all chunks...")
|
| 119 |
+
|
| 120 |
+
# Extract chunk contents for embedding
|
| 121 |
+
chunk_texts = [chunk['content'] for chunk in self.chunks]
|
| 122 |
+
|
| 123 |
+
# Generate embeddings in batch
|
| 124 |
+
embeddings = self.embedding_model.encode(
|
| 125 |
+
chunk_texts,
|
| 126 |
+
show_progress_bar=True,
|
| 127 |
+
convert_to_numpy=True
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Normalize embeddings for cosine similarity
|
| 131 |
+
# FAISS inner product with normalized vectors = cosine similarity
|
| 132 |
+
faiss.normalize_L2(embeddings)
|
| 133 |
+
|
| 134 |
+
# Build FAISS index (using IndexFlatIP for inner product / cosine similarity)
|
| 135 |
+
self.index = faiss.IndexFlatIP(self.embedding_dim)
|
| 136 |
+
self.index.add(embeddings.astype('float32'))
|
| 137 |
+
|
| 138 |
+
print(f"FAISS index built with {self.index.ntotal} vectors")
|
| 139 |
+
|
| 140 |
+
def retrieve(self, question: str, top_k: int = 3) -> List[RetrievedChunk]:
|
| 141 |
+
"""
|
| 142 |
+
Retrieve the most relevant chunks for a given question.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
question: The customer question to search for
|
| 146 |
+
top_k: Number of top chunks to retrieve (default: 3)
|
| 147 |
+
|
| 148 |
+
Returns:
|
| 149 |
+
List of RetrievedChunk objects ordered by descending relevance score
|
| 150 |
+
|
| 151 |
+
Example:
|
| 152 |
+
>>> rag = RAGSystem()
|
| 153 |
+
>>> chunks = rag.retrieve("How do I reset my password?", top_k=3)
|
| 154 |
+
>>> chunks[0].content
|
| 155 |
+
'To reset your password...'
|
| 156 |
+
>>> chunks[0].score
|
| 157 |
+
0.85
|
| 158 |
+
"""
|
| 159 |
+
if self.index is None:
|
| 160 |
+
raise RuntimeError("Index not built. Call _build_index() first.")
|
| 161 |
+
|
| 162 |
+
if not question or not question.strip():
|
| 163 |
+
return []
|
| 164 |
+
|
| 165 |
+
# Limit top_k to available chunks
|
| 166 |
+
actual_k = min(top_k, len(self.chunks))
|
| 167 |
+
|
| 168 |
+
if actual_k == 0:
|
| 169 |
+
return []
|
| 170 |
+
|
| 171 |
+
# Generate embedding for the question
|
| 172 |
+
question_embedding = self.embedding_model.encode(
|
| 173 |
+
[question],
|
| 174 |
+
convert_to_numpy=True
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# Normalize for cosine similarity
|
| 178 |
+
faiss.normalize_L2(question_embedding)
|
| 179 |
+
|
| 180 |
+
# Search the index
|
| 181 |
+
scores, indices = self.index.search(
|
| 182 |
+
question_embedding.astype('float32'),
|
| 183 |
+
actual_k
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Build result list
|
| 187 |
+
results = []
|
| 188 |
+
for score, idx in zip(scores[0], indices[0]):
|
| 189 |
+
if idx < 0 or idx >= len(self.chunks):
|
| 190 |
+
continue
|
| 191 |
+
|
| 192 |
+
chunk = self.chunks[idx]
|
| 193 |
+
results.append(RetrievedChunk(
|
| 194 |
+
content=chunk['content'],
|
| 195 |
+
score=float(score),
|
| 196 |
+
source=chunk['source'],
|
| 197 |
+
chunk_id=chunk['chunk_id'],
|
| 198 |
+
position=chunk['position']
|
| 199 |
+
))
|
| 200 |
+
|
| 201 |
+
return results
|
| 202 |
+
|
| 203 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 204 |
+
"""
|
| 205 |
+
Get statistics about the RAG system.
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
Dictionary with system statistics including number of chunks,
|
| 209 |
+
articles, and index information
|
| 210 |
+
"""
|
| 211 |
+
sources = set(chunk['source'] for chunk in self.chunks)
|
| 212 |
+
|
| 213 |
+
return {
|
| 214 |
+
'total_chunks': len(self.chunks),
|
| 215 |
+
'total_articles': len(sources),
|
| 216 |
+
'articles': sorted(sources),
|
| 217 |
+
'embedding_dim': self.embedding_dim,
|
| 218 |
+
'index_size': self.index.ntotal if self.index else 0
|
| 219 |
+
}
|
test_api_manual.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manual test script for debugging API issues."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
from src.rag import RAGSystem
|
| 6 |
+
from src.llm_client import LLMClient
|
| 7 |
+
from src.escalation import EscalationEngine
|
| 8 |
+
|
| 9 |
+
# Load environment
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
print("Testing API components...")
|
| 13 |
+
print(f"API Key present: {bool(os.getenv('GOOGLE_API_KEY'))}")
|
| 14 |
+
print(f"API Key value: {os.getenv('GOOGLE_API_KEY')[:20]}..." if os.getenv('GOOGLE_API_KEY') else "None")
|
| 15 |
+
|
| 16 |
+
# Initialize RAG
|
| 17 |
+
print("\n1. Initializing RAG system...")
|
| 18 |
+
rag = RAGSystem("data/articles")
|
| 19 |
+
print(f" ✓ Loaded {rag.get_stats()['total_chunks']} chunks")
|
| 20 |
+
|
| 21 |
+
# Test retrieval
|
| 22 |
+
print("\n2. Testing retrieval...")
|
| 23 |
+
question = "What subscription plans are available?"
|
| 24 |
+
chunks = rag.retrieve(question, top_k=3)
|
| 25 |
+
print(f" ✓ Retrieved {len(chunks)} chunks")
|
| 26 |
+
for i, chunk in enumerate(chunks):
|
| 27 |
+
print(f" Chunk {i+1}: score={chunk.score:.3f}, source={chunk.source}")
|
| 28 |
+
print(f" Content: {chunk.content[:100]}...")
|
| 29 |
+
|
| 30 |
+
# Test LLM
|
| 31 |
+
print("\n3. Testing LLM client...")
|
| 32 |
+
try:
|
| 33 |
+
llm = LLMClient(model_name="gemini-2.5-flash")
|
| 34 |
+
print(" ✓ LLM client initialized")
|
| 35 |
+
|
| 36 |
+
# Try to generate answer
|
| 37 |
+
print("\n4. Generating answer...")
|
| 38 |
+
chunk_contents = [c.content for c in chunks]
|
| 39 |
+
response = llm.generate_answer(question, chunk_contents)
|
| 40 |
+
print(f" ✓ Answer generated")
|
| 41 |
+
print(f" Uncertain: {response.uncertain}")
|
| 42 |
+
print(f" Answer: {response.answer[:200]}...")
|
| 43 |
+
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f" ✗ Error: {type(e).__name__}: {str(e)}")
|
| 46 |
+
import traceback
|
| 47 |
+
traceback.print_exc()
|
test_rag_checkpoint.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Checkpoint test script for RAG system.
|
| 3 |
+
|
| 4 |
+
This script tests that the RAG system:
|
| 5 |
+
1. Loads all help articles correctly
|
| 6 |
+
2. Generates embeddings and builds FAISS index
|
| 7 |
+
3. Retrieves relevant chunks for sample questions
|
| 8 |
+
4. Returns results with proper scores and metadata
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from src.rag import RAGSystem
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def print_separator():
|
| 15 |
+
print("\n" + "=" * 80 + "\n")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_rag_initialization():
|
| 19 |
+
"""Test that RAG system initializes and loads articles."""
|
| 20 |
+
print("TEST 1: RAG System Initialization")
|
| 21 |
+
print("-" * 80)
|
| 22 |
+
|
| 23 |
+
rag = RAGSystem(
|
| 24 |
+
articles_dir="data/articles",
|
| 25 |
+
embedding_model="all-MiniLM-L6-v2",
|
| 26 |
+
chunk_size=500,
|
| 27 |
+
chunk_overlap=50
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
stats = rag.get_stats()
|
| 31 |
+
print(f"✓ Total chunks: {stats['total_chunks']}")
|
| 32 |
+
print(f"✓ Total articles: {stats['total_articles']}")
|
| 33 |
+
print(f"✓ Articles loaded: {', '.join(stats['articles'])}")
|
| 34 |
+
print(f"✓ Embedding dimension: {stats['embedding_dim']}")
|
| 35 |
+
print(f"✓ Index size: {stats['index_size']}")
|
| 36 |
+
|
| 37 |
+
assert stats['total_chunks'] > 0, "No chunks loaded"
|
| 38 |
+
assert stats['total_articles'] == 5, f"Expected 5 articles, got {stats['total_articles']}"
|
| 39 |
+
assert stats['index_size'] == stats['total_chunks'], "Index size mismatch"
|
| 40 |
+
|
| 41 |
+
print("\n✓ RAG system initialized successfully!")
|
| 42 |
+
return rag
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_sample_questions(rag: RAGSystem):
|
| 46 |
+
"""Test retrieval with various sample questions."""
|
| 47 |
+
print_separator()
|
| 48 |
+
print("TEST 2: Sample Question Retrieval")
|
| 49 |
+
print("-" * 80)
|
| 50 |
+
|
| 51 |
+
# Define test questions covering different topics
|
| 52 |
+
test_questions = [
|
| 53 |
+
{
|
| 54 |
+
"question": "How do I add a team member to my project?",
|
| 55 |
+
"expected_topic": "permissions",
|
| 56 |
+
"description": "Team management question"
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"question": "What are the subscription plans available?",
|
| 60 |
+
"expected_topic": "billing",
|
| 61 |
+
"description": "Billing question"
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"question": "How do I integrate TaskFlow with Slack?",
|
| 65 |
+
"expected_topic": "integrations",
|
| 66 |
+
"description": "Integration question"
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"question": "I can't log in to my account",
|
| 70 |
+
"expected_topic": "troubleshooting",
|
| 71 |
+
"description": "Troubleshooting question"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"question": "How do I create my first project?",
|
| 75 |
+
"expected_topic": "getting-started",
|
| 76 |
+
"description": "Getting started question"
|
| 77 |
+
}
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
for i, test_case in enumerate(test_questions, 1):
|
| 81 |
+
question = test_case["question"]
|
| 82 |
+
expected_topic = test_case["expected_topic"]
|
| 83 |
+
description = test_case["description"]
|
| 84 |
+
|
| 85 |
+
print(f"\nQuestion {i}: {description}")
|
| 86 |
+
print(f"Q: {question}")
|
| 87 |
+
print()
|
| 88 |
+
|
| 89 |
+
# Retrieve top 3 chunks
|
| 90 |
+
chunks = rag.retrieve(question, top_k=3)
|
| 91 |
+
|
| 92 |
+
assert len(chunks) > 0, f"No chunks retrieved for: {question}"
|
| 93 |
+
assert len(chunks) <= 3, f"Too many chunks returned: {len(chunks)}"
|
| 94 |
+
|
| 95 |
+
# Display results
|
| 96 |
+
for j, chunk in enumerate(chunks, 1):
|
| 97 |
+
print(f" Result {j}:")
|
| 98 |
+
print(f" Source: {chunk.source}")
|
| 99 |
+
print(f" Score: {chunk.score:.4f}")
|
| 100 |
+
print(f" Content preview: {chunk.content[:100]}...")
|
| 101 |
+
print()
|
| 102 |
+
|
| 103 |
+
# Verify scores are in descending order
|
| 104 |
+
scores = [chunk.score for chunk in chunks]
|
| 105 |
+
assert scores == sorted(scores, reverse=True), "Scores not in descending order"
|
| 106 |
+
|
| 107 |
+
# Check if expected topic appears in top results
|
| 108 |
+
sources = [chunk.source for chunk in chunks]
|
| 109 |
+
topic_found = any(expected_topic in source.lower() for source in sources)
|
| 110 |
+
|
| 111 |
+
if topic_found:
|
| 112 |
+
print(f" ✓ Expected topic '{expected_topic}' found in results")
|
| 113 |
+
else:
|
| 114 |
+
print(f" ⚠ Expected topic '{expected_topic}' not in top results")
|
| 115 |
+
print(f" (This may be okay if the question is ambiguous)")
|
| 116 |
+
|
| 117 |
+
print("\n✓ All sample questions processed successfully!")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_edge_cases(rag: RAGSystem):
|
| 121 |
+
"""Test edge cases and boundary conditions."""
|
| 122 |
+
print_separator()
|
| 123 |
+
print("TEST 3: Edge Cases")
|
| 124 |
+
print("-" * 80)
|
| 125 |
+
|
| 126 |
+
# Test 1: Empty question
|
| 127 |
+
print("\n1. Empty question:")
|
| 128 |
+
chunks = rag.retrieve("", top_k=3)
|
| 129 |
+
assert len(chunks) == 0, "Empty question should return no results"
|
| 130 |
+
print(" ✓ Empty question returns no results")
|
| 131 |
+
|
| 132 |
+
# Test 2: Very short question
|
| 133 |
+
print("\n2. Very short question:")
|
| 134 |
+
chunks = rag.retrieve("help", top_k=3)
|
| 135 |
+
assert len(chunks) > 0, "Short question should return results"
|
| 136 |
+
print(f" ✓ Short question returns {len(chunks)} results")
|
| 137 |
+
|
| 138 |
+
# Test 3: Question with special characters
|
| 139 |
+
print("\n3. Question with special characters:")
|
| 140 |
+
chunks = rag.retrieve("How do I use @mentions & #tags?", top_k=3)
|
| 141 |
+
assert len(chunks) > 0, "Question with special chars should return results"
|
| 142 |
+
print(f" ✓ Special characters handled: {len(chunks)} results")
|
| 143 |
+
|
| 144 |
+
# Test 4: Different top_k values
|
| 145 |
+
print("\n4. Different top_k values:")
|
| 146 |
+
for k in [1, 3, 5, 10]:
|
| 147 |
+
chunks = rag.retrieve("How do I get started?", top_k=k)
|
| 148 |
+
expected_k = min(k, rag.get_stats()['total_chunks'])
|
| 149 |
+
assert len(chunks) <= expected_k, f"top_k={k} returned too many results"
|
| 150 |
+
print(f" ✓ top_k={k}: returned {len(chunks)} chunks (max: {expected_k})")
|
| 151 |
+
|
| 152 |
+
# Test 5: Out-of-scope question
|
| 153 |
+
print("\n5. Out-of-scope question:")
|
| 154 |
+
chunks = rag.retrieve("What is the weather today?", top_k=3)
|
| 155 |
+
print(f" ✓ Out-of-scope question returns {len(chunks)} results")
|
| 156 |
+
if chunks:
|
| 157 |
+
print(f" Best score: {chunks[0].score:.4f}")
|
| 158 |
+
print(f" (Low score expected for out-of-scope questions)")
|
| 159 |
+
|
| 160 |
+
print("\n✓ All edge cases handled correctly!")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def main():
|
| 164 |
+
"""Run all checkpoint tests."""
|
| 165 |
+
print("=" * 80)
|
| 166 |
+
print("RAG SYSTEM CHECKPOINT TEST")
|
| 167 |
+
print("=" * 80)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
# Test 1: Initialize RAG system
|
| 171 |
+
rag = test_rag_initialization()
|
| 172 |
+
|
| 173 |
+
# Test 2: Sample questions
|
| 174 |
+
test_sample_questions(rag)
|
| 175 |
+
|
| 176 |
+
# Test 3: Edge cases
|
| 177 |
+
test_edge_cases(rag)
|
| 178 |
+
|
| 179 |
+
# Final summary
|
| 180 |
+
print_separator()
|
| 181 |
+
print("✓ ALL TESTS PASSED!")
|
| 182 |
+
print()
|
| 183 |
+
print("Summary:")
|
| 184 |
+
print(" - RAG system loads articles and builds index correctly")
|
| 185 |
+
print(" - Retrieval returns relevant chunks with proper scores")
|
| 186 |
+
print(" - Results are ordered by descending relevance")
|
| 187 |
+
print(" - Edge cases are handled gracefully")
|
| 188 |
+
print()
|
| 189 |
+
print("The RAG system is ready for Phase 2 (API and Escalation Logic)!")
|
| 190 |
+
print("=" * 80)
|
| 191 |
+
|
| 192 |
+
except Exception as e:
|
| 193 |
+
print(f"\n✗ TEST FAILED: {e}")
|
| 194 |
+
import traceback
|
| 195 |
+
traceback.print_exc()
|
| 196 |
+
return 1
|
| 197 |
+
|
| 198 |
+
return 0
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
exit(main())
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Test suite for Smart Escalation API."""
|
tests/integration/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests for API endpoints and workflows."""
|
tests/property/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Property-based tests using Hypothesis."""
|
tests/property/test_chunking_properties.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Property-based tests for chunking semantic coherence.
|
| 3 |
+
|
| 4 |
+
This module validates Property 3: Chunking Semantic Coherence
|
| 5 |
+
Validates: Requirements 2.6
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from hypothesis import given, strategies as st, assume
|
| 10 |
+
from src.chunking import chunk_text
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@given(st.text(min_size=1, max_size=5000))
|
| 14 |
+
def test_property_chunking_no_word_splits(article_text):
|
| 15 |
+
"""
|
| 16 |
+
Property 3: Chunking Semantic Coherence - No Word Splits
|
| 17 |
+
|
| 18 |
+
For any article text, chunks should not split words mid-character.
|
| 19 |
+
Chunks should end at word boundaries (whitespace or punctuation).
|
| 20 |
+
|
| 21 |
+
Feature: smart-escalation-api
|
| 22 |
+
Property: Chunking preserves semantic coherence
|
| 23 |
+
Validates: Requirements 2.6
|
| 24 |
+
"""
|
| 25 |
+
# Skip empty or whitespace-only text
|
| 26 |
+
assume(article_text.strip())
|
| 27 |
+
|
| 28 |
+
chunks = chunk_text(article_text, source_article="test.md", chunk_size=500, chunk_overlap=50)
|
| 29 |
+
|
| 30 |
+
# If no chunks were created, that's acceptable for certain inputs
|
| 31 |
+
if not chunks:
|
| 32 |
+
return
|
| 33 |
+
|
| 34 |
+
for chunk in chunks:
|
| 35 |
+
content = chunk['content']
|
| 36 |
+
|
| 37 |
+
# Skip empty chunks (shouldn't happen, but defensive)
|
| 38 |
+
if not content:
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
# Check that chunk doesn't end with a partial word
|
| 42 |
+
# A properly chunked text should end with:
|
| 43 |
+
# - Whitespace
|
| 44 |
+
# - Punctuation
|
| 45 |
+
# - End of original text
|
| 46 |
+
|
| 47 |
+
# If the chunk is shorter than chunk_size, it's likely the last chunk
|
| 48 |
+
# and can end anywhere in the original text
|
| 49 |
+
if len(content) < 500:
|
| 50 |
+
continue
|
| 51 |
+
|
| 52 |
+
# For full-size chunks, verify they end at appropriate boundaries
|
| 53 |
+
last_char = content[-1]
|
| 54 |
+
|
| 55 |
+
# Acceptable endings: whitespace, punctuation, or alphanumeric (end of sentence)
|
| 56 |
+
# We should NOT end in the middle of a word (non-whitespace followed by more text)
|
| 57 |
+
assert (
|
| 58 |
+
last_char.isspace() or
|
| 59 |
+
last_char in ".,!?;:)]}" or
|
| 60 |
+
content.rstrip() == content # Already trimmed, so ends cleanly
|
| 61 |
+
), f"Chunk ends with potentially split word: ...{content[-20:]!r}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@given(
|
| 65 |
+
st.text(min_size=100, max_size=5000),
|
| 66 |
+
st.integers(min_value=50, max_value=500),
|
| 67 |
+
st.integers(min_value=10, max_value=100)
|
| 68 |
+
)
|
| 69 |
+
def test_property_chunking_overlap_maintained(article_text, chunk_size, chunk_overlap):
|
| 70 |
+
"""
|
| 71 |
+
Property 3: Chunking Semantic Coherence - Overlap Maintained
|
| 72 |
+
|
| 73 |
+
For any article text, consecutive chunks should not have large gaps between them.
|
| 74 |
+
Due to sentence/word boundary preservation and whitespace stripping, small gaps
|
| 75 |
+
may occur, but they should be minimal (within a few characters).
|
| 76 |
+
|
| 77 |
+
Feature: smart-escalation-api
|
| 78 |
+
Property: Chunking maintains reasonable overlap/adjacency
|
| 79 |
+
Validates: Requirements 2.6
|
| 80 |
+
"""
|
| 81 |
+
# Ensure overlap is less than chunk_size
|
| 82 |
+
assume(chunk_overlap < chunk_size)
|
| 83 |
+
# Skip empty or whitespace-only text
|
| 84 |
+
assume(article_text.strip())
|
| 85 |
+
# Ensure text is long enough to create multiple chunks
|
| 86 |
+
assume(len(article_text) > chunk_size)
|
| 87 |
+
|
| 88 |
+
chunks = chunk_text(
|
| 89 |
+
article_text,
|
| 90 |
+
source_article="test.md",
|
| 91 |
+
chunk_size=chunk_size,
|
| 92 |
+
chunk_overlap=chunk_overlap
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Need at least 2 chunks to test overlap
|
| 96 |
+
if len(chunks) < 2:
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
# Check that chunks progress through the text reasonably
|
| 100 |
+
for i in range(len(chunks) - 1):
|
| 101 |
+
chunk1 = chunks[i]
|
| 102 |
+
chunk2 = chunks[i + 1]
|
| 103 |
+
|
| 104 |
+
pos1 = chunk1['position']
|
| 105 |
+
pos2 = chunk2['position']
|
| 106 |
+
|
| 107 |
+
# Chunks should progress forward through the text
|
| 108 |
+
assert pos2 > pos1, (
|
| 109 |
+
f"Chunk {i+1} position {pos2} should be after chunk {i} position {pos1}"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# The gap between chunk starts should be reasonable
|
| 113 |
+
# It should be roughly (chunk_size - chunk_overlap), but can vary due to
|
| 114 |
+
# sentence/word boundaries and whitespace stripping
|
| 115 |
+
gap = pos2 - pos1
|
| 116 |
+
|
| 117 |
+
# Gap should not be larger than chunk_size (we're making progress)
|
| 118 |
+
assert gap <= chunk_size + 10, (
|
| 119 |
+
f"Gap {gap} between chunk {i} and {i+1} exceeds chunk_size {chunk_size}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Gap should not be too small (we're not stuck)
|
| 123 |
+
assert gap >= 1, (
|
| 124 |
+
f"Gap {gap} between chunk {i} and {i+1} is too small, chunks may overlap completely"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@given(st.text(min_size=1, max_size=5000))
|
| 129 |
+
def test_property_chunking_preserves_content(article_text):
|
| 130 |
+
"""
|
| 131 |
+
Property 3: Chunking Semantic Coherence - Content Preservation
|
| 132 |
+
|
| 133 |
+
For any article text, all content from the original text should appear
|
| 134 |
+
in at least one chunk (no content loss).
|
| 135 |
+
|
| 136 |
+
Feature: smart-escalation-api
|
| 137 |
+
Property: Chunking preserves all content
|
| 138 |
+
Validates: Requirements 2.6
|
| 139 |
+
"""
|
| 140 |
+
# Skip empty or whitespace-only text
|
| 141 |
+
assume(article_text.strip())
|
| 142 |
+
|
| 143 |
+
chunks = chunk_text(article_text, source_article="test.md", chunk_size=500, chunk_overlap=50)
|
| 144 |
+
|
| 145 |
+
# If no chunks were created, the text must have been empty after stripping
|
| 146 |
+
if not chunks:
|
| 147 |
+
assert not article_text.strip()
|
| 148 |
+
return
|
| 149 |
+
|
| 150 |
+
# Combine all chunk contents
|
| 151 |
+
all_chunk_content = ' '.join(chunk['content'] for chunk in chunks)
|
| 152 |
+
|
| 153 |
+
# Normalize whitespace for comparison
|
| 154 |
+
normalized_original = ' '.join(article_text.split())
|
| 155 |
+
normalized_chunks = ' '.join(all_chunk_content.split())
|
| 156 |
+
|
| 157 |
+
# All significant words from original should appear in chunks
|
| 158 |
+
original_words = set(normalized_original.split())
|
| 159 |
+
chunk_words = set(normalized_chunks.split())
|
| 160 |
+
|
| 161 |
+
# All original words should be present in the chunks
|
| 162 |
+
missing_words = original_words - chunk_words
|
| 163 |
+
assert not missing_words, (
|
| 164 |
+
f"Chunking lost {len(missing_words)} words from original text. "
|
| 165 |
+
f"Examples: {list(missing_words)[:5]}"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@given(st.text(min_size=1, max_size=5000))
|
| 170 |
+
def test_property_chunking_no_empty_chunks(article_text):
|
| 171 |
+
"""
|
| 172 |
+
Property 3: Chunking Semantic Coherence - No Empty Chunks
|
| 173 |
+
|
| 174 |
+
For any article text, no chunk should be empty or contain only whitespace.
|
| 175 |
+
|
| 176 |
+
Feature: smart-escalation-api
|
| 177 |
+
Property: Chunking produces non-empty chunks
|
| 178 |
+
Validates: Requirements 2.6
|
| 179 |
+
"""
|
| 180 |
+
# Skip empty or whitespace-only text
|
| 181 |
+
assume(article_text.strip())
|
| 182 |
+
|
| 183 |
+
chunks = chunk_text(article_text, source_article="test.md", chunk_size=500, chunk_overlap=50)
|
| 184 |
+
|
| 185 |
+
for i, chunk in enumerate(chunks):
|
| 186 |
+
content = chunk['content']
|
| 187 |
+
assert content, f"Chunk {i} is empty"
|
| 188 |
+
assert content.strip(), f"Chunk {i} contains only whitespace: {content!r}"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
@given(st.text(min_size=1, max_size=5000))
|
| 192 |
+
def test_property_chunking_metadata_correctness(article_text):
|
| 193 |
+
"""
|
| 194 |
+
Property 3: Chunking Semantic Coherence - Metadata Correctness
|
| 195 |
+
|
| 196 |
+
For any article text, chunk metadata (position, chunk_id, source) should be
|
| 197 |
+
correct and consistent.
|
| 198 |
+
|
| 199 |
+
Feature: smart-escalation-api
|
| 200 |
+
Property: Chunking metadata is accurate
|
| 201 |
+
Validates: Requirements 2.6
|
| 202 |
+
"""
|
| 203 |
+
# Skip empty or whitespace-only text
|
| 204 |
+
assume(article_text.strip())
|
| 205 |
+
|
| 206 |
+
source = "test_article.md"
|
| 207 |
+
chunks = chunk_text(article_text, source_article=source, chunk_size=500, chunk_overlap=50)
|
| 208 |
+
|
| 209 |
+
if not chunks:
|
| 210 |
+
return
|
| 211 |
+
|
| 212 |
+
for i, chunk in enumerate(chunks):
|
| 213 |
+
# Check chunk_id is sequential
|
| 214 |
+
assert chunk['chunk_id'] == i, (
|
| 215 |
+
f"Chunk {i} has incorrect chunk_id: {chunk['chunk_id']}"
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# Check source is preserved
|
| 219 |
+
assert chunk['source'] == source, (
|
| 220 |
+
f"Chunk {i} has incorrect source: {chunk['source']}"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Check position is non-negative
|
| 224 |
+
assert chunk['position'] >= 0, (
|
| 225 |
+
f"Chunk {i} has negative position: {chunk['position']}"
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Check position is within text bounds
|
| 229 |
+
assert chunk['position'] < len(article_text), (
|
| 230 |
+
f"Chunk {i} position {chunk['position']} exceeds text length {len(article_text)}"
|
| 231 |
+
)
|
tests/unit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for individual components."""
|
tests/unit/test_chunking.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Unit tests for the text chunking module.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from src.chunking import chunk_text, _find_sentence_boundary, _find_word_boundary
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TestChunkText:
|
| 10 |
+
"""Test cases for the chunk_text function."""
|
| 11 |
+
|
| 12 |
+
def test_empty_text_returns_empty_list(self):
|
| 13 |
+
"""Empty text should return no chunks."""
|
| 14 |
+
result = chunk_text("", "test.md")
|
| 15 |
+
assert result == []
|
| 16 |
+
|
| 17 |
+
def test_whitespace_only_returns_empty_list(self):
|
| 18 |
+
"""Whitespace-only text should return no chunks."""
|
| 19 |
+
result = chunk_text(" \n\t ", "test.md")
|
| 20 |
+
assert result == []
|
| 21 |
+
|
| 22 |
+
def test_short_text_returns_single_chunk(self):
|
| 23 |
+
"""Text shorter than chunk_size should return single chunk."""
|
| 24 |
+
text = "This is a short text."
|
| 25 |
+
result = chunk_text(text, "test.md", chunk_size=500)
|
| 26 |
+
|
| 27 |
+
assert len(result) == 1
|
| 28 |
+
assert result[0]['content'] == text
|
| 29 |
+
assert result[0]['source'] == "test.md"
|
| 30 |
+
assert result[0]['position'] == 0
|
| 31 |
+
assert result[0]['chunk_id'] == 0
|
| 32 |
+
|
| 33 |
+
def test_preserves_sentence_boundaries(self):
|
| 34 |
+
"""Chunks should end at sentence boundaries when possible."""
|
| 35 |
+
text = "First sentence. Second sentence. Third sentence. Fourth sentence."
|
| 36 |
+
result = chunk_text(text, "test.md", chunk_size=30, chunk_overlap=5)
|
| 37 |
+
|
| 38 |
+
# Each chunk should end with sentence-ending punctuation
|
| 39 |
+
for chunk in result[:-1]: # All but last chunk
|
| 40 |
+
content = chunk['content']
|
| 41 |
+
assert content[-1] in '.!?' or content.endswith('.')
|
| 42 |
+
|
| 43 |
+
def test_chunk_overlap_maintained(self):
|
| 44 |
+
"""Consecutive chunks should have specified overlap."""
|
| 45 |
+
text = "A" * 1000 # Long text without sentence boundaries
|
| 46 |
+
result = chunk_text(text, "test.md", chunk_size=100, chunk_overlap=20)
|
| 47 |
+
|
| 48 |
+
assert len(result) > 1
|
| 49 |
+
# Check that chunks are created (overlap logic is working)
|
| 50 |
+
for i in range(len(result) - 1):
|
| 51 |
+
# Verify position progression accounts for overlap
|
| 52 |
+
current_end = result[i]['position'] + len(result[i]['content'])
|
| 53 |
+
next_start = result[i + 1]['position']
|
| 54 |
+
# Next chunk should start before current chunk ends (overlap)
|
| 55 |
+
assert next_start < current_end
|
| 56 |
+
|
| 57 |
+
def test_no_word_splitting(self):
|
| 58 |
+
"""Chunks should not split words mid-character."""
|
| 59 |
+
text = "The quick brown fox jumps over the lazy dog. " * 20
|
| 60 |
+
result = chunk_text(text, "test.md", chunk_size=50, chunk_overlap=10)
|
| 61 |
+
|
| 62 |
+
for chunk in result:
|
| 63 |
+
content = chunk['content']
|
| 64 |
+
# Check that chunks don't end with partial words (unless at sentence end)
|
| 65 |
+
if len(content) > 0 and content[-1] not in '.!? ':
|
| 66 |
+
# If not ending with punctuation or space, should be end of text
|
| 67 |
+
assert chunk == result[-1]
|
| 68 |
+
|
| 69 |
+
def test_metadata_tracking(self):
|
| 70 |
+
"""Each chunk should have correct metadata."""
|
| 71 |
+
text = "First sentence. Second sentence. Third sentence."
|
| 72 |
+
result = chunk_text(text, "billing.md", chunk_size=25, chunk_overlap=5)
|
| 73 |
+
|
| 74 |
+
for i, chunk in enumerate(result):
|
| 75 |
+
assert 'content' in chunk
|
| 76 |
+
assert 'source' in chunk
|
| 77 |
+
assert 'position' in chunk
|
| 78 |
+
assert 'chunk_id' in chunk
|
| 79 |
+
assert chunk['source'] == "billing.md"
|
| 80 |
+
assert chunk['chunk_id'] == i
|
| 81 |
+
assert chunk['position'] >= 0
|
| 82 |
+
|
| 83 |
+
def test_default_parameters(self):
|
| 84 |
+
"""Test with default chunk_size and overlap."""
|
| 85 |
+
text = "A" * 1000
|
| 86 |
+
result = chunk_text(text, "test.md")
|
| 87 |
+
|
| 88 |
+
assert len(result) > 0
|
| 89 |
+
# First chunk should be around 500 chars (default chunk_size)
|
| 90 |
+
assert len(result[0]['content']) <= 500
|
| 91 |
+
|
| 92 |
+
def test_custom_chunk_size(self):
|
| 93 |
+
"""Test with custom chunk size."""
|
| 94 |
+
text = "This is a test. " * 50
|
| 95 |
+
result = chunk_text(text, "test.md", chunk_size=100, chunk_overlap=10)
|
| 96 |
+
|
| 97 |
+
for chunk in result[:-1]: # All but last
|
| 98 |
+
# Chunks should not exceed chunk_size significantly
|
| 99 |
+
assert len(chunk['content']) <= 100
|
| 100 |
+
|
| 101 |
+
def test_position_tracking(self):
|
| 102 |
+
"""Position should track location in original text."""
|
| 103 |
+
text = "First sentence. Second sentence. Third sentence."
|
| 104 |
+
result = chunk_text(text, "test.md", chunk_size=20, chunk_overlap=5)
|
| 105 |
+
|
| 106 |
+
# Positions should be non-decreasing
|
| 107 |
+
positions = [chunk['position'] for chunk in result]
|
| 108 |
+
assert positions == sorted(positions)
|
| 109 |
+
|
| 110 |
+
# First chunk should start at position 0
|
| 111 |
+
assert result[0]['position'] == 0
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class TestFindSentenceBoundary:
|
| 115 |
+
"""Test cases for _find_sentence_boundary helper function."""
|
| 116 |
+
|
| 117 |
+
def test_finds_period_boundary(self):
|
| 118 |
+
"""Should find sentence ending with period."""
|
| 119 |
+
text = "First sentence. Second"
|
| 120 |
+
pos = _find_sentence_boundary(text)
|
| 121 |
+
assert pos == 15 # Position after "sentence."
|
| 122 |
+
|
| 123 |
+
def test_finds_exclamation_boundary(self):
|
| 124 |
+
"""Should find sentence ending with exclamation mark."""
|
| 125 |
+
text = "Hello world! How are"
|
| 126 |
+
pos = _find_sentence_boundary(text)
|
| 127 |
+
assert pos == 12 # Position after "world!"
|
| 128 |
+
|
| 129 |
+
def test_finds_question_boundary(self):
|
| 130 |
+
"""Should find sentence ending with question mark."""
|
| 131 |
+
text = "What is this? Another"
|
| 132 |
+
pos = _find_sentence_boundary(text)
|
| 133 |
+
assert pos == 13 # Position after "this?"
|
| 134 |
+
|
| 135 |
+
def test_finds_last_boundary(self):
|
| 136 |
+
"""Should find the last sentence boundary."""
|
| 137 |
+
text = "First. Second. Third"
|
| 138 |
+
pos = _find_sentence_boundary(text)
|
| 139 |
+
assert pos == 14 # Position after "Second."
|
| 140 |
+
|
| 141 |
+
def test_no_boundary_returns_zero(self):
|
| 142 |
+
"""Should return 0 when no sentence boundary found."""
|
| 143 |
+
text = "No sentence ending here"
|
| 144 |
+
pos = _find_sentence_boundary(text)
|
| 145 |
+
assert pos == 0
|
| 146 |
+
|
| 147 |
+
def test_boundary_at_end(self):
|
| 148 |
+
"""Should handle sentence boundary at text end."""
|
| 149 |
+
text = "Complete sentence."
|
| 150 |
+
pos = _find_sentence_boundary(text)
|
| 151 |
+
assert pos == 18 # Position after "sentence."
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
class TestFindWordBoundary:
|
| 155 |
+
"""Test cases for _find_word_boundary helper function."""
|
| 156 |
+
|
| 157 |
+
def test_finds_last_space(self):
|
| 158 |
+
"""Should find the last whitespace."""
|
| 159 |
+
text = "Hello world test"
|
| 160 |
+
pos = _find_word_boundary(text)
|
| 161 |
+
assert pos == 12 # Position after "world "
|
| 162 |
+
|
| 163 |
+
def test_no_space_returns_zero(self):
|
| 164 |
+
"""Should return 0 when no whitespace found."""
|
| 165 |
+
text = "NoSpacesHere"
|
| 166 |
+
pos = _find_word_boundary(text)
|
| 167 |
+
assert pos == 0
|
| 168 |
+
|
| 169 |
+
def test_multiple_spaces(self):
|
| 170 |
+
"""Should find the last whitespace with multiple spaces."""
|
| 171 |
+
text = "One two three four"
|
| 172 |
+
pos = _find_word_boundary(text)
|
| 173 |
+
assert pos == 14 # Position after "three "
|
| 174 |
+
|
| 175 |
+
def test_trailing_space(self):
|
| 176 |
+
"""Should handle trailing whitespace."""
|
| 177 |
+
text = "Hello world "
|
| 178 |
+
pos = _find_word_boundary(text)
|
| 179 |
+
assert pos == 12 # Position after last space
|