Mozzicstar commited on
Commit
18ab7fd
·
1 Parent(s): dd7f4c2

Deploy AI Tax Reform API

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ build-essential \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Copy requirements first for caching
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ # Copy application code
15
+ COPY . .
16
+
17
+ # Preload the sentence-transformers model during build
18
+ RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-mpnet-base-v2')"
19
+
20
+ # Expose port
21
+ EXPOSE 7860
22
+
23
+ # Run with gunicorn
24
+ CMD ["gunicorn", "--config", "gunicorn.conf.py", "app:app"]
app.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Tax Reform API - Backend Service
3
+
4
+ A comprehensive Flask API for Nigerian tax calculations and AI-powered Q&A
5
+ about Nigerian tax law based on the Nigeria Tax Act 2025.
6
+ """
7
+
8
+ from flask import Flask, request, jsonify, g
9
+ from flask_cors import CORS
10
+ from flask_limiter import Limiter
11
+ from flask_limiter.util import get_remote_address
12
+ from dotenv import load_dotenv
13
+ from pathlib import Path
14
+ import os
15
+ import time
16
+ import logging
17
+ import re
18
+ from functools import wraps
19
+ from typing import Any, Callable, Dict, Optional, Tuple
20
+
21
+ # Configure logging
22
+ logging.basicConfig(
23
+ level=logging.INFO,
24
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ load_dotenv()
29
+
30
+ # ============================================================================
31
+ # App Configuration
32
+ # ============================================================================
33
+
34
+ app = Flask(__name__)
35
+ app.config['JSON_SORT_KEYS'] = False
36
+ app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 # 1MB max request size
37
+
38
+ # CORS Configuration - Secure defaults
39
+ allowed_origins = os.getenv("CORS_ORIGINS", "").strip()
40
+ if allowed_origins:
41
+ origins = [o.strip() for o in allowed_origins.split(",") if o.strip()]
42
+ else:
43
+ origins = ["http://localhost:3000", "http://localhost:7860"]
44
+
45
+ CORS(app, origins=origins, supports_credentials=True)
46
+
47
+ # Rate Limiting
48
+ limiter = Limiter(
49
+ app=app,
50
+ key_func=get_remote_address,
51
+ default_limits=["200 per day", "50 per hour"],
52
+ storage_uri="memory://",
53
+ )
54
+
55
+ # ============================================================================
56
+ # Imports (after app initialization)
57
+ # ============================================================================
58
+
59
+ from src.tax_calculator import calculate_tax, get_tax_summary, TaxCalculationError
60
+ from scripts.query_qa import load_vectorstore, query
61
+ from scripts.qa_service import generate_answer, verify_answer
62
+ import threading
63
+
64
+ # ============================================================================
65
+ # Vectorstore Cache
66
+ # ============================================================================
67
+
68
+ _vectorstore_cache: Optional[Tuple[Any, Any]] = None
69
+ _vectorstore_loading = False
70
+ _vectorstore_lock = threading.Lock()
71
+
72
+
73
+ def preload_vectorstore():
74
+ """Preload vectorstore in background thread (embeddings use HF API, no local model)."""
75
+ global _vectorstore_cache, _vectorstore_loading
76
+ with _vectorstore_lock:
77
+ if _vectorstore_cache is None and not _vectorstore_loading:
78
+ _vectorstore_loading = True
79
+
80
+ if _vectorstore_loading and _vectorstore_cache is None:
81
+ try:
82
+ logger.info("Background loading vectorstore...")
83
+ _vectorstore_cache = load_vectorstore()
84
+ logger.info("Vectorstore preloaded successfully (using HF API for embeddings)")
85
+ except Exception as e:
86
+ logger.error(f"Background preload failed: {e}")
87
+ finally:
88
+ _vectorstore_loading = False
89
+
90
+
91
+ def get_vectorstore() -> Tuple[Any, Any]:
92
+ """Load and cache vectorstore with thread-safe initialization."""
93
+ global _vectorstore_cache
94
+ if _vectorstore_cache is None:
95
+ logger.info("Loading vectorstore...")
96
+ try:
97
+ _vectorstore_cache = load_vectorstore()
98
+ logger.info("Vectorstore loaded successfully")
99
+ except Exception as e:
100
+ logger.error(f"Failed to load vectorstore: {e}")
101
+ raise
102
+ return _vectorstore_cache
103
+
104
+
105
+ # ============================================================================
106
+ # Input Validation & Security
107
+ # ============================================================================
108
+
109
+ def sanitize_string(text: str, max_length: int = 2000) -> str:
110
+ """Sanitize user input string."""
111
+ if not isinstance(text, str):
112
+ return ""
113
+ # Remove null bytes and control characters (except newlines/tabs)
114
+ text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
115
+ # Limit length
116
+ return text[:max_length].strip()
117
+
118
+
119
+ def validate_numeric(value: Any, field_name: str, min_val: float = 0, max_val: float = 1e15) -> float:
120
+ """Validate and convert numeric input."""
121
+ if value is None:
122
+ raise ValueError(f"'{field_name}' is required")
123
+ try:
124
+ num = float(value)
125
+ if num < min_val or num > max_val:
126
+ raise ValueError(f"'{field_name}' must be between {min_val:,.0f} and {max_val:,.0f}")
127
+ return num
128
+ except (TypeError, ValueError):
129
+ raise ValueError(f"'{field_name}' must be a valid number")
130
+
131
+
132
+ def validate_positive_int(value: Any, field_name: str, min_val: int = 1, max_val: int = 20) -> int:
133
+ """Validate positive integer input."""
134
+ try:
135
+ num = int(value)
136
+ if num < min_val or num > max_val:
137
+ raise ValueError(f"'{field_name}' must be between {min_val} and {max_val}")
138
+ return num
139
+ except (TypeError, ValueError):
140
+ raise ValueError(f"'{field_name}' must be a valid integer")
141
+
142
+
143
+ # ============================================================================
144
+ # Error Handlers
145
+ # ============================================================================
146
+
147
+ class APIError(Exception):
148
+ """Custom API exception with status code."""
149
+ def __init__(self, message: str, status_code: int = 400, details: Optional[str] = None):
150
+ self.message = message
151
+ self.status_code = status_code
152
+ self.details = details
153
+ super().__init__(message)
154
+
155
+
156
+ @app.errorhandler(APIError)
157
+ def handle_api_error(error: APIError):
158
+ """Handle custom API errors."""
159
+ response = {"error": error.message}
160
+ if error.details and app.debug:
161
+ response["details"] = error.details
162
+ return jsonify(response), error.status_code
163
+
164
+
165
+ @app.errorhandler(429)
166
+ def handle_rate_limit(e):
167
+ """Handle rate limit exceeded."""
168
+ return jsonify({
169
+ "error": "Rate limit exceeded",
170
+ "message": "Too many requests. Please try again later."
171
+ }), 429
172
+
173
+
174
+ @app.errorhandler(413)
175
+ def handle_large_request(e):
176
+ """Handle request too large."""
177
+ return jsonify({
178
+ "error": "Request too large",
179
+ "message": "The request payload exceeds the maximum allowed size."
180
+ }), 413
181
+
182
+
183
+ @app.errorhandler(404)
184
+ def handle_not_found(e):
185
+ """Handle page not found errors."""
186
+ return jsonify({
187
+ "error": "Endpoint not found",
188
+ "message": "The requested endpoint does not exist. Check the API documentation at the root endpoint.",
189
+ "available_endpoints": ["/health", "/calculate", "/retrieve", "/qa", "/aqa"]
190
+ }), 404
191
+
192
+
193
+ @app.errorhandler(500)
194
+ def handle_internal_error(e):
195
+ """Handle internal server errors."""
196
+ logger.exception("Internal server error")
197
+ return jsonify({
198
+ "error": "Internal server error",
199
+ "message": "An unexpected error occurred. Please try again later."
200
+ }), 500
201
+
202
+
203
+ @app.errorhandler(Exception)
204
+ def handle_unexpected_error(e):
205
+ """Catch-all handler for any unhandled exceptions."""
206
+ logger.exception(f"Unhandled exception: {e}")
207
+ return jsonify({
208
+ "error": "An unexpected error occurred",
209
+ "message": str(e) if app.debug else "Please try again later."
210
+ }), 500
211
+
212
+
213
+ # ============================================================================
214
+ # Request Logging Middleware
215
+ # ============================================================================
216
+
217
+ @app.before_request
218
+ def before_request():
219
+ """Log request and set start time."""
220
+ g.start_time = time.time()
221
+
222
+
223
+ @app.after_request
224
+ def after_request(response):
225
+ """Log response time."""
226
+ if hasattr(g, 'start_time'):
227
+ elapsed = (time.time() - g.start_time) * 1000
228
+ logger.info(f"{request.method} {request.path} - {response.status_code} - {elapsed:.2f}ms")
229
+ return response
230
+
231
+
232
+ # ============================================================================
233
+ # API Routes
234
+ # ============================================================================
235
+
236
+ @app.route("/health", methods=["GET"])
237
+ @limiter.exempt
238
+ def health():
239
+ """Health check endpoint for monitoring."""
240
+ return jsonify({
241
+ "status": "healthy",
242
+ "service": "AI Tax Reform API",
243
+ "version": "2.0.0",
244
+ "timestamp": time.time()
245
+ }), 200
246
+
247
+
248
+ @app.route("/calculate", methods=["POST"])
249
+ @limiter.limit("30 per minute")
250
+ def calculate_endpoint():
251
+ """
252
+ Calculate Nigerian personal income tax.
253
+
254
+ Request JSON:
255
+ - income (float, required): Gross annual income in NGN
256
+ - allowances (float, optional): Non-taxable allowances
257
+ - reliefs (float, optional): Tax reliefs
258
+ - pension (float, optional): Pension contribution
259
+ - include_cra (bool, optional): Include Consolidated Relief Allowance (default: true)
260
+
261
+ Returns:
262
+ JSON with tax calculation breakdown
263
+ """
264
+ try:
265
+ data = request.get_json() or {}
266
+
267
+ # Validate inputs
268
+ income = validate_numeric(data.get("income"), "income")
269
+ allowances = validate_numeric(data.get("allowances", 0), "allowances", min_val=0)
270
+ reliefs = validate_numeric(data.get("reliefs", 0), "reliefs", min_val=0)
271
+ pension = validate_numeric(data.get("pension", 0), "pension", min_val=0)
272
+ include_cra = bool(data.get("include_cra", True))
273
+
274
+ # Calculate tax using the improved calculator
275
+ result = calculate_tax(
276
+ annual_income=income,
277
+ allowances=allowances,
278
+ reliefs=reliefs,
279
+ pension_contribution=pension,
280
+ include_cra=include_cra
281
+ )
282
+
283
+ return jsonify(get_tax_summary(result)), 200
284
+
285
+ except ValueError as e:
286
+ raise APIError(str(e), 400)
287
+ except TaxCalculationError as e:
288
+ raise APIError(str(e), 400)
289
+ except Exception as e:
290
+ logger.exception("Tax calculation failed")
291
+ raise APIError("Tax calculation failed", 500)
292
+
293
+
294
+ @app.route("/retrieve", methods=["POST"])
295
+ @limiter.limit("20 per minute")
296
+ def retrieve():
297
+ """
298
+ Retrieve relevant document chunks from the tax law knowledge base.
299
+
300
+ Request JSON:
301
+ - query (string, required): Search query
302
+ - top_k (int, optional): Number of results to return (1-20, default: 5)
303
+
304
+ Returns:
305
+ JSON with matching document chunks
306
+ """
307
+ try:
308
+ payload = request.get_json() or {}
309
+
310
+ query_text = sanitize_string(payload.get("query", ""))
311
+ if not query_text or len(query_text) < 2:
312
+ raise APIError("Query must be at least 2 characters", 400)
313
+
314
+ top_k = validate_positive_int(payload.get("top_k", 5), "top_k", min_val=1, max_val=20)
315
+
316
+ index, docs = get_vectorstore()
317
+ results = query(index, docs, query_text, top_k=top_k)
318
+
319
+ return jsonify({
320
+ "query": query_text,
321
+ "count": len(results),
322
+ "results": results
323
+ }), 200
324
+
325
+ except APIError:
326
+ raise
327
+ except Exception as e:
328
+ logger.exception("Retrieval failed")
329
+ raise APIError("Document retrieval failed", 500)
330
+
331
+
332
+ @app.route("/qa", methods=["POST"])
333
+ @limiter.limit("15 per minute")
334
+ def qa():
335
+ """
336
+ Answer questions about Nigerian tax law using RAG (Retrieval-Augmented Generation).
337
+
338
+ Request JSON:
339
+ - query (string, required): Question to answer
340
+ - top_k (int, optional): Number of context documents (1-8, default: 3)
341
+ - prefer_grok (bool, optional): Prefer Groq/Grok model (default: true)
342
+ - fast_mode (bool, optional): Return sources without LLM generation (default: false)
343
+
344
+ Returns:
345
+ JSON with AI-generated answer and source documents
346
+ """
347
+ try:
348
+ payload = request.get_json() or {}
349
+
350
+ query_text = sanitize_string(payload.get("query", ""))
351
+ if not query_text or len(query_text) < 2:
352
+ raise APIError("Query must be at least 2 characters", 400)
353
+
354
+ # Default to 3 docs instead of 5 for faster response
355
+ top_k = validate_positive_int(payload.get("top_k", 3), "top_k", min_val=1, max_val=8)
356
+ prefer_grok = bool(payload.get("prefer_grok", True))
357
+ fast_mode = bool(payload.get("fast_mode", False))
358
+
359
+ # Retrieve relevant context with timeout handling
360
+ try:
361
+ index, docs = get_vectorstore()
362
+ results = query(index, docs, query_text, top_k=top_k)
363
+ except Exception as ve:
364
+ logger.error(f"Vectorstore query failed: {ve}")
365
+ raise APIError("Search service temporarily unavailable", 503)
366
+
367
+ if not results:
368
+ return jsonify({
369
+ "answer": "I couldn't find relevant information about this topic in the tax documentation. Please try rephrasing your question.",
370
+ "model": "none",
371
+ "sources": []
372
+ }), 200
373
+
374
+ # Fast mode: return sources with excerpt instead of calling slow LLM
375
+ if fast_mode:
376
+ top_text = results[0].get("text", "")[:800]
377
+ return jsonify({
378
+ "query": query_text,
379
+ "answer": f"**From the Nigeria Tax Act 2025:**\n\n{top_text}\n\n---\n*[Fast mode - showing direct excerpt from source documents]*",
380
+ "model": "fast",
381
+ "sources": results
382
+ }), 200
383
+
384
+ # Generate answer with shorter timeout
385
+ try:
386
+ answer, model_used, _ = generate_answer(query_text, results, prefer_grok=prefer_grok, timeout=20)
387
+ except Exception as ge:
388
+ logger.error(f"Answer generation failed: {ge}")
389
+ # Return sources even if generation fails
390
+ return jsonify({
391
+ "answer": "I found relevant documents but couldn't generate a complete answer. Here are the key sections from the tax law that may help:",
392
+ "model": "fallback",
393
+ "sources": results
394
+ }), 200
395
+
396
+ return jsonify({
397
+ "query": query_text,
398
+ "answer": answer,
399
+ "model": model_used,
400
+ "sources": results
401
+ }), 200
402
+
403
+ except APIError:
404
+ raise
405
+ except Exception as e:
406
+ logger.exception("QA processing failed")
407
+ raise APIError("Question answering failed. Please try again.", 500)
408
+
409
+
410
+ @app.route("/aqa", methods=["POST"])
411
+ @limiter.limit("10 per minute")
412
+ def aqa():
413
+ """
414
+ Answer questions with verification (Assured QA).
415
+
416
+ Same as /qa but includes answer verification step for higher accuracy.
417
+
418
+ Request JSON:
419
+ - query (string, required): Question to answer
420
+ - top_k (int, optional): Number of context documents (1-10, default: 5)
421
+ - prefer_grok (bool, optional): Prefer Groq/Grok model (default: true)
422
+
423
+ Returns:
424
+ JSON with AI-generated answer, verification result, and source documents
425
+ """
426
+ try:
427
+ payload = request.get_json() or {}
428
+
429
+ query_text = sanitize_string(payload.get("query", ""))
430
+ if not query_text or len(query_text) < 2:
431
+ raise APIError("Query must be at least 2 characters", 400)
432
+
433
+ top_k = validate_positive_int(payload.get("top_k", 5), "top_k", min_val=1, max_val=10)
434
+ prefer_grok = bool(payload.get("prefer_grok", True))
435
+
436
+ # Retrieve relevant context
437
+ index, docs = get_vectorstore()
438
+ results = query(index, docs, query_text, top_k=top_k)
439
+
440
+ if not results:
441
+ return jsonify({
442
+ "answer": "I couldn't find relevant information about this topic in the tax documentation.",
443
+ "model": "none",
444
+ "verification": {"score": 0, "reason": "No relevant documents found"},
445
+ "verified": False,
446
+ "sources": []
447
+ }), 200
448
+
449
+ # Generate answer
450
+ answer, model_used, _ = generate_answer(query_text, results, prefer_grok=prefer_grok)
451
+
452
+ # Verify answer
453
+ try:
454
+ verification = verify_answer(answer, query_text, results, prefer_grok=prefer_grok)
455
+
456
+ # Parse verification result
457
+ verified = False
458
+ if isinstance(verification, dict):
459
+ score = verification.get("score", 0)
460
+ verified = score >= 0.7 if isinstance(score, (int, float)) else False
461
+ elif isinstance(verification, str):
462
+ # Try to extract score from string response
463
+ import json
464
+ try:
465
+ verification = json.loads(verification)
466
+ score = verification.get("score", 0)
467
+ verified = score >= 0.7 if isinstance(score, (int, float)) else False
468
+ except json.JSONDecodeError:
469
+ verification = {"raw": verification, "score": 0}
470
+ verified = "accurate" in verification.get("raw", "").lower()
471
+ except Exception as ve:
472
+ logger.warning(f"Verification failed: {ve}")
473
+ verification = {"error": "Verification unavailable"}
474
+ verified = False
475
+
476
+ return jsonify({
477
+ "query": query_text,
478
+ "answer": answer,
479
+ "model": model_used,
480
+ "verification": verification,
481
+ "verified": verified,
482
+ "sources": results
483
+ }), 200
484
+
485
+ except APIError:
486
+ raise
487
+ except Exception as e:
488
+ logger.exception("AQA processing failed")
489
+ raise APIError("Verified question answering failed. Please try again.", 500)
490
+
491
+
492
+ # ============================================================================
493
+ # API Documentation Endpoint
494
+ # ============================================================================
495
+
496
+ @app.route("/", methods=["GET"])
497
+ @limiter.exempt
498
+ def api_docs():
499
+ """Return API documentation."""
500
+ return jsonify({
501
+ "name": "AI Tax Reform API",
502
+ "version": "2.0.0",
503
+ "description": "AI-powered Nigerian tax calculator and Q&A service",
504
+ "endpoints": {
505
+ "GET /health": "Health check",
506
+ "POST /calculate": "Calculate personal income tax",
507
+ "POST /retrieve": "Retrieve relevant tax documents",
508
+ "POST /qa": "Ask questions about tax law",
509
+ "POST /aqa": "Ask questions with answer verification"
510
+ },
511
+ "documentation": "https://github.com/your-repo/AI-TAX-REFORM#readme"
512
+ }), 200
513
+
514
+
515
+ # ============================================================================
516
+ # Application Startup
517
+ # ============================================================================
518
+
519
+ def start_background_tasks():
520
+ """Start background tasks after app is ready."""
521
+ # Preload vectorstore in background after 2 seconds
522
+ def delayed_preload():
523
+ import time
524
+ time.sleep(2)
525
+ preload_vectorstore()
526
+
527
+ thread = threading.Thread(target=delayed_preload, daemon=True)
528
+ thread.start()
529
+
530
+
531
+ if __name__ == "__main__":
532
+ port = int(os.getenv("PORT", 7860))
533
+ debug = os.getenv("FLASK_ENV") == "development"
534
+
535
+ logger.info(f"Starting AI Tax Reform API v2.0.0 on port {port}")
536
+ logger.info(f"Allowed origins: {origins}")
537
+
538
+ # Start background preloading
539
+ start_background_tasks()
540
+
541
+ app.run(host="0.0.0.0", port=port, debug=debug)
gunicorn.conf.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gunicorn Configuration for AI Tax Reform API
3
+ """
4
+
5
+ import multiprocessing
6
+ import os
7
+
8
+ # Server socket
9
+ bind = f"0.0.0.0:{os.getenv('PORT', '7860')}"
10
+ backlog = 2048
11
+
12
+ # Worker processes
13
+ workers = int(os.getenv('GUNICORN_WORKERS', min(multiprocessing.cpu_count() * 2 + 1, 4)))
14
+ worker_class = "sync"
15
+ worker_connections = 1000
16
+ timeout = 120
17
+ keepalive = 5
18
+
19
+ # Request handling
20
+ max_requests = 1000
21
+ max_requests_jitter = 50
22
+
23
+ # Logging
24
+ accesslog = "-"
25
+ errorlog = "-"
26
+ loglevel = os.getenv('LOG_LEVEL', 'info')
27
+ access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
28
+
29
+ # Process naming
30
+ proc_name = "ai-tax-reform-api"
31
+
32
+ # Server mechanics
33
+ preload_app = True
34
+ daemon = False
35
+
36
+ # Security
37
+ limit_request_line = 4094
38
+ limit_request_fields = 100
39
+ limit_request_field_size = 8190
40
+
41
+
42
+ def on_starting(server):
43
+ """Preload models before workers fork."""
44
+ import logging
45
+ logger = logging.getLogger(__name__)
46
+ logger.info("Preloading sentence-transformers model...")
47
+ try:
48
+ from sentence_transformers import SentenceTransformer
49
+ model = SentenceTransformer("sentence-transformers/all-mpnet-base-v2")
50
+ # Warm up with a test encode
51
+ model.encode(["test"], show_progress_bar=False)
52
+ logger.info("Model preloaded successfully")
53
+ except Exception as e:
54
+ logger.error(f"Model preload failed: {e}")
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core Framework
2
+ flask==3.1.2
3
+ flask-cors==4.0.0
4
+ flask-limiter==3.5.0
5
+ python-dotenv==1.0.0
6
+ gunicorn==21.2.0
7
+
8
+ # Vector Store & Embeddings
9
+ faiss-cpu==1.13.2
10
+ sentence-transformers==2.2.2
11
+
12
+ # Document Processing
13
+ PyPDF2==3.0.1
14
+
15
+ # Utilities
16
+ requests==2.31.0
17
+ tqdm==4.66.1
18
+ numpy>=1.24.0
scripts/__pycache__/ingest_pdf.cpython-311.pyc ADDED
Binary file (7.17 kB). View file
 
