Spaces:
Sleeping
Sleeping
Commit
·
cb88e8d
1
Parent(s):
6e24963
chore: remove obsolete files and frontend directory
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- 5_day_meal_plan.docx +0 -0
- 5_day_meal_plan.xlsx +0 -0
- ANTHROPIC_CONTEXT_ENGINEERING.md +0 -201
- CONTEXT_ENGINEERING_IMPLEMENTATION.md +0 -128
- KB_FIRST_IMPLEMENTATION.md +0 -81
- TESTING_GUIDE.md +0 -308
- backend/api/placeholder.txt +0 -4
- backend/tests/README_RETRY_TESTS.md +0 -266
- backend/tests/conftest.py +0 -1
- backend/tests/test_access_control.py +0 -55
- backend/tests/test_agent_orchestrator.py +0 -230
- backend/tests/test_analytics_store.py +0 -208
- backend/tests/test_api_endpoints.py +0 -222
- backend/tests/test_conversation_memory.py +0 -479
- backend/tests/test_enhanced_admin_rules.py +0 -195
- backend/tests/test_intent.py +0 -118
- backend/tests/test_metadata_extraction.py +0 -461
- backend/tests/test_retry_system.py +0 -651
- backend/tests/test_tool_metadata_and_routing.py +0 -585
- backend/workers/placeholder.txt +0 -4
- check_env.py +0 -106
- check_rag_database.py +0 -125
- check_rules_db.py +0 -43
- check_supabase_rules.py +0 -132
- create_supabase_table.py +0 -185
- create_supabase_table_simple.py +0 -70
- createingdummydata.py +0 -44
- example_rules.txt +0 -133
- example_rules_detailed.json +0 -131
- frontend/.gitignore +0 -41
- frontend/README.md +0 -134
- frontend/app/admin-rules/page.tsx +0 -778
- frontend/app/analytics/page.tsx +0 -82
- frontend/app/chat/page.tsx +0 -36
- frontend/app/favicon.ico +0 -0
- frontend/app/globals.css +0 -116
- frontend/app/ingestion/page.tsx +0 -79
- frontend/app/knowledge-base/page.tsx +0 -394
- frontend/app/layout.tsx +0 -38
- frontend/app/page.tsx +0 -110
- frontend/components/admin-rules-panel.tsx +0 -57
- frontend/components/analytics-panel.tsx +0 -152
- frontend/components/chat-panel.tsx +0 -213
- frontend/components/feature-grid.tsx +0 -56
- frontend/components/footer.tsx +0 -15
- frontend/components/hero.tsx +0 -100
- frontend/components/ingestion-card.tsx +0 -56
- frontend/components/knowledge-base-panel.tsx +0 -614
- frontend/components/reasoning-visualizer.tsx +0 -245
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|