nothingworry commited on
Commit
cb88e8d
·
1 Parent(s): 6e24963

chore: remove obsolete files and frontend directory

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. 5_day_meal_plan.docx +0 -0
  2. 5_day_meal_plan.xlsx +0 -0
  3. ANTHROPIC_CONTEXT_ENGINEERING.md +0 -201
  4. CONTEXT_ENGINEERING_IMPLEMENTATION.md +0 -128
  5. KB_FIRST_IMPLEMENTATION.md +0 -81
  6. TESTING_GUIDE.md +0 -308
  7. backend/api/placeholder.txt +0 -4
  8. backend/tests/README_RETRY_TESTS.md +0 -266
  9. backend/tests/conftest.py +0 -1
  10. backend/tests/test_access_control.py +0 -55
  11. backend/tests/test_agent_orchestrator.py +0 -230
  12. backend/tests/test_analytics_store.py +0 -208
  13. backend/tests/test_api_endpoints.py +0 -222
  14. backend/tests/test_conversation_memory.py +0 -479
  15. backend/tests/test_enhanced_admin_rules.py +0 -195
  16. backend/tests/test_intent.py +0 -118
  17. backend/tests/test_metadata_extraction.py +0 -461
  18. backend/tests/test_retry_system.py +0 -651
  19. backend/tests/test_tool_metadata_and_routing.py +0 -585
  20. backend/workers/placeholder.txt +0 -4
  21. check_env.py +0 -106
  22. check_rag_database.py +0 -125
  23. check_rules_db.py +0 -43
  24. check_supabase_rules.py +0 -132
  25. create_supabase_table.py +0 -185
  26. create_supabase_table_simple.py +0 -70
  27. createingdummydata.py +0 -44
  28. example_rules.txt +0 -133
  29. example_rules_detailed.json +0 -131
  30. frontend/.gitignore +0 -41
  31. frontend/README.md +0 -134
  32. frontend/app/admin-rules/page.tsx +0 -778
  33. frontend/app/analytics/page.tsx +0 -82
  34. frontend/app/chat/page.tsx +0 -36
  35. frontend/app/favicon.ico +0 -0
  36. frontend/app/globals.css +0 -116
  37. frontend/app/ingestion/page.tsx +0 -79
  38. frontend/app/knowledge-base/page.tsx +0 -394
  39. frontend/app/layout.tsx +0 -38
  40. frontend/app/page.tsx +0 -110
  41. frontend/components/admin-rules-panel.tsx +0 -57
  42. frontend/components/analytics-panel.tsx +0 -152
  43. frontend/components/chat-panel.tsx +0 -213
  44. frontend/components/feature-grid.tsx +0 -56
  45. frontend/components/footer.tsx +0 -15
  46. frontend/components/hero.tsx +0 -100
  47. frontend/components/ingestion-card.tsx +0 -56
  48. frontend/components/knowledge-base-panel.tsx +0 -614
  49. frontend/components/reasoning-visualizer.tsx +0 -245
  50. frontend/components/rule-explanation.tsx +0 -129
5_day_meal_plan.docx DELETED
Binary file (37 kB)
 
5_day_meal_plan.xlsx DELETED
Binary file (5.4 kB)
 
ANTHROPIC_CONTEXT_ENGINEERING.md DELETED
@@ -1,201 +0,0 @@
1
- # Anthropic Context Engineering Implementation
2
-
3
- ## Overview
4
- Enhanced context engineering implementation based on Anthropic's best practices and research.
5
-
6
- ## Key Principles from Anthropic
7
-
8
- ### 1. Context as Finite Resource
9
- - **Context Rot**: As tokens increase, model's ability to recall information decreases
10
- - **Attention Budget**: LLMs have finite attention, every token depletes it
11
- - **Diminishing Returns**: More context doesn't always mean better performance
12
-
13
- ### 2. Minimal High-Signal Tokens
14
- - Find the smallest possible set of high-signal tokens
15
- - Maximize likelihood of desired outcome
16
- - Balance between too much and too little context
17
-
18
- ## Implemented Strategies
19
-
20
- ### 1. Structured Prompt Organization ✅
21
- **Anthropic's Recommendation**: Use clear sections with XML tags or Markdown headers
22
-
23
- **Implementation**:
24
- - All prompts now use XML-style tags: `<system>`, `<background_information>`, `<instructions>`, etc.
25
- - Clear separation of concerns
26
- - Better model understanding of context structure
27
-
28
- **Example Structure**:
29
- ```
30
- <system>
31
- System instructions
32
- </system>
33
-
34
- <background_information>
35
- Context and rules
36
- </background_information>
37
-
38
- <knowledge_base_documents>
39
- RAG results
40
- </knowledge_base_documents>
41
-
42
- <instructions>
43
- Task instructions
44
- </instructions>
45
- ```
46
-
47
- ### 2. Compaction (High-Fidelity Summarization) ✅
48
- **Anthropic's Strategy**: Summarize conversations nearing context limit while preserving critical details
49
-
50
- **Implementation**:
51
- - `compact_conversation()`: Preserves architectural decisions, unresolved issues, implementation details
52
- - Discards redundant tool outputs
53
- - Keeps first message + summary + last N messages
54
- - High-fidelity compression maintaining coherence
55
-
56
- **Key Features**:
57
- - Preserves: Architectural decisions, unresolved bugs, implementation details, key facts
58
- - Discards: Redundant tool outputs, repetitive information, verbose explanations
59
-
60
- ### 3. Tool Result Clearing ✅
61
- **Anthropic's Safest Compaction**: Clear tool results once processed
62
-
63
- **Implementation**:
64
- - `clear_tool_results()`: Removes large tool outputs while keeping metadata
65
- - Once a tool is called deep in history, raw results often no longer needed
66
- - Safest form of compaction with minimal information loss
67
-
68
- **Usage**:
69
- - Automatically applied before full compaction
70
- - Reduces tokens without losing critical context
71
- - Preserves tool call metadata for debugging
72
-
73
- ### 4. Structured Note-Taking ✅
74
- **Anthropic's Memory Strategy**: Write notes outside context window, pull back when needed
75
-
76
- **Enhanced Implementation**:
77
- - **Objectives Tracking**: Like Claude playing Pokémon - tracks progress toward goals
78
- - **Architectural Decisions**: Preserved during compaction
79
- - **Unresolved Issues**: Tracked separately for later resolution
80
- - **Structured Summary**: Organized sections (Plan, Objectives, Decisions, Issues, Facts, Notes)
81
-
82
- **Example**:
83
- ```
84
- ## Plan
85
- Multi-step plan: ...
86
-
87
- ## Objectives
88
- - Objective 1: Progress (target: ...)
89
- - Objective 2: Progress (target: ...)
90
-
91
- ## Architectural Decisions
92
- - Decision 1
93
- - Decision 2
94
-
95
- ## Unresolved Issues
96
- - Issue 1
97
- - Issue 2
98
- ```
99
-
100
- ### 5. Just-in-Time Context Loading ✅
101
- **Anthropic's Approach**: Use lightweight identifiers, load data at runtime
102
-
103
- **Implementation**:
104
- - Memory selection: Only relevant memories loaded
105
- - Tool selection: Only relevant tools provided
106
- - Progressive disclosure: Context discovered incrementally
107
-
108
- ### 6. Context Compression Thresholds ✅
109
- **Anthropic's Guidance**: Compress at 80% of context window
110
-
111
- **Implementation**:
112
- - Monitors token usage
113
- - Triggers compression at 80% threshold
114
- - Targets 60% after compression
115
- - Uses tool result clearing first (safest), then full compaction
116
-
117
- ## Prompt Engineering Improvements
118
-
119
- ### System Prompt Structure
120
- - **Right Altitude**: Balance between too specific (brittle) and too vague (ineffective)
121
- - **Clear Sections**: XML tags for better organization
122
- - **Minimal but Complete**: Enough information without bloat
123
-
124
- ### Tool Design
125
- - **Token Efficient**: Tools return concise, relevant information
126
- - **Minimal Overlap**: Clear tool boundaries
127
- - **Self-Contained**: Each tool is independent and robust
128
-
129
- ### Examples (Few-Shot)
130
- - **Diverse, Canonical**: Not laundry lists of edge cases
131
- - **Effective Portrayal**: Examples that show expected behavior
132
- - **Quality over Quantity**: Few good examples better than many mediocre ones
133
-
134
- ## Integration Points
135
-
136
- ### In `agent_orchestrator.py`:
137
-
138
- 1. **Conversation History Compression**:
139
- - Checks token usage at 80% threshold
140
- - Uses tool result clearing first
141
- - Falls back to full compaction if needed
142
-
143
- 2. **Structured Note-Taking**:
144
- - Saves plans, objectives, decisions, issues
145
- - Pulls notes into prompts when relevant
146
- - Preserves across compaction cycles
147
-
148
- 3. **Prompt Structure**:
149
- - All prompts use XML-style sections
150
- - Clear organization improves model understanding
151
- - Better separation of concerns
152
-
153
- 4. **Tool Output Compression**:
154
- - Automatically compresses RAG/web outputs
155
- - Limits results to top 5
156
- - Truncates long text fields
157
-
158
- ## Benefits
159
-
160
- 1. **Better Performance**: Structured prompts improve model understanding
161
- 2. **Reduced Token Usage**: Compression and clearing reduce costs
162
- 3. **Longer Conversations**: Compaction enables extended agent trajectories
163
- 4. **Better Coherence**: Structured notes maintain context across resets
164
- 5. **Cost Efficiency**: Fewer tokens = lower API costs
165
-
166
- ## Comparison: Before vs After
167
-
168
- ### Before:
169
- - Flat prompt structure
170
- - No conversation compression
171
- - All tool outputs kept in context
172
- - No structured note-taking
173
-
174
- ### After:
175
- - XML-structured prompts
176
- - Automatic compaction at 80% threshold
177
- - Tool result clearing (safest compaction)
178
- - Structured note-taking with objectives, decisions, issues
179
- - Better context selection
180
-
181
- ## Files Modified
182
-
183
- - `backend/api/services/context_engineer.py` - Enhanced with Anthropic strategies
184
- - `backend/api/services/agent_orchestrator.py` - Integrated structured prompts and compaction
185
-
186
- ## Testing Recommendations
187
-
188
- 1. **Long Conversations**: Test with 20+ message exchanges
189
- 2. **Compaction**: Verify compaction preserves critical information
190
- 3. **Tool Clearing**: Ensure tool results are cleared appropriately
191
- 4. **Note-Taking**: Verify notes persist across compaction cycles
192
- 5. **Structured Prompts**: Test that XML structure improves responses
193
-
194
- ## Future Enhancements
195
-
196
- 1. **Fine-tuned Compaction**: Train models specifically for context compression
197
- 2. **Hierarchical Summarization**: Multi-level compression for very long conversations
198
- 3. **Embedding-based Selection**: Better memory/tool selection using embeddings
199
- 4. **Sub-agent Architectures**: Specialized agents with clean context windows
200
- 5. **Adaptive Thresholds**: Dynamic compression thresholds based on task complexity
201
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
CONTEXT_ENGINEERING_IMPLEMENTATION.md DELETED
@@ -1,128 +0,0 @@
1
- # Context Engineering Implementation
2
-
3
- ## Overview
4
- Implemented comprehensive context engineering strategies based on LangChain's best practices to optimize agent performance and reduce token usage.
5
-
6
- ## Four Main Strategies
7
-
8
- ### 1. Write Context ✅
9
- **Purpose**: Save context outside the context window for later use.
10
-
11
- **Implementation**:
12
- - **Scratchpad**: `ContextScratchpad` class saves notes, plans, and key facts during agent execution
13
- - **Plan Saving**: Agent plans are saved to scratchpad for persistence
14
- - **Key Facts**: Important information extracted from responses is saved
15
- - **Notes**: Categorized notes (user_query, intent, tool_execution, etc.)
16
-
17
- **Usage in Agent**:
18
- - Saves user queries to scratchpad
19
- - Saves intent classifications
20
- - Saves agent plans from multi-step decisions
21
- - Saves key facts from LLM responses
22
-
23
- ### 2. Select Context ✅
24
- **Purpose**: Pull only relevant context into the context window.
25
-
26
- **Implementation**:
27
- - **Memory Selection**: `ContextSelector.select_relevant_memories()` selects top N relevant memories
28
- - **Tool Selection**: `ContextSelector.select_relevant_tools()` selects most relevant tools
29
- - **Keyword-based**: Uses keyword matching (can be enhanced with embeddings)
30
-
31
- **Usage in Agent**:
32
- - Selects relevant memories before tool selection
33
- - Filters conversation history to most relevant parts
34
- - Can be extended for better RAG retrieval
35
-
36
- ### 3. Compress Context ✅
37
- **Purpose**: Retain only necessary tokens.
38
-
39
- **Implementation**:
40
- - **Conversation Summarization**: `ContextCompressor.summarize_conversation()` summarizes long conversations
41
- - **Message Trimming**: `ContextCompressor.trim_messages()` keeps first N and last M messages
42
- - **Tool Output Compression**: `ContextCompressor.compress_tool_output()` reduces tool output size
43
- - Limits RAG results to top 5
44
- - Limits web search results to top 5
45
- - Truncates long text fields
46
-
47
- **Usage in Agent**:
48
- - Compresses conversation history if > 10 messages
49
- - Compresses RAG tool outputs automatically
50
- - Compresses web search tool outputs automatically
51
- - Summarizes middle sections of long conversations
52
-
53
- ### 4. Isolate Context ✅
54
- **Purpose**: Split context to prevent token bloat.
55
-
56
- **Implementation**:
57
- - **ContextIsolator**: Stores large tool outputs separately
58
- - **Reference System**: Returns references instead of full data
59
- - **Automatic Cleanup**: Clears old isolated data after timeout
60
-
61
- **Usage in Agent**:
62
- - Can isolate large tool outputs (images, audio, large JSON)
63
- - Prevents context window overflow
64
- - Maintains references for later retrieval
65
-
66
- ## Integration Points
67
-
68
- ### In `agent_orchestrator.py`:
69
-
70
- 1. **Request Start**:
71
- - Writes user query to scratchpad
72
- - Compresses conversation history if needed
73
-
74
- 2. **Intent Classification**:
75
- - Saves intent to scratchpad
76
-
77
- 3. **Memory Retrieval**:
78
- - Selects relevant memories using context selector
79
-
80
- 4. **Tool Selection**:
81
- - Saves multi-step plans to scratchpad
82
-
83
- 5. **Tool Execution**:
84
- - Compresses RAG outputs
85
- - Compresses web search outputs
86
- - Saves key facts from responses
87
-
88
- 6. **Prompt Building**:
89
- - Includes scratchpad context in prompts
90
- - Adds context from previous steps
91
-
92
- ## Benefits
93
-
94
- 1. **Reduced Token Usage**: Compression and selection reduce context window usage
95
- 2. **Better Performance**: Relevant context improves agent accuracy
96
- 3. **Longer Conversations**: Summarization enables longer agent trajectories
97
- 4. **Cost Savings**: Fewer tokens = lower costs
98
- 5. **Faster Responses**: Smaller context = faster LLM calls
99
-
100
- ## Future Enhancements
101
-
102
- 1. **Embedding-based Selection**: Use embeddings for better memory/tool selection
103
- 2. **Hierarchical Summarization**: Multi-level summarization for very long conversations
104
- 3. **Fine-tuned Compression**: Train models specifically for context compression
105
- 4. **Knowledge Graph Integration**: Use knowledge graphs for better context selection
106
- 5. **Adaptive Compression**: Adjust compression based on context window usage
107
-
108
- ## Files Created
109
-
110
- - `backend/api/services/context_engineer.py` - Main context engineering service
111
- - `ContextScratchpad` - Write context
112
- - `ContextCompressor` - Compress context
113
- - `ContextSelector` - Select context
114
- - `ContextIsolator` - Isolate context
115
- - `ContextEngineer` - Main orchestrator
116
-
117
- ## Files Modified
118
-
119
- - `backend/api/services/agent_orchestrator.py` - Integrated context engineering throughout
120
-
121
- ## Testing
122
-
123
- Test with:
124
- - Long conversations (> 10 messages)
125
- - Multiple tool calls
126
- - Large tool outputs
127
- - Memory retrieval scenarios
128
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
KB_FIRST_IMPLEMENTATION.md DELETED
@@ -1,81 +0,0 @@
1
- # KB-First Strategy Implementation
2
-
3
- ## Overview
4
- The system now implements a **Knowledge Base (KB) first, web search as fallback** strategy with enhanced safety rules.
5
-
6
- ## Key Behavior
7
-
8
- ### 1. KB-First Approach
9
- - **Always check Knowledge Base first** - RAG search is performed before any other tool
10
- - **Web search is ONLY a fallback** - Used when KB has no relevant information
11
- - **KB is authoritative** - Knowledge Base information takes priority over web search
12
-
13
- ### 2. Safety Rules for Web Search
14
-
15
- When web search is used as a fallback:
16
- - ✅ Keep responses **short, factual, and neutral**
17
- - ✅ **Limit to 2-4 sentences** for web search content
18
- - ❌ Do NOT provide long legal, medical, or highly detailed professional explanations
19
- - ⚠️ For legal, medical, financial, or safety topics: provide brief general explanation + recommend consulting a qualified professional
20
- - 📝 Always clarify that information comes from external sources, not the Knowledge Base
21
-
22
- ### 3. Professional Disclaimers
23
-
24
- For topics involving:
25
- - Legal advice
26
- - Medical advice
27
- - Financial advice
28
- - Safety-critical information
29
-
30
- **Response format:**
31
- > "Brief general explanation. For specific advice, please consult a qualified professional."
32
-
33
- ## Implementation Details
34
-
35
- ### Prompt Updates
36
-
37
- 1. **RAG Prompt (when KB has results)**
38
- - Emphasizes KB as primary and authoritative source
39
- - Clarifies that web search is supplementary only
40
-
41
- 2. **RAG Prompt (when KB has no results)**
42
- - Includes rules for web search fallback
43
- - Adds safety disclaimers for professional advice topics
44
-
45
- 3. **Web Search Prompt**
46
- - Explicitly states KB was checked first
47
- - Includes all safety rules and disclaimers
48
- - Enforces 2-4 sentence limit
49
-
50
- 4. **Multi-Step Synthesis Prompt**
51
- - Prioritizes KB information over web search
52
- - Distinguishes between authoritative (KB) and supplementary (web) sources
53
-
54
- ### Example Test Query
55
-
56
- **Query:** "What are the international laws regarding subletting?"
57
-
58
- **Expected Flow:**
59
- 1. ✅ Check Knowledge Base first
60
- 2. ✅ No relevant KB information found
61
- 3. ✅ Trigger web search as fallback
62
- 4. ✅ Generate short, safe answer
63
-
64
- **Expected Response:**
65
- > "I don't have this in the knowledge base, but based on general information from the web, subletting laws differ widely by country. For specific legal advice, please consult a local authority or legal professional."
66
-
67
- ## Safety Features
68
-
69
- - ✅ Professional advice disclaimers
70
- - ✅ Source distinction (KB vs web)
71
- - ✅ Response length limits for web content
72
- - ✅ Clear messaging about fallback behavior
73
-
74
- ## Configuration
75
-
76
- All rules are built into the prompt templates in:
77
- - `backend/api/services/agent_orchestrator.py`
78
- - `_build_prompt_with_rag()`
79
- - `_build_prompt_with_web()`
80
- - `_execute_multi_step()` (multi-step synthesis)
81
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
TESTING_GUIDE.md DELETED
@@ -1,308 +0,0 @@
1
- # Testing Guide for IntegraChat Improvements
2
-
3
- This guide helps you test all the improvements we've made to the system.
4
-
5
- ## Prerequisites
6
-
7
- 1. Make sure all services are running:
8
- - Backend API server
9
- - MCP servers (RAG, Web, Admin)
10
- - Ollama (if using local LLM)
11
-
12
- 2. Check environment variables in `.env`:
13
- ```
14
- OLLAMA_URL=http://localhost:11434
15
- OLLAMA_MODEL=llama3.1:latest
16
- RAG_MCP_URL=http://localhost:8001
17
- WEB_MCP_URL=http://localhost:8002
18
- ADMIN_MCP_URL=http://localhost:8003
19
- ```
20
-
21
- ## Quick Test Script
22
-
23
- Run the test script:
24
- ```bash
25
- python test_improvements.py
26
- ```
27
-
28
- ## Manual Testing
29
-
30
- ### 1. Test Streaming Response (Character-by-Character)
31
-
32
- **Test Query:**
33
- ```
34
- "Tell me about artificial intelligence"
35
- ```
36
-
37
- **What to Check:**
38
- - Response streams character-by-character (not word-by-word)
39
- - Smooth animation in the UI
40
- - No delays or jumps
41
-
42
- **Expected Behavior:**
43
- - Characters appear one by one smoothly
44
- - Response completes without errors
45
-
46
- ---
47
-
48
- ### 2. Test Query Expansion for Ambiguous Terms
49
-
50
- **Test Queries:**
51
- ```
52
- "latest news about Al"
53
- "atest news about Al" (typo test)
54
- "What is AI?"
55
- "Tell me about ML"
56
- ```
57
-
58
- **What to Check:**
59
- - System expands "Al" to "artificial intelligence"
60
- - System expands "AI" appropriately
61
- - System expands "ML" to "machine learning"
62
- - News queries still work with typos
63
-
64
- **Expected Behavior:**
65
- - Ambiguous terms are expanded
66
- - Better search results
67
- - No "provided context" errors for news queries
68
-
69
- ---
70
-
71
- ### 3. Test Enhanced Error Handling
72
-
73
- **Test Scenarios:**
74
-
75
- **A. Connection Error:**
76
- - Stop Ollama service
77
- - Send any query
78
- - Check error message is user-friendly
79
-
80
- **B. Timeout:**
81
- - Send a very complex query that might timeout
82
- - Check error message explains timeout
83
-
84
- **C. 404 Error:**
85
- - Query something that doesn't exist
86
- - Check error message is helpful
87
-
88
- **Expected Behavior:**
89
- - Clear, actionable error messages
90
- - No technical jargon for users
91
- - Suggestions on what to do next
92
-
93
- ---
94
-
95
- ### 4. Test Multi-Query Web Search
96
-
97
- **Test Query:**
98
- ```
99
- "latest news about artificial intelligence"
100
- ```
101
-
102
- **What to Check:**
103
- - Multiple query variations are tried in parallel
104
- - Results are merged from multiple queries
105
- - Better coverage of results
106
-
107
- **How to Verify:**
108
- - Check backend logs for "web_multi_query_merge"
109
- - Look for multiple web search calls
110
- - Results should be more comprehensive
111
-
112
- ---
113
-
114
- ### 5. Test Caching
115
-
116
- **Test Query:**
117
- ```
118
- "What is Python programming?"
119
- ```
120
-
121
- **Steps:**
122
- 1. Send query first time - note response time
123
- 2. Send same query immediately - should be faster (cached)
124
- 3. Wait 6 minutes - cache should expire
125
- 4. Send again - should be slower (cache expired)
126
-
127
- **Expected Behavior:**
128
- - Second query is much faster
129
- - Cache expires after 5 minutes
130
- - Different queries don't interfere
131
-
132
- ---
133
-
134
- ### 6. Test Enhanced News Query Detection
135
-
136
- **Test Queries:**
137
- ```
138
- "latest news about AI"
139
- "breaking news technology"
140
- "what happened today"
141
- "current events in tech"
142
- ```
143
-
144
- **What to Check:**
145
- - News queries use web search (not RAG)
146
- - No "provided context" errors
147
- - LLM-based detection works for edge cases
148
-
149
- **Expected Behavior:**
150
- - All news queries route to web search
151
- - No RAG results for news queries
152
- - Helpful responses even if web search fails
153
-
154
- ---
155
-
156
- ### 7. Test Enhanced Prompts
157
-
158
- **Test Query:**
159
- ```
160
- "Explain quantum computing"
161
- ```
162
-
163
- **What to Check:**
164
- - Response is well-structured
165
- - Sources are cited
166
- - Response is comprehensive
167
-
168
- **Expected Behavior:**
169
- - Clear sections in response
170
- - Citations when using sources
171
- - Professional and helpful tone
172
-
173
- ---
174
-
175
- ### 8. Test Performance (Parallel Execution)
176
-
177
- **Test Query:**
178
- ```
179
- "Compare Python and JavaScript"
180
- ```
181
-
182
- **What to Check:**
183
- - Multiple tools run in parallel
184
- - Faster overall response time
185
- - Better results from parallel execution
186
-
187
- **How to Verify:**
188
- - Check logs for "parallel_execution"
189
- - Response time should be faster
190
- - Multiple tools used simultaneously
191
-
192
- ---
193
-
194
- ## Using the Debug Endpoint
195
-
196
- Test the `/agent/debug` endpoint to see detailed reasoning:
197
-
198
- ```bash
199
- curl -X POST http://localhost:8000/agent/debug \
200
- -H "Content-Type: application/json" \
201
- -d '{
202
- "tenant_id": "test-tenant",
203
- "message": "latest news about AI"
204
- }'
205
- ```
206
-
207
- This shows:
208
- - Intent classification
209
- - Tool selection reasoning
210
- - Tool scores
211
- - Reasoning trace
212
- - Tool traces
213
-
214
- ---
215
-
216
- ## Testing with Python Script
217
-
218
- Create a test script to automate testing:
219
-
220
- ```python
221
- import requests
222
- import json
223
- import time
224
-
225
- BASE_URL = "http://localhost:8000"
226
-
227
- def test_query(message, tenant_id="test-tenant"):
228
- """Test a query and return response."""
229
- response = requests.post(
230
- f"{BASE_URL}/agent/message",
231
- json={
232
- "tenant_id": tenant_id,
233
- "message": message,
234
- "temperature": 0.0
235
- }
236
- )
237
- return response.json()
238
-
239
- # Test cases
240
- test_cases = [
241
- ("latest news about AI", "News query"),
242
- ("What is Python?", "General query"),
243
- ("Who is the admin?", "Admin query"),
244
- ("atest news about Al", "Typo + ambiguous"),
245
- ]
246
-
247
- for query, description in test_cases:
248
- print(f"\n{'='*50}")
249
- print(f"Testing: {description}")
250
- print(f"Query: {query}")
251
- print(f"{'='*50}")
252
-
253
- start = time.time()
254
- result = test_query(query)
255
- elapsed = time.time() - start
256
-
257
- print(f"Response time: {elapsed:.2f}s")
258
- print(f"Response: {result['text'][:200]}...")
259
- print(f"Tools used: {result.get('decision', {}).get('tool', 'unknown')}")
260
- ```
261
-
262
- ---
263
-
264
- ## Common Issues and Solutions
265
-
266
- ### Issue: "Cannot connect to Ollama"
267
- **Solution:**
268
- - Start Ollama: `ollama serve`
269
- - Pull model: `ollama pull llama3.1:latest`
270
-
271
- ### Issue: Cache not working
272
- **Solution:**
273
- - Check cache is enabled (it is by default)
274
- - Verify query is exactly the same
275
- - Check cache hasn't expired (5 min TTL)
276
-
277
- ### Issue: News queries still using RAG
278
- **Solution:**
279
- - Check logs for "news_query_detection"
280
- - Verify "news" keyword is in query
281
- - Check tool selection decision
282
-
283
- ### Issue: Streaming not smooth
284
- **Solution:**
285
- - Check character-by-character streaming is enabled
286
- - Verify no network issues
287
- - Check browser console for errors
288
-
289
- ---
290
-
291
- ## Performance Benchmarks
292
-
293
- Expected performance improvements:
294
-
295
- - **Caching**: 90%+ faster for repeated queries
296
- - **Parallel execution**: 30-50% faster for multi-tool queries
297
- - **Multi-query search**: 2-3x more results
298
- - **Streaming**: Smoother UX (subjective)
299
-
300
- ---
301
-
302
- ## Next Steps
303
-
304
- 1. Run all test cases
305
- 2. Check logs for any errors
306
- 3. Verify all features work as expected
307
- 4. Report any issues found
308
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/api/placeholder.txt DELETED
@@ -1,4 +0,0 @@
1
- This directory contains the FastAPI backend API code.
2
- For the Hugging Face Space submission, only placeholder files are included.
3
- The full backend implementation exists separately.
4
-
 
 
 
 
 
