diff --git a/backend/lamb/completions/pps/simple_augment.py b/backend/lamb/completions/pps/simple_augment.py index 414dd8654..77d3a6f9c 100644 --- a/backend/lamb/completions/pps/simple_augment.py +++ b/backend/lamb/completions/pps/simple_augment.py @@ -55,6 +55,23 @@ def _has_image_generation_capability(assistant: Assistant) -> bool: return capabilities.get('image_generation', False) except (json.JSONDecodeError, AttributeError): return False + + +def _is_single_file_rag(assistant: Assistant) -> bool: + """ + Check if the assistant uses single_file_rag — the only RAG where + context is byte-identical across requests and cache-aware splitting helps. + """ + if not assistant: + return False + metadata_str = getattr(assistant, 'metadata', None) + if not metadata_str: + return False + try: + metadata = json.loads(metadata_str) + return metadata.get('rag_processor') == 'single_file_rag' + except (json.JSONDecodeError, AttributeError): + return False def prompt_processor( request: Dict[str, Any], assistant: Optional[Assistant] = None, @@ -84,10 +101,38 @@ def prompt_processor( "role": "system", "content": assistant.system_prompt }) - + + # ── Cache-aware mode for single_file_rag ────────────────────── + # When the assistant uses single_file_rag, the file content is + # byte-identical across requests. Emitting it as a separate user + # message BEFORE conversation history lets LLM providers cache the + # stable prefix (system + context), slashing token costs by ~50-90%. + cache_mode = _is_single_file_rag(assistant) and rag_context + + if cache_mode: + context = ( + rag_context.get("context", "") + if isinstance(rag_context, dict) + else str(rag_context) + ) + if context: + processed_messages.append({ + "role": "user", + "content": ( + "The user may ask you questions about the following " + "document. Use this content to answer their questions " + "accurately.\n\n" + f"{context}" + ), + }) + logger.debug( + "Cache-aware mode: emitted context as separate user message " + "(%d chars)", len(context) + ) + # Add previous messages except the last one processed_messages.extend(messages[:-1]) - + # Process the last message using the prompt template if assistant.prompt_template: # Check if assistant has vision capabilities @@ -110,8 +155,8 @@ def prompt_processor( logger.debug(f"User message: {user_input_text}") augmented_text = assistant.prompt_template.replace("{user_input}", "\n\n" + user_input_text + "\n\n") - # Add RAG context if available - if rag_context: + # Add RAG context if available (skip in cache mode — already emitted as separate message) + if rag_context and not cache_mode: context = rag_context.get("context", "") if isinstance(rag_context, dict) else str(rag_context) # Format sources if available @@ -166,8 +211,8 @@ def prompt_processor( logger.debug(f"User message: {user_input_text}") prompt = assistant.prompt_template.replace("{user_input}", "\n\n" + user_input_text + "\n\n") - # Add RAG context if available - if rag_context: + # Add RAG context if available (skip in cache mode — already emitted as separate message) + if rag_context and not cache_mode: context = rag_context.get("context", "") if isinstance(rag_context, dict) else str(rag_context) # Format sources if available diff --git a/testing/playwright/fixtures/single_file_rag_fixture.txt b/testing/playwright/fixtures/single_file_rag_fixture.txt new file mode 100644 index 000000000..100da4696 --- /dev/null +++ b/testing/playwright/fixtures/single_file_rag_fixture.txt @@ -0,0 +1,16 @@ +LAMB Project Overview + +The LAMB (Learning Assistants Manager and Builder) platform allows educators to create +AI-based learning assistants. The platform was founded in 2024 and has grown to support +over 500 educational institutions worldwide. + +Key Features: +- No-code assistant builder with drag-and-drop interface +- Multi-model LLM support including GPT-4, Claude, and Llama +- RAG (Retrieval-Augmented Generation) knowledge bases +- Multi-tenant organizations with role-based access control + +The RAG_caching feature was introduced in version 2.1 to reduce token costs by up to 90% +when using single_file_rag. The total number of registered users as of June 2026 is 12,847. + +The platform's mascot is a llama named "Lambda" who wears a graduation cap. diff --git a/testing/playwright/tests/single_file_rag.spec.js b/testing/playwright/tests/single_file_rag.spec.js new file mode 100644 index 000000000..120ec66f6 --- /dev/null +++ b/testing/playwright/tests/single_file_rag.spec.js @@ -0,0 +1,237 @@ +const { test, expect } = require("@playwright/test"); +const path = require("path"); +require("dotenv").config({ path: path.join(__dirname, ".env"), quiet: true }); + +/** + * Cache-Aware Single File RAG Tests + * + * Validates the cache-aware single_file_rag feature that emits RAG context + * as a separate user message BEFORE conversation history, enabling LLM + * providers to cache the stable prefix and reduce token costs. + * + * Test flow: + * 1. Create an assistant configured with single_file_rag and a fixture file + * 2. Chat: send two messages in the same session — both use file content + * 3. Cleanup: delete the assistant + * + * Prerequisites: + * - Logged in as admin via global-setup.js + * - Backend has single_file_rag plugin enabled (PLUGIN_SINGLE_FILE_RAG=ENABLE) + */ + +test.describe.serial("Cache-Aware Single File RAG", () => { + const timestamp = Date.now(); + const assistantName = `pw_sfrag_${timestamp}`; + const fixtureFileName = "single_file_rag_fixture.txt"; + + // ───────────────────────────────────────────────────────────────────── + // 1. Create assistant with single_file_rag + fixture file + // ───────────────────────────────────────────────────────────────────── + test("1. Create assistant with single_file_rag and upload fixture file", async ({ + page, + }) => { + await page.goto("assistants?view=create"); + await page.waitForLoadState("networkidle"); + + const createButton = page + .getByRole("button", { name: /create assistant/i }) + .first(); + await expect(createButton).toBeVisible({ timeout: 10_000 }); + await createButton.click(); + + const form = page.locator("#assistant-form-main"); + await expect(form).toBeVisible({ timeout: 30_000 }); + + // Fill basic fields + await page.fill("#assistant-name", assistantName); + await page.fill( + "#assistant-description", + "Playwright test for cache-aware single_file_rag", + ); + await page.fill( + "#system-prompt", + "You are a helpful assistant. Answer questions based on the provided document.", + ); + + // Select single_file_rag from the RAG processor dropdown + const ragSelect = page.locator("#rag-processor"); + await expect(ragSelect).toBeVisible({ timeout: 15_000 }); + await ragSelect.selectOption("single_file_rag"); + + // Wait for the file selector to appear (SingleFileSelector component) + // The file upload input has id="file-upload" + const fileUploadInput = page.locator("#file-upload"); + await expect(fileUploadInput).toBeVisible({ timeout: 10_000 }); + + // Upload the fixture file + const fixturePath = path.join( + __dirname, + "..", + "fixtures", + fixtureFileName, + ); + await fileUploadInput.setInputFiles(fixturePath); + + // Wait for the async upload to complete (the "Uploading..." text disappears) + const uploadingIndicator = page.getByText(/uploading/i); + await uploadingIndicator.waitFor({ state: "hidden", timeout: 15_000 }).catch(() => { + // May disappear quickly or never appear — continue either way + }); + + // Wait for the uploaded file to appear as a radio option and select it + const fileRadio = page.getByRole("radio", { name: fixtureFileName }); + await expect(fileRadio).toBeVisible({ timeout: 15_000 }); + await fileRadio.check(); + + // Wait for the create_assistant API response + const createRequest = page.waitForResponse((response) => { + if (response.request().method() !== "POST") return false; + try { + const url = new URL(response.url()); + return ( + url.pathname.endsWith("/assistant/create_assistant") && + response.status() >= 200 && + response.status() < 300 + ); + } catch { + return false; + } + }); + + // Wait for Save button to be enabled + const saveButton = page.locator( + 'button[type="submit"][form="assistant-form-main"]', + ); + await expect(saveButton).toBeEnabled({ timeout: 60_000 }); + + // Submit the form + await Promise.all([createRequest, form.evaluate((f) => f.requestSubmit())]); + + // Verify we land back on the assistants list + await page.waitForURL(/\/assistants(\?.*)?$/, { timeout: 30_000 }); + + // Search for the newly created assistant + const searchBox = page.locator('input[placeholder*="Search" i]'); + if (await searchBox.count()) { + await searchBox.fill(assistantName); + await page.waitForTimeout(500); + } + + await expect(page.getByText(assistantName).first()).toBeVisible({ + timeout: 30_000, + }); + }); + + // ───────────────────────────────────────────────────────────────────── + // 2. Chat — two messages in the same session (validates cache-aware pipeline) + // ───────────────────────────────────────────────────────────────────── + test("2. Chat: two messages in same session both use file content", async ({ + page, + }) => { + // Navigate to assistants list and find our assistant + await page.goto("assistants"); + await page.waitForLoadState("networkidle"); + + const searchBox = page.locator('input[placeholder*="Search" i]'); + if (await searchBox.count()) { + await searchBox.fill(assistantName); + await page.waitForTimeout(500); + } + + // Click the assistant name to open detail view + const assistantRow = page.locator(`tr:has-text("${assistantName}")`); + await expect(assistantRow).toBeVisible({ timeout: 10_000 }); + + const nameButton = assistantRow + .getByRole("button", { name: new RegExp(assistantName, "i") }) + .first(); + if (await nameButton.count()) { + await nameButton.click(); + } else { + await assistantRow.click(); + } + + await page.waitForLoadState("networkidle"); + + // Click the Chat tab + const chatTab = page.getByRole("button", { name: /chat with/i }); + await expect(chatTab).toBeVisible({ timeout: 10_000 }); + await chatTab.click(); + + // Wait for chat input to appear + const chatInput = page.getByPlaceholder(/type your message/i); + await expect(chatInput).toBeVisible({ timeout: 15_000 }); + + // ── Message 1 ────────────────────────────────────────────────── + await chatInput.fill("What is the name of the platform mascot?"); + const sendButton = page.getByRole("button", { name: /^send$/i }); + await expect(sendButton).toBeVisible({ timeout: 5_000 }); + await sendButton.click(); + + // Wait for completed assistant response (prose div = streaming done) + const assistantProse = page.locator(".bg-gray-200 .prose"); + await expect(assistantProse.first()).toBeVisible({ timeout: 120_000 }); + + const response1 = await assistantProse.last().textContent(); + console.log(`[single_file_rag] Msg 1: ${response1?.substring(0, 300)}`); + expect(response1.toLowerCase()).toMatch(/lambda|llama|mascot|graduation/); + + // ── Message 2 (same session — cache-aware prefix reused) ─────── + await chatInput.fill( + "How many registered users does the platform have as of June 2026?", + ); + await expect(sendButton).toBeVisible({ timeout: 5_000 }); + await sendButton.click(); + + // Wait for a NEW prose element to appear (second response). + // We can't just check .first() — it still matches the first response. + const proseElements = page.locator(".bg-gray-200 .prose"); + await expect(proseElements).toHaveCount(2, { timeout: 120_000 }); + + const response2 = await proseElements.last().textContent(); + console.log(`[single_file_rag] Msg 2: ${response2?.substring(0, 300)}`); + expect(response2).toMatch(/12,?847|12847|twelve thousand/); + }); + + // ───────────────────────────────────────────────────────────────────── + // 3. Cleanup: delete the assistant + // ───────────────────────────────────────────────────────────────────── + test("3. Delete the test assistant", async ({ page }) => { + await page.goto("assistants"); + await page.waitForLoadState("networkidle"); + + const searchBox = page.locator('input[placeholder*="Search" i]'); + if (await searchBox.count()) { + await searchBox.fill(assistantName); + await page.waitForTimeout(500); + } + + const assistantRow = page.locator(`tr:has-text("${assistantName}")`); + await expect(assistantRow).toBeVisible({ timeout: 10_000 }); + + // Click the delete button + const deleteButton = assistantRow + .getByRole("button", { name: /delete/i }) + .first(); + await expect(deleteButton).toBeVisible({ timeout: 5_000 }); + await deleteButton.click(); + + // Wait for the confirmation modal + const modal = page.getByRole("dialog"); + await expect(modal).toBeVisible({ timeout: 5_000 }); + await expect(modal.getByText(/delete assistant/i)).toBeVisible(); + + // Confirm deletion + const confirmDeleteButton = modal.getByRole("button", { + name: /^delete$/i, + }); + await expect(confirmDeleteButton).toBeVisible({ timeout: 5_000 }); + await confirmDeleteButton.click(); + + // Wait for modal to close and assistant to be removed + await expect(modal).not.toBeVisible({ timeout: 10_000 }); + await expect(assistantRow).not.toBeVisible({ timeout: 10_000 }); + + console.log(`Assistant "${assistantName}" successfully deleted.`); + }); +});