scripts/__pycache__/qa_service.cpython-311.pyc ADDED
Binary file (15.7 kB). View file
 
scripts/__pycache__/qa_service.cpython-314.pyc ADDED
Binary file (6.77 kB). View file
 
scripts/__pycache__/query_qa.cpython-311.pyc ADDED
Binary file (6.08 kB). View file
 
scripts/__pycache__/query_qa.cpython-314.pyc ADDED
Binary file (5.65 kB). View file
 
scripts/ingest_pdf.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Ingest PDF, chunk text, build FAISS index and save metadata using HF Inference API.
3
+ Usage: python scripts/ingest_pdf.py --pdf data/raw/Nigeria-Tax-Act-2025.pdf
4
+ """
5
+ import argparse
6
+ import os
7
+ import pickle
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv, dotenv_values
11
+ load_dotenv()
12
+
13
+ import faiss
14
+ import numpy as np
15
+ import requests
16
+ from PyPDF2 import PdfReader
17
+ from tqdm import tqdm
18
+
19
+
20
+ def chunk_text(text, chunk_size=500, overlap=100):
21
+ """Chunk text with overlap."""
22
+ start = 0
23
+ length = len(text)
24
+ while start < length:
25
+ end = min(start + chunk_size, length)
26
+ yield text[start:end]
27
+ start = end - overlap if end < length else end
28
+
29
+
30
+ def embed_text_hf(texts, model_id="nvidia/llama-embed-nemotron-8b", api_token=None):
31
+ """Call HF Inference API to embed texts."""
32
+ if api_token is None:
33
+ raise Exception("HF_TOKEN not found. Please set HF_TOKEN in your .env or environment variables.")
34
+ api_url = f"https://router.huggingface.co/models/{model_id}"
35
+ headers = {"Authorization": f"Bearer {api_token}"}
36
+
37
+ payload = {"inputs": texts}
38
+ response = requests.post(api_url, json=payload, headers=headers, timeout=60)
39
+
40
+ if response.status_code != 200:
41
+ # Provide clearer error for 401 Unauthorized
42
+ if response.status_code == 401:
43
+ raise Exception("HF API error 401: Unauthorized. Check your HF_TOKEN and model access permissions.")
44
+ raise Exception(f"HF API error {response.status_code}: {response.text}")
45
+
46
+ embeddings = response.json()
47
+ if isinstance(embeddings, dict) and "error" in embeddings:
48
+ raise Exception(f"HF API error: {embeddings['error']}")
49
+
50
+ return np.array(embeddings, dtype=np.float32)
51
+
52
+
53
+ # Local embedder using sentence-transformers
54
+ def embed_text_local(texts, model_name="sentence-transformers/all-mpnet-base-v2"):
55
+ """Embed texts locally using sentence-transformers."""
56
+ try:
57
+ from sentence_transformers import SentenceTransformer
58
+ except Exception as e:
59
+ raise Exception("Local sentence-transformers not installed. Install it with `pip install sentence-transformers`.")
60
+ model = SentenceTransformer(model_name)
61
+ embs = model.encode(texts, show_progress_bar=False, convert_to_numpy=True)
62
+ return np.array(embs, dtype=np.float32)
63
+
64
+
65
+ def main(pdf_path, persist_dir="vectorstore", model_id="sentence-transformers/all-mpnet-base-v2", batch_size=8, api_token=None):
66
+ pdf_path = Path(pdf_path)
67
+ assert pdf_path.exists(), f"PDF not found: {pdf_path}"
68
+ persist_dir = Path(persist_dir)
69
+ persist_dir.mkdir(parents=True, exist_ok=True)
70
+
71
+ # Allow explicit token via argument, otherwise use env or .env
72
+ if api_token is None:
73
+ env_vars = dotenv_values()
74
+ api_token = os.getenv("HF_TOKEN") or env_vars.get("HF_TOKEN")
75
+
76
+ reader = PdfReader(str(pdf_path))
77
+ pages = [p.extract_text() or "" for p in reader.pages]
78
+
79
+ docs = []
80
+ for i, page_text in enumerate(pages, start=1):
81
+ for j, chunk in enumerate(chunk_text(page_text)):
82
+ docs.append({
83
+ "text": chunk.strip(),
84
+ "source": pdf_path.name,
85
+ "page": i,
86
+ "chunk_id": f"p{i}_c{j}",
87
+ })
88
+
89
+ if not docs:
90
+ print("No text extracted from PDF.")
91
+ return
92
+
93
+ texts = [d["text"] for d in docs]
94
+ print(f"Creating embeddings for {len(texts)} chunks using {model_id}...")
95
+
96
+ # Embed in batches to avoid timeout
97
+ embeddings_list = []
98
+ for batch_start in tqdm(range(0, len(texts), batch_size)):
99
+ batch_end = min(batch_start + batch_size, len(texts))
100
+ batch_texts = texts[batch_start:batch_end]
101
+ # Use local sentence-transformers if model_id points to sentence-transformers namespace
102
+ if model_id.startswith("sentence-transformers/"):
103
+ batch_embs = embed_text_local(batch_texts, model_id)
104
+ else:
105
+ batch_embs = embed_text_hf(batch_texts, model_id, api_token)
106
+ embeddings_list.append(batch_embs)
107
+
108
+ embeddings = np.vstack(embeddings_list)
109
+
110
+ # Normalize for cosine-similarity via inner product
111
+ norms = (embeddings**2).sum(axis=1, keepdims=True) ** 0.5
112
+ norms[norms == 0] = 1.0
113
+ embeddings = embeddings / norms
114
+
115
+ dim = embeddings.shape[1]
116
+ index = faiss.IndexFlatIP(dim)
117
+ index.add(embeddings)
118
+
119
+ faiss.write_index(index, str(persist_dir / "faiss_index.bin"))
120
+
121
+ with open(persist_dir / "metadata.pkl", "wb") as f:
122
+ pickle.dump(docs, f)
123
+
124
+ print("Vectorstore saved to:", persist_dir)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ parser = argparse.ArgumentParser()
129
+ parser.add_argument("--pdf", required=True, help="Path to PDF to ingest")
130
+ parser.add_argument("--persist_dir", default="vectorstore")
131
+ parser.add_argument("--model", default="nvidia/llama-embed-nemotron-8b")
132
+ parser.add_argument("--batch_size", type=int, default=8)
133
+ parser.add_argument("--hf_token", default=None, help="Hugging Face token (overrides HF_TOKEN env/.env)")
134
+ args = parser.parse_args()
135
+ main(args.pdf, args.persist_dir, args.model, args.batch_size, api_token=args.hf_token)
scripts/qa_service.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ QA Service Module - AI-powered Question Answering for Nigerian Tax Law
3
+
4
+ This module provides functions for generating and verifying answers to tax-related
5
+ questions using Groq and Gemini APIs with retrieval-augmented generation (RAG).
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import logging
11
+ import requests
12
+ from typing import Tuple, Dict, Any, List, Optional
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # API Configuration
17
+ GROQ_API_URL = os.getenv("GROQ_API_URL") or "https://api.groq.com/openai/v1/chat/completions"
18
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY") or os.getenv("GROQ_API") or os.getenv("GROQ_API_TOKEN") or os.getenv("GROQ_KEY")
19
+ GROK_API_URL = os.getenv("GROK_API_URL") # Backwards compatibility
20
+ GROK_API_KEY = os.getenv("GROK_API_KEY") # Backwards compatibility
21
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
22
+
23
+ # System prompts
24
+ TAX_ASSISTANT_PROMPT = """You are an expert Nigerian tax consultant assistant with deep knowledge of the Nigeria Tax Act 2025 and related tax legislation.
25
+
26
+ Your role is to:
27
+ 1. Provide accurate, helpful answers about Nigerian tax law
28
+ 2. Cite specific sources and sections when available
29
+ 3. Be clear about limitations and when to consult a professional
30
+ 4. Use Nigerian Naira (₦) for currency references
31
+ 5. Format responses for readability using markdown
32
+
33
+ Guidelines:
34
+ - Base your answers ONLY on the provided context
35
+ - If the context doesn't contain enough information, say so clearly
36
+ - Never make up tax rates, thresholds, or legal requirements
37
+ - Recommend consulting FIRS or a tax professional for complex cases
38
+ """
39
+
40
+ VERIFICATION_PROMPT = """You are a tax law fact-checker. Your task is to verify if an answer about Nigerian tax law is accurate and well-supported by the provided context.
41
+
42
+ Evaluate the answer based on:
43
+ 1. Factual accuracy compared to the source documents
44
+ 2. Completeness of the response
45
+ 3. Proper citation of sources
46
+ 4. Absence of hallucinated information
47
+
48
+ Return a JSON object with:
49
+ - score: float between 0 and 1 (1 = fully accurate)
50
+ - accurate: boolean (true if score >= 0.7)
51
+ - confidence_reason: string explaining your assessment
52
+ - issues: array of strings listing any inaccuracies or concerns (empty if none)
53
+ """
54
+
55
+
56
+ class APIError(Exception):
57
+ """Custom exception for API errors."""
58
+ pass
59
+
60
+
61
+ def call_groq(
62
+ prompt: str,
63
+ system_prompt: Optional[str] = None,
64
+ timeout: int = 25,
65
+ model: str = "llama-3.3-70b-versatile",
66
+ max_tokens: int = 800,
67
+ temperature: float = 0.3
68
+ ) -> str:
69
+ """
70
+ Call Groq API (OpenAI-compatible) for text generation.
71
+
72
+ Args:
73
+ prompt: User message/prompt
74
+ system_prompt: Optional system message for context
75
+ timeout: Request timeout in seconds (default: 25)
76
+ model: Model identifier
77
+ max_tokens: Maximum tokens in response (default: 800)
78
+ temperature: Sampling temperature (0-1)
79
+
80
+ Returns:
81
+ Generated text response
82
+
83
+ Raises:
84
+ APIError: If API call fails
85
+ """
86
+ url = GROQ_API_URL or GROK_API_URL
87
+ key = GROQ_API_KEY or GROK_API_KEY
88
+
89
+ if not key:
90
+ raise APIError("Groq API key not configured. Set GROQ_API_KEY environment variable.")
91
+
92
+ messages = []
93
+ if system_prompt:
94
+ messages.append({"role": "system", "content": system_prompt})
95
+ messages.append({"role": "user", "content": prompt})
96
+
97
+ headers = {
98
+ "Authorization": f"Bearer {key}",
99
+ "Content-Type": "application/json"
100
+ }
101
+
102
+ payload = {
103
+ "model": model,
104
+ "messages": messages,
105
+ "max_tokens": max_tokens,
106
+ "temperature": temperature,
107
+ "top_p": 0.95,
108
+ }
109
+
110
+ try:
111
+ resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
112
+
113
+ if resp.status_code == 401:
114
+ raise APIError("Groq API authentication failed. Check your API key.")
115
+ elif resp.status_code == 429:
116
+ raise APIError("Groq API rate limit exceeded. Please try again later.")
117
+ elif resp.status_code != 200:
118
+ raise APIError(f"Groq API error ({resp.status_code})")
119
+
120
+ data = resp.json()
121
+
122
+ if isinstance(data, dict) and "choices" in data and data["choices"]:
123
+ return data["choices"][0]["message"]["content"]
124
+
125
+ raise APIError("Unexpected response format from Groq API")
126
+
127
+ except requests.Timeout:
128
+ raise APIError("Groq API request timed out")
129
+ except requests.RequestException as e:
130
+ raise APIError(f"Network error calling Groq API: {str(e)}")
131
+
132
+
133
+ # Alias for backwards compatibility
134
+ call_grok = call_groq
135
+
136
+
137
+ def call_gemini(
138
+ prompt: str,
139
+ model: str = "gemini-pro",
140
+ timeout: int = 25,
141
+ max_output_tokens: int = 800
142
+ ) -> str:
143
+ """
144
+ Call Google Gemini API for text generation.
145
+
146
+ Args:
147
+ prompt: User prompt
148
+ model: Gemini model identifier
149
+ timeout: Request timeout in seconds (default: 25)
150
+ max_output_tokens: Maximum tokens in response (default: 800)
151
+
152
+ Returns:
153
+ Generated text response
154
+
155
+ Raises:
156
+ APIError: If API call fails
157
+ """
158
+ if not GEMINI_API_KEY:
159
+ raise APIError("Gemini API key not configured. Set GEMINI_API_KEY environment variable.")
160
+
161
+ url = f"https://generativelanguage.googleapis.com/v1/models/{model}:generateContent?key={GEMINI_API_KEY}"
162
+
163
+ headers = {"Content-Type": "application/json"}
164
+
165
+ payload = {
166
+ "contents": [{"parts": [{"text": prompt}]}],
167
+ "generationConfig": {
168
+ "maxOutputTokens": max_output_tokens,
169
+ "temperature": 0.3,
170
+ }
171
+ }
172
+
173
+ try:
174
+ resp = requests.post(url, json=payload, headers=headers, timeout=timeout)
175
+
176
+ if resp.status_code == 401:
177
+ raise APIError("Gemini API authentication failed. Check your API key.")
178
+ elif resp.status_code == 429:
179
+ raise APIError("Gemini API rate limit exceeded. Please try again later.")
180
+ elif resp.status_code != 200:
181
+ raise APIError(f"Gemini API error ({resp.status_code})")
182
+
183
+ data = resp.json()
184
+
185
+ # Extract text from Gemini response
186
+ if isinstance(data, dict):
187
+ candidates = data.get("candidates", [])
188
+ if candidates:
189
+ content = candidates[0].get("content", {})
190
+ parts = content.get("parts", [])
191
+ if parts:
192
+ return parts[0].get("text", "")
193
+
194
+ raise APIError("Unexpected response format from Gemini API")
195
+
196
+ except requests.Timeout:
197
+ raise APIError("Gemini API request timed out")
198
+ except requests.RequestException as e:
199
+ raise APIError(f"Network error calling Gemini API: {str(e)}")
200
+
201
+
202
+ def format_context(contexts: List[Dict[str, Any]]) -> str:
203
+ """Format context documents for the prompt."""
204
+ formatted = []
205
+ for i, ctx in enumerate(contexts, 1):
206
+ page = ctx.get('page', 'N/A')
207
+ chunk_id = ctx.get('chunk_id', '')
208
+ text = ctx.get('text', '').strip()
209
+ formatted.append(f"[Source {i} | Page {page}]\n{text}")
210
+ return "\n\n---\n\n".join(formatted)
211
+
212
+
213
+ def generate_answer(
214
+ query: str,
215
+ contexts: List[Dict[str, Any]],
216
+ prefer_grok: bool = True,
217
+ timeout: int = 25
218
+ ) -> Tuple[str, str, str]:
219
+ """
220
+ Generate an answer using RAG with the provided contexts.
221
+
222
+ Args:
223
+ query: User's question
224
+ contexts: List of context documents with 'text', 'page', etc.
225
+ prefer_grok: Try Groq/Grok first if True
226
+ timeout: Timeout for API calls in seconds
227
+
228
+ Returns:
229
+ Tuple of (answer_text, model_used, raw_response)
230
+
231
+ Raises:
232
+ APIError: If all API calls fail
233
+ """
234
+ if not contexts:
235
+ return (
236
+ "I couldn't find relevant information to answer your question. "
237
+ "Please try rephrasing or ask about a different topic.",
238
+ "none",
239
+ ""
240
+ )
241
+
242
+ context_text = format_context(contexts)
243
+
244
+ prompt = f"""Based on the following excerpts from the Nigeria Tax Act 2025, please answer the question.
245
+
246
+ CONTEXT:
247
+ {context_text}
248
+
249
+ QUESTION: {query}
250
+
251
+ Please provide a clear, accurate answer based ONLY on the information provided above.
252
+ If the context doesn't contain enough information, clearly state that.
253
+ List the source numbers you used at the end of your response."""
254
+
255
+ errors = []
256
+
257
+ # Try Groq first if preferred
258
+ if prefer_grok:
259
+ try:
260
+ response = call_groq(prompt, system_prompt=TAX_ASSISTANT_PROMPT, timeout=timeout)
261
+ return response, "groq", response
262
+ except APIError as e:
263
+ errors.append(f"Groq: {str(e)}")
264
+ logger.warning(f"Groq API failed: {e}")
265
+
266
+ # Try Gemini as fallback
267
+ try:
268
+ full_prompt = f"{TAX_ASSISTANT_PROMPT}\n\n{prompt}"
269
+ response = call_gemini(full_prompt, timeout=timeout - 5)
270
+ return response, "gemini", response
271
+ except APIError as e:
272
+ errors.append(f"Gemini: {str(e)}")
273
+ logger.warning(f"Gemini API failed: {e}")
274
+
275
+ # If not preferring Grok, try it now
276
+ if not prefer_grok:
277
+ try:
278
+ response = call_groq(prompt, system_prompt=TAX_ASSISTANT_PROMPT, timeout=timeout)
279
+ return response, "groq", response
280
+ except APIError as e:
281
+ errors.append(f"Groq: {str(e)}")
282
+ logger.warning(f"Groq API failed: {e}")
283
+
284
+ # All APIs failed
285
+ error_summary = "; ".join(errors)
286
+ raise APIError(f"All AI services unavailable. {error_summary}")
287
+
288
+
289
+ def verify_answer(
290
+ answer: str,
291
+ query: str,
292
+ contexts: List[Dict[str, Any]],
293
+ prefer_grok: bool = True
294
+ ) -> Dict[str, Any]:
295
+ """
296
+ Verify an answer against the source contexts.
297
+
298
+ Args:
299
+ answer: The generated answer to verify
300
+ query: Original question
301
+ contexts: Source documents used for generation
302
+ prefer_grok: Try Groq/Grok first if True
303
+
304
+ Returns:
305
+ Dictionary with verification results including score, accuracy, and issues
306
+ """
307
+ context_text = format_context(contexts)
308
+
309
+ prompt = f"""Verify the following answer about Nigerian tax law.
310
+
311
+ CONTEXT DOCUMENTS:
312
+ {context_text}
313
+
314
+ QUESTION: {query}
315
+
316
+ ANSWER TO VERIFY:
317
+ {answer}
318
+
319
+ Analyze the answer and return ONLY a valid JSON object (no markdown, no explanation) with these exact fields:
320
+ {{
321
+ "score": <float 0-1>,
322
+ "accurate": <boolean>,
323
+ "confidence_reason": "<string>",
324
+ "issues": ["<string>", ...]
325
+ }}"""
326
+
327
+ try:
328
+ # Try Groq first
329
+ if prefer_grok:
330
+ try:
331
+ response = call_groq(prompt, system_prompt=VERIFICATION_PROMPT, temperature=0.1)
332
+ except APIError:
333
+ response = call_gemini(f"{VERIFICATION_PROMPT}\n\n{prompt}")
334
+ else:
335
+ try:
336
+ response = call_gemini(f"{VERIFICATION_PROMPT}\n\n{prompt}")
337
+ except APIError:
338
+ response = call_groq(prompt, system_prompt=VERIFICATION_PROMPT, temperature=0.1)
339
+
340
+ # Parse JSON from response
341
+ response = response.strip()
342
+
343
+ # Handle markdown code blocks
344
+ if response.startswith("```"):
345
+ lines = response.split("\n")
346
+ response = "\n".join(lines[1:-1])
347
+
348
+ result = json.loads(response)
349
+
350
+ # Ensure required fields
351
+ return {
352
+ "score": float(result.get("score", 0)),
353
+ "accurate": bool(result.get("accurate", False)),
354
+ "confidence_reason": str(result.get("confidence_reason", "")),
355
+ "issues": list(result.get("issues", []))
356
+ }
357
+
358
+ except json.JSONDecodeError:
359
+ logger.warning("Failed to parse verification response as JSON")
360
+ return {
361
+ "score": 0.5,
362
+ "accurate": False,
363
+ "confidence_reason": "Verification response could not be parsed",
364
+ "issues": ["Verification format error"]
365
+ }
366
+ except APIError as e:
367
+ logger.error(f"Verification API error: {e}")
368
+ return {
369
+ "score": 0,
370
+ "accurate": False,
371
+ "confidence_reason": f"Verification unavailable: {str(e)}",
372
+ "issues": ["Verification service unavailable"]
373
+ }
scripts/query_qa.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Query the local FAISS vectorstore and return top-k chunks using HF Inference API.
3
+ Usage: python scripts/query_qa.py --query "what is the personal income tax threshold" --top_k 5
4
+ """
5
+ import argparse
6
+ import json
7
+ import os
8
+ import pickle
9
+ from pathlib import Path
10
+
11
+ from dotenv import load_dotenv, dotenv_values
12
+ load_dotenv()
13
+
14
+ import faiss
15
+ import numpy as np
16
+ import requests
17
+
18
+
19
+ def embed_text_hf(texts, model_id="sentence-transformers/all-mpnet-base-v2", api_token=None, timeout=15):
20
+ """Call HF Inference API to embed texts with timeout."""
21
+ if api_token is None:
22
+ raise Exception("HF_TOKEN not found. Please set HF_TOKEN in your .env or environment variables.")
23
+
24
+ # Use the new router endpoint (api-inference is deprecated)
25
+ api_url = f"https://router.huggingface.co/hf-inference/models/{model_id}"
26
+ headers = {"Authorization": f"Bearer {api_token}"}
27
+
28
+ payload = {"inputs": texts, "options": {"wait_for_model": True}}
29
+ response = requests.post(api_url, json=payload, headers=headers, timeout=timeout)
30
+
31
+ if response.status_code != 200:
32
+ if response.status_code == 401:
33
+ raise Exception("HF API error 401: Unauthorized. Check your HF_TOKEN and model access permissions.")
34
+ if response.status_code == 503:
35
+ raise Exception("HF API: Model is loading, please retry in a moment.")
36
+ raise Exception(f"HF API error {response.status_code}: {response.text}")
37
+
38
+ embeddings = response.json()
39
+ if isinstance(embeddings, dict) and "error" in embeddings:
40
+ raise Exception(f"HF API error: {embeddings['error']}")
41
+
42
+ # HF returns list of embeddings, need to handle the format
43
+ # For feature-extraction, it returns token-level embeddings, we need to mean pool
44
+ emb_array = np.array(embeddings, dtype=np.float32)
45
+ if len(emb_array.shape) == 3:
46
+ # Mean pooling over tokens
47
+ emb_array = emb_array.mean(axis=1)
48
+
49
+ return emb_array
50
+
51
+
52
+ def load_vectorstore(persist_dir="vectorstore"):
53
+ persist_dir = Path(persist_dir)
54
+ index = faiss.read_index(str(persist_dir / "faiss_index.bin"))
55
+ with open(persist_dir / "metadata.pkl", "rb") as f:
56
+ docs = pickle.load(f)
57
+ return index, docs
58
+
59
+
60
+ def query(index, docs, q, model_id="sentence-transformers/all-mpnet-base-v2", top_k=5, api_token=None):
61
+ """Query the vectorstore using local sentence-transformers model."""
62
+ # Use local model - HF API doesn't support direct embeddings for sentence-transformers
63
+ from sentence_transformers import SentenceTransformer
64
+
65
+ # Cache model in module-level variable
66
+ global _st_model
67
+ if '_st_model' not in globals() or _st_model is None:
68
+ _st_model = SentenceTransformer(model_id)
69
+
70
+ emb = _st_model.encode([q], show_progress_bar=False, convert_to_numpy=True)
71
+ emb = np.array(emb, dtype=np.float32)
72
+ emb = emb / (np.linalg.norm(emb, axis=1, keepdims=True) + 1e-12)
73
+
74
+ D, I = index.search(emb, top_k)
75
+ results = []
76
+ for score, idx in zip(D[0], I[0]):
77
+ if idx < 0:
78
+ continue
79
+ meta = docs[idx]
80
+ results.append({"score": float(score), "text": meta["text"], "source": meta["source"], "page": meta["page"], "chunk_id": meta["chunk_id"]})
81
+ return results
82
+
83
+ # Model cache
84
+ _st_model = None
85
+
86
+
87
+ if __name__ == "__main__":
88
+ parser = argparse.ArgumentParser()
89
+ parser.add_argument("--query", required=True)
90
+ parser.add_argument("--persist_dir", default="vectorstore")
91
+ parser.add_argument("--model", default="nvidia/llama-embed-nemotron-8b")
92
+ parser.add_argument("--top_k", type=int, default=5)
93
+ parser.add_argument("--hf_token", default=None, help="Hugging Face token (overrides HF_TOKEN env/.env)")
94
+ args = parser.parse_args()
95
+
96
+ index, docs = load_vectorstore(args.persist_dir)
97
+ res = query(index, docs, args.query, args.model, args.top_k, api_token=args.hf_token)
98
+ print(json.dumps(res, indent=2))
src/__pycache__/tax_calculator.cpython-311.pyc ADDED
Binary file (11.7 kB). View file
 