backend/tests/README_RETRY_TESTS.md DELETED
@@ -1,266 +0,0 @@
1
- # Retry System Testing Guide
2
-
3
- This guide explains how to test the autonomous retry and self-correction system.
4
-
5
- ## Test Files
6
-
7
- ### 1. Unit Tests: `test_retry_system.py`
8
-
9
- Comprehensive unit tests that mock all dependencies and test individual retry methods.
10
-
11
- **Run with:**
12
- ```bash
13
- # Run all retry tests
14
- pytest backend/tests/test_retry_system.py -v
15
-
16
- # Run specific test
17
- pytest backend/tests/test_retry_system.py::test_rag_with_repair_low_score_retry -v
18
-
19
- # Run with coverage
20
- pytest backend/tests/test_retry_system.py --cov=api.services.agent_orchestrator -v
21
- ```
22
-
23
- **What it tests:**
24
- - ✅ RAG retry with low scores (threshold adjustment)
25
- - ✅ RAG retry with query expansion
26
- - ✅ Web search retry with empty results
27
- - ✅ Safe tool call retry mechanism
28
- - ✅ Rule safe message rewriting
29
- - ✅ Analytics logging verification
30
- - ✅ Reasoning trace integration
31
- - ✅ Edge cases and boundary conditions
32
-
33
- **No backend required** - all tests use mocks.
34
-
35
- ### 2. Integration Tests: `test_retry_integration.py`
36
-
37
- Integration tests that require a running backend and test the full system.
38
-
39
- **Prerequisites:**
40
- - FastAPI backend running on `http://localhost:8000`
41
- - MCP server running
42
- - Optional: LLM service available
43
-
44
- **Run with:**
45
- ```bash
46
- python test_retry_integration.py
47
- ```
48
-
49
- **What it tests:**
50
- - ✅ RAG retry scenarios with real backend
51
- - ✅ Web search retry scenarios
52
- - ✅ Reasoning trace verification
53
- - ✅ Analytics logging
54
- - ✅ Full agent flow integration
55
- - ✅ Agent plan endpoint
56
-
57
- ### 3. Quick Test: `test_retry_quick.py`
58
-
59
- Minimal test to quickly verify retry system is active.
60
-
61
- **Prerequisites:**
62
- - Backend running on `http://localhost:8000`
63
-
64
- **Run with:**
65
- ```bash
66
- python test_retry_quick.py
67
- ```
68
-
69
- **What it tests:**
70
- - ✅ Basic connectivity
71
- - ✅ Retry steps in reasoning traces
72
- - ✅ Quick verification retry system is active
73
-
74
- ## Test Scenarios
75
-
76
- ### Scenario 1: RAG Low Score Retry
77
-
78
- **What happens:**
79
- 1. Initial RAG search returns score < 0.30
80
- 2. System retries with lower threshold (0.15)
81
- 3. If still low (< 0.15), expands query and retries
82
-
83
- **How to test:**
84
- ```bash
85
- # Send query that might have low relevance
86
- curl -X POST "http://localhost:8000/agent/debug" \
87
- -H "Content-Type: application/json" \
88
- -d '{
89
- "tenant_id": "test",
90
- "message": "What is quantum field theory and how does it relate to string theory?"
91
- }' | jq '.reasoning_trace[] | select(.step | contains("retry"))'
92
- ```
93
-
94
- **Expected:**
95
- - `rag_retry_low_threshold` step in reasoning trace
96
- - Possibly `rag_retry_expanded_query` if score still low
97
- - Analytics logs showing retry attempts
98
-
99
- ### Scenario 2: Web Search Empty Results Retry
100
-
101
- **What happens:**
102
- 1. Web search returns empty results
103
- 2. System rewrites query as "best explanation of {query}"
104
- 3. If still empty, rewrites as "{query} facts summary"
105
-
106
- **How to test:**
107
- ```bash
108
- # Send obscure query
109
- curl -X POST "http://localhost:8000/agent/debug" \
110
- -H "Content-Type: application/json" \
111
- -d '{
112
- "tenant_id": "test",
113
- "message": "Explain zyxwvutsrqp in detail"
114
- }' | jq '.reasoning_trace[] | select(.step | contains("web_retry"))'
115
- ```
116
-
117
- **Expected:**
118
- - `web_retry_rewritten` steps in reasoning trace
119
- - Rewritten queries visible in trace
120
- - Analytics logs showing retry attempts
121
-
122
- ### Scenario 3: Safe Tool Call Retry
123
-
124
- **What happens:**
125
- 1. Tool call fails
126
- 2. System retries up to max_retries times
127
- 3. Uses fallback params if provided
128
-
129
- **How to test:**
130
- - This is tested automatically in unit tests
131
- - In production, retries happen transparently
132
-
133
- ## Verifying Retry Behavior
134
-
135
- ### Method 1: Check Reasoning Trace
136
-
137
- The `/agent/debug` endpoint shows all reasoning steps including retries:
138
-
139
- ```bash
140
- curl -X POST "http://localhost:8000/agent/debug" \
141
- -H "Content-Type: application/json" \
142
- -d '{"tenant_id": "test", "message": "test query"}' \
143
- | jq '.reasoning_trace[] | select(.step | test("retry|repair"))'
144
- ```
145
-
146
- ### Method 2: Check Analytics
147
-
148
- Retry attempts are logged to analytics:
149
-
150
- ```bash
151
- curl -X GET "http://localhost:8000/analytics/tool-usage?days=1" \
152
- -H "x-tenant-id: test" \
153
- | jq '.logs[] | select(.tool_name | contains("retry"))'
154
- ```
155
-
156
- ### Method 3: Check Tool Traces
157
-
158
- Tool traces in agent responses show retry attempts:
159
-
160
- ```bash
161
- curl -X POST "http://localhost:8000/agent/message" \
162
- -H "Content-Type: application/json" \
163
- -d '{"tenant_id": "test", "message": "test"}' \
164
- | jq '.tool_traces'
165
- ```
166
-
167
- ## Expected Retry Patterns
168
-
169
- ### RAG Retries
170
-
171
- - **Low score (< 0.30)**: Retry with threshold 0.15
172
- - **Very low score (< 0.15)**: Expand query and retry
173
- - **Reasoning trace steps**:
174
- - `rag_retry_low_threshold`
175
- - `rag_retry_expanded_query`
176
- - `rag_expanded_query_result`
177
-
178
- ### Web Retries
179
-
180
- - **Empty results**: Rewrite query and retry
181
- - **Reasoning trace steps**:
182
- - `web_retry_rewritten`
183
- - `web_retry_success`
184
-
185
- ### Tool Call Retries
186
-
187
- - **Tool failure**: Retry up to max_retries
188
- - **Reasoning trace steps**:
189
- - `retry_attempt`
190
- - `retry_success` or `error` after all retries
191
-
192
- ## Troubleshooting
193
-
194
- ### Tests Not Showing Retries
195
-
196
- **Possible reasons:**
197
- 1. **Scores are already high** - Retries only happen when needed
198
- 2. **First attempt succeeded** - System working optimally
199
- 3. **Query doesn't trigger retry** - Try more obscure queries
200
-
201
- **Solution:** This is actually good! Retries only happen when needed.
202
-
203
- ### Backend Not Running
204
-
205
- ```bash
206
- # Start backend
207
- cd backend/api
208
- uvicorn main:app --port 8000 --reload
209
-
210
- # Or use start script
211
- python start.bat
212
- ```
213
-
214
- ### Import Errors
215
-
216
- ```bash
217
- # Install dependencies
218
- pip install -r requirements.txt
219
-
220
- # Run from project root
221
- cd /path/to/IntegraChat
222
- pytest backend/tests/test_retry_system.py
223
- ```
224
-
225
- ## Test Coverage
226
-
227
- The test suite covers:
228
-
229
- - ✅ RAG retry logic (threshold + query expansion)
230
- - ✅ Web retry logic (query rewriting)
231
- - ✅ Safe tool call retries
232
- - ✅ Rule safe message rewriting
233
- - ✅ Analytics logging
234
- - ✅ Reasoning trace integration
235
- - ✅ Edge cases and boundaries
236
- - ✅ Integration with full agent flow
237
-
238
- ## Continuous Testing
239
-
240
- To run tests automatically:
241
-
242
- ```bash
243
- # Watch mode (runs on file changes)
244
- pytest-watch backend/tests/test_retry_system.py
245
-
246
- # With coverage
247
- pytest backend/tests/test_retry_system.py --cov --cov-report=html
248
-
249
- # All tests
250
- pytest backend/tests/ -v -k retry
251
- ```
252
-
253
- ## Next Steps
254
-
255
- 1. ✅ Run unit tests: `pytest backend/tests/test_retry_system.py -v`
256
- 2. ✅ Start backend and run integration tests: `python test_retry_integration.py`
257
- 3. ✅ Quick verification: `python test_retry_quick.py`
258
- 4. ✅ Check reasoning traces for retry steps
259
- 5. ✅ Monitor analytics for retry attempts
260
-
261
- For more information, see `TESTING_GUIDE.md` in the project root.
262
-
263
-
264
-
265
-
266
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/conftest.py DELETED
@@ -1 +0,0 @@
1
-
 
 
backend/tests/test_access_control.py DELETED
@@ -1,55 +0,0 @@
1
- import sys
2
- from pathlib import Path
3
- import pytest
4
-
5
- # Ensure backend package is importable
6
- backend_dir = Path(__file__).parent.parent
7
- sys.path.insert(0, str(backend_dir))
8
-
9
- from mcp_server.common import access_control
10
- from mcp_server.common.utils import execute_tool
11
-
12
-
13
- @pytest.mark.asyncio
14
- async def test_execute_tool_denies_without_permission():
15
- async def handler(context, payload):
16
- return {"ok": True}
17
-
18
- payload = {
19
- "tenant_id": "tenant123",
20
- "session_id": "s1",
21
- "role": "viewer",
22
- }
23
-
24
- result = await execute_tool("rag.ingest", payload, handler)
25
- assert result["status"] == "error"
26
- assert result["error_type"] == "validation_error"
27
- assert "not permitted" in result["message"]
28
-
29
-
30
- @pytest.mark.asyncio
31
- async def test_execute_tool_allows_authorized_role():
32
- async def handler(context, payload):
33
- return {"ok": True}
34
-
35
- payload = {
36
- "tenant_id": "tenant123",
37
- "session_id": "s1",
38
- "role": "admin",
39
- }
40
-
41
- result = await execute_tool("rag.ingest", payload, handler)
42
- assert result["status"] == "ok"
43
- assert result["data"]["ok"] is True
44
-
45
-
46
- def test_normalize_role_defaults_to_viewer():
47
- assert access_control.normalize_role(None) == "viewer"
48
- assert access_control.normalize_role("ADMIN") == "admin"
49
- assert access_control.normalize_role("unknown") == "viewer"
50
-
51
-
52
- def test_role_allows_matrix():
53
- assert access_control.role_allows("owner", "manage_rules")
54
- assert not access_control.role_allows("viewer", "manage_rules")
55
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_agent_orchestrator.py DELETED
@@ -1,230 +0,0 @@
1
- # =============================================================
2
- # File: tests/test_agent_orchestrator.py
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
- try:
13
- import pytest
14
- HAS_PYTEST = True
15
- except ImportError:
16
- HAS_PYTEST = False
17
- # Create a mock pytest decorator if pytest is not available
18
- class MockMark:
19
- def asyncio(self, func):
20
- return func
21
- class MockPytest:
22
- mark = MockMark()
23
- def fixture(self, func):
24
- return func
25
- pytest = MockPytest()
26
-
27
- import os
28
- from api.services.agent_orchestrator import AgentOrchestrator
29
- from api.models.agent import AgentRequest, AgentDecision, AgentResponse
30
- from api.models.redflag import RedFlagMatch
31
- from api.services.llm_client import LLMClient
32
-
33
-
34
- # ---------------------------
35
- # Mock classes
36
- # ---------------------------
37
-
38
- class FakeLLM(LLMClient):
39
- def __init__(self, output="LLM_RESPONSE"):
40
- self.output = output
41
-
42
- async def simple_call(self, prompt: str, temperature: float = 0.0):
43
- return self.output
44
-
45
-
46
- class FakeMCP:
47
- """Fake MCP server client used for rag/web/admin calls."""
48
- def __init__(self):
49
- self.last_rag = None
50
- self.last_web = None
51
- self.last_admin = None
52
-
53
- async def call_rag(self, tenant_id: str, query: str):
54
- self.last_rag = query
55
- return {"results": [{"text": "RAG_DOC_CONTENT"}]}
56
-
57
- async def call_web(self, tenant_id: str, query: str):
58
- self.last_web = query
59
- return {"results": [{"title": "WebResult", "snippet": "Fresh info"}]}
60
-
61
- async def call_admin(self, tenant_id: str, query: str):
62
- self.last_admin = query
63
- return {"action": "allow"}
64
-
65
-
66
- def assert_trace_has_step(resp, step_name):
67
- assert resp.reasoning_trace, "reasoning trace missing"
68
- assert any(entry.get("step") == step_name for entry in resp.reasoning_trace), f"{step_name} missing"
69
-
70
-
71
- # ---------------------------
72
- # Patch orchestrator to use fake MCP + fake redflag
73
- # ---------------------------
74
-
75
- @pytest.fixture
76
- def orchestrator(monkeypatch):
77
-
78
- # Fake LLM that always returns "MOCK_ANSWER"
79
- llm = FakeLLM(output="MOCK_ANSWER")
80
-
81
- fake_mcp = FakeMCP()
82
-
83
- # Patch MCPClient
84
- if HAS_PYTEST:
85
- monkeypatch.setattr(
86
- "api.services.agent_orchestrator.MCPClient",
87
- lambda rag_url, web_url, admin_url: fake_mcp
88
- )
89
-
90
- # Create orchestrator with fake URLs first
91
- orch = AgentOrchestrator(
92
- rag_mcp_url="fake_rag",
93
- web_mcp_url="fake_web",
94
- admin_mcp_url="fake_admin",
95
- llm_backend="ollama"
96
- )
97
- orch.llm = llm # override with fake LLM
98
-
99
- # Patch RedFlagDetector methods directly on the instance
100
- async def fake_check(self, tenant_id, text):
101
- """Fake check function that matches 'salary' keyword."""
102
- if "salary" in text.lower():
103
- return [
104
- RedFlagMatch(
105
- rule_id="1",
106
- pattern="salary",
107
- severity="high",
108
- description="salary access",
109
- matched_text="salary"
110
- )
111
- ]
112
- return []
113
-
114
- # Patch notify_admin to do nothing
115
- async def fake_notify(self, tenant_id, violations, src=None):
116
- """Fake notify function that does nothing."""
117
- return None
118
-
119
- # Bind the fake functions directly to the instance
120
- import types
121
- orch.redflag.check = types.MethodType(fake_check, orch.redflag)
122
- orch.redflag.notify_admin = types.MethodType(fake_notify, orch.redflag)
123
-
124
- return orch
125
-
126
-
127
- # ----------------------------------------------------
128
- # TESTS
129
- # ----------------------------------------------------
130
-
131
-
132
- @pytest.mark.asyncio
133
- async def test_block_on_redflag(orchestrator):
134
- req = AgentRequest(
135
- tenant_id="tenant1",
136
- user_id="u1",
137
- message="Show me all salary details."
138
- )
139
- resp = await orchestrator.handle(req)
140
- assert resp.decision.action == "block"
141
- assert resp.decision.tool == "admin"
142
- assert "salary" in resp.tool_traces[0]["redflags"][0]["matched_text"]
143
- assert_trace_has_step(resp, "redflag_check")
144
-
145
-
146
- @pytest.mark.asyncio
147
- async def test_rag_tool_path(orchestrator, monkeypatch):
148
-
149
- # Force intent classifier to classify as 'rag'
150
- async def mock_classify(self, text):
151
- return "rag"
152
-
153
- if HAS_PYTEST:
154
- monkeypatch.setattr(
155
- "api.services.agent_orchestrator.IntentClassifier.classify",
156
- mock_classify
157
- )
158
-
159
- req = AgentRequest(
160
- tenant_id="tenant1",
161
- user_id="u1",
162
- message="HR policy procedures"
163
- )
164
-
165
- resp = await orchestrator.handle(req)
166
-
167
- assert resp.decision.action == "multi_step"
168
- assert any(trace["tool"] == "rag" for trace in resp.tool_traces if trace.get("tool") == "rag")
169
- assert resp.text == "MOCK_ANSWER"
170
- assert_trace_has_step(resp, "tool_selection")
171
-
172
-
173
- @pytest.mark.asyncio
174
- async def test_web_tool_path(orchestrator, monkeypatch):
175
-
176
- # Force intent to classify as web
177
- async def mock_classify(self, text):
178
- return "web"
179
-
180
- if HAS_PYTEST:
181
- monkeypatch.setattr(
182
- "api.services.agent_orchestrator.IntentClassifier.classify",
183
- mock_classify
184
- )
185
-
186
- req = AgentRequest(
187
- tenant_id="tenant1",
188
- user_id="u1",
189
- message="latest stock price"
190
- )
191
-
192
- resp = await orchestrator.handle(req)
193
-
194
- assert resp.decision.action == "multi_step"
195
- assert any(trace["tool"] == "web" for trace in resp.tool_traces if trace.get("tool") == "web")
196
- assert resp.text == "MOCK_ANSWER"
197
- assert_trace_has_step(resp, "tool_selection")
198
-
199
-
200
- @pytest.mark.asyncio
201
- async def test_default_llm_path(orchestrator, monkeypatch):
202
-
203
- # Force intent = general and force tool selector to NOT call any tool
204
- async def mock_select(self, intent, text, context):
205
- from api.models.agent import AgentDecision
206
- return AgentDecision(
207
- action="respond",
208
- tool=None,
209
- tool_input=None,
210
- reason="forced_llm"
211
- )
212
-
213
- if HAS_PYTEST:
214
- monkeypatch.setattr(
215
- "api.services.agent_orchestrator.ToolSelector.select",
216
- mock_select
217
- )
218
-
219
- req = AgentRequest(
220
- tenant_id="tenant1",
221
- user_id="u1",
222
- message="just a normal question"
223
- )
224
-
225
- resp = await orchestrator.handle(req)
226
-
227
- assert resp.decision.action == "respond"
228
- assert resp.decision.tool is None
229
- assert resp.text == "MOCK_ANSWER"
230
- assert_trace_has_step(resp, "intent_detection")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_analytics_store.py DELETED
@@ -1,208 +0,0 @@
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 DELETED
@@ -1,222 +0,0 @@
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", "x-user-role": "owner"},
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", "x-user-role": "owner"},
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", "x-user-role": "owner"},
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", "x-user-role": "owner"},
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
-
203
-
204
- def test_analytics_requires_admin_role(client):
205
- """Ensure analytics endpoints enforce RBAC."""
206
- response = client.get(
207
- "/analytics/overview",
208
- headers={"x-tenant-id": "test_tenant", "x-user-role": "viewer"},
209
- params={"days": 7}
210
- )
211
- assert response.status_code == 403
212
-
213
-
214
- def test_admin_rules_requires_admin_role(client):
215
- """Ensure rule uploads enforce RBAC."""
216
- response = client.post(
217
- "/admin/rules",
218
- headers={"x-tenant-id": "test_tenant", "x-user-role": "viewer"},
219
- json={"rule": "No passwords"}
220
- )
221
- assert response.status_code == 403
222
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_conversation_memory.py DELETED
@@ -1,479 +0,0 @@
1
- # =============================================================
2
- # File: backend/tests/test_conversation_memory.py
3
- # =============================================================
4
- """
5
- Comprehensive tests for short-term conversation memory with expiration.
6
-
7
- Tests:
8
- 1. Memory storage and retrieval
9
- 2. Memory injection into tool payloads
10
- 3. Session isolation (different session_ids don't share memory)
11
- 4. Memory expiration (TTL)
12
- 5. Memory bounded size (only last N items)
13
- 6. Session clearing (end_session flag)
14
- 7. Memory is NOT keyed by tenant_id (same session_id across tenants shares memory)
15
- """
16
-
17
- import sys
18
- from pathlib import Path
19
- import pytest
20
- import time
21
- from unittest.mock import AsyncMock, MagicMock, patch
22
- import asyncio
23
-
24
- # Add backend directory to Python path
25
- backend_dir = Path(__file__).parent.parent
26
- sys.path.insert(0, str(backend_dir))
27
-
28
- from mcp_server.common import memory
29
- from mcp_server.common.utils import execute_tool, ToolHandler
30
- from mcp_server.common.tenant import TenantContext
31
-
32
-
33
- # =============================================================
34
- # FIXTURES
35
- # =============================================================
36
-
37
- @pytest.fixture(autouse=True)
38
- def clear_memory():
39
- """Clear memory before and after each test."""
40
- # Clear all memory before test
41
- memory._MEMORY.clear()
42
- yield
43
- # Clear all memory after test
44
- memory._MEMORY.clear()
45
-
46
-
47
- @pytest.fixture
48
- def mock_tool_handler():
49
- """Create a mock tool handler that captures the payload."""
50
- captured_payloads = []
51
-
52
- async def handler(context: TenantContext, payload: dict) -> dict:
53
- captured_payloads.append(payload)
54
- return {"result": "success", "tool_output": "test_data"}
55
-
56
- handler.captured = captured_payloads
57
- return handler
58
-
59
-
60
- # =============================================================
61
- # UNIT TESTS: Memory Module
62
- # =============================================================
63
-
64
- def test_extract_session_id():
65
- """Test session ID extraction from payload."""
66
- # Test various key formats
67
- assert memory.extract_session_id({"session_id": "s1"}) == "s1"
68
- assert memory.extract_session_id({"sessionId": "s2"}) == "s2"
69
- assert memory.extract_session_id({"conversation_id": "s3"}) == "s3"
70
- assert memory.extract_session_id({"conversationId": "s4"}) == "s4"
71
-
72
- # Test first match wins
73
- assert memory.extract_session_id({
74
- "session_id": "s1",
75
- "sessionId": "s2"
76
- }) == "s1"
77
-
78
- # Test missing session ID
79
- assert memory.extract_session_id({"tenant_id": "t1"}) is None
80
- assert memory.extract_session_id({}) is None
81
-
82
- # Test empty string
83
- assert memory.extract_session_id({"session_id": ""}) is None
84
- assert memory.extract_session_id({"session_id": " "}) is None
85
-
86
-
87
- def test_add_and_get_entry():
88
- """Test basic memory storage and retrieval."""
89
- session_id = "test-session-1"
90
-
91
- # Add entries
92
- memory.add_entry(session_id, "tool1", {"output": "data1"}, max_items=10, ttl_seconds=900)
93
- memory.add_entry(session_id, "tool2", {"output": "data2"}, max_items=10, ttl_seconds=900)
94
- memory.add_entry(session_id, "tool3", {"output": "data3"}, max_items=10, ttl_seconds=900)
95
-
96
- # Retrieve entries
97
- entries = memory.get_recent(session_id, ttl_seconds=900)
98
-
99
- assert len(entries) == 3
100
- assert entries[0]["tool"] == "tool1"
101
- assert entries[1]["tool"] == "tool2"
102
- assert entries[2]["tool"] == "tool3"
103
- assert entries[0]["output"] == {"output": "data1"}
104
- assert "timestamp" in entries[0]
105
-
106
-
107
- def test_memory_bounded_size():
108
- """Test that memory only keeps last N items."""
109
- session_id = "test-session-2"
110
- max_items = 3
111
-
112
- # Add more items than max
113
- for i in range(5):
114
- memory.add_entry(session_id, f"tool{i}", {"data": i}, max_items=max_items, ttl_seconds=900)
115
-
116
- entries = memory.get_recent(session_id, ttl_seconds=900)
117
-
118
- # Should only have last 3 items
119
- assert len(entries) == 3
120
- assert entries[0]["tool"] == "tool2"
121
- assert entries[1]["tool"] == "tool3"
122
- assert entries[2]["tool"] == "tool4"
123
-
124
-
125
- def test_memory_expiration():
126
- """Test that expired entries are automatically removed."""
127
- session_id = "test-session-3"
128
- short_ttl = 1 # 1 second TTL
129
-
130
- # Add entry
131
- memory.add_entry(session_id, "tool1", {"data": "old"}, max_items=10, ttl_seconds=short_ttl)
132
-
133
- # Should be present immediately
134
- entries = memory.get_recent(session_id, ttl_seconds=short_ttl)
135
- assert len(entries) == 1
136
-
137
- # Wait for expiration
138
- time.sleep(1.1)
139
-
140
- # Should be expired now
141
- entries = memory.get_recent(session_id, ttl_seconds=short_ttl)
142
- assert len(entries) == 0
143
-
144
-
145
- def test_session_isolation():
146
- """Test that different session_ids don't share memory."""
147
- session1 = "session-1"
148
- session2 = "session-2"
149
-
150
- memory.add_entry(session1, "tool1", {"data": "s1"}, max_items=10, ttl_seconds=900)
151
- memory.add_entry(session2, "tool2", {"data": "s2"}, max_items=10, ttl_seconds=900)
152
-
153
- entries1 = memory.get_recent(session1, ttl_seconds=900)
154
- entries2 = memory.get_recent(session2, ttl_seconds=900)
155
-
156
- assert len(entries1) == 1
157
- assert len(entries2) == 1
158
- assert entries1[0]["tool"] == "tool1"
159
- assert entries2[0]["tool"] == "tool2"
160
-
161
-
162
- def test_clear_session():
163
- """Test that clear_session removes all memory for a session."""
164
- session_id = "test-session-4"
165
-
166
- memory.add_entry(session_id, "tool1", {"data": "d1"}, max_items=10, ttl_seconds=900)
167
- memory.add_entry(session_id, "tool2", {"data": "d2"}, max_items=10, ttl_seconds=900)
168
-
169
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 2
170
-
171
- memory.clear_session(session_id)
172
-
173
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 0
174
-
175
-
176
- def test_memory_not_keyed_by_tenant():
177
- """Test that memory is keyed by session_id, NOT tenant_id."""
178
- session_id = "shared-session"
179
- tenant1 = "tenant-a"
180
- tenant2 = "tenant-b"
181
-
182
- # Simulate: tenant1 calls tool, then tenant2 calls tool with same session_id
183
- # They should see each other's tool outputs (because memory is session-based, not tenant-based)
184
-
185
- # This is intentional for safety - memory is NOT per-tenant
186
- # In a real scenario, you'd want to ensure session_ids are unique per tenant
187
- # But the memory system itself doesn't enforce this
188
-
189
- # Add entry from tenant1 perspective
190
- memory.add_entry(session_id, "tool1", {"tenant": tenant1, "data": "from-tenant1"}, max_items=10, ttl_seconds=900)
191
-
192
- # Add entry from tenant2 perspective (same session_id)
193
- memory.add_entry(session_id, "tool2", {"tenant": tenant2, "data": "from-tenant2"}, max_items=10, ttl_seconds=900)
194
-
195
- # Both should see both entries (because same session_id)
196
- entries = memory.get_recent(session_id, ttl_seconds=900)
197
- assert len(entries) == 2
198
- assert entries[0]["output"]["tenant"] == tenant1
199
- assert entries[1]["output"]["tenant"] == tenant2
200
-
201
-
202
- def test_get_recent_with_limit():
203
- """Test that get_recent respects the limit parameter."""
204
- session_id = "test-session-5"
205
-
206
- # Add 5 entries
207
- for i in range(5):
208
- memory.add_entry(session_id, f"tool{i}", {"data": i}, max_items=10, ttl_seconds=900)
209
-
210
- # Get all
211
- all_entries = memory.get_recent(session_id, limit=None, ttl_seconds=900)
212
- assert len(all_entries) == 5
213
-
214
- # Get last 2
215
- recent_2 = memory.get_recent(session_id, limit=2, ttl_seconds=900)
216
- assert len(recent_2) == 2
217
- assert recent_2[0]["tool"] == "tool3"
218
- assert recent_2[1]["tool"] == "tool4"
219
-
220
-
221
- # =============================================================
222
- # INTEGRATION TESTS: execute_tool with Memory
223
- # =============================================================
224
-
225
- @pytest.mark.asyncio
226
- async def test_execute_tool_stores_memory(mock_tool_handler):
227
- """Test that execute_tool stores tool output in memory."""
228
- payload = {
229
- "tenant_id": "test-tenant",
230
- "session_id": "test-session-6",
231
- "query": "test query"
232
- }
233
-
234
- result = await execute_tool("test.tool", payload, mock_tool_handler)
235
-
236
- # Check that result is successful
237
- assert result["status"] == "ok"
238
-
239
- # Check that memory was stored
240
- entries = memory.get_recent("test-session-6", ttl_seconds=900)
241
- assert len(entries) == 1
242
- assert entries[0]["tool"] == "test.tool"
243
- assert entries[0]["output"] == {"result": "success", "tool_output": "test_data"}
244
-
245
-
246
- @pytest.mark.asyncio
247
- async def test_execute_tool_injects_memory(mock_tool_handler):
248
- """Test that execute_tool injects recent memory into payload."""
249
- session_id = "test-session-7"
250
-
251
- # First call - no memory yet
252
- payload1 = {
253
- "tenant_id": "test-tenant",
254
- "session_id": session_id,
255
- "query": "first query"
256
- }
257
-
258
- await execute_tool("tool1", payload1, mock_tool_handler)
259
-
260
- # Second call - should have memory from first call
261
- payload2 = {
262
- "tenant_id": "test-tenant",
263
- "session_id": session_id,
264
- "query": "second query"
265
- }
266
-
267
- await execute_tool("tool2", payload2, mock_tool_handler)
268
-
269
- # Check that second call received memory
270
- assert len(mock_tool_handler.captured) == 2
271
- second_payload = mock_tool_handler.captured[1]
272
-
273
- assert "memory" in second_payload
274
- assert len(second_payload["memory"]) == 1
275
- assert second_payload["memory"][0]["tool"] == "tool1"
276
-
277
-
278
- @pytest.mark.asyncio
279
- async def test_execute_tool_clears_memory_on_end_session(mock_tool_handler):
280
- """Test that execute_tool clears memory when end_session is True."""
281
- session_id = "test-session-8"
282
-
283
- # First call - store memory
284
- payload1 = {
285
- "tenant_id": "test-tenant",
286
- "session_id": session_id,
287
- "query": "first query"
288
- }
289
-
290
- await execute_tool("tool1", payload1, mock_tool_handler)
291
-
292
- # Verify memory exists
293
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 1
294
-
295
- # Second call with end_session=True
296
- payload2 = {
297
- "tenant_id": "test-tenant",
298
- "session_id": session_id,
299
- "end_session": True,
300
- "query": "closing"
301
- }
302
-
303
- await execute_tool("tool2", payload2, mock_tool_handler)
304
-
305
- # Memory should be cleared
306
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 0
307
-
308
- # Third call - should have no memory
309
- payload3 = {
310
- "tenant_id": "test-tenant",
311
- "session_id": session_id,
312
- "query": "new query"
313
- }
314
-
315
- await execute_tool("tool3", payload3, mock_tool_handler)
316
-
317
- # Check that third call received no memory
318
- third_payload = mock_tool_handler.captured[2]
319
- assert "memory" in third_payload
320
- assert len(third_payload["memory"]) == 0
321
-
322
-
323
- @pytest.mark.asyncio
324
- async def test_execute_tool_no_memory_without_session_id(mock_tool_handler):
325
- """Test that execute_tool doesn't store/inject memory if no session_id."""
326
- payload = {
327
- "tenant_id": "test-tenant",
328
- "query": "test query"
329
- # No session_id
330
- }
331
-
332
- await execute_tool("test.tool", payload, mock_tool_handler)
333
-
334
- # Should not have stored memory
335
- # (We can't easily check this without session_id, but handler shouldn't have memory field)
336
- first_payload = mock_tool_handler.captured[0]
337
- assert "memory" not in first_payload
338
-
339
-
340
- @pytest.mark.asyncio
341
- async def test_execute_tool_multi_step_workflow(mock_tool_handler):
342
- """Test a multi-step workflow where each step sees previous tool outputs."""
343
- session_id = "test-session-9"
344
-
345
- # Step 1: RAG search
346
- payload1 = {
347
- "tenant_id": "test-tenant",
348
- "session_id": session_id,
349
- "query": "search for X"
350
- }
351
-
352
- await execute_tool("rag.search", payload1, mock_tool_handler)
353
-
354
- # Step 2: Web search (should see RAG results in memory)
355
- payload2 = {
356
- "tenant_id": "test-tenant",
357
- "session_id": session_id,
358
- "query": "search web for Y"
359
- }
360
-
361
- await execute_tool("web.search", payload2, mock_tool_handler)
362
-
363
- # Step 3: LLM synthesis (should see both RAG and Web results)
364
- payload3 = {
365
- "tenant_id": "test-tenant",
366
- "session_id": session_id,
367
- "query": "synthesize results"
368
- }
369
-
370
- await execute_tool("llm.synthesize", payload3, mock_tool_handler)
371
-
372
- # Verify all steps captured memory
373
- assert len(mock_tool_handler.captured) == 3
374
-
375
- # First call has no memory
376
- assert "memory" not in mock_tool_handler.captured[0] or len(mock_tool_handler.captured[0].get("memory", [])) == 0
377
-
378
- # Second call has memory from first
379
- assert len(mock_tool_handler.captured[1].get("memory", [])) == 1
380
- assert mock_tool_handler.captured[1]["memory"][0]["tool"] == "rag.search"
381
-
382
- # Third call has memory from both previous calls
383
- assert len(mock_tool_handler.captured[2].get("memory", [])) == 2
384
- assert mock_tool_handler.captured[2]["memory"][0]["tool"] == "rag.search"
385
- assert mock_tool_handler.captured[2]["memory"][1]["tool"] == "web.search"
386
-
387
-
388
- @pytest.mark.asyncio
389
- async def test_execute_tool_end_session_variants(mock_tool_handler):
390
- """Test that both end_session and endSession flags work."""
391
- session_id = "test-session-10"
392
-
393
- # Store some memory
394
- payload1 = {
395
- "tenant_id": "test-tenant",
396
- "session_id": session_id,
397
- "query": "first"
398
- }
399
- await execute_tool("tool1", payload1, mock_tool_handler)
400
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 1
401
-
402
- # Test end_session (snake_case)
403
- payload2 = {
404
- "tenant_id": "test-tenant",
405
- "session_id": session_id,
406
- "end_session": True,
407
- "query": "end"
408
- }
409
- await execute_tool("tool2", payload2, mock_tool_handler)
410
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 0
411
-
412
- # Store memory again
413
- await execute_tool("tool3", payload1, mock_tool_handler)
414
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 1
415
-
416
- # Test endSession (camelCase)
417
- payload3 = {
418
- "tenant_id": "test-tenant",
419
- "session_id": session_id,
420
- "endSession": True,
421
- "query": "end"
422
- }
423
- await execute_tool("tool4", payload3, mock_tool_handler)
424
- assert len(memory.get_recent(session_id, ttl_seconds=900)) == 0
425
-
426
-
427
- # =============================================================
428
- # EDGE CASES
429
- # =============================================================
430
-
431
- def test_empty_session_id():
432
- """Test that empty session_id doesn't cause errors."""
433
- memory.add_entry("", "tool1", {"data": "test"}, max_items=10, ttl_seconds=900)
434
- # Should not store anything
435
- assert len(memory.get_recent("", ttl_seconds=900)) == 0
436
-
437
-
438
- def test_none_session_id():
439
- """Test that None session_id doesn't cause errors."""
440
- # This shouldn't happen in practice, but test for safety
441
- entries = memory.get_recent(None, ttl_seconds=900) # type: ignore
442
- assert entries == []
443
-
444
-
445
- @pytest.mark.asyncio
446
- async def test_concurrent_sessions(mock_tool_handler):
447
- """Test that concurrent sessions don't interfere with each other."""
448
- session1 = "session-concurrent-1"
449
- session2 = "session-concurrent-2"
450
-
451
- # Execute tools in both sessions concurrently
452
- tasks = [
453
- execute_tool("tool1", {
454
- "tenant_id": "tenant1",
455
- "session_id": session1,
456
- "query": "q1"
457
- }, mock_tool_handler),
458
- execute_tool("tool2", {
459
- "tenant_id": "tenant2",
460
- "session_id": session2,
461
- "query": "q2"
462
- }, mock_tool_handler),
463
- ]
464
-
465
- await asyncio.gather(*tasks)
466
-
467
- # Each session should have its own memory
468
- entries1 = memory.get_recent(session1, ttl_seconds=900)
469
- entries2 = memory.get_recent(session2, ttl_seconds=900)
470
-
471
- assert len(entries1) == 1
472
- assert len(entries2) == 1
473
- assert entries1[0]["tool"] == "tool1"
474
- assert entries2[0]["tool"] == "tool2"
475
-
476
-
477
- if __name__ == "__main__":
478
- pytest.main([__file__, "-v"])
479
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_enhanced_admin_rules.py DELETED
@@ -1,195 +0,0 @@
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
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_intent.py DELETED
@@ -1,118 +0,0 @@
1
- # =============================================================
2
- # File: tests/test_intent.py
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
- try:
13
- import pytest
14
- HAS_PYTEST = True
15
- except ImportError:
16
- HAS_PYTEST = False
17
- # Create a mock pytest decorator if pytest is not available
18
- class MockMark:
19
- def asyncio(self, func):
20
- return func
21
- class MockPytest:
22
- mark = MockMark()
23
- pytest = MockPytest()
24
-
25
- import asyncio
26
- from api.services.intent_classifier import IntentClassifier
27
- from api.services.llm_client import LLMClient
28
- from api.services.redflag_detector import RedFlagDetector
29
- from api.services.tool_selector import ToolSelector
30
- from api.models.redflag import RedFlagMatch
31
-
32
-
33
- @pytest.mark.asyncio
34
- async def test_intent_rag_keywords():
35
- classifier = IntentClassifier()
36
- intent = await classifier.classify("Please check the HR policy document")
37
- assert intent == "rag"
38
-
39
- @pytest.mark.asyncio
40
- async def test_intent_web_keywords():
41
- classifier = IntentClassifier()
42
- intent = await classifier.classify("latest news about Tesla stock")
43
- assert intent == "web"
44
-
45
- @pytest.mark.asyncio
46
- async def test_intent_admin_keywords():
47
- classifier = IntentClassifier()
48
- intent = await classifier.classify("export all user data")
49
- assert intent == "admin"
50
-
51
- @pytest.mark.asyncio
52
- async def test_intent_general():
53
- classifier = IntentClassifier()
54
- intent = await classifier.classify("explain how gravity works")
55
- assert intent == "general"
56
-
57
-
58
- # ---- LLM fallback test ----
59
-
60
- class FakeLLM:
61
- async def simple_call(self, prompt: str, temperature: float = 0.0):
62
- return "web"
63
-
64
- @pytest.mark.asyncio
65
- async def test_intent_llm_fallback():
66
- classifier = IntentClassifier(llm_client=FakeLLM())
67
- intent = await classifier.classify("What's going on in the world?")
68
- assert intent == "web"
69
-
70
-
71
- # ---- Manual run function (for non-pytest execution) ----
72
-
73
- async def run_manual_tests():
74
- llm = LLMClient()
75
- clf = IntentClassifier(llm_client=llm)
76
-
77
- # Initialize detector with empty creds (will return empty results if no Supabase)
78
- import os
79
- detector = RedFlagDetector(
80
- supabase_url=os.getenv("SUPABASE_URL") or "",
81
- supabase_key=os.getenv("SUPABASE_SERVICE_KEY") or ""
82
- )
83
- selector = ToolSelector(llm_client=llm)
84
-
85
- print("Intent Classification:")
86
- print("RAG:", await clf.classify("summarize internal policy"))
87
- print("WEB:", await clf.classify("latest news about ai"))
88
- print("ADMIN:", await clf.classify("delete all data"))
89
- print("GENERAL:", await clf.classify("hi how are you"))
90
-
91
- print("\nRedFlag checks (will be empty if no Supabase configured):")
92
- try:
93
- print(await detector.check("tenant123", "My email is test@gmail.com"))
94
- print(await detector.check("tenant123", "delete all data now"))
95
- print(await detector.check("tenant123", "confidential salary report"))
96
- print(await detector.check("tenant123", "hello world"))
97
- except Exception as e:
98
- print(f"RedFlag check failed (expected if Supabase not configured): {e}")
99
-
100
- print("\nTool selection:")
101
- print(await selector.select("admin", "delete all data", {}))
102
- print(await selector.select("rag", "summarize policy", {}))
103
- print(await selector.select("web", "latest news", {}))
104
- print(await selector.select("general", "hello", {}))
105
-
106
- print("\nLLM Test:")
107
- try:
108
- if llm.url and llm.model:
109
- result = await llm.simple_call("Hello Llama!")
110
- print(f"LLM Result: {result}")
111
- else:
112
- print("LLM not configured (OLLAMA_URL/OLLAMA_MODEL not set) - skipping LLM test")
113
- except Exception as e:
114
- print(f"LLM call failed (expected if Ollama not running or not configured): {e}")
115
-
116
-
117
- if __name__ == "__main__":
118
- asyncio.run(run_manual_tests())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_metadata_extraction.py DELETED
@@ -1,461 +0,0 @@
1
- """
2
- Comprehensive tests for AI-Generated Knowledge Base Metadata Extraction
3
-
4
- Tests all metadata extraction features:
5
- - Title extraction (from filename, content, URL)
6
- - Summary generation (LLM and fallback)
7
- - Tags extraction (LLM and fallback)
8
- - Topics extraction (LLM and fallback)
9
- - Date detection
10
- - Quality score calculation
11
- - Database storage
12
- - Integration with ingestion pipeline
13
- """
14
-
15
- import pytest
16
- import asyncio
17
- from unittest.mock import Mock, patch, AsyncMock
18
- from backend.api.services.metadata_extractor import MetadataExtractor
19
- from backend.mcp_server.common.database import insert_document_chunks, get_connection
20
- import json
21
-
22
-
23
- class TestMetadataExtractor:
24
- """Test the MetadataExtractor service"""
25
-
26
- @pytest.fixture
27
- def extractor(self):
28
- """Create a MetadataExtractor instance"""
29
- return MetadataExtractor()
30
-
31
- @pytest.fixture
32
- def sample_content(self):
33
- """Sample document content for testing"""
34
- return """
35
- # API Documentation Guide
36
-
37
- This comprehensive guide covers REST API endpoints, authentication, and best practices.
38
- Published on 2024-01-15, this document provides detailed information about our API.
39
-
40
- ## Authentication
41
- All API requests require authentication using API keys or OAuth tokens.
42
-
43
- ## Endpoints
44
- - GET /api/v1/users - List all users
45
- - POST /api/v1/users - Create a new user
46
- - GET /api/v1/users/{id} - Get user by ID
47
-
48
- ## Examples
49
- Here are some example requests and responses.
50
-
51
- ## Troubleshooting
52
- Common issues and their solutions.
53
- """
54
-
55
- def test_extract_title_from_filename(self, extractor):
56
- """Test title extraction from filename"""
57
- content = "Some content here"
58
- filename = "API_Documentation_Guide.pdf"
59
-
60
- title = extractor._extract_title(content, filename=filename, url=None)
61
- assert title == "Api Documentation Guide"
62
- assert "API" in title or "Api" in title
63
-
64
- def test_extract_title_from_content(self, extractor, sample_content):
65
- """Test title extraction from content (first line or markdown)"""
66
- title = extractor._extract_title(sample_content, filename=None, url=None)
67
- # Should extract from markdown header or first meaningful line
68
- assert len(title) > 0
69
- assert len(title) < 200
70
-
71
- def test_extract_title_from_url(self, extractor):
72
- """Test title extraction from URL"""
73
- content = "Some content"
74
- url = "https://example.com/api/documentation-guide"
75
-
76
- title = extractor._extract_title(content, filename=None, url=url)
77
- # URL extraction should return something (may be from URL path or fallback)
78
- assert len(title) > 0
79
- assert isinstance(title, str)
80
-
81
- def test_extract_title_fallback(self, extractor):
82
- """Test title fallback to first 50 chars"""
83
- content = "This is a very long document that doesn't have a clear title structure and continues with more text"
84
- title = extractor._extract_title(content, filename=None, url=None)
85
- assert len(title) > 0
86
- # Fallback should return first line or first 50 chars (may not have ...)
87
- assert isinstance(title, str)
88
- # Title should be reasonable length (not the entire content if content is long)
89
- # If content is short, title might equal content, which is fine
90
- if len(content) > 50:
91
- assert len(title) <= len(content)
92
-
93
- def test_detect_date_formats(self, extractor):
94
- """Test date detection in various formats"""
95
- # YYYY-MM-DD format
96
- content1 = "Published on 2024-01-15"
97
- date1 = extractor._detect_date(content1)
98
- assert date1 == "2024-01-15"
99
-
100
- # MM/DD/YYYY format
101
- content2 = "Created on 01/15/2024"
102
- date2 = extractor._detect_date(content2)
103
- assert date2 is not None
104
-
105
- # Month name format
106
- content3 = "Last updated January 15, 2024"
107
- date3 = extractor._detect_date(content3)
108
- assert date3 is not None
109
-
110
- def test_detect_date_none(self, extractor):
111
- """Test date detection when no date is present"""
112
- content = "This document has no date information"
113
- date = extractor._detect_date(content)
114
- assert date is None
115
-
116
- def test_generate_basic_summary(self, extractor, sample_content):
117
- """Test basic summary generation"""
118
- summary = extractor._generate_basic_summary(sample_content)
119
- assert len(summary) > 0
120
- assert len(summary) < len(sample_content)
121
- assert summary.endswith('.')
122
-
123
- def test_extract_basic_tags(self, extractor, sample_content):
124
- """Test basic tag extraction without LLM"""
125
- tags = extractor._extract_basic_tags(sample_content)
126
- assert isinstance(tags, list)
127
- assert len(tags) > 0
128
- assert len(tags) <= 8
129
- # Should find "api" in tags
130
- assert any("api" in tag.lower() for tag in tags)
131
-
132
- def test_extract_basic_topics(self, extractor, sample_content):
133
- """Test basic topic extraction without LLM"""
134
- topics = extractor._extract_basic_topics(sample_content)
135
- assert isinstance(topics, list)
136
- assert len(topics) > 0
137
- assert len(topics) <= 5
138
- # Should find topics from headers
139
- assert any("API" in topic or "api" in topic.lower() for topic in topics)
140
-
141
- def test_calculate_quality_score(self, extractor):
142
- """Test quality score calculation"""
143
- # Good quality content
144
- good_content = "This is a well-structured document. " * 50
145
- good_content += "It has multiple paragraphs. " * 10
146
- score1 = extractor._calculate_quality_score(good_content, 500, "Good summary")
147
- assert 0.0 <= score1 <= 1.0
148
- assert score1 > 0.5 # Should be decent quality
149
-
150
- # Poor quality content
151
- poor_content = "x" * 100
152
- score2 = extractor._calculate_quality_score(poor_content, 10, "")
153
- assert 0.0 <= score2 <= 1.0
154
- assert score2 < score1 # Should be lower quality
155
-
156
- def test_extract_fallback(self, extractor, sample_content):
157
- """Test fallback metadata extraction"""
158
- result = extractor._extract_fallback(sample_content, "Test Title")
159
- assert "summary" in result
160
- assert "tags" in result
161
- assert "topics" in result
162
- assert isinstance(result["tags"], list)
163
- assert isinstance(result["topics"], list)
164
- assert len(result["summary"]) > 0
165
-
166
- @pytest.mark.asyncio
167
- async def test_extract_with_llm_success(self, extractor, sample_content):
168
- """Test LLM-based metadata extraction (mocked)"""
169
- # Mock LLM response
170
- mock_response = json.dumps({
171
- "summary": "This document provides comprehensive API documentation.",
172
- "tags": ["api", "documentation", "rest", "endpoints"],
173
- "topics": ["API", "REST", "Endpoints"],
174
- "domain": "Software Development"
175
- })
176
-
177
- with patch.object(extractor.llm, 'simple_call', new_callable=AsyncMock) as mock_llm:
178
- mock_llm.return_value = mock_response
179
-
180
- result = await extractor._extract_with_llm(sample_content, "API Documentation")
181
-
182
- assert "summary" in result
183
- assert "tags" in result
184
- assert "topics" in result
185
- assert len(result["tags"]) > 0
186
- assert len(result["topics"]) > 0
187
- assert "api" in [tag.lower() for tag in result["tags"]]
188
-
189
- @pytest.mark.asyncio
190
- async def test_extract_with_llm_timeout(self, extractor, sample_content):
191
- """Test LLM extraction timeout handling"""
192
- with patch.object(extractor.llm, 'simple_call', new_callable=AsyncMock) as mock_llm:
193
- mock_llm.side_effect = asyncio.TimeoutError()
194
-
195
- with pytest.raises(Exception) as exc_info:
196
- await extractor._extract_with_llm(sample_content, "Test")
197
- assert "timeout" in str(exc_info.value).lower() or isinstance(exc_info.value, asyncio.TimeoutError)
198
-
199
- @pytest.mark.asyncio
200
- async def test_extract_metadata_full(self, extractor, sample_content):
201
- """Test full metadata extraction (with LLM fallback)"""
202
- # Mock LLM to fail (will use fallback)
203
- with patch.object(extractor.llm, 'simple_call', new_callable=AsyncMock) as mock_llm:
204
- mock_llm.side_effect = Exception("LLM unavailable")
205
-
206
- metadata = await extractor.extract_metadata(
207
- content=sample_content,
208
- filename="api_docs.md",
209
- url=None,
210
- source_type="markdown"
211
- )
212
-
213
- # Verify all required fields
214
- assert "title" in metadata
215
- assert "summary" in metadata
216
- assert "tags" in metadata
217
- assert "topics" in metadata
218
- assert "detected_date" in metadata
219
- assert "quality_score" in metadata
220
- assert "word_count" in metadata
221
- assert "char_count" in metadata
222
- assert "source_type" in metadata
223
- assert "extraction_method" in metadata
224
-
225
- # Verify data types and ranges
226
- assert isinstance(metadata["title"], str)
227
- assert isinstance(metadata["summary"], str)
228
- assert isinstance(metadata["tags"], list)
229
- assert isinstance(metadata["topics"], list)
230
- assert isinstance(metadata["quality_score"], float)
231
- assert 0.0 <= metadata["quality_score"] <= 1.0
232
- assert metadata["word_count"] > 0
233
- assert metadata["extraction_method"] in ["llm", "fallback"]
234
-
235
- @pytest.mark.asyncio
236
- async def test_extract_metadata_with_llm(self, extractor, sample_content):
237
- """Test metadata extraction with successful LLM call"""
238
- mock_response = json.dumps({
239
- "summary": "Comprehensive API documentation guide.",
240
- "tags": ["api", "documentation", "rest"],
241
- "topics": ["API", "REST", "Documentation"],
242
- "domain": "API"
243
- })
244
-
245
- with patch.object(extractor.llm, 'simple_call', new_callable=AsyncMock) as mock_llm:
246
- mock_llm.return_value = mock_response
247
-
248
- metadata = await extractor.extract_metadata(
249
- content=sample_content,
250
- filename="api_docs.md"
251
- )
252
-
253
- assert metadata["extraction_method"] == "llm"
254
- assert len(metadata["summary"]) > 0
255
- assert len(metadata["tags"]) > 0
256
- assert len(metadata["topics"]) > 0
257
-
258
-
259
- class TestDatabaseMetadataStorage:
260
- """Test database storage of metadata"""
261
-
262
- @pytest.fixture
263
- def sample_metadata(self):
264
- """Sample metadata for testing"""
265
- return {
266
- "title": "Test Document",
267
- "summary": "This is a test document for metadata extraction.",
268
- "tags": ["test", "documentation"],
269
- "topics": ["Testing", "Metadata"],
270
- "detected_date": "2024-01-15",
271
- "quality_score": 0.85,
272
- "word_count": 100,
273
- "char_count": 500,
274
- "source_type": "txt",
275
- "extraction_method": "llm"
276
- }
277
-
278
- def test_insert_with_metadata(self, sample_metadata):
279
- """Test inserting document chunk with metadata"""
280
- # This test requires a real database connection
281
- # Skip if database is not available
282
- try:
283
- conn = get_connection()
284
- conn.close()
285
- except Exception:
286
- pytest.skip("Database not available for testing")
287
-
288
- tenant_id = "test_tenant_metadata"
289
- text = "This is a test chunk with metadata."
290
-
291
- # Generate a simple embedding (384 dimensions)
292
- embedding = [0.1] * 384
293
-
294
- # Insert with metadata
295
- insert_document_chunks(
296
- tenant_id=tenant_id,
297
- text=text,
298
- embedding=embedding,
299
- metadata=sample_metadata,
300
- doc_id="test_doc_123"
301
- )
302
-
303
- # Verify insertion by querying
304
- conn = get_connection()
305
- cur = conn.cursor()
306
- cur.execute("""
307
- SELECT metadata, doc_id
308
- FROM documents
309
- WHERE tenant_id = %s
310
- AND chunk_text = %s
311
- LIMIT 1;
312
- """, (tenant_id, text))
313
-
314
- result = cur.fetchone()
315
- assert result is not None
316
-
317
- stored_metadata = result[0]
318
- stored_doc_id = result[1]
319
-
320
- # Verify metadata was stored correctly
321
- assert stored_metadata is not None
322
- assert stored_metadata["title"] == sample_metadata["title"]
323
- assert stored_metadata["summary"] == sample_metadata["summary"]
324
- assert stored_metadata["quality_score"] == sample_metadata["quality_score"]
325
-
326
- # Verify doc_id was stored
327
- assert stored_doc_id == "test_doc_123"
328
-
329
- # Cleanup
330
- cur.execute("DELETE FROM documents WHERE tenant_id = %s", (tenant_id,))
331
- conn.commit()
332
- cur.close()
333
- conn.close()
334
-
335
-
336
- class TestIngestionIntegration:
337
- """Test metadata extraction integration with ingestion pipeline"""
338
-
339
- @pytest.mark.asyncio
340
- async def test_metadata_extraction_in_ingestion(self):
341
- """Test that metadata is extracted during document ingestion"""
342
- from backend.api.services.document_ingestion import prepare_ingestion_payload, process_ingestion
343
- from backend.api.mcp_clients.rag_client import RAGClient
344
- from unittest.mock import AsyncMock, patch, MagicMock
345
-
346
- # Mock RAG client
347
- mock_rag_client = Mock(spec=RAGClient)
348
- mock_rag_client.ingest_with_metadata = AsyncMock(return_value={
349
- "chunks_stored": 3,
350
- "status": "ok"
351
- })
352
-
353
- # Prepare payload
354
- payload = await prepare_ingestion_payload(
355
- tenant_id="test_tenant",
356
- content="This is a test document about API documentation. Published on 2024-01-15.",
357
- source_type="txt",
358
- filename="api_docs.txt"
359
- )
360
-
361
- # Process with metadata extraction - patch the import path used in the function
362
- with patch('backend.api.services.metadata_extractor.MetadataExtractor') as mock_extractor_class:
363
- mock_extractor = MagicMock()
364
- mock_extractor.extract_metadata = AsyncMock(return_value={
365
- "title": "API Documentation",
366
- "summary": "Test document about APIs",
367
- "tags": ["api", "documentation"],
368
- "topics": ["API"],
369
- "detected_date": "2024-01-15",
370
- "quality_score": 0.8,
371
- "word_count": 10,
372
- "char_count": 50,
373
- "source_type": "txt",
374
- "extraction_method": "llm"
375
- })
376
- mock_extractor_class.return_value = mock_extractor
377
-
378
- result = await process_ingestion(payload, mock_rag_client, extract_metadata=True)
379
-
380
- # Verify metadata was extracted
381
- assert "extracted_metadata" in result
382
- assert result["extracted_metadata"]["title"] == "API Documentation"
383
- assert result["extracted_metadata"]["quality_score"] == 0.8
384
-
385
- # Verify RAG client was called with metadata
386
- mock_rag_client.ingest_with_metadata.assert_called_once()
387
- call_args = mock_rag_client.ingest_with_metadata.call_args
388
- # Check that metadata was passed (either as kwarg or in the merged metadata)
389
- assert call_args is not None
390
-
391
-
392
- class TestMetadataEdgeCases:
393
- """Test edge cases and error handling"""
394
-
395
- @pytest.mark.asyncio
396
- async def test_empty_content(self):
397
- """Test metadata extraction with empty content"""
398
- extractor = MetadataExtractor()
399
-
400
- metadata = await extractor.extract_metadata(
401
- content="",
402
- filename="empty.txt"
403
- )
404
-
405
- # Should still return metadata structure
406
- assert "title" in metadata
407
- assert "summary" in metadata
408
- assert metadata["word_count"] == 0
409
-
410
- @pytest.mark.asyncio
411
- async def test_very_long_content(self):
412
- """Test metadata extraction with very long content"""
413
- extractor = MetadataExtractor()
414
- long_content = "Word " * 10000 # 10,000 words
415
-
416
- metadata = await extractor.extract_metadata(
417
- content=long_content,
418
- filename="long_doc.txt"
419
- )
420
-
421
- assert metadata["word_count"] == 10000
422
- assert len(metadata["summary"]) > 0
423
- assert metadata["quality_score"] >= 0.0
424
-
425
- @pytest.mark.asyncio
426
- async def test_special_characters(self):
427
- """Test metadata extraction with special characters"""
428
- extractor = MetadataExtractor()
429
- special_content = "Document with émojis 🚀 and spéciál chàracters!"
430
-
431
- metadata = await extractor.extract_metadata(
432
- content=special_content,
433
- filename="special.txt"
434
- )
435
-
436
- assert "title" in metadata
437
- assert len(metadata["title"]) > 0
438
-
439
- def test_quality_score_edge_cases(self):
440
- """Test quality score with edge cases"""
441
- extractor = MetadataExtractor()
442
-
443
- # Very short content
444
- short = "Hi"
445
- score1 = extractor._calculate_quality_score(short, 1, "")
446
- assert 0.0 <= score1 <= 1.0
447
-
448
- # Very long content
449
- long = "Word " * 20000
450
- score2 = extractor._calculate_quality_score(long, 20000, "Summary")
451
- assert 0.0 <= score2 <= 1.0
452
-
453
- # No summary
454
- no_summary = "Content " * 100
455
- score3 = extractor._calculate_quality_score(no_summary, 100, "")
456
- assert 0.0 <= score3 <= 1.0
457
-
458
-
459
- if __name__ == "__main__":
460
- pytest.main([__file__, "-v", "--tb=short"])
461
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_retry_system.py DELETED
@@ -1,651 +0,0 @@
1
- # =============================================================
2
- # File: backend/tests/test_retry_system.py
3
- # =============================================================
4
- """
5
- Comprehensive tests for autonomous retry and self-correction system.
6
-
7
- Tests:
8
- 1. RAG retry with low scores (threshold adjustment + query expansion)
9
- 2. Web search retry with empty results (query rewriting)
10
- 3. Safe tool call retry mechanism
11
- 4. Rule safe message rewriting
12
- 5. Integration tests with reasoning traces
13
- 6. Analytics logging verification
14
- """
15
-
16
- import sys
17
- from pathlib import Path
18
- import pytest
19
- from unittest.mock import AsyncMock, MagicMock, patch
20
- import asyncio
21
-
22
- # Add backend directory to Python path
23
- backend_dir = Path(__file__).parent.parent
24
- sys.path.insert(0, str(backend_dir))
25
-
26
- try:
27
- HAS_PYTEST = True
28
- except ImportError:
29
- HAS_PYTEST = False
30
- class MockMark:
31
- def asyncio(self, func):
32
- return func
33
- class MockPytest:
34
- mark = MockMark()
35
- def fixture(self, func):
36
- return func
37
- pytest = MockPytest()
38
-
39
- from api.services.agent_orchestrator import AgentOrchestrator
40
- from api.models.agent import AgentRequest
41
- from api.models.redflag import RedFlagMatch
42
-
43
-
44
- # =============================================================
45
- # FIXTURES
46
- # =============================================================
47
-
48
- @pytest.fixture
49
- def mock_orchestrator():
50
- """Create orchestrator with mocked dependencies."""
51
- orch = AgentOrchestrator(
52
- rag_mcp_url="http://fake:8001",
53
- web_mcp_url="http://fake:8002",
54
- admin_mcp_url="http://fake:8003",
55
- llm_backend="ollama"
56
- )
57
-
58
- # Mock MCP client
59
- orch.mcp = MagicMock()
60
- orch.analytics = MagicMock()
61
- orch.llm = MagicMock()
62
- orch.redflag = MagicMock()
63
-
64
- return orch
65
-
66
-
67
- # =============================================================
68
- # RAG RETRY TESTS
69
- # =============================================================
70
-
71
- @pytest.mark.asyncio
72
- async def test_rag_with_repair_high_score_no_retry(mock_orchestrator):
73
- """Test RAG repair doesn't retry when scores are good."""
74
-
75
- # Mock high score result
76
- mock_orchestrator.mcp.call_rag = AsyncMock(return_value={
77
- "results": [{"text": "relevant content", "score": 0.85}]
78
- })
79
-
80
- reasoning_trace = []
81
- result = await mock_orchestrator.rag_with_repair(
82
- query="test query",
83
- tenant_id="tenant1",
84
- reasoning_trace=reasoning_trace,
85
- user_id="user1"
86
- )
87
-
88
- # Should only call once (no retry needed)
89
- assert mock_orchestrator.mcp.call_rag.call_count == 1
90
- assert result["results"][0]["score"] == 0.85
91
-
92
-
93
- @pytest.mark.asyncio
94
- async def test_rag_with_repair_low_score_retry_threshold(mock_orchestrator):
95
- """Test RAG repair retries with lower threshold when score < 0.30."""
96
-
97
- # Mock first call - low score, second call - better score
98
- mock_orchestrator.mcp.call_rag = AsyncMock(side_effect=[
99
- {"results": [{"text": "low relevance", "score": 0.25}]},
100
- {"results": [{"text": "better match", "score": 0.45}]}
101
- ])
102
-
103
- reasoning_trace = []
104
- result = await mock_orchestrator.rag_with_repair(
105
- query="test query",
106
- tenant_id="tenant1",
107
- original_threshold=0.3,
108
- reasoning_trace=reasoning_trace,
109
- user_id="user1"
110
- )
111
-
112
- # Should have retried with lower threshold (0.15)
113
- assert mock_orchestrator.mcp.call_rag.call_count == 2
114
-
115
- # Check second call used threshold 0.15
116
- second_call_kwargs = mock_orchestrator.mcp.call_rag.call_args_list[1].kwargs
117
- assert second_call_kwargs.get("threshold") == 0.15
118
-
119
- # Verify reasoning trace has retry step
120
- retry_steps = [s for s in reasoning_trace if "retry" in str(s).lower()]
121
- assert len(retry_steps) > 0
122
-
123
-
124
- @pytest.mark.asyncio
125
- async def test_rag_with_repair_expand_query(mock_orchestrator):
126
- """Test RAG repair expands query when score still low after threshold retry."""
127
-
128
- # Mock: low score -> still low after threshold retry -> better after expansion
129
- mock_orchestrator.mcp.call_rag = AsyncMock(side_effect=[
130
- {"results": [{"text": "low", "score": 0.12}]}, # Initial - very low
131
- {"results": [{"text": "still low", "score": 0.10}]}, # After threshold retry - still low
132
- {"results": [{"text": "better", "score": 0.35}]} # After query expansion - better
133
- ])
134
-
135
- reasoning_trace = []
136
- result = await mock_orchestrator.rag_with_repair(
137
- query="test",
138
- tenant_id="tenant1",
139
- original_threshold=0.3,
140
- reasoning_trace=reasoning_trace,
141
- user_id="user1"
142
- )
143
-
144
- # Should have retried 3 times (initial + threshold + expanded query)
145
- assert mock_orchestrator.mcp.call_rag.call_count == 3
146
-
147
- # Check reasoning trace has expanded query step
148
- expand_steps = [s for s in reasoning_trace if "expanded" in str(s).lower() or "expand" in str(s).lower()]
149
- assert len(expand_steps) > 0
150
-
151
- # Verify analytics was called for retries
152
- assert mock_orchestrator.analytics.log_tool_usage.call_count > 1
153
-
154
-
155
- @pytest.mark.asyncio
156
- async def test_rag_with_repair_no_results(mock_orchestrator):
157
- """Test RAG repair handles empty results gracefully."""
158
-
159
- mock_orchestrator.mcp.call_rag = AsyncMock(return_value={
160
- "results": []
161
- })
162
-
163
- reasoning_trace = []
164
- result = await mock_orchestrator.rag_with_repair(
165
- query="test query",
166
- tenant_id="tenant1",
167
- reasoning_trace=reasoning_trace,
168
- user_id="user1"
169
- )
170
-
171
- # Should handle gracefully (may retry or return empty)
172
- assert isinstance(result, dict)
173
- assert "results" in result
174
-
175
-
176
- # =============================================================
177
- # WEB SEARCH RETRY TESTS
178
- # =============================================================
179
-
180
- @pytest.mark.asyncio
181
- async def test_web_with_repair_has_results_no_retry(mock_orchestrator):
182
- """Test web repair doesn't retry when results are found."""
183
-
184
- mock_orchestrator.mcp.call_web = AsyncMock(return_value={
185
- "results": [
186
- {"title": "Result 1", "snippet": "Content", "url": "http://example.com"}
187
- ]
188
- })
189
-
190
- reasoning_trace = []
191
- result = await mock_orchestrator.web_with_repair(
192
- query="normal query",
193
- tenant_id="tenant1",
194
- reasoning_trace=reasoning_trace,
195
- user_id="user1"
196
- )
197
-
198
- # Should only call once (no retry needed)
199
- assert mock_orchestrator.mcp.call_web.call_count == 1
200
- assert len(result["results"]) > 0
201
-
202
-
203
- @pytest.mark.asyncio
204
- async def test_web_with_repair_empty_results_retry(mock_orchestrator):
205
- """Test web repair retries with rewritten query when results are empty."""
206
-
207
- # Mock: empty -> empty -> success
208
- mock_orchestrator.mcp.call_web = AsyncMock(side_effect=[
209
- {"results": []}, # Initial - empty
210
- {"results": []}, # First retry - still empty
211
- {"results": [{"title": "Found", "snippet": "Result", "url": "http://example.com"}]} # Second retry - success
212
- ])
213
-
214
- reasoning_trace = []
215
- result = await mock_orchestrator.web_with_repair(
216
- query="obscure query xyz",
217
- tenant_id="tenant1",
218
- reasoning_trace=reasoning_trace,
219
- user_id="user1"
220
- )
221
-
222
- # Should have retried (up to 2 rewrites)
223
- assert mock_orchestrator.mcp.call_web.call_count >= 2
224
-
225
- # Verify reasoning trace has retry steps
226
- retry_steps = [s for s in reasoning_trace if "retry" in str(s).lower()]
227
- assert len(retry_steps) > 0
228
-
229
- # Check that rewritten queries were used
230
- # call_web takes positional args: (tenant_id, query)
231
- calls = mock_orchestrator.mcp.call_web.call_args_list
232
- rewritten_queries = []
233
- for call in calls:
234
- # Extract query from positional args (args[1] after tenant_id)
235
- if len(call.args) > 1:
236
- rewritten_queries.append(call.args[1])
237
-
238
- # Should have at least original + retry queries
239
- assert len(rewritten_queries) >= 2
240
- # Check that at least one rewritten query contains our rewrite patterns
241
- assert any("best explanation" in str(q).lower() or "facts summary" in str(q).lower()
242
- for q in rewritten_queries if q)
243
-
244
-
245
- @pytest.mark.asyncio
246
- async def test_web_with_repair_analytics_logging(mock_orchestrator):
247
- """Test web repair logs retry attempts to analytics."""
248
-
249
- mock_orchestrator.mcp.call_web = AsyncMock(side_effect=[
250
- {"results": []},
251
- {"results": [{"title": "Result", "snippet": "Content"}]}
252
- ])
253
-
254
- await mock_orchestrator.web_with_repair(
255
- query="test",
256
- tenant_id="tenant1",
257
- user_id="user1"
258
- )
259
-
260
- # Verify analytics was called
261
- assert mock_orchestrator.analytics.log_tool_usage.called
262
-
263
-
264
- # =============================================================
265
- # SAFE TOOL CALL TESTS
266
- # =============================================================
267
-
268
- @pytest.mark.asyncio
269
- async def test_safe_tool_call_success_first_attempt(mock_orchestrator):
270
- """Test safe_tool_call succeeds on first attempt."""
271
-
272
- successful_tool = AsyncMock(return_value={"success": True, "data": "result"})
273
-
274
- result = await mock_orchestrator.safe_tool_call(
275
- tool_fn=successful_tool,
276
- params={"param1": "value1"},
277
- max_retries=2,
278
- tool_name="test_tool",
279
- tenant_id="tenant1",
280
- user_id="user1"
281
- )
282
-
283
- # Should succeed on first try
284
- assert successful_tool.call_count == 1
285
- assert result["success"] is True
286
- assert result["data"] == "result"
287
-
288
-
289
- @pytest.mark.asyncio
290
- async def test_safe_tool_call_retry_on_failure(mock_orchestrator):
291
- """Test safe_tool_call retries on failure."""
292
-
293
- failing_tool = AsyncMock(side_effect=[
294
- Exception("First failure"),
295
- {"success": True, "data": "recovered"}
296
- ])
297
-
298
- reasoning_trace = []
299
- result = await mock_orchestrator.safe_tool_call(
300
- tool_fn=failing_tool,
301
- params={},
302
- max_retries=2,
303
- tool_name="test_tool",
304
- tenant_id="tenant1",
305
- user_id="user1",
306
- reasoning_trace=reasoning_trace
307
- )
308
-
309
- # Should have retried
310
- assert failing_tool.call_count == 2
311
- assert result["success"] is True
312
-
313
- # Verify reasoning trace has retry info
314
- retry_steps = [s for s in reasoning_trace if "retry" in str(s).lower()]
315
- assert len(retry_steps) > 0
316
-
317
-
318
- @pytest.mark.asyncio
319
- async def test_safe_tool_call_exhausts_retries(mock_orchestrator):
320
- """Test safe_tool_call returns error after all retries exhausted."""
321
-
322
- failing_tool = AsyncMock(side_effect=Exception("Always fails"))
323
-
324
- reasoning_trace = []
325
- result = await mock_orchestrator.safe_tool_call(
326
- tool_fn=failing_tool,
327
- params={},
328
- max_retries=2,
329
- tool_name="test_tool",
330
- tenant_id="tenant1",
331
- user_id="user1",
332
- reasoning_trace=reasoning_trace
333
- )
334
-
335
- # Should have retried max_retries times
336
- assert failing_tool.call_count == 2
337
- assert "error" in result
338
-
339
- # Verify analytics logged failures
340
- assert mock_orchestrator.analytics.log_tool_usage.called
341
-
342
-
343
- @pytest.mark.asyncio
344
- async def test_safe_tool_call_fallback_params(mock_orchestrator):
345
- """Test safe_tool_call uses fallback params on retry."""
346
-
347
- tool_calls = []
348
-
349
- async def mock_tool_async(**kwargs):
350
- tool_calls.append(kwargs.copy())
351
- if len(tool_calls) == 1:
352
- raise Exception("First attempt failed")
353
- return {"success": True, "params": kwargs}
354
-
355
- result = await mock_orchestrator.safe_tool_call(
356
- tool_fn=mock_tool_async,
357
- params={"param1": "value1"},
358
- max_retries=2,
359
- fallback_params={"param1": "fallback_value"},
360
- tool_name="test_tool",
361
- tenant_id="tenant1"
362
- )
363
-
364
- # Should have used fallback params on retry
365
- assert len(tool_calls) == 2
366
- assert tool_calls[0]["param1"] == "value1" # Original params
367
- assert tool_calls[1]["param1"] == "fallback_value" # Fallback params on retry
368
- assert result["success"] is True
369
-
370
-
371
- # =============================================================
372
- # RULE SAFE MESSAGE TESTS
373
- # =============================================================
374
-
375
- @pytest.mark.asyncio
376
- async def test_rule_safe_message_no_violations(mock_orchestrator):
377
- """Test rule_safe_message returns original when no violations."""
378
-
379
- mock_orchestrator.redflag.check = AsyncMock(return_value=[])
380
-
381
- safe_msg = await mock_orchestrator.rule_safe_message(
382
- user_message="Normal message",
383
- tenant_id="tenant1"
384
- )
385
-
386
- # Should return original message
387
- assert safe_msg == "Normal message"
388
- assert mock_orchestrator.redflag.check.call_count == 1
389
-
390
-
391
- @pytest.mark.asyncio
392
- async def test_rule_safe_message_rewrites_violation(mock_orchestrator):
393
- """Test rule_safe_message rewrites violating messages."""
394
-
395
- # Mock redflag check - first call violates, second (rewritten) passes
396
- violation = RedFlagMatch(
397
- rule_id="1",
398
- pattern="salary",
399
- severity="high",
400
- description="salary access",
401
- matched_text="salary"
402
- )
403
-
404
- mock_orchestrator.redflag.check = AsyncMock(side_effect=[
405
- [violation], # Original message violates
406
- [] # Rewritten message is safe
407
- ])
408
-
409
- mock_orchestrator.llm.simple_call = AsyncMock(
410
- return_value="This is a compliant version of your request about compensation"
411
- )
412
-
413
- reasoning_trace = []
414
- safe_msg = await mock_orchestrator.rule_safe_message(
415
- user_message="I want to see salary info",
416
- tenant_id="tenant1",
417
- reasoning_trace=reasoning_trace
418
- )
419
-
420
- # Should have checked rules twice (original + rewritten)
421
- assert mock_orchestrator.redflag.check.call_count == 2
422
-
423
- # Should have called LLM to rewrite
424
- assert mock_orchestrator.llm.simple_call.called
425
-
426
- # Should return rewritten message
427
- assert "compliant" in safe_msg.lower() or safe_msg != "I want to see salary info"
428
-
429
- # Verify reasoning trace
430
- rewrite_steps = [s for s in reasoning_trace if "rewrite" in str(s).lower()]
431
- assert len(rewrite_steps) > 0
432
-
433
-
434
- @pytest.mark.asyncio
435
- async def test_rule_safe_message_brief_rule_no_rewrite(mock_orchestrator):
436
- """Test rule_safe_message doesn't rewrite brief response rules."""
437
-
438
- # Brief response rules are handled separately, so should return original
439
- brief_rule = RedFlagMatch(
440
- rule_id="1",
441
- pattern="greeting",
442
- severity="low",
443
- description="greeting",
444
- matched_text="hi"
445
- )
446
-
447
- mock_orchestrator.redflag.check = AsyncMock(return_value=[brief_rule])
448
-
449
- safe_msg = await mock_orchestrator.rule_safe_message(
450
- user_message="Hi there",
451
- tenant_id="tenant1"
452
- )
453
-
454
- # Should return original (brief rules are handled elsewhere)
455
- assert safe_msg == "Hi there"
456
-
457
-
458
- @pytest.mark.asyncio
459
- async def test_rule_safe_message_llm_failure_fallback(mock_orchestrator):
460
- """Test rule_safe_message falls back to original if LLM rewrite fails."""
461
-
462
- violation = RedFlagMatch(
463
- rule_id="1",
464
- pattern="blocked",
465
- severity="high",
466
- description="blocked",
467
- matched_text="blocked"
468
- )
469
-
470
- mock_orchestrator.redflag.check = AsyncMock(return_value=[violation])
471
- mock_orchestrator.llm.simple_call = AsyncMock(side_effect=Exception("LLM failed"))
472
-
473
- original_msg = "I want blocked content"
474
- safe_msg = await mock_orchestrator.rule_safe_message(
475
- user_message=original_msg,
476
- tenant_id="tenant1"
477
- )
478
-
479
- # Should return original message if rewrite fails
480
- assert safe_msg == original_msg
481
-
482
-
483
- # =============================================================
484
- # INTEGRATION TESTS
485
- # =============================================================
486
-
487
- @pytest.mark.asyncio
488
- async def test_rag_integration_reasoning_trace(mock_orchestrator):
489
- """Test RAG retry steps appear in reasoning trace."""
490
-
491
- mock_orchestrator.mcp.call_rag = AsyncMock(side_effect=[
492
- {"results": [{"text": "low", "score": 0.20}]},
493
- {"results": [{"text": "better", "score": 0.50}]}
494
- ])
495
-
496
- reasoning_trace = []
497
- await mock_orchestrator.rag_with_repair(
498
- query="test",
499
- tenant_id="tenant1",
500
- reasoning_trace=reasoning_trace,
501
- user_id="user1"
502
- )
503
-
504
- # Check reasoning trace has retry information
505
- trace_str = str(reasoning_trace).lower()
506
- assert "retry" in trace_str or "threshold" in trace_str
507
-
508
-
509
- @pytest.mark.asyncio
510
- async def test_web_integration_reasoning_trace(mock_orchestrator):
511
- """Test web retry steps appear in reasoning trace."""
512
-
513
- mock_orchestrator.mcp.call_web = AsyncMock(side_effect=[
514
- {"results": []},
515
- {"results": [{"title": "Result", "snippet": "Content"}]}
516
- ])
517
-
518
- reasoning_trace = []
519
- await mock_orchestrator.web_with_repair(
520
- query="test",
521
- tenant_id="tenant1",
522
- reasoning_trace=reasoning_trace,
523
- user_id="user1"
524
- )
525
-
526
- # Check reasoning trace has retry information
527
- trace_str = str(reasoning_trace).lower()
528
- assert "retry" in trace_str or "rewritten" in trace_str
529
-
530
-
531
- @pytest.mark.asyncio
532
- async def test_analytics_logging_on_retries(mock_orchestrator):
533
- """Test that retry attempts are logged to analytics."""
534
-
535
- mock_orchestrator.mcp.call_rag = AsyncMock(side_effect=[
536
- {"results": [{"text": "low", "score": 0.25}]},
537
- {"results": [{"text": "better", "score": 0.45}]}
538
- ])
539
-
540
- await mock_orchestrator.rag_with_repair(
541
- query="test",
542
- tenant_id="tenant1",
543
- user_id="user1"
544
- )
545
-
546
- # Verify analytics was called (for initial + retry)
547
- assert mock_orchestrator.analytics.log_tool_usage.call_count > 0
548
-
549
- # Verify RAG search was logged
550
- assert mock_orchestrator.analytics.log_rag_search.called
551
-
552
-
553
- @pytest.mark.asyncio
554
- async def test_full_agent_flow_with_retry(mock_orchestrator):
555
- """Test full agent flow integrates retry system."""
556
-
557
- # Setup mocks for a full agent request
558
- mock_orchestrator.intent = MagicMock()
559
- mock_orchestrator.intent.classify = AsyncMock(return_value="rag")
560
-
561
- mock_orchestrator.selector = MagicMock()
562
- from api.models.agent import AgentDecision
563
- mock_orchestrator.selector.select = AsyncMock(return_value=AgentDecision(
564
- action="call_tool",
565
- tool="rag",
566
- tool_input={"query": "test query"},
567
- reason="test"
568
- ))
569
-
570
- mock_orchestrator.redflag.check = AsyncMock(return_value=[])
571
-
572
- mock_orchestrator.mcp.call_rag = AsyncMock(side_effect=[
573
- {"results": [{"text": "low relevance", "score": 0.25}]},
574
- {"results": [{"text": "better match", "score": 0.50}]}
575
- ])
576
-
577
- mock_orchestrator.llm.simple_call = AsyncMock(return_value="Final answer")
578
-
579
- # Create request
580
- req = AgentRequest(
581
- tenant_id="tenant1",
582
- user_id="user1",
583
- message="test query"
584
- )
585
-
586
- # Handle request
587
- response = await mock_orchestrator.handle(req)
588
-
589
- # Verify retry happened (2 RAG calls)
590
- assert mock_orchestrator.mcp.call_rag.call_count == 2
591
-
592
- # Verify response is generated
593
- assert response.text == "Final answer"
594
-
595
- # Verify reasoning trace contains retry info
596
- trace_str = str(response.reasoning_trace).lower()
597
- # Should have retry or repair related steps
598
-
599
-
600
- # =============================================================
601
- # EDGE CASES
602
- # =============================================================
603
-
604
- @pytest.mark.asyncio
605
- async def test_rag_repair_edge_case_exactly_threshold(mock_orchestrator):
606
- """Test RAG repair behavior at threshold boundary."""
607
-
608
- # Score exactly at threshold - should not retry
609
- mock_orchestrator.mcp.call_rag = AsyncMock(return_value={
610
- "results": [{"text": "content", "score": 0.30}]} # Exactly at threshold
611
- )
612
-
613
- reasoning_trace = []
614
- await mock_orchestrator.rag_with_repair(
615
- query="test",
616
- tenant_id="tenant1",
617
- original_threshold=0.3,
618
- reasoning_trace=reasoning_trace,
619
- user_id="user1"
620
- )
621
-
622
- # Should not retry (score >= 0.30)
623
- assert mock_orchestrator.mcp.call_rag.call_count == 1
624
-
625
-
626
- @pytest.mark.asyncio
627
- async def test_web_repair_all_retries_fail(mock_orchestrator):
628
- """Test web repair handles case where all retries return empty."""
629
-
630
- mock_orchestrator.mcp.call_web = AsyncMock(return_value={"results": []})
631
-
632
- reasoning_trace = []
633
- result = await mock_orchestrator.web_with_repair(
634
- query="very obscure query",
635
- tenant_id="tenant1",
636
- reasoning_trace=reasoning_trace,
637
- user_id="user1"
638
- )
639
-
640
- # Should have attempted retries
641
- assert mock_orchestrator.mcp.call_web.call_count >= 2
642
-
643
- # Should still return result (even if empty)
644
- assert isinstance(result, dict)
645
-
646
-
647
- if __name__ == "__main__":
648
- # Allow running tests directly
649
- print("Running retry system tests...")
650
- pytest.main([__file__, "-v", "--tb=short"])
651
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_tool_metadata_and_routing.py DELETED
@@ -1,585 +0,0 @@
1
- """
2
- Comprehensive tests for:
3
- 1. Per-Tool Latency Prediction
4
- 2. Context-Aware MCP Routing
5
- 3. Tool Output Schemas
6
-
7
- Tests all three new features for intelligent tool selection and output validation.
8
- """
9
-
10
- import pytest
11
- from unittest.mock import Mock, patch, AsyncMock
12
- from backend.api.services.tool_metadata import (
13
- get_tool_latency_estimate,
14
- estimate_path_latency,
15
- get_fastest_path,
16
- validate_tool_output,
17
- get_tool_schema,
18
- TOOL_LATENCY_METADATA,
19
- TOOL_OUTPUT_SCHEMAS
20
- )
21
- from backend.api.services.tool_selector import ToolSelector
22
- from backend.api.services.agent_orchestrator import AgentOrchestrator
23
-
24
-
25
- class TestLatencyPrediction:
26
- """Test per-tool latency prediction"""
27
-
28
- def test_get_tool_latency_estimate_basic(self):
29
- """Test basic latency estimation without context"""
30
- rag_latency = get_tool_latency_estimate("rag")
31
- web_latency = get_tool_latency_estimate("web")
32
- admin_latency = get_tool_latency_estimate("admin")
33
- llm_latency = get_tool_latency_estimate("llm")
34
-
35
- # Check that latencies are within expected ranges
36
- assert 60 <= rag_latency <= 120
37
- assert 400 <= web_latency <= 1800
38
- assert 5 <= admin_latency <= 20
39
- assert 500 <= llm_latency <= 5000
40
-
41
- def test_get_tool_latency_estimate_with_context(self):
42
- """Test latency estimation with context"""
43
- # RAG with long query
44
- rag_long = get_tool_latency_estimate("rag", {"query_length": 200})
45
- rag_short = get_tool_latency_estimate("rag", {"query_length": 10})
46
-
47
- assert rag_long >= rag_short # Longer queries should take more time
48
-
49
- # Web with complexity
50
- web_complex = get_tool_latency_estimate("web", {"query_complexity": "high"})
51
- web_simple = get_tool_latency_estimate("web", {"query_complexity": "low"})
52
-
53
- assert web_complex >= web_simple # Complex queries should take more time
54
-
55
- def test_estimate_path_latency(self):
56
- """Test total latency estimation for tool sequences"""
57
- # Single tool
58
- single = estimate_path_latency(["admin"])
59
- assert single > 0
60
- assert single <= 20
61
-
62
- # Multiple tools
63
- multi = estimate_path_latency(["rag", "web", "llm"])
64
- assert multi > 0
65
- # Should be sum of individual latencies
66
- assert multi >= get_tool_latency_estimate("rag")
67
- assert multi >= get_tool_latency_estimate("web")
68
- assert multi >= get_tool_latency_estimate("llm")
69
-
70
- def test_get_fastest_path(self):
71
- """Test fastest path optimization"""
72
- tools = ["llm", "admin", "rag", "web"]
73
- fastest = get_fastest_path(tools)
74
-
75
- # Should be sorted by latency (fastest first)
76
- assert len(fastest) == len(tools)
77
- assert "admin" in fastest # Fastest tool
78
- assert fastest[0] == "admin" # Should be first
79
-
80
- # Verify order is optimized
81
- latencies = [get_tool_latency_estimate(t) for t in fastest]
82
- assert latencies == sorted(latencies) # Should be in ascending order
83
-
84
- def test_latency_metadata_structure(self):
85
- """Test that latency metadata has correct structure"""
86
- for tool_name, metadata in TOOL_LATENCY_METADATA.items():
87
- assert metadata.tool_name == tool_name
88
- assert metadata.min_ms > 0
89
- assert metadata.max_ms >= metadata.min_ms
90
- assert metadata.avg_ms >= metadata.min_ms
91
- assert metadata.avg_ms <= metadata.max_ms
92
- assert len(metadata.description) > 0
93
-
94
-
95
- class TestToolOutputSchemas:
96
- """Test tool output schema validation"""
97
-
98
- def test_get_tool_schema(self):
99
- """Test schema retrieval"""
100
- rag_schema = get_tool_schema("rag")
101
- web_schema = get_tool_schema("web")
102
- admin_schema = get_tool_schema("admin")
103
- llm_schema = get_tool_schema("llm")
104
-
105
- assert rag_schema is not None
106
- assert web_schema is not None
107
- assert admin_schema is not None
108
- assert llm_schema is not None
109
-
110
- assert rag_schema.tool_name == "rag"
111
- assert web_schema.tool_name == "web"
112
- assert admin_schema.tool_name == "admin"
113
- assert llm_schema.tool_name == "llm"
114
-
115
- def test_validate_rag_output_valid(self):
116
- """Test validation of valid RAG output"""
117
- valid_rag = {
118
- "results": [
119
- {
120
- "text": "Document chunk",
121
- "similarity": 0.85,
122
- "metadata": {"title": "Test"},
123
- "doc_id": "doc123"
124
- }
125
- ],
126
- "query": "test query",
127
- "tenant_id": "tenant1",
128
- "hits_count": 1,
129
- "avg_score": 0.85,
130
- "top_score": 0.85,
131
- "latency_ms": 90
132
- }
133
-
134
- is_valid, error = validate_tool_output("rag", valid_rag)
135
- assert is_valid is True
136
- assert error is None
137
-
138
- def test_validate_rag_output_missing_field(self):
139
- """Test validation catches missing required fields"""
140
- invalid_rag = {
141
- "results": [],
142
- # Missing "query" and "tenant_id"
143
- "hits_count": 0
144
- }
145
-
146
- is_valid, error = validate_tool_output("rag", invalid_rag)
147
- assert is_valid is False
148
- assert "Missing required field" in error
149
-
150
- def test_validate_web_output_valid(self):
151
- """Test validation of valid Web output"""
152
- valid_web = {
153
- "results": [
154
- {
155
- "title": "Result Title",
156
- "snippet": "Result snippet",
157
- "link": "https://example.com",
158
- "displayLink": "example.com"
159
- }
160
- ],
161
- "query": "search query",
162
- "total_results": 10,
163
- "latency_ms": 800
164
- }
165
-
166
- is_valid, error = validate_tool_output("web", valid_web)
167
- assert is_valid is True
168
- assert error is None
169
-
170
- def test_validate_admin_output_valid(self):
171
- """Test validation of valid Admin output"""
172
- valid_admin = {
173
- "violations": [
174
- {
175
- "rule_id": "rule1",
176
- "rule_pattern": ".*password.*",
177
- "severity": "high",
178
- "matched_text": "password",
179
- "confidence": 0.95,
180
- "message_preview": "User asked for password"
181
- }
182
- ],
183
- "checked": True,
184
- "rules_count": 5,
185
- "latency_ms": 10
186
- }
187
-
188
- is_valid, error = validate_tool_output("admin", valid_admin)
189
- assert is_valid is True
190
- assert error is None
191
-
192
- def test_validate_llm_output_valid(self):
193
- """Test validation of valid LLM output"""
194
- valid_llm = {
195
- "text": "Generated response",
196
- "tokens_used": 150,
197
- "latency_ms": 2000,
198
- "model": "llama3.1:latest",
199
- "temperature": 0.0
200
- }
201
-
202
- is_valid, error = validate_tool_output("llm", valid_llm)
203
- assert is_valid is True
204
- assert error is None
205
-
206
- def test_validate_type_mismatch(self):
207
- """Test validation catches type mismatches"""
208
- invalid_rag = {
209
- "results": "not an array", # Should be array
210
- "query": "test",
211
- "tenant_id": "tenant1"
212
- }
213
-
214
- is_valid, error = validate_tool_output("rag", invalid_rag)
215
- assert is_valid is False
216
- assert "must be array" in error
217
-
218
- def test_schema_examples(self):
219
- """Test that all schemas have examples"""
220
- for tool_name, schema in TOOL_OUTPUT_SCHEMAS.items():
221
- assert schema.example is not None
222
- assert isinstance(schema.example, dict)
223
- # Example should be valid
224
- is_valid, error = validate_tool_output(tool_name, schema.example)
225
- assert is_valid is True, f"Schema example for {tool_name} is invalid: {error}"
226
-
227
-
228
- class TestContextAwareRouting:
229
- """Test context-aware MCP routing"""
230
-
231
- @pytest.fixture
232
- def tool_selector(self):
233
- """Create a ToolSelector instance"""
234
- return ToolSelector(llm_client=None)
235
-
236
- def test_analyze_context_rag_high_score(self, tool_selector):
237
- """Test context analysis when RAG returns high score"""
238
- rag_results = [
239
- {"similarity": 0.85, "text": "High quality result"},
240
- {"similarity": 0.90, "text": "Another high quality result"}
241
- ]
242
- memory = []
243
- admin_violations = []
244
- tool_scores = {"rag_fitness": 0.8, "web_fitness": 0.5}
245
-
246
- hints = tool_selector._analyze_context(rag_results, memory, admin_violations, tool_scores)
247
-
248
- assert hints.get("skip_web_if_rag_high") is True
249
- assert hints.get("rag_high_confidence") is True
250
-
251
- def test_analyze_context_rag_low_score(self, tool_selector):
252
- """Test context analysis when RAG returns low score"""
253
- rag_results = [
254
- {"similarity": 0.3, "text": "Low quality result"}
255
- ]
256
- memory = []
257
- admin_violations = []
258
- tool_scores = {"rag_fitness": 0.3, "web_fitness": 0.7}
259
-
260
- hints = tool_selector._analyze_context(rag_results, memory, admin_violations, tool_scores)
261
-
262
- # Should not skip web if RAG score is low
263
- assert hints.get("skip_web_if_rag_high") is not True
264
-
265
- def test_analyze_context_memory_relevant(self, tool_selector):
266
- """Test context analysis when relevant memory exists"""
267
- rag_results = []
268
- memory = [
269
- {
270
- "tool": "rag",
271
- "result": {
272
- "results": [
273
- {"similarity": 0.80, "text": "Recent RAG result"}
274
- ]
275
- }
276
- }
277
- ]
278
- admin_violations = []
279
- tool_scores = {}
280
-
281
- hints = tool_selector._analyze_context(rag_results, memory, admin_violations, tool_scores)
282
-
283
- assert hints.get("has_relevant_memory") is True
284
- # Should suggest skipping RAG if memory is recent and high quality
285
- if memory[0]["result"]["results"][0]["similarity"] >= 0.75:
286
- assert hints.get("skip_rag_if_memory") is True
287
-
288
- def test_analyze_context_admin_critical(self, tool_selector):
289
- """Test context analysis when admin violation is critical"""
290
- rag_results = []
291
- memory = []
292
- admin_violations = [
293
- {
294
- "severity": "critical",
295
- "rule_id": "rule1",
296
- "matched_text": "sensitive data"
297
- }
298
- ]
299
- tool_scores = {}
300
-
301
- hints = tool_selector._analyze_context(rag_results, memory, admin_violations, tool_scores)
302
-
303
- assert hints.get("skip_agent_reasoning") is True
304
- assert hints.get("critical_violation") is True
305
-
306
- def test_analyze_context_admin_low_severity(self, tool_selector):
307
- """Test context analysis when admin violation is low severity"""
308
- rag_results = []
309
- memory = []
310
- admin_violations = [
311
- {
312
- "severity": "low",
313
- "rule_id": "rule1",
314
- "matched_text": "minor issue"
315
- }
316
- ]
317
- tool_scores = {}
318
-
319
- hints = tool_selector._analyze_context(rag_results, memory, admin_violations, tool_scores)
320
-
321
- # Low severity should not skip reasoning
322
- assert hints.get("skip_agent_reasoning") is not True
323
-
324
- @pytest.mark.asyncio
325
- async def test_tool_selection_with_context_hints(self, tool_selector):
326
- """Test tool selection uses context hints"""
327
- # Mock LLM client
328
- tool_selector.llm_client = AsyncMock()
329
-
330
- # Context with high RAG score
331
- ctx = {
332
- "tenant_id": "test_tenant",
333
- "rag_results": [
334
- {"similarity": 0.85, "text": "High quality result"}
335
- ],
336
- "tool_scores": {
337
- "rag_fitness": 0.8,
338
- "web_fitness": 0.6,
339
- "llm_only": 0.3
340
- },
341
- "memory": [],
342
- "admin_violations": []
343
- }
344
-
345
- decision = await tool_selector.select("general", "What is our company policy?", ctx)
346
-
347
- # Should include latency estimates in reason
348
- assert "latency" in decision.reason.lower() or "est." in decision.reason.lower()
349
-
350
- # Check that steps have latency estimates (for non-LLM tools)
351
- if decision.tool_input and "steps" in decision.tool_input:
352
- steps = decision.tool_input["steps"]
353
- for step in steps:
354
- if isinstance(step, dict) and "input" in step and step.get("tool") != "llm":
355
- # Non-LLM tools should have estimated latency (or be parallel)
356
- assert "_estimated_latency_ms" in step["input"] or "parallel" in step or step.get("tool") == "llm"
357
-
358
- @pytest.mark.asyncio
359
- async def test_tool_selection_skips_web_on_high_rag(self, tool_selector):
360
- """Test that tool selection skips web when RAG has high score"""
361
- tool_selector.llm_client = AsyncMock()
362
-
363
- ctx = {
364
- "tenant_id": "test_tenant",
365
- "rag_results": [
366
- {"similarity": 0.90, "text": "Very high quality result"}
367
- ],
368
- "tool_scores": {
369
- "rag_fitness": 0.9,
370
- "web_fitness": 0.7,
371
- "llm_only": 0.2
372
- },
373
- "memory": [],
374
- "admin_violations": []
375
- }
376
-
377
- decision = await tool_selector.select("general", "What is our internal policy?", ctx)
378
-
379
- # Check reason includes context hint
380
- assert "skip web" in decision.reason.lower() or "rag high" in decision.reason.lower() or "context" in decision.reason.lower()
381
-
382
- @pytest.mark.asyncio
383
- async def test_tool_selection_admin_critical_skip_reasoning(self, tool_selector):
384
- """Test that tool selection skips reasoning for critical admin violations"""
385
- tool_selector.llm_client = None # No LLM needed for admin-only path
386
-
387
- ctx = {
388
- "tenant_id": "test_tenant",
389
- "rag_results": [],
390
- "tool_scores": {},
391
- "memory": [],
392
- "admin_violations": [
393
- {
394
- "severity": "critical",
395
- "rule_id": "rule1",
396
- "matched_text": "critical violation"
397
- }
398
- ]
399
- }
400
-
401
- decision = await tool_selector.select("admin", "User trying to access sensitive data", ctx)
402
-
403
- # Should skip LLM reasoning for critical violations
404
- if decision.tool_input and "steps" in decision.tool_input:
405
- steps = decision.tool_input["steps"]
406
- # Should have admin step but may skip LLM
407
- has_admin = any(s.get("tool") == "admin" for s in steps if isinstance(s, dict))
408
- assert has_admin
409
-
410
-
411
- class TestOrchestratorIntegration:
412
- """Test orchestrator integration with new features"""
413
-
414
- @pytest.fixture
415
- def orchestrator(self):
416
- """Create an AgentOrchestrator instance"""
417
- return AgentOrchestrator(
418
- rag_mcp_url="http://localhost:8900/rag",
419
- web_mcp_url="http://localhost:8900/web",
420
- admin_mcp_url="http://localhost:8900/admin",
421
- llm_backend="ollama"
422
- )
423
-
424
- def test_format_rag_output(self, orchestrator):
425
- """Test RAG output formatting"""
426
- raw_output = {
427
- "results": [
428
- {"text": "Chunk 1", "similarity": 0.85},
429
- {"text": "Chunk 2", "similarity": 0.75}
430
- ],
431
- "query": "test query"
432
- }
433
-
434
- formatted = orchestrator._format_tool_output("rag", raw_output, 90)
435
-
436
- # Check schema compliance
437
- assert "results" in formatted
438
- assert "query" in formatted
439
- assert "tenant_id" in formatted
440
- assert "hits_count" in formatted
441
- assert "avg_score" in formatted
442
- assert "top_score" in formatted
443
- assert "latency_ms" in formatted
444
-
445
- # Validate against schema
446
- is_valid, error = validate_tool_output("rag", formatted)
447
- assert is_valid is True, f"Formatted RAG output invalid: {error}"
448
-
449
- def test_format_web_output(self, orchestrator):
450
- """Test Web output formatting"""
451
- raw_output = {
452
- "items": [
453
- {
454
- "title": "Result Title",
455
- "snippet": "Result snippet",
456
- "link": "https://example.com"
457
- }
458
- ]
459
- }
460
-
461
- formatted = orchestrator._format_tool_output("web", raw_output, 800)
462
-
463
- # Check schema compliance
464
- assert "results" in formatted
465
- assert "query" in formatted
466
- assert "total_results" in formatted
467
- assert "latency_ms" in formatted
468
-
469
- # Validate against schema
470
- is_valid, error = validate_tool_output("web", formatted)
471
- assert is_valid is True, f"Formatted Web output invalid: {error}"
472
-
473
- def test_format_admin_output(self, orchestrator):
474
- """Test Admin output formatting"""
475
- raw_output = {
476
- "matches": [
477
- {
478
- "rule_id": "rule1",
479
- "pattern": ".*password.*",
480
- "severity": "high",
481
- "text": "password",
482
- "confidence": 0.95
483
- }
484
- ]
485
- }
486
-
487
- formatted = orchestrator._format_tool_output("admin", raw_output, 10)
488
-
489
- # Check schema compliance
490
- assert "violations" in formatted
491
- assert "checked" in formatted
492
- assert "rules_count" in formatted
493
- assert "latency_ms" in formatted
494
-
495
- # Validate against schema
496
- is_valid, error = validate_tool_output("admin", formatted)
497
- assert is_valid is True, f"Formatted Admin output invalid: {error}"
498
-
499
- def test_format_llm_output(self, orchestrator):
500
- """Test LLM output formatting"""
501
- raw_output = "This is a generated response from the LLM."
502
-
503
- formatted = orchestrator._format_tool_output("llm", raw_output, 2000)
504
-
505
- # Check schema compliance
506
- assert "text" in formatted
507
- assert "tokens_used" in formatted
508
- assert "latency_ms" in formatted
509
- assert "model" in formatted
510
- assert "temperature" in formatted
511
-
512
- # Validate against schema
513
- is_valid, error = validate_tool_output("llm", formatted)
514
- assert is_valid is True, f"Formatted LLM output invalid: {error}"
515
-
516
- def test_format_output_handles_missing_fields(self, orchestrator):
517
- """Test output formatting handles missing fields gracefully"""
518
- # Minimal RAG output
519
- minimal = {"results": []}
520
-
521
- formatted = orchestrator._format_tool_output("rag", minimal, 90)
522
-
523
- # Should have all required fields with defaults
524
- assert "query" in formatted
525
- assert "tenant_id" in formatted
526
- assert "hits_count" in formatted
527
- assert formatted["hits_count"] == 0
528
-
529
-
530
- class TestEndToEndRouting:
531
- """End-to-end tests for context-aware routing"""
532
-
533
- @pytest.mark.asyncio
534
- async def test_routing_with_high_rag_score(self):
535
- """Test that high RAG score prevents web search"""
536
- selector = ToolSelector(llm_client=None)
537
-
538
- ctx = {
539
- "tenant_id": "test",
540
- "rag_results": [{"similarity": 0.92, "text": "Perfect match"}],
541
- "tool_scores": {"rag_fitness": 0.9, "web_fitness": 0.7},
542
- "memory": [],
543
- "admin_violations": []
544
- }
545
-
546
- decision = await selector.select("general", "What is our policy?", ctx)
547
-
548
- # Check that context hints are applied
549
- if decision.tool_input and "steps" in decision.tool_input:
550
- steps = decision.tool_input["steps"]
551
- tool_names = [s.get("tool") for s in steps if isinstance(s, dict) and "tool" in s]
552
-
553
- # Should have RAG but may skip web due to high score
554
- assert "rag" in tool_names or "llm" in tool_names
555
-
556
- @pytest.mark.asyncio
557
- async def test_routing_with_memory(self):
558
- """Test that relevant memory prevents redundant RAG call"""
559
- selector = ToolSelector(llm_client=None)
560
-
561
- ctx = {
562
- "tenant_id": "test",
563
- "rag_results": [],
564
- "tool_scores": {"rag_fitness": 0.6},
565
- "memory": [
566
- {
567
- "tool": "rag",
568
- "result": {
569
- "results": [{"similarity": 0.85, "text": "Recent result"}]
570
- }
571
- }
572
- ],
573
- "admin_violations": []
574
- }
575
-
576
- decision = await selector.select("general", "Tell me about our policy", ctx)
577
-
578
- # Context should be analyzed
579
- # (Actual behavior depends on implementation, but should use memory)
580
- assert decision is not None
581
-
582
-
583
- if __name__ == "__main__":
584
- pytest.main([__file__, "-v", "--tb=short"])
585
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/workers/placeholder.txt DELETED
@@ -1,4 +0,0 @@
1
- This directory contains Celery worker tasks for async processing.
2
- For the Hugging Face Space submission, only placeholder files are included.
3
- The full worker implementation exists separately.
4
-
 
 
 
 
 
