161 lines
5.7 KiB
TypeScript
161 lines
5.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import { MindDB } from '../../src/mind/db.js';
|
||
import { FrameStore } from '../../src/mind/frames.js';
|
||
import { SessionStore } from '../../src/mind/sessions.js';
|
||
import { rawTurnHeader } from '../../src/harvest/raw-turns.js';
|
||
import { fetchRawDetailLane, rawTurnBody } from '../../src/mind/raw-detail-lane.js';
|
||
import type { Reranker } from '../../src/mind/inprocess-reranker.js';
|
||
|
||
/**
|
||
* W4.6 — RAWDETAIL recall lane: pool (window/FTS) → CE top-K → ±1 dialogue
|
||
* neighbors → chronological order, excluding already-rendered frames.
|
||
*/
|
||
|
||
/** Deterministic fake CE: score = number of marker words present in the doc. */
|
||
function markerReranker(markers: string[]): Reranker {
|
||
return {
|
||
scoreBatch: async (_q: string, docs: string[]) =>
|
||
docs.map(d => markers.reduce((s, m) => s + (d.includes(m) ? 1 : 0), 0)),
|
||
} as Reranker;
|
||
}
|
||
|
||
describe('W4.6 — fetchRawDetailLane', () => {
|
||
let db: MindDB;
|
||
let frames: FrameStore;
|
||
let gopId: string;
|
||
|
||
beforeEach(() => {
|
||
db = new MindDB(':memory:');
|
||
frames = new FrameStore(db);
|
||
gopId = new SessionStore(db).create().gop_id;
|
||
});
|
||
|
||
afterEach(() => db.close());
|
||
|
||
/** Seed one conversation of consecutive turns, one day apart. */
|
||
function seedConv(conv: string, texts: string[], baseDay = 10): number[] {
|
||
const ids: number[] = [];
|
||
texts.forEach((text, i) => {
|
||
const day = String(baseDay + i).padStart(2, '0');
|
||
const f = frames.createIFrame(
|
||
gopId,
|
||
`${rawTurnHeader(conv, i, i % 2 === 0 ? 'user' : 'assistant')}\n${text}`,
|
||
'normal',
|
||
'import',
|
||
`2026-05-${day}T12:00:00Z`,
|
||
);
|
||
ids.push(f.id);
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
it('returns CE-top turns expanded with ±1 dialogue neighbors, chronological', async () => {
|
||
seedConv('conv-a', [
|
||
'we talked about logistics planning',
|
||
'I saw a painting of a sunset with a pink sky', // CE hit (turn 1)
|
||
'it was at the Mauritshuis museum',
|
||
'unrelated chatter about lunch options',
|
||
]);
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'what painting did they discuss',
|
||
markerReranker(['painting']),
|
||
{ k: 1 },
|
||
);
|
||
// turn 1 + neighbors 0 and 2, in dialogue order
|
||
expect(hits.map(h => h.turn)).toEqual([0, 1, 2]);
|
||
expect(hits[1].speaker).toBe('assistant');
|
||
expect(rawTurnBody(hits[1].content)).toContain('pink sky');
|
||
});
|
||
|
||
it('respects excludeIds (already-rendered frames never double-render)', async () => {
|
||
const ids = seedConv('conv-b', [
|
||
'first turn about painting brushes',
|
||
'second turn about painting canvases',
|
||
'third turn about painting frames',
|
||
]);
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'painting supplies',
|
||
markerReranker(['painting']),
|
||
{ k: 1, excludeIds: new Set([ids[0]]) },
|
||
);
|
||
expect(hits.map(h => h.id)).not.toContain(ids[0]);
|
||
expect(hits.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('date window restricts the pool to in-window turns', async () => {
|
||
seedConv('conv-c', [
|
||
'painting discussion in early may', // 2026-05-10
|
||
'painting discussion mid may', // 2026-05-11
|
||
'painting discussion late may', // 2026-05-12
|
||
]);
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'painting discussion',
|
||
markerReranker(['painting']),
|
||
{ k: 3, window: { since: '2026-05-11', until: '2026-05-11' } },
|
||
);
|
||
// pool = only the mid-may turn; neighbor expansion may pull ±1 — but the
|
||
// CE top itself must be the in-window turn
|
||
expect(hits.some(h => h.content.includes('mid may'))).toBe(true);
|
||
});
|
||
|
||
it('empty window falls back to FTS so the lane never loses recall', async () => {
|
||
seedConv('conv-d', ['the gallery showed a watercolor landscape']);
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'watercolor landscape gallery',
|
||
markerReranker(['watercolor']),
|
||
{ window: { since: '2020-01-01', until: '2020-01-31' } },
|
||
);
|
||
expect(hits.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('returns [] when no raw turns exist', async () => {
|
||
frames.createIFrame(gopId, 'plain frame about painting', 'normal', 'system');
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'painting',
|
||
markerReranker(['painting']),
|
||
);
|
||
expect(hits).toEqual([]);
|
||
});
|
||
|
||
it('soft-fails to [] when the reranker throws', async () => {
|
||
seedConv('conv-e', ['painting one', 'painting two']);
|
||
const broken = { scoreBatch: async () => { throw new Error('model load failed'); } } as unknown as Reranker;
|
||
const hits = await fetchRawDetailLane(db.getDatabase(), 'painting', broken);
|
||
expect(hits).toEqual([]);
|
||
});
|
||
|
||
it('neighbor expansion never crosses conversations', async () => {
|
||
seedConv('conv-f', ['solo painting turn in conv f']);
|
||
seedConv('conv-g', ['unrelated turn in conv g'], 20);
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'painting',
|
||
markerReranker(['painting']),
|
||
{ k: 1 },
|
||
);
|
||
expect(hits).toHaveLength(1);
|
||
expect(hits[0].conv).toBe('conv-f');
|
||
});
|
||
|
||
it('pools Cyrillic raw turns via FTS (S1 Unicode sanitizer)', async () => {
|
||
seedConv('conv-cy', [
|
||
'разговор о логистици и плановима',
|
||
'видели смо слику заласка сунца у Београду',
|
||
'посета музеју је била сјајна',
|
||
]);
|
||
const hits = await fetchRawDetailLane(
|
||
db.getDatabase(),
|
||
'слику Београду',
|
||
markerReranker(['слику']),
|
||
{ k: 1 },
|
||
);
|
||
expect(hits.length).toBeGreaterThan(0);
|
||
expect(hits.some(h => rawTurnBody(h.content).includes('слику'))).toBe(true);
|
||
});
|
||
});
|