From a127080e0884d9abbf669da79e624fc99577d83e Mon Sep 17 00:00:00 2001 From: mBerasategui-ehu Date: Tue, 16 Jun 2026 10:22:57 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20cach=C3=A9-aware=20single=20file=20?= =?UTF-8?q?RAG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lamb/completions/pps/simple_augment.py | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) 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 From 55a5266a62817459fe22df76d70017ba8a792bf2 Mon Sep 17 00:00:00 2001 From: mBerasategui-ehu Date: Thu, 2 Jul 2026 09:58:48 +0200 Subject: [PATCH 2/3] feat: implement cache-aware single file RAG with Playwright tests --- cache_aware_simple_file_rag.md | 103 ++++++++ .../fixtures/single_file_rag_fixture.txt | 16 ++ .../playwright/tests/single_file_rag.spec.js | 237 ++++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 cache_aware_simple_file_rag.md create mode 100644 testing/playwright/fixtures/single_file_rag_fixture.txt create mode 100644 testing/playwright/tests/single_file_rag.spec.js diff --git a/cache_aware_simple_file_rag.md b/cache_aware_simple_file_rag.md new file mode 100644 index 000000000..f2690abc5 --- /dev/null +++ b/cache_aware_simple_file_rag.md @@ -0,0 +1,103 @@ +# Implementation Log: Cache-Aware RAG & Grep RAG + +Tracking all changes made per `IMPLEMENTATION_PLAN_CACHED_GREP_RAG.md`. + +--- + +## Cache-Aware Single File RAG ✅ + +**Implementation choice: Option B** — Extended `simple_augment.py` with automatic cache-aware mode detection. When an assistant has `rag_processor: "single_file_rag"` in its metadata, `simple_augment` automatically splits the RAG context into a separate user message (before the question), enabling LLM provider prompt caching. No new prompt processor file needed. No auto-selection in `main.py` needed. Fully backward compatible. + +### Files Changed + +| File | Operation | Description | +|------|-----------|-------------| +| `backend/lamb/completions/pps/simple_augment.py` | **Edited** | Added `_is_single_file_rag()` helper + cache-aware logic. When assistant metadata has `rag_processor: "single_file_rag"`, context is emitted as a separate user message before conversation history. When not, existing template-based behavior is unchanged. | +| `testing/playwright/tests/single_file_rag.spec.js` | **Created** | Playwright E2E test: creates an assistant with `single_file_rag`, uploads a fixture file, sends two messages in the same chat session, and verifies both responses use file content (proving the cache-aware pipeline works across multiple turns). Cleans up the assistant afterward. | +| `testing/playwright/fixtures/single_file_rag_fixture.txt` | **Created** | Test fixture file containing fake LAMB platform facts ("Lambda" the mascot, "12,847" users) that the E2E test verifies in chat responses. | +| `docker-compose-example.yaml` | **Edited** | Changed `GLOBAL_LOG_LEVEL=WARNING` to `${GLOBAL_LOG_LEVEL:-WARNING}` so debug logs can be enabled from the project `.env` | + +### Tests + +**Playwright E2E** (`testing/playwright/tests/single_file_rag.spec.js`, 3 serial tests): + +```bash +cd testing/playwright && npx playwright test tests/single_file_rag.spec.js +``` + +| # | Test | What it validates | +|---|------|-------------------| +| 1 | Create assistant with `single_file_rag` | Fills form, selects `single_file_rag` from RAG dropdown, uploads fixture file via `#file-upload`, selects the radio, saves | +| 2 | Chat: two messages in same session | Sends "What is the name of the platform mascot?" → asserts response contains "Lambda"/"llama". Then sends "How many registered users...?" → asserts response contains "12,847". Both in the same chat session (cache-aware prefix reused across turns) | +| 3 | Cleanup | Deletes the assistant via confirmation modal | + +### Verified with real API call (OpenAI gpt-4o-mini) + +**Integration test passed.** Ran `python tests/test_integration_cache.py --real --size 12000` against the live OpenAI API. Results: + +| Call | Mode | prompt_tokens | cached_tokens | Result | +|------|------|:---:|:---:|--------| +| 1 | Cache (warm-up) | 2,060 | 0 | (expected) | +| 2 | Cache (test) | 2,058 | **1,920** | ✅ **CACHE HIT** | +| 3 | Standard (control) | 2,035 | **0** | ✅ No cache | + +**Real cost savings: 46%** with gpt-4o-mini. With gpt-4o the savings would be ~88%. + +### How it works +- `simple_augment` checks `assistant.metadata` for `rag_processor == "single_file_rag"` +- If true + RAG context exists: emits context as separate cached user message +- Messages: `[system] → [user: file context] → [prev msgs] → [user: question]` +- System + file context are byte-identical across requests → LLM provider caches them +- No new files, no config changes, no UI changes — just works + +### Example: messages sent to the LLM + +With prompt template: `"Responde la pregunta del usuario: --- {user_input} ---\n\nEste es el contexto:\n--- {context} ---"` + +``` +Message 1 — System (CACHED) +─────────────────────────────────────────────────────────────────────────┐ +│ Eres un asistente de aprendizaje que ayuda a los estudiantes a │ +│ aprender sobre un tema específico. Utiliza el contexto para responder │ +│ las preguntas del usuario. │ +└────────────────────────────────────────────────────────────────────────┘ + +Message 2 — User: file context (CACHED) +┌─────────────────────────────────────────────────────────────────────────┐ +│ The user may ask you questions about the following document. Use this │ +│ content to answer their questions accurately. │ +│ │ +│ El programa de Dbizi es un sistema de préstamo de bicicletas públicas…│ +│ [full file content — thousands of characters] │ +└─────────────────────────────────────────────────────────────────────────┘ + +Message 3 — User: question + template (INPUT — only this changes) +┌─────────────────────────────────────────────────────────────────────────┐ +│ Responde la pregunta del usuario: --- ¿Cuál es el tiempo máximo? --- │ +│ │ +│ Este es el contexto: │ +│ │ +│ --- --- │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Placeholder handling in cache mode + +In cache mode, `{context}` is replaced with empty string (the actual file content is already in Message 2). The `--- ---` structure remains as a template artifact. This avoids duplicating the file in the prompt while keeping the template structure intact. `{user_input}` is replaced with the actual question as usual. + +The replacement is language-agnostic — works regardless of what language the template is written in. + +### Verified in production (Docker + backend logs) + +Confirmed working via `docker compose -f docker-compose-example.yaml up -d --build` with `GLOBAL_LOG_LEVEL=DEBUG`. Backend log output: + +``` +DEBUG:lamb.completions:Processed messages: [ + {'role': 'system', 'content': 'Eres un asistente de aprendizaje...'}, + {'role': 'user', 'content': 'The user may ask you questions about the following document...[full file content]'}, + {'role': 'user', 'content': 'Este es el contexto:\n --- --- \n\nAhora responde la pregunta del usuario: --- Cuáles son los precios? ---'} +] +``` + +Message [0] and [1] are identical across requests → cached. Only message [2] changes. + 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.`); + }); +}); From 9db5fece806c3ec6cf82f95fb80280d1d4bd87a1 Mon Sep 17 00:00:00 2001 From: mBerasategui-ehu Date: Thu, 2 Jul 2026 10:18:35 +0200 Subject: [PATCH 3/3] refactor: remove implementation log for cache-aware RAG & Grep RAG --- cache_aware_simple_file_rag.md | 103 --------------------------------- 1 file changed, 103 deletions(-) delete mode 100644 cache_aware_simple_file_rag.md diff --git a/cache_aware_simple_file_rag.md b/cache_aware_simple_file_rag.md deleted file mode 100644 index f2690abc5..000000000 --- a/cache_aware_simple_file_rag.md +++ /dev/null @@ -1,103 +0,0 @@ -# Implementation Log: Cache-Aware RAG & Grep RAG - -Tracking all changes made per `IMPLEMENTATION_PLAN_CACHED_GREP_RAG.md`. - ---- - -## Cache-Aware Single File RAG ✅ - -**Implementation choice: Option B** — Extended `simple_augment.py` with automatic cache-aware mode detection. When an assistant has `rag_processor: "single_file_rag"` in its metadata, `simple_augment` automatically splits the RAG context into a separate user message (before the question), enabling LLM provider prompt caching. No new prompt processor file needed. No auto-selection in `main.py` needed. Fully backward compatible. - -### Files Changed - -| File | Operation | Description | -|------|-----------|-------------| -| `backend/lamb/completions/pps/simple_augment.py` | **Edited** | Added `_is_single_file_rag()` helper + cache-aware logic. When assistant metadata has `rag_processor: "single_file_rag"`, context is emitted as a separate user message before conversation history. When not, existing template-based behavior is unchanged. | -| `testing/playwright/tests/single_file_rag.spec.js` | **Created** | Playwright E2E test: creates an assistant with `single_file_rag`, uploads a fixture file, sends two messages in the same chat session, and verifies both responses use file content (proving the cache-aware pipeline works across multiple turns). Cleans up the assistant afterward. | -| `testing/playwright/fixtures/single_file_rag_fixture.txt` | **Created** | Test fixture file containing fake LAMB platform facts ("Lambda" the mascot, "12,847" users) that the E2E test verifies in chat responses. | -| `docker-compose-example.yaml` | **Edited** | Changed `GLOBAL_LOG_LEVEL=WARNING` to `${GLOBAL_LOG_LEVEL:-WARNING}` so debug logs can be enabled from the project `.env` | - -### Tests - -**Playwright E2E** (`testing/playwright/tests/single_file_rag.spec.js`, 3 serial tests): - -```bash -cd testing/playwright && npx playwright test tests/single_file_rag.spec.js -``` - -| # | Test | What it validates | -|---|------|-------------------| -| 1 | Create assistant with `single_file_rag` | Fills form, selects `single_file_rag` from RAG dropdown, uploads fixture file via `#file-upload`, selects the radio, saves | -| 2 | Chat: two messages in same session | Sends "What is the name of the platform mascot?" → asserts response contains "Lambda"/"llama". Then sends "How many registered users...?" → asserts response contains "12,847". Both in the same chat session (cache-aware prefix reused across turns) | -| 3 | Cleanup | Deletes the assistant via confirmation modal | - -### Verified with real API call (OpenAI gpt-4o-mini) - -**Integration test passed.** Ran `python tests/test_integration_cache.py --real --size 12000` against the live OpenAI API. Results: - -| Call | Mode | prompt_tokens | cached_tokens | Result | -|------|------|:---:|:---:|--------| -| 1 | Cache (warm-up) | 2,060 | 0 | (expected) | -| 2 | Cache (test) | 2,058 | **1,920** | ✅ **CACHE HIT** | -| 3 | Standard (control) | 2,035 | **0** | ✅ No cache | - -**Real cost savings: 46%** with gpt-4o-mini. With gpt-4o the savings would be ~88%. - -### How it works -- `simple_augment` checks `assistant.metadata` for `rag_processor == "single_file_rag"` -- If true + RAG context exists: emits context as separate cached user message -- Messages: `[system] → [user: file context] → [prev msgs] → [user: question]` -- System + file context are byte-identical across requests → LLM provider caches them -- No new files, no config changes, no UI changes — just works - -### Example: messages sent to the LLM - -With prompt template: `"Responde la pregunta del usuario: --- {user_input} ---\n\nEste es el contexto:\n--- {context} ---"` - -``` -Message 1 — System (CACHED) -─────────────────────────────────────────────────────────────────────────┐ -│ Eres un asistente de aprendizaje que ayuda a los estudiantes a │ -│ aprender sobre un tema específico. Utiliza el contexto para responder │ -│ las preguntas del usuario. │ -└────────────────────────────────────────────────────────────────────────┘ - -Message 2 — User: file context (CACHED) -┌─────────────────────────────────────────────────────────────────────────┐ -│ The user may ask you questions about the following document. Use this │ -│ content to answer their questions accurately. │ -│ │ -│ El programa de Dbizi es un sistema de préstamo de bicicletas públicas…│ -│ [full file content — thousands of characters] │ -└─────────────────────────────────────────────────────────────────────────┘ - -Message 3 — User: question + template (INPUT — only this changes) -┌─────────────────────────────────────────────────────────────────────────┐ -│ Responde la pregunta del usuario: --- ¿Cuál es el tiempo máximo? --- │ -│ │ -│ Este es el contexto: │ -│ │ -│ --- --- │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -### Placeholder handling in cache mode - -In cache mode, `{context}` is replaced with empty string (the actual file content is already in Message 2). The `--- ---` structure remains as a template artifact. This avoids duplicating the file in the prompt while keeping the template structure intact. `{user_input}` is replaced with the actual question as usual. - -The replacement is language-agnostic — works regardless of what language the template is written in. - -### Verified in production (Docker + backend logs) - -Confirmed working via `docker compose -f docker-compose-example.yaml up -d --build` with `GLOBAL_LOG_LEVEL=DEBUG`. Backend log output: - -``` -DEBUG:lamb.completions:Processed messages: [ - {'role': 'system', 'content': 'Eres un asistente de aprendizaje...'}, - {'role': 'user', 'content': 'The user may ask you questions about the following document...[full file content]'}, - {'role': 'user', 'content': 'Este es el contexto:\n --- --- \n\nAhora responde la pregunta del usuario: --- Cuáles son los precios? ---'} -] -``` - -Message [0] and [1] are identical across requests → cached. Only message [2] changes. -