check_env.py DELETED
@@ -1,106 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Simple script to check Supabase environment variables
4
- """
5
-
6
- import os
7
- import sys
8
- from pathlib import Path
9
- from dotenv import load_dotenv
10
-
11
- # Load .env file
12
- load_dotenv()
13
-
14
- print("=" * 70)
15
- print("Supabase Environment Variables Check")
16
- print("=" * 70)
17
- print()
18
-
19
- # Check SUPABASE_URL
20
- supabase_url = os.getenv("SUPABASE_URL")
21
- if supabase_url:
22
- print(f"[OK] SUPABASE_URL is set")
23
- print(f" Value: {supabase_url}")
24
- if not supabase_url.startswith("https://"):
25
- print(f" [WARNING] URL should start with https://")
26
- if ".supabase.co" not in supabase_url:
27
- print(f" [WARNING] URL should contain .supabase.co")
28
- else:
29
- print("[ERROR] SUPABASE_URL is NOT set")
30
- print(" Required for Supabase integration")
31
-
32
- print()
33
-
34
- # Check SUPABASE_SERVICE_KEY
35
- supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
36
- if supabase_key:
37
- key_length = len(supabase_key)
38
- print(f"[OK] SUPABASE_SERVICE_KEY is set")
39
- print(f" Length: {key_length} characters")
40
-
41
- if key_length < 100:
42
- print(f" [ERROR] Key is too short ({key_length} chars)")
43
- print(f" Expected: 200+ characters")
44
- print(f" This looks like an 'anon' key, not 'service_role' key!")
45
- print(f" Get the correct key from:")
46
- print(f" Supabase Dashboard -> Settings -> API -> service_role key")
47
- elif key_length < 200:
48
- print(f" [WARNING] Key might be incomplete ({key_length} chars)")
49
- print(f" Expected: 200+ characters")
50
- else:
51
- print(f" [OK] Key length looks correct ({key_length} chars)")
52
-
53
- # Check if it starts with eyJ (JWT token format)
54
- if supabase_key.startswith("eyJ"):
55
- print(f" [OK] Key format looks correct (JWT token)")
56
- else:
57
- print(f" [WARNING] Key doesn't start with 'eyJ' (unusual for JWT)")
58
-
59
- # Show first and last few characters (masked)
60
- if key_length > 20:
61
- masked = supabase_key[:10] + "..." + supabase_key[-10:]
62
- print(f" Preview: {masked}")
63
- else:
64
- print("[ERROR] SUPABASE_SERVICE_KEY is NOT set")
65
- print(" Required for Supabase integration")
66
- print(" Get it from: Supabase Dashboard -> Settings -> API -> service_role key")
67
-
68
- print()
69
-
70
- # Check POSTGRESQL_URL (optional)
71
- postgres_url = os.getenv("POSTGRESQL_URL")
72
- if postgres_url:
73
- print(f"[INFO] POSTGRESQL_URL is set (optional, for migrations)")
74
- if len(postgres_url) > 50:
75
- masked = postgres_url[:30] + "..." + postgres_url[-20:]
76
- print(f" Value: {masked}")
77
- else:
78
- print(f" Value: {postgres_url}")
79
- else:
80
- print("[INFO] POSTGRESQL_URL is not set (optional, only needed for migrations)")
81
-
82
- print()
83
- print("=" * 70)
84
- print("Summary")
85
- print("=" * 70)
86
-
87
- has_url = bool(supabase_url)
88
- has_key = bool(supabase_key)
89
- key_valid = has_key and len(supabase_key) >= 200
90
-
91
- if has_url and has_key and key_valid:
92
- print("[SUCCESS] Supabase environment variables are correctly configured!")
93
- print(" Your data should upload to Supabase automatically.")
94
- elif has_url and has_key:
95
- print("[WARNING] Supabase URL and key are set, but key appears invalid.")
96
- print(" Check that you're using the 'service_role' key (not 'anon' key).")
97
- elif has_url or has_key:
98
- print("[ERROR] Supabase configuration is incomplete.")
99
- print(" Both SUPABASE_URL and SUPABASE_SERVICE_KEY must be set.")
100
- else:
101
- print("[ERROR] Supabase is not configured.")
102
- print(" Set SUPABASE_URL and SUPABASE_SERVICE_KEY in your .env file.")
103
-
104
- print()
105
- print("=" * 70)
106
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
check_rag_database.py DELETED
@@ -1,125 +0,0 @@
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_server.common.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_server.common.embeddings import embed_text
69
- from mcp_server.common.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
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
check_rules_db.py DELETED
@@ -1,43 +0,0 @@
1
- """
2
- Quick script to check if admin rules are saved in the database
3
- """
4
- import sqlite3
5
- from pathlib import Path
6
-
7
- db_path = Path("data/admin_rules.db")
8
-
9
- if db_path.exists():
10
- print(f"✅ Database found at: {db_path}")
11
- print("\n" + "="*60)
12
-
13
- conn = sqlite3.connect(db_path)
14
- conn.row_factory = sqlite3.Row
15
- cursor = conn.cursor()
16
-
17
- # Get all rules
18
- cursor.execute("SELECT * FROM admin_rules ORDER BY created_at DESC")
19
- rules = cursor.fetchall()
20
-
21
- if rules:
22
- print(f"📋 Found {len(rules)} rule(s) in database:\n")
23
- for rule in rules:
24
- print(f"Tenant: {rule['tenant_id']}")
25
- print(f"Rule: {rule['rule']}")
26
- print(f"Pattern: {rule['pattern'] or 'N/A'}")
27
- print(f"Severity: {rule['severity']}")
28
- print(f"Enabled: {rule['enabled']}")
29
- print(f"Created: {rule['created_at']}")
30
- print("-" * 60)
31
- else:
32
- print("⚠️ No rules found in database.")
33
- print(" Add rules via the Gradio UI or API to populate the database.")
34
-
35
- conn.close()
36
- else:
37
- print(f"❌ Database not found at: {db_path}")
38
- print(" The database will be created automatically when you add your first rule.")
39
- print("\n💡 To add rules:")
40
- print(" 1. Open Gradio UI (python app.py)")
41
- print(" 2. Go to 'Admin Rules & Compliance' tab")
42
- print(" 3. Add rules in the text box and click 'Upload / Append Rules'")
43
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
check_supabase_rules.py DELETED
@@ -1,132 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Quick script to verify Supabase rules storage is working.
4
- Run this to check if rules are being saved to Supabase.
5
- """
6
-
7
- import os
8
- import sys
9
- from pathlib import Path
10
-
11
- # Load environment variables from .env file
12
- from dotenv import load_dotenv
13
- load_dotenv()
14
-
15
- # Add backend to path
16
- backend_dir = Path(__file__).resolve().parent
17
- sys.path.insert(0, str(backend_dir))
18
-
19
- from backend.api.storage.rules_store import RulesStore
20
-
21
-
22
- def main():
23
- print("=" * 60)
24
- print("Supabase Rules Storage Verification")
25
- print("=" * 60)
26
-
27
- # Check environment variables
28
- supabase_url = os.getenv("SUPABASE_URL")
29
- supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
30
-
31
- print("\n1. Checking Environment Variables:")
32
- if supabase_url:
33
- print(f" ✅ SUPABASE_URL is set: {supabase_url[:50]}...")
34
- else:
35
- print(" ❌ SUPABASE_URL is not set")
36
- print(" Add it to your .env file: SUPABASE_URL=https://your-project.supabase.co")
37
-
38
- if supabase_key:
39
- print(f" ✅ SUPABASE_SERVICE_KEY is set: {supabase_key[:20]}...")
40
- else:
41
- print(" ❌ SUPABASE_SERVICE_KEY is not set")
42
- print(" Add it to your .env file: SUPABASE_SERVICE_KEY=your_service_role_key")
43
-
44
- if not supabase_url or not supabase_key:
45
- print("\n⚠️ Supabase credentials are missing!")
46
- print(" Rules will be saved to SQLite instead.")
47
- print(" See SUPABASE_SETUP.md for setup instructions.")
48
- print("\n To use Supabase:")
49
- print(" 1. Add SUPABASE_URL and SUPABASE_SERVICE_KEY to your .env file")
50
- print(" 2. Create the admin_rules table in Supabase (see supabase_admin_rules_table.sql)")
51
- print(" 3. Restart your application")
52
- return
53
-
54
- # Initialize RulesStore
55
- print("\n2. Initializing RulesStore:")
56
- try:
57
- store = RulesStore(auto_create_table=True)
58
- print(f" ✅ RulesStore initialized")
59
- print(f" 📦 Using Supabase: {store.use_supabase}")
60
-
61
- if not store.use_supabase:
62
- print(" ⚠️ RulesStore is using SQLite, not Supabase!")
63
- print(" Check that:")
64
- print(" - SUPABASE_URL and SUPABASE_SERVICE_KEY are correct")
65
- print(" - Supabase Python client is installed: pip install supabase")
66
- return
67
-
68
- except Exception as e:
69
- print(f" ❌ Failed to initialize RulesStore: {e}")
70
- return
71
-
72
- # Test adding a rule
73
- print("\n3. Testing Rule Storage:")
74
- test_tenant = "test_verification"
75
- test_rule = "Test rule for Supabase verification"
76
-
77
- try:
78
- # Delete test rule if it exists
79
- store.delete_rule(test_tenant, test_rule)
80
-
81
- # Add test rule
82
- success = store.add_rule(
83
- test_tenant,
84
- test_rule,
85
- severity="medium",
86
- description="Verification test rule"
87
- )
88
-
89
- if success:
90
- print(f" ✅ Successfully added test rule to Supabase")
91
- else:
92
- print(f" ❌ Failed to add rule to Supabase")
93
- return
94
-
95
- # Retrieve rule
96
- rules = store.get_rules(test_tenant)
97
- if test_rule in rules:
98
- print(f" ✅ Successfully retrieved rule from Supabase")
99
- print(f" 📋 Found {len(rules)} rule(s) for tenant '{test_tenant}'")
100
- else:
101
- print(f" ❌ Rule not found after adding")
102
- return
103
-
104
- # Get detailed rules
105
- detailed_rules = store.get_rules_detailed(test_tenant)
106
- if detailed_rules:
107
- print(f" ✅ Successfully retrieved detailed rules")
108
- for rule in detailed_rules:
109
- if rule['rule'] == test_rule:
110
- print(f" 📝 Rule details:")
111
- print(f" - Pattern: {rule.get('pattern', 'N/A')}")
112
- print(f" - Severity: {rule.get('severity', 'N/A')}")
113
- print(f" - Enabled: {rule.get('enabled', 'N/A')}")
114
-
115
- # Cleanup test rule
116
- store.delete_rule(test_tenant, test_rule)
117
- print(f" 🧹 Cleaned up test rule")
118
-
119
- except Exception as e:
120
- print(f" ❌ Error during test: {e}")
121
- import traceback
122
- traceback.print_exc()
123
- return
124
-
125
- print("\n" + "=" * 60)
126
- print("✅ All checks passed! Rules are being saved to Supabase.")
127
- print("=" * 60)
128
-
129
-
130
- if __name__ == "__main__":
131
- main()
132
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
create_supabase_table.py DELETED
@@ -1,185 +0,0 @@
1
- """
2
- Create admin_rules table in Supabase programmatically.
3
- This script uses the Supabase Python client to set up the table.
4
- """
5
-
6
- import os
7
- import sys
8
- from pathlib import Path
9
- from dotenv import load_dotenv
10
-
11
- load_dotenv()
12
-
13
- def create_table_using_supabase_client():
14
- """
15
- Create the admin_rules table using Supabase client.
16
- Since Supabase doesn't allow direct SQL execution via REST API,
17
- we'll use a workaround or provide clear instructions.
18
- """
19
- supabase_url = os.getenv("SUPABASE_URL")
20
- supabase_key = os.getenv("SUPABASE_SERVICE_KEY")
21
-
22
- if not supabase_url or not supabase_key:
23
- print("❌ Missing Supabase credentials!")
24
- print(" Set SUPABASE_URL and SUPABASE_SERVICE_KEY in .env file")
25
- return False
26
-
27
- try:
28
- from supabase import create_client
29
- import httpx
30
-
31
- print("🔗 Connecting to Supabase...")
32
- client = create_client(supabase_url, supabase_key)
33
-
34
- # Read SQL from file
35
- sql_file = Path(__file__).parent / "supabase_admin_rules_table.sql"
36
- if not sql_file.exists():
37
- print(f"❌ SQL file not found: {sql_file}")
38
- return False
39
-
40
- with open(sql_file, "r", encoding="utf-8") as f:
41
- sql_content = f.read()
42
-
43
- print("📝 Attempting to create table via Supabase API...")
44
-
45
- # Method 1: Try using Supabase Management API (if available)
46
- # This requires the project to have pg_net extension enabled
47
- try:
48
- # Use the REST API to execute SQL via a custom function
49
- # First, check if we can use the SQL execution endpoint
50
- response = httpx.post(
51
- f"{supabase_url}/rest/v1/rpc/exec_sql",
52
- headers={
53
- "apikey": supabase_key,
54
- "Authorization": f"Bearer {supabase_key}",
55
- "Content-Type": "application/json",
56
- "Prefer": "return=representation"
57
- },
58
- json={"query": sql_content},
59
- timeout=30
60
- )
61
-
62
- if response.status_code in [200, 201, 204]:
63
- print("✅ Table created successfully via API!")
64
- return True
65
- else:
66
- print(f"⚠️ API method returned: {response.status_code}")
67
- print(f" Response: {response.text[:200]}")
68
- except Exception as e:
69
- print(f"⚠️ API method failed: {e}")
70
-
71
- # Method 2: Try using Supabase Python client's table operations
72
- # This won't work for DDL, but we can verify if table exists
73
- print("\n🔍 Checking if table already exists...")
74
- try:
75
- result = client.table("admin_rules").select("id").limit(1).execute()
76
- print("✅ Table 'admin_rules' already exists!")
77
- return True
78
- except Exception as e:
79
- error_str = str(e).lower()
80
- if "relation" in error_str or "does not exist" in error_str:
81
- print("⚠️ Table does not exist yet.")
82
- else:
83
- print(f"⚠️ Error checking table: {e}")
84
-
85
- # Method 3: Since direct SQL execution isn't supported, show instructions
86
- print("\n" + "=" * 70)
87
- print("📋 MANUAL SETUP REQUIRED")
88
- print("=" * 70)
89
- print("\nSupabase doesn't allow programmatic SQL execution for security.")
90
- print("Please run the SQL manually in Supabase Dashboard:\n")
91
- print("1. Go to: https://app.supabase.com")
92
- print("2. Select your project")
93
- print("3. Click 'SQL Editor' (left sidebar)")
94
- print("4. Click 'New query'")
95
- print("5. Copy the SQL below and paste it:")
96
- print("\n" + "-" * 70)
97
- print(sql_content)
98
- print("-" * 70)
99
- print("\n6. Click 'Run' button (or press Ctrl+Enter)")
100
- print("7. Wait for success confirmation")
101
- print("\n✅ After running, the table will be created automatically!")
102
-
103
- return False
104
-
105
- except ImportError:
106
- print("❌ Supabase client not installed")
107
- print(" Run: pip install supabase")
108
- return False
109
- except Exception as e:
110
- print(f"❌ Error: {e}")
111
- import traceback
112
- traceback.print_exc()
113
- return False
114
-
115
-
116
- def create_table_via_psql():
117
- """
118
- Alternative: Use psql (PostgreSQL client) to execute SQL directly.
119
- This requires POSTGRESQL_URL to be set.
120
- """
121
- postgres_url = os.getenv("POSTGRESQL_URL")
122
- if not postgres_url:
123
- print("⚠️ POSTGRESQL_URL not set, skipping psql method")
124
- return False
125
-
126
- sql_file = Path(__file__).parent / "supabase_admin_rules_table.sql"
127
- if not sql_file.exists():
128
- return False
129
-
130
- try:
131
- import subprocess
132
- print("📝 Attempting to create table via psql...")
133
-
134
- # Execute SQL using psql
135
- result = subprocess.run(
136
- ["psql", postgres_url, "-f", str(sql_file)],
137
- capture_output=True,
138
- text=True,
139
- timeout=30
140
- )
141
-
142
- if result.returncode == 0:
143
- print("✅ Table created successfully via psql!")
144
- return True
145
- else:
146
- print(f"⚠️ psql failed: {result.stderr}")
147
- return False
148
- except FileNotFoundError:
149
- print("⚠️ psql not found in PATH")
150
- return False
151
- except Exception as e:
152
- print(f"⚠️ psql method failed: {e}")
153
- return False
154
-
155
-
156
- if __name__ == "__main__":
157
- print("=" * 70)
158
- print("Supabase Admin Rules Table Creator")
159
- print("=" * 70)
160
- print()
161
-
162
- # Try Method 1: Supabase client
163
- success = create_table_using_supabase_client()
164
-
165
- if not success:
166
- # Try Method 2: psql (if available)
167
- print("\n" + "=" * 70)
168
- print("Trying alternative method: psql")
169
- print("=" * 70)
170
- success = create_table_via_psql()
171
-
172
- if success:
173
- print("\n" + "=" * 70)
174
- print("✅ SUCCESS!")
175
- print("=" * 70)
176
- print("\nThe admin_rules table has been created in Supabase.")
177
- print("RulesStore will now use Supabase instead of SQLite.")
178
- else:
179
- print("\n" + "=" * 70)
180
- print("📝 Manual Setup Required")
181
- print("=" * 70)
182
- print("\nPlease run the SQL manually in Supabase SQL Editor.")
183
- print("The SQL script is ready in: supabase_admin_rules_table.sql")
184
- print("\nAfter creating the table, RulesStore will automatically use Supabase.")
185
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
create_supabase_table_simple.py DELETED
@@ -1,70 +0,0 @@
1
- """
2
- Simple script to create admin_rules table in Supabase.
3
- This uses the Supabase Management API or direct SQL execution.
4
- """
5
-
6
- import os
7
- from dotenv import load_dotenv
8
- import httpx
9
- import json
10
-
11
- load_dotenv()
12
-
13
- SUPABASE_URL = os.getenv("SUPABASE_URL")
14
- SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY")
15
-
16
- if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
17
- print("❌ Missing Supabase credentials!")
18
- print(" Set SUPABASE_URL and SUPABASE_SERVICE_KEY in .env file")
19
- exit(1)
20
-
21
- # Read the SQL file
22
- sql_file = Path("supabase_admin_rules_table.sql")
23
- if not sql_file.exists():
24
- print(f"❌ SQL file not found: {sql_file}")
25
- exit(1)
26
-
27
- with open(sql_file, "r") as f:
28
- sql_content = f.read()
29
-
30
- print("🔗 Connecting to Supabase...")
31
- print(f" URL: {SUPABASE_URL[:50]}...")
32
-
33
- # Method 1: Try using Supabase REST API with SQL execution
34
- # Note: This requires the pg_net extension or a custom function
35
- # Most Supabase projects don't allow direct SQL execution via REST API
36
-
37
- # Method 2: Use Supabase Python client to execute via RPC
38
- try:
39
- from supabase import create_client
40
-
41
- client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
42
-
43
- # Split SQL into individual statements
44
- statements = [s.strip() for s in sql_content.split(";") if s.strip() and not s.strip().startswith("--")]
45
-
46
- print(f"\n📝 Executing {len(statements)} SQL statements...")
47
-
48
- # Execute each statement
49
- # Note: Supabase Python client doesn't support direct SQL execution
50
- # We'll need to use a workaround or manual execution
51
-
52
- print("\n⚠️ Direct SQL execution via Python client is not supported.")
53
- print(" Supabase requires SQL to be executed via the SQL Editor.")
54
- print("\n📋 Please follow these steps:")
55
- print(" 1. Go to: https://app.supabase.com")
56
- print(" 2. Select your project")
57
- print(" 3. Click 'SQL Editor' in the left sidebar")
58
- print(" 4. Click 'New query'")
59
- print(" 5. Copy the contents of: supabase_admin_rules_table.sql")
60
- print(" 6. Paste into the SQL Editor")
61
- print(" 7. Click 'Run' (or press Ctrl+Enter)")
62
- print("\n✅ After running the SQL, the table will be created!")
63
-
64
- except ImportError:
65
- print("❌ Supabase client not installed")
66
- print(" Run: pip install supabase")
67
- except Exception as e:
68
- print(f"❌ Error: {e}")
69
- print("\n💡 Manual setup required - see instructions above")
70
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
createingdummydata.py DELETED
@@ -1,44 +0,0 @@
1
- from docx import Document
2
-
3
- # Dummy data
4
- data = {
5
- "Day": ["Day 1", "Day 2", "Day 3", "Day 4", "Day 5"],
6
- "Breakfast": [
7
- "Oatmeal with sliced bananas and honey",
8
- "Scrambled eggs with toast and orange juice",
9
- "Greek yogurt with granola and berries",
10
- "Pancakes with maple syrup and strawberries",
11
- "Smoothie (spinach, banana, yogurt, almond milk)"
12
- ],
13
- "Lunch": [
14
- "Grilled chicken salad with mixed greens and vinaigrette",
15
- "Turkey sandwich with lettuce, tomato, and chips",
16
- "Vegetable soup with whole-grain roll",
17
- "Tuna salad wrap with carrot sticks",
18
- "Caesar salad with grilled shrimp"
19
- ],
20
- "Dinner": [
21
- "Spaghetti with marinara sauce and garlic bread",
22
- "Baked salmon with steamed broccoli and rice",
23
- "Beef stir-fry with mixed vegetables and noodles",
24
- "Chicken curry with basmati rice",
25
- "Veggie pizza with side salad"
26
- ]
27
- }
28
-
29
- # Create DOCX document
30
- doc = Document()
31
- doc.add_heading("5-Day Meal Plan", level=1)
32
-
33
- for i in range(5):
34
- doc.add_heading(data["Day"][i], level=2)
35
- doc.add_paragraph(f"Breakfast: {data['Breakfast'][i]}")
36
- doc.add_paragraph(f"Lunch: {data['Lunch'][i]}")
37
- doc.add_paragraph(f"Dinner: {data['Dinner'][i]}")
38
- doc.add_paragraph("")
39
-
40
- # Save file
41
- path = "5_day_meal_plan.docx"
42
- doc.save(path)
43
-
44
- print(f"Saved DOCX file to: {path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
example_rules.txt DELETED
@@ -1,133 +0,0 @@
1
- # Admin Rules Examples for IntegraChat
2
- # Copy and paste these rules into the Admin Rules & Compliance tab in Gradio UI
3
-
4
- # ============================================================
5
- # HIGH PRIORITY SECURITY RULES
6
- # ============================================================
7
-
8
- Block password disclosure requests
9
- Prevent sharing of authentication credentials
10
- No sharing of API keys or tokens
11
- Block requests for user account passwords
12
- Prevent disclosure of security credentials
13
- Block social security number requests
14
- No sharing of credit card information
15
- Prevent disclosure of personal identification numbers
16
- Block requests for bank account details
17
- No sharing of confidential access codes
18
-
19
- # ============================================================
20
- # MEDIUM PRIORITY COMPLIANCE RULES
21
- # ============================================================
22
-
23
- Block requests for employee personal information
24
- Prevent sharing of customer data without authorization
25
- No unauthorized access to financial records
26
- Block requests for confidential business strategies
27
- Prevent disclosure of proprietary information
28
- No sharing of trade secrets
29
- Block requests for competitor analysis data
30
- Prevent unauthorized data export
31
- No sharing of internal process documentation
32
- Block requests for customer contact lists
33
-
34
- # ============================================================
35
- # DATA PRIVACY RULES
36
- # ============================================================
37
-
38
- Block requests for personal data of EU citizens
39
- Prevent sharing of health information
40
- No disclosure of medical records
41
- Block requests for biometric data
42
- Prevent sharing of location tracking information
43
- No disclosure of children's personal information
44
- Block requests for genetic information
45
- Prevent sharing of religious or political affiliations
46
- No disclosure of sexual orientation data
47
- Block requests for financial transaction history
48
-
49
- # ============================================================
50
- # OPERATIONAL RULES
51
- # ============================================================
52
-
53
- Block requests to delete system logs
54
- Prevent unauthorized system configuration changes
55
- No sharing of infrastructure credentials
56
- Block requests for production database access
57
- Prevent disclosure of deployment procedures
58
- No sharing of monitoring tool credentials
59
- Block requests for backup restoration procedures
60
- Prevent unauthorized access to cloud resources
61
- No sharing of encryption keys
62
- Block requests for system administrator privileges
63
-
64
- # ============================================================
65
- # CONTENT MODERATION RULES
66
- # ============================================================
67
-
68
- Block requests for generating harmful content
69
- Prevent creation of offensive material
70
- No sharing of inappropriate content
71
- Block requests for generating misleading information
72
- Prevent creation of fake news content
73
- No sharing of defamatory statements
74
- Block requests for generating hate speech
75
- Prevent creation of discriminatory content
76
- No sharing of violent content
77
- Block requests for generating illegal content
78
-
79
- # ============================================================
80
- # SPECIFIC KEYWORD-BASED RULES
81
- # ============================================================
82
-
83
- Block queries containing "password" and "reset"
84
- Prevent requests with "API key" and "generate"
85
- No queries containing "SSN" or "social security"
86
- Block requests with "credit card" and "number"
87
- Prevent queries containing "bank account" and "details"
88
- No requests with "admin" and "access"
89
- Block queries containing "delete" and "all data"
90
- Prevent requests with "export" and "customer list"
91
- No queries containing "encryption key" and "show"
92
- Block requests with "root password" and "share"
93
-
94
- # ============================================================
95
- # REGULATORY COMPLIANCE RULES
96
- # ============================================================
97
-
98
- Block requests violating GDPR regulations
99
- Prevent sharing of data without consent
100
- No disclosure of information to unauthorized parties
101
- Block requests for data subject to HIPAA
102
- Prevent sharing of protected health information
103
- No disclosure of financial data subject to PCI-DSS
104
- Block requests violating SOX compliance
105
- Prevent sharing of audit trail information
106
- No disclosure of information subject to FERPA
107
- Block requests violating industry-specific regulations
108
-
109
- # ============================================================
110
- # RESPONSE BEHAVIOR RULES
111
- # ============================================================
112
-
113
- Keep greeting responses brief and simple
114
- Do not provide verbose responses to simple greetings
115
- Respond to hello and hi with short friendly greetings only
116
- Avoid mentioning RAG or documentation sources in greeting responses
117
- Keep casual conversation responses concise
118
-
119
- # ============================================================
120
- # CUSTOM BUSINESS RULES (Examples)
121
- # ============================================================
122
-
123
- Block requests for competitor pricing information
124
- Prevent sharing of upcoming product launch details
125
- No disclosure of merger and acquisition information
126
- Block requests for employee salary information
127
- Prevent sharing of vendor contract terms
128
- No disclosure of strategic partnership details
129
- Block requests for customer churn analysis data
130
- Prevent sharing of marketing campaign strategies
131
- No disclosure of research and development projects
132
- Block requests for intellectual property information
133
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
example_rules_detailed.json DELETED
@@ -1,131 +0,0 @@
1
- {
2
- "rules": [
3
- {
4
- "rule": "Block password disclosure requests",
5
- "pattern": ".*(password|pwd|passcode|credential|login).*",
6
- "severity": "high",
7
- "description": "Prevents users from requesting or sharing passwords, credentials, or authentication information"
8
- },
9
- {
10
- "rule": "Prevent sharing of API keys or tokens",
11
- "pattern": ".*(api.?key|token|secret|access.?key|auth.?token).*",
12
- "severity": "critical",
13
- "description": "Blocks requests to share, generate, or disclose API keys, tokens, or authentication secrets"
14
- },
15
- {
16
- "rule": "Block social security number requests",
17
- "pattern": ".*(ssn|social.?security|tax.?id|ein).*",
18
- "severity": "high",
19
- "description": "Prevents disclosure of social security numbers or tax identification numbers"
20
- },
21
- {
22
- "rule": "No sharing of credit card information",
23
- "pattern": ".*(credit.?card|card.?number|cvv|cvc|expiration).*",
24
- "severity": "critical",
25
- "description": "Blocks requests to share or store credit card numbers, CVV codes, or payment card information"
26
- },
27
- {
28
- "rule": "Block requests for bank account details",
29
- "pattern": ".*(bank.?account|routing.?number|account.?number|swift|iban).*",
30
- "severity": "high",
31
- "description": "Prevents disclosure of bank account numbers, routing numbers, or financial account information"
32
- },
33
- {
34
- "rule": "Prevent sharing of employee personal information",
35
- "pattern": ".*(employee.?data|staff.?info|personnel.?record|hr.?data).*",
36
- "severity": "medium",
37
- "description": "Blocks requests to access or share employee personal information without authorization"
38
- },
39
- {
40
- "rule": "No unauthorized access to financial records",
41
- "pattern": ".*(financial.?record|accounting|bookkeeping|financial.?data).*",
42
- "severity": "high",
43
- "description": "Prevents unauthorized access to financial records, accounting data, or bookkeeping information"
44
- },
45
- {
46
- "rule": "Block requests for confidential business strategies",
47
- "pattern": ".*(business.?strategy|strategic.?plan|confidential.?plan|roadmap).*",
48
- "severity": "medium",
49
- "description": "Prevents disclosure of confidential business strategies, plans, or roadmaps"
50
- },
51
- {
52
- "rule": "Prevent disclosure of proprietary information",
53
- "pattern": ".*(proprietary|trade.?secret|intellectual.?property|ip).*",
54
- "severity": "high",
55
- "description": "Blocks requests to share proprietary information, trade secrets, or intellectual property"
56
- },
57
- {
58
- "rule": "Block requests for personal data of EU citizens",
59
- "pattern": ".*(gdpr|eu.?citizen|personal.?data|data.?subject).*",
60
- "severity": "critical",
61
- "description": "Prevents unauthorized access to personal data of EU citizens, violating GDPR regulations"
62
- },
63
- {
64
- "rule": "Prevent sharing of health information",
65
- "pattern": ".*(health.?info|medical.?record|patient.?data|hipaa).*",
66
- "severity": "critical",
67
- "description": "Blocks requests to share health information or medical records, protecting HIPAA compliance"
68
- },
69
- {
70
- "rule": "No disclosure of children's personal information",
71
- "pattern": ".*(child|minor|under.?18|coppa).*",
72
- "severity": "critical",
73
- "description": "Prevents disclosure of personal information of children under 18, ensuring COPPA compliance"
74
- },
75
- {
76
- "rule": "Block requests to delete system logs",
77
- "pattern": ".*(delete.?log|remove.?log|clear.?log|purge.?log).*",
78
- "severity": "high",
79
- "description": "Prevents deletion or modification of system logs, which are critical for security and compliance"
80
- },
81
- {
82
- "rule": "Prevent unauthorized system configuration changes",
83
- "pattern": ".*(system.?config|change.?setting|modify.?config|update.?config).*",
84
- "severity": "high",
85
- "description": "Blocks unauthorized changes to system configuration that could compromise security"
86
- },
87
- {
88
- "rule": "No sharing of infrastructure credentials",
89
- "pattern": ".*(infrastructure|server.?credential|deployment.?key|cloud.?access).*",
90
- "severity": "critical",
91
- "description": "Prevents sharing of infrastructure credentials, server access, or cloud deployment keys"
92
- },
93
- {
94
- "rule": "Block requests for generating harmful content",
95
- "pattern": ".*(harmful|violent|hate.?speech|offensive|illegal).*",
96
- "severity": "medium",
97
- "description": "Prevents generation of harmful, violent, hateful, or illegal content"
98
- },
99
- {
100
- "rule": "Prevent creation of misleading information",
101
- "pattern": ".*(misleading|fake.?news|false.?info|disinformation).*",
102
- "severity": "medium",
103
- "description": "Blocks creation of misleading information, fake news, or disinformation"
104
- },
105
- {
106
- "rule": "No sharing of defamatory statements",
107
- "pattern": ".*(defamatory|libel|slander|defame).*",
108
- "severity": "medium",
109
- "description": "Prevents creation or sharing of defamatory statements that could cause legal issues"
110
- },
111
- {
112
- "rule": "Block requests for competitor pricing information",
113
- "pattern": ".*(competitor|pricing|competitive.?intelligence).*",
114
- "severity": "low",
115
- "description": "Prevents sharing of competitor pricing information or competitive intelligence"
116
- },
117
- {
118
- "rule": "Prevent sharing of upcoming product launch details",
119
- "pattern": ".*(product.?launch|upcoming.?release|new.?product).*",
120
- "severity": "medium",
121
- "description": "Blocks disclosure of upcoming product launches or new product information"
122
- }
123
- ],
124
- "usage_instructions": {
125
- "simple": "Copy rules from example_rules.txt and paste into Gradio UI",
126
- "detailed": "Use the JSON format with patterns and severity levels for more control",
127
- "bulk_upload": "Use the /admin/rules/bulk endpoint with the rules array",
128
- "individual": "Add rules one by one using the /admin/rules endpoint with JSON payload"
129
- }
130
- }
131
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/.gitignore DELETED
@@ -1,41 +0,0 @@
1
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
4
- /node_modules
5
- /.pnp
6
- .pnp.*
7
- .yarn/*
8
- !.yarn/patches
9
- !.yarn/plugins
10
- !.yarn/releases
11
- !.yarn/versions
12
-
13
- # testing
14
- /coverage
15
-
16
- # next.js
17
- /.next/
18
- /out/
19
-
20
- # production
21
- /build
22
-
23
- # misc
24
- .DS_Store
25
- *.pem
26
-
27
- # debug
28
- npm-debug.log*
29
- yarn-debug.log*
30
- yarn-error.log*
31
- .pnpm-debug.log*
32
-
33
- # env files (can opt-in for committing if needed)
34
- .env*
35
-
36
- # vercel
37
- .vercel
38
-
39
- # typescript
40
- *.tsbuildinfo
41
- next-env.d.ts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/README.md DELETED
@@ -1,134 +0,0 @@
1
- ## IntegraChat Frontend
2
-
3
- Next.js 16 / React 19 app that showcases everything wired up in `backend/`.
4
- It provides a polished operator console with:
5
-
6
- - **Hero section** + feature overview describing the FastAPI + MCP stack
7
- - **Live chat panel** that POSTs to `POST /agent/message` for AI conversations
8
- - **Analytics dashboard** pulling from `GET /analytics/overview` with real-time metrics
9
- - **Knowledge base management** page (`/knowledge-base`) for document search and ingestion
10
- - **Document ingestion UI** for uploading PDF, DOCX, TXT files or raw text
11
- - **Feature grid** showcasing platform capabilities
12
-
13
- **Note:** IntegraChat also includes a Gradio-based UI (`app.py`) with interactive visualizations, statistics cards, and Plotly charts. See the root `README.md` for details on running the Gradio interface.
14
-
15
- ## Running Locally
16
-
17
- ```bash
18
- cd frontend
19
- npm install
20
- npm run dev
21
- ```
22
-
23
- Visit `http://localhost:3000` for the main landing page, or `http://localhost:3000/knowledge-base` for document management.
24
-
25
- ### API configuration
26
-
27
- The UI calls the FastAPI service through `NEXT_PUBLIC_API_URL` (default `http://localhost:8000`).
28
- Update `.env.local` if your backend runs elsewhere:
29
-
30
- ```
31
- NEXT_PUBLIC_API_URL=http://localhost:8000
32
- ```
33
-
34
- ### Tenant & Role selector
35
-
36
- - The navbar widget now stores both the tenant ID and the MCP role (Viewer, Editor, Admin, Owner) in `localStorage`.
37
- - Every API call automatically includes `x-tenant-id` and `x-user-role` headers so the backend RBAC layer can authorize ingestion, admin rule uploads, analytics, and delete operations.
38
- - If you see a 403 "insufficient permissions" error, switch the role dropdown to a higher privilege (e.g., Admin) before retrying the action.
39
- - **Note**: Analytics is now accessible to all roles (viewer, editor, admin, owner) for improved transparency.
40
-
41
- ## Features
42
-
43
- ### Main Landing Page (`/`)
44
- - **Hero section** with platform introduction
45
- - **Feature grid** showcasing key capabilities
46
- - **Chat panel** for real-time AI conversations with reasoning visualizations
47
- - **Analytics panel** with query metrics and tool usage statistics
48
- - **Ingestion card** for quick document uploads
49
-
50
- ### Real-Time Visualizations
51
-
52
- The frontend includes three powerful visualization components:
53
-
54
- #### 1. Reasoning Path Visualizer (`reasoning-visualizer.tsx`)
55
- - Step-by-step visualization of agent decision-making
56
- - Animated progression through reasoning steps
57
- - Status indicators and detailed metrics
58
- - **Latency predictions** shown for each step (estimated vs actual)
59
- - **Context-aware routing hints** displayed (skip web/RAG/reasoning decisions)
60
- - Integrated into chat panel with collapsible section
61
-
62
- #### 2. Tool Invocation Timeline (`tool-timeline.tsx`)
63
- - Visual timeline of tool executions
64
- - Latency and result count visualization
65
- - **Schema-validated outputs** displayed (RAG results, Web results, Admin violations, LLM tokens)
66
- - Summary statistics
67
- - Integrated into chat panel
68
-
69
- #### 3. Tenant Activity Heatmap (`tenant-heatmap.tsx`)
70
- - Query activity heatmap (hour-by-hour, day-by-day)
71
- - Per-tool usage trends
72
- - Integrated into analytics page
73
-
74
- ### Knowledge Base Page (`/knowledge-base`)
75
- - **Document listing** with pagination and filtering by type (text, PDF, FAQ, link)
76
- - **Search interface** for semantic search with cross-encoder re-ranking across documents
77
- - **AI-Generated Metadata Display**: After ingestion, shows extracted:
78
- - Title, Summary, Tags, Topics
79
- - Quality Score (0.0-1.0)
80
- - Detected Date
81
- - Extraction Method (LLM vs fallback)
82
- - **Document ingestion** with support for:
83
- - Raw text input
84
- - URL ingestion (automatic content fetching)
85
- - PDF file uploads
86
- - DOCX file uploads
87
- - TXT and Markdown file uploads
88
- - **Document management** with tenant + role isolation:
89
- - Delete individual documents by ID
90
- - Delete all documents for a tenant (with confirmation)
91
- - Real-time document list updates after operations
92
- - Error handling with clear user feedback
93
-
94
- ### Analytics Page (`/analytics`)
95
- - **Analytics overview** with key metrics (queries, users, red flags)
96
- - **Tool usage statistics** with detailed breakdowns
97
- - **Tenant activity heatmap** showing query patterns over time
98
- - **Per-tool usage trends** with visual bar charts
99
- - **Access**: All roles can view analytics (viewer, editor, admin, owner)
100
-
101
- ### Admin Rules Page (`/admin-rules`)
102
- - **Rule management** with bulk upload and individual rule deletion
103
- - **File upload support** for TXT, PDF, DOC, DOCX, MD files with drag-and-drop
104
- - **LLM-Guided Rule Explanations**:
105
- - Automatic generation of human-readable explanations
106
- - Concrete examples of what would trigger the rule
107
- - Missing pattern suggestions for rule improvement
108
- - Edge cases and improvements identified by LLM
109
- - **Intelligent fallback**: When LLM times out, uses keyword extraction to generate useful explanations, examples, and suggestions
110
- - **Expandable explanations** with "Explain" button for each rule
111
- - **Auto-expand** for newly added rules with explanations
112
- - **Role-based access**: Requires Admin or Owner role to manage rules
113
- - **Real-time updates** with refresh functionality
114
-
115
- ### Components
116
-
117
- - `chat-panel.tsx` - Real-time chat interface with streaming responses and visualization integration
118
- - `analytics-panel.tsx` - Analytics dashboard with metrics visualization
119
- - `knowledge-base-panel.tsx` - Document search and ingestion component
120
- - `ingestion-card.tsx` - Quick document upload card
121
- - `hero.tsx` - Landing page hero section
122
- - `feature-grid.tsx` - Feature showcase grid
123
- - `footer.tsx` - Footer component
124
- - `reasoning-visualizer.tsx` - Real-time reasoning path visualizer component
125
- - `tool-timeline.tsx` - Tool invocation timeline component
126
- - `tenant-heatmap.tsx` - Tenant activity heatmap component
127
- - `rule-explanation.tsx` - LLM-generated rule explanation component with examples and pattern suggestions
128
- - `admin-rules-panel.tsx` - Admin rules management panel component
129
-
130
- ## Deploy
131
-
132
- Deploy like any Next.js app (Vercel, Docker, etc.). Ensure the backend endpoints are reachable from the browser and CORS is enabled (already configured in `backend/api/main.py`).
133
-
134
- **Note:** Make sure Celery workers are running in production for document ingestion and analytics processing to work properly.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/admin-rules/page.tsx DELETED
@@ -1,778 +0,0 @@
1
- "use client";
2
-
3
- import React, { useCallback, useMemo, useState, useRef, useEffect } from "react";
4
- import Link from "next/link";
5
-
6
- import { AdminRulesPanel } from "@/components/admin-rules-panel";
7
- import { RuleExplanation } from "@/components/rule-explanation";
8
- import { Footer } from "@/components/footer";
9
- import { useTenant } from "@/contexts/TenantContext";
10
- import { TenantSelector } from "@/components/tenant-selector";
11
- import { canManageRules } from "@/lib/permissions";
12
-
13
- const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "http://localhost:8000";
14
-
15
- type StatusState = { tone: "info" | "success" | "error"; message: string } | null;
16
-
17
- const RBAC_ERROR_HINT =
18
- "Insufficient permissions for this action. Switch your role to Admin or Owner in the navbar and try again.";
19
-
20
- async function buildErrorMessage(response: Response) {
21
- const fallback = `Backend error ${response.status}`;
22
- try {
23
- const text = await response.text();
24
- if (!text) {
25
- return response.status === 403 ? RBAC_ERROR_HINT : fallback;
26
- }
27
- try {
28
- const parsed = JSON.parse(text);
29
- const detail = parsed.detail || parsed.message;
30
- if (response.status === 403) {
31
- return detail || RBAC_ERROR_HINT;
32
- }
33
- return detail || fallback;
34
- } catch {
35
- if (response.status === 403) {
36
- return text || RBAC_ERROR_HINT;
37
- }
38
- return text || fallback;
39
- }
40
- } catch {
41
- return response.status === 403 ? RBAC_ERROR_HINT : fallback;
42
- }
43
- }
44
-
45
- export default function AdminRulesPage() {
46
- const { tenantId, role } = useTenant();
47
- const [rulesInput, setRulesInput] = useState("");
48
- const [deleteInput, setDeleteInput] = useState("");
49
- const [rules, setRules] = useState<string[]>([]);
50
- const [loading, setLoading] = useState(false);
51
- const [status, setStatus] = useState<StatusState>(null);
52
- const [isDragging, setIsDragging] = useState(false);
53
- const [lastUpdated, setLastUpdated] = useState<string>("");
54
- const [ruleExplanations, setRuleExplanations] = useState<Record<string, any>>({});
55
- const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set());
56
- const [loadingExplanations, setLoadingExplanations] = useState<Set<string>>(new Set());
57
- const fileInputRef = useRef<HTMLInputElement>(null);
58
-
59
- // Set initial time only on client side to avoid hydration mismatch
60
- useEffect(() => {
61
- setLastUpdated(new Date().toLocaleTimeString());
62
- }, []);
63
-
64
- const headers = useMemo(() => {
65
- if (!tenantId.trim()) return undefined;
66
- return {
67
- "Content-Type": "application/json",
68
- "x-tenant-id": tenantId.trim(),
69
- "x-user-role": role,
70
- };
71
- }, [tenantId, role]);
72
-
73
- const requireTenant = useCallback(() => {
74
- if (!tenantId.trim()) {
75
- setStatus({ tone: "error", message: "Enter a tenant ID in the navbar first." });
76
- return false;
77
- }
78
- return true;
79
- }, [tenantId]);
80
-
81
- const handleRefresh = useCallback(async () => {
82
- if (!requireTenant()) return;
83
- try {
84
- setLoading(true);
85
- setStatus({ tone: "info", message: "Loading rules..." });
86
- const response = await fetch(`${BACKEND_BASE_URL}/admin/rules`, {
87
- method: "GET",
88
- headers,
89
- });
90
- if (!response.ok) {
91
- throw new Error(await buildErrorMessage(response));
92
- }
93
- const data = await response.json();
94
- setRules(data.rules ?? []);
95
- setLastUpdated(new Date().toLocaleTimeString());
96
- setStatus({ tone: "success", message: "Rules synced." });
97
- } catch (error: any) {
98
- setStatus({ tone: "error", message: error.message || "Failed to fetch rules" });
99
- } finally {
100
- setLoading(false);
101
- }
102
- }, [headers, requireTenant]);
103
-
104
- const handleUpload = useCallback(async () => {
105
- if (!requireTenant()) return;
106
- const lines = rulesInput
107
- .split("\n")
108
- .map((line) => line.trim())
109
- .filter((line) => line && !line.startsWith("#")); // Filter out comments and empty lines
110
-
111
- if (!lines.length) {
112
- setStatus({ tone: "error", message: "Add at least one rule to upload. (Comment lines starting with # are ignored)" });
113
- return;
114
- }
115
-
116
- try {
117
- setLoading(true);
118
- setStatus({ tone: "info", message: `Uploading ${lines.length} rule(s)...` });
119
- const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk?enhance=true`, {
120
- method: "POST",
121
- headers,
122
- body: JSON.stringify({ rules: lines }),
123
- });
124
- if (!response.ok) {
125
- throw new Error(await buildErrorMessage(response));
126
- }
127
- const data = await response.json();
128
- await handleRefresh();
129
- setRulesInput("");
130
-
131
- // Store explanations for display and auto-expand
132
- if (data.explanations && Array.isArray(data.explanations)) {
133
- const explanationsMap: Record<string, any> = {};
134
- const newExpanded = new Set(expandedRules);
135
-
136
- data.explanations.forEach((exp: any) => {
137
- if (exp.rule) {
138
- explanationsMap[exp.rule] = exp;
139
- // Auto-expand rules that have explanations
140
- if (exp.explanation || exp.examples || exp.missing_patterns) {
141
- newExpanded.add(exp.rule);
142
- }
143
- }
144
- });
145
- setRuleExplanations((prev) => ({ ...prev, ...explanationsMap }));
146
- setExpandedRules(newExpanded);
147
- }
148
-
149
- const enhancedMsg = data.enhanced ? " (enhanced by LLM)" : "";
150
- setStatus({ tone: "success", message: `Uploaded ${data.added_rules?.length || lines.length} rule(s)${enhancedMsg}.` });
151
- } catch (error: any) {
152
- setStatus({ tone: "error", message: error.message || "Failed to upload rules" });
153
- } finally {
154
- setLoading(false);
155
- }
156
- }, [handleRefresh, headers, requireTenant, rulesInput]);
157
-
158
- const processFile = useCallback(async (file: File) => {
159
- if (!requireTenant()) return;
160
-
161
- const fileExt = file.name.split('.').pop()?.toLowerCase();
162
- if (!fileExt || !['txt', 'pdf', 'doc', 'docx', 'md'].includes(fileExt)) {
163
- setStatus({ tone: "error", message: "Unsupported file type. Supported: TXT, PDF, DOC, DOCX, MD" });
164
- return;
165
- }
166
-
167
- try {
168
- setLoading(true);
169
- setStatus({ tone: "info", message: `Uploading and processing ${file.name}...` });
170
-
171
- // For TXT files, read client-side for faster processing
172
- if (fileExt === 'txt' || fileExt === 'md') {
173
- const fileContent = await file.text();
174
- const lines = fileContent
175
- .split("\n")
176
- .map((line) => line.trim())
177
- .filter((line) => line && !line.startsWith("#"));
178
-
179
- if (!lines.length) {
180
- setStatus({ tone: "error", message: "No valid rules found in file (after filtering comments)." });
181
- setLoading(false);
182
- return;
183
- }
184
-
185
- // Upload rules via bulk endpoint
186
- setStatus({ tone: "info", message: `Uploading ${lines.length} rule(s)...` });
187
- const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/bulk?enhance=true`, {
188
- method: "POST",
189
- headers,
190
- body: JSON.stringify({ rules: lines }),
191
- });
192
-
193
- if (!response.ok) {
194
- throw new Error(await buildErrorMessage(response));
195
- }
196
-
197
- const data = await response.json();
198
- await handleRefresh();
199
-
200
- // Store explanations for display and auto-expand
201
- if (data.explanations && Array.isArray(data.explanations)) {
202
- const explanationsMap: Record<string, any> = {};
203
- const newExpanded = new Set(expandedRules);
204
-
205
- data.explanations.forEach((exp: any) => {
206
- if (exp.rule) {
207
- explanationsMap[exp.rule] = exp;
208
- // Auto-expand rules that have explanations
209
- if (exp.explanation || exp.examples || exp.missing_patterns) {
210
- newExpanded.add(exp.rule);
211
- }
212
- }
213
- });
214
- setRuleExplanations((prev) => ({ ...prev, ...explanationsMap }));
215
- setExpandedRules(newExpanded);
216
- }
217
-
218
- const enhancedMsg = data.enhanced ? " (enhanced by LLM)" : "";
219
- setStatus({ tone: "success", message: `Uploaded ${data.added_rules?.length || lines.length} rule(s) from ${file.name}${enhancedMsg}.` });
220
- return;
221
- }
222
-
223
- // For PDF, DOC, DOCX - use backend file upload endpoint
224
- const formData = new FormData();
225
- formData.append('file', file);
226
-
227
- setStatus({ tone: "info", message: `Extracting text from ${file.name}...` });
228
- const response = await fetch(`${BACKEND_BASE_URL}/admin/rules/upload-file?enhance=true`, {
229
- method: "POST",
230
- headers: {
231
- "x-tenant-id": tenantId.trim(),
232
- "x-user-role": role,
233
- },
234
- body: formData,
235
- });
236
-
237
- if (!response.ok) {
238
- throw new Error(await buildErrorMessage(response));
239
- }
240
-
241
- const data = await response.json();
242
- await handleRefresh();
243
-
244
- // Store explanations for display and auto-expand
245
- if (data.explanations && Array.isArray(data.explanations)) {
246
- const explanationsMap: Record<string, any> = {};
247
- const newExpanded = new Set(expandedRules);
248
-
249
- data.explanations.forEach((exp: any) => {
250
- if (exp.rule) {
251
- explanationsMap[exp.rule] = exp;
252
- // Auto-expand rules that have explanations
253
- if (exp.explanation || exp.examples || exp.missing_patterns) {
254
- newExpanded.add(exp.rule);
255
- }
256
- }
257
- });
258
- setRuleExplanations((prev) => ({ ...prev, ...explanationsMap }));
259
- setExpandedRules(newExpanded);
260
- }
261
-
262
- const enhancedMsg = data.enhanced ? " (enhanced by LLM)" : "";
263
- setStatus({
264
- tone: "success",
265
- message: `Uploaded ${data.added_rules?.length || data.total_extracted || 0} rule(s) from ${file.name}${enhancedMsg}.`
266
- });
267
- } catch (error: any) {
268
- setStatus({ tone: "error", message: error.message || "Failed to upload rules from file" });
269
- } finally {
270
- setLoading(false);
271
- }
272
- }, [handleRefresh, headers, requireTenant, tenantId]);
273
-
274
- const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
275
- const file = event.target.files?.[0];
276
- if (!file) return;
277
- await processFile(file);
278
- if (fileInputRef.current) {
279
- fileInputRef.current.value = "";
280
- }
281
- }, [processFile]);
282
-
283
- const handleDragOver = useCallback((e: React.DragEvent) => {
284
- e.preventDefault();
285
- e.stopPropagation();
286
- setIsDragging(true);
287
- }, []);
288
-
289
- const handleDragLeave = useCallback((e: React.DragEvent) => {
290
- e.preventDefault();
291
- e.stopPropagation();
292
- setIsDragging(false);
293
- }, []);
294
-
295
- const handleDrop = useCallback(async (e: React.DragEvent) => {
296
- e.preventDefault();
297
- e.stopPropagation();
298
- setIsDragging(false);
299
-
300
- const file = e.dataTransfer.files?.[0];
301
- if (!file) return;
302
-
303
- const fileExt = file.name.split('.').pop()?.toLowerCase();
304
- if (!fileExt || !['txt', 'pdf', 'doc', 'docx', 'md'].includes(fileExt)) {
305
- setStatus({ tone: "error", message: "Unsupported file type. Supported: TXT, PDF, DOC, DOCX, MD" });
306
- return;
307
- }
308
-
309
- await processFile(file);
310
- }, [processFile]);
311
-
312
- const fetchRuleExplanation = useCallback(async (rule: string) => {
313
- if (!requireTenant()) return;
314
- if (ruleExplanations[rule]) return; // Already have explanation
315
-
316
- try {
317
- setLoadingExplanations((prev) => new Set(prev).add(rule));
318
-
319
- // Fetch explanation by calling the enhance endpoint
320
- // We'll use POST with the rule in the body to get explanation
321
- const response = await fetch(
322
- `${BACKEND_BASE_URL}/admin/rules?enhance=true`,
323
- {
324
- method: "POST",
325
- headers: {
326
- "Content-Type": "application/json",
327
- "x-tenant-id": tenantId.trim(),
328
- "x-user-role": role,
329
- },
330
- body: JSON.stringify({ rule }),
331
- }
332
- );
333
-
334
- if (response.ok) {
335
- const data = await response.json();
336
- if (data.explanation || data.examples || data.missing_patterns) {
337
- setRuleExplanations((prev) => ({
338
- ...prev,
339
- [rule]: {
340
- explanation: data.explanation,
341
- examples: data.examples || [],
342
- missing_patterns: data.missing_patterns || [],
343
- edge_cases: data.edge_cases || [],
344
- improvements: data.improvements || [],
345
- severity: data.severity,
346
- },
347
- }));
348
- }
349
- }
350
- } catch (error) {
351
- console.error("Failed to fetch rule explanation:", error);
352
- } finally {
353
- setLoadingExplanations((prev) => {
354
- const next = new Set(prev);
355
- next.delete(rule);
356
- return next;
357
- });
358
- }
359
- }, [tenantId, role, ruleExplanations, requireTenant]);
360
-
361
- const toggleRuleExplanation = useCallback((rule: string) => {
362
- setExpandedRules((prev) => {
363
- const next = new Set(prev);
364
- if (next.has(rule)) {
365
- next.delete(rule);
366
- } else {
367
- next.add(rule);
368
- // Fetch explanation if we don't have it
369
- if (!ruleExplanations[rule]) {
370
- fetchRuleExplanation(rule);
371
- }
372
- }
373
- return next;
374
- });
375
- }, [ruleExplanations, fetchRuleExplanation]);
376
-
377
- const handleDelete = useCallback(async () => {
378
- if (!requireTenant()) return;
379
- if (!deleteInput.trim()) {
380
- setStatus({ tone: "error", message: "Enter the rule text you want to delete." });
381
- return;
382
- }
383
-
384
- try {
385
- setLoading(true);
386
- setStatus({ tone: "info", message: "Deleting rule..." });
387
- const response = await fetch(
388
- `${BACKEND_BASE_URL}/admin/rules/${encodeURIComponent(deleteInput.trim())}`,
389
- {
390
- method: "DELETE",
391
- headers,
392
- }
393
- );
394
- if (!response.ok) {
395
- throw new Error(await buildErrorMessage(response));
396
- }
397
- await handleRefresh();
398
- setDeleteInput("");
399
- setStatus({ tone: "success", message: "Rule deleted." });
400
- } catch (error: any) {
401
- setStatus({ tone: "error", message: error.message || "Failed to delete rule" });
402
- } finally {
403
- setLoading(false);
404
- }
405
- }, [deleteInput, handleRefresh, headers, requireTenant]);
406
-
407
- // Check permissions AFTER all hooks are called
408
- if (!canManageRules(role)) {
409
- return (
410
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
411
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
412
- <div className="flex items-center justify-between gap-3">
413
- <div className="flex items-center gap-3 text-base font-semibold">
414
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
415
- IC
416
- </span>
417
- IntegraChat · Admin Rules
418
- </div>
419
- <div className="flex items-center gap-4">
420
- <TenantSelector />
421
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
422
- ← Back Home
423
- </Link>
424
- </div>
425
- </div>
426
- </header>
427
-
428
- <div className="rounded-2xl border border-red-500/50 bg-red-500/10 p-8 text-center">
429
- <h2 className="text-2xl font-bold text-red-300 mb-2">Access Denied</h2>
430
- <p className="text-slate-300 mb-4">
431
- You need <strong>Admin</strong> or <strong>Owner</strong> role to manage rules.
432
- </p>
433
- <p className="text-sm text-slate-400">
434
- Your current role: <strong className="text-slate-200">{role.charAt(0).toUpperCase() + role.slice(1)}</strong>
435
- </p>
436
- <p className="text-sm text-slate-400 mt-2">
437
- Please switch your role using the dropdown in the header.
438
- </p>
439
- </div>
440
- <Footer />
441
- </main>
442
- );
443
- }
444
-
445
- return (
446
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
447
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
448
- <div className="flex items-center justify-between gap-3">
449
- <div className="flex items-center gap-3 text-base font-semibold">
450
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
451
- IC
452
- </span>
453
- IntegraChat · Admin Rule Ingestion
454
- </div>
455
- <div className="flex items-center gap-4">
456
- <TenantSelector />
457
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
458
- ← Back Home
459
- </Link>
460
- </div>
461
- </div>
462
- <div className="space-y-2">
463
- <p className="text-sm text-slate-300">
464
- Upload governance policies, compliance workflows, and red-flag patterns. Rules are automatically enhanced by LLM and stored in the backend.
465
- </p>
466
- <div className="flex flex-wrap gap-2 text-xs text-slate-400">
467
- <span className="flex items-center gap-1 rounded-full bg-white/5 px-3 py-1">
468
- <span>✨</span>
469
- <span>LLM Enhanced</span>
470
- </span>
471
- <span className="flex items-center gap-1 rounded-full bg-white/5 px-3 py-1">
472
- <span>📄</span>
473
- <span>File Upload</span>
474
- </span>
475
- <span className="flex items-center gap-1 rounded-full bg-white/5 px-3 py-1">
476
- <span>🔄</span>
477
- <span>Chunk Processing</span>
478
- </span>
479
- </div>
480
- </div>
481
- </header>
482
-
483
- <AdminRulesPanel />
484
-
485
- <section className="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-2xl shadow-slate-950/40">
486
- <div className="flex flex-col gap-6">
487
- <div className="flex items-center justify-between gap-3">
488
- {lastUpdated && (
489
- <div className="flex items-center gap-2 text-sm text-slate-400">
490
- <span>🔄</span>
491
- <span>Last updated: {lastUpdated}</span>
492
- </div>
493
- )}
494
- {!lastUpdated && <div></div>}
495
- <button
496
- onClick={handleRefresh}
497
- disabled={loading}
498
- className="flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
499
- >
500
- {loading ? (
501
- <>
502
- <span className="animate-spin">⏳</span>
503
- <span>Refreshing...</span>
504
- </>
505
- ) : (
506
- <>
507
- <span>🔄</span>
508
- <span>Refresh Rules</span>
509
- </>
510
- )}
511
- </button>
512
- </div>
513
-
514
- <div className="grid gap-6 lg:grid-cols-2">
515
- {/* Left Column: Upload Rules */}
516
- <div className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-slate-900/30 p-6">
517
- <div className="flex items-center gap-2">
518
- <span className="text-lg">📝</span>
519
- <h3 className="text-lg font-semibold text-slate-200">Add Rules</h3>
520
- </div>
521
-
522
- <label className="flex flex-col gap-2 text-sm font-semibold text-slate-200">
523
- <span>Bulk Upload Rules (one per line)</span>
524
- <textarea
525
- value={rulesInput}
526
- onChange={(e) => setRulesInput(e.target.value)}
527
- placeholder="Block password disclosure requests\nPrevent sharing of API keys\nNo sharing of credit card information"
528
- rows={8}
529
- className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-3 text-sm text-white placeholder:text-slate-500 outline-none ring-0 transition focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/20"
530
- />
531
- <span className="text-xs text-slate-400">
532
- 💡 Tip: Comment lines (starting with #) are automatically ignored
533
- </span>
534
- </label>
535
-
536
- <button
537
- onClick={handleUpload}
538
- disabled={loading || !rulesInput.trim()}
539
- className="rounded-xl bg-gradient-to-r from-emerald-400 to-lime-400 px-6 py-3 text-sm font-semibold text-slate-900 shadow-lg shadow-emerald-500/30 transition hover:shadow-emerald-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
540
- >
541
- {loading ? "⏳ Uploading..." : "✅ Upload / Append Rules"}
542
- </button>
543
-
544
- <div className="flex items-center gap-3 py-2">
545
- <span className="h-px flex-1 bg-white/10"></span>
546
- <span className="text-xs font-semibold uppercase tracking-wider text-slate-400">OR</span>
547
- <span className="h-px flex-1 bg-white/10"></span>
548
- </div>
549
-
550
- <label className="flex flex-col gap-2 text-sm font-semibold text-slate-200">
551
- <span>📄 Upload Rules from File</span>
552
- <div
553
- onDragOver={handleDragOver}
554
- onDragLeave={handleDragLeave}
555
- onDrop={handleDrop}
556
- className={`relative rounded-xl border-2 border-dashed transition-all ${
557
- isDragging
558
- ? "border-cyan-400 bg-cyan-500/10 scale-[1.02]"
559
- : "border-white/20 bg-slate-900/50 hover:border-cyan-400/50 hover:bg-slate-900/70"
560
- }`}
561
- >
562
- <input
563
- ref={fileInputRef}
564
- type="file"
565
- accept=".txt,.pdf,.doc,.docx,.md"
566
- onChange={handleFileUpload}
567
- disabled={loading}
568
- className="hidden"
569
- id="file-upload-input"
570
- />
571
- <label
572
- htmlFor="file-upload-input"
573
- className="flex flex-col items-center justify-center gap-3 p-8 cursor-pointer"
574
- >
575
- {isDragging ? (
576
- <>
577
- <span className="text-4xl animate-bounce">📥</span>
578
- <span className="text-sm font-semibold text-cyan-300">Drop file here</span>
579
- </>
580
- ) : (
581
- <>
582
- <span className="text-4xl">📄</span>
583
- <div className="text-center">
584
- <span className="text-sm font-semibold text-slate-200">
585
- Drag & drop file here
586
- </span>
587
- <span className="text-xs text-slate-400 block mt-1">or click to browse</span>
588
- </div>
589
- <button
590
- type="button"
591
- disabled={loading}
592
- className="mt-2 rounded-lg bg-gradient-to-r from-cyan-500 to-blue-500 px-4 py-2 text-xs font-semibold text-slate-900 transition hover:from-cyan-400 hover:to-blue-400 disabled:opacity-50"
593
- >
594
- Choose File
595
- </button>
596
- </>
597
- )}
598
- </label>
599
- </div>
600
- <span className="text-xs text-slate-400">
601
- Supported: TXT, PDF, DOC, DOCX, MD • Files processed server-side with LLM enhancement
602
- </span>
603
- </label>
604
- </div>
605
-
606
- {/* Right Column: Delete Rules */}
607
- <div className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-slate-900/30 p-6">
608
- <div className="flex items-center gap-2">
609
- <span className="text-lg">🗑️</span>
610
- <h3 className="text-lg font-semibold text-slate-200">Delete Rule</h3>
611
- </div>
612
-
613
- <label className="flex flex-col gap-2 text-sm font-semibold text-slate-200">
614
- <span>Enter exact rule text to remove</span>
615
- <textarea
616
- value={deleteInput}
617
- onChange={(e) => setDeleteInput(e.target.value)}
618
- placeholder="Paste the exact rule text here to delete it..."
619
- rows={8}
620
- className="rounded-xl border border-white/10 bg-slate-900/50 px-4 py-3 text-sm text-white placeholder:text-slate-500 outline-none ring-0 transition focus:border-rose-400 focus:ring-2 focus:ring-rose-400/20"
621
- />
622
- <span className="text-xs text-slate-400">
623
- ⚠️ This action cannot be undone. Make sure the text matches exactly.
624
- </span>
625
- </label>
626
-
627
- <button
628
- onClick={handleDelete}
629
- disabled={loading || !deleteInput.trim()}
630
- className="rounded-xl border-2 border-rose-500 bg-rose-500/10 px-6 py-3 text-sm font-semibold text-rose-300 transition hover:bg-rose-500/20 hover:border-rose-400 disabled:opacity-50 disabled:cursor-not-allowed"
631
- >
632
- {loading ? "⏳ Deleting..." : "🗑️ Delete Rule"}
633
- </button>
634
- </div>
635
- </div>
636
-
637
- {status && (
638
- <div
639
- className={`rounded-xl border-2 px-5 py-4 text-sm font-medium shadow-lg ${
640
- status.tone === "error"
641
- ? "border-rose-500/50 bg-rose-500/10 text-rose-200 shadow-rose-500/20"
642
- : status.tone === "success"
643
- ? "border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-emerald-500/20"
644
- : "border-cyan-500/50 bg-cyan-500/10 text-cyan-200 shadow-cyan-500/20"
645
- }`}
646
- >
647
- <div className="flex items-start gap-3">
648
- <span className="text-lg">
649
- {status.tone === "error" ? "❌" : status.tone === "success" ? "✅" : "ℹ️"}
650
- </span>
651
- <span className="flex-1">{status.message}</span>
652
- </div>
653
- </div>
654
- )}
655
-
656
- <div className="rounded-2xl border border-white/10 bg-gradient-to-br from-slate-900/60 to-slate-950/60 shadow-xl">
657
- <div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-6 py-4">
658
- <div className="flex items-center gap-3">
659
- <span className="text-xl">📋</span>
660
- <h3 className="text-base font-semibold uppercase tracking-[0.2em] text-slate-300">Rule Set</h3>
661
- </div>
662
- <div className="flex items-center gap-3">
663
- <button
664
- onClick={handleRefresh}
665
- disabled={loading}
666
- className="flex items-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-4 py-2 text-xs font-semibold text-cyan-300 transition hover:bg-cyan-500/20 hover:border-cyan-400/50 disabled:opacity-50 disabled:cursor-not-allowed"
667
- title="Refresh rules from database"
668
- >
669
- {loading ? (
670
- <>
671
- <span className="animate-spin">⏳</span>
672
- <span>Refreshing...</span>
673
- </>
674
- ) : (
675
- <>
676
- <span>🔄</span>
677
- <span>Refresh</span>
678
- </>
679
- )}
680
- </button>
681
- <div className="flex items-center gap-2 rounded-full bg-cyan-500/20 px-4 py-2">
682
- <span className="text-sm font-semibold text-cyan-300">{rules.length}</span>
683
- <span className="text-xs text-slate-400">entries</span>
684
- </div>
685
- </div>
686
- </div>
687
- <div className="overflow-x-auto max-h-[500px] overflow-y-auto">
688
- <table className="w-full text-left text-sm">
689
- <thead className="sticky top-0 bg-slate-900/95 backdrop-blur-sm text-xs uppercase tracking-[0.2em] text-slate-400">
690
- <tr>
691
- <th className="px-6 py-4 font-semibold">#</th>
692
- <th className="px-6 py-4 font-semibold">Rule</th>
693
- </tr>
694
- </thead>
695
- <tbody>
696
- {rules.length === 0 && (
697
- <tr>
698
- <td colSpan={2} className="px-6 py-12 text-center">
699
- <div className="flex flex-col items-center gap-3">
700
- <span className="text-4xl">📝</span>
701
- <p className="text-slate-400">No rules loaded</p>
702
- <p className="text-xs text-slate-500">Use the refresh button above to load rules</p>
703
- </div>
704
- </td>
705
- </tr>
706
- )}
707
- {rules.map((rule, idx) => {
708
- const explanation = ruleExplanations[rule];
709
- const isExpanded = expandedRules.has(rule);
710
- const isLoading = loadingExplanations.has(rule);
711
- return (
712
- <React.Fragment key={`${rule}-${idx}`}>
713
- <tr className="border-t border-white/5 transition hover:bg-white/5">
714
- <td className="px-6 py-4 text-slate-400 font-mono">{idx + 1}</td>
715
- <td className="px-6 py-4">
716
- <div className="flex items-center justify-between gap-3">
717
- <span className="text-slate-200 flex-1">{rule}</span>
718
- <button
719
- onClick={() => toggleRuleExplanation(rule)}
720
- className="flex items-center gap-2 rounded-lg border border-cyan-500/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-semibold text-cyan-300 transition hover:bg-cyan-500/20 hover:border-cyan-400/50"
721
- title={isExpanded ? "Hide explanation" : "Show explanation"}
722
- >
723
- {isLoading ? (
724
- <>
725
- <span className="animate-spin">⏳</span>
726
- <span>Loading...</span>
727
- </>
728
- ) : isExpanded ? (
729
- <>
730
- <span>▼</span>
731
- <span>Hide</span>
732
- </>
733
- ) : (
734
- <>
735
- <span>▶</span>
736
- <span>Explain</span>
737
- </>
738
- )}
739
- </button>
740
- </div>
741
- </td>
742
- </tr>
743
- {isExpanded && explanation && (
744
- <tr>
745
- <td colSpan={2} className="px-6 py-4">
746
- <RuleExplanation
747
- explanation={explanation.explanation}
748
- examples={explanation.examples}
749
- missingPatterns={explanation.missing_patterns}
750
- edgeCases={explanation.edge_cases}
751
- improvements={explanation.improvements}
752
- severity={explanation.severity}
753
- />
754
- </td>
755
- </tr>
756
- )}
757
- {isExpanded && !explanation && !isLoading && (
758
- <tr>
759
- <td colSpan={2} className="px-6 py-4 text-center text-slate-400 text-sm">
760
- No explanation available for this rule.
761
- </td>
762
- </tr>
763
- )}
764
- </React.Fragment>
765
- );
766
- })}
767
- </tbody>
768
- </table>
769
- </div>
770
- </div>
771
- </div>
772
- </section>
773
-
774
- <Footer />
775
- </main>
776
- );
777
- }
778
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/analytics/page.tsx DELETED
@@ -1,82 +0,0 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
-
5
- import { AnalyticsPanel } from "@/components/analytics-panel";
6
- import { TenantHeatmap } from "@/components/tenant-heatmap";
7
- import { Footer } from "@/components/footer";
8
- import { TenantSelector } from "@/components/tenant-selector";
9
- import { useTenant } from "@/contexts/TenantContext";
10
- import { canViewAnalytics } from "@/lib/permissions";
11
-
12
- export default function AnalyticsPage() {
13
- const { role } = useTenant();
14
-
15
- if (!canViewAnalytics(role)) {
16
- return (
17
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
18
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
19
- <div className="flex items-center justify-between gap-3">
20
- <div className="flex items-center gap-3 text-base font-semibold">
21
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
22
- IC
23
- </span>
24
- IntegraChat · Analytics
25
- </div>
26
- <div className="flex items-center gap-4">
27
- <TenantSelector />
28
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
29
- ← Back Home
30
- </Link>
31
- </div>
32
- </div>
33
- </header>
34
-
35
- <div className="rounded-2xl border border-red-500/50 bg-red-500/10 p-8 text-center">
36
- <h2 className="text-2xl font-bold text-red-300 mb-2">Access Denied</h2>
37
- <p className="text-slate-300 mb-4">
38
- Unable to access analytics. Please check your role permissions.
39
- </p>
40
- <p className="text-sm text-slate-400">
41
- Your current role: <strong className="text-slate-200">{role.charAt(0).toUpperCase() + role.slice(1)}</strong>
42
- </p>
43
- </div>
44
- <Footer />
45
- </main>
46
- );
47
- }
48
-
49
- return (
50
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
51
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
52
- <div className="flex items-center justify-between gap-3">
53
- <div className="flex items-center gap-3 text-base font-semibold">
54
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
55
- IC
56
- </span>
57
- IntegraChat · Analytics
58
- </div>
59
- <div className="flex items-center gap-4">
60
- <TenantSelector />
61
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
62
- ← Back Home
63
- </Link>
64
- </div>
65
- </div>
66
- <p className="text-sm text-slate-300">
67
- Inspect tenant-wide metrics including tool usage, red-flag violations, and overall activity—all powered by the
68
- FastAPI analytics endpoints.
69
- </p>
70
- </header>
71
-
72
- <AnalyticsPanel />
73
-
74
- <div className="mt-6">
75
- <TenantHeatmap days={7} />
76
- </div>
77
-
78
- <Footer />
79
- </main>
80
- );
81
- }
82
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/chat/page.tsx DELETED
@@ -1,36 +0,0 @@
1
- import Link from "next/link";
2
-
3
- import { ChatPanel } from "@/components/chat-panel";
4
- import { Footer } from "@/components/footer";
5
- import { TenantSelector } from "@/components/tenant-selector";
6
-
7
- export default function ChatPage() {
8
- return (
9
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
10
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
11
- <div className="flex items-center justify-between gap-3">
12
- <div className="flex items-center gap-3 text-base font-semibold">
13
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
14
- IC
15
- </span>
16
- IntegraChat · Chat Bot
17
- </div>
18
- <div className="flex items-center gap-4">
19
- <TenantSelector />
20
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
21
- ← Back Home
22
- </Link>
23
- </div>
24
- </div>
25
- <p className="text-sm text-slate-300">
26
- Experience the MCP agent orchestration layer with multi-tool reasoning, tenant isolation, and red-flag aware
27
- responses.
28
- </p>
29
- </header>
30
-
31
- <ChatPanel />
32
- <Footer />
33
- </main>
34
- );
35
- }
36
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/favicon.ico DELETED
Binary file (25.9 kB)
 
