diff --git a/content/develop/ai/context-engine/langcache/_index.md b/content/develop/ai/context-engine/langcache/_index.md index 5025fbef3b..7e1ee54340 100644 --- a/content/develop/ai/context-engine/langcache/_index.md +++ b/content/develop/ai/context-engine/langcache/_index.md @@ -94,6 +94,8 @@ Imagine you’re using an LLM to build an agent to answer questions about your c These prompts may have slight variations, but they essentially ask the same question. LangCache can help you avoid calling the LLM for each of these prompts by caching the response to the first prompt and returning it for any similar prompts. +To watch a cache answer paraphrased questions, try the [interactive demo](/content/develop/ai/context-engine/langcache/interactive-demo.md). + Using LangCache as a semantic caching service has the following benefits: - **Lower LLM costs**: Reduce costly LLM calls by easily storing the most frequently-requested responses. diff --git a/content/develop/ai/context-engine/langcache/interactive-demo.md b/content/develop/ai/context-engine/langcache/interactive-demo.md new file mode 100644 index 0000000000..21e1ebf97a --- /dev/null +++ b/content/develop/ai/context-engine/langcache/interactive-demo.md @@ -0,0 +1,30 @@ +--- +alwaysopen: false +categories: +- docs +- develop +- ai +description: Watch a semantic cache answer customers' repeat questions, tune its similarity threshold, and scope answers with attributes. +hideListLinks: true +linktitle: Interactive demo +title: Redis LangCache interactive demo +weight: 5 +--- + +Redis LangCache stores the answers your LLM generates and returns them when someone asks a similar question, so your app doesn't call the LLM again. This demo follows the help assistant of a food delivery app. You choose which questions its customers ask, and the demo shows each search, each cache hit or miss, and what the cache stores. + +The demo runs in your browser and doesn't connect to a LangCache service or an LLM. Its requests and responses follow the shapes of the [LangCache API](/content/develop/ai/context-engine/langcache/api-reference.md). The similarity scores, answers, and timings are illustrative. The customers and restaurants come from the same sample data as the [Context Retriever interactive demo](/content/develop/ai/context-engine/context-retriever/interactive-demo.md). + +{{< langcache-demo >}} + +## What the demo shows + +1. **Hits and misses.** The app searches the cache before it calls the LLM. A miss calls the LLM and stores the answer, so the next similar question is a hit. +2. **Similarity threshold.** A search is a hit when a cached prompt is at least as similar as the threshold. A higher threshold gives fewer wrong answers and more LLM calls. A lower threshold does the opposite. +3. **Attributes.** Attributes scope searches and deletes. A restaurant attribute keeps one restaurant's answers from reaching another restaurant's customers, and lets the app delete a restaurant's answers when its menu changes. + +## Next steps + +- Read [LangCache concepts](/content/develop/ai/context-engine/langcache/concepts.md) to learn how to choose a similarity threshold. +- [Create a LangCache service](/content/operate/iris/langcache/create-service.md) on Redis Cloud. +- Use the [LangCache API and SDK](/content/develop/ai/context-engine/langcache/api-examples.md) to search and store entries from your app. diff --git a/layouts/shortcodes/langcache-demo.html b/layouts/shortcodes/langcache-demo.html new file mode 100644 index 0000000000..266a171b6b --- /dev/null +++ b/layouts/shortcodes/langcache-demo.html @@ -0,0 +1,33 @@ +{{- /* + langcache-demo + -------------- + Scripted, dependency-free demo of Redis LangCache: a help assistant + searches the cache before calling its LLM and stores new answers, a + replay shows the similarity threshold trade-off, and a restaurant + attribute scopes and invalidates menu answers. Runs entirely in the + browser; no service is called. + + Usage (no arguments): + {{< langcache-demo >}} + + Base styles come from context-retriever-demo.css so the Iris demos share + one look; langcache-demo.css adds this demo's pieces. + + The assets are emitted on every call rather than deduplicated with + .Page.Store: the store survives `hugo server` rebuilds, so a page that + isn't edited would lose its assets after the first rebuild. The script + guards against running twice if the shortcode is used more than once. +*/ -}} + +{{- /* ?v= is a hash of the file, so a reload never runs a stale cached copy. */ -}} +{{- $v := dict -}} +{{- range slice "css/context-retriever-demo.css" "css/langcache-demo.css" "js/langcache-demo.js" -}} + {{- $v = merge $v (dict . (substr (md5 (os.ReadFile (print "static/" .))) 0 10)) -}} +{{- end }} + + + + +
data array: a miss. The app calls its LLM for an answer."];
+ } else if (!last.near) {
+ notes.push("Miss: the cache is empty. The app calls its LLM, replies, and stores the prompt and response as the cache's first entry.");
+ } else if (last.near.entry.src.intent === last.src.intent) {
+ notes.push("Miss, although " + quote(last.near.entry.prompt) + " asks nearly the same thing. Their similarity is " + f2(last.near.sim) +
+ ", below the " + f2(THRESHOLD) + " threshold, so the search returns an empty data array. The app calls the LLM again and stores " +
+ "a second entry for nearly the same question. Step 2 looks at where to set the threshold.");
+ } else {
+ notes.push("Miss. Nothing in the cache is similar enough: the closest entry, " + quote(last.near.entry.prompt) + ", scores " + f2(last.near.sim) +
+ ". The app calls its LLM, replies, and stores the answer for the next customer who asks something similar.");
+ }
+ /* Every in-flight frame returned above, so the last request is complete:
+ this is its final frame, or a later redraw. */
+ if (items.length === QUESTIONS.length) {
+ var m = metrics(items);
+ notes.push("All six questions are answered: " + m.hits + " from the cache and " + m.llm + " from the LLM, a " + m.ratio +
+ "% hit ratio. A new cache starts with misses, and its hit ratio rises as it fills with answers to common questions.");
+ }
+ return notes;
+ }
+
+ function thresholdNotes(ev, t) {
+ var notes = [], isDefault = Math.abs(t - THRESHOLD) < 1e-9;
+ notes.push("At " + f2(t) + (isDefault ? ", the default," : "") + " the cache answers " + ev.hits + " of " + TESTS.length + " questions" +
+ (ev.hits === 0 ? "." : ev.wrong ? ", and " + (ev.wrong === 1 ? "1 answer is" : ev.wrong + " answers are") + " wrong." : ", and every answer is right."));
+ ev.rows.forEach(function (r) {
+ if (r.verdict === "wrong") notes.push(quote(r.t.text) + " scores " + f2(r.best.sim) + " against " + quote(r.best.entry.prompt) + ". " + esc(r.t.why));
+ });
+ var missed = ev.rows.filter(function (r) { return r.verdict === "missed"; });
+ if (missed.length) {
+ notes.push(missed.map(function (r) { return quote(r.t.text) + " (" + f2(r.best.sim) + ")"; }).join(" and ") +
+ (missed.length === 1 ? " calls" : " call") + " the LLM, although a cached answer fits.");
+ }
+ var falseHit = byKey(TESTS, "t2"), weakHit = byKey(TESTS, "t3");
+ notes.push("No threshold gets all six right, because a wrong match can score higher than a right one: " + quote(falseHit.text) + " scores " +
+ f2(falseHit.sims.fee) + ", and " + quote(weakHit.text) + " scores " + f2(weakHit.sims.fee) + ". LangCache recommends starting between 0.80 and 0.90. " +
+ "Start high, then lower the threshold gradually while you check the matches.");
+ return notes;
+ }
+
+ function menuNotes(n, phase) {
+ var m = MENU[n - 1];
+ if (!n) return ["Both lanes use the same service and get the same events. Only their requests differ. Start with the first event."];
+ if (phase != null && phase < (m.kind === "change" ? 2 : 4)) {
+ return [m.kind === "change" ? "The recipe changed, so Lotus Thai's cached answer about its green curry is out of date." :
+ "Both lanes get the same question. Only their requests differ."];
+ }
+ return {
+ m1: ["Both searches miss, because nothing about this curry is cached yet. Each lane calls the LLM and stores the answer. " +
+ "The lane with attributes stores it with \"restaurant\": \"r209\"."],
+ m2: ["Without attributes, Liam gets Lotus Thai's answer. The two questions score " + f2(MENU_SIM["m1|m2"]) + ", and nothing tells the cache " +
+ "they're about different restaurants. With the attribute, the search only looks at Bangkok Street Kitchen's entries, finds none, and the app asks the LLM."],
+ m3: ["With attributes, one DELETE call removes every Lotus Thai entry and nothing else. Without them, the app can't select " +
+ "Lotus Thai's entries. It can delete entries one at a time by ID, if it kept the IDs, or flush the whole cache."],
+ m4: ["Without attributes, Aisha gets the old answer, which says the curry has fish sauce. With attributes, the search misses, and the LLM " +
+ "answers from the new recipe.",
+ "A service can have up to 5 attributes, and you can't change them after you create the service, so plan them first."]
+ }[m.key];
+ }
+
+ /* ------------------------------------------------------------------ view */
+
+ function init(root) {
+ var st = { tab: "questions", asked: [], threshold: THRESHOLD, seen: {}, menu: 0, open: {}, anim: 0, busy: false };
+ var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ function wait(ms) { return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); }); }
+
+ root.innerHTML = "";
+ root.appendChild(buildSettings());
+ var tabs = el("div", "rcr-tabs");
+ tabs.setAttribute("role", "tablist");
+ var TABS = [["questions", "1", "Hits and misses"], ["threshold", "2", "Similarity threshold"], ["attributes", "3", "Attributes"]];
+ var panels = {};
+ TABS.forEach(function (t, i) {
+ var b = el("button", "rcr-tab", '' + t[1] + "" + esc(t[2]));
+ b.type = "button"; b.setAttribute("role", "tab"); b.dataset.tab = t[0];
+ b.setAttribute("aria-selected", i === 0 ? "true" : "false");
+ b.addEventListener("click", function () { show(t[0]); });
+ tabs.appendChild(b);
+ panels[t[0]] = el("div", "rcr-panel");
+ panels[t[0]].setAttribute("role", "tabpanel");
+ panels[t[0]].setAttribute("aria-live", "polite");
+ if (i) panels[t[0]].hidden = true;
+ });
+ root.appendChild(tabs);
+ Object.keys(panels).forEach(function (k) { root.appendChild(panels[k]); });
+
+ /* Animations run as timed steps; the last step renders the final state.
+ Anything that changes the story bumps st.anim, so a replay in flight
+ stops and the panel shows the final state instead. */
+ function cancel() { st.anim++; st.busy = false; }
+ function play(steps) {
+ var id = ++st.anim;
+ st.busy = true;
+ var chain = Promise.resolve();
+ steps.forEach(function (s, i) {
+ chain = chain.then(function () { return wait(s[0]); }).then(function () {
+ if (id !== st.anim) return;
+ if (i === steps.length - 1) st.busy = false;
+ s[1]();
+ });
+ });
+ }
+
+ /* Steps unlock in order: the threshold once every question has been
+ answered, attributes once the threshold step has been opened. */
+ function lockReason(name) {
+ if (name === "threshold" && (st.asked.length < QUESTIONS.length || (st.busy && st.tab === "questions"))) return "Send all six questions first.";
+ if (name === "attributes" && !st.seen.threshold) return "Try the similarity threshold first.";
+ return null;
+ }
+ function syncTabs() {
+ root.setAttribute("data-busy", st.busy ? "true" : "false");
+ Array.prototype.forEach.call(tabs.children, function (b) {
+ var why = lockReason(b.dataset.tab);
+ b.classList.toggle("is-locked", !!why);
+ if (why) { b.setAttribute("aria-disabled", "true"); b.title = why; }
+ else { b.removeAttribute("aria-disabled"); b.removeAttribute("title"); }
+ });
+ }
+ function show(name) {
+ if (lockReason(name)) return;
+ cancel();
+ st.tab = name;
+ if (name === "threshold") st.seen.threshold = true;
+ Array.prototype.forEach.call(tabs.children, function (b) { b.setAttribute("aria-selected", b.dataset.tab === name ? "true" : "false"); });
+ Object.keys(panels).forEach(function (k) { panels[k].hidden = k !== name; });
+ renderCurrent();
+ }
+ /* The Next and Restart buttons sit at the bottom of a long step, and the
+ step they open starts at the top. Scroll the tabs to just under the
+ site's sticky header, and move keyboard focus to the new tab so it isn't
+ lost with the replaced button. */
+ function advance(name) {
+ show(name);
+ var header = document.querySelector("header");
+ var under = header && /sticky|fixed/.test(getComputedStyle(header).position) ? header.getBoundingClientRect().bottom : 0;
+ window.scrollTo({ top: window.pageYOffset + tabs.getBoundingClientRect().top - under - 16, behavior: reduced ? "instant" : "smooth" });
+ tabs.querySelector('[data-tab="' + name + '"]').focus({ preventScroll: true });
+ }
+ function renderCurrent() {
+ if (st.tab === "questions") renderQuestions();
+ else if (st.tab === "threshold") renderThreshold();
+ else renderAttributes();
+ }
+ function restart() {
+ cancel();
+ st.asked = []; st.threshold = THRESHOLD; st.seen = {}; st.menu = 0; st.open = {};
+ advance("questions");
+ }
+
+ /* ---- service settings (read-only) ---- */
+
+ function buildSettings() {
+ var d = el("details", "rcr-card rlc-settings");
+ d.appendChild(el("summary", "", 'Service settings' + SERVICE +
+ " · threshold " + f2(THRESHOLD) + " · no TTL · attribute: restaurant"));
+ var grid = el("div", "rlc-setgrid");
+ [
+ ["Similarity threshold", f2(THRESHOLD), "The default. A search can override it with similarityThreshold."],
+ ["TTL", "No expiration", "The default. Entries stay until they're deleted or the cache is flushed. A store request can set ttlMillis for one entry."],
+ ["Embedding provider", "Redis", "LangCache generates the embeddings, so the app sends text."],
+ ["Search strategy", "semantic", "The default. Add exact to searchStrategies to also match identical prompts, ignoring case."],
+ ["Attributes", "restaurant", "Defined when the service was created. A service can have up to 5, and they can't be changed later."]
+ ].forEach(function (r) {
+ var row = el("div", "rlc-set");
+ row.appendChild(el("span", "rcr-lbl", esc(r[0])));
+ row.appendChild(el("span", "rcr-mono", esc(r[1])));
+ row.appendChild(el("span", "rcr-faint", r[2]));
+ grid.appendChild(row);
+ });
+ d.appendChild(grid);
+ d.appendChild(el("p", "rcr-note", "These settings are fixed in the demo. On Redis Cloud, you can edit the threshold and TTL after you create the service."));
+ return d;
+ }
+
+ /* ---- shared pieces ---- */
+
+ function callout(notes) {
+ var c = el("div", "rlc-callout");
+ notes.forEach(function (n) { c.appendChild(el("p", "", n)); });
+ return c;
+ }
+ function controlsRow(primary) {
+ var row = el("div", "rlc-controls");
+ var r = el("button", "rcr-btn", "Restart");
+ r.type = "button";
+ r.disabled = !st.asked.length && st.tab === "questions";
+ r.addEventListener("click", restart);
+ row.appendChild(r);
+ if (primary) {
+ var b = el("button", "rcr-btn rcr-btn-primary", esc(primary.label));
+ b.type = "button";
+ b.disabled = st.busy;
+ b.addEventListener("click", primary.fn);
+ row.appendChild(b);
+ }
+ return row;
+ }
+ function pre(obj) { return el("pre", "rcr-code rcr-code-sm", jsonHtml(obj)); }
+ function grid(a, b, cls) { var g = el("div", "rlc-grid" + (cls ? " " + cls : "")); g.appendChild(a); g.appendChild(b); return g; }
+ function remember(d, key) {
+ if (st.open[key]) d.open = true;
+ d.addEventListener("toggle", function () { st.open[key] = d.open; });
+ return d;
+ }
+
+ /* A LangCache API call. With no status it's still in flight. */
+ function callCard(label, method, path, request, status, response, tag) {
+ var head = (tag ? '' + esc(tag.text) + "" : "") +
+ '' + method + ' ' + esc(path) +
+ ' ' + esc(label) + "";
+ if (!status) return el("div", "rcr-call rlc-callrow", head + ' …');
+ var d = el("details", "rcr-call");
+ d.appendChild(el("summary", "", head + ' ' + status + ""));
+ var body = el("div", "rcr-callbody");
+ if (request) { body.appendChild(el("div", "rcr-lbl", "Request body")); body.appendChild(pre(request)); }
+ body.appendChild(el("div", "rcr-lbl", "Response"));
+ body.appendChild(response == null ? el("p", "rcr-faint", "No content.") : pre(response));
+ var cd = el("details", "rcr-curl");
+ cd.appendChild(el("summary", "", "Run this call yourself with curl"));
+ cd.appendChild(el("pre", "rcr-code rcr-code-sm", esc(curl(method, path, request))));
+ body.appendChild(cd);
+ d.appendChild(body);
+ return d;
+ }
+ /* The app's own LLM call: not part of LangCache, so it has no request to show. */
+ function llmRow(src, done) {
+ return el("div", "rlc-llm", 'LLMYour app calls its own model' +
+ '' + (done ? ' ' + seconds(src.llmMs) + " · " + src.tokens + " output tokens" :
+ ' generating…') + "");
+ }
+ function thinking() { return el("div", "rcr-msg rcr-agent rcr-thinking", ""); }
+ function searchTag(item) {
+ return item.match ? { kind: "hit", text: "Hit " + f2(item.match.sim) } : { kind: "miss", text: "Miss" };
+ }
+ var VERDICTS = {
+ other: ["bad", "Another restaurant's answer"],
+ stale: ["bad", "Out-of-date answer"]
+ };
+ function reply(item) {
+ var wrap = el("div", "rlc-reply");
+ wrap.appendChild(el("div", "rcr-msg rcr-agent", esc(item.reply)));
+ var src = item.match ?
+ 'From the cache similarity ' + f2(item.match.sim) + " · " + item.src.searchMs + " ms" :
+ 'From the LLM ' + seconds(item.src.searchMs + item.src.llmMs);
+ var v = VERDICTS[item.verdict];
+ wrap.appendChild(el("div", "rlc-srcline", src + (v ? ' ' + v[1] + "" : "")));
+ return wrap;
+ }
+ /* The row's summary already names the customer, so this shows the ID,
+ and in step 3 the restaurant, which the summary leaves out. */
+ function whoLine(item) {
+ var r = item.src.restaurant;
+ return el("div", "rlc-who", '' + item.uid + "" + (r ? " at " + esc(RESTAURANTS[r]) : ""));
+ }
+
+ /* One request, drawn up to `phase`: 1 searching, 2 search done (a hit is
+ complete here), 3 the LLM has answered, 4 the answer is stored. */
+ function requestBody(item, phase) {
+ var box = el("div", "rlc-req");
+ if (item.change) {
+ box.appendChild(el("div", "rlc-event", 'Menu change ' + esc(item.src.text)));
+ if (item.del) {
+ box.appendChild(callCard("Delete Lotus Thai's entries", "DELETE", "/entries", item.del.request, phase >= 2 ? "200" : null, item.del.response));
+ } else if (phase >= 2) {
+ box.appendChild(el("p", "rlc-nocall", "No request. Without an attribute, the app has no way to select Lotus Thai's entries, so " +
+ (item.stale ? "the cached answer stays." : "nothing changes.")));
+ }
+ return box;
+ }
+ box.appendChild(whoLine(item));
+ box.appendChild(el("div", "rcr-msg rcr-user", esc(item.src.text)));
+ box.appendChild(callCard("Search the cache", "POST", "/entries/search", item.search.request, phase >= 2 ? "200" : null,
+ item.search.response, phase >= 2 ? searchTag(item) : null));
+ if (item.match) { if (phase >= 2) box.appendChild(reply(item)); return box; }
+ if (phase >= 2) box.appendChild(llmRow(item.src, phase >= 3));
+ if (phase === 2) box.appendChild(thinking());
+ if (phase >= 3) {
+ box.appendChild(reply(item));
+ box.appendChild(callCard("Store the answer", "POST", "/entries", item.store.request, phase >= 4 ? "201" : null, item.store.response));
+ }
+ return box;
+ }
+ /* Every request is a one-line row that expands. Earlier rows start
+ collapsed and the latest starts open; its outcome shows once its search
+ is done. Collapsing the latest row holds while its animation plays, and
+ it's kept apart from the earlier rows' state, so the row collapses like
+ the others when the next request arrives. */
+ function logRow(item, key, phase, current) {
+ var d = el("details", "rlc-past" + (current ? " is-current" : ""));
+ var done = phase >= 2, outcome = "";
+ if (item.change) {
+ if (done) outcome = '' + (item.del ? "Deleted " + item.del.response.deletedEntriesCount : "No request") + "";
+ } else {
+ outcome = done ? '' + esc(searchTag(item).text) + "" :
+ 'Searching…';
+ }
+ var v = done ? VERDICTS[item.verdict] : null;
+ var sum = el("summary", "", '' + esc(item.change ? "Menu change" : CUSTOMERS[item.uid]) + "" +
+ '' + esc(item.src.text) + "" + (v ? '' + v[1] + "" : "") + outcome);
+ d.appendChild(sum);
+ d.appendChild(requestBody(item, phase));
+ if (current) {
+ d.open = st.open["now:" + key] !== false;
+ sum.addEventListener("click", function () { st.open["now:" + key] = !d.open; });
+ } else remember(d, key);
+ return d;
+ }
+ function requestLog(items, phase, prefix) {
+ var log = el("div", "rlc-log");
+ items.forEach(function (item, i) {
+ var current = i === items.length - 1;
+ log.appendChild(logRow(item, prefix + i, current && phase != null ? phase : 4, current));
+ });
+ return log;
+ }
+
+ function entryRow(e, opts) {
+ var r = el("div", "rlc-entry" + (opts.fresh ? " rlc-fresh" : "") + (opts.used ? " is-used" : "") +
+ (e.stale ? " is-stale" : "") + (e.deleted ? " is-deleted" : ""));
+ var attrs = Object.keys(e.attributes).map(function (k) { return '' + esc(k) + ": " + esc(e.attributes[k]) + ""; }).join("");
+ var state = e.deleted ? 'deleted' : e.stale ? 'out of date' : "";
+ r.appendChild(el("div", "rlc-eprompt", quote(e.prompt)));
+ r.appendChild(el("div", "rlc-eresp", esc(e.response)));
+ r.appendChild(el("div", "rlc-emeta", '' + shortId(e.id) + " · " + plural(e.hits, "hit") + attrs + state));
+ return r;
+ }
+ function metricTiles(m) {
+ var t = el("div", "rlc-metrics");
+ [[m.ratio == null ? "–" : m.ratio + "%", "Cache hit ratio"], [String(m.requests), "Cache search requests"],
+ [m.latency == null ? "–" : m.latency + " ms", "Cache latency"], [String(m.saved), "Output tokens saved"]].forEach(function (x) {
+ t.appendChild(el("div", "rlc-metric", '' + x[0] + '' + x[1] + ""));
+ });
+ return t;
+ }
+ function cacheCard(title, entries, opts) {
+ var card = el("div", "rcr-card rlc-cache");
+ card.appendChild(el("div", "rcr-cardhead", "" + esc(title) + '' +
+ plural(entries.filter(function (e) { return !e.deleted; }).length, "entry", "entries") + ""));
+ if (opts.metrics) card.appendChild(opts.metrics);
+ if (!entries.length) card.appendChild(el("p", "rlc-empty", opts.empty || "Empty."));
+ entries.forEach(function (e) { card.appendChild(entryRow(e, { fresh: opts.fresh === e, used: opts.used === e })); });
+ if (opts.note) card.appendChild(el("p", "rcr-note", opts.note));
+ return card;
+ }
+
+ /* ---- tab 1: hits and misses ---- */
+
+ function renderQuestions(view) {
+ var phase = view ? view.phase : null;
+ var run = runQuestions(st.asked), items = run.items, last = items[items.length - 1];
+ /* While the latest request is in flight, the cache and metrics show
+ what the app knows so far. */
+ var before = phase === 1 ? runQuestions(st.asked.slice(0, -1)) : run;
+ var entries = before.entries.filter(function (e) { return !(last && e === last.stored && phase != null && phase < 4); });
+ var m = metrics(phase === 1 ? before.items : items);
+ var p = panels.questions;
+ p.innerHTML = "";
+ p.appendChild(el("p", "rcr-lede", "Customers of the food delivery app ask its help assistant questions. Before the app calls its LLM, " +
+ "it searches LangCache for a similar question that's already been answered. Choose which question arrives next, and watch the cache fill."));
+
+ var log = el("div", "rcr-card rlc-logcard");
+ log.appendChild(el("div", "rcr-cardhead", 'Help assistant' + items.length + " of " + QUESTIONS.length + " questions"));
+ if (!items.length) log.appendChild(el("p", "rlc-empty", "No questions yet."));
+ else log.appendChild(requestLog(items, phase, "q"));
+ var left = QUESTIONS.filter(function (q) { return st.asked.indexOf(q.key) < 0; });
+ if (!st.busy && left.length) {
+ var c = el("div", "rlc-composer");
+ c.appendChild(el("span", "rcr-lbl", "Choose the next question"));
+ var list = el("div", "rlc-options");
+ left.forEach(function (q) {
+ var b = el("button", "rcr-chip rlc-option", '' + esc(CUSTOMERS[q.uid]) + "" + esc(q.text));
+ b.type = "button";
+ b.addEventListener("click", function () { ask(q.key); });
+ list.appendChild(b);
+ });
+ c.appendChild(list);
+ log.appendChild(c);
+ }
+ var used = phase != null && last && last.match ? last.match.entry : null;
+ var fresh = phase === 4 && last ? last.stored : null;
+ p.appendChild(grid(log, cacheCard("Cache", entries, { metrics: metricTiles(m), fresh: fresh, used: used,
+ empty: "Empty. Nothing has been stored yet.",
+ note: "The first three figures are the ones on the service's Metrics tab in Redis Cloud. The API searches entries but doesn't list them: the demo shows them so you can follow along." })));
+ p.appendChild(callout(questionNotes(run, phase)));
+ p.appendChild(controlsRow(!st.busy && !left.length ? { label: "Next: similarity threshold", fn: function () { advance("threshold"); } } : null));
+ syncTabs();
+ }
+ function ask(key) {
+ if (st.busy || st.asked.indexOf(key) >= 0) return;
+ st.asked.push(key);
+ var item = runQuestions(st.asked).items[st.asked.length - 1];
+ var steps = [[0, function () { renderQuestions({ phase: 1 }); }]];
+ if (item.match) steps.push([700, function () { renderQuestions({ phase: 2 }); }]);
+ else {
+ steps.push([700, function () { renderQuestions({ phase: 2 }); }]);
+ steps.push([1100, function () { renderQuestions({ phase: 3 }); }]);
+ steps.push([600, function () { renderQuestions({ phase: 4 }); }]);
+ }
+ play(steps);
+ }
+
+ /* ---- tab 2: similarity threshold ---- */
+
+ var VERDICT_LABEL = {
+ right: ["ok", "Hit, right answer"], wrong: ["bad", "Hit, wrong answer"],
+ missed: ["warn", "Miss, a cached answer fits"], none: ["none", "Miss, nothing cached fits"]
+ };
+ function barPos(x) { return Math.max(0, Math.min(100, (x - 0.5) / 0.5 * 100)); }
+
+ function renderThreshold() {
+ var run = runQuestions(st.asked);
+ var p = panels.threshold;
+ p.innerHTML = "";
+ p.appendChild(el("p", "rcr-lede", "Six new questions, replayed against the cache from step 1. Move the threshold to see which ones " +
+ "the cache answers and whether each answer is right. The replay only searches, so the cache doesn't change."));
+
+ var card = el("div", "rcr-card rlc-thrcard");
+ var ctl = el("div", "rlc-thrctl");
+ var lbl = el("label", "rlc-ctl", 'Similarity threshold');
+ var thr = el("input", "rlc-range");
+ thr.type = "range"; thr.min = String(MIN_T); thr.max = String(MAX_T); thr.step = "0.01"; thr.value = String(st.threshold);
+ thr.setAttribute("aria-label", "Similarity threshold");
+ var val = el("span", "rcr-mono rlc-thrval");
+ lbl.appendChild(thr); lbl.appendChild(val);
+ ctl.appendChild(lbl);
+ var reset = el("button", "rcr-btn rlc-small", "Back to the default");
+ reset.type = "button";
+ ctl.appendChild(reset);
+ card.appendChild(ctl);
+ var tiles = el("div", "rlc-metrics rlc-metrics-3");
+ card.appendChild(tiles);
+ var rows = el("div", "rlc-rows");
+ card.appendChild(rows);
+ card.appendChild(el("p", "rcr-note", "The bars run from 0.50 to 1.00, and the line marks the threshold. The search request sets " +
+ "similarityThreshold, so it overrides the service's default for that search only."));
+ p.appendChild(card);
+ var notes = el("div", "");
+ p.appendChild(notes);
+ p.appendChild(controlsRow({ label: "Next: attributes", fn: function () { advance("attributes"); } }));
+
+ /* Redraw only the results, so the slider keeps focus while it's dragged. */
+ function refresh() {
+ var t = st.threshold, ev = evalTests(run.entries, t);
+ val.textContent = f2(t) + (Math.abs(t - THRESHOLD) < 1e-9 ? " (default)" : "");
+ reset.hidden = Math.abs(t - THRESHOLD) < 1e-9;
+ tiles.innerHTML = "";
+ [[ev.hits + " of " + TESTS.length, "Answered from the cache"], [String(ev.wrong), "Wrong answers"],
+ [String(ev.missed), "LLM calls a cached answer could have saved"]].forEach(function (x, i) {
+ tiles.appendChild(el("div", "rlc-metric" + (i === 1 && ev.wrong ? " is-bad" : ""), '' + x[0] + '' + x[1] + ""));
+ });
+ rows.innerHTML = "";
+ ev.rows.forEach(function (r) {
+ var v = VERDICT_LABEL[r.verdict];
+ var d = remember(el("details", "rlc-row is-" + v[0]), "t:" + r.t.key);
+ d.appendChild(el("summary", "",
+ '' + quote(r.t.text) + '" +
+ '' + f2(r.best.sim) + "" +
+ '' + v[1] + ""));
+ var body = el("div", "rcr-callbody");
+ body.appendChild(el("div", "rcr-lbl", "Request body"));
+ body.appendChild(pre(r.request));
+ body.appendChild(el("div", "rcr-lbl", "Response · 200"));
+ body.appendChild(pre(r.response));
+ var cd = el("details", "rcr-curl");
+ cd.appendChild(el("summary", "", "Run this call yourself with curl"));
+ cd.appendChild(el("pre", "rcr-code rcr-code-sm", esc(curl("POST", "/entries/search", r.request))));
+ body.appendChild(cd);
+ d.appendChild(body);
+ rows.appendChild(d);
+ });
+ notes.innerHTML = "";
+ notes.appendChild(callout(thresholdNotes(ev, t)));
+ }
+ thr.addEventListener("input", function () { st.threshold = round2(+thr.value); refresh(); });
+ reset.addEventListener("click", function () { st.threshold = THRESHOLD; thr.value = String(THRESHOLD); refresh(); thr.focus(); });
+ refresh();
+ syncTabs();
+ }
+
+ /* ---- tab 3: attributes ---- */
+
+ function lane(scoped, phase) {
+ var run = runMenu(st.menu, scoped), items = run.items, last = items[items.length - 1];
+ /* While the latest event is in flight, the cache shows what it held before it. */
+ var before = phase === 1 ? runMenu(st.menu - 1, scoped) : run;
+ var entries = before.entries.filter(function (e) { return !(last && e === last.stored && phase != null && phase < 4); });
+ var card = el("div", "rcr-card rlc-lane" + (scoped ? " is-scoped" : ""));
+ var wrong = phase != null && last && (last.verdict === "other" || last.verdict === "stale") && phase < 2 ? run.wrong - 1 : run.wrong;
+ card.appendChild(el("div", "rlc-lanehead", 'restaurant attribute, " +
+ "so the app can store each answer with the restaurant it's about, and search only that restaurant's answers. " +
+ "Step through the same events with and without it."));
+ p.appendChild(grid(lane(false, phase), lane(true, phase), "rlc-lanes"));
+ p.appendChild(el("p", "rcr-note", "The FAQ entries from step 1 are still in the cache. They have no restaurant attribute, and they're " +
+ "not similar enough to match, so the lanes leave them out."));
+ p.appendChild(callout(menuNotes(st.menu, phase)));
+ var next = MENU[st.menu];
+ p.appendChild(controlsRow(!st.busy && next ? { label: next.action, fn: step } : null));
+ syncTabs();
+ }
+ function step() {
+ if (st.busy || st.menu >= MENU.length) return;
+ var m = MENU[st.menu++];
+ var steps = [[0, function () { renderAttributes({ phase: 1 }); }], [700, function () { renderAttributes({ phase: 2 }); }]];
+ if (m.kind === "ask") {
+ steps.push([1100, function () { renderAttributes({ phase: 3 }); }]);
+ steps.push([600, function () { renderAttributes({ phase: 4 }); }]);
+ }
+ play(steps);
+ }
+
+ renderQuestions();
+ }
+
+ function boot() { Array.prototype.forEach.call(document.querySelectorAll(".rlc[data-rlc]"), init); }
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
+
+ /* Exposed for tests. */
+ window.LangCacheDemo = { THRESHOLD: THRESHOLD, QUESTIONS: QUESTIONS, TESTS: TESTS, MENU: MENU, questionSim: questionSim,
+ runQuestions: runQuestions, metrics: metrics, evalTests: evalTests, runMenu: runMenu, questionNotes: questionNotes,
+ thresholdNotes: thresholdNotes, menuNotes: menuNotes, curl: curl, id32: id32 };
+})();