Spaces:
Sleeping
Sleeping
Commit
Β·
c509b44
1
Parent(s):
f5cdb7d
working Tenant ID
Browse files- README.md +99 -20
- TESTING_GUIDE.md +430 -0
- backend/api/routes/admin.py +156 -10
- backend/api/routes/agent.py +89 -0
- backend/api/routes/analytics.py +75 -38
- backend/api/services/agent_orchestrator.py +282 -9
- backend/api/storage/analytics_store.py +401 -0
- backend/api/storage/rules_store.py +64 -5
- backend/mcp_servers/database.py +22 -4
- backend/mcp_servers/main.py +13 -1
- backend/mcp_servers/rag_server.py +17 -26
- backend/tests/test_analytics_store.py +208 -0
- backend/tests/test_api_endpoints.py +202 -0
- backend/tests/test_enhanced_admin_rules.py +195 -0
- check_rag_database.py +125 -0
- data/admin_rules.db +0 -0
- data/analytics.db +0 -0
- test_manual.py +306 -0
- test_simple.py +148 -0
- verify_tenant_isolation.py +449 -0
README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# IntegraChat β MCP Autonomous Agent
|
| 2 |
|
| 3 |
**Track:** MCP in Action
|
| 4 |
**Category:** Enterprise
|
|
@@ -8,23 +8,38 @@
|
|
| 8 |
|
| 9 |
## Overview
|
| 10 |
|
| 11 |
-
IntegraChat is an enterprise-
|
| 12 |
|
| 13 |
-
This
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
## Features
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
- π **Live Web Search** β DuckDuckGo-based MCP server with English-biased results
|
| 25 |
-
- π’ **Multi-Tenant Isolation** β
|
| 26 |
-
- π **Multi-Tool
|
| 27 |
-
- β‘ **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
---
|
| 30 |
|
|
@@ -93,28 +108,80 @@ Then open `http://localhost:3000`. The navbar links on the landing page route to
|
|
| 93 |
|
| 94 |
---
|
| 95 |
|
| 96 |
-
## API
|
| 97 |
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
| --- | --- | --- |
|
| 100 |
-
| Chat with agent | `POST /agent/message` |
|
| 101 |
-
|
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
| List documents | `GET /rag/list` | Returns all documents for a tenant with pagination |
|
| 104 |
| Delete document | `DELETE /rag/delete/{document_id}` | Deletes a specific document by ID |
|
| 105 |
| Delete all documents | `DELETE /rag/delete-all` | Deletes all documents for a tenant |
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
All calls are proxied through the FastAPI backend running at `http://localhost:8000`. Ensure those services are online before launching the Space.
|
| 110 |
|
| 111 |
---
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
## Demo Video
|
| 114 |
|
| 115 |
π₯ **[Demo Video Placeholder]** - Coming soon!
|
| 116 |
|
| 117 |
-
Watch how IntegraChat uses MCP to power autonomous agents with multi-tool selection, RAG retrieval, and governance.
|
| 118 |
|
| 119 |
---
|
| 120 |
|
|
@@ -138,11 +205,21 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
| 138 |
|
| 139 |
---
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
## Acknowledgments
|
| 142 |
|
| 143 |
- Built with [Model Context Protocol (MCP)](https://modelcontextprotocol.io/)
|
| 144 |
- Powered by [Gradio](https://gradio.app/) for the interface
|
| 145 |
- Backend built with [FastAPI](https://fastapi.tiangolo.com/)
|
|
|
|
| 146 |
|
| 147 |
---
|
| 148 |
|
|
@@ -150,6 +227,8 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
|
| 150 |
|
| 151 |
**Made with β€οΈ for the MCP Hackathon**
|
| 152 |
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
|
| 155 |
</div>
|
|
|
|
| 1 |
+
# IntegraChat β Enterprise MCP Autonomous Agent Platform
|
| 2 |
|
| 3 |
**Track:** MCP in Action
|
| 4 |
**Category:** Enterprise
|
|
|
|
| 8 |
|
| 9 |
## Overview
|
| 10 |
|
| 11 |
+
**IntegraChat** is an enterprise-grade, multi-tenant AI platform that demonstrates the full capabilities of the **Model Context Protocol (MCP)** in a production-style environment. Built with enterprise governance and observability in mind, IntegraChat combines autonomous tool-using agents, RAG retrieval, live web search, and admin compliance under strict tenant isolation.
|
| 12 |
|
| 13 |
+
This platform showcases how MCP can power intelligent, governed, multi-tenant AI systems with real-time analytics, regex-based red-flag detection, and comprehensive tool orchestration.
|
| 14 |
|
| 15 |
---
|
| 16 |
|
| 17 |
## Features
|
| 18 |
|
| 19 |
+
### Core Capabilities
|
| 20 |
+
|
| 21 |
+
- π€ **Autonomous Multi-Step MCP Agents** β Intelligent tool-aware agent that plans and executes multi-step workflows across RAG, Web, Admin, and LLM tools with memory of previous tool outputs
|
| 22 |
+
- π **Enhanced Knowledge Base Management** β Upload raw text, URLs, or documents (PDF/DOCX/TXT/MD) with rich metadata (source URL, timestamp, document type) and optimized chunking (400-600 tokens)
|
| 23 |
+
- ποΈ **Document Management** β Delete individual documents or bulk delete all documents for a tenant with confirmation dialogs
|
| 24 |
+
- π‘οΈ **Enterprise Admin Governance** β Regex-based red-flag pattern matching with severity levels (low/medium/high/critical) and automatic admin alerts
|
| 25 |
+
- π **Comprehensive Analytics & Observability** β Full tenant-level analytics logging with SQLite backend:
|
| 26 |
+
- Tool usage breakdown (RAG, Web, Admin, LLM) with latency and token tracking
|
| 27 |
+
- RAG recall/precision indicators (average hits, scores, top scores)
|
| 28 |
+
- Per-tenant query volume and active users
|
| 29 |
+
- Red-flag violations with timestamps and confidence scores
|
| 30 |
+
- LLM token logs and latency metrics
|
| 31 |
- π **Live Web Search** β DuckDuckGo-based MCP server with English-biased results
|
| 32 |
+
- π’ **Multi-Tenant Isolation** β Complete tenant isolation with centralized tenant ID management; backend enforces strict isolation for chat, ingestion, and admin ops
|
| 33 |
+
- π **Intelligent Multi-Tool Orchestration** β MCP agent orchestrator autonomously selects optimal tool chains (RAG + Web + LLM, etc.) based on query intent and context
|
| 34 |
+
- β‘ **Robust Error Handling** β Structured error responses, retry mechanisms, and graceful fallbacks (e.g., if RAG fails β fallback to LLM-only)
|
| 35 |
+
|
| 36 |
+
### Enterprise Features
|
| 37 |
+
|
| 38 |
+
- π **Regex-Based Red-Flag Detection** β Support for complex regex patterns with keyword fallback and semantic scoring
|
| 39 |
+
- π **Real-Time Analytics Dashboard** β Per-tenant analytics with configurable time windows (7, 30, 90 days)
|
| 40 |
+
- π οΈ **Admin API Endpoints** β `/admin/violations`, `/admin/tools/logs`, `/admin/tenants` for comprehensive governance
|
| 41 |
+
- π§ **Agent Debug & Planning** β `/agent/debug` and `/agent/plan` endpoints for observability and tool selection inspection
|
| 42 |
+
- πΎ **Persistent Analytics Storage** β SQLite-based analytics store with indexes for fast queries
|
| 43 |
|
| 44 |
---
|
| 45 |
|
|
|
|
| 108 |
|
| 109 |
---
|
| 110 |
|
| 111 |
+
## API Endpoints
|
| 112 |
|
| 113 |
+
### Agent Endpoints
|
| 114 |
+
|
| 115 |
+
| Purpose | Method & Path | Description |
|
| 116 |
| --- | --- | --- |
|
| 117 |
+
| Chat with agent | `POST /agent/message` | Main chat endpoint with `tenant_id`, `message`, optional history |
|
| 118 |
+
| Agent debug | `POST /agent/debug` | Returns detailed debugging info: reasoning trace, tool selection, intent classification |
|
| 119 |
+
| Agent plan | `POST /agent/plan` | Returns tool selection plan without execution (intent, tool scores, planned steps) |
|
| 120 |
+
|
| 121 |
+
### RAG Endpoints
|
| 122 |
+
|
| 123 |
+
| Purpose | Method & Path | Description |
|
| 124 |
+
| --- | --- | --- |
|
| 125 |
+
| Ingest document | `POST /rag/ingest-document` | Accepts `source_type`, `content`, metadata (filename, URL, doc_id) |
|
| 126 |
+
| Ingest file | `POST /rag/ingest-file` | Multipart upload with `x-tenant-id` header (PDF/DOCX/TXT/MD) |
|
| 127 |
| List documents | `GET /rag/list` | Returns all documents for a tenant with pagination |
|
| 128 |
| Delete document | `DELETE /rag/delete/{document_id}` | Deletes a specific document by ID |
|
| 129 |
| Delete all documents | `DELETE /rag/delete-all` | Deletes all documents for a tenant |
|
| 130 |
+
|
| 131 |
+
### Admin & Governance Endpoints
|
| 132 |
+
|
| 133 |
+
| Purpose | Method & Path | Description |
|
| 134 |
+
| --- | --- | --- |
|
| 135 |
+
| List rules | `GET /admin/rules?detailed=true` | Get all rules (use `detailed=true` for regex/severity metadata) |
|
| 136 |
+
| Add rule | `POST /admin/rules` | Add rule with optional `pattern` (regex), `severity` (low/medium/high/critical), `description` |
|
| 137 |
+
| Delete rule | `DELETE /admin/rules/{rule}` | Delete a specific rule |
|
| 138 |
+
| List violations | `GET /admin/violations?days=30&limit=50` | Get red-flag violations with timestamps and confidence scores |
|
| 139 |
+
| Tool logs | `GET /admin/tools/logs?tool_name=rag&days=7` | Get detailed tool usage logs with latency and token counts |
|
| 140 |
+
| Manage tenants | `GET/POST/DELETE /admin/tenants` | Tenant management endpoints (placeholder implementation) |
|
| 141 |
+
|
| 142 |
+
### Analytics Endpoints
|
| 143 |
+
|
| 144 |
+
| Purpose | Method & Path | Description |
|
| 145 |
+
| --- | --- | --- |
|
| 146 |
+
| Overview | `GET /analytics/overview?days=30` | Comprehensive analytics: total queries, tool usage, red-flag count, RAG quality |
|
| 147 |
+
| Tool usage | `GET /analytics/tool-usage?days=30` | Detailed tool usage stats: counts, latency, tokens, success/error rates |
|
| 148 |
+
| Red flags | `GET /analytics/redflags?limit=50&days=30` | Recent red-flag violations for tenant |
|
| 149 |
+
| Activity | `GET /analytics/activity?days=30` | Tenant activity summary: queries, active users, last query timestamp |
|
| 150 |
+
| RAG quality | `GET /analytics/rag-quality?days=30` | RAG quality metrics: avg hits, scores, latency (recall/precision indicators) |
|
| 151 |
|
| 152 |
All calls are proxied through the FastAPI backend running at `http://localhost:8000`. Ensure those services are online before launching the Space.
|
| 153 |
|
| 154 |
---
|
| 155 |
|
| 156 |
+
## Architecture Highlights
|
| 157 |
+
|
| 158 |
+
### Enterprise-Grade Features
|
| 159 |
+
|
| 160 |
+
1. **Autonomous Multi-Step Planning**: The agent uses LLM-powered planning to determine optimal tool sequences, with memory of previous tool outputs in multi-step workflows.
|
| 161 |
+
|
| 162 |
+
2. **Regex-Based Governance**: Admin rules support regex patterns with fallback to keyword matching and semantic similarity scoring for flexible policy enforcement.
|
| 163 |
+
|
| 164 |
+
3. **Comprehensive Analytics**: All tool usage, RAG searches, LLM calls, and red-flag violations are logged to SQLite with indexed queries for fast analytics retrieval.
|
| 165 |
+
|
| 166 |
+
4. **Enhanced RAG Pipeline**: Documents are chunked with optimal size (400-600 tokens) and enriched with metadata (source URL, timestamp, document type) for better retrieval.
|
| 167 |
+
|
| 168 |
+
5. **Structured Error Handling**: All errors are logged with context, and the system gracefully falls back (e.g., if RAG fails β use LLM-only, if web fails β skip web).
|
| 169 |
+
|
| 170 |
+
### Data Storage
|
| 171 |
+
|
| 172 |
+
- **SQLite Databases** (for demo/development):
|
| 173 |
+
- `data/admin_rules.db` - Admin rules with regex patterns and severity
|
| 174 |
+
- `data/analytics.db` - Analytics events, tool usage, violations, RAG metrics
|
| 175 |
+
|
| 176 |
+
- **Production Ready**: Can easily swap SQLite for PostgreSQL/Supabase for production deployments.
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
## Demo Video
|
| 181 |
|
| 182 |
π₯ **[Demo Video Placeholder]** - Coming soon!
|
| 183 |
|
| 184 |
+
Watch how IntegraChat uses MCP to power autonomous agents with multi-tool selection, RAG retrieval, and enterprise governance.
|
| 185 |
|
| 186 |
---
|
| 187 |
|
|
|
|
| 205 |
|
| 206 |
---
|
| 207 |
|
| 208 |
+
## Technical Stack
|
| 209 |
+
|
| 210 |
+
- **Backend**: FastAPI with async/await for high-performance MCP orchestration
|
| 211 |
+
- **Frontend**: Gradio interface + Next.js operator console
|
| 212 |
+
- **LLM Integration**: Ollama (local) or Groq (cloud) via configurable backend
|
| 213 |
+
- **Vector Store**: pgvector (via Supabase) or SQLite embeddings
|
| 214 |
+
- **Analytics**: SQLite with indexed queries for fast analytics
|
| 215 |
+
- **MCP Servers**: RAG (8001), Web (8002), Admin (8003)
|
| 216 |
+
|
| 217 |
## Acknowledgments
|
| 218 |
|
| 219 |
- Built with [Model Context Protocol (MCP)](https://modelcontextprotocol.io/)
|
| 220 |
- Powered by [Gradio](https://gradio.app/) for the interface
|
| 221 |
- Backend built with [FastAPI](https://fastapi.tiangolo.com/)
|
| 222 |
+
- Analytics and governance features inspired by enterprise AI platform requirements
|
| 223 |
|
| 224 |
---
|
| 225 |
|
|
|
|
| 227 |
|
| 228 |
**Made with β€οΈ for the MCP Hackathon**
|
| 229 |
|
| 230 |
+
**IntegraChat: Enterprise-Grade MCP Autonomous Agent Platform**
|
| 231 |
+
|
| 232 |
+
[β¬ Back to Top](#integrachat--enterprise-mcp-autonomous-agent-platform)
|
| 233 |
|
| 234 |
</div>
|
TESTING_GUIDE.md
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# IntegraChat Testing Guide
|
| 2 |
+
|
| 3 |
+
This guide explains how to test all the new features and improvements in IntegraChat.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
1. **Install Dependencies**
|
| 8 |
+
```bash
|
| 9 |
+
pip install -r requirements.txt
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
2. **Environment Setup**
|
| 13 |
+
- Create a `.env` file or set environment variables
|
| 14 |
+
- Optional: Set up Ollama for LLM testing
|
| 15 |
+
- Optional: Set up Supabase for production analytics
|
| 16 |
+
|
| 17 |
+
## Test Structure
|
| 18 |
+
|
| 19 |
+
### 1. Unit Tests
|
| 20 |
+
|
| 21 |
+
Run unit tests for individual components:
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
# Run all unit tests
|
| 25 |
+
pytest backend/tests/
|
| 26 |
+
|
| 27 |
+
# Run specific test files
|
| 28 |
+
pytest backend/tests/test_analytics_store.py -v
|
| 29 |
+
pytest backend/tests/test_enhanced_admin_rules.py -v
|
| 30 |
+
pytest backend/tests/test_api_endpoints.py -v
|
| 31 |
+
|
| 32 |
+
# Run with coverage
|
| 33 |
+
pytest backend/tests/ --cov=backend/api --cov-report=html
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### 2. Integration Tests
|
| 37 |
+
|
| 38 |
+
Test API endpoints with the FastAPI test client:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
pytest backend/tests/test_api_endpoints.py -v
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
**Note**: Some integration tests may fail if MCP servers or LLM are not running. That's expected.
|
| 45 |
+
|
| 46 |
+
### 3. Manual Testing Scripts
|
| 47 |
+
|
| 48 |
+
Create test data and verify functionality manually:
|
| 49 |
+
|
| 50 |
+
#### A. Test Analytics Store
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
python -c "
|
| 54 |
+
from backend.api.storage.analytics_store import AnalyticsStore
|
| 55 |
+
import time
|
| 56 |
+
|
| 57 |
+
store = AnalyticsStore()
|
| 58 |
+
|
| 59 |
+
# Log tool usage
|
| 60 |
+
store.log_tool_usage('test_tenant', 'rag', latency_ms=150, tokens_used=500, success=True)
|
| 61 |
+
store.log_tool_usage('test_tenant', 'web', latency_ms=80, success=True)
|
| 62 |
+
|
| 63 |
+
# Log red-flag violation
|
| 64 |
+
store.log_redflag_violation(
|
| 65 |
+
'test_tenant',
|
| 66 |
+
'rule1',
|
| 67 |
+
'.*password.*',
|
| 68 |
+
'high',
|
| 69 |
+
'password123',
|
| 70 |
+
confidence=0.95
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Log RAG search
|
| 74 |
+
store.log_rag_search('test_tenant', 'test query', hits_count=5, avg_score=0.85, top_score=0.92)
|
| 75 |
+
|
| 76 |
+
# Log agent query
|
| 77 |
+
store.log_agent_query('test_tenant', 'test message', intent='rag', tools_used=['rag', 'llm'], total_tokens=1000)
|
| 78 |
+
|
| 79 |
+
# Get stats
|
| 80 |
+
print('Tool Usage:', store.get_tool_usage_stats('test_tenant'))
|
| 81 |
+
print('Violations:', store.get_redflag_violations('test_tenant'))
|
| 82 |
+
print('Activity:', store.get_activity_summary('test_tenant'))
|
| 83 |
+
print('RAG Quality:', store.get_rag_quality_metrics('test_tenant'))
|
| 84 |
+
"
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
#### B. Test Admin Rules with Regex
|
| 88 |
+
|
| 89 |
+
```bash
|
| 90 |
+
python -c "
|
| 91 |
+
from backend.api.storage.rules_store import RulesStore
|
| 92 |
+
import re
|
| 93 |
+
|
| 94 |
+
store = RulesStore()
|
| 95 |
+
|
| 96 |
+
# Add rule with regex pattern
|
| 97 |
+
store.add_rule(
|
| 98 |
+
'test_tenant',
|
| 99 |
+
'Block password queries',
|
| 100 |
+
pattern='.*password.*|.*pwd.*',
|
| 101 |
+
severity='high',
|
| 102 |
+
description='Blocks password-related queries'
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Get detailed rules
|
| 106 |
+
rules = store.get_rules_detailed('test_tenant')
|
| 107 |
+
print('Rules:', rules)
|
| 108 |
+
|
| 109 |
+
# Test regex matching
|
| 110 |
+
pattern = rules[0]['pattern']
|
| 111 |
+
regex = re.compile(pattern, re.IGNORECASE)
|
| 112 |
+
test_text = 'What is my password?'
|
| 113 |
+
match = regex.search(test_text)
|
| 114 |
+
print(f'Match for \"{test_text}\": {match is not None}')
|
| 115 |
+
"
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
## API Endpoint Testing
|
| 119 |
+
|
| 120 |
+
### Using curl
|
| 121 |
+
|
| 122 |
+
#### 1. Test Analytics Endpoints
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
# Overview
|
| 126 |
+
curl -X GET "http://localhost:8000/analytics/overview?days=30" \
|
| 127 |
+
-H "x-tenant-id: test_tenant"
|
| 128 |
+
|
| 129 |
+
# Tool Usage
|
| 130 |
+
curl -X GET "http://localhost:8000/analytics/tool-usage?days=30" \
|
| 131 |
+
-H "x-tenant-id: test_tenant"
|
| 132 |
+
|
| 133 |
+
# RAG Quality
|
| 134 |
+
curl -X GET "http://localhost:8000/analytics/rag-quality?days=30" \
|
| 135 |
+
-H "x-tenant-id: test_tenant"
|
| 136 |
+
|
| 137 |
+
# Red Flags
|
| 138 |
+
curl -X GET "http://localhost:8000/analytics/redflags?limit=50&days=30" \
|
| 139 |
+
-H "x-tenant-id: test_tenant"
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
#### 2. Test Admin Endpoints
|
| 143 |
+
|
| 144 |
+
```bash
|
| 145 |
+
# Add rule with regex and severity
|
| 146 |
+
curl -X POST "http://localhost:8000/admin/rules" \
|
| 147 |
+
-H "x-tenant-id: test_tenant" \
|
| 148 |
+
-H "Content-Type: application/json" \
|
| 149 |
+
-d '{
|
| 150 |
+
"rule": "Block password queries",
|
| 151 |
+
"pattern": ".*password.*",
|
| 152 |
+
"severity": "high",
|
| 153 |
+
"description": "Blocks password-related queries"
|
| 154 |
+
}'
|
| 155 |
+
|
| 156 |
+
# Get detailed rules
|
| 157 |
+
curl -X GET "http://localhost:8000/admin/rules?detailed=true" \
|
| 158 |
+
-H "x-tenant-id: test_tenant"
|
| 159 |
+
|
| 160 |
+
# Get violations
|
| 161 |
+
curl -X GET "http://localhost:8000/admin/violations?limit=50&days=30" \
|
| 162 |
+
-H "x-tenant-id: test_tenant"
|
| 163 |
+
|
| 164 |
+
# Get tool logs
|
| 165 |
+
curl -X GET "http://localhost:8000/admin/tools/logs?tool_name=rag&days=7" \
|
| 166 |
+
-H "x-tenant-id: test_tenant"
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
#### 3. Test Agent Endpoints
|
| 170 |
+
|
| 171 |
+
```bash
|
| 172 |
+
# Agent chat (normal)
|
| 173 |
+
curl -X POST "http://localhost:8000/agent/message" \
|
| 174 |
+
-H "Content-Type: application/json" \
|
| 175 |
+
-d '{
|
| 176 |
+
"tenant_id": "test_tenant",
|
| 177 |
+
"message": "What is the company policy?",
|
| 178 |
+
"temperature": 0.0
|
| 179 |
+
}'
|
| 180 |
+
|
| 181 |
+
# Agent debug
|
| 182 |
+
curl -X POST "http://localhost:8000/agent/debug" \
|
| 183 |
+
-H "Content-Type: application/json" \
|
| 184 |
+
-d '{
|
| 185 |
+
"tenant_id": "test_tenant",
|
| 186 |
+
"message": "What is the company policy?",
|
| 187 |
+
"temperature": 0.0
|
| 188 |
+
}'
|
| 189 |
+
|
| 190 |
+
# Agent plan
|
| 191 |
+
curl -X POST "http://localhost:8000/agent/plan" \
|
| 192 |
+
-H "Content-Type: application/json" \
|
| 193 |
+
-d '{
|
| 194 |
+
"tenant_id": "test_tenant",
|
| 195 |
+
"message": "What is the company policy?",
|
| 196 |
+
"temperature": 0.0
|
| 197 |
+
}'
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
### Using Python requests
|
| 201 |
+
|
| 202 |
+
Create a test script `test_api_manual.py`:
|
| 203 |
+
|
| 204 |
+
```python
|
| 205 |
+
import requests
|
| 206 |
+
import json
|
| 207 |
+
|
| 208 |
+
BASE_URL = "http://localhost:8000"
|
| 209 |
+
TENANT_ID = "test_tenant"
|
| 210 |
+
|
| 211 |
+
headers = {"x-tenant-id": TENANT_ID}
|
| 212 |
+
|
| 213 |
+
# Test analytics
|
| 214 |
+
print("Testing Analytics Endpoints...")
|
| 215 |
+
response = requests.get(f"{BASE_URL}/analytics/overview?days=30", headers=headers)
|
| 216 |
+
print(f"Overview: {response.status_code} - {json.dumps(response.json(), indent=2)}")
|
| 217 |
+
|
| 218 |
+
response = requests.get(f"{BASE_URL}/analytics/tool-usage?days=30", headers=headers)
|
| 219 |
+
print(f"Tool Usage: {response.status_code} - {json.dumps(response.json(), indent=2)}")
|
| 220 |
+
|
| 221 |
+
# Test admin rules
|
| 222 |
+
print("\nTesting Admin Rules...")
|
| 223 |
+
response = requests.post(
|
| 224 |
+
f"{BASE_URL}/admin/rules",
|
| 225 |
+
headers=headers,
|
| 226 |
+
json={
|
| 227 |
+
"rule": "Block password queries",
|
| 228 |
+
"pattern": ".*password.*",
|
| 229 |
+
"severity": "high"
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
print(f"Add Rule: {response.status_code} - {json.dumps(response.json(), indent=2)}")
|
| 233 |
+
|
| 234 |
+
response = requests.get(
|
| 235 |
+
f"{BASE_URL}/admin/rules?detailed=true",
|
| 236 |
+
headers=headers
|
| 237 |
+
)
|
| 238 |
+
print(f"Get Rules: {response.status_code} - {json.dumps(response.json(), indent=2)}")
|
| 239 |
+
|
| 240 |
+
# Test agent endpoints
|
| 241 |
+
print("\nTesting Agent Endpoints...")
|
| 242 |
+
response = requests.post(
|
| 243 |
+
f"{BASE_URL}/agent/plan",
|
| 244 |
+
json={
|
| 245 |
+
"tenant_id": TENANT_ID,
|
| 246 |
+
"message": "What is the company policy?",
|
| 247 |
+
"temperature": 0.0
|
| 248 |
+
}
|
| 249 |
+
)
|
| 250 |
+
print(f"Agent Plan: {response.status_code} - {json.dumps(response.json(), indent=2)}")
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
Run it:
|
| 254 |
+
```bash
|
| 255 |
+
python test_api_manual.py
|
| 256 |
+
```
|
| 257 |
+
|
| 258 |
+
## End-to-End Testing Workflow
|
| 259 |
+
|
| 260 |
+
### Step 1: Start Backend Services
|
| 261 |
+
|
| 262 |
+
```bash
|
| 263 |
+
# Terminal 1: Start FastAPI backend
|
| 264 |
+
cd backend/api
|
| 265 |
+
uvicorn main:app --port 8000 --reload
|
| 266 |
+
|
| 267 |
+
# Terminal 2: Start RAG MCP server
|
| 268 |
+
cd backend/mcp_servers
|
| 269 |
+
python main.py # or uvicorn main:app --port 8001
|
| 270 |
+
|
| 271 |
+
# Terminal 3: Start Web MCP server
|
| 272 |
+
cd backend/mcp_servers
|
| 273 |
+
python web_server.py # or uvicorn web_server:app --port 8002
|
| 274 |
+
|
| 275 |
+
# Terminal 4: Start Admin MCP server
|
| 276 |
+
cd backend/mcp_servers
|
| 277 |
+
python admin_server.py # or uvicorn admin_server:app --port 8003
|
| 278 |
+
|
| 279 |
+
# Optional: Start Ollama for LLM
|
| 280 |
+
ollama serve
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
### Step 2: Generate Test Data
|
| 284 |
+
|
| 285 |
+
Run the analytics and rules tests to populate the database:
|
| 286 |
+
|
| 287 |
+
```bash
|
| 288 |
+
pytest backend/tests/test_analytics_store.py -v
|
| 289 |
+
pytest backend/tests/test_enhanced_admin_rules.py -v
|
| 290 |
+
```
|
| 291 |
+
|
| 292 |
+
### Step 3: Test Agent Flow
|
| 293 |
+
|
| 294 |
+
1. **Add some admin rules:**
|
| 295 |
+
```bash
|
| 296 |
+
curl -X POST "http://localhost:8000/admin/rules" \
|
| 297 |
+
-H "x-tenant-id: test_tenant" \
|
| 298 |
+
-H "Content-Type: application/json" \
|
| 299 |
+
-d '{"rule": "Block password queries", "pattern": ".*password.*", "severity": "high"}'
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
2. **Send a query that triggers red-flag:**
|
| 303 |
+
```bash
|
| 304 |
+
curl -X POST "http://localhost:8000/agent/message" \
|
| 305 |
+
-H "Content-Type: application/json" \
|
| 306 |
+
-d '{"tenant_id": "test_tenant", "message": "What is my password?"}'
|
| 307 |
+
```
|
| 308 |
+
|
| 309 |
+
3. **Check violations were logged:**
|
| 310 |
+
```bash
|
| 311 |
+
curl -X GET "http://localhost:8000/admin/violations" \
|
| 312 |
+
-H "x-tenant-id: test_tenant"
|
| 313 |
+
```
|
| 314 |
+
|
| 315 |
+
4. **Send normal queries and check analytics:**
|
| 316 |
+
```bash
|
| 317 |
+
curl -X POST "http://localhost:8000/agent/message" \
|
| 318 |
+
-H "Content-Type: application/json" \
|
| 319 |
+
-d '{"tenant_id": "test_tenant", "message": "What is the company policy?"}'
|
| 320 |
+
|
| 321 |
+
curl -X GET "http://localhost:8000/analytics/overview" \
|
| 322 |
+
-H "x-tenant-id: test_tenant"
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
5. **Use debug endpoint to see reasoning:**
|
| 326 |
+
```bash
|
| 327 |
+
curl -X POST "http://localhost:8000/agent/debug" \
|
| 328 |
+
-H "Content-Type: application/json" \
|
| 329 |
+
-d '{"tenant_id": "test_tenant", "message": "What is the company policy?"}'
|
| 330 |
+
```
|
| 331 |
+
|
| 332 |
+
### Step 4: Verify Database
|
| 333 |
+
|
| 334 |
+
Check that data is being stored:
|
| 335 |
+
|
| 336 |
+
```bash
|
| 337 |
+
# SQLite databases are in data/ directory
|
| 338 |
+
sqlite3 data/analytics.db "SELECT * FROM tool_usage_events LIMIT 10;"
|
| 339 |
+
sqlite3 data/analytics.db "SELECT * FROM redflag_violations LIMIT 10;"
|
| 340 |
+
sqlite3 data/admin_rules.db "SELECT * FROM admin_rules;"
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
## Testing Checklist
|
| 344 |
+
|
| 345 |
+
### Analytics Store
|
| 346 |
+
- [ ] Tool usage logging works
|
| 347 |
+
- [ ] Red-flag violations are logged
|
| 348 |
+
- [ ] RAG search events are logged with quality metrics
|
| 349 |
+
- [ ] Agent query events are logged
|
| 350 |
+
- [ ] Stats can be filtered by time
|
| 351 |
+
- [ ] Multiple tenants are isolated
|
| 352 |
+
|
| 353 |
+
### Admin Rules
|
| 354 |
+
- [ ] Rules can be added with regex patterns
|
| 355 |
+
- [ ] Severity levels work (low/medium/high/critical)
|
| 356 |
+
- [ ] Rules without pattern use rule text
|
| 357 |
+
- [ ] Disabled rules are not returned
|
| 358 |
+
- [ ] Multiple tenants are isolated
|
| 359 |
+
- [ ] Regex patterns actually match correctly
|
| 360 |
+
|
| 361 |
+
### API Endpoints
|
| 362 |
+
- [ ] `/analytics/overview` returns correct data
|
| 363 |
+
- [ ] `/analytics/tool-usage` returns stats
|
| 364 |
+
- [ ] `/analytics/rag-quality` returns metrics
|
| 365 |
+
- [ ] `/admin/rules` accepts regex/severity
|
| 366 |
+
- [ ] `/admin/violations` returns violations
|
| 367 |
+
- [ ] `/admin/tools/logs` returns tool usage
|
| 368 |
+
- [ ] `/agent/debug` returns reasoning trace
|
| 369 |
+
- [ ] `/agent/plan` returns tool selection plan
|
| 370 |
+
- [ ] Missing tenant_id returns 400
|
| 371 |
+
|
| 372 |
+
### Integration
|
| 373 |
+
- [ ] Agent orchestrator logs to analytics
|
| 374 |
+
- [ ] Red-flag detector logs violations
|
| 375 |
+
- [ ] Tool calls are tracked
|
| 376 |
+
- [ ] Multi-step workflows are logged
|
| 377 |
+
- [ ] Errors are logged correctly
|
| 378 |
+
|
| 379 |
+
## Common Issues
|
| 380 |
+
|
| 381 |
+
### Database Not Found
|
| 382 |
+
- Ensure `data/` directory exists
|
| 383 |
+
- Analytics store will create it automatically
|
| 384 |
+
|
| 385 |
+
### Tests Fail Due to Missing Services
|
| 386 |
+
- Some tests require MCP servers or LLM to be running
|
| 387 |
+
- Mock these services or skip tests if services unavailable
|
| 388 |
+
- Unit tests should work without external services
|
| 389 |
+
|
| 390 |
+
### Import Errors
|
| 391 |
+
- Ensure you're running from project root
|
| 392 |
+
- Check that `backend/` is in Python path
|
| 393 |
+
- Install all dependencies: `pip install -r requirements.txt`
|
| 394 |
+
|
| 395 |
+
## Performance Testing
|
| 396 |
+
|
| 397 |
+
For large-scale testing:
|
| 398 |
+
|
| 399 |
+
```python
|
| 400 |
+
# Load test analytics store
|
| 401 |
+
from backend.api.storage.analytics_store import AnalyticsStore
|
| 402 |
+
import time
|
| 403 |
+
|
| 404 |
+
store = AnalyticsStore()
|
| 405 |
+
tenant_id = "load_test_tenant"
|
| 406 |
+
|
| 407 |
+
start = time.time()
|
| 408 |
+
for i in range(1000):
|
| 409 |
+
store.log_tool_usage(tenant_id, "rag", latency_ms=100 + i % 50)
|
| 410 |
+
|
| 411 |
+
elapsed = time.time() - start
|
| 412 |
+
print(f"Logged 1000 events in {elapsed:.2f}s ({1000/elapsed:.0f} events/sec)")
|
| 413 |
+
|
| 414 |
+
# Query performance
|
| 415 |
+
start = time.time()
|
| 416 |
+
stats = store.get_tool_usage_stats(tenant_id)
|
| 417 |
+
elapsed = time.time() - start
|
| 418 |
+
print(f"Query took {elapsed*1000:.2f}ms")
|
| 419 |
+
```
|
| 420 |
+
|
| 421 |
+
## Next Steps
|
| 422 |
+
|
| 423 |
+
1. **Add more test cases** for edge cases
|
| 424 |
+
2. **Set up CI/CD** to run tests automatically
|
| 425 |
+
3. **Add performance benchmarks** for analytics queries
|
| 426 |
+
4. **Create integration test suite** that spins up all services
|
| 427 |
+
5. **Add E2E tests** using Playwright or Selenium for frontend
|
| 428 |
+
|
| 429 |
+
For questions or issues, check the test files in `backend/tests/` or refer to the main README.md.
|
| 430 |
+
|
backend/api/routes/admin.py
CHANGED
|
@@ -1,16 +1,23 @@
|
|
| 1 |
-
from fastapi import APIRouter, Header, HTTPException
|
| 2 |
from pydantic import BaseModel
|
| 3 |
-
from typing import List, Optional
|
|
|
|
| 4 |
|
| 5 |
from backend.api.storage.rules_store import RulesStore
|
|
|
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
| 9 |
rules_store = RulesStore()
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class RulePayload(BaseModel):
|
| 13 |
rule: str
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
class BulkRulePayload(BaseModel):
|
|
@@ -23,19 +30,31 @@ def get_rules_for_tenant(tenant_id: str) -> List[str]:
|
|
| 23 |
|
| 24 |
@router.get("/rules")
|
| 25 |
async def get_redflag_rules(
|
| 26 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 27 |
):
|
| 28 |
"""
|
| 29 |
Returns all red-flag rules for this tenant.
|
|
|
|
| 30 |
"""
|
| 31 |
|
| 32 |
if not x_tenant_id:
|
| 33 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
@router.post("/rules")
|
|
@@ -45,8 +64,9 @@ async def add_redflag_rule(
|
|
| 45 |
x_tenant_id: str = Header(None)
|
| 46 |
):
|
| 47 |
"""
|
| 48 |
-
Adds a new red-flag rule to this tenant.
|
| 49 |
-
Accepts either JSON body
|
|
|
|
| 50 |
"""
|
| 51 |
|
| 52 |
if not x_tenant_id:
|
|
@@ -60,12 +80,32 @@ async def add_redflag_rule(
|
|
| 60 |
if not rule_value:
|
| 61 |
raise HTTPException(status_code=400, detail="Rule cannot be empty")
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
rules = get_rules_for_tenant(x_tenant_id)
|
| 65 |
|
| 66 |
return {
|
| 67 |
"tenant_id": x_tenant_id,
|
| 68 |
"added_rule": rule_value,
|
|
|
|
|
|
|
|
|
|
| 69 |
"rules": rules
|
| 70 |
}
|
| 71 |
|
|
@@ -118,3 +158,109 @@ async def delete_redflag_rule(
|
|
| 118 |
"deleted_rule": rule,
|
| 119 |
"rules": rules
|
| 120 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Header, HTTPException, Query
|
| 2 |
from pydantic import BaseModel
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
|
| 6 |
from backend.api.storage.rules_store import RulesStore
|
| 7 |
+
from backend.api.storage.analytics_store import AnalyticsStore
|
| 8 |
|
| 9 |
router = APIRouter()
|
| 10 |
|
| 11 |
rules_store = RulesStore()
|
| 12 |
+
analytics_store = AnalyticsStore()
|
| 13 |
|
| 14 |
|
| 15 |
class RulePayload(BaseModel):
|
| 16 |
rule: str
|
| 17 |
+
pattern: Optional[str] = None # Regex pattern
|
| 18 |
+
severity: Optional[str] = "medium" # low, medium, high, critical
|
| 19 |
+
description: Optional[str] = None
|
| 20 |
+
enabled: Optional[bool] = True
|
| 21 |
|
| 22 |
|
| 23 |
class BulkRulePayload(BaseModel):
|
|
|
|
| 30 |
|
| 31 |
@router.get("/rules")
|
| 32 |
async def get_redflag_rules(
|
| 33 |
+
x_tenant_id: str = Header(None),
|
| 34 |
+
detailed: bool = Query(False, description="Return full rule metadata including pattern and severity")
|
| 35 |
):
|
| 36 |
"""
|
| 37 |
Returns all red-flag rules for this tenant.
|
| 38 |
+
Set detailed=true to get full metadata including regex patterns and severity levels.
|
| 39 |
"""
|
| 40 |
|
| 41 |
if not x_tenant_id:
|
| 42 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 43 |
|
| 44 |
+
if detailed:
|
| 45 |
+
rules = rules_store.get_rules_detailed(x_tenant_id)
|
| 46 |
+
return {
|
| 47 |
+
"tenant_id": x_tenant_id,
|
| 48 |
+
"rules": rules,
|
| 49 |
+
"count": len(rules)
|
| 50 |
+
}
|
| 51 |
+
else:
|
| 52 |
+
rules = get_rules_for_tenant(x_tenant_id)
|
| 53 |
+
return {
|
| 54 |
+
"tenant_id": x_tenant_id,
|
| 55 |
+
"rules": rules,
|
| 56 |
+
"count": len(rules)
|
| 57 |
+
}
|
| 58 |
|
| 59 |
|
| 60 |
@router.post("/rules")
|
|
|
|
| 64 |
x_tenant_id: str = Header(None)
|
| 65 |
):
|
| 66 |
"""
|
| 67 |
+
Adds a new red-flag rule to this tenant with optional regex pattern and severity.
|
| 68 |
+
Accepts either JSON body or query parameter ?rule=...
|
| 69 |
+
JSON body supports: rule, pattern (regex), severity (low/medium/high/critical), description, enabled
|
| 70 |
"""
|
| 71 |
|
| 72 |
if not x_tenant_id:
|
|
|
|
| 80 |
if not rule_value:
|
| 81 |
raise HTTPException(status_code=400, detail="Rule cannot be empty")
|
| 82 |
|
| 83 |
+
# Extract optional parameters if payload provided
|
| 84 |
+
pattern = payload.pattern if payload else None
|
| 85 |
+
severity = payload.severity if payload else "medium"
|
| 86 |
+
description = payload.description if payload else None
|
| 87 |
+
enabled = payload.enabled if payload else True
|
| 88 |
+
|
| 89 |
+
# Validate severity
|
| 90 |
+
if severity not in ["low", "medium", "high", "critical"]:
|
| 91 |
+
severity = "medium"
|
| 92 |
+
|
| 93 |
+
rules_store.add_rule(
|
| 94 |
+
x_tenant_id,
|
| 95 |
+
rule_value,
|
| 96 |
+
pattern=pattern,
|
| 97 |
+
severity=severity,
|
| 98 |
+
description=description,
|
| 99 |
+
enabled=enabled
|
| 100 |
+
)
|
| 101 |
rules = get_rules_for_tenant(x_tenant_id)
|
| 102 |
|
| 103 |
return {
|
| 104 |
"tenant_id": x_tenant_id,
|
| 105 |
"added_rule": rule_value,
|
| 106 |
+
"pattern": pattern or rule_value,
|
| 107 |
+
"severity": severity,
|
| 108 |
+
"description": description or rule_value,
|
| 109 |
"rules": rules
|
| 110 |
}
|
| 111 |
|
|
|
|
| 158 |
"deleted_rule": rule,
|
| 159 |
"rules": rules
|
| 160 |
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@router.get("/violations")
|
| 164 |
+
async def get_violations(
|
| 165 |
+
x_tenant_id: str = Header(None),
|
| 166 |
+
limit: int = Query(50, description="Maximum number of violations to return"),
|
| 167 |
+
days: int = Query(30, description="Number of days to look back")
|
| 168 |
+
):
|
| 169 |
+
"""
|
| 170 |
+
Returns red-flag violations for this tenant.
|
| 171 |
+
Includes rule details, severity, confidence, and timestamps.
|
| 172 |
+
"""
|
| 173 |
+
|
| 174 |
+
if not x_tenant_id:
|
| 175 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 176 |
+
|
| 177 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 178 |
+
violations = analytics_store.get_redflag_violations(x_tenant_id, limit, since_timestamp)
|
| 179 |
+
|
| 180 |
+
# Convert timestamps to ISO format
|
| 181 |
+
for violation in violations:
|
| 182 |
+
if "timestamp" in violation:
|
| 183 |
+
violation["timestamp_iso"] = datetime.fromtimestamp(violation["timestamp"]).isoformat()
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
"tenant_id": x_tenant_id,
|
| 187 |
+
"violations": violations,
|
| 188 |
+
"count": len(violations),
|
| 189 |
+
"period_days": days
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@router.get("/tools/logs")
|
| 194 |
+
async def get_tool_logs(
|
| 195 |
+
x_tenant_id: str = Header(None),
|
| 196 |
+
tool_name: Optional[str] = Query(None, description="Filter by tool name (rag, web, admin, llm)"),
|
| 197 |
+
days: int = Query(7, description="Number of days to look back"),
|
| 198 |
+
limit: int = Query(100, description="Maximum number of logs to return")
|
| 199 |
+
):
|
| 200 |
+
"""
|
| 201 |
+
Returns detailed tool usage logs for this tenant.
|
| 202 |
+
Includes every tool call with timestamp, latency, tokens, and success/error status.
|
| 203 |
+
"""
|
| 204 |
+
|
| 205 |
+
if not x_tenant_id:
|
| 206 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 207 |
+
|
| 208 |
+
# For now, return aggregated stats. Full log querying would require extending AnalyticsStore
|
| 209 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 210 |
+
tool_stats = analytics_store.get_tool_usage_stats(x_tenant_id, since_timestamp)
|
| 211 |
+
|
| 212 |
+
# Filter by tool if specified
|
| 213 |
+
if tool_name:
|
| 214 |
+
tool_stats = {tool_name: tool_stats.get(tool_name)} if tool_name in tool_stats else {}
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
"tenant_id": x_tenant_id,
|
| 218 |
+
"tool_usage": tool_stats,
|
| 219 |
+
"period_days": days
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.get("/tenants")
|
| 224 |
+
async def list_tenants():
|
| 225 |
+
"""
|
| 226 |
+
Lists all tenants (placeholder - would need tenant management table).
|
| 227 |
+
For demo purposes, returns info about available tenant data.
|
| 228 |
+
"""
|
| 229 |
+
|
| 230 |
+
# Placeholder implementation - in production, this would query a tenants table
|
| 231 |
+
return {
|
| 232 |
+
"tenants": [],
|
| 233 |
+
"message": "Tenant management not fully implemented. Use tenant_id in headers for multi-tenant operations."
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@router.post("/tenants")
|
| 238 |
+
async def create_tenant(
|
| 239 |
+
tenant_id: str,
|
| 240 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 241 |
+
):
|
| 242 |
+
"""
|
| 243 |
+
Creates a new tenant (placeholder - would need tenant management table).
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
# Placeholder implementation
|
| 247 |
+
return {
|
| 248 |
+
"tenant_id": tenant_id,
|
| 249 |
+
"status": "created",
|
| 250 |
+
"message": "Tenant management not fully implemented. Tenant IDs are created on first use."
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@router.delete("/tenants/{tenant_id}")
|
| 255 |
+
async def delete_tenant(tenant_id: str):
|
| 256 |
+
"""
|
| 257 |
+
Deletes a tenant and all associated data (placeholder).
|
| 258 |
+
WARNING: This would delete all rules, analytics, and documents for the tenant.
|
| 259 |
+
"""
|
| 260 |
+
|
| 261 |
+
# Placeholder implementation
|
| 262 |
+
return {
|
| 263 |
+
"tenant_id": tenant_id,
|
| 264 |
+
"status": "deleted",
|
| 265 |
+
"message": "Tenant deletion not fully implemented. This would delete all tenant data."
|
| 266 |
+
}
|
backend/api/routes/agent.py
CHANGED
|
@@ -45,3 +45,92 @@ async def agent_chat(req: ChatRequest):
|
|
| 45 |
temperature=req.temperature
|
| 46 |
)
|
| 47 |
return await orchestrator.handle(agent_req)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
temperature=req.temperature
|
| 46 |
)
|
| 47 |
return await orchestrator.handle(agent_req)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.post("/debug")
|
| 51 |
+
async def agent_debug(req: ChatRequest):
|
| 52 |
+
"""
|
| 53 |
+
Returns detailed debugging information about agent reasoning.
|
| 54 |
+
Includes intent classification, tool selection, reasoning trace, and tool traces.
|
| 55 |
+
"""
|
| 56 |
+
agent_req = AgentRequest(
|
| 57 |
+
tenant_id=req.tenant_id,
|
| 58 |
+
user_id=req.user_id,
|
| 59 |
+
message=req.message,
|
| 60 |
+
conversation_history=req.conversation_history,
|
| 61 |
+
temperature=req.temperature
|
| 62 |
+
)
|
| 63 |
+
response = await orchestrator.handle(agent_req)
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"request": {
|
| 67 |
+
"tenant_id": req.tenant_id,
|
| 68 |
+
"user_id": req.user_id,
|
| 69 |
+
"message": req.message[:200],
|
| 70 |
+
"temperature": req.temperature
|
| 71 |
+
},
|
| 72 |
+
"response": {
|
| 73 |
+
"text": response.text[:500] + "..." if len(response.text) > 500 else response.text,
|
| 74 |
+
"decision": response.decision.dict() if response.decision else None,
|
| 75 |
+
"tool_traces": response.tool_traces,
|
| 76 |
+
"reasoning_trace": response.reasoning_trace
|
| 77 |
+
},
|
| 78 |
+
"debug_info": {
|
| 79 |
+
"intent": response.reasoning_trace[1].get("intent") if len(response.reasoning_trace) > 1 else None,
|
| 80 |
+
"tool_selection": next((t for t in response.reasoning_trace if t.get("step") == "tool_selection"), None),
|
| 81 |
+
"tool_scores": next((t for t in response.reasoning_trace if t.get("step") == "tool_scoring"), None),
|
| 82 |
+
"redflag_check": next((t for t in response.reasoning_trace if t.get("step") == "redflag_check"), None),
|
| 83 |
+
"total_steps": len(response.reasoning_trace)
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/plan")
|
| 89 |
+
async def agent_plan(req: ChatRequest):
|
| 90 |
+
"""
|
| 91 |
+
Returns only the agent's planning output (tool selection decision).
|
| 92 |
+
Useful for understanding what tools the agent would use without executing them.
|
| 93 |
+
"""
|
| 94 |
+
from ..services.intent_classifier import IntentClassifier
|
| 95 |
+
from ..services.tool_selector import ToolSelector
|
| 96 |
+
from ..services.tool_scoring import ToolScoringService
|
| 97 |
+
import os
|
| 98 |
+
|
| 99 |
+
# Create minimal orchestrator components for planning only
|
| 100 |
+
llm = orchestrator.llm
|
| 101 |
+
intent_classifier = IntentClassifier(llm_client=llm)
|
| 102 |
+
tool_selector = ToolSelector(llm_client=llm)
|
| 103 |
+
tool_scorer = ToolScoringService()
|
| 104 |
+
|
| 105 |
+
# Classify intent
|
| 106 |
+
intent = await intent_classifier.classify(req.message)
|
| 107 |
+
|
| 108 |
+
# Pre-fetch RAG for context (optional)
|
| 109 |
+
rag_results = []
|
| 110 |
+
try:
|
| 111 |
+
rag_prefetch = await orchestrator.mcp.call_rag(req.tenant_id, req.message)
|
| 112 |
+
if isinstance(rag_prefetch, dict):
|
| 113 |
+
rag_results = rag_prefetch.get("results") or rag_prefetch.get("hits") or []
|
| 114 |
+
except Exception:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
# Score tools
|
| 118 |
+
tool_scores = tool_scorer.score(req.message, intent, rag_results)
|
| 119 |
+
|
| 120 |
+
# Select tools
|
| 121 |
+
ctx = {
|
| 122 |
+
"tenant_id": req.tenant_id,
|
| 123 |
+
"rag_results": rag_results,
|
| 124 |
+
"tool_scores": tool_scores
|
| 125 |
+
}
|
| 126 |
+
decision = await tool_selector.select(intent, req.message, ctx)
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
"tenant_id": req.tenant_id,
|
| 130 |
+
"message": req.message,
|
| 131 |
+
"intent": intent,
|
| 132 |
+
"tool_scores": tool_scores,
|
| 133 |
+
"plan": decision.dict(),
|
| 134 |
+
"steps": decision.tool_input.get("steps", []) if decision.tool_input else [],
|
| 135 |
+
"reason": decision.reason
|
| 136 |
+
}
|
backend/api/routes/analytics.py
CHANGED
|
@@ -1,103 +1,140 @@
|
|
| 1 |
-
from fastapi import APIRouter, Header, HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
router = APIRouter()
|
| 4 |
|
| 5 |
-
#
|
| 6 |
-
|
| 7 |
-
"tool_usage": {
|
| 8 |
-
"rag": 12,
|
| 9 |
-
"web": 8,
|
| 10 |
-
"admin": 3
|
| 11 |
-
},
|
| 12 |
-
"redflags": [
|
| 13 |
-
{
|
| 14 |
-
"tenant": "tenant123",
|
| 15 |
-
"match": "salary",
|
| 16 |
-
"message": "get salary data now",
|
| 17 |
-
"timestamp": "2025-01-14T10:22:00Z"
|
| 18 |
-
}
|
| 19 |
-
],
|
| 20 |
-
"activity": {
|
| 21 |
-
"total_queries": 23,
|
| 22 |
-
"active_users": 4,
|
| 23 |
-
"last_query": "2025-01-14T10:24:31Z"
|
| 24 |
-
}
|
| 25 |
-
}
|
| 26 |
|
| 27 |
|
| 28 |
@router.get("/overview")
|
| 29 |
async def analytics_overview(
|
| 30 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 31 |
):
|
| 32 |
"""
|
| 33 |
Returns an overview of analytics for the dashboard.
|
|
|
|
| 34 |
"""
|
| 35 |
|
| 36 |
if not x_tenant_id:
|
| 37 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
return {
|
| 40 |
"tenant_id": x_tenant_id,
|
| 41 |
"overview": {
|
| 42 |
-
"total_queries":
|
| 43 |
-
"tool_usage":
|
| 44 |
-
"redflag_count":
|
| 45 |
-
"active_users":
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
|
| 50 |
@router.get("/tool-usage")
|
| 51 |
async def analytics_tool_usage(
|
| 52 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 53 |
):
|
| 54 |
"""
|
| 55 |
-
Returns how often each tool (RAG, Web, Admin) was used.
|
|
|
|
| 56 |
"""
|
| 57 |
|
| 58 |
if not x_tenant_id:
|
| 59 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
return {
|
| 62 |
"tenant_id": x_tenant_id,
|
| 63 |
-
"tool_usage":
|
|
|
|
| 64 |
}
|
| 65 |
|
| 66 |
|
| 67 |
@router.get("/redflags")
|
| 68 |
async def analytics_redflags(
|
| 69 |
-
x_tenant_id: str = Header(None)
|
|
|
|
|
|
|
| 70 |
):
|
| 71 |
"""
|
| 72 |
Returns red-flag violations for this tenant.
|
|
|
|
| 73 |
"""
|
| 74 |
|
| 75 |
if not x_tenant_id:
|
| 76 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 77 |
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
return {
|
| 84 |
"tenant_id": x_tenant_id,
|
| 85 |
-
"redflags": redflags
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
|
| 89 |
@router.get("/activity")
|
| 90 |
async def analytics_activity(
|
| 91 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 92 |
):
|
| 93 |
"""
|
| 94 |
Returns general tenant activity statistics.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
"""
|
| 96 |
|
| 97 |
if not x_tenant_id:
|
| 98 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 99 |
|
|
|
|
|
|
|
|
|
|
| 100 |
return {
|
| 101 |
"tenant_id": x_tenant_id,
|
| 102 |
-
"
|
|
|
|
| 103 |
}
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Header, HTTPException, Query
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
|
| 5 |
+
from ..storage.analytics_store import AnalyticsStore
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
| 9 |
+
# Initialize analytics store
|
| 10 |
+
analytics_store = AnalyticsStore()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
@router.get("/overview")
|
| 14 |
async def analytics_overview(
|
| 15 |
+
x_tenant_id: str = Header(None),
|
| 16 |
+
days: int = Query(30, description="Number of days to look back")
|
| 17 |
):
|
| 18 |
"""
|
| 19 |
Returns an overview of analytics for the dashboard.
|
| 20 |
+
Includes total queries, tool usage, red-flag count, and active users.
|
| 21 |
"""
|
| 22 |
|
| 23 |
if not x_tenant_id:
|
| 24 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 25 |
|
| 26 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 27 |
+
|
| 28 |
+
tool_usage = analytics_store.get_tool_usage_stats(x_tenant_id, since_timestamp)
|
| 29 |
+
activity = analytics_store.get_activity_summary(x_tenant_id, since_timestamp)
|
| 30 |
+
rag_quality = analytics_store.get_rag_quality_metrics(x_tenant_id, since_timestamp)
|
| 31 |
+
|
| 32 |
return {
|
| 33 |
"tenant_id": x_tenant_id,
|
| 34 |
"overview": {
|
| 35 |
+
"total_queries": activity["total_queries"],
|
| 36 |
+
"tool_usage": tool_usage,
|
| 37 |
+
"redflag_count": activity["redflag_count"],
|
| 38 |
+
"active_users": activity["active_users"],
|
| 39 |
+
"last_query": activity["last_query"],
|
| 40 |
+
"rag_quality": rag_quality
|
| 41 |
}
|
| 42 |
}
|
| 43 |
|
| 44 |
|
| 45 |
@router.get("/tool-usage")
|
| 46 |
async def analytics_tool_usage(
|
| 47 |
+
x_tenant_id: str = Header(None),
|
| 48 |
+
days: int = Query(30, description="Number of days to look back")
|
| 49 |
):
|
| 50 |
"""
|
| 51 |
+
Returns how often each tool (RAG, Web, Admin, LLM) was used with detailed stats.
|
| 52 |
+
Includes counts, latency, tokens, and success/error rates.
|
| 53 |
"""
|
| 54 |
|
| 55 |
if not x_tenant_id:
|
| 56 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 57 |
|
| 58 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 59 |
+
tool_usage = analytics_store.get_tool_usage_stats(x_tenant_id, since_timestamp)
|
| 60 |
+
|
| 61 |
return {
|
| 62 |
"tenant_id": x_tenant_id,
|
| 63 |
+
"tool_usage": tool_usage,
|
| 64 |
+
"period_days": days
|
| 65 |
}
|
| 66 |
|
| 67 |
|
| 68 |
@router.get("/redflags")
|
| 69 |
async def analytics_redflags(
|
| 70 |
+
x_tenant_id: str = Header(None),
|
| 71 |
+
limit: int = Query(50, description="Maximum number of violations to return"),
|
| 72 |
+
days: int = Query(30, description="Number of days to look back")
|
| 73 |
):
|
| 74 |
"""
|
| 75 |
Returns red-flag violations for this tenant.
|
| 76 |
+
Includes rule details, severity, confidence, and timestamps.
|
| 77 |
"""
|
| 78 |
|
| 79 |
if not x_tenant_id:
|
| 80 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 81 |
|
| 82 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 83 |
+
redflags = analytics_store.get_redflag_violations(x_tenant_id, limit, since_timestamp)
|
| 84 |
+
|
| 85 |
+
# Convert timestamps to ISO format
|
| 86 |
+
for violation in redflags:
|
| 87 |
+
if "timestamp" in violation:
|
| 88 |
+
violation["timestamp_iso"] = datetime.fromtimestamp(violation["timestamp"]).isoformat()
|
| 89 |
|
| 90 |
return {
|
| 91 |
"tenant_id": x_tenant_id,
|
| 92 |
+
"redflags": redflags,
|
| 93 |
+
"count": len(redflags)
|
| 94 |
}
|
| 95 |
|
| 96 |
|
| 97 |
@router.get("/activity")
|
| 98 |
async def analytics_activity(
|
| 99 |
+
x_tenant_id: str = Header(None),
|
| 100 |
+
days: int = Query(30, description="Number of days to look back")
|
| 101 |
):
|
| 102 |
"""
|
| 103 |
Returns general tenant activity statistics.
|
| 104 |
+
Includes total queries, active users, and last query timestamp.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
if not x_tenant_id:
|
| 108 |
+
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 109 |
+
|
| 110 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 111 |
+
activity = analytics_store.get_activity_summary(x_tenant_id, since_timestamp)
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"tenant_id": x_tenant_id,
|
| 115 |
+
"activity": activity,
|
| 116 |
+
"period_days": days
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.get("/rag-quality")
|
| 121 |
+
async def analytics_rag_quality(
|
| 122 |
+
x_tenant_id: str = Header(None),
|
| 123 |
+
days: int = Query(30, description="Number of days to look back")
|
| 124 |
+
):
|
| 125 |
+
"""
|
| 126 |
+
Returns RAG quality metrics including recall/precision indicators.
|
| 127 |
+
Includes average hits, scores, and latency.
|
| 128 |
"""
|
| 129 |
|
| 130 |
if not x_tenant_id:
|
| 131 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 132 |
|
| 133 |
+
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 134 |
+
rag_quality = analytics_store.get_rag_quality_metrics(x_tenant_id, since_timestamp)
|
| 135 |
+
|
| 136 |
return {
|
| 137 |
"tenant_id": x_tenant_id,
|
| 138 |
+
"rag_quality": rag_quality,
|
| 139 |
+
"period_days": days
|
| 140 |
}
|
backend/api/services/agent_orchestrator.py
CHANGED
|
@@ -22,6 +22,8 @@ from .tool_selector import ToolSelector
|
|
| 22 |
from .llm_client import LLMClient
|
| 23 |
from ..mcp_clients.mcp_client import MCPClient
|
| 24 |
from .tool_scoring import ToolScoringService
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class AgentOrchestrator:
|
|
@@ -40,8 +42,10 @@ class AgentOrchestrator:
|
|
| 40 |
self.intent = IntentClassifier(llm_client=self.llm)
|
| 41 |
self.selector = ToolSelector(llm_client=self.llm)
|
| 42 |
self.tool_scorer = ToolScoringService()
|
|
|
|
| 43 |
|
| 44 |
async def handle(self, req: AgentRequest) -> AgentResponse:
|
|
|
|
| 45 |
reasoning_trace: List[Dict[str, Any]] = []
|
| 46 |
reasoning_trace.append({
|
| 47 |
"step": "request_received",
|
|
@@ -58,6 +62,19 @@ class AgentOrchestrator:
|
|
| 58 |
"matches": [m.__dict__ for m in matches]
|
| 59 |
})
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
if matches:
|
| 62 |
# Notify admin asynchronously (do not await blocking the response path if you prefer)
|
| 63 |
# we await here to ensure admin receives the alert before responding
|
|
@@ -76,6 +93,19 @@ class AgentOrchestrator:
|
|
| 76 |
f"{m.description or m.pattern} [severity: {m.severity}]"
|
| 77 |
for m in matches
|
| 78 |
) or "Policy violation detected"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
return AgentResponse(
|
| 80 |
text=f"β οΈ Request blocked by Admin Plan: {summary}. Please review your governance rules or contact an administrator.",
|
| 81 |
decision=decision,
|
|
@@ -95,16 +125,54 @@ class AgentOrchestrator:
|
|
| 95 |
rag_results = []
|
| 96 |
try:
|
| 97 |
# Try to pre-fetch RAG to help tool selector make better decisions
|
|
|
|
| 98 |
rag_prefetch = await self.mcp.call_rag(req.tenant_id, req.message)
|
|
|
|
|
|
|
| 99 |
if isinstance(rag_prefetch, dict):
|
| 100 |
rag_results = rag_prefetch.get("results") or rag_prefetch.get("hits") or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
reasoning_trace.append({
|
| 102 |
"step": "rag_prefetch",
|
| 103 |
"status": "ok",
|
| 104 |
-
"hit_count": len(rag_results)
|
|
|
|
| 105 |
})
|
| 106 |
except Exception as pref_err:
|
| 107 |
# If RAG fails, continue without it
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
reasoning_trace.append({
|
| 109 |
"step": "rag_prefetch",
|
| 110 |
"status": "error",
|
|
@@ -147,58 +215,230 @@ class AgentOrchestrator:
|
|
| 147 |
)
|
| 148 |
|
| 149 |
# 5) Execute single tool
|
|
|
|
|
|
|
|
|
|
| 150 |
if decision.action == "call_tool" and decision.tool:
|
| 151 |
try:
|
| 152 |
if decision.tool == "rag":
|
|
|
|
| 153 |
rag_resp = await self.mcp.call_rag(req.tenant_id, decision.tool_input.get("query") if decision.tool_input else req.message)
|
|
|
|
|
|
|
|
|
|
| 154 |
tool_traces.append({"tool": "rag", "response": rag_resp})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
reasoning_trace.append({
|
| 156 |
"step": "tool_execution",
|
| 157 |
"tool": "rag",
|
| 158 |
-
"hit_count":
|
| 159 |
-
"summary": self._summarize_hits(rag_resp, limit=2)
|
|
|
|
| 160 |
})
|
| 161 |
prompt = self._build_prompt_with_rag(req, rag_resp)
|
|
|
|
|
|
|
| 162 |
llm_out = await self.llm.simple_call(prompt, temperature=req.temperature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
reasoning_trace.append({
|
| 164 |
"step": "llm_response",
|
| 165 |
-
"mode": "rag_synthesis"
|
|
|
|
|
|
|
| 166 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
return AgentResponse(text=llm_out, decision=decision, tool_traces=tool_traces, reasoning_trace=reasoning_trace)
|
| 168 |
|
| 169 |
if decision.tool == "web":
|
|
|
|
| 170 |
web_resp = await self.mcp.call_web(req.tenant_id, decision.tool_input.get("query") if decision.tool_input else req.message)
|
|
|
|
|
|
|
|
|
|
| 171 |
tool_traces.append({"tool": "web", "response": web_resp})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
reasoning_trace.append({
|
| 173 |
"step": "tool_execution",
|
| 174 |
"tool": "web",
|
| 175 |
-
"hit_count":
|
| 176 |
-
"summary": self._summarize_hits(web_resp, limit=2)
|
|
|
|
| 177 |
})
|
| 178 |
prompt = self._build_prompt_with_web(req, web_resp)
|
|
|
|
|
|
|
| 179 |
llm_out = await self.llm.simple_call(prompt, temperature=req.temperature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
reasoning_trace.append({
|
| 181 |
"step": "llm_response",
|
| 182 |
-
"mode": "web_synthesis"
|
|
|
|
|
|
|
| 183 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return AgentResponse(text=llm_out, decision=decision, tool_traces=tool_traces, reasoning_trace=reasoning_trace)
|
| 185 |
|
| 186 |
if decision.tool == "admin":
|
|
|
|
| 187 |
admin_resp = await self.mcp.call_admin(req.tenant_id, decision.tool_input.get("query") if decision.tool_input else req.message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
tool_traces.append({"tool": "admin", "response": admin_resp})
|
| 189 |
reasoning_trace.append({
|
| 190 |
"step": "tool_execution",
|
| 191 |
"tool": "admin",
|
| 192 |
-
"status": "completed"
|
|
|
|
| 193 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
return AgentResponse(text=json.dumps(admin_resp), decision=decision, tool_traces=tool_traces, reasoning_trace=reasoning_trace)
|
| 195 |
|
| 196 |
if decision.tool == "llm":
|
|
|
|
| 197 |
llm_out = await self.llm.simple_call(req.message, temperature=req.temperature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
reasoning_trace.append({
|
| 199 |
"step": "llm_response",
|
| 200 |
-
"mode": "direct"
|
|
|
|
|
|
|
| 201 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
return AgentResponse(text=llm_out, decision=decision, reasoning_trace=reasoning_trace)
|
| 203 |
|
| 204 |
except Exception as e:
|
|
@@ -231,7 +471,20 @@ class AgentOrchestrator:
|
|
| 231 |
|
| 232 |
# Default: direct LLM response
|
| 233 |
try:
|
|
|
|
| 234 |
llm_out = await self.llm.simple_call(req.message, temperature=req.temperature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
except Exception as e:
|
| 236 |
# If LLM fails, return a helpful error message
|
| 237 |
error_msg = str(e)
|
|
@@ -247,12 +500,32 @@ class AgentOrchestrator:
|
|
| 247 |
)
|
| 248 |
else:
|
| 249 |
llm_out = f"I apologize, but I'm unable to process your request right now. The AI service is unavailable: {error_msg}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
reasoning_trace.append({
|
| 251 |
"step": "error",
|
| 252 |
"tool": "llm",
|
| 253 |
"error": str(e)
|
| 254 |
})
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
return AgentResponse(
|
| 257 |
text=llm_out,
|
| 258 |
decision=AgentDecision(action="respond", tool=None, tool_input=None, reason="default_llm"),
|
|
|
|
| 22 |
from .llm_client import LLMClient
|
| 23 |
from ..mcp_clients.mcp_client import MCPClient
|
| 24 |
from .tool_scoring import ToolScoringService
|
| 25 |
+
from ..storage.analytics_store import AnalyticsStore
|
| 26 |
+
import time
|
| 27 |
|
| 28 |
|
| 29 |
class AgentOrchestrator:
|
|
|
|
| 42 |
self.intent = IntentClassifier(llm_client=self.llm)
|
| 43 |
self.selector = ToolSelector(llm_client=self.llm)
|
| 44 |
self.tool_scorer = ToolScoringService()
|
| 45 |
+
self.analytics = AnalyticsStore()
|
| 46 |
|
| 47 |
async def handle(self, req: AgentRequest) -> AgentResponse:
|
| 48 |
+
start_time = time.time()
|
| 49 |
reasoning_trace: List[Dict[str, Any]] = []
|
| 50 |
reasoning_trace.append({
|
| 51 |
"step": "request_received",
|
|
|
|
| 62 |
"matches": [m.__dict__ for m in matches]
|
| 63 |
})
|
| 64 |
|
| 65 |
+
# Log red-flag violations
|
| 66 |
+
for match in matches:
|
| 67 |
+
self.analytics.log_redflag_violation(
|
| 68 |
+
tenant_id=req.tenant_id,
|
| 69 |
+
rule_id=match.rule_id,
|
| 70 |
+
rule_pattern=match.pattern,
|
| 71 |
+
severity=match.severity,
|
| 72 |
+
matched_text=match.matched_text,
|
| 73 |
+
confidence=match.confidence,
|
| 74 |
+
message_preview=req.message[:200],
|
| 75 |
+
user_id=req.user_id
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
if matches:
|
| 79 |
# Notify admin asynchronously (do not await blocking the response path if you prefer)
|
| 80 |
# we await here to ensure admin receives the alert before responding
|
|
|
|
| 93 |
f"{m.description or m.pattern} [severity: {m.severity}]"
|
| 94 |
for m in matches
|
| 95 |
) or "Policy violation detected"
|
| 96 |
+
|
| 97 |
+
total_latency_ms = int((time.time() - start_time) * 1000)
|
| 98 |
+
self.analytics.log_agent_query(
|
| 99 |
+
tenant_id=req.tenant_id,
|
| 100 |
+
message_preview=req.message[:200],
|
| 101 |
+
intent="admin",
|
| 102 |
+
tools_used=["admin"],
|
| 103 |
+
total_tokens=0,
|
| 104 |
+
total_latency_ms=total_latency_ms,
|
| 105 |
+
success=False,
|
| 106 |
+
user_id=req.user_id
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
return AgentResponse(
|
| 110 |
text=f"β οΈ Request blocked by Admin Plan: {summary}. Please review your governance rules or contact an administrator.",
|
| 111 |
decision=decision,
|
|
|
|
| 125 |
rag_results = []
|
| 126 |
try:
|
| 127 |
# Try to pre-fetch RAG to help tool selector make better decisions
|
| 128 |
+
rag_start = time.time()
|
| 129 |
rag_prefetch = await self.mcp.call_rag(req.tenant_id, req.message)
|
| 130 |
+
rag_latency_ms = int((time.time() - rag_start) * 1000)
|
| 131 |
+
|
| 132 |
if isinstance(rag_prefetch, dict):
|
| 133 |
rag_results = rag_prefetch.get("results") or rag_prefetch.get("hits") or []
|
| 134 |
+
# Log RAG search event
|
| 135 |
+
hits_count = len(rag_results)
|
| 136 |
+
avg_score = None
|
| 137 |
+
top_score = None
|
| 138 |
+
if rag_results:
|
| 139 |
+
scores = [h.get("score", 0.0) for h in rag_results if isinstance(h, dict) and "score" in h]
|
| 140 |
+
if scores:
|
| 141 |
+
avg_score = sum(scores) / len(scores)
|
| 142 |
+
top_score = max(scores)
|
| 143 |
+
self.analytics.log_rag_search(
|
| 144 |
+
tenant_id=req.tenant_id,
|
| 145 |
+
query=req.message[:500],
|
| 146 |
+
hits_count=hits_count,
|
| 147 |
+
avg_score=avg_score,
|
| 148 |
+
top_score=top_score,
|
| 149 |
+
latency_ms=rag_latency_ms
|
| 150 |
+
)
|
| 151 |
+
# Log tool usage
|
| 152 |
+
self.analytics.log_tool_usage(
|
| 153 |
+
tenant_id=req.tenant_id,
|
| 154 |
+
tool_name="rag",
|
| 155 |
+
latency_ms=rag_latency_ms,
|
| 156 |
+
success=True,
|
| 157 |
+
user_id=req.user_id
|
| 158 |
+
)
|
| 159 |
reasoning_trace.append({
|
| 160 |
"step": "rag_prefetch",
|
| 161 |
"status": "ok",
|
| 162 |
+
"hit_count": len(rag_results),
|
| 163 |
+
"latency_ms": rag_latency_ms
|
| 164 |
})
|
| 165 |
except Exception as pref_err:
|
| 166 |
# If RAG fails, continue without it
|
| 167 |
+
rag_latency_ms = 0 # 0 for failed
|
| 168 |
+
self.analytics.log_tool_usage(
|
| 169 |
+
tenant_id=req.tenant_id,
|
| 170 |
+
tool_name="rag",
|
| 171 |
+
latency_ms=rag_latency_ms,
|
| 172 |
+
success=False,
|
| 173 |
+
error_message=str(pref_err)[:200],
|
| 174 |
+
user_id=req.user_id
|
| 175 |
+
)
|
| 176 |
reasoning_trace.append({
|
| 177 |
"step": "rag_prefetch",
|
| 178 |
"status": "error",
|
|
|
|
| 215 |
)
|
| 216 |
|
| 217 |
# 5) Execute single tool
|
| 218 |
+
tools_used = []
|
| 219 |
+
total_tokens = 0
|
| 220 |
+
|
| 221 |
if decision.action == "call_tool" and decision.tool:
|
| 222 |
try:
|
| 223 |
if decision.tool == "rag":
|
| 224 |
+
rag_start = time.time()
|
| 225 |
rag_resp = await self.mcp.call_rag(req.tenant_id, decision.tool_input.get("query") if decision.tool_input else req.message)
|
| 226 |
+
rag_latency_ms = int((time.time() - rag_start) * 1000)
|
| 227 |
+
tools_used.append("rag")
|
| 228 |
+
|
| 229 |
tool_traces.append({"tool": "rag", "response": rag_resp})
|
| 230 |
+
hits = self._extract_hits(rag_resp)
|
| 231 |
+
|
| 232 |
+
# Log RAG search and tool usage
|
| 233 |
+
hits_count = len(hits)
|
| 234 |
+
avg_score = None
|
| 235 |
+
top_score = None
|
| 236 |
+
if hits:
|
| 237 |
+
scores = [h.get("score", 0.0) for h in hits if isinstance(h, dict) and "score" in h]
|
| 238 |
+
if scores:
|
| 239 |
+
avg_score = sum(scores) / len(scores)
|
| 240 |
+
top_score = max(scores)
|
| 241 |
+
self.analytics.log_rag_search(
|
| 242 |
+
tenant_id=req.tenant_id,
|
| 243 |
+
query=req.message[:500],
|
| 244 |
+
hits_count=hits_count,
|
| 245 |
+
avg_score=avg_score,
|
| 246 |
+
top_score=top_score,
|
| 247 |
+
latency_ms=rag_latency_ms
|
| 248 |
+
)
|
| 249 |
+
self.analytics.log_tool_usage(
|
| 250 |
+
tenant_id=req.tenant_id,
|
| 251 |
+
tool_name="rag",
|
| 252 |
+
latency_ms=rag_latency_ms,
|
| 253 |
+
success=True,
|
| 254 |
+
user_id=req.user_id
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
reasoning_trace.append({
|
| 258 |
"step": "tool_execution",
|
| 259 |
"tool": "rag",
|
| 260 |
+
"hit_count": hits_count,
|
| 261 |
+
"summary": self._summarize_hits(rag_resp, limit=2),
|
| 262 |
+
"latency_ms": rag_latency_ms
|
| 263 |
})
|
| 264 |
prompt = self._build_prompt_with_rag(req, rag_resp)
|
| 265 |
+
|
| 266 |
+
llm_start = time.time()
|
| 267 |
llm_out = await self.llm.simple_call(prompt, temperature=req.temperature)
|
| 268 |
+
llm_latency_ms = int((time.time() - llm_start) * 1000)
|
| 269 |
+
tools_used.append("llm")
|
| 270 |
+
|
| 271 |
+
# Estimate tokens (rough: ~4 chars per token)
|
| 272 |
+
estimated_tokens = len(llm_out) // 4 + len(prompt) // 4
|
| 273 |
+
total_tokens += estimated_tokens
|
| 274 |
+
|
| 275 |
+
self.analytics.log_tool_usage(
|
| 276 |
+
tenant_id=req.tenant_id,
|
| 277 |
+
tool_name="llm",
|
| 278 |
+
latency_ms=llm_latency_ms,
|
| 279 |
+
tokens_used=estimated_tokens,
|
| 280 |
+
success=True,
|
| 281 |
+
user_id=req.user_id
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
reasoning_trace.append({
|
| 285 |
"step": "llm_response",
|
| 286 |
+
"mode": "rag_synthesis",
|
| 287 |
+
"latency_ms": llm_latency_ms,
|
| 288 |
+
"estimated_tokens": estimated_tokens
|
| 289 |
})
|
| 290 |
+
|
| 291 |
+
total_latency_ms = int((time.time() - start_time) * 1000)
|
| 292 |
+
self.analytics.log_agent_query(
|
| 293 |
+
tenant_id=req.tenant_id,
|
| 294 |
+
message_preview=req.message[:200],
|
| 295 |
+
intent=intent,
|
| 296 |
+
tools_used=tools_used,
|
| 297 |
+
total_tokens=total_tokens,
|
| 298 |
+
total_latency_ms=total_latency_ms,
|
| 299 |
+
success=True,
|
| 300 |
+
user_id=req.user_id
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
return AgentResponse(text=llm_out, decision=decision, tool_traces=tool_traces, reasoning_trace=reasoning_trace)
|
| 304 |
|
| 305 |
if decision.tool == "web":
|
| 306 |
+
web_start = time.time()
|
| 307 |
web_resp = await self.mcp.call_web(req.tenant_id, decision.tool_input.get("query") if decision.tool_input else req.message)
|
| 308 |
+
web_latency_ms = int((time.time() - web_start) * 1000)
|
| 309 |
+
tools_used.append("web")
|
| 310 |
+
|
| 311 |
tool_traces.append({"tool": "web", "response": web_resp})
|
| 312 |
+
hits_count = len(self._extract_hits(web_resp))
|
| 313 |
+
|
| 314 |
+
self.analytics.log_tool_usage(
|
| 315 |
+
tenant_id=req.tenant_id,
|
| 316 |
+
tool_name="web",
|
| 317 |
+
latency_ms=web_latency_ms,
|
| 318 |
+
success=True,
|
| 319 |
+
user_id=req.user_id
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
reasoning_trace.append({
|
| 323 |
"step": "tool_execution",
|
| 324 |
"tool": "web",
|
| 325 |
+
"hit_count": hits_count,
|
| 326 |
+
"summary": self._summarize_hits(web_resp, limit=2),
|
| 327 |
+
"latency_ms": web_latency_ms
|
| 328 |
})
|
| 329 |
prompt = self._build_prompt_with_web(req, web_resp)
|
| 330 |
+
|
| 331 |
+
llm_start = time.time()
|
| 332 |
llm_out = await self.llm.simple_call(prompt, temperature=req.temperature)
|
| 333 |
+
llm_latency_ms = int((time.time() - llm_start) * 1000)
|
| 334 |
+
tools_used.append("llm")
|
| 335 |
+
|
| 336 |
+
estimated_tokens = len(llm_out) // 4 + len(prompt) // 4
|
| 337 |
+
total_tokens += estimated_tokens
|
| 338 |
+
|
| 339 |
+
self.analytics.log_tool_usage(
|
| 340 |
+
tenant_id=req.tenant_id,
|
| 341 |
+
tool_name="llm",
|
| 342 |
+
latency_ms=llm_latency_ms,
|
| 343 |
+
tokens_used=estimated_tokens,
|
| 344 |
+
success=True,
|
| 345 |
+
user_id=req.user_id
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
reasoning_trace.append({
|
| 349 |
"step": "llm_response",
|
| 350 |
+
"mode": "web_synthesis",
|
| 351 |
+
"latency_ms": llm_latency_ms,
|
| 352 |
+
"estimated_tokens": estimated_tokens
|
| 353 |
})
|
| 354 |
+
|
| 355 |
+
total_latency_ms = int((time.time() - start_time) * 1000)
|
| 356 |
+
self.analytics.log_agent_query(
|
| 357 |
+
tenant_id=req.tenant_id,
|
| 358 |
+
message_preview=req.message[:200],
|
| 359 |
+
intent=intent,
|
| 360 |
+
tools_used=tools_used,
|
| 361 |
+
total_tokens=total_tokens,
|
| 362 |
+
total_latency_ms=total_latency_ms,
|
| 363 |
+
success=True,
|
| 364 |
+
user_id=req.user_id
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
return AgentResponse(text=llm_out, decision=decision, tool_traces=tool_traces, reasoning_trace=reasoning_trace)
|
| 368 |
|
| 369 |
if decision.tool == "admin":
|
| 370 |
+
admin_start = time.time()
|
| 371 |
admin_resp = await self.mcp.call_admin(req.tenant_id, decision.tool_input.get("query") if decision.tool_input else req.message)
|
| 372 |
+
admin_latency_ms = int((time.time() - admin_start) * 1000)
|
| 373 |
+
tools_used.append("admin")
|
| 374 |
+
|
| 375 |
+
self.analytics.log_tool_usage(
|
| 376 |
+
tenant_id=req.tenant_id,
|
| 377 |
+
tool_name="admin",
|
| 378 |
+
latency_ms=admin_latency_ms,
|
| 379 |
+
success=True,
|
| 380 |
+
user_id=req.user_id
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
tool_traces.append({"tool": "admin", "response": admin_resp})
|
| 384 |
reasoning_trace.append({
|
| 385 |
"step": "tool_execution",
|
| 386 |
"tool": "admin",
|
| 387 |
+
"status": "completed",
|
| 388 |
+
"latency_ms": admin_latency_ms
|
| 389 |
})
|
| 390 |
+
|
| 391 |
+
total_latency_ms = int((time.time() - start_time) * 1000)
|
| 392 |
+
self.analytics.log_agent_query(
|
| 393 |
+
tenant_id=req.tenant_id,
|
| 394 |
+
message_preview=req.message[:200],
|
| 395 |
+
intent=intent,
|
| 396 |
+
tools_used=tools_used,
|
| 397 |
+
total_tokens=0,
|
| 398 |
+
total_latency_ms=total_latency_ms,
|
| 399 |
+
success=True,
|
| 400 |
+
user_id=req.user_id
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
return AgentResponse(text=json.dumps(admin_resp), decision=decision, tool_traces=tool_traces, reasoning_trace=reasoning_trace)
|
| 404 |
|
| 405 |
if decision.tool == "llm":
|
| 406 |
+
llm_start = time.time()
|
| 407 |
llm_out = await self.llm.simple_call(req.message, temperature=req.temperature)
|
| 408 |
+
llm_latency_ms = int((time.time() - llm_start) * 1000)
|
| 409 |
+
tools_used.append("llm")
|
| 410 |
+
|
| 411 |
+
estimated_tokens = len(llm_out) // 4 + len(req.message) // 4
|
| 412 |
+
total_tokens += estimated_tokens
|
| 413 |
+
|
| 414 |
+
self.analytics.log_tool_usage(
|
| 415 |
+
tenant_id=req.tenant_id,
|
| 416 |
+
tool_name="llm",
|
| 417 |
+
latency_ms=llm_latency_ms,
|
| 418 |
+
tokens_used=estimated_tokens,
|
| 419 |
+
success=True,
|
| 420 |
+
user_id=req.user_id
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
reasoning_trace.append({
|
| 424 |
"step": "llm_response",
|
| 425 |
+
"mode": "direct",
|
| 426 |
+
"latency_ms": llm_latency_ms,
|
| 427 |
+
"estimated_tokens": estimated_tokens
|
| 428 |
})
|
| 429 |
+
|
| 430 |
+
total_latency_ms = int((time.time() - start_time) * 1000)
|
| 431 |
+
self.analytics.log_agent_query(
|
| 432 |
+
tenant_id=req.tenant_id,
|
| 433 |
+
message_preview=req.message[:200],
|
| 434 |
+
intent=intent,
|
| 435 |
+
tools_used=tools_used,
|
| 436 |
+
total_tokens=total_tokens,
|
| 437 |
+
total_latency_ms=total_latency_ms,
|
| 438 |
+
success=True,
|
| 439 |
+
user_id=req.user_id
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
return AgentResponse(text=llm_out, decision=decision, reasoning_trace=reasoning_trace)
|
| 443 |
|
| 444 |
except Exception as e:
|
|
|
|
| 471 |
|
| 472 |
# Default: direct LLM response
|
| 473 |
try:
|
| 474 |
+
llm_start = time.time()
|
| 475 |
llm_out = await self.llm.simple_call(req.message, temperature=req.temperature)
|
| 476 |
+
llm_latency_ms = int((time.time() - llm_start) * 1000)
|
| 477 |
+
tools_used = ["llm"]
|
| 478 |
+
estimated_tokens = len(llm_out) // 4 + len(req.message) // 4
|
| 479 |
+
|
| 480 |
+
self.analytics.log_tool_usage(
|
| 481 |
+
tenant_id=req.tenant_id,
|
| 482 |
+
tool_name="llm",
|
| 483 |
+
latency_ms=llm_latency_ms,
|
| 484 |
+
tokens_used=estimated_tokens,
|
| 485 |
+
success=True,
|
| 486 |
+
user_id=req.user_id
|
| 487 |
+
)
|
| 488 |
except Exception as e:
|
| 489 |
# If LLM fails, return a helpful error message
|
| 490 |
error_msg = str(e)
|
|
|
|
| 500 |
)
|
| 501 |
else:
|
| 502 |
llm_out = f"I apologize, but I'm unable to process your request right now. The AI service is unavailable: {error_msg}"
|
| 503 |
+
|
| 504 |
+
self.analytics.log_tool_usage(
|
| 505 |
+
tenant_id=req.tenant_id,
|
| 506 |
+
tool_name="llm",
|
| 507 |
+
success=False,
|
| 508 |
+
error_message=error_msg[:200],
|
| 509 |
+
user_id=req.user_id
|
| 510 |
+
)
|
| 511 |
reasoning_trace.append({
|
| 512 |
"step": "error",
|
| 513 |
"tool": "llm",
|
| 514 |
"error": str(e)
|
| 515 |
})
|
| 516 |
|
| 517 |
+
total_latency_ms = int((time.time() - start_time) * 1000)
|
| 518 |
+
self.analytics.log_agent_query(
|
| 519 |
+
tenant_id=req.tenant_id,
|
| 520 |
+
message_preview=req.message[:200],
|
| 521 |
+
intent=intent,
|
| 522 |
+
tools_used=tools_used if 'tools_used' in locals() else [],
|
| 523 |
+
total_tokens=estimated_tokens if 'estimated_tokens' in locals() else 0,
|
| 524 |
+
total_latency_ms=total_latency_ms,
|
| 525 |
+
success=True if 'llm_out' in locals() else False,
|
| 526 |
+
user_id=req.user_id
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
return AgentResponse(
|
| 530 |
text=llm_out,
|
| 531 |
decision=AgentDecision(action="respond", tool=None, tool_input=None, reason="default_llm"),
|
backend/api/storage/analytics_store.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Analytics Store for tenant-level analytics logging
|
| 3 |
+
|
| 4 |
+
Tracks:
|
| 5 |
+
- Tool usage (RAG, Web, Admin, LLM)
|
| 6 |
+
- LLM token counts and latency
|
| 7 |
+
- RAG recall/precision indicators
|
| 8 |
+
- Red-flag violations
|
| 9 |
+
- Per-tenant query volume
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import sqlite3
|
| 13 |
+
import json
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import List, Dict, Any, Optional
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class AnalyticsStore:
|
| 21 |
+
"""
|
| 22 |
+
SQLite-backed store for analytics logging.
|
| 23 |
+
Provides tenant-level analytics for tool usage, tokens, latency, and violations.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, db_path: Optional[str] = None):
|
| 27 |
+
if db_path is None:
|
| 28 |
+
root_dir = Path(__file__).resolve().parents[3]
|
| 29 |
+
data_dir = root_dir / "data"
|
| 30 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
self.db_path = data_dir / "analytics.db"
|
| 32 |
+
else:
|
| 33 |
+
self.db_path = Path(db_path)
|
| 34 |
+
|
| 35 |
+
self._init_db()
|
| 36 |
+
|
| 37 |
+
def _init_db(self):
|
| 38 |
+
"""Initialize database tables for analytics."""
|
| 39 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 40 |
+
# Tool usage events table
|
| 41 |
+
conn.execute("""
|
| 42 |
+
CREATE TABLE IF NOT EXISTS tool_usage_events (
|
| 43 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 44 |
+
tenant_id TEXT NOT NULL,
|
| 45 |
+
user_id TEXT,
|
| 46 |
+
tool_name TEXT NOT NULL,
|
| 47 |
+
timestamp INTEGER NOT NULL,
|
| 48 |
+
latency_ms INTEGER,
|
| 49 |
+
tokens_used INTEGER,
|
| 50 |
+
success BOOLEAN DEFAULT 1,
|
| 51 |
+
error_message TEXT,
|
| 52 |
+
metadata TEXT
|
| 53 |
+
)
|
| 54 |
+
""")
|
| 55 |
+
|
| 56 |
+
# Red-flag violations table
|
| 57 |
+
conn.execute("""
|
| 58 |
+
CREATE TABLE IF NOT EXISTS redflag_violations (
|
| 59 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 60 |
+
tenant_id TEXT NOT NULL,
|
| 61 |
+
user_id TEXT,
|
| 62 |
+
rule_id TEXT NOT NULL,
|
| 63 |
+
rule_pattern TEXT,
|
| 64 |
+
severity TEXT NOT NULL,
|
| 65 |
+
matched_text TEXT,
|
| 66 |
+
confidence REAL,
|
| 67 |
+
message_preview TEXT,
|
| 68 |
+
timestamp INTEGER NOT NULL
|
| 69 |
+
)
|
| 70 |
+
""")
|
| 71 |
+
|
| 72 |
+
# RAG search events with quality metrics
|
| 73 |
+
conn.execute("""
|
| 74 |
+
CREATE TABLE IF NOT EXISTS rag_search_events (
|
| 75 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 76 |
+
tenant_id TEXT NOT NULL,
|
| 77 |
+
query TEXT NOT NULL,
|
| 78 |
+
hits_count INTEGER,
|
| 79 |
+
avg_score REAL,
|
| 80 |
+
top_score REAL,
|
| 81 |
+
timestamp INTEGER NOT NULL,
|
| 82 |
+
latency_ms INTEGER
|
| 83 |
+
)
|
| 84 |
+
""")
|
| 85 |
+
|
| 86 |
+
# Agent query events (overall query tracking)
|
| 87 |
+
conn.execute("""
|
| 88 |
+
CREATE TABLE IF NOT EXISTS agent_query_events (
|
| 89 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 90 |
+
tenant_id TEXT NOT NULL,
|
| 91 |
+
user_id TEXT,
|
| 92 |
+
message_preview TEXT,
|
| 93 |
+
intent TEXT,
|
| 94 |
+
tools_used TEXT,
|
| 95 |
+
total_tokens INTEGER,
|
| 96 |
+
total_latency_ms INTEGER,
|
| 97 |
+
success BOOLEAN DEFAULT 1,
|
| 98 |
+
timestamp INTEGER NOT NULL
|
| 99 |
+
)
|
| 100 |
+
""")
|
| 101 |
+
|
| 102 |
+
# Create indexes separately (SQLite doesn't support inline INDEX in CREATE TABLE)
|
| 103 |
+
conn.execute("""
|
| 104 |
+
CREATE INDEX IF NOT EXISTS idx_tool_usage_tenant_timestamp
|
| 105 |
+
ON tool_usage_events(tenant_id, timestamp)
|
| 106 |
+
""")
|
| 107 |
+
|
| 108 |
+
conn.execute("""
|
| 109 |
+
CREATE INDEX IF NOT EXISTS idx_redflag_tenant_timestamp
|
| 110 |
+
ON redflag_violations(tenant_id, timestamp)
|
| 111 |
+
""")
|
| 112 |
+
|
| 113 |
+
conn.execute("""
|
| 114 |
+
CREATE INDEX IF NOT EXISTS idx_rag_search_tenant_timestamp
|
| 115 |
+
ON rag_search_events(tenant_id, timestamp)
|
| 116 |
+
""")
|
| 117 |
+
|
| 118 |
+
conn.execute("""
|
| 119 |
+
CREATE INDEX IF NOT EXISTS idx_agent_query_tenant_timestamp
|
| 120 |
+
ON agent_query_events(tenant_id, timestamp)
|
| 121 |
+
""")
|
| 122 |
+
|
| 123 |
+
conn.commit()
|
| 124 |
+
|
| 125 |
+
def log_tool_usage(
|
| 126 |
+
self,
|
| 127 |
+
tenant_id: str,
|
| 128 |
+
tool_name: str,
|
| 129 |
+
latency_ms: Optional[int] = None,
|
| 130 |
+
tokens_used: Optional[int] = None,
|
| 131 |
+
success: bool = True,
|
| 132 |
+
error_message: Optional[str] = None,
|
| 133 |
+
metadata: Optional[Dict[str, Any]] = None,
|
| 134 |
+
user_id: Optional[str] = None
|
| 135 |
+
):
|
| 136 |
+
"""Log a tool usage event."""
|
| 137 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 138 |
+
conn.execute("""
|
| 139 |
+
INSERT INTO tool_usage_events
|
| 140 |
+
(tenant_id, user_id, tool_name, timestamp, latency_ms, tokens_used, success, error_message, metadata)
|
| 141 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 142 |
+
""", (
|
| 143 |
+
tenant_id,
|
| 144 |
+
user_id,
|
| 145 |
+
tool_name,
|
| 146 |
+
int(time.time()),
|
| 147 |
+
latency_ms,
|
| 148 |
+
tokens_used,
|
| 149 |
+
1 if success else 0,
|
| 150 |
+
error_message,
|
| 151 |
+
json.dumps(metadata) if metadata else None
|
| 152 |
+
))
|
| 153 |
+
conn.commit()
|
| 154 |
+
|
| 155 |
+
def log_redflag_violation(
|
| 156 |
+
self,
|
| 157 |
+
tenant_id: str,
|
| 158 |
+
rule_id: str,
|
| 159 |
+
rule_pattern: str,
|
| 160 |
+
severity: str,
|
| 161 |
+
matched_text: str,
|
| 162 |
+
confidence: Optional[float] = None,
|
| 163 |
+
message_preview: Optional[str] = None,
|
| 164 |
+
user_id: Optional[str] = None
|
| 165 |
+
):
|
| 166 |
+
"""Log a red-flag violation."""
|
| 167 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 168 |
+
conn.execute("""
|
| 169 |
+
INSERT INTO redflag_violations
|
| 170 |
+
(tenant_id, user_id, rule_id, rule_pattern, severity, matched_text, confidence, message_preview, timestamp)
|
| 171 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 172 |
+
""", (
|
| 173 |
+
tenant_id,
|
| 174 |
+
user_id,
|
| 175 |
+
rule_id,
|
| 176 |
+
rule_pattern,
|
| 177 |
+
severity,
|
| 178 |
+
matched_text,
|
| 179 |
+
confidence,
|
| 180 |
+
message_preview[:200] if message_preview else None,
|
| 181 |
+
int(time.time())
|
| 182 |
+
))
|
| 183 |
+
conn.commit()
|
| 184 |
+
|
| 185 |
+
def log_rag_search(
|
| 186 |
+
self,
|
| 187 |
+
tenant_id: str,
|
| 188 |
+
query: str,
|
| 189 |
+
hits_count: int,
|
| 190 |
+
avg_score: Optional[float] = None,
|
| 191 |
+
top_score: Optional[float] = None,
|
| 192 |
+
latency_ms: Optional[int] = None
|
| 193 |
+
):
|
| 194 |
+
"""Log a RAG search event with quality metrics."""
|
| 195 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 196 |
+
conn.execute("""
|
| 197 |
+
INSERT INTO rag_search_events
|
| 198 |
+
(tenant_id, query, hits_count, avg_score, top_score, timestamp, latency_ms)
|
| 199 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 200 |
+
""", (
|
| 201 |
+
tenant_id,
|
| 202 |
+
query[:500], # Limit query length
|
| 203 |
+
hits_count,
|
| 204 |
+
avg_score,
|
| 205 |
+
top_score,
|
| 206 |
+
int(time.time()),
|
| 207 |
+
latency_ms
|
| 208 |
+
))
|
| 209 |
+
conn.commit()
|
| 210 |
+
|
| 211 |
+
def log_agent_query(
|
| 212 |
+
self,
|
| 213 |
+
tenant_id: str,
|
| 214 |
+
message_preview: str,
|
| 215 |
+
intent: Optional[str] = None,
|
| 216 |
+
tools_used: Optional[List[str]] = None,
|
| 217 |
+
total_tokens: Optional[int] = None,
|
| 218 |
+
total_latency_ms: Optional[int] = None,
|
| 219 |
+
success: bool = True,
|
| 220 |
+
user_id: Optional[str] = None
|
| 221 |
+
):
|
| 222 |
+
"""Log an agent query event (overall query tracking)."""
|
| 223 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 224 |
+
conn.execute("""
|
| 225 |
+
INSERT INTO agent_query_events
|
| 226 |
+
(tenant_id, user_id, message_preview, intent, tools_used, total_tokens, total_latency_ms, success, timestamp)
|
| 227 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 228 |
+
""", (
|
| 229 |
+
tenant_id,
|
| 230 |
+
user_id,
|
| 231 |
+
message_preview[:200],
|
| 232 |
+
intent,
|
| 233 |
+
json.dumps(tools_used) if tools_used else None,
|
| 234 |
+
total_tokens,
|
| 235 |
+
total_latency_ms,
|
| 236 |
+
1 if success else 0,
|
| 237 |
+
int(time.time())
|
| 238 |
+
))
|
| 239 |
+
conn.commit()
|
| 240 |
+
|
| 241 |
+
def get_tool_usage_stats(
|
| 242 |
+
self,
|
| 243 |
+
tenant_id: str,
|
| 244 |
+
since_timestamp: Optional[int] = None
|
| 245 |
+
) -> Dict[str, Any]:
|
| 246 |
+
"""Get tool usage statistics for a tenant."""
|
| 247 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 248 |
+
conn.row_factory = sqlite3.Row
|
| 249 |
+
|
| 250 |
+
query = """
|
| 251 |
+
SELECT
|
| 252 |
+
tool_name,
|
| 253 |
+
COUNT(*) as count,
|
| 254 |
+
AVG(latency_ms) as avg_latency_ms,
|
| 255 |
+
SUM(tokens_used) as total_tokens,
|
| 256 |
+
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count
|
| 257 |
+
FROM tool_usage_events
|
| 258 |
+
WHERE tenant_id = ?
|
| 259 |
+
"""
|
| 260 |
+
params = [tenant_id]
|
| 261 |
+
|
| 262 |
+
if since_timestamp:
|
| 263 |
+
query += " AND timestamp >= ?"
|
| 264 |
+
params.append(since_timestamp)
|
| 265 |
+
|
| 266 |
+
query += " GROUP BY tool_name"
|
| 267 |
+
|
| 268 |
+
cursor = conn.execute(query, params)
|
| 269 |
+
rows = cursor.fetchall()
|
| 270 |
+
|
| 271 |
+
stats = {}
|
| 272 |
+
for row in rows:
|
| 273 |
+
tool_name = row["tool_name"]
|
| 274 |
+
stats[tool_name] = {
|
| 275 |
+
"count": row["count"],
|
| 276 |
+
"avg_latency_ms": round(row["avg_latency_ms"] or 0, 2),
|
| 277 |
+
"total_tokens": row["total_tokens"] or 0,
|
| 278 |
+
"success_count": row["success_count"],
|
| 279 |
+
"error_count": row["count"] - row["success_count"]
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
return stats
|
| 283 |
+
|
| 284 |
+
def get_redflag_violations(
|
| 285 |
+
self,
|
| 286 |
+
tenant_id: str,
|
| 287 |
+
limit: int = 50,
|
| 288 |
+
since_timestamp: Optional[int] = None
|
| 289 |
+
) -> List[Dict[str, Any]]:
|
| 290 |
+
"""Get recent red-flag violations for a tenant."""
|
| 291 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 292 |
+
conn.row_factory = sqlite3.Row
|
| 293 |
+
|
| 294 |
+
query = """
|
| 295 |
+
SELECT * FROM redflag_violations
|
| 296 |
+
WHERE tenant_id = ?
|
| 297 |
+
"""
|
| 298 |
+
params = [tenant_id]
|
| 299 |
+
|
| 300 |
+
if since_timestamp:
|
| 301 |
+
query += " AND timestamp >= ?"
|
| 302 |
+
params.append(since_timestamp)
|
| 303 |
+
|
| 304 |
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
| 305 |
+
params.append(limit)
|
| 306 |
+
|
| 307 |
+
cursor = conn.execute(query, params)
|
| 308 |
+
rows = cursor.fetchall()
|
| 309 |
+
|
| 310 |
+
return [dict(row) for row in rows]
|
| 311 |
+
|
| 312 |
+
def get_activity_summary(
|
| 313 |
+
self,
|
| 314 |
+
tenant_id: str,
|
| 315 |
+
since_timestamp: Optional[int] = None
|
| 316 |
+
) -> Dict[str, Any]:
|
| 317 |
+
"""Get activity summary for a tenant."""
|
| 318 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 319 |
+
conn.row_factory = sqlite3.Row
|
| 320 |
+
|
| 321 |
+
# Total queries
|
| 322 |
+
query = "SELECT COUNT(*) as total FROM agent_query_events WHERE tenant_id = ?"
|
| 323 |
+
params = [tenant_id]
|
| 324 |
+
if since_timestamp:
|
| 325 |
+
query += " AND timestamp >= ?"
|
| 326 |
+
params.append(since_timestamp)
|
| 327 |
+
|
| 328 |
+
total_queries = conn.execute(query, params).fetchone()["total"]
|
| 329 |
+
|
| 330 |
+
# Active users (unique user_ids in the period)
|
| 331 |
+
query = """
|
| 332 |
+
SELECT COUNT(DISTINCT user_id) as active_users
|
| 333 |
+
FROM agent_query_events
|
| 334 |
+
WHERE tenant_id = ? AND user_id IS NOT NULL
|
| 335 |
+
"""
|
| 336 |
+
params = [tenant_id]
|
| 337 |
+
if since_timestamp:
|
| 338 |
+
query += " AND timestamp >= ?"
|
| 339 |
+
params.append(since_timestamp)
|
| 340 |
+
|
| 341 |
+
active_users = conn.execute(query, params).fetchone()["active_users"]
|
| 342 |
+
|
| 343 |
+
# Last query timestamp
|
| 344 |
+
query = """
|
| 345 |
+
SELECT MAX(timestamp) as last_query
|
| 346 |
+
FROM agent_query_events
|
| 347 |
+
WHERE tenant_id = ?
|
| 348 |
+
"""
|
| 349 |
+
last_query_ts = conn.execute(query, [tenant_id]).fetchone()["last_query"]
|
| 350 |
+
|
| 351 |
+
# Red-flag count
|
| 352 |
+
query = "SELECT COUNT(*) as count FROM redflag_violations WHERE tenant_id = ?"
|
| 353 |
+
params = [tenant_id]
|
| 354 |
+
if since_timestamp:
|
| 355 |
+
query += " AND timestamp >= ?"
|
| 356 |
+
params.append(since_timestamp)
|
| 357 |
+
|
| 358 |
+
redflag_count = conn.execute(query, params).fetchone()["count"]
|
| 359 |
+
|
| 360 |
+
return {
|
| 361 |
+
"total_queries": total_queries,
|
| 362 |
+
"active_users": active_users or 0,
|
| 363 |
+
"redflag_count": redflag_count,
|
| 364 |
+
"last_query": datetime.fromtimestamp(last_query_ts).isoformat() if last_query_ts else None
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
def get_rag_quality_metrics(
|
| 368 |
+
self,
|
| 369 |
+
tenant_id: str,
|
| 370 |
+
since_timestamp: Optional[int] = None
|
| 371 |
+
) -> Dict[str, Any]:
|
| 372 |
+
"""Get RAG quality metrics (recall/precision indicators)."""
|
| 373 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 374 |
+
conn.row_factory = sqlite3.Row
|
| 375 |
+
|
| 376 |
+
query = """
|
| 377 |
+
SELECT
|
| 378 |
+
COUNT(*) as total_searches,
|
| 379 |
+
AVG(hits_count) as avg_hits,
|
| 380 |
+
AVG(avg_score) as avg_avg_score,
|
| 381 |
+
AVG(top_score) as avg_top_score,
|
| 382 |
+
AVG(latency_ms) as avg_latency_ms
|
| 383 |
+
FROM rag_search_events
|
| 384 |
+
WHERE tenant_id = ?
|
| 385 |
+
"""
|
| 386 |
+
params = [tenant_id]
|
| 387 |
+
|
| 388 |
+
if since_timestamp:
|
| 389 |
+
query += " AND timestamp >= ?"
|
| 390 |
+
params.append(since_timestamp)
|
| 391 |
+
|
| 392 |
+
row = conn.execute(query, params).fetchone()
|
| 393 |
+
|
| 394 |
+
return {
|
| 395 |
+
"total_searches": row["total_searches"] or 0,
|
| 396 |
+
"avg_hits_per_search": round(row["avg_hits"] or 0, 2),
|
| 397 |
+
"avg_score": round(row["avg_avg_score"] or 0, 3),
|
| 398 |
+
"avg_top_score": round(row["avg_top_score"] or 0, 3),
|
| 399 |
+
"avg_latency_ms": round(row["avg_latency_ms"] or 0, 2)
|
| 400 |
+
}
|
| 401 |
+
|
backend/api/storage/rules_store.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import sqlite3
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
-
from typing import List
|
| 4 |
|
| 5 |
|
| 6 |
class RulesStore:
|
|
@@ -18,32 +19,90 @@ class RulesStore:
|
|
| 18 |
|
| 19 |
def _init_db(self):
|
| 20 |
with sqlite3.connect(self.db_path) as conn:
|
|
|
|
| 21 |
conn.execute(
|
| 22 |
"""
|
| 23 |
CREATE TABLE IF NOT EXISTS admin_rules (
|
| 24 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 25 |
tenant_id TEXT NOT NULL,
|
| 26 |
rule TEXT NOT NULL,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
UNIQUE(tenant_id, rule)
|
| 28 |
)
|
| 29 |
"""
|
| 30 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
conn.commit()
|
| 32 |
|
| 33 |
def get_rules(self, tenant_id: str) -> List[str]:
|
|
|
|
| 34 |
with sqlite3.connect(self.db_path) as conn:
|
| 35 |
cursor = conn.execute(
|
| 36 |
-
"SELECT rule FROM admin_rules WHERE tenant_id = ? ORDER BY id ASC",
|
| 37 |
(tenant_id,),
|
| 38 |
)
|
| 39 |
return [row[0] for row in cursor.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
def add_rule(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
try:
|
| 43 |
with sqlite3.connect(self.db_path) as conn:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
conn.execute(
|
| 45 |
-
"INSERT OR IGNORE INTO admin_rules
|
| 46 |
-
(tenant_id, rule)
|
|
|
|
|
|
|
| 47 |
)
|
| 48 |
conn.commit()
|
| 49 |
return True
|
|
|
|
| 1 |
import sqlite3
|
| 2 |
+
import time
|
| 3 |
from pathlib import Path
|
| 4 |
+
from typing import List, Optional, Dict, Any
|
| 5 |
|
| 6 |
|
| 7 |
class RulesStore:
|
|
|
|
| 19 |
|
| 20 |
def _init_db(self):
|
| 21 |
with sqlite3.connect(self.db_path) as conn:
|
| 22 |
+
# Create table with regex pattern and severity support
|
| 23 |
conn.execute(
|
| 24 |
"""
|
| 25 |
CREATE TABLE IF NOT EXISTS admin_rules (
|
| 26 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 27 |
tenant_id TEXT NOT NULL,
|
| 28 |
rule TEXT NOT NULL,
|
| 29 |
+
pattern TEXT,
|
| 30 |
+
severity TEXT DEFAULT 'medium',
|
| 31 |
+
description TEXT,
|
| 32 |
+
enabled BOOLEAN DEFAULT 1,
|
| 33 |
+
created_at INTEGER,
|
| 34 |
UNIQUE(tenant_id, rule)
|
| 35 |
)
|
| 36 |
"""
|
| 37 |
)
|
| 38 |
+
# Add new columns if they don't exist (for backward compatibility)
|
| 39 |
+
try:
|
| 40 |
+
conn.execute("ALTER TABLE admin_rules ADD COLUMN pattern TEXT")
|
| 41 |
+
except sqlite3.OperationalError:
|
| 42 |
+
pass # Column already exists
|
| 43 |
+
try:
|
| 44 |
+
conn.execute("ALTER TABLE admin_rules ADD COLUMN severity TEXT DEFAULT 'medium'")
|
| 45 |
+
except sqlite3.OperationalError:
|
| 46 |
+
pass
|
| 47 |
+
try:
|
| 48 |
+
conn.execute("ALTER TABLE admin_rules ADD COLUMN description TEXT")
|
| 49 |
+
except sqlite3.OperationalError:
|
| 50 |
+
pass
|
| 51 |
+
try:
|
| 52 |
+
conn.execute("ALTER TABLE admin_rules ADD COLUMN enabled BOOLEAN DEFAULT 1")
|
| 53 |
+
except sqlite3.OperationalError:
|
| 54 |
+
pass
|
| 55 |
+
try:
|
| 56 |
+
conn.execute("ALTER TABLE admin_rules ADD COLUMN created_at INTEGER")
|
| 57 |
+
except sqlite3.OperationalError:
|
| 58 |
+
pass
|
| 59 |
conn.commit()
|
| 60 |
|
| 61 |
def get_rules(self, tenant_id: str) -> List[str]:
|
| 62 |
+
"""Get all rules as a list of rule text strings (backward compatibility)."""
|
| 63 |
with sqlite3.connect(self.db_path) as conn:
|
| 64 |
cursor = conn.execute(
|
| 65 |
+
"SELECT rule FROM admin_rules WHERE tenant_id = ? AND enabled = 1 ORDER BY id ASC",
|
| 66 |
(tenant_id,),
|
| 67 |
)
|
| 68 |
return [row[0] for row in cursor.fetchall()]
|
| 69 |
+
|
| 70 |
+
def get_rules_detailed(self, tenant_id: str) -> List[Dict[str, Any]]:
|
| 71 |
+
"""Get all rules with full metadata including pattern, severity, etc."""
|
| 72 |
+
with sqlite3.connect(self.db_path) as conn:
|
| 73 |
+
conn.row_factory = sqlite3.Row
|
| 74 |
+
cursor = conn.execute(
|
| 75 |
+
"""SELECT id, tenant_id, rule, pattern, severity, description, enabled, created_at
|
| 76 |
+
FROM admin_rules WHERE tenant_id = ? AND enabled = 1 ORDER BY id ASC""",
|
| 77 |
+
(tenant_id,),
|
| 78 |
+
)
|
| 79 |
+
rows = cursor.fetchall()
|
| 80 |
+
return [dict(row) for row in rows]
|
| 81 |
|
| 82 |
+
def add_rule(
|
| 83 |
+
self,
|
| 84 |
+
tenant_id: str,
|
| 85 |
+
rule: str,
|
| 86 |
+
pattern: Optional[str] = None,
|
| 87 |
+
severity: str = "medium",
|
| 88 |
+
description: Optional[str] = None,
|
| 89 |
+
enabled: bool = True
|
| 90 |
+
) -> bool:
|
| 91 |
+
"""
|
| 92 |
+
Add a rule with optional regex pattern and severity.
|
| 93 |
+
If pattern is None, the rule text itself is used as the pattern.
|
| 94 |
+
"""
|
| 95 |
try:
|
| 96 |
with sqlite3.connect(self.db_path) as conn:
|
| 97 |
+
# If pattern not provided, use rule text as pattern
|
| 98 |
+
pattern_value = pattern or rule
|
| 99 |
+
description_value = description or rule
|
| 100 |
+
|
| 101 |
conn.execute(
|
| 102 |
+
"""INSERT OR IGNORE INTO admin_rules
|
| 103 |
+
(tenant_id, rule, pattern, severity, description, enabled, created_at)
|
| 104 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
| 105 |
+
(tenant_id, rule, pattern_value, severity, description_value, 1 if enabled else 0, int(time.time())),
|
| 106 |
)
|
| 107 |
conn.commit()
|
| 108 |
return True
|
backend/mcp_servers/database.py
CHANGED
|
@@ -135,15 +135,24 @@ def insert_document_chunks(tenant_id: str, text: str, embedding: list):
|
|
| 135 |
def search_vectors(tenant_id: str, vector: list, limit: int = 5) -> List[Dict[str, Any]]:
|
| 136 |
"""
|
| 137 |
Perform semantic vector search using pgvector.
|
|
|
|
| 138 |
"""
|
| 139 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
conn = get_connection()
|
| 141 |
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
| 142 |
|
|
|
|
| 143 |
cur.execute(
|
| 144 |
"""
|
| 145 |
SELECT
|
| 146 |
chunk_text,
|
|
|
|
| 147 |
1 - (embedding <=> %s::vector(384)) AS similarity
|
| 148 |
FROM documents
|
| 149 |
WHERE tenant_id = %s
|
|
@@ -155,21 +164,30 @@ def search_vectors(tenant_id: str, vector: list, limit: int = 5) -> List[Dict[st
|
|
| 155 |
|
| 156 |
rows = cur.fetchall()
|
| 157 |
|
| 158 |
-
|
| 159 |
-
conn.close()
|
| 160 |
-
|
| 161 |
results: List[Dict[str, Any]] = []
|
| 162 |
for row in rows:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
results.append(
|
| 164 |
{
|
| 165 |
"text": row["chunk_text"],
|
| 166 |
"similarity": float(row.get("similarity", 0.0)),
|
| 167 |
}
|
| 168 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
return results
|
| 170 |
|
| 171 |
except Exception as e:
|
| 172 |
-
print("DB SEARCH ERROR:
|
|
|
|
|
|
|
| 173 |
return []
|
| 174 |
|
| 175 |
|
|
|
|
| 135 |
def search_vectors(tenant_id: str, vector: list, limit: int = 5) -> List[Dict[str, Any]]:
|
| 136 |
"""
|
| 137 |
Perform semantic vector search using pgvector.
|
| 138 |
+
Results are filtered by tenant_id to ensure data isolation.
|
| 139 |
"""
|
| 140 |
try:
|
| 141 |
+
# Validate tenant_id
|
| 142 |
+
if not tenant_id or not tenant_id.strip():
|
| 143 |
+
print("DB SEARCH ERROR: tenant_id is empty")
|
| 144 |
+
return []
|
| 145 |
+
|
| 146 |
+
tenant_id = tenant_id.strip()
|
| 147 |
conn = get_connection()
|
| 148 |
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
| 149 |
|
| 150 |
+
# Query with explicit tenant_id filtering
|
| 151 |
cur.execute(
|
| 152 |
"""
|
| 153 |
SELECT
|
| 154 |
chunk_text,
|
| 155 |
+
tenant_id,
|
| 156 |
1 - (embedding <=> %s::vector(384)) AS similarity
|
| 157 |
FROM documents
|
| 158 |
WHERE tenant_id = %s
|
|
|
|
| 164 |
|
| 165 |
rows = cur.fetchall()
|
| 166 |
|
| 167 |
+
# Verify all results belong to the requested tenant (safety check)
|
|
|
|
|
|
|
| 168 |
results: List[Dict[str, Any]] = []
|
| 169 |
for row in rows:
|
| 170 |
+
row_tenant_id = row.get("tenant_id", "")
|
| 171 |
+
if row_tenant_id != tenant_id:
|
| 172 |
+
print(f"WARNING: Found document with tenant_id '{row_tenant_id}' when searching for '{tenant_id}' - skipping")
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
results.append(
|
| 176 |
{
|
| 177 |
"text": row["chunk_text"],
|
| 178 |
"similarity": float(row.get("similarity", 0.0)),
|
| 179 |
}
|
| 180 |
)
|
| 181 |
+
|
| 182 |
+
cur.close()
|
| 183 |
+
conn.close()
|
| 184 |
+
|
| 185 |
return results
|
| 186 |
|
| 187 |
except Exception as e:
|
| 188 |
+
print(f"DB SEARCH ERROR (tenant_id={tenant_id}): {e}")
|
| 189 |
+
import traceback
|
| 190 |
+
traceback.print_exc()
|
| 191 |
return []
|
| 192 |
|
| 193 |
|
backend/mcp_servers/main.py
CHANGED
|
@@ -137,10 +137,19 @@ def ingest(payload: IngestPayload):
|
|
| 137 |
def search(payload: SearchPayload):
|
| 138 |
"""
|
| 139 |
Semantic search using pgvector + MiniLM embeddings.
|
|
|
|
| 140 |
"""
|
| 141 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
query_embedding = embed_text(payload.query)
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
return {
|
| 146 |
"tenant_id": payload.tenant_id,
|
|
@@ -148,7 +157,10 @@ def search(payload: SearchPayload):
|
|
| 148 |
"results": results
|
| 149 |
}
|
| 150 |
|
|
|
|
|
|
|
| 151 |
except Exception as e:
|
|
|
|
| 152 |
raise HTTPException(status_code=500, detail=str(e))
|
| 153 |
|
| 154 |
|
|
|
|
| 137 |
def search(payload: SearchPayload):
|
| 138 |
"""
|
| 139 |
Semantic search using pgvector + MiniLM embeddings.
|
| 140 |
+
Results are filtered by tenant_id in the database query.
|
| 141 |
"""
|
| 142 |
try:
|
| 143 |
+
# Validate tenant_id is provided
|
| 144 |
+
if not payload.tenant_id or not payload.tenant_id.strip():
|
| 145 |
+
raise HTTPException(status_code=400, detail="tenant_id is required")
|
| 146 |
+
|
| 147 |
query_embedding = embed_text(payload.query)
|
| 148 |
+
# search_vectors filters by tenant_id in the SQL query
|
| 149 |
+
results = search_vectors(payload.tenant_id.strip(), query_embedding, limit=10)
|
| 150 |
+
|
| 151 |
+
# Log for debugging (remove in production)
|
| 152 |
+
print(f"[RAG Search] tenant_id={payload.tenant_id}, query={payload.query[:50]}, results_count={len(results)}")
|
| 153 |
|
| 154 |
return {
|
| 155 |
"tenant_id": payload.tenant_id,
|
|
|
|
| 157 |
"results": results
|
| 158 |
}
|
| 159 |
|
| 160 |
+
except HTTPException:
|
| 161 |
+
raise
|
| 162 |
except Exception as e:
|
| 163 |
+
print(f"[RAG Search Error] tenant_id={payload.tenant_id}, error={str(e)}")
|
| 164 |
raise HTTPException(status_code=500, detail=str(e))
|
| 165 |
|
| 166 |
|
backend/mcp_servers/rag_server.py
CHANGED
|
@@ -39,7 +39,15 @@ def db_insert(tenant_id: str, content: str, vector: list):
|
|
| 39 |
def db_search(tenant_id: str, vector: list, limit: int = 5):
|
| 40 |
"""Wrapper for search_vectors to match expected interface."""
|
| 41 |
results = search_vectors(tenant_id, vector, limit)
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
@rag_app.post("/ingest")
|
|
@@ -49,34 +57,17 @@ async def ingest(req: IngestRequest):
|
|
| 49 |
return {"status": "ok"}
|
| 50 |
|
| 51 |
|
| 52 |
-
def cosine_similarity(vec_a: List[float], vec_b: List[float]) -> float:
|
| 53 |
-
import math
|
| 54 |
-
|
| 55 |
-
if not vec_a or not vec_b:
|
| 56 |
-
return 0.0
|
| 57 |
-
numerator = sum(a * b for a, b in zip(vec_a, vec_b))
|
| 58 |
-
denom = math.sqrt(sum(a * a for a in vec_a)) * math.sqrt(sum(b * b for b in vec_b))
|
| 59 |
-
if denom == 0:
|
| 60 |
-
return 0.0
|
| 61 |
-
return numerator / denom
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def rank_chunks(chunks: List[Dict[str, Any]], query_embedding: List[float]):
|
| 65 |
-
ranked = []
|
| 66 |
-
for chunk in chunks:
|
| 67 |
-
chunk_vector = embed_text(chunk.get("text", ""))
|
| 68 |
-
relevance = cosine_similarity(chunk_vector, query_embedding)
|
| 69 |
-
chunk["relevance"] = relevance
|
| 70 |
-
ranked.append(chunk)
|
| 71 |
-
return sorted(ranked, key=lambda x: x["relevance"], reverse=True)
|
| 72 |
-
|
| 73 |
-
|
| 74 |
@rag_app.post("/search")
|
| 75 |
async def search(req: SearchRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
vector = embed_text(req.query)
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
return {
|
| 81 |
"results": filtered,
|
| 82 |
"metadata": {
|
|
|
|
| 39 |
def db_search(tenant_id: str, vector: list, limit: int = 5):
|
| 40 |
"""Wrapper for search_vectors to match expected interface."""
|
| 41 |
results = search_vectors(tenant_id, vector, limit)
|
| 42 |
+
# search_vectors returns list of dicts with "text" and "similarity"
|
| 43 |
+
# Preserve the structure and use similarity as relevance
|
| 44 |
+
return [
|
| 45 |
+
{
|
| 46 |
+
"text": result.get("text", ""),
|
| 47 |
+
"relevance": result.get("similarity", 0.0)
|
| 48 |
+
}
|
| 49 |
+
for result in results
|
| 50 |
+
]
|
| 51 |
|
| 52 |
|
| 53 |
@rag_app.post("/ingest")
|
|
|
|
| 57 |
return {"status": "ok"}
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
@rag_app.post("/search")
|
| 61 |
async def search(req: SearchRequest):
|
| 62 |
+
"""
|
| 63 |
+
Search documents for a specific tenant.
|
| 64 |
+
Results are already filtered by tenant_id in the database query.
|
| 65 |
+
"""
|
| 66 |
vector = embed_text(req.query)
|
| 67 |
+
# db_search already filters by tenant_id and returns results sorted by similarity
|
| 68 |
+
results = db_search(req.tenant_id, vector, limit=10) # Get more results for filtering
|
| 69 |
+
# Filter by relevance threshold and limit to top 3
|
| 70 |
+
filtered = [chunk for chunk in results if chunk.get("relevance", 0.0) >= 0.55][:3]
|
| 71 |
return {
|
| 72 |
"results": filtered,
|
| 73 |
"metadata": {
|
backend/tests/test_analytics_store.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for AnalyticsStore - tenant-level analytics logging
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Add backend directory to Python path
|
| 9 |
+
backend_dir = Path(__file__).parent.parent
|
| 10 |
+
sys.path.insert(0, str(backend_dir))
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
import time
|
| 14 |
+
import tempfile
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
from api.storage.analytics_store import AnalyticsStore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def temp_analytics_db():
|
| 22 |
+
"""Create a temporary database for testing."""
|
| 23 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.db') as f:
|
| 24 |
+
db_path = f.name
|
| 25 |
+
yield db_path
|
| 26 |
+
# Cleanup - close any connections first
|
| 27 |
+
try:
|
| 28 |
+
if os.path.exists(db_path):
|
| 29 |
+
# On Windows, we need to ensure the file is closed
|
| 30 |
+
import time
|
| 31 |
+
time.sleep(0.1) # Brief delay to ensure file is released
|
| 32 |
+
os.unlink(db_path)
|
| 33 |
+
except (PermissionError, OSError):
|
| 34 |
+
# File might still be in use, that's okay for temp files
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@pytest.fixture
|
| 39 |
+
def analytics_store(temp_analytics_db):
|
| 40 |
+
"""Create an AnalyticsStore instance with temporary database."""
|
| 41 |
+
return AnalyticsStore(db_path=temp_analytics_db)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_analytics_store_init(analytics_store):
|
| 45 |
+
"""Test that AnalyticsStore initializes correctly."""
|
| 46 |
+
assert analytics_store is not None
|
| 47 |
+
assert analytics_store.db_path.exists()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_log_tool_usage(analytics_store):
|
| 51 |
+
"""Test logging tool usage events."""
|
| 52 |
+
analytics_store.log_tool_usage(
|
| 53 |
+
tenant_id="test_tenant",
|
| 54 |
+
tool_name="rag",
|
| 55 |
+
latency_ms=150,
|
| 56 |
+
tokens_used=500,
|
| 57 |
+
success=True,
|
| 58 |
+
user_id="user123"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
stats = analytics_store.get_tool_usage_stats("test_tenant")
|
| 62 |
+
assert "rag" in stats
|
| 63 |
+
assert stats["rag"]["count"] == 1
|
| 64 |
+
assert stats["rag"]["avg_latency_ms"] == 150.0
|
| 65 |
+
assert stats["rag"]["total_tokens"] == 500
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_log_redflag_violation(analytics_store):
|
| 69 |
+
"""Test logging red-flag violations."""
|
| 70 |
+
analytics_store.log_redflag_violation(
|
| 71 |
+
tenant_id="test_tenant",
|
| 72 |
+
rule_id="rule123",
|
| 73 |
+
rule_pattern=".*password.*",
|
| 74 |
+
severity="high",
|
| 75 |
+
matched_text="password123",
|
| 76 |
+
confidence=0.95,
|
| 77 |
+
message_preview="User entered password123",
|
| 78 |
+
user_id="user123"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
violations = analytics_store.get_redflag_violations("test_tenant", limit=10)
|
| 82 |
+
assert len(violations) == 1
|
| 83 |
+
assert violations[0]["severity"] == "high"
|
| 84 |
+
assert violations[0]["confidence"] == 0.95
|
| 85 |
+
assert violations[0]["matched_text"] == "password123"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_log_rag_search(analytics_store):
|
| 89 |
+
"""Test logging RAG search events with quality metrics."""
|
| 90 |
+
analytics_store.log_rag_search(
|
| 91 |
+
tenant_id="test_tenant",
|
| 92 |
+
query="What is the policy?",
|
| 93 |
+
hits_count=5,
|
| 94 |
+
avg_score=0.85,
|
| 95 |
+
top_score=0.92,
|
| 96 |
+
latency_ms=120
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
metrics = analytics_store.get_rag_quality_metrics("test_tenant")
|
| 100 |
+
assert metrics["total_searches"] == 1
|
| 101 |
+
assert metrics["avg_hits_per_search"] == 5.0
|
| 102 |
+
assert metrics["avg_score"] == 0.85
|
| 103 |
+
assert metrics["avg_top_score"] == 0.92
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_log_agent_query(analytics_store):
|
| 107 |
+
"""Test logging agent query events."""
|
| 108 |
+
analytics_store.log_agent_query(
|
| 109 |
+
tenant_id="test_tenant",
|
| 110 |
+
message_preview="What is the company policy?",
|
| 111 |
+
intent="rag",
|
| 112 |
+
tools_used=["rag", "llm"],
|
| 113 |
+
total_tokens=1000,
|
| 114 |
+
total_latency_ms=250,
|
| 115 |
+
success=True,
|
| 116 |
+
user_id="user123"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
activity = analytics_store.get_activity_summary("test_tenant")
|
| 120 |
+
assert activity["total_queries"] == 1
|
| 121 |
+
assert activity["active_users"] == 1
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_tool_usage_stats_filtered_by_time(analytics_store):
|
| 125 |
+
"""Test that tool usage stats can be filtered by timestamp."""
|
| 126 |
+
# Log an old event (1 day ago)
|
| 127 |
+
old_timestamp = int(time.time()) - 86400
|
| 128 |
+
# Note: We can't directly set timestamp in current implementation,
|
| 129 |
+
# but we can test the filtering works
|
| 130 |
+
|
| 131 |
+
analytics_store.log_tool_usage(
|
| 132 |
+
tenant_id="test_tenant",
|
| 133 |
+
tool_name="web",
|
| 134 |
+
latency_ms=100
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Get stats without time filter
|
| 138 |
+
all_stats = analytics_store.get_tool_usage_stats("test_tenant")
|
| 139 |
+
assert "web" in all_stats
|
| 140 |
+
|
| 141 |
+
# Get stats with recent time filter
|
| 142 |
+
recent_timestamp = int(time.time()) - 3600 # Last hour
|
| 143 |
+
recent_stats = analytics_store.get_tool_usage_stats("test_tenant", recent_timestamp)
|
| 144 |
+
assert "web" in recent_stats
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_get_activity_summary(analytics_store):
|
| 148 |
+
"""Test getting activity summary for a tenant."""
|
| 149 |
+
# Log multiple queries
|
| 150 |
+
for i in range(3):
|
| 151 |
+
analytics_store.log_agent_query(
|
| 152 |
+
tenant_id="test_tenant",
|
| 153 |
+
message_preview=f"Query {i}",
|
| 154 |
+
intent="general",
|
| 155 |
+
tools_used=["llm"],
|
| 156 |
+
user_id=f"user{i}"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
activity = analytics_store.get_activity_summary("test_tenant")
|
| 160 |
+
assert activity["total_queries"] == 3
|
| 161 |
+
assert activity["active_users"] == 3
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_get_rag_quality_metrics(analytics_store):
|
| 165 |
+
"""Test getting RAG quality metrics."""
|
| 166 |
+
# Log multiple RAG searches
|
| 167 |
+
for i in range(3):
|
| 168 |
+
analytics_store.log_rag_search(
|
| 169 |
+
tenant_id="test_tenant",
|
| 170 |
+
query=f"Query {i}",
|
| 171 |
+
hits_count=5 + i,
|
| 172 |
+
avg_score=0.8 + i * 0.05,
|
| 173 |
+
top_score=0.9 + i * 0.05,
|
| 174 |
+
latency_ms=100 + i * 10
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
metrics = analytics_store.get_rag_quality_metrics("test_tenant")
|
| 178 |
+
assert metrics["total_searches"] == 3
|
| 179 |
+
assert metrics["avg_hits_per_search"] > 0
|
| 180 |
+
assert metrics["avg_score"] > 0
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_multiple_tenants_isolation(analytics_store):
|
| 184 |
+
"""Test that analytics are properly isolated by tenant."""
|
| 185 |
+
# Log events for tenant1
|
| 186 |
+
analytics_store.log_tool_usage(
|
| 187 |
+
tenant_id="tenant1",
|
| 188 |
+
tool_name="rag",
|
| 189 |
+
latency_ms=100
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Log events for tenant2
|
| 193 |
+
analytics_store.log_tool_usage(
|
| 194 |
+
tenant_id="tenant2",
|
| 195 |
+
tool_name="web",
|
| 196 |
+
latency_ms=200
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Check tenant1 stats
|
| 200 |
+
tenant1_stats = analytics_store.get_tool_usage_stats("tenant1")
|
| 201 |
+
assert "rag" in tenant1_stats
|
| 202 |
+
assert "web" not in tenant1_stats
|
| 203 |
+
|
| 204 |
+
# Check tenant2 stats
|
| 205 |
+
tenant2_stats = analytics_store.get_tool_usage_stats("tenant2")
|
| 206 |
+
assert "web" in tenant2_stats
|
| 207 |
+
assert "rag" not in tenant2_stats
|
| 208 |
+
|
backend/tests/test_api_endpoints.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Integration tests for new API endpoints
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Add backend to path
|
| 9 |
+
backend_dir = Path(__file__).parent.parent
|
| 10 |
+
sys.path.insert(0, str(backend_dir))
|
| 11 |
+
|
| 12 |
+
# Add root directory to path for backend.api imports
|
| 13 |
+
root_dir = Path(__file__).resolve().parents[2]
|
| 14 |
+
sys.path.insert(0, str(root_dir))
|
| 15 |
+
|
| 16 |
+
import pytest
|
| 17 |
+
from fastapi.testclient import TestClient
|
| 18 |
+
from fastapi import FastAPI
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from backend.api.main import app
|
| 22 |
+
except ImportError:
|
| 23 |
+
# Fallback if backend.api.main doesn't work
|
| 24 |
+
from api.main import app
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.fixture
|
| 28 |
+
def client():
|
| 29 |
+
"""Create a test client."""
|
| 30 |
+
return TestClient(app)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_analytics_overview_endpoint(client):
|
| 34 |
+
"""Test /analytics/overview endpoint."""
|
| 35 |
+
response = client.get(
|
| 36 |
+
"/analytics/overview",
|
| 37 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 38 |
+
params={"days": 30}
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
assert response.status_code == 200
|
| 42 |
+
data = response.json()
|
| 43 |
+
assert "tenant_id" in data
|
| 44 |
+
assert "overview" in data
|
| 45 |
+
assert "total_queries" in data["overview"]
|
| 46 |
+
assert "tool_usage" in data["overview"]
|
| 47 |
+
assert "redflag_count" in data["overview"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_analytics_tool_usage_endpoint(client):
|
| 51 |
+
"""Test /analytics/tool-usage endpoint."""
|
| 52 |
+
response = client.get(
|
| 53 |
+
"/analytics/tool-usage",
|
| 54 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 55 |
+
params={"days": 30}
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
assert response.status_code == 200
|
| 59 |
+
data = response.json()
|
| 60 |
+
assert "tenant_id" in data
|
| 61 |
+
assert "tool_usage" in data
|
| 62 |
+
assert "period_days" in data
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_analytics_rag_quality_endpoint(client):
|
| 66 |
+
"""Test /analytics/rag-quality endpoint."""
|
| 67 |
+
response = client.get(
|
| 68 |
+
"/analytics/rag-quality",
|
| 69 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 70 |
+
params={"days": 30}
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
assert response.status_code == 200
|
| 74 |
+
data = response.json()
|
| 75 |
+
assert "tenant_id" in data
|
| 76 |
+
assert "rag_quality" in data
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_admin_rules_with_regex(client):
|
| 80 |
+
"""Test adding admin rule with regex pattern and severity."""
|
| 81 |
+
response = client.post(
|
| 82 |
+
"/admin/rules",
|
| 83 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 84 |
+
json={
|
| 85 |
+
"rule": "Block password queries",
|
| 86 |
+
"pattern": ".*password.*",
|
| 87 |
+
"severity": "high",
|
| 88 |
+
"description": "Blocks password-related queries"
|
| 89 |
+
}
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
assert response.status_code == 200
|
| 93 |
+
data = response.json()
|
| 94 |
+
assert data["severity"] == "high"
|
| 95 |
+
assert ".*password.*" in data["pattern"]
|
| 96 |
+
|
| 97 |
+
# Get detailed rules
|
| 98 |
+
response = client.get(
|
| 99 |
+
"/admin/rules",
|
| 100 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 101 |
+
params={"detailed": True}
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
assert response.status_code == 200
|
| 105 |
+
data = response.json()
|
| 106 |
+
assert "rules" in data
|
| 107 |
+
assert len(data["rules"]) > 0
|
| 108 |
+
assert data["rules"][0]["severity"] == "high"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_admin_violations_endpoint(client):
|
| 112 |
+
"""Test /admin/violations endpoint."""
|
| 113 |
+
response = client.get(
|
| 114 |
+
"/admin/violations",
|
| 115 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 116 |
+
params={"limit": 50, "days": 30}
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
assert response.status_code == 200
|
| 120 |
+
data = response.json()
|
| 121 |
+
assert "tenant_id" in data
|
| 122 |
+
assert "violations" in data
|
| 123 |
+
assert "count" in data
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def test_admin_tools_logs_endpoint(client):
|
| 127 |
+
"""Test /admin/tools/logs endpoint."""
|
| 128 |
+
response = client.get(
|
| 129 |
+
"/admin/tools/logs",
|
| 130 |
+
headers={"x-tenant-id": "test_tenant"},
|
| 131 |
+
params={"tool_name": "rag", "days": 7}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
assert response.status_code == 200
|
| 135 |
+
data = response.json()
|
| 136 |
+
assert "tenant_id" in data
|
| 137 |
+
assert "tool_usage" in data
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_agent_debug_endpoint(client):
|
| 141 |
+
"""Test /agent/debug endpoint."""
|
| 142 |
+
# Note: This will fail if LLM/MCP servers are not running
|
| 143 |
+
# But we can at least test the endpoint structure
|
| 144 |
+
response = client.post(
|
| 145 |
+
"/agent/debug",
|
| 146 |
+
json={
|
| 147 |
+
"tenant_id": "test_tenant",
|
| 148 |
+
"message": "Test message",
|
| 149 |
+
"temperature": 0.0
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
# Might fail if services not available, but should have proper error handling
|
| 154 |
+
assert response.status_code in [200, 500, 503] # Accept various status codes
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_agent_plan_endpoint(client):
|
| 158 |
+
"""Test /agent/plan endpoint."""
|
| 159 |
+
# Note: This will fail if LLM/MCP servers are not running
|
| 160 |
+
response = client.post(
|
| 161 |
+
"/agent/plan",
|
| 162 |
+
json={
|
| 163 |
+
"tenant_id": "test_tenant",
|
| 164 |
+
"message": "What is the company policy?",
|
| 165 |
+
"temperature": 0.0
|
| 166 |
+
}
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Might fail if services not available
|
| 170 |
+
assert response.status_code in [200, 500, 503]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_missing_tenant_id_returns_400(client):
|
| 174 |
+
"""Test that endpoints return 400 when tenant ID is missing."""
|
| 175 |
+
endpoints = [
|
| 176 |
+
"/analytics/overview",
|
| 177 |
+
"/analytics/tool-usage",
|
| 178 |
+
"/admin/rules",
|
| 179 |
+
"/admin/violations"
|
| 180 |
+
]
|
| 181 |
+
|
| 182 |
+
for endpoint in endpoints:
|
| 183 |
+
response = client.get(endpoint)
|
| 184 |
+
assert response.status_code == 400, f"Endpoint {endpoint} should return 400"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_admin_tenants_endpoints(client):
|
| 188 |
+
"""Test tenant management endpoints (placeholders)."""
|
| 189 |
+
# List tenants
|
| 190 |
+
response = client.get("/admin/tenants")
|
| 191 |
+
assert response.status_code == 200
|
| 192 |
+
data = response.json()
|
| 193 |
+
assert "tenants" in data
|
| 194 |
+
|
| 195 |
+
# Create tenant (placeholder)
|
| 196 |
+
response = client.post("/admin/tenants", params={"tenant_id": "new_tenant"})
|
| 197 |
+
assert response.status_code == 200
|
| 198 |
+
|
| 199 |
+
# Delete tenant (placeholder)
|
| 200 |
+
response = client.delete("/admin/tenants/new_tenant")
|
| 201 |
+
assert response.status_code == 200
|
| 202 |
+
|
backend/tests/test_enhanced_admin_rules.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for enhanced admin rules with regex and severity support
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
# Add backend directory to Python path
|
| 9 |
+
backend_dir = Path(__file__).parent.parent
|
| 10 |
+
sys.path.insert(0, str(backend_dir))
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
import tempfile
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
|
| 17 |
+
from api.storage.rules_store import RulesStore
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def temp_rules_db():
|
| 22 |
+
"""Create a temporary database for testing."""
|
| 23 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.db') as f:
|
| 24 |
+
db_path = f.name
|
| 25 |
+
yield db_path
|
| 26 |
+
# Cleanup
|
| 27 |
+
if os.path.exists(db_path):
|
| 28 |
+
os.unlink(db_path)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@pytest.fixture
|
| 32 |
+
def rules_store(temp_rules_db):
|
| 33 |
+
"""Create a RulesStore instance with temporary database."""
|
| 34 |
+
# RulesStore uses a fixed path, so we'll just use the default
|
| 35 |
+
# For tests, it will create/use data/admin_rules.db
|
| 36 |
+
# Each test should use unique tenant_id to avoid conflicts
|
| 37 |
+
store = RulesStore()
|
| 38 |
+
yield store
|
| 39 |
+
# Cleanup: Delete test data after each test
|
| 40 |
+
# Note: In a real scenario, you'd want to clean up specific tenant data
|
| 41 |
+
# For now, tests use unique tenant IDs to avoid conflicts
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_add_rule_with_regex_and_severity(rules_store):
|
| 45 |
+
"""Test adding a rule with regex pattern and severity."""
|
| 46 |
+
tenant_id = "test_tenant_regex_severity" # Unique tenant ID
|
| 47 |
+
success = rules_store.add_rule(
|
| 48 |
+
tenant_id=tenant_id,
|
| 49 |
+
rule="Block password queries",
|
| 50 |
+
pattern=r".*password.*|.*pwd.*",
|
| 51 |
+
severity="high",
|
| 52 |
+
description="Blocks any queries containing password or pwd",
|
| 53 |
+
enabled=True
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
assert success is True
|
| 57 |
+
|
| 58 |
+
# Get detailed rules
|
| 59 |
+
rules = rules_store.get_rules_detailed(tenant_id)
|
| 60 |
+
assert len(rules) == 1
|
| 61 |
+
assert rules[0]["pattern"] == r".*password.*|.*pwd.*"
|
| 62 |
+
assert rules[0]["severity"] == "high"
|
| 63 |
+
assert rules[0]["description"] == "Blocks any queries containing password or pwd"
|
| 64 |
+
assert rules[0]["enabled"] == 1
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_add_rule_without_pattern_uses_rule_text(rules_store):
|
| 68 |
+
"""Test that if pattern is not provided, rule text is used as pattern."""
|
| 69 |
+
tenant_id = "test_tenant_no_pattern" # Unique tenant ID
|
| 70 |
+
rules_store.add_rule(
|
| 71 |
+
tenant_id=tenant_id,
|
| 72 |
+
rule="Block sensitive data",
|
| 73 |
+
severity="medium"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
rules = rules_store.get_rules_detailed(tenant_id)
|
| 77 |
+
assert len(rules) == 1
|
| 78 |
+
assert rules[0]["pattern"] == "Block sensitive data"
|
| 79 |
+
assert rules[0]["severity"] == "medium"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_get_rules_backward_compatibility(rules_store):
|
| 83 |
+
"""Test that get_rules() still returns simple list for backward compatibility."""
|
| 84 |
+
tenant_id = "test_tenant_backward_compat" # Unique tenant ID
|
| 85 |
+
rules_store.add_rule(
|
| 86 |
+
tenant_id=tenant_id,
|
| 87 |
+
rule="Rule 1",
|
| 88 |
+
severity="low"
|
| 89 |
+
)
|
| 90 |
+
rules_store.add_rule(
|
| 91 |
+
tenant_id=tenant_id,
|
| 92 |
+
rule="Rule 2",
|
| 93 |
+
severity="high"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
rules = rules_store.get_rules(tenant_id)
|
| 97 |
+
assert isinstance(rules, list)
|
| 98 |
+
assert len(rules) == 2
|
| 99 |
+
assert "Rule 1" in rules
|
| 100 |
+
assert "Rule 2" in rules
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_regex_pattern_matching(rules_store):
|
| 104 |
+
"""Test that regex patterns work correctly."""
|
| 105 |
+
tenant_id = "test_tenant_regex_match" # Unique tenant ID
|
| 106 |
+
rules_store.add_rule(
|
| 107 |
+
tenant_id=tenant_id,
|
| 108 |
+
rule="Email pattern",
|
| 109 |
+
pattern=r".*@.*\..*",
|
| 110 |
+
severity="medium"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
rules = rules_store.get_rules_detailed(tenant_id)
|
| 114 |
+
assert len(rules) == 1
|
| 115 |
+
pattern = rules[0]["pattern"]
|
| 116 |
+
|
| 117 |
+
# Test regex matching
|
| 118 |
+
test_cases = [
|
| 119 |
+
("user@example.com", True),
|
| 120 |
+
("contact me at test@domain.org", True),
|
| 121 |
+
("no email here", False),
|
| 122 |
+
("just text", False)
|
| 123 |
+
]
|
| 124 |
+
|
| 125 |
+
regex = re.compile(pattern, re.IGNORECASE)
|
| 126 |
+
for text, should_match in test_cases:
|
| 127 |
+
assert (regex.search(text) is not None) == should_match, f"Failed for: {text}"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_severity_levels(rules_store):
|
| 131 |
+
"""Test different severity levels."""
|
| 132 |
+
tenant_id = "test_tenant_severity" # Unique tenant ID
|
| 133 |
+
severities = ["low", "medium", "high", "critical"]
|
| 134 |
+
|
| 135 |
+
for i, severity in enumerate(severities):
|
| 136 |
+
rules_store.add_rule(
|
| 137 |
+
tenant_id=tenant_id,
|
| 138 |
+
rule=f"Rule {severity}",
|
| 139 |
+
severity=severity
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
rules = rules_store.get_rules_detailed(tenant_id)
|
| 143 |
+
assert len(rules) == len(severities)
|
| 144 |
+
|
| 145 |
+
for rule in rules:
|
| 146 |
+
assert rule["severity"] in severities
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_disabled_rules_not_returned(rules_store):
|
| 150 |
+
"""Test that disabled rules are not returned by get_rules()."""
|
| 151 |
+
tenant_id = "test_tenant_disabled" # Unique tenant ID
|
| 152 |
+
rules_store.add_rule(
|
| 153 |
+
tenant_id=tenant_id,
|
| 154 |
+
rule="Enabled rule",
|
| 155 |
+
enabled=True
|
| 156 |
+
)
|
| 157 |
+
rules_store.add_rule(
|
| 158 |
+
tenant_id=tenant_id,
|
| 159 |
+
rule="Disabled rule",
|
| 160 |
+
enabled=False
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
rules = rules_store.get_rules(tenant_id)
|
| 164 |
+
assert len(rules) == 1
|
| 165 |
+
assert "Enabled rule" in rules
|
| 166 |
+
assert "Disabled rule" not in rules
|
| 167 |
+
|
| 168 |
+
# But disabled rules should still exist in detailed view (if we add a method for that)
|
| 169 |
+
# For now, we rely on enabled column filtering
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def test_multiple_tenants_isolation(rules_store):
|
| 173 |
+
"""Test that rules are properly isolated by tenant."""
|
| 174 |
+
rules_store.add_rule(
|
| 175 |
+
tenant_id="tenant1",
|
| 176 |
+
rule="Tenant 1 rule",
|
| 177 |
+
severity="low"
|
| 178 |
+
)
|
| 179 |
+
rules_store.add_rule(
|
| 180 |
+
tenant_id="tenant2",
|
| 181 |
+
rule="Tenant 2 rule",
|
| 182 |
+
severity="high"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
tenant1_rules = rules_store.get_rules("tenant1")
|
| 186 |
+
tenant2_rules = rules_store.get_rules("tenant2")
|
| 187 |
+
|
| 188 |
+
assert len(tenant1_rules) == 1
|
| 189 |
+
assert "Tenant 1 rule" in tenant1_rules
|
| 190 |
+
assert "Tenant 2 rule" not in tenant1_rules
|
| 191 |
+
|
| 192 |
+
assert len(tenant2_rules) == 1
|
| 193 |
+
assert "Tenant 2 rule" in tenant2_rules
|
| 194 |
+
assert "Tenant 1 rule" not in tenant2_rules
|
| 195 |
+
|
check_rag_database.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diagnostic script to check RAG database tenant isolation
|
| 3 |
+
|
| 4 |
+
This script directly queries the database to verify tenant_id isolation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
# Add backend to path
|
| 11 |
+
backend_dir = Path(__file__).parent / "backend"
|
| 12 |
+
sys.path.insert(0, str(backend_dir))
|
| 13 |
+
|
| 14 |
+
def check_database():
|
| 15 |
+
"""Check database directly for tenant isolation"""
|
| 16 |
+
print("\n" + "="*60)
|
| 17 |
+
print("RAG Database Tenant Isolation Check")
|
| 18 |
+
print("="*60)
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from mcp_servers.database import get_connection
|
| 22 |
+
import psycopg2.extras
|
| 23 |
+
|
| 24 |
+
conn = get_connection()
|
| 25 |
+
cur = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
| 26 |
+
|
| 27 |
+
# Check all tenant_ids in database
|
| 28 |
+
print("\n1. Checking all tenant_ids in database...")
|
| 29 |
+
cur.execute("SELECT DISTINCT tenant_id, COUNT(*) as count FROM documents GROUP BY tenant_id")
|
| 30 |
+
rows = cur.fetchall()
|
| 31 |
+
|
| 32 |
+
if not rows:
|
| 33 |
+
print(" β οΈ No documents found in database")
|
| 34 |
+
cur.close()
|
| 35 |
+
conn.close()
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
print(f" Found {len(rows)} unique tenant(s):")
|
| 39 |
+
for row in rows:
|
| 40 |
+
print(f" - tenant_id: '{row['tenant_id']}' ({row['count']} documents)")
|
| 41 |
+
|
| 42 |
+
# Check for tenant1 documents
|
| 43 |
+
print("\n2. Checking documents for 'verify_tenant1'...")
|
| 44 |
+
cur.execute(
|
| 45 |
+
"SELECT id, tenant_id, LEFT(chunk_text, 50) as preview FROM documents WHERE tenant_id = %s LIMIT 5",
|
| 46 |
+
("verify_tenant1",)
|
| 47 |
+
)
|
| 48 |
+
tenant1_docs = cur.fetchall()
|
| 49 |
+
print(f" Found {len(tenant1_docs)} documents for verify_tenant1")
|
| 50 |
+
for doc in tenant1_docs:
|
| 51 |
+
preview = doc['preview'].replace('\n', ' ')
|
| 52 |
+
print(f" - ID: {doc['id']}, tenant_id: '{doc['tenant_id']}', preview: {preview[:50]}...")
|
| 53 |
+
|
| 54 |
+
# Check for tenant2 documents
|
| 55 |
+
print("\n3. Checking documents for 'verify_tenant2'...")
|
| 56 |
+
cur.execute(
|
| 57 |
+
"SELECT id, tenant_id, LEFT(chunk_text, 50) as preview FROM documents WHERE tenant_id = %s LIMIT 5",
|
| 58 |
+
("verify_tenant2",)
|
| 59 |
+
)
|
| 60 |
+
tenant2_docs = cur.fetchall()
|
| 61 |
+
print(f" Found {len(tenant2_docs)} documents for verify_tenant2")
|
| 62 |
+
for doc in tenant2_docs:
|
| 63 |
+
preview = doc['preview'].replace('\n', ' ')
|
| 64 |
+
print(f" - ID: {doc['id']}, tenant_id: '{doc['tenant_id']}', preview: {preview[:50]}...")
|
| 65 |
+
|
| 66 |
+
# Test search_vectors function directly
|
| 67 |
+
print("\n4. Testing search_vectors function directly...")
|
| 68 |
+
from mcp_servers.embeddings import embed_text
|
| 69 |
+
from mcp_servers.database import search_vectors
|
| 70 |
+
|
| 71 |
+
# Search for tenant1's secret as tenant1
|
| 72 |
+
query = "TENANT1_SECRET"
|
| 73 |
+
query_vector = embed_text(query)
|
| 74 |
+
results_tenant1 = search_vectors("verify_tenant1", query_vector, limit=5)
|
| 75 |
+
print(f" Searching for '{query}' as verify_tenant1: {len(results_tenant1)} results")
|
| 76 |
+
for i, result in enumerate(results_tenant1[:2], 1):
|
| 77 |
+
text_preview = result['text'][:80].replace('\n', ' ')
|
| 78 |
+
print(f" Result {i}: {text_preview}...")
|
| 79 |
+
|
| 80 |
+
# Search for tenant1's secret as tenant2 (should NOT find)
|
| 81 |
+
results_tenant2 = search_vectors("verify_tenant2", query_vector, limit=5)
|
| 82 |
+
print(f" Searching for '{query}' as verify_tenant2: {len(results_tenant2)} results")
|
| 83 |
+
if results_tenant2:
|
| 84 |
+
print(" β οΈ WARNING: tenant2 found tenant1's secret!")
|
| 85 |
+
for i, result in enumerate(results_tenant2[:2], 1):
|
| 86 |
+
text_preview = result['text'][:80].replace('\n', ' ')
|
| 87 |
+
print(f" Result {i}: {text_preview}...")
|
| 88 |
+
else:
|
| 89 |
+
print(" β
PASSED: tenant2 cannot see tenant1's secret")
|
| 90 |
+
|
| 91 |
+
# Check for any documents with wrong tenant_id
|
| 92 |
+
print("\n5. Checking for data integrity issues...")
|
| 93 |
+
cur.execute("""
|
| 94 |
+
SELECT tenant_id, COUNT(*) as count
|
| 95 |
+
FROM documents
|
| 96 |
+
WHERE tenant_id IN ('verify_tenant1', 'verify_tenant2')
|
| 97 |
+
GROUP BY tenant_id
|
| 98 |
+
""")
|
| 99 |
+
integrity_check = cur.fetchall()
|
| 100 |
+
print(" Tenant document counts:")
|
| 101 |
+
for row in integrity_check:
|
| 102 |
+
print(f" - {row['tenant_id']}: {row['count']} documents")
|
| 103 |
+
|
| 104 |
+
cur.close()
|
| 105 |
+
conn.close()
|
| 106 |
+
|
| 107 |
+
print("\n" + "="*60)
|
| 108 |
+
if results_tenant2 and "TENANT1_SECRET" in str(results_tenant2):
|
| 109 |
+
print("β ISOLATION FAILED: tenant2 can see tenant1's documents")
|
| 110 |
+
else:
|
| 111 |
+
print("β
Database isolation appears to be working correctly")
|
| 112 |
+
print("="*60)
|
| 113 |
+
|
| 114 |
+
except ImportError as e:
|
| 115 |
+
print(f"\nβ Import error: {e}")
|
| 116 |
+
print(" Make sure you're running from the project root directory")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"\nβ Error: {e}")
|
| 119 |
+
import traceback
|
| 120 |
+
traceback.print_exc()
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
check_database()
|
| 125 |
+
|
data/admin_rules.db
CHANGED
|
Binary files a/data/admin_rules.db and b/data/admin_rules.db differ
|
|
|
data/analytics.db
ADDED
|
Binary file (41 kB). View file
|
|
|
test_manual.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Manual testing script for IntegraChat improvements
|
| 3 |
+
|
| 4 |
+
Run this script to test all new features:
|
| 5 |
+
- Analytics logging
|
| 6 |
+
- Enhanced admin rules with regex/severity
|
| 7 |
+
- API endpoints
|
| 8 |
+
- Agent debug/plan endpoints
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python test_manual.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import requests
|
| 15 |
+
import json
|
| 16 |
+
import time
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import sys
|
| 19 |
+
|
| 20 |
+
# Add backend to path
|
| 21 |
+
backend_dir = Path(__file__).parent / "backend"
|
| 22 |
+
sys.path.insert(0, str(backend_dir))
|
| 23 |
+
|
| 24 |
+
# Also add root for backend.api imports
|
| 25 |
+
root_dir = Path(__file__).parent
|
| 26 |
+
sys.path.insert(0, str(root_dir))
|
| 27 |
+
|
| 28 |
+
BASE_URL = "http://localhost:8000"
|
| 29 |
+
TENANT_ID = "test_tenant_manual"
|
| 30 |
+
|
| 31 |
+
def print_section(title):
|
| 32 |
+
print("\n" + "=" * 60)
|
| 33 |
+
print(f" {title}")
|
| 34 |
+
print("=" * 60)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_analytics_store():
|
| 38 |
+
"""Test AnalyticsStore directly."""
|
| 39 |
+
print_section("Testing AnalyticsStore")
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
from api.storage.analytics_store import AnalyticsStore
|
| 43 |
+
|
| 44 |
+
store = AnalyticsStore()
|
| 45 |
+
|
| 46 |
+
# Log various events
|
| 47 |
+
print("Logging tool usage...")
|
| 48 |
+
store.log_tool_usage(TENANT_ID, "rag", latency_ms=150, tokens_used=500, success=True)
|
| 49 |
+
store.log_tool_usage(TENANT_ID, "web", latency_ms=80, success=True)
|
| 50 |
+
store.log_tool_usage(TENANT_ID, "llm", latency_ms=200, tokens_used=1000, success=True)
|
| 51 |
+
|
| 52 |
+
print("Logging red-flag violation...")
|
| 53 |
+
store.log_redflag_violation(
|
| 54 |
+
TENANT_ID,
|
| 55 |
+
"rule1",
|
| 56 |
+
".*password.*",
|
| 57 |
+
"high",
|
| 58 |
+
"password123",
|
| 59 |
+
confidence=0.95,
|
| 60 |
+
message_preview="User asked about password"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
print("Logging RAG search...")
|
| 64 |
+
store.log_rag_search(
|
| 65 |
+
TENANT_ID,
|
| 66 |
+
"What is the company policy?",
|
| 67 |
+
hits_count=5,
|
| 68 |
+
avg_score=0.85,
|
| 69 |
+
top_score=0.92,
|
| 70 |
+
latency_ms=120
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
print("Logging agent query...")
|
| 74 |
+
store.log_agent_query(
|
| 75 |
+
TENANT_ID,
|
| 76 |
+
"What is the company policy?",
|
| 77 |
+
intent="rag",
|
| 78 |
+
tools_used=["rag", "llm"],
|
| 79 |
+
total_tokens=1000,
|
| 80 |
+
total_latency_ms=250,
|
| 81 |
+
success=True
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Get stats
|
| 85 |
+
print("\nπ Tool Usage Stats:")
|
| 86 |
+
print(json.dumps(store.get_tool_usage_stats(TENANT_ID), indent=2))
|
| 87 |
+
|
| 88 |
+
print("\nπ¨ Red-Flag Violations:")
|
| 89 |
+
violations = store.get_redflag_violations(TENANT_ID)
|
| 90 |
+
print(json.dumps(violations, indent=2, default=str))
|
| 91 |
+
|
| 92 |
+
print("\nπ Activity Summary:")
|
| 93 |
+
print(json.dumps(store.get_activity_summary(TENANT_ID), indent=2, default=str))
|
| 94 |
+
|
| 95 |
+
print("\nπ RAG Quality Metrics:")
|
| 96 |
+
print(json.dumps(store.get_rag_quality_metrics(TENANT_ID), indent=2))
|
| 97 |
+
|
| 98 |
+
print("\nβ
AnalyticsStore tests passed!")
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
except Exception as e:
|
| 102 |
+
print(f"β AnalyticsStore test failed: {e}")
|
| 103 |
+
import traceback
|
| 104 |
+
traceback.print_exc()
|
| 105 |
+
return False
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_admin_rules():
|
| 109 |
+
"""Test enhanced admin rules with regex and severity."""
|
| 110 |
+
print_section("Testing Enhanced Admin Rules")
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
from api.storage.rules_store import RulesStore
|
| 114 |
+
import re
|
| 115 |
+
|
| 116 |
+
store = RulesStore()
|
| 117 |
+
|
| 118 |
+
# Add rules with regex and severity
|
| 119 |
+
print("Adding rules with regex patterns...")
|
| 120 |
+
store.add_rule(
|
| 121 |
+
TENANT_ID,
|
| 122 |
+
"Block password queries",
|
| 123 |
+
pattern=".*password.*|.*pwd.*",
|
| 124 |
+
severity="high",
|
| 125 |
+
description="Blocks password-related queries"
|
| 126 |
+
)
|
| 127 |
+
store.add_rule(
|
| 128 |
+
TENANT_ID,
|
| 129 |
+
"Block email sharing",
|
| 130 |
+
pattern=".*@.*\\..*",
|
| 131 |
+
severity="medium",
|
| 132 |
+
description="Blocks email addresses"
|
| 133 |
+
)
|
| 134 |
+
store.add_rule(
|
| 135 |
+
TENANT_ID,
|
| 136 |
+
"Simple keyword rule",
|
| 137 |
+
severity="low"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Get detailed rules
|
| 141 |
+
rules = store.get_rules_detailed(TENANT_ID)
|
| 142 |
+
print("\nπ Rules with Metadata:")
|
| 143 |
+
print(json.dumps(rules, indent=2, default=str))
|
| 144 |
+
|
| 145 |
+
# Test regex matching
|
| 146 |
+
print("\nπ§ͺ Testing Regex Patterns:")
|
| 147 |
+
for rule in rules:
|
| 148 |
+
if rule.get("pattern"):
|
| 149 |
+
pattern = rule["pattern"]
|
| 150 |
+
regex = re.compile(pattern, re.IGNORECASE)
|
| 151 |
+
test_cases = [
|
| 152 |
+
"What is my password?",
|
| 153 |
+
"My email is test@example.com",
|
| 154 |
+
"Just regular text"
|
| 155 |
+
]
|
| 156 |
+
for test_text in test_cases:
|
| 157 |
+
match = regex.search(test_text)
|
| 158 |
+
print(f" Pattern: {pattern[:30]}... | Text: \"{test_text}\" | Match: {match is not None}")
|
| 159 |
+
|
| 160 |
+
print("\nβ
Admin Rules tests passed!")
|
| 161 |
+
return True
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
print(f"β Admin Rules test failed: {e}")
|
| 165 |
+
import traceback
|
| 166 |
+
traceback.print_exc()
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_api_endpoints():
|
| 171 |
+
"""Test API endpoints."""
|
| 172 |
+
print_section("Testing API Endpoints")
|
| 173 |
+
|
| 174 |
+
headers = {"x-tenant-id": TENANT_ID}
|
| 175 |
+
|
| 176 |
+
endpoints = [
|
| 177 |
+
("GET", "/analytics/overview?days=30", None),
|
| 178 |
+
("GET", "/analytics/tool-usage?days=30", None),
|
| 179 |
+
("GET", "/analytics/rag-quality?days=30", None),
|
| 180 |
+
("GET", "/analytics/redflags?limit=50&days=30", None),
|
| 181 |
+
("GET", "/admin/rules?detailed=true", None),
|
| 182 |
+
("GET", "/admin/violations?limit=50&days=30", None),
|
| 183 |
+
("GET", "/admin/tools/logs?days=7", None),
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
results = []
|
| 187 |
+
|
| 188 |
+
for method, endpoint, data in endpoints:
|
| 189 |
+
try:
|
| 190 |
+
url = f"{BASE_URL}{endpoint}"
|
| 191 |
+
if method == "GET":
|
| 192 |
+
response = requests.get(url, headers=headers, timeout=5)
|
| 193 |
+
else:
|
| 194 |
+
response = requests.post(url, headers=headers, json=data, timeout=5)
|
| 195 |
+
|
| 196 |
+
status = "β
" if response.status_code == 200 else "β οΈ"
|
| 197 |
+
print(f"{status} {method} {endpoint} - Status: {response.status_code}")
|
| 198 |
+
|
| 199 |
+
if response.status_code == 200:
|
| 200 |
+
result = response.json()
|
| 201 |
+
print(f" Response keys: {list(result.keys())[:5]}")
|
| 202 |
+
|
| 203 |
+
results.append(response.status_code == 200)
|
| 204 |
+
|
| 205 |
+
except requests.exceptions.ConnectionError:
|
| 206 |
+
print(f"β {method} {endpoint} - Cannot connect to {BASE_URL}")
|
| 207 |
+
print(" Make sure the FastAPI server is running on port 8000")
|
| 208 |
+
results.append(False)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
print(f"β {method} {endpoint} - Error: {e}")
|
| 211 |
+
results.append(False)
|
| 212 |
+
|
| 213 |
+
# Test POST endpoints
|
| 214 |
+
print("\nπ Testing POST Endpoints...")
|
| 215 |
+
|
| 216 |
+
try:
|
| 217 |
+
# Add admin rule
|
| 218 |
+
response = requests.post(
|
| 219 |
+
f"{BASE_URL}/admin/rules",
|
| 220 |
+
headers=headers,
|
| 221 |
+
json={
|
| 222 |
+
"rule": "Test rule via API",
|
| 223 |
+
"pattern": ".*test.*",
|
| 224 |
+
"severity": "medium"
|
| 225 |
+
},
|
| 226 |
+
timeout=5
|
| 227 |
+
)
|
| 228 |
+
status = "β
" if response.status_code == 200 else "β οΈ"
|
| 229 |
+
print(f"{status} POST /admin/rules - Status: {response.status_code}")
|
| 230 |
+
results.append(response.status_code == 200)
|
| 231 |
+
except Exception as e:
|
| 232 |
+
print(f"β POST /admin/rules - Error: {e}")
|
| 233 |
+
results.append(False)
|
| 234 |
+
|
| 235 |
+
# Test agent endpoints (may fail if services not running)
|
| 236 |
+
print("\nπ€ Testing Agent Endpoints...")
|
| 237 |
+
|
| 238 |
+
agent_endpoints = [
|
| 239 |
+
("/agent/plan", {"tenant_id": TENANT_ID, "message": "Test message", "temperature": 0.0}),
|
| 240 |
+
]
|
| 241 |
+
|
| 242 |
+
for endpoint, data in agent_endpoints:
|
| 243 |
+
try:
|
| 244 |
+
response = requests.post(
|
| 245 |
+
f"{BASE_URL}{endpoint}",
|
| 246 |
+
json=data,
|
| 247 |
+
timeout=10
|
| 248 |
+
)
|
| 249 |
+
status = "β
" if response.status_code == 200 else "β οΈ"
|
| 250 |
+
print(f"{status} POST {endpoint} - Status: {response.status_code}")
|
| 251 |
+
if response.status_code == 200:
|
| 252 |
+
result = response.json()
|
| 253 |
+
print(f" Response keys: {list(result.keys())[:5]}")
|
| 254 |
+
results.append(response.status_code in [200, 500, 503]) # Accept various status codes
|
| 255 |
+
except Exception as e:
|
| 256 |
+
print(f"β οΈ POST {endpoint} - Error: {e} (May be expected if services not running)")
|
| 257 |
+
results.append(True) # Don't fail if services not running
|
| 258 |
+
|
| 259 |
+
success_count = sum(results)
|
| 260 |
+
total_count = len(results)
|
| 261 |
+
|
| 262 |
+
print(f"\nπ API Endpoint Tests: {success_count}/{total_count} passed")
|
| 263 |
+
return success_count == total_count or success_count >= total_count * 0.8 # 80% pass rate
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def main():
|
| 267 |
+
"""Run all manual tests."""
|
| 268 |
+
print("\n" + "π" * 30)
|
| 269 |
+
print("IntegraChat Manual Testing Suite")
|
| 270 |
+
print("π" * 30)
|
| 271 |
+
|
| 272 |
+
results = []
|
| 273 |
+
|
| 274 |
+
# Test Analytics Store
|
| 275 |
+
results.append(test_analytics_store())
|
| 276 |
+
time.sleep(1)
|
| 277 |
+
|
| 278 |
+
# Test Admin Rules
|
| 279 |
+
results.append(test_admin_rules())
|
| 280 |
+
time.sleep(1)
|
| 281 |
+
|
| 282 |
+
# Test API Endpoints
|
| 283 |
+
results.append(test_api_endpoints())
|
| 284 |
+
|
| 285 |
+
# Summary
|
| 286 |
+
print_section("Test Summary")
|
| 287 |
+
passed = sum(results)
|
| 288 |
+
total = len(results)
|
| 289 |
+
|
| 290 |
+
print(f"Tests Passed: {passed}/{total}")
|
| 291 |
+
if passed == total:
|
| 292 |
+
print("β
All tests passed!")
|
| 293 |
+
elif passed >= total * 0.8:
|
| 294 |
+
print("β οΈ Most tests passed (some may require running services)")
|
| 295 |
+
else:
|
| 296 |
+
print("β Some tests failed. Check errors above.")
|
| 297 |
+
|
| 298 |
+
print("\nπ‘ Tips:")
|
| 299 |
+
print(" - For API tests, ensure FastAPI server is running: uvicorn backend.api.main:app --port 8000")
|
| 300 |
+
print(" - Agent endpoints may require MCP servers and LLM to be running")
|
| 301 |
+
print(" - Check TESTING_GUIDE.md for more detailed testing instructions")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
if __name__ == "__main__":
|
| 305 |
+
main()
|
| 306 |
+
|
test_simple.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple standalone test script - can be run directly without pytest
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python test_simple.py
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
# Setup paths
|
| 12 |
+
backend_dir = Path(__file__).parent / "backend"
|
| 13 |
+
sys.path.insert(0, str(backend_dir))
|
| 14 |
+
root_dir = Path(__file__).parent
|
| 15 |
+
sys.path.insert(0, str(root_dir))
|
| 16 |
+
|
| 17 |
+
def test_analytics_store():
|
| 18 |
+
"""Test AnalyticsStore"""
|
| 19 |
+
print("\n" + "="*60)
|
| 20 |
+
print("Testing AnalyticsStore")
|
| 21 |
+
print("="*60)
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
from api.storage.analytics_store import AnalyticsStore
|
| 25 |
+
|
| 26 |
+
store = AnalyticsStore()
|
| 27 |
+
tenant_id = "test_simple"
|
| 28 |
+
|
| 29 |
+
# Log some events
|
| 30 |
+
print("β Logging tool usage...")
|
| 31 |
+
store.log_tool_usage(tenant_id, "rag", latency_ms=150, tokens_used=500, success=True)
|
| 32 |
+
store.log_tool_usage(tenant_id, "web", latency_ms=80, success=True)
|
| 33 |
+
|
| 34 |
+
print("β Logging red-flag violation...")
|
| 35 |
+
store.log_redflag_violation(
|
| 36 |
+
tenant_id, "rule1", ".*password.*", "high",
|
| 37 |
+
"password123", confidence=0.95
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
print("β Logging RAG search...")
|
| 41 |
+
store.log_rag_search(tenant_id, "test query", hits_count=5, avg_score=0.85)
|
| 42 |
+
|
| 43 |
+
# Get stats
|
| 44 |
+
print("\nπ Tool Usage Stats:")
|
| 45 |
+
stats = store.get_tool_usage_stats(tenant_id)
|
| 46 |
+
print(f" RAG: {stats.get('rag', {})}")
|
| 47 |
+
print(f" Web: {stats.get('web', {})}")
|
| 48 |
+
|
| 49 |
+
print("\nπ¨ Violations:")
|
| 50 |
+
violations = store.get_redflag_violations(tenant_id)
|
| 51 |
+
print(f" Count: {len(violations)}")
|
| 52 |
+
if violations:
|
| 53 |
+
print(f" First: {violations[0]['severity']} - {violations[0]['matched_text']}")
|
| 54 |
+
|
| 55 |
+
print("\nβ
AnalyticsStore test PASSED!")
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"\nβ AnalyticsStore test FAILED: {e}")
|
| 60 |
+
import traceback
|
| 61 |
+
traceback.print_exc()
|
| 62 |
+
return False
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_admin_rules():
|
| 66 |
+
"""Test Admin Rules with regex"""
|
| 67 |
+
print("\n" + "="*60)
|
| 68 |
+
print("Testing Admin Rules (Regex & Severity)")
|
| 69 |
+
print("="*60)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
from api.storage.rules_store import RulesStore
|
| 73 |
+
import re
|
| 74 |
+
|
| 75 |
+
store = RulesStore()
|
| 76 |
+
tenant_id = "test_simple"
|
| 77 |
+
|
| 78 |
+
# Add rule with regex
|
| 79 |
+
print("β Adding rule with regex pattern...")
|
| 80 |
+
store.add_rule(
|
| 81 |
+
tenant_id,
|
| 82 |
+
"Block password queries",
|
| 83 |
+
pattern=".*password.*",
|
| 84 |
+
severity="high",
|
| 85 |
+
description="Blocks password queries"
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Get detailed rules
|
| 89 |
+
rules = store.get_rules_detailed(tenant_id)
|
| 90 |
+
print(f"\nπ Rules found: {len(rules)}")
|
| 91 |
+
|
| 92 |
+
if rules:
|
| 93 |
+
rule = rules[0]
|
| 94 |
+
print(f" Pattern: {rule['pattern']}")
|
| 95 |
+
print(f" Severity: {rule['severity']}")
|
| 96 |
+
print(f" Description: {rule['description']}")
|
| 97 |
+
|
| 98 |
+
# Test regex
|
| 99 |
+
print("\nπ§ͺ Testing regex pattern...")
|
| 100 |
+
regex = re.compile(rule['pattern'], re.IGNORECASE)
|
| 101 |
+
test_cases = [
|
| 102 |
+
("What is my password?", True),
|
| 103 |
+
("Regular text", False)
|
| 104 |
+
]
|
| 105 |
+
for text, should_match in test_cases:
|
| 106 |
+
match = regex.search(text) is not None
|
| 107 |
+
status = "β" if match == should_match else "β"
|
| 108 |
+
print(f" {status} '{text}' -> {match} (expected {should_match})")
|
| 109 |
+
|
| 110 |
+
print("\nβ
Admin Rules test PASSED!")
|
| 111 |
+
return True
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
print(f"\nβ Admin Rules test FAILED: {e}")
|
| 115 |
+
import traceback
|
| 116 |
+
traceback.print_exc()
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def main():
|
| 121 |
+
"""Run all tests"""
|
| 122 |
+
print("\nπ IntegraChat Simple Tests")
|
| 123 |
+
print("="*60)
|
| 124 |
+
|
| 125 |
+
results = []
|
| 126 |
+
|
| 127 |
+
results.append(test_analytics_store())
|
| 128 |
+
results.append(test_admin_rules())
|
| 129 |
+
|
| 130 |
+
# Summary
|
| 131 |
+
print("\n" + "="*60)
|
| 132 |
+
print("Test Summary")
|
| 133 |
+
print("="*60)
|
| 134 |
+
passed = sum(results)
|
| 135 |
+
total = len(results)
|
| 136 |
+
print(f"Tests Passed: {passed}/{total}")
|
| 137 |
+
|
| 138 |
+
if passed == total:
|
| 139 |
+
print("β
All tests passed!")
|
| 140 |
+
return 0
|
| 141 |
+
else:
|
| 142 |
+
print("β Some tests failed")
|
| 143 |
+
return 1
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
exit(main())
|
| 148 |
+
|
verify_tenant_isolation.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
verify_tenant_isolation.py
|
| 3 |
+
Script to verify tenant_id is properly used for data isolation
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python verify_tenant_isolation.py
|
| 7 |
+
|
| 8 |
+
This script tests:
|
| 9 |
+
- Admin rules isolation
|
| 10 |
+
- Analytics isolation
|
| 11 |
+
- RAG document isolation
|
| 12 |
+
- Database direct verification
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import requests
|
| 16 |
+
import json
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import sys
|
| 19 |
+
|
| 20 |
+
# Add backend to path
|
| 21 |
+
backend_dir = Path(__file__).parent / "backend"
|
| 22 |
+
sys.path.insert(0, str(backend_dir))
|
| 23 |
+
root_dir = Path(__file__).parent
|
| 24 |
+
sys.path.insert(0, str(root_dir))
|
| 25 |
+
|
| 26 |
+
BASE_URL = "http://localhost:8000"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def print_section(title):
|
| 30 |
+
"""Print a formatted section header"""
|
| 31 |
+
print("\n" + "="*60)
|
| 32 |
+
print(f" {title}")
|
| 33 |
+
print("="*60)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def verify_admin_rules_isolation():
|
| 37 |
+
"""Verify admin rules are isolated by tenant_id"""
|
| 38 |
+
print_section("Testing Admin Rules Isolation")
|
| 39 |
+
|
| 40 |
+
tenant1 = "verify_tenant1"
|
| 41 |
+
tenant2 = "verify_tenant2"
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
# Add rules for different tenants
|
| 45 |
+
print(f"\n1. Adding rule for {tenant1}...")
|
| 46 |
+
response = requests.post(
|
| 47 |
+
f"{BASE_URL}/admin/rules",
|
| 48 |
+
headers={"x-tenant-id": tenant1, "Content-Type": "application/json"},
|
| 49 |
+
json={"rule": f"Rule for {tenant1}", "severity": "high"},
|
| 50 |
+
timeout=5
|
| 51 |
+
)
|
| 52 |
+
print(f" Status: {response.status_code}")
|
| 53 |
+
|
| 54 |
+
print(f"\n2. Adding rule for {tenant2}...")
|
| 55 |
+
response = requests.post(
|
| 56 |
+
f"{BASE_URL}/admin/rules",
|
| 57 |
+
headers={"x-tenant-id": tenant2, "Content-Type": "application/json"},
|
| 58 |
+
json={"rule": f"Rule for {tenant2}", "severity": "low"},
|
| 59 |
+
timeout=5
|
| 60 |
+
)
|
| 61 |
+
print(f" Status: {response.status_code}")
|
| 62 |
+
|
| 63 |
+
# Get rules for tenant1
|
| 64 |
+
print(f"\n3. Getting rules for {tenant1}...")
|
| 65 |
+
response = requests.get(
|
| 66 |
+
f"{BASE_URL}/admin/rules",
|
| 67 |
+
headers={"x-tenant-id": tenant1},
|
| 68 |
+
timeout=5
|
| 69 |
+
)
|
| 70 |
+
tenant1_rules = response.json().get("rules", [])
|
| 71 |
+
print(f" Found {len(tenant1_rules)} rules")
|
| 72 |
+
print(f" Rules: {tenant1_rules}")
|
| 73 |
+
|
| 74 |
+
# Get rules for tenant2
|
| 75 |
+
print(f"\n4. Getting rules for {tenant2}...")
|
| 76 |
+
response = requests.get(
|
| 77 |
+
f"{BASE_URL}/admin/rules",
|
| 78 |
+
headers={"x-tenant-id": tenant2},
|
| 79 |
+
timeout=5
|
| 80 |
+
)
|
| 81 |
+
tenant2_rules = response.json().get("rules", [])
|
| 82 |
+
print(f" Found {len(tenant2_rules)} rules")
|
| 83 |
+
print(f" Rules: {tenant2_rules}")
|
| 84 |
+
|
| 85 |
+
# Verify isolation
|
| 86 |
+
print("\n5. Verifying isolation...")
|
| 87 |
+
tenant1_rule_text = f"Rule for {tenant1}"
|
| 88 |
+
tenant2_rule_text = f"Rule for {tenant2}"
|
| 89 |
+
|
| 90 |
+
tenant1_has_own_rule = tenant1_rule_text in tenant1_rules
|
| 91 |
+
tenant1_has_other_rule = tenant2_rule_text in tenant1_rules
|
| 92 |
+
|
| 93 |
+
tenant2_has_own_rule = tenant2_rule_text in tenant2_rules
|
| 94 |
+
tenant2_has_other_rule = tenant1_rule_text in tenant2_rules
|
| 95 |
+
|
| 96 |
+
print(f" Tenant1 has own rule: {tenant1_has_own_rule} β")
|
| 97 |
+
print(f" Tenant1 has other's rule: {tenant1_has_other_rule} {'β FAILED!' if tenant1_has_other_rule else 'β PASSED'}")
|
| 98 |
+
print(f" Tenant2 has own rule: {tenant2_has_own_rule} β")
|
| 99 |
+
print(f" Tenant2 has other's rule: {tenant2_has_other_rule} {'β FAILED!' if tenant2_has_other_rule else 'β PASSED'}")
|
| 100 |
+
|
| 101 |
+
if not tenant1_has_other_rule and not tenant2_has_other_rule:
|
| 102 |
+
print("\nβ
Admin Rules Isolation: PASSED")
|
| 103 |
+
return True
|
| 104 |
+
else:
|
| 105 |
+
print("\nβ Admin Rules Isolation: FAILED")
|
| 106 |
+
return False
|
| 107 |
+
|
| 108 |
+
except requests.exceptions.ConnectionError:
|
| 109 |
+
print("\nβ οΈ Cannot connect to API. Make sure it's running:")
|
| 110 |
+
print(" uvicorn backend.api.main:app --port 8000")
|
| 111 |
+
return None
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"\nβ Error: {e}")
|
| 114 |
+
import traceback
|
| 115 |
+
traceback.print_exc()
|
| 116 |
+
return False
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def verify_analytics_isolation():
|
| 120 |
+
"""Verify analytics are isolated by tenant_id"""
|
| 121 |
+
print_section("Testing Analytics Isolation")
|
| 122 |
+
|
| 123 |
+
tenant1 = "verify_tenant1"
|
| 124 |
+
tenant2 = "verify_tenant2"
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
# Make queries for different tenants
|
| 128 |
+
print(f"\n1. Making query as {tenant1}...")
|
| 129 |
+
response = requests.post(
|
| 130 |
+
f"{BASE_URL}/agent/message",
|
| 131 |
+
json={"tenant_id": tenant1, "message": "Test query from tenant1"},
|
| 132 |
+
timeout=10
|
| 133 |
+
)
|
| 134 |
+
print(f" Status: {response.status_code}")
|
| 135 |
+
|
| 136 |
+
print(f"\n2. Making query as {tenant2}...")
|
| 137 |
+
response = requests.post(
|
| 138 |
+
f"{BASE_URL}/agent/message",
|
| 139 |
+
json={"tenant_id": tenant2, "message": "Test query from tenant2"},
|
| 140 |
+
timeout=10
|
| 141 |
+
)
|
| 142 |
+
print(f" Status: {response.status_code}")
|
| 143 |
+
|
| 144 |
+
# Get analytics for tenant1
|
| 145 |
+
print(f"\n3. Getting analytics for {tenant1}...")
|
| 146 |
+
response = requests.get(
|
| 147 |
+
f"{BASE_URL}/analytics/overview?days=30",
|
| 148 |
+
headers={"x-tenant-id": tenant1},
|
| 149 |
+
timeout=5
|
| 150 |
+
)
|
| 151 |
+
tenant1_analytics = response.json()
|
| 152 |
+
print(f" Total queries: {tenant1_analytics.get('total_queries', 0)}")
|
| 153 |
+
|
| 154 |
+
# Get analytics for tenant2
|
| 155 |
+
print(f"\n4. Getting analytics for {tenant2}...")
|
| 156 |
+
response = requests.get(
|
| 157 |
+
f"{BASE_URL}/analytics/overview?days=30",
|
| 158 |
+
headers={"x-tenant-id": tenant2},
|
| 159 |
+
timeout=5
|
| 160 |
+
)
|
| 161 |
+
tenant2_analytics = response.json()
|
| 162 |
+
print(f" Total queries: {tenant2_analytics.get('total_queries', 0)}")
|
| 163 |
+
|
| 164 |
+
# Verify they're different
|
| 165 |
+
print("\n5. Verifying isolation...")
|
| 166 |
+
tenant1_queries = tenant1_analytics.get('total_queries', 0)
|
| 167 |
+
tenant2_queries = tenant2_analytics.get('total_queries', 0)
|
| 168 |
+
|
| 169 |
+
print(f" Tenant1 queries: {tenant1_queries}")
|
| 170 |
+
print(f" Tenant2 queries: {tenant2_queries}")
|
| 171 |
+
|
| 172 |
+
if tenant1_queries > 0 and tenant2_queries > 0:
|
| 173 |
+
print("\nβ
Analytics Isolation: PASSED (both tenants have their own data)")
|
| 174 |
+
return True
|
| 175 |
+
else:
|
| 176 |
+
print("\nβ οΈ Analytics Isolation: Need more queries to verify")
|
| 177 |
+
return True
|
| 178 |
+
|
| 179 |
+
except requests.exceptions.ConnectionError:
|
| 180 |
+
print("\nβ οΈ Cannot connect to API. Make sure it's running:")
|
| 181 |
+
print(" uvicorn backend.api.main:app --port 8000")
|
| 182 |
+
return None
|
| 183 |
+
except Exception as e:
|
| 184 |
+
print(f"\nβ Error: {e}")
|
| 185 |
+
import traceback
|
| 186 |
+
traceback.print_exc()
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def verify_rag_isolation():
|
| 191 |
+
"""Verify RAG documents are isolated by tenant_id"""
|
| 192 |
+
print_section("Testing RAG Document Isolation")
|
| 193 |
+
|
| 194 |
+
tenant1 = "verify_tenant1"
|
| 195 |
+
tenant2 = "verify_tenant2"
|
| 196 |
+
|
| 197 |
+
try:
|
| 198 |
+
# Ingest documents for different tenants
|
| 199 |
+
print(f"\n1. Ingesting document for {tenant1}...")
|
| 200 |
+
response = requests.post(
|
| 201 |
+
f"{BASE_URL}/rag/ingest-document",
|
| 202 |
+
headers={"x-tenant-id": tenant1, "Content-Type": "application/json"},
|
| 203 |
+
json={
|
| 204 |
+
"content": "This is a confidential document for Tenant 1 only. Secret code: TENANT1_SECRET_12345",
|
| 205 |
+
"source_type": "raw_text"
|
| 206 |
+
},
|
| 207 |
+
timeout=10
|
| 208 |
+
)
|
| 209 |
+
print(f" Status: {response.status_code}")
|
| 210 |
+
if response.status_code != 200:
|
| 211 |
+
print(f" Error: {response.text}")
|
| 212 |
+
|
| 213 |
+
print(f"\n2. Ingesting document for {tenant2}...")
|
| 214 |
+
response = requests.post(
|
| 215 |
+
f"{BASE_URL}/rag/ingest-document",
|
| 216 |
+
headers={"x-tenant-id": tenant2, "Content-Type": "application/json"},
|
| 217 |
+
json={
|
| 218 |
+
"content": "This is a confidential document for Tenant 2 only. Secret code: TENANT2_SECRET_67890",
|
| 219 |
+
"source_type": "raw_text"
|
| 220 |
+
},
|
| 221 |
+
timeout=10
|
| 222 |
+
)
|
| 223 |
+
print(f" Status: {response.status_code}")
|
| 224 |
+
if response.status_code != 200:
|
| 225 |
+
print(f" Error: {response.text}")
|
| 226 |
+
|
| 227 |
+
# List documents for tenant1
|
| 228 |
+
print(f"\n3. Listing documents for {tenant1}...")
|
| 229 |
+
response = requests.get(
|
| 230 |
+
f"{BASE_URL}/rag/list",
|
| 231 |
+
headers={"x-tenant-id": tenant1},
|
| 232 |
+
timeout=5
|
| 233 |
+
)
|
| 234 |
+
tenant1_docs = response.json().get("documents", [])
|
| 235 |
+
print(f" Found {len(tenant1_docs)} documents")
|
| 236 |
+
|
| 237 |
+
# List documents for tenant2
|
| 238 |
+
print(f"\n4. Listing documents for {tenant2}...")
|
| 239 |
+
response = requests.get(
|
| 240 |
+
f"{BASE_URL}/rag/list",
|
| 241 |
+
headers={"x-tenant-id": tenant2},
|
| 242 |
+
timeout=5
|
| 243 |
+
)
|
| 244 |
+
tenant2_docs = response.json().get("documents", [])
|
| 245 |
+
print(f" Found {len(tenant2_docs)} documents")
|
| 246 |
+
|
| 247 |
+
# Search for tenant1's secret
|
| 248 |
+
print(f"\n5. Searching for tenant1's secret as tenant1...")
|
| 249 |
+
response = requests.post(
|
| 250 |
+
f"{BASE_URL}/rag/search",
|
| 251 |
+
headers={"x-tenant-id": tenant1, "Content-Type": "application/json"},
|
| 252 |
+
json={"query": "TENANT1_SECRET"},
|
| 253 |
+
timeout=10
|
| 254 |
+
)
|
| 255 |
+
tenant1_search = response.json()
|
| 256 |
+
|
| 257 |
+
# Check only the result texts, not the entire JSON (which includes the query)
|
| 258 |
+
tenant1_results = tenant1_search.get("results", [])
|
| 259 |
+
tenant1_found = False
|
| 260 |
+
for result in tenant1_results:
|
| 261 |
+
result_text = result.get("text", "") or result.get("content", "") or str(result)
|
| 262 |
+
if "TENANT1_SECRET" in result_text:
|
| 263 |
+
tenant1_found = True
|
| 264 |
+
break
|
| 265 |
+
|
| 266 |
+
print(f" Found: {tenant1_found}")
|
| 267 |
+
if tenant1_results:
|
| 268 |
+
print(f" Results count: {len(tenant1_results)}")
|
| 269 |
+
if tenant1_results:
|
| 270 |
+
print(f" First result preview: {str(tenant1_results[0].get('text', ''))[:100]}...")
|
| 271 |
+
|
| 272 |
+
# Search for tenant1's secret as tenant2 (should NOT find it)
|
| 273 |
+
print(f"\n6. Searching for tenant1's secret as tenant2 (should NOT find)...")
|
| 274 |
+
response = requests.post(
|
| 275 |
+
f"{BASE_URL}/rag/search",
|
| 276 |
+
headers={"x-tenant-id": tenant2, "Content-Type": "application/json"},
|
| 277 |
+
json={"query": "TENANT1_SECRET"},
|
| 278 |
+
timeout=10
|
| 279 |
+
)
|
| 280 |
+
tenant2_search = response.json()
|
| 281 |
+
|
| 282 |
+
# Check results more carefully
|
| 283 |
+
tenant2_results = tenant2_search.get("results", [])
|
| 284 |
+
tenant2_found = False
|
| 285 |
+
tenant2_found_texts = []
|
| 286 |
+
|
| 287 |
+
for result in tenant2_results:
|
| 288 |
+
result_text = result.get("text", "") or result.get("content", "") or str(result)
|
| 289 |
+
if "TENANT1_SECRET" in result_text:
|
| 290 |
+
tenant2_found = True
|
| 291 |
+
tenant2_found_texts.append(result_text[:100])
|
| 292 |
+
|
| 293 |
+
print(f" Found: {tenant2_found}")
|
| 294 |
+
print(f" Results count: {len(tenant2_results)}")
|
| 295 |
+
if tenant2_results:
|
| 296 |
+
print(f" First result preview: {str(tenant2_results[0])[:150]}")
|
| 297 |
+
if tenant2_found_texts:
|
| 298 |
+
print(f" β οΈ Found TENANT1_SECRET in {len(tenant2_found_texts)} result(s):")
|
| 299 |
+
for i, text in enumerate(tenant2_found_texts, 1):
|
| 300 |
+
print(f" {i}. {text}...")
|
| 301 |
+
|
| 302 |
+
# Verify isolation
|
| 303 |
+
print("\n7. Verifying isolation...")
|
| 304 |
+
if tenant1_found and not tenant2_found:
|
| 305 |
+
print(" β
Tenant1 can find their own secret")
|
| 306 |
+
print(" β
Tenant2 cannot find tenant1's secret")
|
| 307 |
+
print("\nβ
RAG Isolation: PASSED")
|
| 308 |
+
return True
|
| 309 |
+
elif tenant1_found and tenant2_found:
|
| 310 |
+
print(" β Tenant2 can see tenant1's secret - ISOLATION FAILED!")
|
| 311 |
+
print(f" Debug: tenant2 found {len(tenant2_found_texts)} result(s) containing TENANT1_SECRET")
|
| 312 |
+
print("\nβ RAG Isolation: FAILED")
|
| 313 |
+
return False
|
| 314 |
+
else:
|
| 315 |
+
print(" β οΈ Could not verify (may need RAG server running)")
|
| 316 |
+
print("\nβ οΈ RAG Isolation: INCONCLUSIVE")
|
| 317 |
+
return None
|
| 318 |
+
|
| 319 |
+
except requests.exceptions.ConnectionError:
|
| 320 |
+
print("\nβ οΈ Cannot connect to API/RAG server. Make sure they're running:")
|
| 321 |
+
print(" uvicorn backend.api.main:app --port 8000")
|
| 322 |
+
print(" python -m backend.mcp_servers.rag_server")
|
| 323 |
+
return None
|
| 324 |
+
except Exception as e:
|
| 325 |
+
print(f"\nβ Error: {e}")
|
| 326 |
+
import traceback
|
| 327 |
+
traceback.print_exc()
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def verify_database_directly():
|
| 332 |
+
"""Verify tenant_id in database directly"""
|
| 333 |
+
print_section("Verifying Database Directly")
|
| 334 |
+
|
| 335 |
+
try:
|
| 336 |
+
from api.storage.analytics_store import AnalyticsStore
|
| 337 |
+
from api.storage.rules_store import RulesStore
|
| 338 |
+
|
| 339 |
+
# Check analytics store
|
| 340 |
+
print("\n1. Checking Analytics Store...")
|
| 341 |
+
analytics = AnalyticsStore()
|
| 342 |
+
|
| 343 |
+
# Log events for different tenants
|
| 344 |
+
analytics.log_tool_usage("db_verify_tenant1", "rag", latency_ms=100)
|
| 345 |
+
analytics.log_tool_usage("db_verify_tenant2", "web", latency_ms=200)
|
| 346 |
+
|
| 347 |
+
# Get stats
|
| 348 |
+
tenant1_stats = analytics.get_tool_usage_stats("db_verify_tenant1")
|
| 349 |
+
tenant2_stats = analytics.get_tool_usage_stats("db_verify_tenant2")
|
| 350 |
+
|
| 351 |
+
print(f" Tenant1 stats: {list(tenant1_stats.keys())}")
|
| 352 |
+
print(f" Tenant2 stats: {list(tenant2_stats.keys())}")
|
| 353 |
+
|
| 354 |
+
# Check rules store
|
| 355 |
+
print("\n2. Checking Rules Store...")
|
| 356 |
+
rules = RulesStore()
|
| 357 |
+
|
| 358 |
+
rules.add_rule("db_verify_tenant1", "Rule 1", severity="high")
|
| 359 |
+
rules.add_rule("db_verify_tenant2", "Rule 2", severity="low")
|
| 360 |
+
|
| 361 |
+
tenant1_rules = rules.get_rules("db_verify_tenant1")
|
| 362 |
+
tenant2_rules = rules.get_rules("db_verify_tenant2")
|
| 363 |
+
|
| 364 |
+
print(f" Tenant1 rules: {tenant1_rules}")
|
| 365 |
+
print(f" Tenant2 rules: {tenant2_rules}")
|
| 366 |
+
|
| 367 |
+
# Verify isolation
|
| 368 |
+
print("\n3. Verifying isolation...")
|
| 369 |
+
tenant1_has_rule1 = "Rule 1" in tenant1_rules
|
| 370 |
+
tenant1_has_rule2 = "Rule 2" in tenant1_rules
|
| 371 |
+
tenant2_has_rule1 = "Rule 1" in tenant2_rules
|
| 372 |
+
tenant2_has_rule2 = "Rule 2" in tenant2_rules
|
| 373 |
+
|
| 374 |
+
print(f" Tenant1 has Rule 1: {tenant1_has_rule1} β")
|
| 375 |
+
print(f" Tenant1 has Rule 2: {tenant1_has_rule2} {'β FAILED!' if tenant1_has_rule2 else 'β PASSED'}")
|
| 376 |
+
print(f" Tenant2 has Rule 1: {tenant2_has_rule1} {'β FAILED!' if tenant2_has_rule1 else 'β PASSED'}")
|
| 377 |
+
print(f" Tenant2 has Rule 2: {tenant2_has_rule2} β")
|
| 378 |
+
|
| 379 |
+
if tenant1_has_rule1 and not tenant1_has_rule2 and not tenant2_has_rule1 and tenant2_has_rule2:
|
| 380 |
+
print("\nβ
Database Direct Verification: PASSED")
|
| 381 |
+
return True
|
| 382 |
+
else:
|
| 383 |
+
print("\nβ Database Direct Verification: FAILED")
|
| 384 |
+
return False
|
| 385 |
+
|
| 386 |
+
except Exception as e:
|
| 387 |
+
print(f"\nβ Error: {e}")
|
| 388 |
+
import traceback
|
| 389 |
+
traceback.print_exc()
|
| 390 |
+
return False
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def main():
|
| 394 |
+
"""Run all verification tests"""
|
| 395 |
+
print("\n" + "π" * 30)
|
| 396 |
+
print("Tenant ID Isolation Verification")
|
| 397 |
+
print("π" * 30)
|
| 398 |
+
|
| 399 |
+
results = []
|
| 400 |
+
|
| 401 |
+
# Test 1: Database direct verification (always runs, no API needed)
|
| 402 |
+
print("\nπ Running database direct verification (no API required)...")
|
| 403 |
+
result = verify_database_directly()
|
| 404 |
+
if result is not None:
|
| 405 |
+
results.append(result)
|
| 406 |
+
|
| 407 |
+
# Test 2: Admin rules isolation (requires API running)
|
| 408 |
+
print("\nπ Testing admin rules isolation (requires API)...")
|
| 409 |
+
result = verify_admin_rules_isolation()
|
| 410 |
+
if result is not None:
|
| 411 |
+
results.append(result)
|
| 412 |
+
|
| 413 |
+
# Test 3: Analytics isolation (requires API running)
|
| 414 |
+
print("\nπ Testing analytics isolation (requires API)...")
|
| 415 |
+
result = verify_analytics_isolation()
|
| 416 |
+
if result is not None:
|
| 417 |
+
results.append(result)
|
| 418 |
+
|
| 419 |
+
# Test 4: RAG isolation (requires API and RAG server running)
|
| 420 |
+
print("\nπ Testing RAG document isolation (requires API + RAG server)...")
|
| 421 |
+
result = verify_rag_isolation()
|
| 422 |
+
if result is not None:
|
| 423 |
+
results.append(result)
|
| 424 |
+
|
| 425 |
+
# Summary
|
| 426 |
+
print_section("Verification Summary")
|
| 427 |
+
passed = sum(1 for r in results if r is True)
|
| 428 |
+
failed = sum(1 for r in results if r is False)
|
| 429 |
+
total = len(results)
|
| 430 |
+
|
| 431 |
+
print(f"\nTests Completed: {total}")
|
| 432 |
+
print(f"β
Passed: {passed}")
|
| 433 |
+
print(f"β Failed: {failed}")
|
| 434 |
+
|
| 435 |
+
if total == 0:
|
| 436 |
+
print("\nβ οΈ No tests could run. Make sure services are running:")
|
| 437 |
+
print(" - API: uvicorn backend.api.main:app --port 8000")
|
| 438 |
+
print(" - RAG Server: python -m backend.mcp_servers.rag_server")
|
| 439 |
+
elif failed == 0 and passed > 0:
|
| 440 |
+
print("\nβ
All tenant isolation tests PASSED!")
|
| 441 |
+
elif failed > 0:
|
| 442 |
+
print("\nβ Some tenant isolation tests FAILED!")
|
| 443 |
+
else:
|
| 444 |
+
print("\nβ οΈ Some tests were inconclusive or skipped")
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
if __name__ == "__main__":
|
| 448 |
+
main()
|
| 449 |
+
|