Tripagra commited on
Commit
b33a861
·
0 Parent(s):

first commit

Browse files
.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