Skip to content

Commit a8b725d

Browse files
nizzlenitzclaude
andcommitted
feat(chat): cache eviction on thumbs-down / faithfulness flag + hit-rate card
A cached answer otherwise lives for its full 7-day TTL even if it's wrong — a thumbs-down or a hallucination flag couldn't clear it. Now each answer's ChatLog row records the ChatCache id it's stored under (computeCacheId), so: - recordFeedback: a thumbs-down (-1) evicts that entry. - scoreFaithfulness: a flagged (likely-hallucinated) answer evicts it. The generate path now awaits storeCache BEFORE firing the async monitor so the entry exists when the judge (a multi-second call) decides to evict it. - CacheHit carries its row id, so a thumbs-down on a *cached* answer evicts too. Also surfaces a "Cache hit rate" card (cacheHits / chats over the window) on the admin Chat dashboard. Verified live: fresh answer cached → thumbs-down → ChatCache row deleted (1 → 0), and the next identical ask regenerates (cached flag absent); cacheId recorded on both generated and cache-hit ChatLog rows; admin card renders 6.6%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Eijma5uKcPZi9tqV2aJBeh
1 parent 7d9942e commit a8b725d

6 files changed

Lines changed: 57 additions & 7 deletions

File tree

app/lib/admin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export interface ChatAnalytics {
8787
chats: number;
8888
grounded: number;
8989
groundedRate: number;
90+
cacheHits: number; // answers served from the ChatCache (no generation)
91+
cacheHitRate: number; // cacheHits / chats
9092
avgLatencyMs: number;
9193
windowDays: number;
9294
avgFaithfulness: number; // over scored answers
@@ -401,6 +403,7 @@ export function renderChatDashboard(a: ChatAnalytics): string {
401403
value: num(t.flaggedCount),
402404
color: t.flaggedCount > 0 ? STATUS.rejected.color : undefined,
403405
},
406+
{ label: 'Cache hit rate', value: `${pct(t.cacheHitRate)}%`, color: STATUS.activated.color },
404407
{ label: 'Avg latency', value: fmtDuration(t.avgLatencyMs) },
405408
{ label: 'Window', value: `${t.windowDays} days` },
406409
];

app/lib/chat.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,23 @@ function cacheKey(release: string, version: string, normQ: string): string {
203203
return `${release}:${version}:${createHash('sha256').update(normQ).digest('hex').slice(0, 20)}`;
204204
}
205205

