mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(search): implement documented relative since/until durations (#3442)
The SearchOpts contract and query --help have documented relative
durations ('7d', '2w', '1y') since v0.29.1, but the raw string flowed
straight into the engines' ::timestamptz casts. Every search arm failed
fail-open and the date filter was SILENTLY ignored — results still came
back, so the user could not tell the filter never applied.
resolveDateBoundary at the single hybridSearch seam now resolves
relative durations to concrete timestamps, lands a plain YYYY-MM-DD
'until' at end-of-day (also documented, also never implemented), and
throws loudly on unparseable input instead of degrading.
Date-filtered requests also skip the semantic query cache: since/until
are not part of knobsHash, so a filtered result set could be served to
an unfiltered lookup (and relative forms resolve to now-relative
timestamps a persisted row can't express).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6920744dd8
commit
921b54fc44
@@ -860,6 +860,41 @@ export async function embedQueryBounded(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #3442 — resolve the public `since`/`until` contract (SearchOpts v0.29.1):
|
||||
* ISO-8601 passes through, relative durations ('7d', '2w', '1y') resolve to a
|
||||
* concrete timestamp, and a plain YYYY-MM-DD `until` lands at end-of-day.
|
||||
* The relative form was documented since v0.29.1 but never implemented — the
|
||||
* raw string ('60d') flowed into the engines' `::timestamptz` casts, every
|
||||
* arm failed fail-open, and the date filter was SILENTLY ignored.
|
||||
* Unparseable input now throws loudly instead of degrading.
|
||||
*/
|
||||
export function resolveDateBoundary(
|
||||
raw: string | undefined,
|
||||
boundary: 'since' | 'until',
|
||||
): string | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
const s = String(raw).trim();
|
||||
if (!s) return undefined;
|
||||
const rel = /^(\d+)\s*([dwmy])$/i.exec(s);
|
||||
if (rel) {
|
||||
const n = parseInt(rel[1], 10);
|
||||
const unit = rel[2].toLowerCase();
|
||||
// m = months (30d). Minutes make no sense for an effective_date filter.
|
||||
const days = unit === 'd' ? n : unit === 'w' ? n * 7 : unit === 'm' ? n * 30 : n * 365;
|
||||
return new Date(Date.now() - days * 86400000).toISOString();
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
// Plain date: `until` lands at end-of-day (documented SearchOpts
|
||||
// semantics); `since` keeps UTC start-of-day.
|
||||
return boundary === 'until' ? `${s}T23:59:59.999Z` : s;
|
||||
}
|
||||
if (Number.isFinite(Date.parse(s))) return s;
|
||||
throw new Error(
|
||||
`Invalid ${boundary} value "${s}" — expected ISO-8601 (YYYY-MM-DD or timestamp) or a relative duration like '7d', '2w', '1y'.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function hybridSearch(
|
||||
engine: BrainEngine,
|
||||
query: string,
|
||||
@@ -954,8 +989,10 @@ export async function hybridSearch(
|
||||
// v0.29.1: since/until take precedence over deprecated afterDate/beforeDate.
|
||||
// The engine still consumes the legacy field names; this aliasing keeps
|
||||
// PR #618 callers compiling while the new names are the public surface.
|
||||
afterDate: opts?.since ?? opts?.afterDate,
|
||||
beforeDate: opts?.until ?? opts?.beforeDate,
|
||||
// #3442: resolveDateBoundary implements the documented contract (relative
|
||||
// durations + end-of-day for plain-date `until`) at this single seam.
|
||||
afterDate: resolveDateBoundary(opts?.since ?? opts?.afterDate, 'since'),
|
||||
beforeDate: resolveDateBoundary(opts?.until ?? opts?.beforeDate, 'until'),
|
||||
// v0.34.1 (#861, D9 — P0 leak seal): thread source-scoping through so the
|
||||
// inner engine.searchKeyword / engine.searchVector calls apply the
|
||||
// WHERE source_id filter at SQL level. Pre-fix, this explicit pick
|
||||
@@ -1803,12 +1840,19 @@ export async function hybridSearchCached(
|
||||
opts?.adaptiveReturn,
|
||||
cfgCached as unknown as Record<string, unknown> | null,
|
||||
);
|
||||
// #3442: date-filtered requests skip the cache — since/until are not part
|
||||
// of knobsHash, so a filtered result set could be served to an unfiltered
|
||||
// lookup (and vice versa). Relative forms ('60d') also resolve to a
|
||||
// now-relative timestamp, which a persisted cache row can't express.
|
||||
const dateFiltered =
|
||||
Boolean(opts?.since ?? opts?.afterDate) || Boolean(opts?.until ?? opts?.beforeDate);
|
||||
const skipCache =
|
||||
!cache.isEnabled() ||
|
||||
(opts?.walkDepth ?? 0) > 0 ||
|
||||
Boolean(opts?.nearSymbol) ||
|
||||
isNonDefaultColumn ||
|
||||
adaptiveReturnOn;
|
||||
adaptiveReturnOn ||
|
||||
dateFiltered;
|
||||
|
||||
let cacheStatus: 'hit' | 'miss' | 'disabled' = skipCache ? 'disabled' : 'miss';
|
||||
let cacheSimilarity: number | undefined;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* #3442 — `query --since` relative durations ('7d', '2w', '1y').
|
||||
*
|
||||
* The SearchOpts contract (types.ts, since v0.29.1) and the query op's
|
||||
* --help both document relative durations, but the raw string ('60d')
|
||||
* flowed straight into the engines' `::timestamptz` casts. Every search
|
||||
* arm failed fail-open and the date filter was SILENTLY ignored — the
|
||||
* command still returned results, so the user could not tell the filter
|
||||
* never applied.
|
||||
*
|
||||
* Fix: resolveDateBoundary at the single hybridSearch seam resolves
|
||||
* relative durations to concrete timestamps, lands a plain YYYY-MM-DD
|
||||
* `until` at end-of-day (also documented, also never implemented), and
|
||||
* throws loudly on unparseable input instead of degrading.
|
||||
*
|
||||
* Serial: mutates OPENAI_API_KEY to force the keyword-only path.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { hybridSearch, resolveDateBoundary } from '../src/core/search/hybrid.ts';
|
||||
import type { PageInput } from '../src/core/types.ts';
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
describe('resolveDateBoundary (#3442)', () => {
|
||||
test('relative durations resolve to a concrete past timestamp', () => {
|
||||
const cases: Array<[string, number]> = [
|
||||
['7d', 7],
|
||||
['60d', 60],
|
||||
['2w', 14],
|
||||
['3m', 90],
|
||||
['1y', 365],
|
||||
];
|
||||
for (const [raw, days] of cases) {
|
||||
const out = resolveDateBoundary(raw, 'since');
|
||||
expect(out).toBeDefined();
|
||||
const delta = Date.now() - Date.parse(out!);
|
||||
// Within a minute of the expected offset.
|
||||
expect(Math.abs(delta - days * DAY_MS)).toBeLessThan(60_000);
|
||||
}
|
||||
});
|
||||
|
||||
test('ISO dates and timestamps pass through', () => {
|
||||
expect(resolveDateBoundary('2026-06-01', 'since')).toBe('2026-06-01');
|
||||
expect(resolveDateBoundary('2026-06-01T10:00:00Z', 'since')).toBe('2026-06-01T10:00:00Z');
|
||||
});
|
||||
|
||||
test('plain-date `until` lands at end-of-day (documented SearchOpts semantics)', () => {
|
||||
expect(resolveDateBoundary('2026-06-01', 'until')).toBe('2026-06-01T23:59:59.999Z');
|
||||
});
|
||||
|
||||
test('empty/undefined stay undefined', () => {
|
||||
expect(resolveDateBoundary(undefined, 'since')).toBeUndefined();
|
||||
expect(resolveDateBoundary(' ', 'since')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unparseable input throws loudly instead of silently degrading', () => {
|
||||
expect(() => resolveDateBoundary('sixty days', 'since')).toThrow(/Invalid since value/);
|
||||
expect(() => resolveDateBoundary('60x', 'until')).toThrow(/Invalid until value/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybridSearch since/until end-to-end (#3442)', () => {
|
||||
let engine: PGLiteEngine;
|
||||
const savedKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
beforeAll(async () => {
|
||||
delete process.env.OPENAI_API_KEY; // keyword-only path, no embedding calls
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
await engine.putPage('notes/widget-old', {
|
||||
type: 'note',
|
||||
title: 'Widget Old',
|
||||
compiled_truth:
|
||||
'Widget foundation repair notes from the original crawlspace assessment two years back.',
|
||||
});
|
||||
await engine.putPage('notes/widget-new', {
|
||||
type: 'note',
|
||||
title: 'Widget New',
|
||||
compiled_truth:
|
||||
'Widget foundation repair follow-up: contractor quote for the pier replacement arrived yesterday.',
|
||||
});
|
||||
// putPage does not chunk; give the keyword arm content to match.
|
||||
await engine.upsertChunks('notes/widget-old', [{
|
||||
chunk_index: 0,
|
||||
chunk_text: 'Widget foundation repair notes from the original crawlspace assessment two years back.',
|
||||
chunk_source: 'compiled_truth',
|
||||
}]);
|
||||
await engine.upsertChunks('notes/widget-new', [{
|
||||
chunk_index: 0,
|
||||
chunk_text: 'Widget foundation repair follow-up: contractor quote for the pier replacement arrived yesterday.',
|
||||
chunk_source: 'compiled_truth',
|
||||
}]);
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET effective_date = $1 WHERE slug = 'notes/widget-old'`,
|
||||
[new Date(Date.now() - 400 * DAY_MS).toISOString()],
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`UPDATE pages SET effective_date = $1 WHERE slug = 'notes/widget-new'`,
|
||||
[new Date(Date.now() - 1 * DAY_MS).toISOString()],
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (savedKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = savedKey;
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
test('since "60d" filters out old pages (was silently ignored)', async () => {
|
||||
const out = await hybridSearch(engine, 'widget repair', { since: '60d' });
|
||||
const slugs = out.map((r) => r.slug);
|
||||
expect(slugs).toContain('notes/widget-new');
|
||||
expect(slugs).not.toContain('notes/widget-old');
|
||||
});
|
||||
|
||||
test('until "60d" filters out recent pages', async () => {
|
||||
const out = await hybridSearch(engine, 'widget repair', { until: '60d' });
|
||||
const slugs = out.map((r) => r.slug);
|
||||
expect(slugs).toContain('notes/widget-old');
|
||||
expect(slugs).not.toContain('notes/widget-new');
|
||||
});
|
||||
|
||||
test('control: no filter returns both', async () => {
|
||||
const out = await hybridSearch(engine, 'widget repair', {});
|
||||
const slugs = out.map((r) => r.slug);
|
||||
expect(slugs).toContain('notes/widget-new');
|
||||
expect(slugs).toContain('notes/widget-old');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user