fix(FEEDBACK-0001): complete iteration 0 - harden context search
This commit is contained in:
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { RepositoryService } from '$lib/server/services/repository.service';
|
||||
import { VersionService } from '$lib/server/services/version.service';
|
||||
|
||||
@@ -24,21 +25,34 @@ vi.mock('$lib/server/pipeline/startup.js', () => ({
|
||||
getQueue: () => queue
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/embeddings/registry', () => ({
|
||||
createProviderFromProfile: () => null
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/embeddings/registry.js', () => ({
|
||||
createProviderFromProfile: () => null
|
||||
}));
|
||||
|
||||
import { POST as postLibraries } from './libs/+server.js';
|
||||
import { GET as getLibrary } from './libs/[id]/+server.js';
|
||||
import { GET as getJobs } from './jobs/+server.js';
|
||||
import { GET as getJob } from './jobs/[id]/+server.js';
|
||||
import { GET as getVersions, POST as postVersions } from './libs/[id]/versions/+server.js';
|
||||
import { GET as getContext } from './context/+server.js';
|
||||
|
||||
const NOW_S = Math.floor(Date.now() / 1000);
|
||||
|
||||
function createTestDb(): Database.Database {
|
||||
const client = new Database(':memory:');
|
||||
client.pragma('foreign_keys = ON');
|
||||
|
||||
const migrationsFolder = join(import.meta.dirname, '../../../lib/server/db/migrations');
|
||||
const ftsFile = join(import.meta.dirname, '../../../lib/server/db/fts.sql');
|
||||
|
||||
// Apply all migration files in order
|
||||
const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
|
||||
const migration1 = readFileSync(join(migrationsFolder, '0001_quick_nighthawk.sql'), 'utf-8');
|
||||
const migration2 = readFileSync(join(migrationsFolder, '0002_silky_stellaris.sql'), 'utf-8');
|
||||
|
||||
// Apply first migration
|
||||
const statements0 = migration0
|
||||
@@ -60,9 +74,126 @@ function createTestDb(): Database.Database {
|
||||
client.exec(statement);
|
||||
}
|
||||
|
||||
const statements2 = migration2
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const statement of statements2) {
|
||||
client.exec(statement);
|
||||
}
|
||||
|
||||
client.exec(readFileSync(ftsFile, 'utf-8'));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function seedRepo(
|
||||
client: Database.Database,
|
||||
overrides: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
source?: 'github' | 'local';
|
||||
sourceUrl?: string;
|
||||
state?: 'pending' | 'indexing' | 'indexed' | 'error';
|
||||
} = {}
|
||||
): string {
|
||||
const id = overrides.id ?? '/facebook/react';
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO repositories
|
||||
(id, title, source, source_url, state, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
overrides.title ?? 'React',
|
||||
overrides.source ?? 'github',
|
||||
overrides.sourceUrl ?? 'https://github.com/facebook/react',
|
||||
overrides.state ?? 'indexed',
|
||||
NOW_S,
|
||||
NOW_S
|
||||
);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function seedVersion(client: Database.Database, repositoryId: string, tag: string): string {
|
||||
const versionId = `${repositoryId}/${tag}`;
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO repository_versions
|
||||
(id, repository_id, tag, state, total_snippets, indexed_at, created_at)
|
||||
VALUES (?, ?, ?, 'indexed', 0, ?, ?)`
|
||||
)
|
||||
.run(versionId, repositoryId, tag, NOW_S, NOW_S);
|
||||
|
||||
return versionId;
|
||||
}
|
||||
|
||||
function seedDocument(
|
||||
client: Database.Database,
|
||||
repositoryId: string,
|
||||
versionId: string | null = null
|
||||
): string {
|
||||
const documentId = crypto.randomUUID();
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO documents (id, repository_id, version_id, file_path, checksum, indexed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(documentId, repositoryId, versionId, 'README.md', 'checksum', NOW_S);
|
||||
|
||||
return documentId;
|
||||
}
|
||||
|
||||
function seedSnippet(
|
||||
client: Database.Database,
|
||||
options: {
|
||||
documentId: string;
|
||||
repositoryId: string;
|
||||
versionId?: string | null;
|
||||
type?: 'code' | 'info';
|
||||
title?: string | null;
|
||||
content: string;
|
||||
language?: string | null;
|
||||
breadcrumb?: string | null;
|
||||
tokenCount?: number;
|
||||
}
|
||||
): string {
|
||||
const snippetId = crypto.randomUUID();
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO snippets
|
||||
(id, document_id, repository_id, version_id, type, title, content, language, breadcrumb, token_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
snippetId,
|
||||
options.documentId,
|
||||
options.repositoryId,
|
||||
options.versionId ?? null,
|
||||
options.type ?? 'info',
|
||||
options.title ?? null,
|
||||
options.content,
|
||||
options.language ?? null,
|
||||
options.breadcrumb ?? null,
|
||||
options.tokenCount ?? 0,
|
||||
NOW_S
|
||||
);
|
||||
|
||||
return snippetId;
|
||||
}
|
||||
|
||||
function seedRules(client: Database.Database, repositoryId: string, rules: string[]) {
|
||||
client
|
||||
.prepare(
|
||||
`INSERT INTO repository_configs (repository_id, rules, updated_at)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(repositoryId, JSON.stringify(rules), NOW_S);
|
||||
}
|
||||
|
||||
describe('API contract integration', () => {
|
||||
beforeEach(() => {
|
||||
db = createTestDb();
|
||||
@@ -174,4 +305,78 @@ describe('API contract integration', () => {
|
||||
expect(getBody.versions[0]).not.toHaveProperty('repository_id');
|
||||
expect(getBody.versions[0]).not.toHaveProperty('total_snippets');
|
||||
});
|
||||
|
||||
it('GET /api/v1/context returns informative txt output for empty results', async () => {
|
||||
const repositoryId = seedRepo(db);
|
||||
|
||||
const response = await getContext({
|
||||
url: new URL(
|
||||
`http://test/api/v1/context?libraryId=${encodeURIComponent(repositoryId)}&query=${encodeURIComponent('no matches here')}&type=txt`
|
||||
)
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/plain');
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toContain('## Context Results');
|
||||
expect(body).toContain('No matching snippets found');
|
||||
expect(body).toContain('Repository: React (/facebook/react)');
|
||||
expect(body).toContain('Result count: 0');
|
||||
});
|
||||
|
||||
it('GET /api/v1/context returns additive repository and version metadata for versioned results', async () => {
|
||||
const repositoryId = seedRepo(db);
|
||||
const versionId = seedVersion(db, repositoryId, 'v18.3.0');
|
||||
const documentId = seedDocument(db, repositoryId, versionId);
|
||||
seedRules(db, repositoryId, ['Prefer hooks over classes']);
|
||||
seedSnippet(db, {
|
||||
documentId,
|
||||
repositoryId,
|
||||
versionId,
|
||||
type: 'code',
|
||||
title: 'useThing',
|
||||
content: 'export function useThing() { return true; }',
|
||||
language: 'ts',
|
||||
breadcrumb: 'Hooks > useThing',
|
||||
tokenCount: 42
|
||||
});
|
||||
|
||||
const response = await getContext({
|
||||
url: new URL(
|
||||
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/v18.3.0`)}&query=${encodeURIComponent('useThing')}`
|
||||
)
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.snippets).toHaveLength(1);
|
||||
expect(body.rules).toEqual(['Prefer hooks over classes']);
|
||||
expect(body.totalTokens).toBe(42);
|
||||
expect(body.localSource).toBe(false);
|
||||
expect(body.resultCount).toBe(1);
|
||||
expect(body.repository).toEqual({
|
||||
id: '/facebook/react',
|
||||
title: 'React',
|
||||
source: 'github',
|
||||
sourceUrl: 'https://github.com/facebook/react',
|
||||
branch: 'main',
|
||||
isLocal: false
|
||||
});
|
||||
expect(body.version).toEqual({
|
||||
requested: 'v18.3.0',
|
||||
resolved: 'v18.3.0',
|
||||
id: '/facebook/react/v18.3.0'
|
||||
});
|
||||
expect(body.snippets[0].origin).toEqual({
|
||||
repositoryId: '/facebook/react',
|
||||
repositoryTitle: 'React',
|
||||
source: 'github',
|
||||
sourceUrl: 'https://github.com/facebook/react',
|
||||
version: 'v18.3.0',
|
||||
versionId: '/facebook/react/v18.3.0',
|
||||
isLocal: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatContextTxt,
|
||||
CORS_HEADERS
|
||||
} from '$lib/server/api/formatters';
|
||||
import type { ContextResponseMetadata } from '$lib/server/mappers/context-response.mapper';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -67,7 +68,32 @@ function getRules(db: ReturnType<typeof getClient>, repositoryId: string): strin
|
||||
|
||||
interface RawRepoState {
|
||||
state: 'pending' | 'indexing' | 'indexed' | 'error';
|
||||
id: string;
|
||||
title: string;
|
||||
source: 'github' | 'local';
|
||||
source_url: string;
|
||||
branch: string | null;
|
||||
}
|
||||
|
||||
interface RawVersionRow {
|
||||
id: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
function getSnippetVersionTags(
|
||||
db: ReturnType<typeof getClient>,
|
||||
versionIds: string[]
|
||||
): Record<string, string> {
|
||||
if (versionIds.length === 0) return {};
|
||||
|
||||
const placeholders = versionIds.map(() => '?').join(', ');
|
||||
const rows = db
|
||||
.prepare<string[], RawVersionRow>(
|
||||
`SELECT id, tag FROM repository_versions WHERE id IN (${placeholders})`
|
||||
)
|
||||
.all(...versionIds);
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.id, row.tag]));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -131,7 +157,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
// Verify the repository exists and check its state.
|
||||
const repo = db
|
||||
.prepare<[string], RawRepoState>(`SELECT state, title FROM repositories WHERE id = ?`)
|
||||
.prepare<[string], RawRepoState>(
|
||||
`SELECT id, state, title, source, source_url, branch FROM repositories WHERE id = ?`
|
||||
)
|
||||
.get(parsed.repositoryId);
|
||||
|
||||
if (!repo) {
|
||||
@@ -162,15 +190,16 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
|
||||
// Resolve version ID if a specific version was requested.
|
||||
let versionId: string | undefined;
|
||||
let resolvedVersion: RawVersionRow | undefined;
|
||||
if (parsed.version) {
|
||||
const versionRow = db
|
||||
.prepare<[string, string], { id: string }>(
|
||||
`SELECT id FROM repository_versions WHERE repository_id = ? AND tag = ?`
|
||||
resolvedVersion = db
|
||||
.prepare<[string, string], RawVersionRow>(
|
||||
`SELECT id, tag FROM repository_versions WHERE repository_id = ? AND tag = ?`
|
||||
)
|
||||
.get(parsed.repositoryId, parsed.version);
|
||||
|
||||
// Version not found is not fatal — fall back to default branch.
|
||||
versionId = versionRow?.id;
|
||||
versionId = resolvedVersion?.id;
|
||||
}
|
||||
|
||||
// Execute hybrid search (falls back to FTS5 when no embedding provider is set).
|
||||
@@ -193,11 +222,39 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
return found;
|
||||
});
|
||||
|
||||
const snippetVersionIds = Array.from(
|
||||
new Set(
|
||||
selectedResults
|
||||
.map((result) => result.snippet.versionId)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
);
|
||||
const snippetVersions = getSnippetVersionTags(db, snippetVersionIds);
|
||||
const metadata: ContextResponseMetadata = {
|
||||
localSource: repo.source === 'local',
|
||||
resultCount: selectedResults.length,
|
||||
repository: {
|
||||
id: repo.id,
|
||||
title: repo.title,
|
||||
source: repo.source,
|
||||
sourceUrl: repo.source_url,
|
||||
branch: repo.branch
|
||||
},
|
||||
version: parsed.version || resolvedVersion
|
||||
? {
|
||||
requested: parsed.version ?? null,
|
||||
resolved: resolvedVersion?.tag ?? null,
|
||||
id: resolvedVersion?.id ?? null
|
||||
}
|
||||
: null,
|
||||
snippetVersions
|
||||
};
|
||||
|
||||
// Load rules from repository_configs.
|
||||
const rules = getRules(db, parsed.repositoryId);
|
||||
|
||||
if (responseType === 'txt') {
|
||||
const text = formatContextTxt(selectedResults, rules);
|
||||
const text = formatContextTxt(selectedResults, rules, metadata);
|
||||
return new Response(text, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -208,7 +265,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
|
||||
// Default: JSON
|
||||
const body = formatContextJson(selectedResults, rules);
|
||||
const body = formatContextJson(selectedResults, rules, metadata);
|
||||
return dtoJsonResponse(body, {
|
||||
status: 200,
|
||||
headers: CORS_HEADERS
|
||||
|
||||
Reference in New Issue
Block a user