206+
// The cache id a generated answer will be stored under — recorded on its ChatLog
207+
// row so a later thumbs-down or faithfulness flag can evict exactly that entry.
208+
export function computeCacheId(release: string, version: string, question: string): string {
209+
return cacheKey(release, version, normalizeQuestion(question));
210+
}
211+
212+
// Drop a cached answer (thumbs-down / faithfulness flag) so a bad answer is not
213+
// re-served for the rest of its TTL. Best-effort; a missing id is a no-op.
214+
export async function evictCache(cacheId: unknown): Promise<void> {
215+
if (!CACHE_ENABLED || typeof cacheId !== 'string' || !cacheId) return;
216+
try {
217+
await ChatCache.delete(cacheId);
218+
} catch {
219+
/* best-effort: a missing/already-evicted row is fine */
220+
}
221+
}
222+
206223
function cosine(a: number[], b: number[]): number {
207224
let dot = 0;
208225
let na = 0;
@@ -217,6 +234,7 @@ function cosine(a: number[], b: number[]): number {
217234
}
218235

219236
export interface CacheHit {
237+
id: string; // the ChatCache row id — recorded on the hit's ChatLog row for eviction
220238
answer: string;
221239
sources: Source[];
222240
model: string;
@@ -234,7 +252,7 @@ export async function lookupCache(version: string, release: string, question: st
234252
const exact = await ChatCache.get(cacheKey(release, version, normQ));
235253
if (exact && exact.release === release && exact.answer) {
236254
bumpCacheHit(exact.id, exact.hitCount ?? 0);
237-
return { answer: exact.answer, sources: safeSources(exact.sources), model: exact.model ?? 'cache', via: 'exact' };
255+
return { id: exact.id, answer: exact.answer, sources: safeSources(exact.sources), model: exact.model ?? 'cache', via: 'exact' };
238256
}
239257
} catch {
240258
/* fall through to semantic */
@@ -253,7 +271,7 @@ export async function lookupCache(version: string, release: string, question: st
253271
})) {
254272
if (row?.answer && Array.isArray(row.embedding) && cosine(qv, row.embedding) >= CACHE_SIM_THRESHOLD) {
255273
bumpCacheHit(row.id, row.hitCount ?? 0);
256-
return { answer: row.answer, sources: safeSources(row.sources), model: row.model ?? 'cache', via: 'semantic' };
274+
return { id: row.id, answer: row.answer, sources: safeSources(row.sources), model: row.model ?? 'cache', via: 'semantic' };
257275
}
258276
break; // only the single nearest neighbor
259277
}
@@ -506,6 +524,7 @@ export interface ChatLogInput {
506524
sessionId: string;
507525
ipHash: string;
508526
cached?: boolean;
527+
cacheId?: string; // the ChatCache entry this answer is stored under (for eviction)
509528
}
510529

511530
export async function logChat(input: ChatLogInput): Promise<void> {
@@ -524,6 +543,7 @@ export async function logChat(input: ChatLogInput): Promise<void> {
524543
ipHash: input.ipHash,
525544
feedback: 0,
526545
cached: Boolean(input.cached),
546+
cacheId: input.cacheId ?? '',
527547
});
528548
} catch {
529549
// observability is best-effort; never fail a chat on it
@@ -535,7 +555,7 @@ export async function logChat(input: ChatLogInput): Promise<void> {
535555
// the score + any unsupported claim on the ChatLog row. This catches hallucinations
536556
// (e.g. an invented Docker image) for review in the admin tab — WITHOUT adding
537557
// latency or cost to the live chat. Best-effort: never throws.
538-
export async function scoreFaithfulness(id: string, context: string, answer: string): Promise<void> {
558+
export async function scoreFaithfulness(id: string, context: string, answer: string, cacheId?: string): Promise<void> {
539559
if (!hasLiveModel() || !context || !answer.trim()) return;
540560
// Both context (doc content) and answer are treated as data to check.
541561
const safe = (s: string, n: number) => s.replace(/<\/?(context|answer)>/gi, '').slice(0, n);
@@ -578,6 +598,9 @@ export async function scoreFaithfulness(id: string, context: string, answer: str
578598
flagged,
579599
flaggedNote: flagged ? note.slice(0, 300) : '',
580600
});
601+
// A flagged (likely-hallucinated) answer is evicted so it isn't re-served
602+
// from cache while it sits in the review queue.
603+
if (flagged && cacheId) void evictCache(cacheId);
581604
} catch (err: any) {
582605
console.error('[chat] faithfulness error', err?.message ?? err);
583606
}
@@ -592,6 +615,9 @@ export async function recordFeedback(id: unknown, value: unknown): Promise<boole
592615
const existing = await ChatLog.get(id);
593616
if (!existing) return false;
594617
await ChatLog.patch({ id, feedback: v });
618+
// A thumbs-down evicts the cached answer so the next asker doesn't get the
619+
// same disliked response for the rest of its TTL.
620+
if (v < 0 && existing.cacheId) void evictCache(existing.cacheId);
595621
return true;
596622
} catch {
597623
return false;

app/lib/metrics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ export async function chatAnalytics(): Promise<ChatAnalytics> {
178178
const feedback = { up: 0, down: 0, none: 0 };
179179
let chats = 0;
180180
let grounded = 0;
181+
let cacheHits = 0;
181182
let latencySum = 0;
182183
let latencyN = 0;
183184
let faithSum = 0;
@@ -189,6 +190,7 @@ export async function chatAnalytics(): Promise<ChatAnalytics> {
189190
if (now - created > windowMs) continue;
190191
chats++;
191192
if (row.grounded) grounded++;
193+
if (row.cached) cacheHits++;
192194
if (typeof row.faithfulness === 'number') {
193195
faithSum += row.faithfulness;
194196
faithN++;
@@ -247,6 +249,8 @@ export async function chatAnalytics(): Promise<ChatAnalytics> {
247249
chats,
248250
grounded,
249251
groundedRate: chats ? grounded / chats : 0,
252+
cacheHits,
253+
cacheHitRate: chats ? cacheHits / chats : 0,
250254
avgLatencyMs: latencyN ? latencySum / latencyN : 0,
251255
windowDays: WINDOW_DAYS,
252256
avgFaithfulness: faithN ? faithSum / faithN : 0,

app/resources/site.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
scoreFaithfulness,
2424
lookupCache,
2525
storeCache,
26+
computeCacheId,
2627
currentRelease,
2728
condenseQuestion,
2829
type CacheHit,
@@ -431,6 +432,12 @@ async function handleChat(request: HarperRequest): Promise<Response> {
431432
const quota = await checkAndBumpQuota(ipHash);
432433
if (!quota.ok) return jsonResponse({ error: 'daily quota exceeded', cap: quota.cap }, 429);
433434

435+
// The id this answer will be cached under (empty when uncacheable — dev stub or
436+
// no release). Recorded on the ChatLog row so a thumbs-down or a faithfulness
437+
// flag can evict exactly this entry.
438+
const cacheable = modelId() !== 'stub' && Boolean(release);
439+
const cacheId = cacheable ? computeCacheId(release, version, question) : '';
440+
434441
const started = Date.now();
435442
const grounding = await retrieve(question, body.section ?? null, body.version ?? null);
436443
const { system } = buildMessages(question, grounding.context);
@@ -462,6 +469,7 @@ async function handleChat(request: HarperRequest): Promise<Response> {
462469
latencyMs: Date.now() - started,
463470
sessionId,
464471
ipHash,
472+
cacheId: cacheable ? cacheId : undefined,
465473
});
466474
try {
467475
send('sources', grounding.sources);
@@ -473,11 +481,13 @@ async function handleChat(request: HarperRequest): Promise<Response> {
473481
// /api/chat-feedback can't arrive before the ChatLog row exists.
474482
await writeLog();
475483
send('done', { id: chatId, model: modelId(), latencyMs: Date.now() - started });
484+
// Cache the answer for future repeats (skip the dev stub). Store BEFORE
485+
// scoring so the faithfulness monitor can evict this exact entry if it
486+
// flags the answer (the store is a fast put; the judge is a slow call).
487+
if (cacheable) await storeCache(version, release, question, answer, grounding.sources, modelId());
476488
// Fire-and-forget: score faithfulness AFTER responding, off the user
477-
// path — patches the ChatLog row when it completes. Never awaited.
478-
void scoreFaithfulness(chatId, grounding.context, answer);
479-
// Cache the answer for future repeats (skip the dev stub). Off-path.
480-
if (modelId() !== 'stub') void storeCache(version, release, question, answer, grounding.sources, modelId());
489+
// path — patches the ChatLog row and evicts the cache entry if flagged.
490+
void scoreFaithfulness(chatId, grounding.context, answer, cacheable ? cacheId : undefined);
481491
} catch (err: any) {
482492
// Log detail server-side; the client only gets a generic message.
483493
console.error('[chat] generation error', err?.message ?? err);
@@ -543,6 +553,7 @@ function streamCachedAnswer(cached: CacheHit, question: string, sessionId: strin
543553
sessionId,
544554
ipHash,
545555
cached: true,
556+
cacheId: cached.id, // so a thumbs-down on a cached answer evicts it too
546557
});
547558
}
548559
},

app/schemas/docs.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ type ChatLog @table(expiration: 7776000) {
194194
ipHash: String # hashed client IP (never store raw IPs)
195195
feedback: Int @indexed # -1 | 0 | +1 (thumbs), 0 = none
196196
cached: Boolean @indexed # answer was served from the ChatCache (no generation)
197+
cacheId: String # the ChatCache entry this answer is stored under (thumbs-down / flag evicts it)
197198
# Async faithfulness monitor (scored after the answer streams — off the user path):
198199
faithfulness: Float # 0..1, how well the answer is supported by the retrieved context
199200
flagged: Boolean @indexed # faithfulness below the flag threshold (needs review)

app/test/unit/admin.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ const SAMPLE_CHAT: ChatAnalytics = {
134134
chats: 19,
135135
grounded: 19,
136136
groundedRate: 1,
137+
cacheHits: 5,
138+
cacheHitRate: 5 / 19,
137139
avgLatencyMs: 390,
138140
windowDays: 14,
139141
avgFaithfulness: 0.94,
@@ -171,6 +173,9 @@ test('renderChatDashboard: active Chat tab, grounded %, recent rows', () => {
171173
// Faithfulness monitor: card + flagged-answers panel.
172174
assert.match(html, /Avg faithfulness/);
173175
assert.match(html, /94\.0%/); // avgFaithfulness 0.94
176+
// Cache hit-rate card: 5/19 → 26.3%.
177+
assert.match(html, /Cache hit rate/);
178+
assert.match(html, /26\.3%/);
174179
assert.match(html, /Flagged answers/);
175180
assert.match(html, /invented docker image/);
176181
});

0 commit comments

Comments
 (0)