diff --git a/GRAPHRAG_QUICK_START.md b/GRAPHRAG_QUICK_START.md new file mode 100644 index 0000000..942c154 --- /dev/null +++ b/GRAPHRAG_QUICK_START.md @@ -0,0 +1,199 @@ +# GraphRAG Quick Start Guide + +## Enable GraphRAG in SciDK + +### 1. Set Environment Variable +```bash +export SCIDK_GRAPHRAG_ENABLED=1 +``` + +### 2. Optional: Add Anthropic API Key (Better Entity Extraction) +```bash +export SCIDK_ANTHROPIC_API_KEY=sk-ant-your-key-here +``` + +### 3. Start SciDK +```bash +python3 -m scidk +``` + +### 4. Navigate to Chat +Open browser: `http://localhost:5000/chat` + +--- + +## Example Queries + +### Schema-Agnostic Examples (Works with ANY Neo4j Database) + +``` +How many nodes are in the database? + +Find all File nodes + +Show me recent scans + +List all folders + +What types of nodes exist? + +Find files with name=test.txt + +Count all relationships +``` + +### For File-Based Graphs (SciDK Default Schema) +``` +Find all files in my project + +Show recent file scans + +How many Python files are there? + +List all folders containing CSV files + +Find files modified this week +``` + +--- + +## UI Features + +### Basic Mode (Default) +- Type natural language queries +- Get conversational responses +- Chat history saved automatically + +### Verbose Mode (Toggle Checkbox) +- See extracted entities (IDs, labels, properties) +- View execution time +- See result counts +- Colored entity badges + +### Actions +- **Send:** Submit query (or press Enter) +- **Clear History:** Delete all messages +- **Verbose Toggle:** Show/hide technical details + +--- + +## API Usage + +### Query Endpoint +```bash +curl -X POST http://localhost:5000/api/chat/graphrag \ + -H "Content-Type: application/json" \ + -d '{"message": "How many files are there?"}' +``` + +### Response Format +```json +{ + "status": "ok", + "reply": "Found 42 files in the database.", + "metadata": { + "entities": { + "identifiers": [], + "labels": ["File"], + "properties": {}, + "intent": "count" + }, + "execution_time_ms": 1234, + "result_count": 1 + } +} +``` + +--- + +## Configuration Reference + +### Required +```bash +SCIDK_GRAPHRAG_ENABLED=1 # Turn on GraphRAG +``` + +### Optional +```bash +SCIDK_ANTHROPIC_API_KEY=... # Better entity extraction +SCIDK_GRAPHRAG_VERBOSE=true # Always show metadata +SCIDK_GRAPHRAG_SCHEMA_CACHE_TTL_SEC=300 # Schema cache duration +``` + +### Privacy Controls +```bash +SCIDK_GRAPHRAG_ALLOW_LABELS=File,Folder # Only query these labels +SCIDK_GRAPHRAG_DENY_LABELS=User,Secret # Never query these labels +``` + +--- + +## Troubleshooting + +### "GraphRAG disabled" +**Solution:** Set `SCIDK_GRAPHRAG_ENABLED=1` + +### "Neo4j is not configured" +**Solution:** Set Neo4j connection: +```bash +export NEO4J_URI=neo4j://localhost:7687 +export NEO4J_USERNAME=neo4j +export NEO4J_PASSWORD=your_password +``` + +### "neo4j-graphrag not installed" +**Solution:** Install dependency: +```bash +pip install neo4j-graphrag>=0.3.0 +``` + +### Queries return "No results" +**Possible causes:** +1. Empty database → Run a scan first +2. Wrong node labels → Check schema with "What types of nodes exist?" +3. Query too specific → Try broader query + +--- + +## Testing + +### Run Pytest +```bash +python3 -m pytest tests/test_graphrag_*_simple.py -v +``` + +### Run E2E Tests +```bash +npm run e2e -- chat-graphrag.spec.ts +``` + +--- + +## Architecture + +``` +User Query + ↓ +Entity Extractor (extract IDs, labels, properties, intent) + ↓ +Neo4j Schema Discovery (CALL db.labels(), db.relationshipTypes()) + ↓ +neo4j-graphrag Text2CypherRetriever (generate Cypher) + ↓ +Neo4j Execution + ↓ +Result Formatting + ↓ +Response to User +``` + +**Key Feature:** Schema-agnostic - works with ANY Neo4j database! + +--- + +## Learn More + +- **Implementation:** `scidk/services/graphrag/` +- **Tests:** `tests/test_graphrag_*_simple.py` +- **E2E Tests:** `e2e/chat-graphrag.spec.ts` +- **Session Summary:** `dev/sessions/2026-02-07-graphrag-integration-summary.md` diff --git a/dev b/dev index d71bf7f..1bb4006 160000 --- a/dev +++ b/dev @@ -1 +1 @@ -Subproject commit d71bf7f5f11241d8e0db2efc04cd35ee0f3e2c73 +Subproject commit 1bb4006473d9b4ec5fd8f3275a80f21d5ad16c3b diff --git a/e2e/browse.spec.ts b/e2e/browse.spec.ts index 7b37d77..f468275 100644 --- a/e2e/browse.spec.ts +++ b/e2e/browse.spec.ts @@ -18,8 +18,8 @@ test('files page loads and shows stable hooks', async ({ page, baseURL }) => { await expect(page.getByTestId('files-title')).toBeVisible(); await expect(page.getByTestId('files-root')).toBeVisible(); - // Let network settle and ensure no console errors - await page.waitForLoadState('networkidle'); + // Wait briefly for any delayed errors (skip networkidle due to polling on datasets page) + await page.waitForTimeout(1000); const errors = consoleMessages.filter((m) => m.type === 'error'); expect(errors.length).toBe(0); }); diff --git a/e2e/chat-graphrag.spec.ts b/e2e/chat-graphrag.spec.ts new file mode 100644 index 0000000..3bbbcde --- /dev/null +++ b/e2e/chat-graphrag.spec.ts @@ -0,0 +1,306 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Chat GraphRAG', () => { + test.beforeEach(async ({ page }) => { + // Clear localStorage before each test + await page.goto('/chat'); + await page.evaluate(() => { + localStorage.clear(); + }); + await page.reload(); + }); + + test('displays chat page with correct elements', async ({ page }) => { + await page.goto('/chat'); + + // Check title and header + await expect(page.locator('h2')).toContainText('Chat'); + await expect(page.locator('h2')).toContainText('GraphRAG'); + + // Check form elements + await expect(page.getByTestId('chat-input')).toBeVisible(); + await expect(page.getByTestId('chat-send')).toBeVisible(); + await expect(page.getByTestId('chat-clear')).toBeVisible(); + + // Check verbose mode checkbox + await expect(page.locator('#verbose-mode')).toBeVisible(); + + // Check history area + await expect(page.getByTestId('chat-history')).toBeVisible(); + }); + + test('shows empty state message initially', async ({ page }) => { + await page.goto('/chat'); + + const history = page.getByTestId('chat-history'); + await expect(history).toContainText('No messages yet'); + }); + + test('can send a message and receive response', async ({ page }) => { + await page.goto('/chat'); + + // Type and send message + await page.getByTestId('chat-input').fill('How many files are there?'); + await page.getByTestId('chat-send').click(); + + // User message should appear immediately + await expect(page.getByTestId('chat-message-user')).toBeVisible(); + await expect(page.getByTestId('chat-message-user')).toContainText('How many files are there?'); + + // Wait for assistant response (may take a moment for GraphRAG) + await expect(page.getByTestId('chat-message-assistant')).toBeVisible({ timeout: 10000 }); + + // Response should contain some text + const assistantMessage = page.getByTestId('chat-message-assistant'); + await expect(assistantMessage).not.toBeEmpty(); + }); + + test('input is cleared after sending', async ({ page }) => { + await page.goto('/chat'); + + const input = page.getByTestId('chat-input'); + await input.fill('Test message'); + await page.getByTestId('chat-send').click(); + + // Input should be cleared + await expect(input).toHaveValue(''); + }); + + test('verbose mode shows metadata', async ({ page }) => { + await page.goto('/chat'); + + // Enable verbose mode + await page.locator('#verbose-mode').check(); + + // Send a query + await page.getByTestId('chat-input').fill('Find files'); + await page.getByTestId('chat-send').click(); + + // Wait for response + await expect(page.getByTestId('chat-message-assistant')).toBeVisible({ timeout: 10000 }); + + // Check for metadata elements (execution time badge should appear) + const assistantMessage = page.getByTestId('chat-message-assistant'); + const metadataSection = assistantMessage.locator('.chat-metadata'); + + // Metadata may or may not have content depending on query, but section should exist if verbose + // Just verify the verbose mode affects rendering + const hasMetadata = await metadataSection.count(); + // Metadata section appears when there's data to show + }); + + test('can clear history', async ({ page }) => { + await page.goto('/chat'); + + // Send a message + await page.getByTestId('chat-input').fill('Test message'); + await page.getByTestId('chat-send').click(); + + // Wait for message to appear + await expect(page.getByTestId('chat-message-user')).toBeVisible(); + + // Click clear button and confirm + page.on('dialog', dialog => dialog.accept()); + await page.getByTestId('chat-clear').click(); + + // History should show empty state + const history = page.getByTestId('chat-history'); + await expect(history).toContainText('History cleared'); + }); + + test('history persists across page reloads', async ({ page }) => { + await page.goto('/chat'); + + // Send a message + const testMessage = 'Test persistence message'; + await page.getByTestId('chat-input').fill(testMessage); + await page.getByTestId('chat-send').click(); + + // Wait for user message + await expect(page.getByTestId('chat-message-user')).toBeVisible(); + + // Reload page + await page.reload(); + + // Message should still be there + await expect(page.getByTestId('chat-message-user')).toContainText(testMessage); + }); + + test('verbose preference persists', async ({ page }) => { + await page.goto('/chat'); + + // Enable verbose mode + const verboseCheckbox = page.locator('#verbose-mode'); + await verboseCheckbox.check(); + await expect(verboseCheckbox).toBeChecked(); + + // Reload page + await page.reload(); + + // Verbose mode should still be checked + await expect(verboseCheckbox).toBeChecked(); + }); + + test('displays user and assistant messages with different styles', async ({ page }) => { + await page.goto('/chat'); + + // Send a message + await page.getByTestId('chat-input').fill('Test styling'); + await page.getByTestId('chat-send').click(); + + // Wait for both messages + await expect(page.getByTestId('chat-message-user')).toBeVisible(); + await expect(page.getByTestId('chat-message-assistant')).toBeVisible({ timeout: 10000 }); + + // Check that messages have different styling + const userMessage = page.getByTestId('chat-message-user'); + const assistantMessage = page.getByTestId('chat-message-assistant'); + + // Verify role indicators + await expect(userMessage).toContainText('You'); + await expect(assistantMessage).toContainText('Assistant'); + + // Verify CSS classes + await expect(userMessage).toHaveClass(/user/); + await expect(assistantMessage).toHaveClass(/assistant/); + }); + + test('prevents sending empty messages', async ({ page }) => { + await page.goto('/chat'); + + // Try to send empty message + await page.getByTestId('chat-send').click(); + + // No message should appear + const userMessages = page.getByTestId('chat-message-user'); + await expect(userMessages).toHaveCount(0); + }); + + test('handles error responses gracefully', async ({ page }) => { + await page.goto('/chat'); + + // Mock a failing API response + await page.route('/api/chat/graphrag', route => { + route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ status: 'error', error: 'Test error' }) + }); + }); + + // Send a message + await page.getByTestId('chat-input').fill('This will fail'); + await page.getByTestId('chat-send').click(); + + // Error message should appear + await expect(page.getByTestId('chat-message-assistant')).toBeVisible(); + await expect(page.getByTestId('chat-message-assistant')).toContainText('Error'); + }); + + test('displays execution time in verbose mode', async ({ page }) => { + await page.goto('/chat'); + + // Enable verbose mode + await page.locator('#verbose-mode').check(); + + // Mock response with metadata + await page.route('/api/chat/graphrag', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'ok', + reply: 'Found 3 results', + history: [], + metadata: { + entities: { identifiers: ['TEST_001'], labels: ['File'], properties: {} }, + execution_time_ms: 1234, + result_count: 3 + } + }) + }); + }); + + // Send a query + await page.getByTestId('chat-input').fill('Find files'); + await page.getByTestId('chat-send').click(); + + // Wait for response + await expect(page.getByTestId('chat-message-assistant')).toBeVisible(); + + // Check for metadata + const assistantMessage = page.getByTestId('chat-message-assistant'); + await expect(assistantMessage).toContainText('1234ms'); + await expect(assistantMessage).toContainText('3 results'); + }); + + test('displays entity badges in verbose mode', async ({ page }) => { + await page.goto('/chat'); + + // Enable verbose mode + await page.locator('#verbose-mode').check(); + + // Mock response with entities + await page.route('/api/chat/graphrag', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'ok', + reply: 'Found results', + history: [], + metadata: { + entities: { + identifiers: ['TEST_001', 'ABC_123'], + labels: ['File', 'Scan'], + properties: { name: 'test.txt' } + }, + execution_time_ms: 500, + result_count: 2 + } + }) + }); + }); + + // Send query + await page.getByTestId('chat-input').fill('Find TEST_001'); + await page.getByTestId('chat-send').click(); + + // Wait for response + await expect(page.getByTestId('chat-message-assistant')).toBeVisible(); + + // Check for entity badges + const assistantMessage = page.getByTestId('chat-message-assistant'); + await expect(assistantMessage).toContainText('ID: TEST_001'); + await expect(assistantMessage).toContainText('ID: ABC_123'); + await expect(assistantMessage).toContainText('Label: File'); + await expect(assistantMessage).toContainText('Label: Scan'); + await expect(assistantMessage).toContainText('name: test.txt'); + }); + + test('multiple messages display in chronological order', async ({ page }) => { + await page.goto('/chat'); + + // Send first message + await page.getByTestId('chat-input').fill('First message'); + await page.getByTestId('chat-send').click(); + await expect(page.getByTestId('chat-message-user').first()).toContainText('First message'); + + // Wait for first response + await expect(page.getByTestId('chat-message-assistant').first()).toBeVisible({ timeout: 10000 }); + + // Send second message + await page.getByTestId('chat-input').fill('Second message'); + await page.getByTestId('chat-send').click(); + + // Check message order + const messages = page.locator('.chat-message'); + await expect(messages).toHaveCount(4); // 2 user + 2 assistant (at least) + + // First user message should be before second + const allText = await page.getByTestId('chat-history').textContent(); + const firstIndex = allText?.indexOf('First message') ?? -1; + const secondIndex = allText?.indexOf('Second message') ?? -1; + expect(firstIndex).toBeLessThan(secondIndex); + }); +}); diff --git a/e2e/chat.spec.ts b/e2e/chat.spec.ts index 97398f5..bc08f68 100644 --- a/e2e/chat.spec.ts +++ b/e2e/chat.spec.ts @@ -18,12 +18,12 @@ test('chat page loads and displays beta badge', async ({ page, baseURL }) => { await page.waitForLoadState('networkidle'); // Verify page loads - await expect(page).toHaveTitle(/-SciDK-> Chats/i, { timeout: 10_000 }); + await expect(page).toHaveTitle(/-SciDK-> Chat/i, { timeout: 10_000 }); - // Check for Beta badge + // Check for GraphRAG badge const betaBadge = page.locator('.badge'); await expect(betaBadge).toBeVisible(); - await expect(betaBadge).toHaveText('Beta'); + await expect(betaBadge).toHaveText('GraphRAG'); // Check for chat form const chatForm = page.locator('#chat-form'); @@ -32,7 +32,7 @@ test('chat page loads and displays beta badge', async ({ page, baseURL }) => { // Check for chat input const chatInput = page.locator('#chat-input'); await expect(chatInput).toBeVisible(); - await expect(chatInput).toHaveAttribute('placeholder', /Ask something/i); + await expect(chatInput).toHaveAttribute('placeholder', /Example|Find all files/i); // Check for send button const sendButton = page.locator('#chat-form button[type="submit"]'); @@ -57,7 +57,7 @@ test('chat navigation link is visible in header', async ({ page, baseURL }) => { // Click it and verify we navigate to chat page await chatsLink.click(); await page.waitForLoadState('networkidle'); - await expect(page).toHaveTitle(/-SciDK-> Chats/i); + await expect(page).toHaveTitle(/-SciDK-> Chat/i); }); test('chat form can accept input', async ({ page, baseURL }) => { @@ -85,15 +85,17 @@ test('chat form submits to /api/chat endpoint', async ({ page, baseURL }) => { // Listen for API request const apiRequestPromise = page.waitForRequest( - (request) => request.url().includes('/api/chat') && request.method() === 'POST' + (request) => request.url().includes('/api/chat/graphrag') && request.method() === 'POST' ); // Mock the API response to avoid actual chat API calls - await page.route('**/api/chat', async (route) => { + await page.route('**/api/chat/graphrag', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + status: 'ok', + reply: 'Here are your datasets...', history: [ { role: 'user', content: 'What are my datasets?' }, { role: 'assistant', content: 'Here are your datasets...' } @@ -111,7 +113,7 @@ test('chat form submits to /api/chat endpoint', async ({ page, baseURL }) => { submitButton.click() ]); - expect(apiRequest.url()).toContain('/api/chat'); + expect(apiRequest.url()).toContain('/api/chat/graphrag'); // Verify request payload const postData = apiRequest.postDataJSON(); @@ -128,11 +130,13 @@ test('chat form displays history after response', async ({ page, baseURL }) => { const chatHistory = page.locator('#chat-history'); // Mock the API response - await page.route('**/api/chat', async (route) => { + await page.route('**/api/chat/graphrag', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ + status: 'ok', + reply: 'Test response', history: [ { role: 'user', content: 'Test question' }, { role: 'assistant', content: 'Test response' } @@ -149,11 +153,11 @@ test('chat form displays history after response', async ({ page, baseURL }) => { // Wait for history to be populated await page.waitForTimeout(1000); // Wait for API mock and DOM update - // Verify history has content + // Verify history has content (new UI shows "👤 You" and "🤖 Assistant") const historyContent = await chatHistory.textContent(); - expect(historyContent).toContain('user:'); + expect(historyContent).toContain('You'); expect(historyContent).toContain('Test question'); - expect(historyContent).toContain('assistant:'); + expect(historyContent).toContain('Assistant'); expect(historyContent).toContain('Test response'); // Verify input is cleared after submission @@ -175,7 +179,7 @@ test('chat form handles API errors gracefully', async ({ page, baseURL }) => { }); // Mock an API error - await page.route('**/api/chat', async (route) => { + await page.route('**/api/chat/graphrag', async (route) => { await route.abort('failed'); }); @@ -201,7 +205,7 @@ test('chat form does not submit empty messages', async ({ page, baseURL }) => { // Track API calls let apiCallMade = false; page.on('request', (request) => { - if (request.url().includes('/api/chat') && request.method() === 'POST') { + if (request.url().includes('/api/chat/graphrag') && request.method() === 'POST') { apiCallMade = true; } }); diff --git a/e2e/core-flows.spec.ts b/e2e/core-flows.spec.ts index 6150141..c70186f 100644 --- a/e2e/core-flows.spec.ts +++ b/e2e/core-flows.spec.ts @@ -49,7 +49,8 @@ test('complete flow: scan → browse → file details', async ({ page, baseURL, // Step 3: Navigate to Files page await page.getByTestId('nav-files').click(); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (datasets page has continuous polling) + await page.getByTestId('files-title').waitFor({ state: 'visible', timeout: 10000 }); await expect(page.getByTestId('files-title')).toBeVisible(); await expect(page.getByTestId('files-root')).toBeVisible(); @@ -109,7 +110,8 @@ test('browse page shows correct file listing structure', async ({ page, baseURL, await page.goto(base); await page.waitForLoadState('networkidle'); await page.getByTestId('nav-files').click(); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (datasets page has continuous polling) + await page.getByTestId('files-title').waitFor({ state: 'visible', timeout: 10000 }); // Verify stable selectors are present await expect(page.getByTestId('files-title')).toBeVisible(); @@ -147,7 +149,13 @@ test('navigation covers all 7 pages', async ({ page, baseURL }) => { // Navigate await navLink.click(); - await page.waitForLoadState('networkidle'); + + // For /datasets page, wait for specific element instead of networkidle (has polling) + if (url === '/datasets') { + await page.getByTestId('files-title').waitFor({ state: 'visible', timeout: 10000 }); + } else { + await page.waitForLoadState('networkidle', { timeout: 15000 }); + } // Verify page loads correctly await expect(page).toHaveURL(new RegExp(url)); diff --git a/e2e/files-browse.spec.ts b/e2e/files-browse.spec.ts index 289d5aa..6c043ca 100644 --- a/e2e/files-browse.spec.ts +++ b/e2e/files-browse.spec.ts @@ -8,7 +8,8 @@ import { test, expect } from '@playwright/test'; test('files page provider browser controls are present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); // Check provider selector const provSelect = page.locator('#prov-select'); @@ -46,7 +47,8 @@ test('files page provider browser controls are present', async ({ page, baseURL test('provider selector can change providers', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const provSelect = page.locator('#prov-select'); @@ -69,7 +71,8 @@ test('provider selector can change providers', async ({ page, baseURL }) => { test('root selector updates when provider changes', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const rootSelect = page.locator('#root-select'); @@ -83,7 +86,8 @@ test('root selector updates when provider changes', async ({ page, baseURL }) => test('path input accepts user input', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const provPath = page.locator('#prov-path'); @@ -99,7 +103,8 @@ test('path input accepts user input', async ({ page, baseURL }) => { test('recursive browse checkbox toggles', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const recursiveCheckbox = page.locator('#prov-browse-recursive'); @@ -118,7 +123,8 @@ test('recursive browse checkbox toggles', async ({ page, baseURL }) => { test('fast-list checkbox toggles', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const fastListCheckbox = page.locator('#prov-browse-fast-list'); @@ -133,7 +139,8 @@ test('fast-list checkbox toggles', async ({ page, baseURL }) => { test('max depth input accepts numeric values', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const maxDepthInput = page.locator('#prov-browse-max-depth'); @@ -149,7 +156,8 @@ test('max depth input accepts numeric values', async ({ page, baseURL }) => { test('go button triggers browse action', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); // Track navigation/requests let browseRequestMade = false; @@ -172,7 +180,8 @@ test('go button triggers browse action', async ({ page, baseURL }) => { test('rocrate viewer buttons exist if feature is enabled', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); // Check if RO-Crate buttons exist (they may not be present if feature is disabled) const openButton = page.locator('#open-rocrate'); @@ -194,7 +203,8 @@ test('rocrate viewer buttons exist if feature is enabled', async ({ page, baseUR test('recent scans selector and controls are present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); // Check recent scans dropdown const recentScans = page.locator('#recent-scans'); @@ -212,7 +222,8 @@ test('recent scans selector and controls are present', async ({ page, baseURL }) test('refresh scans button is functional', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#prov-select').waitFor({ state: 'visible', timeout: 10000 }); const refreshButton = page.locator('#refresh-scans'); diff --git a/e2e/files-snapshot.spec.ts b/e2e/files-snapshot.spec.ts index c9cf55b..0d6ea1d 100644 --- a/e2e/files-snapshot.spec.ts +++ b/e2e/files-snapshot.spec.ts @@ -8,7 +8,8 @@ import { test, expect } from '@playwright/test'; test('snapshot browse controls are present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); // Check snapshot scan selector const scanSelect = page.locator('#snapshot-scan'); @@ -38,7 +39,8 @@ test('snapshot browse controls are present', async ({ page, baseURL }) => { test('snapshot path input accepts values', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const snapPath = page.locator('#snap-path'); @@ -50,7 +52,8 @@ test('snapshot path input accepts values', async ({ page, baseURL }) => { test('snapshot type filter can be changed', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const typeFilter = page.locator('#snap-type'); @@ -69,7 +72,8 @@ test('snapshot type filter can be changed', async ({ page, baseURL }) => { test('snapshot extension filter accepts input', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const extFilter = page.locator('#snap-ext'); @@ -85,7 +89,8 @@ test('snapshot extension filter accepts input', async ({ page, baseURL }) => { test('snapshot page size input accepts numeric values', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const pageSize = page.locator('#snap-page-size'); @@ -101,7 +106,8 @@ test('snapshot page size input accepts numeric values', async ({ page, baseURL } test('snapshot pagination controls are present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); // Check prev button const prevButton = page.locator('#snap-prev'); @@ -115,7 +121,8 @@ test('snapshot pagination controls are present', async ({ page, baseURL }) => { test('snapshot use live path button is present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const useLiveButton = page.locator('#snap-use-live'); await expect(useLiveButton).toBeVisible(); @@ -124,7 +131,8 @@ test('snapshot use live path button is present', async ({ page, baseURL }) => { test('snapshot commit button is present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const commitButton = page.locator('#snap-commit'); await expect(commitButton).toBeVisible(); @@ -133,7 +141,8 @@ test('snapshot commit button is present', async ({ page, baseURL }) => { test('snapshot search controls are present', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); // Check search query input const searchQuery = page.locator('#snap-search-q'); @@ -155,7 +164,8 @@ test('snapshot search controls are present', async ({ page, baseURL }) => { test('snapshot search query input accepts text', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const searchQuery = page.locator('#snap-search-q'); @@ -167,7 +177,8 @@ test('snapshot search query input accepts text', async ({ page, baseURL }) => { test('snapshot search extension filter accepts input', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const searchExt = page.locator('#snap-search-ext'); @@ -179,7 +190,8 @@ test('snapshot search extension filter accepts input', async ({ page, baseURL }) test('snapshot search prefix filter accepts input', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const searchPrefix = page.locator('#snap-search-prefix'); @@ -191,7 +203,8 @@ test('snapshot search prefix filter accepts input', async ({ page, baseURL }) => test('snapshot search button is clickable', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const searchButton = page.locator('#snap-search-go'); @@ -211,7 +224,8 @@ test('snapshot search button is clickable', async ({ page, baseURL }) => { test('snapshot browse button triggers browse action', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const browseButton = page.locator('#snap-go'); @@ -228,7 +242,8 @@ test('snapshot browse button triggers browse action', async ({ page, baseURL }) test('snapshot pagination buttons are clickable', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); const prevButton = page.locator('#snap-prev'); const nextButton = page.locator('#snap-next'); @@ -248,7 +263,8 @@ test('snapshot pagination buttons are clickable', async ({ page, baseURL }) => { test('use live path button copies path between sections', async ({ page, baseURL }) => { const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.locator('#snapshot-scan').waitFor({ state: 'visible', timeout: 10000 }); // Set a value in live path const provPath = page.locator('#prov-path'); diff --git a/e2e/negative.spec.ts b/e2e/negative.spec.ts index e1040d4..a348cb0 100644 --- a/e2e/negative.spec.ts +++ b/e2e/negative.spec.ts @@ -54,8 +54,8 @@ test('files page loads even with no providers', async ({ page, baseURL }) => { await expect(page.getByTestId('files-title')).toBeVisible(); await expect(page.getByTestId('files-root')).toBeVisible(); - // Even if no data, the structure should be present - await page.waitForLoadState('networkidle'); + // Even if no data, the structure should be present (skip networkidle due to polling) + await page.waitForTimeout(1000); // No console errors expected const consoleErrors: string[] = []; @@ -97,7 +97,8 @@ test('scan form shows validation for empty path', async ({ page, baseURL }) => { // Go to Files page where scan form exists await page.goto(`${base}/datasets`); - await page.waitForLoadState('networkidle'); + // Wait for key elements instead of networkidle (page has continuous polling) + await page.getByTestId('files-title').waitFor({ state: 'visible', timeout: 10000 }); // Find scan form if it exists const scanForm = page.getByTestId('prov-scan-form'); @@ -128,6 +129,7 @@ test('optional dependencies gracefully degrade', async ({ page, baseURL }) => { // Navigate through key pages await page.getByTestId('nav-files').click(); await expect(page.getByTestId('files-title')).toBeVisible(); + await page.waitForTimeout(500); // Brief wait instead of networkidle (datasets has polling) await page.getByTestId('nav-maps').click(); await page.waitForLoadState('networkidle'); diff --git a/scidk/services/graphrag/__init__.py b/scidk/services/graphrag/__init__.py new file mode 100644 index 0000000..2db6ec4 --- /dev/null +++ b/scidk/services/graphrag/__init__.py @@ -0,0 +1,3 @@ +""" +GraphRAG services for SciDK - Schema-agnostic natural language queries. +""" diff --git a/scidk/services/graphrag/entity_extractor.py b/scidk/services/graphrag/entity_extractor.py new file mode 100644 index 0000000..0eda508 --- /dev/null +++ b/scidk/services/graphrag/entity_extractor.py @@ -0,0 +1,164 @@ +""" +Generic entity extraction for GraphRAG queries. +Extracts structured entities from natural language, but doesn't assume specific schema. +""" +from typing import Dict, List, Optional, Any +import re +import os + + +class EntityExtractor: + """ + Extract entities from natural language queries. + Schema-agnostic: extracts generic patterns (IDs, names, types) without hardcoding node labels. + """ + + def __init__(self, anthropic_api_key: Optional[str] = None): + """ + Initialize entity extractor. + + Args: + anthropic_api_key: Optional Anthropic API key for LLM-based extraction. + Falls back to pattern matching if not provided. + """ + self.anthropic_api_key = anthropic_api_key or os.environ.get('SCIDK_ANTHROPIC_API_KEY') + self.use_llm = bool(self.anthropic_api_key) + + def extract(self, query: str, schema_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Extract entities from natural language query. + + Args: + query: Natural language query + schema_context: Optional schema info (labels, relationships) for context + + Returns: + Dict with extracted entities: { + 'identifiers': [...], # IDs, UIDs, codes + 'labels': [...], # Potential node labels mentioned + 'properties': {...}, # Property filters (name=X, type=Y) + 'intent': str, # Query intent (find, count, show, list) + } + """ + if self.use_llm and schema_context: + return self._extract_with_llm(query, schema_context) + return self._extract_with_patterns(query, schema_context) + + def _extract_with_patterns(self, query: str, schema_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Pattern-based entity extraction (fallback when no LLM). + Extracts generic patterns without assuming specific schema. + """ + entities = { + 'identifiers': [], + 'labels': [], + 'properties': {}, + 'intent': 'find', + } + + # Extract identifiers (IDs, UIDs, codes) + # Patterns: uppercase+numbers, quoted strings, specific ID formats + id_patterns = [ + r'\b([A-Z]{2,}[_-]?[0-9]{3,})\b', # e.g., NHP123, SEQ_001 + r'\b([A-Z]+[0-9]+)\b', # e.g., A001, S123 + r'["\']([^"\']+)["\']', # Quoted strings + ] + for pattern in id_patterns: + matches = re.findall(pattern, query, re.IGNORECASE) + entities['identifiers'].extend(matches) + + # Detect intent (most specific first) + query_lower = query.lower() + if any(word in query_lower for word in ['count', 'how many', 'number of']): + entities['intent'] = 'count' + elif any(word in query_lower for word in ['list', 'every']): + entities['intent'] = 'list' + elif any(word in query_lower for word in ['show', 'display', 'view']): + entities['intent'] = 'show' + elif any(word in query_lower for word in ['find', 'search', 'look for', 'get']): + entities['intent'] = 'find' + + # Match against known labels from schema + if schema_context and 'labels' in schema_context: + for label in schema_context['labels']: + # Case-insensitive match - try both exact and plural forms + if re.search(r'\b' + re.escape(label) + r's?\b', query, re.IGNORECASE): + if label not in entities['labels']: + entities['labels'].append(label) + + # Extract property filters (name=X, type=Y, etc.) + # Look for patterns like "name is X", "type: Y", "called X", "with name=X" + property_patterns = [ + (r'(?:name|called)\s+(?:is|=|:)\s*["\']?([^"\'\s,]+)["\']?', 'name'), + (r'(?:with|having)\s+name\s*=\s*["\']?([^"\'\s,]+)["\']?', 'name'), + (r'type\s*(?:is|=|:)\s*["\']?([^"\'\s,]+)["\']?', 'type'), + ] + for pattern, prop_name in property_patterns: + match = re.search(pattern, query, re.IGNORECASE) + if match: + value = match.group(1).strip() + if value and len(value) > 0: + entities['properties'][prop_name] = value + + return entities + + def _extract_with_llm(self, query: str, schema_context: Dict[str, Any]) -> Dict[str, Any]: + """ + LLM-based entity extraction with schema context. + More accurate but requires API call. + """ + try: + import anthropic + + client = anthropic.Anthropic(api_key=self.anthropic_api_key) + + # Build prompt with schema context + labels_str = ', '.join(schema_context.get('labels', [])) + rels_str = ', '.join(schema_context.get('relationships', [])) + + prompt = f"""Extract structured information from this graph database query. + +Available Schema: +- Node Labels: {labels_str} +- Relationships: {rels_str} + +User Query: "{query}" + +Extract and return as JSON: +{{ + "identifiers": ["list", "of", "IDs", "or", "codes"], + "labels": ["matching", "node", "labels"], + "properties": {{"property_name": "value"}}, + "intent": "find|count|show|list" +}} + +Rules: +- Only include labels that exist in the schema +- Identifiers are IDs, codes, UIDs mentioned in query +- Properties are name/type/status filters +- Intent is the main action (find, count, show, list) + +Return ONLY the JSON, no explanation.""" + + message = client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=500, + messages=[{"role": "user", "content": prompt}] + ) + + # Parse response + import json + response_text = message.content[0].text.strip() + # Handle markdown code blocks + if '```' in response_text: + response_text = response_text.split('```')[1] + if response_text.startswith('json'): + response_text = response_text[4:] + + entities = json.loads(response_text) + return entities + + except Exception as e: + # Fall back to pattern matching + print(f"LLM extraction failed: {e}, falling back to patterns") + return self._extract_with_patterns(query, schema_context) diff --git a/scidk/services/graphrag/query_engine.py b/scidk/services/graphrag/query_engine.py new file mode 100644 index 0000000..da61496 --- /dev/null +++ b/scidk/services/graphrag/query_engine.py @@ -0,0 +1,199 @@ +""" +Schema-agnostic GraphRAG query engine for SciDK. +Combines entity extraction with neo4j-graphrag's Text2CypherRetriever. +""" +from typing import Dict, Any, Optional +import time + + +class QueryEngine: + """ + High-level GraphRAG query interface. + Schema-agnostic: works with any Neo4j database. + """ + + def __init__( + self, + driver: Any, + neo4j_schema: Dict[str, Any], + anthropic_api_key: Optional[str] = None, + examples: Optional[list] = None, + verbose: bool = False + ): + """ + Initialize query engine. + + Args: + driver: Neo4j driver instance + neo4j_schema: Schema dict with 'labels' and 'relationships' + anthropic_api_key: Optional API key for entity extraction + examples: Optional Text2Cypher examples + verbose: If True, include extracted entities and Cypher in response + """ + self.driver = driver + self.neo4j_schema = neo4j_schema + self.verbose = verbose + self.examples = examples or [] + + # Initialize entity extractor + from .entity_extractor import EntityExtractor + self.entity_extractor = EntityExtractor(anthropic_api_key) + + def query(self, question: str) -> Dict[str, Any]: + """ + Execute natural language query against Neo4j. + + Args: + question: Natural language question + + Returns: + Dict with: + - status: 'ok' or 'error' + - answer: Natural language answer + - entities: Extracted entities (if verbose) + - cypher: Generated Cypher query (if verbose) + - results: Raw results (if verbose) + - execution_time_ms: Query execution time + """ + start_time = time.time() + + try: + # Step 1: Extract entities (with schema context) + entities = self.entity_extractor.extract(question, self.neo4j_schema) + + # Step 2: Use neo4j-graphrag's Text2CypherRetriever + try: + from neo4j_graphrag.retrievers import Text2CypherRetriever + from neo4j_graphrag.llm import LLMInterface + + # Create simple LLM wrapper if we have API key + llm = None + if self.entity_extractor.use_llm: + llm = self._create_llm_adapter() + + # Create retriever with schema + retriever = Text2CypherRetriever( + driver=self.driver, + neo4j_schema=self.neo4j_schema, + examples=self.examples, + llm=llm + ) + + # Execute query + result = retriever.search(query_text=question) + + # Format response + execution_time = int((time.time() - start_time) * 1000) + + response = { + 'status': 'ok', + 'answer': self._format_answer(result, question), + 'execution_time_ms': execution_time + } + + if self.verbose: + response['entities'] = entities + response['results'] = result.items if hasattr(result, 'items') else [] + + return response + + except ImportError: + return { + 'status': 'error', + 'error': 'neo4j-graphrag not installed', + 'hint': 'pip install neo4j-graphrag>=0.3.0' + } + + except Exception as e: + return { + 'status': 'error', + 'error': str(e), + 'execution_time_ms': int((time.time() - start_time) * 1000) + } + + def _create_llm_adapter(self) -> Optional[Any]: + """Create LLM adapter for neo4j-graphrag.""" + try: + import anthropic + + class AnthropicLLMAdapter: + """Simple adapter for Anthropic Claude with neo4j-graphrag.""" + + def __init__(self, api_key: str): + self.client = anthropic.Anthropic(api_key=api_key) + + def invoke(self, prompt: str) -> str: + """Required method for neo4j-graphrag LLMInterface.""" + message = self.client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=2000, + messages=[{"role": "user", "content": prompt}] + ) + return message.content[0].text + + return AnthropicLLMAdapter(self.entity_extractor.anthropic_api_key) + + except Exception: + return None + + def _format_answer(self, result: Any, question: str) -> str: + """ + Format retriever results into natural language answer. + + Args: + result: Result from Text2CypherRetriever + question: Original question + + Returns: + Natural language answer string + """ + try: + # Extract items from result + items = [] + if hasattr(result, 'items'): + items = result.items + elif hasattr(result, 'records'): + items = result.records + elif isinstance(result, list): + items = result + + if not items: + return "No results found for your query." + + # Count results + count = len(items) + + # Format based on count + if count == 0: + return "No results found." + elif count == 1: + return f"Found 1 result: {self._format_item(items[0])}" + elif count <= 5: + formatted_items = [self._format_item(item) for item in items] + return f"Found {count} results:\n" + "\n".join(f"- {item}" for item in formatted_items) + else: + formatted_items = [self._format_item(item) for item in items[:5]] + return (f"Found {count} results (showing first 5):\n" + + "\n".join(f"- {item}" for item in formatted_items)) + + except Exception as e: + # Fallback to simple representation + return f"Query completed. Results: {result}" + + def _format_item(self, item: Any) -> str: + """Format a single result item.""" + try: + if isinstance(item, dict): + # Extract key fields + if 'name' in item: + return item['name'] + elif 'id' in item: + return f"ID: {item['id']}" + else: + # Show first few fields + fields = list(item.items())[:3] + return ", ".join(f"{k}: {v}" for k, v in fields) + else: + return str(item) + except Exception: + return str(item) diff --git a/scidk/ui/templates/chat.html b/scidk/ui/templates/chat.html index 04475aa..43fb3e3 100644 --- a/scidk/ui/templates/chat.html +++ b/scidk/ui/templates/chat.html @@ -1,45 +1,832 @@ {% extends 'base.html' %} -{% block title %}-SciDK-> Chats{% endblock %} +{% block title %}-SciDK-> Chat{% endblock %} {% block content %} -
-

Chat Beta

-

Placeholder for a chat assistant. This page will host an interactive assistant.

-
- -
- +
+ +
+
+

💬 Chat GraphRAG

+
+ + + +
- -
-
+ +
+ +
+ +
+ + + +
+
+ + + +
+ + +
+ +
+
+

Data Output

+
+ + +
+
+ +
+ +
+
+

Query results will appear here

+
+ +
+ + +
+
+

Graph visualization will appear here

+
+ +
+
+
+ + +
+ + +
+
+

Query Editor

+
+ + + + +
+
+ +
+ Ready +
+
+
+ {% endblock %} {% block head %} + {% endblock %} diff --git a/scidk/ui/templates/settings.html b/scidk/ui/templates/settings.html index bccc7fe..8f326c8 100644 --- a/scidk/ui/templates/settings.html +++ b/scidk/ui/templates/settings.html @@ -74,6 +74,7 @@