frontend/app/globals.css DELETED
@@ -1,116 +0,0 @@
1
- @import "tailwindcss";
2
-
3
- :root {
4
- --background: #020617;
5
- --foreground: #f8fafc;
6
- --card: rgba(12, 17, 32, 0.88);
7
- --card-border: rgba(255, 255, 255, 0.08);
8
- --accent: #38bdf8;
9
- --accent-strong: #0ea5e9;
10
- }
11
-
12
- @theme inline {
13
- --color-background: var(--background);
14
- --color-foreground: var(--foreground);
15
- --font-sans: var(--font-geist-sans);
16
- --font-mono: var(--font-geist-mono);
17
- }
18
-
19
- body {
20
- min-height: 100vh;
21
- margin: 0;
22
- background: radial-gradient(
23
- circle at top,
24
- rgba(14, 165, 233, 0.15),
25
- transparent 45%
26
- ),
27
- radial-gradient(
28
- circle at 20% 20%,
29
- rgba(59, 130, 246, 0.18),
30
- transparent 35%
31
- ),
32
- var(--background);
33
- color: var(--foreground);
34
- font-family: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
35
- "Segoe UI", sans-serif;
36
- line-height: 1.5;
37
- }
38
-
39
- ::selection {
40
- background: rgba(14, 165, 233, 0.35);
41
- color: #f8fafc;
42
- }
43
-
44
- .gradient-border {
45
- position: relative;
46
- border-radius: 28px;
47
- background: radial-gradient(
48
- circle at 10% 20%,
49
- rgba(59, 130, 246, 0.35),
50
- rgba(15, 23, 42, 0.95)
51
- );
52
- overflow: hidden;
53
- }
54
-
55
- .gradient-border::before {
56
- content: "";
57
- position: absolute;
58
- inset: 0;
59
- padding: 1.5px;
60
- border-radius: 30px;
61
- background: linear-gradient(120deg, #60a5fa, #22d3ee, #f97316);
62
- mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
63
- mask-composite: exclude;
64
- -webkit-mask: linear-gradient(#fff 0 0) content-box,
65
- linear-gradient(#fff 0 0);
66
- -webkit-mask-composite: xor;
67
- pointer-events: none;
68
- opacity: 0.9;
69
- }
70
-
71
- .glass-panel {
72
- background: var(--card);
73
- border: 1px solid var(--card-border);
74
- border-radius: 24px;
75
- box-shadow: 0 20px 60px rgba(2, 6, 23, 0.65);
76
- backdrop-filter: blur(18px);
77
- }
78
-
79
- .badge {
80
- border-radius: 999px;
81
- background: rgba(56, 189, 248, 0.12);
82
- color: #bae6fd;
83
- font-size: 0.85rem;
84
- padding: 0.25rem 0.9rem;
85
- display: inline-flex;
86
- align-items: center;
87
- gap: 0.4rem;
88
- border: 1px solid rgba(56, 189, 248, 0.4);
89
- }
90
-
91
- .grid-fade {
92
- position: absolute;
93
- inset: 0;
94
- background-image: linear-gradient(
95
- rgba(248, 250, 252, 0.04) 1px,
96
- transparent 1px
97
- ),
98
- linear-gradient(90deg, rgba(248, 250, 252, 0.04) 1px, transparent 1px);
99
- background-size: 50px 50px;
100
- opacity: 0.4;
101
- pointer-events: none;
102
- }
103
-
104
- .scrollArea {
105
- scrollbar-width: thin;
106
- scrollbar-color: rgba(56, 189, 248, 0.6) transparent;
107
- }
108
-
109
- .scrollArea::-webkit-scrollbar {
110
- width: 6px;
111
- }
112
-
113
- .scrollArea::-webkit-scrollbar-thumb {
114
- background: rgba(56, 189, 248, 0.45);
115
- border-radius: 999px;
116
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/ingestion/page.tsx DELETED
@@ -1,79 +0,0 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
-
5
- import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
6
- import { Footer } from "@/components/footer";
7
- import { TenantSelector } from "@/components/tenant-selector";
8
- import { useTenant } from "@/contexts/TenantContext";
9
- import { canIngestDocuments } from "@/lib/permissions";
10
-
11
- export default function IngestionPage() {
12
- const { role } = useTenant();
13
-
14
- if (!canIngestDocuments(role)) {
15
- return (
16
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
17
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
18
- <div className="flex items-center justify-between gap-3">
19
- <div className="flex items-center gap-3 text-base font-semibold">
20
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
21
- IC
22
- </span>
23
- IntegraChat · Data Ingestion
24
- </div>
25
- <div className="flex items-center gap-4">
26
- <TenantSelector />
27
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
28
- ← Back Home
29
- </Link>
30
- </div>
31
- </div>
32
- </header>
33
-
34
- <div className="rounded-2xl border border-red-500/50 bg-red-500/10 p-8 text-center">
35
- <h2 className="text-2xl font-bold text-red-300 mb-2">Access Denied</h2>
36
- <p className="text-slate-300 mb-4">
37
- You need <strong>Editor</strong>, <strong>Admin</strong>, or <strong>Owner</strong> role to ingest documents.
38
- </p>
39
- <p className="text-sm text-slate-400">
40
- Your current role: <strong className="text-slate-200">{role.charAt(0).toUpperCase() + role.slice(1)}</strong>
41
- </p>
42
- <p className="text-sm text-slate-400 mt-2">
43
- Please switch your role using the dropdown in the header.
44
- </p>
45
- </div>
46
- <Footer />
47
- </main>
48
- );
49
- }
50
-
51
- return (
52
- <main className="mx-auto flex min-h-screen max-w-5xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
53
- <header className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
54
- <div className="flex items-center justify-between gap-3">
55
- <div className="flex items-center gap-3 text-base font-semibold">
56
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
57
- IC
58
- </span>
59
- IntegraChat · Data Ingestion
60
- </div>
61
- <div className="flex items-center gap-4">
62
- <TenantSelector />
63
- <Link href="/" className="text-xs font-semibold uppercase tracking-[0.3em] text-cyan-300 hover:text-white">
64
- ← Back Home
65
- </Link>
66
- </div>
67
- </div>
68
- <p className="text-sm text-slate-300">
69
- Upload raw text, URLs, or documents to feed the tenant-specific RAG index. All inputs flow into the FastAPI +
70
- MCP ingestion pipeline.
71
- </p>
72
- </header>
73
-
74
- <KnowledgeBasePanel />
75
- <Footer />
76
- </main>
77
- );
78
- }
79
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/knowledge-base/page.tsx DELETED
@@ -1,394 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useEffect } from "react";
4
- import Link from "next/link";
5
- import { useTenant } from "@/contexts/TenantContext";
6
-
7
- type Document = {
8
- id: number;
9
- text: string;
10
- created_at: string | null;
11
- };
12
-
13
- type DocumentListResponse = {
14
- documents: Document[];
15
- total: number;
16
- limit: number;
17
- offset: number;
18
- };
19
-
20
- const API_BASE =
21
- process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
22
-
23
- export default function KnowledgeBasePage() {
24
- const { tenantId, isLoading: tenantLoading, role } = useTenant();
25
- const [documents, setDocuments] = useState<Document[]>([]);
26
- const [total, setTotal] = useState(0);
27
- const [loading, setLoading] = useState(false);
28
- const [error, setError] = useState<string | null>(null);
29
- const [searchFilter, setSearchFilter] = useState("");
30
- const [filterType, setFilterType] = useState<"all" | "pdf" | "text" | "faq" | "link">("all");
31
- const [isDeleting, setIsDeleting] = useState<number | null>(null);
32
- const [isDeletingAll, setIsDeletingAll] = useState(false);
33
-
34
- async function loadDocuments() {
35
- // Guard against empty tenant ID
36
- if (!tenantId || !tenantId.trim()) {
37
- setError("Please enter a tenant ID");
38
- setLoading(false);
39
- return;
40
- }
41
-
42
- setLoading(true);
43
- setError(null);
44
-
45
- try {
46
- const response = await fetch(`${API_BASE}/rag/list?limit=1000&offset=0`, {
47
- headers: {
48
- "x-tenant-id": tenantId,
49
- "x-user-role": role,
50
- },
51
- });
52
-
53
- if (!response.ok) {
54
- const errorData = await response.json().catch(() => ({}));
55
- const errorMsg = errorData.detail || errorData.message || `Failed to load documents (${response.status})`;
56
-
57
- if (response.status === 400) {
58
- throw new Error(errorMsg.includes("tenant")
59
- ? "Missing tenant ID. Please enter a tenant ID in the navbar."
60
- : errorMsg);
61
- } else if (response.status === 503) {
62
- throw new Error("Cannot connect to RAG MCP server. Please ensure the RAG server is running.");
63
- } else {
64
- throw new Error(errorMsg);
65
- }
66
- }
67
-
68
- const data: DocumentListResponse = await response.json();
69
- setDocuments(data.documents || []);
70
- setTotal(data.total || 0);
71
- } catch (err) {
72
- console.error(err);
73
- setError(
74
- err instanceof Error
75
- ? err.message
76
- : "Failed to load knowledge base. Please check if the backend services are running.",
77
- );
78
- } finally {
79
- setLoading(false);
80
- }
81
- }
82
-
83
- useEffect(() => {
84
- // Wait for tenant context to finish loading, then load documents if tenant ID is available
85
- if (!tenantLoading && tenantId && tenantId.trim()) {
86
- loadDocuments();
87
- }
88
- }, [tenantId, tenantLoading]);
89
-
90
- // Filter documents based on search and type
91
- const filteredDocuments = documents.filter((doc) => {
92
- const matchesSearch =
93
- !searchFilter ||
94
- doc.text.toLowerCase().includes(searchFilter.toLowerCase());
95
-
96
- // Simple heuristics for document type detection
97
- const textLower = doc.text.toLowerCase();
98
- let docType: "pdf" | "text" | "faq" | "link" = "text";
99
-
100
- if (textLower.includes("http://") || textLower.includes("https://") || textLower.includes("www.")) {
101
- docType = "link";
102
- } else if (
103
- textLower.includes("q:") ||
104
- textLower.includes("question:") ||
105
- textLower.includes("faq") ||
106
- textLower.includes("frequently asked")
107
- ) {
108
- docType = "faq";
109
- } else if (textLower.includes(".pdf") || textLower.includes("pdf document")) {
110
- docType = "pdf";
111
- }
112
-
113
- const matchesType = filterType === "all" || docType === filterType;
114
- return matchesSearch && matchesType;
115
- });
116
-
117
- const getDocumentType = (text: string): "pdf" | "text" | "faq" | "link" => {
118
- const textLower = text.toLowerCase();
119
- if (textLower.includes("http://") || textLower.includes("https://") || textLower.includes("www.")) {
120
- return "link";
121
- } else if (
122
- textLower.includes("q:") ||
123
- textLower.includes("question:") ||
124
- textLower.includes("faq") ||
125
- textLower.includes("frequently asked")
126
- ) {
127
- return "faq";
128
- } else if (textLower.includes(".pdf") || textLower.includes("pdf document")) {
129
- return "pdf";
130
- }
131
- return "text";
132
- };
133
-
134
- const getTypeColor = (type: string) => {
135
- switch (type) {
136
- case "pdf":
137
- return "bg-red-500/20 text-red-300 border-red-500/30";
138
- case "faq":
139
- return "bg-purple-500/20 text-purple-300 border-purple-500/30";
140
- case "link":
141
- return "bg-blue-500/20 text-blue-300 border-blue-500/30";
142
- default:
143
- return "bg-slate-500/20 text-slate-300 border-slate-500/30";
144
- }
145
- };
146
-
147
- async function handleDeleteDocument(documentId: number) {
148
- if (!tenantId.trim() || isDeleting !== null) return;
149
- setIsDeleting(documentId);
150
-
151
- try {
152
- const response = await fetch(`${API_BASE}/rag/delete/${documentId}`, {
153
- method: "DELETE",
154
- headers: {
155
- "x-tenant-id": tenantId,
156
- "x-user-role": role,
157
- },
158
- });
159
-
160
- if (!response.ok) {
161
- const errorData = await response.json().catch(() => ({}));
162
- const errorMsg = errorData.detail || `Failed to delete document: ${response.status}`;
163
- throw new Error(errorMsg);
164
- }
165
-
166
- // Remove from local state and update total
167
- setDocuments(docs => docs.filter(doc => doc.id !== documentId));
168
- setTotal(prev => Math.max(0, prev - 1));
169
- } catch (err) {
170
- console.error(err);
171
- setError(
172
- err instanceof Error
173
- ? err.message
174
- : "Failed to delete document",
175
- );
176
- } finally {
177
- setIsDeleting(null);
178
- }
179
- }
180
-
181
- async function handleDeleteAll() {
182
- if (!tenantId.trim() || isDeletingAll) return;
183
-
184
- if (!confirm("Are you sure you want to delete ALL documents for this tenant? This action cannot be undone.")) {
185
- return;
186
- }
187
-
188
- setIsDeletingAll(true);
189
-
190
- try {
191
- const response = await fetch(`${API_BASE}/rag/delete-all`, {
192
- method: "DELETE",
193
- headers: {
194
- "x-tenant-id": tenantId,
195
- "x-user-role": role,
196
- },
197
- });
198
-
199
- if (!response.ok) {
200
- throw new Error(`Failed to delete all documents: ${response.status}`);
201
- }
202
-
203
- const data = await response.json();
204
- setDocuments([]);
205
- setTotal(0);
206
- } catch (err) {
207
- console.error(err);
208
- setError(
209
- err instanceof Error
210
- ? err.message
211
- : "Failed to delete all documents",
212
- );
213
- } finally {
214
- setIsDeletingAll(false);
215
- }
216
- }
217
-
218
- return (
219
- <main className="mx-auto flex min-h-screen max-w-7xl flex-col gap-8 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
220
- {/* Header */}
221
- <header className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-sm text-slate-100 shadow-lg shadow-slate-950/40">
222
- <div className="flex items-center gap-3">
223
- <Link
224
- href="/"
225
- className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950 transition hover:scale-105"
226
- >
227
-
228
- </Link>
229
- <div>
230
- <h1 className="text-xl font-semibold">Knowledge Base Library</h1>
231
- <p className="text-xs text-slate-400">
232
- All ingested documents, PDFs, FAQs, links, and text content
233
- </p>
234
- </div>
235
- </div>
236
- <div className="flex items-center gap-3">
237
- {documents.length > 0 && (
238
- <button
239
- onClick={handleDeleteAll}
240
- disabled={isDeletingAll}
241
- className="rounded-full border border-red-500/50 bg-red-500/10 px-5 py-2 text-sm font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
242
- >
243
- {isDeletingAll ? "Deleting…" : "Delete All"}
244
- </button>
245
- )}
246
- <button
247
- onClick={loadDocuments}
248
- disabled={loading}
249
- className="rounded-full bg-gradient-to-r from-sky-400 to-cyan-500 px-5 py-2 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:opacity-60"
250
- >
251
- {loading ? "Loading…" : "Refresh"}
252
- </button>
253
- </div>
254
- </header>
255
-
256
- {/* Stats & Filters */}
257
- <div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/40 p-6">
258
- <div className="flex flex-wrap items-center gap-6">
259
- <div>
260
- <p className="text-xs uppercase tracking-widest text-slate-400">
261
- Total Documents
262
- </p>
263
- <p className="mt-1 text-2xl font-semibold text-white">{total}</p>
264
- </div>
265
- <div>
266
- <p className="text-xs uppercase tracking-widest text-slate-400">
267
- Filtered
268
- </p>
269
- <p className="mt-1 text-2xl font-semibold text-cyan-300">
270
- {filteredDocuments.length}
271
- </p>
272
- </div>
273
- </div>
274
-
275
- <div className="flex flex-wrap items-center gap-3">
276
- <input
277
- type="text"
278
- placeholder="Search documents..."
279
- value={searchFilter}
280
- onChange={(e) => setSearchFilter(e.target.value)}
281
- className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white outline-none focus:border-cyan-300"
282
- />
283
- <div className="flex gap-2">
284
- {(["all", "text", "pdf", "faq", "link"] as const).map((type) => (
285
- <button
286
- key={type}
287
- onClick={() => setFilterType(type)}
288
- className={`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-wider transition ${
289
- filterType === type
290
- ? "bg-cyan-500 text-slate-950"
291
- : "bg-white/5 text-slate-300 hover:bg-white/10"
292
- }`}
293
- >
294
- {type}
295
- </button>
296
- ))}
297
- </div>
298
- </div>
299
- </div>
300
-
301
- {/* Error Message */}
302
- {error && (
303
- <div className="rounded-2xl border border-red-500/40 bg-red-500/10 px-6 py-4 text-red-200">
304
- <p className="font-semibold">⚠️ Error loading knowledge base</p>
305
- <p className="mt-1 text-sm">{error}</p>
306
- <button
307
- onClick={() => {
308
- setError(null);
309
- loadDocuments();
310
- }}
311
- className="mt-3 rounded-lg border border-red-500/50 bg-red-500/20 px-4 py-2 text-sm font-semibold text-red-200 transition hover:bg-red-500/30"
312
- >
313
- Try Again
314
- </button>
315
- </div>
316
- )}
317
-
318
- {/* Documents Grid */}
319
- {loading ? (
320
- <div className="flex items-center justify-center py-20">
321
- <p className="text-slate-400">Loading documents...</p>
322
- </div>
323
- ) : filteredDocuments.length === 0 ? (
324
- <div className="flex flex-col items-center justify-center rounded-2xl border border-white/10 bg-slate-950/40 py-20">
325
- <p className="text-lg font-semibold text-slate-300">
326
- No documents found
327
- </p>
328
- <p className="mt-2 text-sm text-slate-400">
329
- {documents.length === 0
330
- ? "Start by ingesting some content in the Knowledge Base panel."
331
- : "Try adjusting your search or filter criteria."}
332
- </p>
333
- </div>
334
- ) : (
335
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
336
- {filteredDocuments.map((doc) => {
337
- const docType = getDocumentType(doc.text);
338
- const preview = doc.text.slice(0, 200) + (doc.text.length > 200 ? "..." : "");
339
-
340
- return (
341
- <div
342
- key={doc.id}
343
- className="group relative rounded-2xl border border-white/10 bg-slate-950/40 p-5 transition hover:border-cyan-500/50 hover:bg-slate-900/60"
344
- >
345
- <div className="mb-3 flex items-start justify-between gap-2">
346
- <span
347
- className={`rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-wider ${getTypeColor(
348
- docType,
349
- )}`}
350
- >
351
- {docType}
352
- </span>
353
- {doc.created_at && (
354
- <span className="text-xs text-slate-500">
355
- {new Date(doc.created_at).toLocaleDateString()}
356
- </span>
357
- )}
358
- </div>
359
- <p className="text-sm leading-relaxed text-slate-200 line-clamp-6">
360
- {preview}
361
- </p>
362
- <div className="mt-4 flex items-center justify-between">
363
- <div className="flex items-center gap-2 text-xs text-slate-400">
364
- <span>ID: {doc.id}</span>
365
- <span>•</span>
366
- <span>{doc.text.length} chars</span>
367
- </div>
368
- <button
369
- onClick={() => handleDeleteDocument(doc.id)}
370
- disabled={isDeleting === doc.id}
371
- className="rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
372
- >
373
- {isDeleting === doc.id ? "Deleting…" : "Delete"}
374
- </button>
375
- </div>
376
- </div>
377
- );
378
- })}
379
- </div>
380
- )}
381
-
382
- {/* Footer */}
383
- <div className="mt-8 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-center text-sm text-slate-400">
384
- <p>
385
- Knowledge base powered by pgvector + MiniLM embeddings •{" "}
386
- <Link href="/" className="text-cyan-300 hover:text-cyan-200">
387
- Back to Console
388
- </Link>
389
- </p>
390
- </div>
391
- </main>
392
- );
393
- }
394
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/layout.tsx DELETED
@@ -1,38 +0,0 @@
1
- import type { Metadata } from "next";
2
- import { Geist, Geist_Mono } from "next/font/google";
3
- import "./globals.css";
4
- import { TenantProvider } from "@/contexts/TenantContext";
5
-
6
- const geistSans = Geist({
7
- variable: "--font-geist-sans",
8
- subsets: ["latin"],
9
- });
10
-
11
- const geistMono = Geist_Mono({
12
- variable: "--font-geist-mono",
13
- subsets: ["latin"],
14
- });
15
-
16
- export const metadata: Metadata = {
17
- title: "IntegraChat | Multi-Agent Governance Console",
18
- description:
19
- "Operate the IntegraChat MCP stack with live chat, knowledge ingestion, and compliance analytics.",
20
- };
21
-
22
- export default function RootLayout({
23
- children,
24
- }: Readonly<{
25
- children: React.ReactNode;
26
- }>) {
27
- return (
28
- <html lang="en">
29
- <body
30
- className={`${geistSans.variable} ${geistMono.variable} antialiased`}
31
- >
32
- <TenantProvider>
33
- {children}
34
- </TenantProvider>
35
- </body>
36
- </html>
37
- );
38
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/page.tsx DELETED
@@ -1,110 +0,0 @@
1
- "use client";
2
-
3
- import Link from "next/link";
4
-
5
- import { AdminRulesPanel } from "@/components/admin-rules-panel";
6
- import { AnalyticsPanel } from "@/components/analytics-panel";
7
- import { ChatPanel } from "@/components/chat-panel";
8
- import { FeatureGrid } from "@/components/feature-grid";
9
- import { Footer } from "@/components/footer";
10
- import { Hero } from "@/components/hero";
11
- import { KnowledgeBasePanel } from "@/components/knowledge-base-panel";
12
- import { TenantSelector } from "@/components/tenant-selector";
13
- import { useTenant } from "@/contexts/TenantContext";
14
- import {
15
- canManageRules,
16
- canViewAnalytics,
17
- canIngestDocuments,
18
- } from "@/lib/permissions";
19
-
20
- function Navigation() {
21
- const { role } = useTenant();
22
-
23
- const navItems = [
24
- {
25
- label: "Data Ingestion",
26
- href: "/ingestion",
27
- visible: canIngestDocuments(role),
28
- },
29
- { label: "Chat Bot", href: "/chat", visible: true }, // Chat is available to all
30
- {
31
- label: "Analytics",
32
- href: "/analytics",
33
- visible: canViewAnalytics(role),
34
- },
35
- {
36
- label: "Admin Rule Ingestion",
37
- href: "/admin-rules",
38
- visible: canManageRules(role),
39
- },
40
- ];
41
-
42
- const visibleNavItems = navItems.filter((item) => item.visible);
43
-
44
- return (
45
- <nav className="flex flex-wrap gap-2">
46
- {visibleNavItems.map((item) => (
47
- <Link
48
- key={item.href}
49
- href={item.href}
50
- className="rounded-full border border-white/15 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-200 transition hover:border-cyan-400 hover:text-white"
51
- >
52
- {item.label}
53
- </Link>
54
- ))}
55
- </nav>
56
- );
57
- }
58
-
59
- export default function Home() {
60
- const { role } = useTenant();
61
-
62
- return (
63
- <main className="mx-auto flex min-h-screen max-w-6xl flex-col gap-10 px-4 pb-16 pt-12 sm:px-6 lg:px-8">
64
- <header className="flex flex-col gap-6 rounded-2xl border border-white/10 bg-white/5 px-6 py-6 text-slate-100 shadow-lg shadow-slate-950/40">
65
- <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
66
- <div className="flex items-center gap-3 text-base font-semibold">
67
- <span className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950">
68
- IC
69
- </span>
70
- IntegraChat Operator Console
71
- </div>
72
- <div className="flex flex-wrap items-center gap-3 text-xs uppercase tracking-[0.4em] text-slate-400">
73
- FastAPI · MCP Servers · Celery · Next.js
74
- </div>
75
- </div>
76
- <div className="flex flex-wrap items-center justify-between gap-4">
77
- <Navigation />
78
- <TenantSelector />
79
- </div>
80
- </header>
81
-
82
- <Hero />
83
- <FeatureGrid />
84
-
85
- {canIngestDocuments(role) && (
86
- <section id="data-ingestion" className="scroll-mt-28">
87
- <KnowledgeBasePanel />
88
- </section>
89
- )}
90
-
91
- <section id="chat-bot" className="scroll-mt-28">
92
- <ChatPanel />
93
- </section>
94
-
95
- {canViewAnalytics(role) && (
96
- <section id="analytics" className="scroll-mt-28">
97
- <AnalyticsPanel />
98
- </section>
99
- )}
100
-
101
- {canManageRules(role) && (
102
- <section id="admin-rules" className="scroll-mt-28">
103
- <AdminRulesPanel />
104
- </section>
105
- )}
106
-
107
- <Footer />
108
- </main>
109
- );
110
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/admin-rules-panel.tsx DELETED
@@ -1,57 +0,0 @@
1
- export function AdminRulesPanel() {
2
- const highlights = [
3
- {
4
- icon: "📝",
5
- title: "Bulk Upload",
6
- description: "Paste multiple rules or upload from files (TXT, PDF, DOC, DOCX).",
7
- },
8
- {
9
- icon: "🤖",
10
- title: "LLM Enhancement",
11
- description: "Rules are automatically enhanced with edge cases and improved patterns.",
12
- },
13
- {
14
- icon: "⚡",
15
- title: "Chunk Processing",
16
- description: "Large rule sets processed in chunks to avoid timeouts.",
17
- },
18
- {
19
- icon: "🔒",
20
- title: "Tenant Isolation",
21
- description: "Rules are scoped per tenant, ensuring zero data leakage.",
22
- },
23
- ];
24
-
25
- return (
26
- <div className="rounded-3xl border border-white/10 bg-gradient-to-br from-slate-900/80 to-slate-950/80 p-8 shadow-2xl shadow-slate-950/40">
27
- <div className="flex flex-col gap-6">
28
- <div>
29
- <p className="text-sm font-semibold uppercase tracking-[0.3em] text-cyan-400">🛡️ Admin Controls</p>
30
- <h2 className="mt-3 text-3xl font-bold text-white">Admin Rule Management</h2>
31
- <p className="mt-3 text-base leading-relaxed text-slate-300">
32
- Upload governance policies, red-flag keywords, and compliance workflows. Rules are automatically enhanced by LLM,
33
- stored in Supabase/SQLite, and enforced across all MCP toolchains with intelligent pattern matching.
34
- </p>
35
- </div>
36
-
37
- <div className="grid gap-4 md:grid-cols-2">
38
- {highlights.map((item) => (
39
- <div
40
- key={item.title}
41
- className="group rounded-xl border border-white/10 bg-white/5 p-5 text-slate-200 transition-all hover:border-cyan-400/40 hover:bg-white/10 hover:shadow-lg hover:shadow-cyan-500/10"
42
- >
43
- <div className="flex items-start gap-3">
44
- <span className="text-2xl">{item.icon}</span>
45
- <div>
46
- <p className="text-sm font-semibold text-cyan-300">{item.title}</p>
47
- <p className="mt-2 text-sm leading-relaxed text-slate-300">{item.description}</p>
48
- </div>
49
- </div>
50
- </div>
51
- ))}
52
- </div>
53
- </div>
54
- </div>
55
- );
56
- }
57
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/analytics-panel.tsx DELETED
@@ -1,152 +0,0 @@
1
- "use client";
2
-
3
- import { useState } from "react";
4
- import { useTenant } from "@/contexts/TenantContext";
5
-
6
- type ToolUsageStats = {
7
- count: number;
8
- avg_latency_ms: number;
9
- total_tokens: number;
10
- success_count: number;
11
- error_count: number;
12
- };
13
-
14
- type AnalyticsOverview = {
15
- overview: {
16
- total_queries: number;
17
- tool_usage: Record<string, ToolUsageStats>;
18
- redflag_count: number;
19
- active_users: number;
20
- };
21
- };
22
-
23
- const API_BASE =
24
- process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
25
-
26
- export function AnalyticsPanel() {
27
- const { tenantId, role } = useTenant();
28
- const [loading, setLoading] = useState(false);
29
- const [error, setError] = useState<string | null>(null);
30
- const [data, setData] = useState<AnalyticsOverview["overview"] | null>(null);
31
-
32
- async function fetchAnalytics() {
33
- setLoading(true);
34
- setError(null);
35
- try {
36
- const res = await fetch(`${API_BASE}/analytics/overview`, {
37
- headers: {
38
- "x-tenant-id": tenantId,
39
- "x-user-role": role,
40
- },
41
- });
42
- if (!res.ok) {
43
- if (res.status === 403) {
44
- const errorData = await res.json().catch(() => ({}));
45
- throw new Error(
46
- errorData.detail || "Access denied. You need Admin or Owner role to view analytics."
47
- );
48
- }
49
- throw new Error(`Analytics endpoint returned ${res.status}`);
50
- }
51
- const payload: AnalyticsOverview = await res.json();
52
- setData(payload.overview);
53
- } catch (err) {
54
- console.error(err);
55
- setError(
56
- err instanceof Error
57
- ? err.message
58
- : "Unable to reach analytics API. Is the FastAPI service running?",
59
- );
60
- } finally {
61
- setLoading(false);
62
- }
63
- }
64
-
65
- return (
66
- <section
67
- id="analytics"
68
- className="glass-panel border border-white/10 p-6 text-white"
69
- >
70
- <div className="flex flex-wrap items-center justify-between gap-4">
71
- <div>
72
- <p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
73
- Compliance Pulse
74
- </p>
75
- <h2 className="mt-2 text-3xl font-semibold">Analytics snapshot</h2>
76
- </div>
77
- <button
78
- onClick={fetchAnalytics}
79
- disabled={loading}
80
- className="rounded-full bg-white/90 px-5 py-2.5 text-sm font-semibold text-slate-900 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:opacity-60"
81
- >
82
- {loading ? "Loading…" : "Refresh metrics"}
83
- </button>
84
- </div>
85
-
86
- <div className="mt-6 grid gap-4 md:grid-cols-4">
87
- {["total_queries", "active_users", "redflag_count"].map((key) => (
88
- <div
89
- key={key}
90
- className="rounded-2xl border border-white/5 bg-slate-900/40 p-4 text-center"
91
- >
92
- <p className="text-sm uppercase tracking-widest text-slate-400">
93
- {key.replace("_", " ")}
94
- </p>
95
- <p className="mt-2 text-3xl font-semibold">
96
- {data ? data[key as keyof typeof data] : "—"}
97
- </p>
98
- </div>
99
- ))}
100
- <div className="rounded-2xl border border-white/5 bg-slate-900/40 p-4 text-center">
101
- <p className="text-sm uppercase tracking-widest text-slate-400">
102
- Tool usage (top)
103
- </p>
104
- <p className="mt-2 text-3xl font-semibold">
105
- {data
106
- ? Object.entries(data.tool_usage)
107
- .sort((a, b) => b[1].count - a[1].count)[0]?.[0] ?? "—"
108
- : "—"}
109
- </p>
110
- </div>
111
- </div>
112
-
113
- <div className="mt-6 rounded-2xl border border-white/5 bg-slate-950/50 p-4">
114
- <p className="text-sm uppercase tracking-[0.5em] text-slate-400">
115
- Raw tool usage
116
- </p>
117
- <div className="mt-4 grid gap-3 sm:grid-cols-3">
118
- {data
119
- ? Object.entries(data.tool_usage).map(([tool, stats]) => (
120
- <div
121
- key={tool}
122
- className="rounded-xl border border-white/10 bg-white/5 px-4 py-3"
123
- >
124
- <p className="text-sm uppercase tracking-widest text-slate-400">
125
- {tool}
126
- </p>
127
- <p className="text-2xl font-semibold text-white">{stats.count}</p>
128
- </div>
129
- ))
130
- : Array.from({ length: 3 }).map((_, idx) => (
131
- <div
132
- key={idx}
133
- className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-slate-500"
134
- >
135
- <p className="text-sm uppercase tracking-widest text-slate-500">
136
- Tool {idx + 1}
137
- </p>
138
- <p className="text-2xl font-semibold text-slate-500">—</p>
139
- </div>
140
- ))}
141
- </div>
142
- </div>
143
-
144
- {error && (
145
- <p className="mt-4 rounded-2xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
146
- {error}
147
- </p>
148
- )}
149
- </section>
150
- );
151
- }
152
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/chat-panel.tsx DELETED
@@ -1,213 +0,0 @@
1
- "use client";
2
-
3
- import { useMemo, useState } from "react";
4
- import { useTenant } from "@/contexts/TenantContext";
5
- import { ReasoningVisualizer } from "@/components/reasoning-visualizer";
6
- import { ToolTimeline } from "@/components/tool-timeline";
7
-
8
- type Message = {
9
- role: "user" | "assistant" | "system";
10
- content: string;
11
- meta?: string;
12
- reasoningTrace?: Array<Record<string, any>>;
13
- toolTraces?: Array<Record<string, any>>;
14
- };
15
-
16
- const API_BASE =
17
- process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
18
-
19
- export function ChatPanel() {
20
- const { tenantId } = useTenant();
21
- const [message, setMessage] = useState("");
22
- const [isSending, setIsSending] = useState(false);
23
- const [history, setHistory] = useState<Message[]>([
24
- {
25
- role: "assistant",
26
- content:
27
- "Hi there! I'm the IntegraChat orchestrator. Ask anything about your tenant data and I will route the right MCP tools.",
28
- meta: "Agent ready",
29
- },
30
- ]);
31
- const [lastDecision, setLastDecision] = useState<string | null>(null);
32
- const [showVisualizations, setShowVisualizations] = useState(true);
33
- const [currentReasoningTrace, setCurrentReasoningTrace] = useState<Array<Record<string, any>>>([]);
34
- const [currentToolTraces, setCurrentToolTraces] = useState<Array<Record<string, any>>>([]);
35
-
36
- const conversationPayload = useMemo(
37
- () =>
38
- history
39
- .filter((m) => m.role !== "system")
40
- .map((m) => ({
41
- role: m.role,
42
- content: m.content,
43
- })),
44
- [history],
45
- );
46
-
47
- async function handleSend() {
48
- if (!message.trim() || isSending) return;
49
- const userMessage: Message = { role: "user", content: message.trim() };
50
- const optimisticHistory = [...history, userMessage];
51
- setHistory(optimisticHistory);
52
- setMessage("");
53
- setIsSending(true);
54
-
55
- try {
56
- const response = await fetch(`${API_BASE}/agent/message`, {
57
- method: "POST",
58
- headers: {
59
- "Content-Type": "application/json",
60
- },
61
- body: JSON.stringify({
62
- tenant_id: tenantId,
63
- message: userMessage.content,
64
- conversation_history: conversationPayload,
65
- temperature: 0,
66
- }),
67
- });
68
-
69
- if (!response.ok) {
70
- throw new Error(
71
- `API error (${response.status}) – check backend/api/main.py`,
72
- );
73
- }
74
-
75
- const data = await response.json();
76
- const assistantText =
77
- data?.text ??
78
- "Agent responded but text field was empty. Inspect FastAPI logs for clues.";
79
-
80
- // Extract reasoning trace and tool traces
81
- const reasoningTrace = data?.reasoning_trace || [];
82
- const toolTraces = data?.tool_traces || [];
83
-
84
- setHistory((prev) => [
85
- ...prev,
86
- {
87
- role: "assistant",
88
- content: assistantText,
89
- meta: data?.decision?.reason ?? "response",
90
- reasoningTrace,
91
- toolTraces,
92
- },
93
- ]);
94
-
95
- // Update current visualizations
96
- setCurrentReasoningTrace(reasoningTrace);
97
- setCurrentToolTraces(toolTraces);
98
-
99
- setLastDecision(
100
- data?.decision
101
- ? `${data.decision.action} · ${data.decision.tool ?? "llm"}`
102
- : null,
103
- );
104
- } catch (err) {
105
- console.error(err);
106
- setHistory((prev) => [
107
- ...prev,
108
- {
109
- role: "assistant",
110
- content:
111
- err instanceof Error
112
- ? err.message
113
- : "Failed to reach the FastAPI gateway.",
114
- meta: "error",
115
- },
116
- ]);
117
- setLastDecision("error");
118
- } finally {
119
- setIsSending(false);
120
- }
121
- }
122
-
123
- return (
124
- <section
125
- id="chat"
126
- className="gradient-border relative rounded-[28px] p-1 text-white"
127
- >
128
- <div className="glass-panel relative rounded-[26px] p-6">
129
- <div>
130
- <p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
131
- Orchestrator Console
132
- </p>
133
- <h2 className="mt-2 text-3xl font-semibold">
134
- Talk to your enterprise agent
135
- </h2>
136
- </div>
137
- <div className="mt-6 h-[360px] space-y-3 overflow-y-auto rounded-2xl border border-white/10 bg-slate-950/40 p-4 scrollArea">
138
- {history.map((msg, idx) => (
139
- <div
140
- key={`${msg.role}-${idx}`}
141
- className={`flex gap-3 rounded-2xl px-4 py-3 ${
142
- msg.role === "user"
143
- ? "bg-slate-900/70 text-slate-100"
144
- : "bg-cyan-500/10 text-slate-100"
145
- }`}
146
- >
147
- <span className="text-xs font-semibold uppercase tracking-widest text-cyan-200/80">
148
- {msg.role}
149
- </span>
150
- <div className="space-y-1 text-sm">
151
- <p>{msg.content}</p>
152
- {msg.meta && (
153
- <p className="text-xs text-slate-400">{msg.meta}</p>
154
- )}
155
- </div>
156
- </div>
157
- ))}
158
- </div>
159
-
160
- <div className="mt-5 flex flex-col gap-3 md:flex-row">
161
- <textarea
162
- placeholder="Ask about policies, knowledge base hits, or route through RAG/Web/Admin..."
163
- value={message}
164
- onChange={(e) => setMessage(e.target.value)}
165
- className="flex-1 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
166
- rows={3}
167
- />
168
- <button
169
- onClick={handleSend}
170
- disabled={isSending}
171
- className="min-w-[160px] rounded-2xl bg-gradient-to-r from-sky-400 to-cyan-500 px-6 py-3 font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
172
- >
173
- {isSending ? "Routing…" : "Send to MCP"}
174
- </button>
175
- </div>
176
-
177
- <div className="mt-4 flex items-center gap-3 text-sm text-slate-300">
178
- <span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_12px_#34d399]" />
179
- {lastDecision
180
- ? `Last decision: ${lastDecision}`
181
- : "No tool invocation yet"}
182
- </div>
183
- </div>
184
-
185
- {/* Visualizations Toggle */}
186
- {(currentReasoningTrace.length > 0 || currentToolTraces.length > 0) && (
187
- <div className="mt-6">
188
- <button
189
- onClick={() => setShowVisualizations(!showVisualizations)}
190
- className="mb-4 flex w-full items-center justify-between rounded-xl border border-white/10 bg-slate-950/40 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-900/60"
191
- >
192
- <span>Visualizations</span>
193
- <span>{showVisualizations ? "▼" : "▶"}</span>
194
- </button>
195
-
196
- {showVisualizations && (
197
- <div className="space-y-6">
198
- <ReasoningVisualizer
199
- reasoningTrace={currentReasoningTrace}
200
- isActive={isSending}
201
- />
202
- <ToolTimeline
203
- toolTraces={currentToolTraces}
204
- reasoningTrace={currentReasoningTrace}
205
- />
206
- </div>
207
- )}
208
- </div>
209
- )}
210
- </section>
211
- );
212
- }
213
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/feature-grid.tsx DELETED
@@ -1,56 +0,0 @@
1
- const features = [
2
- {
3
- title: "Agent Orchestrator",
4
- description:
5
- "Observability for the FastAPI orchestrator: intents, tool routing, red-flag blocking, and reasoning traces.",
6
- tag: "backend/api/services/agent_orchestrator.py",
7
- },
8
- {
9
- title: "Knowledge RAG MCP",
10
- description:
11
- "Ingest docs, embed with MiniLM, and search tenant-scoped corpora via pgvector—all from the UI.",
12
- tag: "backend/mcp_server/server.py",
13
- },
14
- {
15
- title: "Governance Policies",
16
- description:
17
- "Create, test, and ship regex + semantic rule packs that instantly sync to Admin MCP and Celery alerts.",
18
- tag: "backend/api/services/redflag_detector.py",
19
- },
20
- {
21
- title: "Analytics + Workers",
22
- description:
23
- "Monitor Celery ingestion throughput, tool usage trends, and daily compliance KPIs in one glance.",
24
- tag: "backend/workers/*",
25
- },
26
- ];
27
-
28
- export function FeatureGrid() {
29
- return (
30
- <section className="grid gap-6 md:grid-cols-2">
31
- {features.map((feature) => (
32
- <article
33
- key={feature.title}
34
- className="glass-panel flex flex-col justify-between p-6 transition hover:-translate-y-1 hover:border-cyan-300/50"
35
- >
36
- <div>
37
- <p className="text-xs uppercase tracking-[0.3em] text-slate-400">
38
- {feature.tag}
39
- </p>
40
- <h3 className="mt-4 text-2xl font-semibold text-white">
41
- {feature.title}
42
- </h3>
43
- <p className="mt-4 text-base text-slate-300">
44
- {feature.description}
45
- </p>
46
- </div>
47
- <div className="mt-6 inline-flex items-center gap-2 text-sm text-cyan-200">
48
- <span className="h-1.5 w-1.5 rounded-full bg-cyan-400" />
49
- Ready to run
50
- </div>
51
- </article>
52
- ))}
53
- </section>
54
- );
55
- }
56
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/footer.tsx DELETED
@@ -1,15 +0,0 @@
1
- export function Footer() {
2
- return (
3
- <footer className="mt-16 flex flex-col items-center gap-3 border-t border-white/5 py-8 text-center text-sm text-slate-400">
4
- <p>
5
- IntegraChat · FastAPI + Next.js reference console for multi-agent MCP
6
- stacks.
7
- </p>
8
- <p className="text-xs">
9
- Need backend ports? API 8000 · RAG 8001 · Web 8002 · Admin 8003 · update
10
- env via <code className="rounded bg-white/5 px-1">NEXT_PUBLIC_API_URL</code>
11
- </p>
12
- </footer>
13
- );
14
- }
15
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/hero.tsx DELETED
@@ -1,100 +0,0 @@
1
- import Link from "next/link";
2
-
3
- const stats = [
4
- { label: "Multi-tenant agents", value: "3 MCPs" },
5
- { label: "Policies enforced", value: "128 rules" },
6
- { label: "Avg. response time", value: "1.8s" },
7
- ];
8
-
9
- export function Hero() {
10
- return (
11
- <section className="relative overflow-hidden rounded-[32px] border border-white/10 bg-gradient-to-br from-slate-900 via-slate-900/70 to-cyan-900/40 p-10 text-white shadow-2xl">
12
- <div className="grid gap-12 md:grid-cols-[1.2fr,0.8fr]">
13
- <div className="space-y-8">
14
- <span className="badge">
15
- <span className="h-2 w-2 rounded-full bg-cyan-400" />
16
- realtime oversight
17
- </span>
18
- <h1 className="text-4xl font-semibold leading-tight md:text-5xl">
19
- Run chat agents, red-flag governance, and analytics from a single
20
- console.
21
- </h1>
22
- <p className="text-lg text-slate-200">
23
- IntegraChat brings together the FastAPI backend, MCP tool servers,
24
- and compliance automation into a cohesive operator experience.
25
- Trigger conversations, inspect tool traces, and stream policy
26
- alerts—without leaving the browser.
27
- </p>
28
- <div className="flex flex-wrap gap-4 text-base font-medium">
29
- <Link
30
- href="#chat"
31
- className="rounded-full bg-white/90 px-6 py-3 text-slate-900 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 hover:bg-white"
32
- >
33
- Launch chat workspace
34
- </Link>
35
- <Link
36
- href="#analytics"
37
- className="rounded-full border border-white/30 px-6 py-3 text-white transition hover:border-cyan-300/70 hover:text-cyan-100"
38
- >
39
- View governance metrics
40
- </Link>
41
- </div>
42
- </div>
43
- <div className="glass-panel p-6">
44
- <p className="text-sm uppercase tracking-[0.3em] text-cyan-200/70">
45
- Stack Snapshot
46
- </p>
47
- <ul className="mt-6 space-y-4 text-sm text-slate-100">
48
- <li className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
49
- <div>
50
- <p className="text-xs uppercase tracking-wider text-slate-300">
51
- API Gateway
52
- </p>
53
- <p className="font-semibold text-white">FastAPI 0.110 + CORS</p>
54
- </div>
55
- <span className="text-xs text-slate-300">backend/api</span>
56
- </li>
57
- <li className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
58
- <div>
59
- <p className="text-xs uppercase tracking-wider text-slate-300">
60
- MCP Servers
61
- </p>
62
- <p className="font-semibold text-white">
63
- RAG · Web · Admin policy
64
- </p>
65
- </div>
66
- <span className="text-xs text-slate-300">ports 8001-8003</span>
67
- </li>
68
- <li className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
69
- <div>
70
- <p className="text-xs uppercase tracking-wider text-slate-300">
71
- Workers
72
- </p>
73
- <p className="font-semibold text-white">
74
- Celery ingestion + analytics
75
- </p>
76
- </div>
77
- <span className="text-xs text-slate-300">beat + workers</span>
78
- </li>
79
- </ul>
80
- <div className="mt-6 grid gap-4 rounded-2xl border border-white/10 bg-slate-900/30 p-5 text-center sm:grid-cols-3">
81
- {stats.map((stat) => (
82
- <div key={stat.label}>
83
- <p className="text-2xl font-semibold text-white">
84
- {stat.value}
85
- </p>
86
- <p className="text-xs uppercase tracking-wider text-slate-400">
87
- {stat.label}
88
- </p>
89
- </div>
90
- ))}
91
- </div>
92
- </div>
93
- </div>
94
- <div className="pointer-events-none absolute inset-0 opacity-40">
95
- <div className="grid-fade" />
96
- </div>
97
- </section>
98
- );
99
- }
100
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/ingestion-card.tsx DELETED
@@ -1,56 +0,0 @@
1
- const steps = [
2
- {
3
- title: "Chunk & Embed",
4
- detail:
5
- "Uploads land in Celery ingestion workers, chunked to 800 chars with 100 overlap, and embedded via MiniLM or hash fallback.",
6
- },
7
- {
8
- title: "Supabase / pgvector",
9
- detail:
10
- "Chunks upsert into tenant-scoped tables with metadata, ready for RAG MCP retrieval.",
11
- },
12
- {
13
- title: "Quality Tasks",
14
- detail:
15
- "Nightly analytics + RAG precision@k jobs run through Celery beat (`scheduler.py`).",
16
- },
17
- ];
18
-
19
- export function IngestionCard() {
20
- return (
21
- <section className="glass-panel border border-white/10 p-6 text-white">
22
- <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
23
- <div>
24
- <p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
25
- Knowledge Ops
26
- </p>
27
- <h2 className="mt-2 text-3xl font-semibold">Ingestion pipeline</h2>
28
- <p className="mt-4 text-base text-slate-300">
29
- Drop PDFs, DOCX, MD, or direct raw text and let Celery handle the
30
- rest. Every step mirrors the backend implementation in
31
- `backend/workers/ingestion_worker.py`, so what you see locally is
32
- what ships to production.
33
- </p>
34
- </div>
35
- <div className="rounded-full border border-white/10 bg-white/10 px-4 py-2 text-sm text-slate-100">
36
- Celery broker / beat ready
37
- </div>
38
- </div>
39
- <ol className="mt-6 grid gap-4 md:grid-cols-3">
40
- {steps.map((step, idx) => (
41
- <li
42
- key={step.title}
43
- className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"
44
- >
45
- <p className="text-xs uppercase tracking-[0.4em] text-slate-400">
46
- Step {idx + 1}
47
- </p>
48
- <h3 className="mt-2 text-xl font-semibold">{step.title}</h3>
49
- <p className="mt-2 text-sm text-slate-300">{step.detail}</p>
50
- </li>
51
- ))}
52
- </ol>
53
- </section>
54
- );
55
- }
56
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/knowledge-base-panel.tsx DELETED
@@ -1,614 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useRef, useEffect } from "react";
4
- import Link from "next/link";
5
- import { useTenant } from "@/contexts/TenantContext";
6
- import { canDeleteDocuments } from "@/lib/permissions";
7
-
8
- type SearchResult = {
9
- text: string;
10
- similarity?: number;
11
- relevance?: number;
12
- };
13
-
14
- type Document = {
15
- id: number;
16
- text: string;
17
- created_at?: string;
18
- };
19
-
20
- type SourceType = "raw_text" | "url" | "pdf" | "docx" | "txt" | "markdown";
21
-
22
- const API_BASE =
23
- process.env.NEXT_PUBLIC_BACKEND_BASE_URL?.replace(/\/$/, "") || "http://localhost:8000";
24
-
25
- export function KnowledgeBasePanel() {
26
- const { tenantId, isLoading: tenantLoading, role } = useTenant();
27
- const canDelete = canDeleteDocuments(role);
28
- const [searchQuery, setSearchQuery] = useState("");
29
- const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
30
- const [isSearching, setIsSearching] = useState(false);
31
- const [ingestContent, setIngestContent] = useState("");
32
- const [sourceType, setSourceType] = useState<SourceType>("raw_text");
33
- const [filename, setFilename] = useState("");
34
- const [url, setUrl] = useState("");
35
- const [isIngesting, setIsIngesting] = useState(false);
36
- const [ingestStatus, setIngestStatus] = useState<string | null>(null);
37
- const [searchError, setSearchError] = useState<string | null>(null);
38
- const [documents, setDocuments] = useState<Document[]>([]);
39
- const [isLoadingDocs, setIsLoadingDocs] = useState(false);
40
- const [isDeleting, setIsDeleting] = useState<number | null>(null);
41
- const [isDeletingAll, setIsDeletingAll] = useState(false);
42
- const fileInputRef = useRef<HTMLInputElement>(null);
43
-
44
- async function handleSearch() {
45
- if (!searchQuery.trim() || isSearching) return;
46
- setIsSearching(true);
47
- setSearchError(null);
48
- setSearchResults([]);
49
-
50
- try {
51
- const response = await fetch(`${API_BASE}/rag/search`, {
52
- method: "POST",
53
- headers: {
54
- "Content-Type": "application/json",
55
- "x-tenant-id": tenantId,
56
- "x-user-role": role,
57
- },
58
- body: JSON.stringify({ query: searchQuery }),
59
- });
60
-
61
- if (!response.ok) {
62
- throw new Error(`Search failed: ${response.status}`);
63
- }
64
-
65
- const data = await response.json();
66
- setSearchResults(data.results || []);
67
- } catch (err) {
68
- console.error(err);
69
- setSearchError(
70
- err instanceof Error
71
- ? err.message
72
- : "Failed to search knowledge base. Is the RAG MCP server running?",
73
- );
74
- } finally {
75
- setIsSearching(false);
76
- }
77
- }
78
-
79
- async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
80
- const file = event.target.files?.[0];
81
- if (!file) return;
82
-
83
- // Detect file type from extension
84
- const ext = file.name.split('.').pop()?.toLowerCase();
85
- let detectedType: SourceType = "raw_text";
86
- if (ext === "pdf") detectedType = "pdf";
87
- else if (ext === "docx" || ext === "doc") detectedType = "docx";
88
- else if (ext === "txt" || ext === "text") detectedType = "txt";
89
- else if (ext === "md" || ext === "markdown") detectedType = "markdown";
90
-
91
- setSourceType(detectedType);
92
- setFilename(file.name);
93
-
94
- // For binary files (PDF, DOCX), upload directly to server
95
- if (detectedType === "pdf" || detectedType === "docx") {
96
- await handleFileIngest(file);
97
- return;
98
- }
99
-
100
- // For text files, read and show in textarea
101
- const reader = new FileReader();
102
- reader.onload = async (e) => {
103
- const text = e.target?.result as string;
104
- setIngestContent(text);
105
- };
106
- reader.readAsText(file);
107
- }
108
-
109
- async function handleFileIngest(file: File) {
110
- setIsIngesting(true);
111
- setIngestStatus(null);
112
-
113
- try {
114
- const formData = new FormData();
115
- formData.append("file", file);
116
-
117
- const response = await fetch(`${API_BASE}/rag/ingest-file`, {
118
- method: "POST",
119
- headers: {
120
- "x-tenant-id": tenantId,
121
- "x-user-role": role,
122
- },
123
- body: formData,
124
- });
125
-
126
- if (!response.ok) {
127
- const errorData = await response.json().catch(() => ({}));
128
- throw new Error(
129
- errorData.detail || `File ingestion failed: ${response.status}`,
130
- );
131
- }
132
-
133
- const data = await response.json();
134
- setIngestStatus(
135
- `✅ ${data.message || `Successfully ingested ${data.chunks_stored || 0} chunk(s)`}`,
136
- );
137
- setFilename("");
138
- if (fileInputRef.current) {
139
- fileInputRef.current.value = "";
140
- }
141
- // Reload documents after successful ingestion
142
- loadDocuments();
143
- } catch (err) {
144
- console.error(err);
145
- setIngestStatus(
146
- err instanceof Error
147
- ? `❌ Error: ${err.message}`
148
- : "Failed to ingest file. Is the RAG MCP server running?",
149
- );
150
- } finally {
151
- setIsIngesting(false);
152
- }
153
- }
154
-
155
- async function handleIngest() {
156
- if (!ingestContent.trim() || isIngesting) return;
157
- setIsIngesting(true);
158
- setIngestStatus(null);
159
-
160
- try {
161
- // Prepare metadata
162
- const metadata: Record<string, string> = {};
163
- if (filename) metadata.filename = filename;
164
- if (url || sourceType === "url") {
165
- const ingestUrl = url || ingestContent.trim();
166
- metadata.url = ingestUrl;
167
- }
168
- if (filename) {
169
- // Generate doc_id from filename
170
- metadata.doc_id = filename.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase();
171
- }
172
-
173
- // Use the new enhanced endpoint
174
- const response = await fetch(`${API_BASE}/rag/ingest-document`, {
175
- method: "POST",
176
- headers: {
177
- "Content-Type": "application/json",
178
- "x-tenant-id": tenantId,
179
- "x-user-role": role,
180
- },
181
- body: JSON.stringify({
182
- action: "ingest_document",
183
- tenant_id: tenantId,
184
- source_type: sourceType,
185
- content: sourceType === "url" ? (url || ingestContent.trim()) : ingestContent,
186
- metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
187
- }),
188
- });
189
-
190
- if (!response.ok) {
191
- const errorData = await response.json().catch(() => ({}));
192
- throw new Error(
193
- errorData.detail || `Ingestion failed: ${response.status}`,
194
- );
195
- }
196
-
197
- const data = await response.json();
198
- setIngestStatus(
199
- `✅ ${data.message || `Successfully ingested ${data.chunks_stored || 0} chunk(s)`}`,
200
- );
201
- setIngestContent("");
202
- setFilename("");
203
- setUrl("");
204
- if (fileInputRef.current) {
205
- fileInputRef.current.value = "";
206
- }
207
- // Reload documents after successful ingestion
208
- loadDocuments();
209
- } catch (err) {
210
- console.error(err);
211
- setIngestStatus(
212
- err instanceof Error
213
- ? `❌ Error: ${err.message}`
214
- : "Failed to ingest content. Is the RAG MCP server running?",
215
- );
216
- } finally {
217
- setIsIngesting(false);
218
- }
219
- }
220
-
221
- async function loadDocuments() {
222
- // Guard against empty tenant ID
223
- if (!tenantId || !tenantId.trim() || isLoadingDocs) return;
224
- setIsLoadingDocs(true);
225
-
226
- try {
227
- const response = await fetch(`${API_BASE}/rag/list?limit=10&offset=0`, {
228
- method: "GET",
229
- headers: {
230
- "x-tenant-id": tenantId,
231
- "x-user-role": role,
232
- },
233
- });
234
-
235
- if (!response.ok) {
236
- const errorData = await response.json().catch(() => ({}));
237
- const errorMsg = errorData.detail || errorData.message || `Failed to load documents (${response.status})`;
238
-
239
- if (response.status === 400) {
240
- // Missing tenant ID - silently fail, user will see empty list
241
- console.warn("Cannot load documents: Missing tenant ID");
242
- setDocuments([]);
243
- return;
244
- } else if (response.status === 503) {
245
- console.warn("Cannot connect to RAG MCP server");
246
- setDocuments([]);
247
- return;
248
- } else {
249
- throw new Error(errorMsg);
250
- }
251
- }
252
-
253
- const data = await response.json();
254
- setDocuments(data.documents || []);
255
- } catch (err) {
256
- // Handle network errors (e.g., backend not running, CORS, etc.)
257
- if (err instanceof TypeError && err.message === "Failed to fetch") {
258
- // Network error - backend likely not running or unreachable
259
- console.warn("Cannot connect to backend. Make sure the backend server is running.");
260
- setDocuments([]);
261
- } else {
262
- console.error("Error loading documents:", err);
263
- setDocuments([]);
264
- }
265
- // Don't show error in status for document loading - it's not critical
266
- } finally {
267
- setIsLoadingDocs(false);
268
- }
269
- }
270
-
271
- async function handleDeleteDocument(documentId: number) {
272
- if (!tenantId.trim() || isDeleting !== null) return;
273
- setIsDeleting(documentId);
274
-
275
- try {
276
- const response = await fetch(`${API_BASE}/rag/delete/${documentId}`, {
277
- method: "DELETE",
278
- headers: {
279
- "x-tenant-id": tenantId,
280
- "x-user-role": role,
281
- },
282
- });
283
-
284
- if (!response.ok) {
285
- const errorData = await response.json().catch(() => ({}));
286
- const errorMsg = errorData.detail || `Failed to delete document: ${response.status}`;
287
- throw new Error(errorMsg);
288
- }
289
-
290
- // Remove from local state
291
- setDocuments(docs => docs.filter(doc => doc.id !== documentId));
292
- setIngestStatus("✅ Document deleted successfully");
293
- } catch (err) {
294
- console.error(err);
295
- setIngestStatus(
296
- err instanceof Error
297
- ? `❌ Error: ${err.message}`
298
- : "Failed to delete document",
299
- );
300
- } finally {
301
- setIsDeleting(null);
302
- }
303
- }
304
-
305
- async function handleDeleteAll() {
306
- if (!tenantId.trim() || isDeletingAll) return;
307
-
308
- if (!confirm("Are you sure you want to delete ALL documents for this tenant? This action cannot be undone.")) {
309
- return;
310
- }
311
-
312
- setIsDeletingAll(true);
313
-
314
- try {
315
- const response = await fetch(`${API_BASE}/rag/delete-all`, {
316
- method: "DELETE",
317
- headers: {
318
- "x-tenant-id": tenantId,
319
- "x-user-role": role,
320
- },
321
- });
322
-
323
- if (!response.ok) {
324
- throw new Error(`Failed to delete all documents: ${response.status}`);
325
- }
326
-
327
- const data = await response.json();
328
- setDocuments([]);
329
- setIngestStatus(`✅ Deleted ${data.deleted_count || 0} document(s)`);
330
- } catch (err) {
331
- console.error(err);
332
- setIngestStatus(
333
- err instanceof Error
334
- ? `❌ Error: ${err.message}`
335
- : "Failed to delete all documents",
336
- );
337
- } finally {
338
- setIsDeletingAll(false);
339
- }
340
- }
341
-
342
- // Load documents on mount and when tenant changes
343
- useEffect(() => {
344
- // Wait for tenant context to finish loading, then load documents if tenant ID is available
345
- if (!tenantLoading && tenantId && tenantId.trim()) {
346
- loadDocuments();
347
- }
348
- // eslint-disable-next-line react-hooks/exhaustive-deps
349
- }, [tenantId, tenantLoading, role]);
350
-
351
- return (
352
- <section
353
- id="knowledge-base"
354
- className="gradient-border relative rounded-[28px] p-1 text-white"
355
- >
356
- <div className="glass-panel relative rounded-[26px] p-6">
357
- <div className="flex flex-wrap items-center justify-between gap-4">
358
- <div>
359
- <p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70">
360
- Knowledge Base
361
- </p>
362
- <h2 className="mt-2 text-3xl font-semibold">
363
- Search & ingest documents
364
- </h2>
365
- </div>
366
- <Link
367
- href="/knowledge-base"
368
- className="rounded-full border border-cyan-500/50 bg-cyan-500/10 px-5 py-2.5 text-sm font-semibold text-cyan-300 transition hover:bg-cyan-500/20"
369
- >
370
- View All Documents →
371
- </Link>
372
- </div>
373
-
374
- {/* Search Section */}
375
- <div className="mt-6">
376
- <div className="flex flex-col gap-3 md:flex-row">
377
- <input
378
- type="text"
379
- placeholder="Search knowledge base (e.g., 'HR policy', 'refund procedure')..."
380
- value={searchQuery}
381
- onChange={(e) => setSearchQuery(e.target.value)}
382
- onKeyDown={(e) => e.key === "Enter" && handleSearch()}
383
- className="flex-1 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
384
- />
385
- <button
386
- onClick={handleSearch}
387
- disabled={isSearching}
388
- className="min-w-[140px] rounded-2xl bg-gradient-to-r from-sky-400 to-cyan-500 px-6 py-3 font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
389
- >
390
- {isSearching ? "Searching…" : "Search"}
391
- </button>
392
- </div>
393
-
394
- {searchError && (
395
- <p className="mt-3 rounded-2xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
396
- {searchError}
397
- </p>
398
- )}
399
-
400
- {searchResults.length > 0 && (
401
- <div className="mt-4 space-y-3">
402
- <p className="text-sm uppercase tracking-widest text-slate-400">
403
- Found {searchResults.length} result(s)
404
- </p>
405
- {searchResults.map((result, idx) => (
406
- <div
407
- key={idx}
408
- className="rounded-2xl border border-white/10 bg-slate-950/40 p-4"
409
- >
410
- <div className="flex items-start justify-between gap-3">
411
- <p className="flex-1 text-sm text-slate-200">
412
- {result.text}
413
- </p>
414
- {(result.similarity !== undefined ||
415
- result.relevance !== undefined) && (
416
- <span className="text-xs text-cyan-300">
417
- {(
418
- result.similarity ?? result.relevance ?? 0
419
- ).toFixed(2)}
420
- </span>
421
- )}
422
- </div>
423
- </div>
424
- ))}
425
- </div>
426
- )}
427
- </div>
428
-
429
- {/* Ingest Section */}
430
- <div className="mt-8 rounded-2xl border border-white/10 bg-slate-950/40 p-4">
431
- <p className="text-sm uppercase tracking-[0.5em] text-slate-400">
432
- Add to Knowledge Base
433
- </p>
434
- <p className="mt-2 text-sm text-slate-300">
435
- Upload files (PDF, DOCX, TXT, MD), paste text, or provide URLs. Content will be chunked, embedded, and stored.
436
- </p>
437
-
438
- {/* Source Type Selector */}
439
- <div className="mt-4 flex flex-wrap gap-2">
440
- {(["raw_text", "url", "pdf", "docx", "txt", "markdown"] as SourceType[]).map((type) => (
441
- <button
442
- key={type}
443
- onClick={() => {
444
- setSourceType(type);
445
- if (type !== "url") setUrl("");
446
- }}
447
- className={`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-wider transition ${
448
- sourceType === type
449
- ? "bg-cyan-500 text-slate-950"
450
- : "bg-white/5 text-slate-300 hover:bg-white/10"
451
- }`}
452
- >
453
- {type.replace("_", " ")}
454
- </button>
455
- ))}
456
- </div>
457
-
458
- {/* File Upload */}
459
- <div className="mt-4">
460
- <input
461
- ref={fileInputRef}
462
- type="file"
463
- accept=".pdf,.docx,.doc,.txt,.md,.markdown"
464
- onChange={handleFileUpload}
465
- className="hidden"
466
- id="file-upload"
467
- />
468
- <label
469
- htmlFor="file-upload"
470
- className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10"
471
- >
472
- 📄 Upload File (PDF, DOCX, TXT, MD)
473
- </label>
474
- {filename && (
475
- <span className="ml-3 text-sm text-cyan-300">{filename}</span>
476
- )}
477
- </div>
478
-
479
- {/* URL Input (when source type is URL) */}
480
- {sourceType === "url" && (
481
- <input
482
- type="url"
483
- placeholder="Enter URL to fetch content from..."
484
- value={url}
485
- onChange={(e) => setUrl(e.target.value)}
486
- className="mt-4 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
487
- />
488
- )}
489
-
490
- {/* Content Textarea */}
491
- <textarea
492
- placeholder={
493
- sourceType === "url"
494
- ? "Or paste URL here..."
495
- : "Paste document content here (e.g., policy text, procedures, documentation, FAQs)..."
496
- }
497
- value={ingestContent}
498
- onChange={(e) => setIngestContent(e.target.value)}
499
- className="mt-4 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
500
- rows={6}
501
- />
502
-
503
- {/* Filename Input (optional) */}
504
- {sourceType !== "url" && (
505
- <input
506
- type="text"
507
- placeholder="Filename (optional, e.g., policy.pdf)"
508
- value={filename}
509
- onChange={(e) => setFilename(e.target.value)}
510
- className="mt-3 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80"
511
- />
512
- )}
513
-
514
- <div className="mt-4 flex items-center gap-3">
515
- <button
516
- onClick={handleIngest}
517
- disabled={
518
- isIngesting ||
519
- (!ingestContent.trim() && !url.trim() && sourceType === "url")
520
- }
521
- className="rounded-2xl bg-gradient-to-r from-emerald-400 to-teal-500 px-6 py-2.5 font-semibold text-slate-950 shadow-lg shadow-emerald-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60"
522
- >
523
- {isIngesting ? "Ingesting…" : `Ingest as ${sourceType.replace("_", " ")}`}
524
- </button>
525
- {ingestStatus && (
526
- <p className="text-sm text-slate-300">{ingestStatus}</p>
527
- )}
528
- </div>
529
- </div>
530
-
531
- {/* Manage Documents Section */}
532
- <div className="mt-8 rounded-2xl border border-white/10 bg-slate-950/40 p-4">
533
- <div className="flex items-center justify-between mb-4">
534
- <div>
535
- <p className="text-sm uppercase tracking-[0.5em] text-slate-400">
536
- Manage Documents
537
- </p>
538
- <p className="mt-2 text-sm text-slate-300">
539
- View and delete your ingested documents
540
- </p>
541
- </div>
542
- <div className="flex items-center gap-3">
543
- <button
544
- onClick={loadDocuments}
545
- disabled={isLoadingDocs}
546
- className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/10 disabled:opacity-60"
547
- >
548
- {isLoadingDocs ? "Loading…" : "Refresh"}
549
- </button>
550
- {canDelete && documents.length > 0 && (
551
- <button
552
- onClick={handleDeleteAll}
553
- disabled={isDeletingAll}
554
- className="rounded-full border border-red-500/50 bg-red-500/10 px-4 py-2 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
555
- >
556
- {isDeletingAll ? "Deleting…" : "Delete All"}
557
- </button>
558
- )}
559
- </div>
560
- </div>
561
-
562
- {documents.length === 0 && !isLoadingDocs && (
563
- <p className="text-sm text-slate-400 text-center py-4">
564
- No documents found. Ingest some content to get started.
565
- </p>
566
- )}
567
-
568
- {isLoadingDocs && (
569
- <p className="text-sm text-slate-400 text-center py-4">
570
- Loading documents…
571
- </p>
572
- )}
573
-
574
- {documents.length > 0 && (
575
- <div className="space-y-2 max-h-96 overflow-y-auto">
576
- {documents.map((doc) => (
577
- <div
578
- key={doc.id}
579
- className="flex items-start justify-between gap-3 rounded-xl border border-white/10 bg-white/5 p-3"
580
- >
581
- <div className="flex-1 min-w-0">
582
- <p className="text-xs text-slate-400 mb-1">
583
- ID: {doc.id}
584
- {doc.created_at && (
585
- <span className="ml-2">
586
- • {new Date(doc.created_at).toLocaleDateString()}
587
- </span>
588
- )}
589
- </p>
590
- <p className="text-sm text-slate-200 line-clamp-2">
591
- {doc.text.length > 150
592
- ? `${doc.text.substring(0, 150)}...`
593
- : doc.text}
594
- </p>
595
- </div>
596
- {canDelete && (
597
- <button
598
- onClick={() => handleDeleteDocument(doc.id)}
599
- disabled={isDeleting === doc.id}
600
- className="flex-shrink-0 rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60"
601
- >
602
- {isDeleting === doc.id ? "Deleting…" : "Delete"}
603
- </button>
604
- )}
605
- </div>
606
- ))}
607
- </div>
608
- )}
609
- </div>
610
- </div>
611
- </section>
612
- );
613
- }
614
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/reasoning-visualizer.tsx DELETED
@@ -1,245 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect, useState } from "react";
4
-
5
- type ReasoningStep = {
6
- step: string;
7
- status: "pending" | "running" | "completed" | "error";
8
- message?: string;
9
- details?: Record<string, any>;
10
- timestamp?: number;
11
- };
12
-
13
- type ReasoningVisualizerProps = {
14
- reasoningTrace?: Array<Record<string, any>>;
15
- isActive?: boolean;
16
- onComplete?: () => void;
17
- };
18
-
19
- const STEP_ICONS: Record<string, string> = {
20
- request_received: "📥",
21
- admin_rules_check: "🛡️",
22
- intent_detection: "🧠",
23
- rag_prefetch: "📚",
24
- tool_scoring: "📊",
25
- tool_selection: "🎯",
26
- tool_execution: "⚙️",
27
- llm_response: "💬",
28
- result_merger: "🔀",
29
- parallel_execution: "⚡",
30
- error: "❌",
31
- };
32
-
33
- const STEP_LABELS: Record<string, string> = {
34
- request_received: "Request Received",
35
- admin_rules_check: "Checking Admin Rules",
36
- intent_detection: "Detecting Intent",
37
- rag_prefetch: "Pre-fetching RAG Results",
38
- tool_scoring: "Scoring Tools",
39
- tool_selection: "Selecting Tools",
40
- tool_execution: "Executing Tools",
41
- llm_response: "Generating Response",
42
- result_merger: "Merging Results",
43
- parallel_execution: "Parallel Execution",
44
- error: "Error",
45
- };
46
-
47
- export function ReasoningVisualizer({
48
- reasoningTrace = [],
49
- isActive = false,
50
- onComplete,
51
- }: ReasoningVisualizerProps) {
52
- const [steps, setSteps] = useState<ReasoningStep[]>([]);
53
- const [currentStepIndex, setCurrentStepIndex] = useState(0);
54
-
55
- useEffect(() => {
56
- if (!reasoningTrace || reasoningTrace.length === 0) {
57
- setSteps([]);
58
- setCurrentStepIndex(0);
59
- return;
60
- }
61
-
62
- // Convert reasoning trace to visual steps
63
- const visualSteps: ReasoningStep[] = reasoningTrace.map((trace, idx) => {
64
- const stepName = trace.step || "unknown";
65
- const icon = STEP_ICONS[stepName] || "⚙️";
66
- const label = STEP_LABELS[stepName] || stepName.replace(/_/g, " ");
67
-
68
- // Build message from trace data
69
- let message = label;
70
- const details: Record<string, any> = {};
71
-
72
- if (stepName === "admin_rules_check") {
73
- const matchCount = trace.match_count || 0;
74
- message = matchCount > 0
75
- ? `Found ${matchCount} rule violation(s)`
76
- : "No violations found";
77
- details.matches = trace.matches || [];
78
- } else if (stepName === "intent_detection") {
79
- message = `Intent: ${trace.intent || "unknown"}`;
80
- details.intent = trace.intent;
81
- } else if (stepName === "rag_prefetch") {
82
- const hitCount = trace.hit_count || 0;
83
- message = hitCount > 0
84
- ? `Found ${hitCount} relevant document(s)`
85
- : "No documents found";
86
- details.hit_count = hitCount;
87
- details.latency_ms = trace.latency_ms;
88
- } else if (stepName === "tool_selection") {
89
- const decision = trace.decision;
90
- if (decision) {
91
- message = `Selected: ${decision.tool || "llm"} (${decision.action})`;
92
- details.decision = decision;
93
- }
94
- } else if (stepName === "tool_execution") {
95
- const tool = trace.tool || "unknown";
96
- const hitCount = trace.hit_count || 0;
97
- message = `${tool.toUpperCase()}: ${hitCount} result(s)`;
98
- details.tool = tool;
99
- details.hit_count = hitCount;
100
- } else if (stepName === "result_merger") {
101
- const mergedItems = trace.merged_items || 0;
102
- message = `Merged ${mergedItems} result(s)`;
103
- details.merged_items = mergedItems;
104
- details.sources = trace.sources || [];
105
- } else if (stepName === "llm_response") {
106
- message = "Generating final response";
107
- details.latency_ms = trace.latency_ms;
108
- details.estimated_tokens = trace.estimated_tokens;
109
- }
110
-
111
- return {
112
- step: stepName,
113
- status: idx < currentStepIndex ? "completed" : idx === currentStepIndex ? "running" : "pending",
114
- message,
115
- details,
116
- timestamp: Date.now(),
117
- };
118
- });
119
-
120
- setSteps(visualSteps);
121
-
122
- // Animate through steps if active
123
- if (isActive && visualSteps.length > 0) {
124
- const interval = setInterval(() => {
125
- setCurrentStepIndex((prev) => {
126
- if (prev < visualSteps.length - 1) {
127
- return prev + 1;
128
- } else {
129
- clearInterval(interval);
130
- if (onComplete) onComplete();
131
- return prev;
132
- }
133
- });
134
- }, 800); // 800ms per step
135
-
136
- return () => clearInterval(interval);
137
- } else if (!isActive && visualSteps.length > 0) {
138
- // Show all steps as completed if not active
139
- setCurrentStepIndex(visualSteps.length);
140
- }
141
- }, [reasoningTrace, isActive, currentStepIndex, onComplete]);
142
-
143
- if (steps.length === 0) {
144
- return (
145
- <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
146
- <p className="text-sm text-slate-400 text-center">
147
- No reasoning trace available. Send a message to see the agent's reasoning path.
148
- </p>
149
- </div>
150
- );
151
- }
152
-
153
- return (
154
- <div className="rounded-2xl border border-white/10 bg-slate-950/40 p-6">
155
- <div className="mb-4 flex items-center justify-between">
156
- <h3 className="text-lg font-semibold text-white">Real-Time Reasoning Path</h3>
157
- <span className="text-xs text-slate-400">{steps.length} steps</span>
158
- </div>
159
-
160
- <div className="space-y-3">
161
- {steps.map((step, idx) => {
162
- const isCompleted = step.status === "completed";
163
- const isRunning = step.status === "running";
164
- const isPending = step.status === "pending";
165
-
166
- return (
167
- <div
168
- key={idx}
169
- className={`relative flex items-start gap-4 rounded-xl border p-4 transition-all ${
170
- isRunning
171
- ? "border-cyan-500/50 bg-cyan-500/10 shadow-lg shadow-cyan-500/20"
172
- : isCompleted
173
- ? "border-emerald-500/30 bg-emerald-500/5"
174
- : "border-white/5 bg-white/5 opacity-50"
175
- }`}
176
- >
177
- {/* Step number and icon */}
178
- <div
179
- className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-lg transition-all ${
180
- isRunning
181
- ? "bg-cyan-500 text-white animate-pulse"
182
- : isCompleted
183
- ? "bg-emerald-500 text-white"
184
- : "bg-slate-700 text-slate-400"
185
- }`}
186
- >
187
- {isRunning ? (
188
- <span className="animate-spin">⏳</span>
189
- ) : isCompleted ? (
190
- "✓"
191
- ) : (
192
- idx + 1
193
- )}
194
- </div>
195
-
196
- {/* Step content */}
197
- <div className="flex-1 min-w-0">
198
- <div className="flex items-center gap-2">
199
- <span className="text-lg">{STEP_ICONS[step.step] || "⚙️"}</span>
200
- <h4 className="font-semibold text-white">
201
- {STEP_LABELS[step.step] || step.step.replace(/_/g, " ")}
202
- </h4>
203
- {isRunning && (
204
- <span className="ml-auto text-xs text-cyan-300 animate-pulse">
205
- Running...
206
- </span>
207
- )}
208
- </div>
209
- <p className="mt-1 text-sm text-slate-300">{step.message}</p>
210
-
211
- {/* Step details */}
212
- {step.details && Object.keys(step.details).length > 0 && isCompleted && (
213
- <div className="mt-2 space-y-1 text-xs text-slate-400">
214
- {step.details.latency_ms && (
215
- <span>⏱️ {step.details.latency_ms}ms</span>
216
- )}
217
- {step.details.hit_count !== undefined && (
218
- <span>📊 {step.details.hit_count} hits</span>
219
- )}
220
- {step.details.estimated_tokens && (
221
- <span>🔢 ~{step.details.estimated_tokens} tokens</span>
222
- )}
223
- {step.details.score && (
224
- <span>⭐ Score: {step.details.score.toFixed(2)}</span>
225
- )}
226
- </div>
227
- )}
228
- </div>
229
-
230
- {/* Connecting line */}
231
- {idx < steps.length - 1 && (
232
- <div
233
- className={`absolute left-[29px] top-[50px] h-6 w-0.5 ${
234
- isCompleted ? "bg-emerald-500/50" : "bg-slate-700"
235
- }`}
236
- />
237
- )}
238
- </div>
239
- );
240
- })}
241
- </div>
242
- </div>
243
- );
244
- }
245
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components/rule-explanation.tsx DELETED
@@ -1,129 +0,0 @@
1
- "use client";
2
-
3
- type RuleExplanationProps = {
4
- explanation?: string;
5
- examples?: string[];
6
- missingPatterns?: string[];
7
- edgeCases?: string[];
8
- improvements?: string[];
9
- severity?: string;
10
- };
11
-
12
- export function RuleExplanation({
13
- explanation,
14
- examples = [],
15
- missingPatterns = [],
16
- edgeCases = [],
17
- improvements = [],
18
- severity,
19
- }: RuleExplanationProps) {
20
- if (!explanation && examples.length === 0 && missingPatterns.length === 0) {
21
- return null;
22
- }
23
-
24
- const severityColors = {
25
- low: "bg-blue-500/20 border-blue-500/50 text-blue-200",
26
- medium: "bg-yellow-500/20 border-yellow-500/50 text-yellow-200",
27
- high: "bg-orange-500/20 border-orange-500/50 text-orange-200",
28
- critical: "bg-red-500/20 border-red-500/50 text-red-200",
29
- };
30
-
31
- const severityColor = severityColors[severity as keyof typeof severityColors] || severityColors.medium;
32
-
33
- return (
34
- <div className="mt-4 space-y-4 rounded-2xl border border-white/10 bg-slate-950/60 p-6">
35
- {/* Explanation */}
36
- {explanation && (
37
- <div>
38
- <h4 className="mb-2 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-cyan-300">
39
- <span>💡</span> Explanation
40
- </h4>
41
- <p className="text-sm leading-relaxed text-slate-200">{explanation}</p>
42
- </div>
43
- )}
44
-
45
- {/* Examples */}
46
- {examples.length > 0 && (
47
- <div>
48
- <h4 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-emerald-300">
49
- <span>📋</span> Examples This Rule Would Catch
50
- </h4>
51
- <div className="space-y-2">
52
- {examples.map((example, idx) => (
53
- <div
54
- key={idx}
55
- className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-4 py-2.5 text-sm text-slate-100"
56
- >
57
- <span className="font-mono text-emerald-300">"{example}"</span>
58
- </div>
59
- ))}
60
- </div>
61
- </div>
62
- )}
63
-
64
- {/* Missing Patterns */}
65
- {missingPatterns.length > 0 && (
66
- <div>
67
- <h4 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-amber-300">
68
- <span>🔍</span> Suggested Missing Patterns
69
- </h4>
70
- <div className="space-y-2">
71
- {missingPatterns.map((pattern, idx) => (
72
- <div
73
- key={idx}
74
- className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-2.5 text-sm text-slate-100"
75
- >
76
- <span className="font-mono text-amber-300">{pattern}</span>
77
- </div>
78
- ))}
79
- </div>
80
- </div>
81
- )}
82
-
83
- {/* Edge Cases */}
84
- {edgeCases.length > 0 && (
85
- <div>
86
- <h4 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-purple-300">
87
- <span>⚠️</span> Edge Cases Identified
88
- </h4>
89
- <ul className="space-y-1.5">
90
- {edgeCases.map((edgeCase, idx) => (
91
- <li key={idx} className="text-sm text-slate-300">
92
- • {edgeCase}
93
- </li>
94
- ))}
95
- </ul>
96
- </div>
97
- )}
98
-
99
- {/* Improvements */}
100
- {improvements.length > 0 && (
101
- <div>
102
- <h4 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-cyan-300">
103
- <span>✨</span> Improvements Applied
104
- </h4>
105
- <ul className="space-y-1.5">
106
- {improvements.map((improvement, idx) => (
107
- <li key={idx} className="text-sm text-slate-300">
108
- • {improvement}
109
- </li>
110
- ))}
111
- </ul>
112
- </div>
113
- )}
114
-
115
- {/* Severity Badge */}
116
- {severity && (
117
- <div className="pt-2">
118
- <span
119
- className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wider ${severityColor}`}
120
- >
121
- <span>🛡️</span>
122
- Severity: {severity}
123
- </span>
124
- </div>
125
- )}
126
- </div>
127
- );
128
- }
129
-