src/__pycache__/tax_calculator.cpython-314.pyc ADDED
Binary file (1.1 kB). View file
 
src/tax_calculator.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nigerian Tax Calculator - Based on Nigeria Tax Act 2025
3
+
4
+ This module provides comprehensive tax calculation functionality for Nigerian
5
+ personal income tax, including progressive tax brackets, reliefs, and deductions.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import List, Tuple, Optional
10
+ from decimal import Decimal, ROUND_HALF_UP
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Tax brackets as per Nigeria Tax Act 2025
16
+ # (upper_limit, rate) - amounts in NGN
17
+ NIGERIA_TAX_BRACKETS: List[Tuple[Decimal, Decimal]] = [
18
+ (Decimal('300000'), Decimal('0.07')), # First ₦300,000 at 7%
19
+ (Decimal('300000'), Decimal('0.11')), # Next ₦300,000 at 11%
20
+ (Decimal('500000'), Decimal('0.15')), # Next ₦500,000 at 15%
21
+ (Decimal('500000'), Decimal('0.19')), # Next ₦500,000 at 19%
22
+ (Decimal('1600000'), Decimal('0.21')), # Next ₦1,600,000 at 21%
23
+ (Decimal('Infinity'), Decimal('0.24')), # Above ₦3,200,000 at 24%
24
+ ]
25
+
26
+ # Minimum tax threshold
27
+ MINIMUM_TAX_THRESHOLD = Decimal('30000000') # ₦30 million
28
+ MINIMUM_TAX_RATE = Decimal('0.01') # 1% minimum tax
29
+
30
+ # Consolidated Relief Allowance (CRA)
31
+ CRA_FIXED_AMOUNT = Decimal('200000') # ₦200,000 fixed
32
+ CRA_PERCENTAGE = Decimal('0.20') # 20% of gross income
33
+
34
+ # Pension contribution limits
35
+ PENSION_MAX_PERCENTAGE = Decimal('0.20') # 20% max pension contribution
36
+ PENSION_EMPLOYER_RATE = Decimal('0.10') # 10% employer contribution
37
+ PENSION_EMPLOYEE_RATE = Decimal('0.08') # 8% employee contribution
38
+
39
+
40
+ @dataclass
41
+ class TaxBracketResult:
42
+ """Result for a single tax bracket calculation."""
43
+ bracket_range: str
44
+ rate_percentage: float
45
+ taxable_amount: Decimal
46
+ tax_amount: Decimal
47
+
48
+
49
+ @dataclass
50
+ class TaxCalculationResult:
51
+ """Complete tax calculation result."""
52
+ gross_income: Decimal
53
+ total_allowances: Decimal
54
+ total_reliefs: Decimal
55
+ consolidated_relief: Decimal
56
+ taxable_income: Decimal
57
+ tax_due: Decimal
58
+ effective_rate: Decimal
59
+ breakdown: List[TaxBracketResult]
60
+ minimum_tax_applies: bool
61
+ minimum_tax_amount: Decimal
62
+ net_income: Decimal
63
+ monthly_tax: Decimal
64
+
65
+
66
+ class TaxCalculationError(Exception):
67
+ """Custom exception for tax calculation errors."""
68
+ pass
69
+
70
+
71
+ def calculate_consolidated_relief(gross_income: Decimal) -> Decimal:
72
+ """
73
+ Calculate Consolidated Relief Allowance (CRA).
74
+ CRA = ₦200,000 OR 1% of gross income, whichever is higher
75
+ PLUS 20% of gross income
76
+ """
77
+ fixed_or_percentage = max(CRA_FIXED_AMOUNT, gross_income * Decimal('0.01'))
78
+ variable_relief = gross_income * CRA_PERCENTAGE
79
+ return fixed_or_percentage + variable_relief
80
+
81
+
82
+ def calculate_pension_relief(gross_income: Decimal, pension_contribution: Decimal = Decimal('0')) -> Decimal:
83
+ """
84
+ Calculate pension contribution relief.
85
+ Employee pension contributions are tax-exempt up to 8% of basic salary.
86
+ """
87
+ max_exempt = gross_income * PENSION_EMPLOYEE_RATE
88
+ return min(pension_contribution, max_exempt)
89
+
90
+
91
+ def is_taxable(annual_income: float, threshold: float = 0) -> bool:
92
+ """
93
+ Check if income is taxable.
94
+ In Nigeria, there's no specific threshold - all income above reliefs is taxable.
95
+ """
96
+ return Decimal(str(annual_income)) > Decimal(str(threshold))
97
+
98
+
99
+ def calculate_tax_breakdown(taxable_income: Decimal) -> Tuple[Decimal, List[TaxBracketResult]]:
100
+ """
101
+ Calculate tax using progressive brackets.
102
+ Returns (total_tax, breakdown_list).
103
+ """
104
+ total_tax = Decimal('0')
105
+ remaining = taxable_income
106
+ breakdown: List[TaxBracketResult] = []
107
+ cumulative_lower = Decimal('0')
108
+
109
+ for bracket_limit, rate in NIGERIA_TAX_BRACKETS:
110
+ if remaining <= 0:
111
+ break
112
+
113
+ taxable_in_bracket = min(remaining, bracket_limit) if bracket_limit != Decimal('Infinity') else remaining
114
+ tax_in_bracket = (taxable_in_bracket * rate).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
115
+
116
+ if taxable_in_bracket > 0:
117
+ if bracket_limit == Decimal('Infinity'):
118
+ bracket_range = f"Above ₦{int(cumulative_lower):,}"
119
+ else:
120
+ bracket_range = f"₦{int(cumulative_lower):,} - ₦{int(cumulative_lower + bracket_limit):,}"
121
+
122
+ breakdown.append(TaxBracketResult(
123
+ bracket_range=bracket_range,
124
+ rate_percentage=float(rate * 100),
125
+ taxable_amount=taxable_in_bracket,
126
+ tax_amount=tax_in_bracket
127
+ ))
128
+
129
+ total_tax += tax_in_bracket
130
+ cumulative_lower += bracket_limit if bracket_limit != Decimal('Infinity') else Decimal('0')
131
+
132
+ remaining -= taxable_in_bracket
133
+
134
+ return total_tax, breakdown
135
+
136
+
137
+ def calculate_minimum_tax(gross_income: Decimal) -> Decimal:
138
+ """
139
+ Calculate minimum tax if applicable.
140
+ Minimum tax is 1% of gross income if gross income > ₦30 million.
141
+ """
142
+ if gross_income > MINIMUM_TAX_THRESHOLD:
143
+ return (gross_income * MINIMUM_TAX_RATE).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
144
+ return Decimal('0')
145
+
146
+
147
+ def calculate_tax(
148
+ annual_income: float,
149
+ allowances: float = 0,
150
+ reliefs: float = 0,
151
+ pension_contribution: float = 0,
152
+ include_cra: bool = True
153
+ ) -> TaxCalculationResult:
154
+ """
155
+ Calculate comprehensive tax liability for Nigerian personal income tax.
156
+
157
+ Args:
158
+ annual_income: Gross annual income in NGN
159
+ allowances: Non-taxable allowances (housing, transport, etc.)
160
+ reliefs: Additional tax reliefs (life insurance, etc.)
161
+ pension_contribution: Employee pension contribution
162
+ include_cra: Whether to automatically apply Consolidated Relief Allowance
163
+
164
+ Returns:
165
+ TaxCalculationResult with complete breakdown
166
+
167
+ Raises:
168
+ TaxCalculationError: If inputs are invalid
169
+ """
170
+ try:
171
+ # Convert to Decimal for precision
172
+ gross_income = Decimal(str(annual_income))
173
+ total_allowances = Decimal(str(allowances))
174
+ total_reliefs = Decimal(str(reliefs))
175
+ pension = Decimal(str(pension_contribution))
176
+
177
+ # Validate inputs
178
+ if gross_income < 0:
179
+ raise TaxCalculationError("Annual income cannot be negative")
180
+ if total_allowances < 0:
181
+ raise TaxCalculationError("Allowances cannot be negative")
182
+ if total_reliefs < 0:
183
+ raise TaxCalculationError("Reliefs cannot be negative")
184
+
185
+ # Calculate Consolidated Relief Allowance
186
+ cra = calculate_consolidated_relief(gross_income) if include_cra else Decimal('0')
187
+
188
+ # Calculate pension relief
189
+ pension_relief = calculate_pension_relief(gross_income, pension)
190
+ total_reliefs += pension_relief
191
+
192
+ # Calculate taxable income
193
+ taxable_income = max(Decimal('0'), gross_income - total_allowances - total_reliefs - cra)
194
+
195
+ # Calculate tax using progressive brackets
196
+ tax_due, breakdown = calculate_tax_breakdown(taxable_income)
197
+
198
+ # Check for minimum tax
199
+ minimum_tax = calculate_minimum_tax(gross_income)
200
+ minimum_tax_applies = minimum_tax > tax_due
201
+
202
+ # Apply minimum tax if higher
203
+ final_tax = max(tax_due, minimum_tax) if gross_income > MINIMUM_TAX_THRESHOLD else tax_due
204
+
205
+ # Calculate effective rate
206
+ effective_rate = (final_tax / gross_income * 100) if gross_income > 0 else Decimal('0')
207
+ effective_rate = effective_rate.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
208
+
209
+ # Calculate net income
210
+ net_income = gross_income - final_tax
211
+
212
+ # Monthly tax
213
+ monthly_tax = (final_tax / 12).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
214
+
215
+ return TaxCalculationResult(
216
+ gross_income=gross_income,
217
+ total_allowances=total_allowances,
218
+ total_reliefs=total_reliefs,
219
+ consolidated_relief=cra,
220
+ taxable_income=taxable_income,
221
+ tax_due=final_tax,
222
+ effective_rate=effective_rate,
223
+ breakdown=breakdown,
224
+ minimum_tax_applies=minimum_tax_applies,
225
+ minimum_tax_amount=minimum_tax,
226
+ net_income=net_income,
227
+ monthly_tax=monthly_tax
228
+ )
229
+
230
+ except (ValueError, TypeError) as e:
231
+ logger.error(f"Tax calculation error: {e}")
232
+ raise TaxCalculationError(f"Invalid input values: {e}")
233
+
234
+
235
+ def format_currency(amount: Decimal) -> str:
236
+ """Format amount as Nigerian Naira."""
237
+ return f"₦{int(amount):,}"
238
+
239
+
240
+ def get_tax_summary(result: TaxCalculationResult) -> dict:
241
+ """Convert TaxCalculationResult to API-friendly dictionary."""
242
+ return {
243
+ "gross_income": float(result.gross_income),
244
+ "total_allowances": float(result.total_allowances),
245
+ "total_reliefs": float(result.total_reliefs),
246
+ "consolidated_relief": float(result.consolidated_relief),
247
+ "taxable_income": float(result.taxable_income),
248
+ "tax_due": float(result.tax_due),
249
+ "effective_rate": float(result.effective_rate),
250
+ "breakdown": [
251
+ {
252
+ "bracket": br.bracket_range,
253
+ "rate": f"{br.rate_percentage}%",
254
+ "taxable_amount": float(br.taxable_amount),
255
+ "tax": float(br.tax_amount)
256
+ }
257
+ for br in result.breakdown
258
+ ],
259
+ "minimum_tax_applies": result.minimum_tax_applies,
260
+ "minimum_tax_amount": float(result.minimum_tax_amount),
261
+ "net_income": float(result.net_income),
262
+ "monthly_tax": float(result.monthly_tax)
263
+ }
vectorstore/faiss_index.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4fee8ae5a990882271c453a4d4302b96f60cd66944d951fdc3ccfce2ad45f280
3
+ size 3511341
vectorstore/metadata.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0615fceb03208665d769cdeec26af3683ab074d70f469258bc9f0e2e7254ec42
3
+ size 567830