fix(cli): wire wiki/knowledge search through resolveProjectEmbeddingProvider

This commit is contained in:
Andrey Avtomonov 2026-05-21 01:46:40 +02:00
parent 8aa9ab8843
commit 1a48ff02a9
4 changed files with 120 additions and 35 deletions

View file

@ -59,6 +59,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
userId: options.userId,
output: options.output,
json: options.json,
cliVersion: context.packageInfo.version,
});
return;
}
@ -71,6 +72,7 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
json: options.json,
...(isDebugEnabled(command) ? { debug: true } : {}),
...(options.limit !== undefined ? { limit: options.limit } : {}),
cliVersion: context.packageInfo.version,
});
},
);

View file

@ -148,12 +148,12 @@ describe('runKtxCli', () => {
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', '--json'], listIo.io, { knowledge }))
.resolves.toBe(0);
expect(knowledge).toHaveBeenCalledWith(
{
expect.objectContaining({
command: 'list',
projectDir: tempDir,
userId: 'local',
json: true,
},
}),
listIo.io,
);
@ -162,14 +162,14 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
expect.objectContaining({
command: 'search',
projectDir: tempDir,
query: 'revenue',
userId: 'local',
json: false,
limit: 5,
},
}),
searchIo.io,
);
@ -178,14 +178,14 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'revenue'], debugSearchIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
expect.objectContaining({
command: 'search',
projectDir: tempDir,
query: 'revenue',
userId: 'local',
json: false,
debug: true,
},
}),
debugSearchIo.io,
);
@ -194,13 +194,13 @@ describe('runKtxCli', () => {
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', 'policy'], multiWordIo.io, { knowledge }),
).resolves.toBe(0);
expect(knowledge).toHaveBeenLastCalledWith(
{
expect.objectContaining({
command: 'search',
projectDir: tempDir,
query: 'revenue policy',
userId: 'local',
json: false,
},
}),
multiWordIo.io,
);
});

View file

@ -5,7 +5,7 @@ import { stripVTControlCharacters } from 'node:util';
import { initKtxProject, loadKtxProject } from '@ktx/context/project';
import type { KtxEmbeddingPort } from '@ktx/context';
import { writeLocalKnowledgePage } from '@ktx/context/wiki';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { runKtxKnowledge } from './knowledge.js';
function makeIo() {
@ -81,12 +81,17 @@ describe('runKtxKnowledge', () => {
await seedWikiPage(projectDir);
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
await expect(
runKtxKnowledge({ command: 'list', projectDir, userId: 'local', cliVersion: '0.0.0-test' }, listIo.io),
).resolves.toBe(0);
expect(listIo.stdout()).toContain('GLOBAL\tmetrics-revenue\tRevenue');
const searchIo = makeIo();
await expect(
runKtxKnowledge({ command: 'search', projectDir, query: 'paid order', userId: 'local' }, searchIo.io),
runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', cliVersion: '0.0.0-test' },
searchIo.io,
),
).resolves.toBe(0);
expect(searchIo.stdout()).toContain('metrics-revenue');
});
@ -99,7 +104,14 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', output: 'pretty' },
{
command: 'search',
projectDir,
query: 'paid order',
userId: 'local',
output: 'pretty',
cliVersion: '0.0.0-test',
},
searchIo.io,
),
).resolves.toBe(0);
@ -115,9 +127,12 @@ describe('runKtxKnowledge', () => {
await seedWikiPage(projectDir);
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
0,
);
await expect(
runKtxKnowledge(
{ command: 'list', projectDir, userId: 'local', json: true, cliVersion: '0.0.0-test' },
listIo.io,
),
).resolves.toBe(0);
expect(JSON.parse(listIo.stdout())).toMatchObject({
kind: 'list',
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
@ -127,7 +142,15 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', json: true, limit: 5 },
{
command: 'search',
projectDir,
query: 'paid order',
userId: 'local',
json: true,
limit: 5,
cliVersion: '0.0.0-test',
},
searchIo.io,
),
).resolves.toBe(0);
@ -144,7 +167,10 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo();
await expect(
runKtxKnowledge({ command: 'search', projectDir, query: 'revenue', userId: 'local' }, searchIo.io),
runKtxKnowledge(
{ command: 'search', projectDir, query: 'revenue', userId: 'local', cliVersion: '0.0.0-test' },
searchIo.io,
),
).resolves.toBe(0);
expect(searchIo.stdout()).toBe('');
@ -166,7 +192,7 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{ command: 'search', projectDir, query: 'revenue', userId: 'local' },
{ command: 'search', projectDir, query: 'revenue', userId: 'local', cliVersion: '0.0.0-test' },
searchIo.io,
{ embeddingService: new FakeEmbeddingPort() },
),
@ -176,6 +202,37 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stderr()).toBe('');
});
it('routes wiki search through resolveEmbeddingProvider when no embeddingService is injected', async () => {
const projectDir = join(tempDir, 'resolver-project');
await initKtxProject({ projectDir });
const search = vi.fn(async () => []);
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{
command: 'search',
projectDir,
query: 'income',
userId: 'local',
cliVersion: '0.5.0',
},
searchIo.io,
{
resolveEmbeddingProvider: async () => ({
kind: 'managed-running',
provider: { id: 'fake' } as never,
baseUrl: 'http://127.0.0.1:51234',
}),
searchLocalKnowledgePages: search,
},
),
).resolves.toBe(0);
expect(search).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ embeddingService: expect.any(Object) }),
);
});
it('writes wiki search lane diagnostics to stderr when debug is enabled', async () => {
const projectDir = join(tempDir, 'debug-project');
await initKtxProject({ projectDir });
@ -184,7 +241,15 @@ describe('runKtxKnowledge', () => {
const searchIo = makeIo();
await expect(
runKtxKnowledge(
{ command: 'search', projectDir, query: 'paid order', userId: 'local', json: true, debug: true },
{
command: 'search',
projectDir,
query: 'paid order',
userId: 'local',
json: true,
debug: true,
cliVersion: '0.0.0-test',
},
searchIo.io,
{ embeddingService: new FakeEmbeddingPort() },
),

View file

@ -1,20 +1,20 @@
import {
createLocalKtxEmbeddingProviderFromConfig,
KtxIngestEmbeddingPortAdapter,
type KtxEmbeddingPort,
} from '@ktx/context';
import { KtxIngestEmbeddingPortAdapter, type KtxEmbeddingPort } from '@ktx/context';
import { loadKtxProject } from '@ktx/context/project';
import {
type LocalKnowledgeSearchResult,
type LocalKnowledgeSummary,
listLocalKnowledgePages,
searchLocalKnowledgePages,
searchLocalKnowledgePages as defaultSearchLocalKnowledgePages,
} from '@ktx/context/wiki';
import {
resolveProjectEmbeddingProvider,
type EmbeddingProviderResolution,
} from './embedding-resolution.js';
import { resolveOutputMode } from './io/mode.js';
import { createRankBadgeFormatter, printList, type PrintListColumn } from './io/print-list.js';
export type KtxKnowledgeArgs =
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean }
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean; cliVersion: string }
| {
command: 'search';
projectDir: string;
@ -24,6 +24,7 @@ export type KtxKnowledgeArgs =
json?: boolean;
limit?: number;
debug?: boolean;
cliVersion: string;
};
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
@ -54,20 +55,36 @@ function wikiSearchColumns(
interface KtxKnowledgeDeps {
embeddingService?: KtxEmbeddingPort | null;
createEmbeddingProvider?: typeof createLocalKtxEmbeddingProviderFromConfig;
resolveEmbeddingProvider?: typeof resolveProjectEmbeddingProvider;
searchLocalKnowledgePages?: typeof defaultSearchLocalKnowledgePages;
}
function wikiSearchEmbeddingService(
function resolutionToEmbeddingPort(resolution: EmbeddingProviderResolution): KtxEmbeddingPort | null {
if (
resolution.kind === 'configured' ||
resolution.kind === 'managed-running' ||
resolution.kind === 'managed-started'
) {
return new KtxIngestEmbeddingPortAdapter(resolution.provider);
}
return null;
}
async function wikiSearchEmbeddingService(
project: Awaited<ReturnType<typeof loadKtxProject>>,
deps: KtxKnowledgeDeps,
): KtxEmbeddingPort | null {
args: { cliVersion: string },
io: KtxKnowledgeIo,
): Promise<KtxEmbeddingPort | null> {
if ('embeddingService' in deps) {
return deps.embeddingService ?? null;
}
const provider = (deps.createEmbeddingProvider ?? createLocalKtxEmbeddingProviderFromConfig)(
project.config.ingest.embeddings,
);
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
const resolution = await (deps.resolveEmbeddingProvider ?? resolveProjectEmbeddingProvider)(project, {
mode: 'use-if-running',
cliVersion: args.cliVersion,
io,
});
return resolutionToEmbeddingPort(resolution);
}
function writeWikiSearchDebug(
@ -114,8 +131,9 @@ export async function runKtxKnowledge(
return 0;
}
if (args.command === 'search') {
const embeddingService = wikiSearchEmbeddingService(project, deps);
const results = await searchLocalKnowledgePages(project, {
const embeddingService = await wikiSearchEmbeddingService(project, deps, { cliVersion: args.cliVersion }, io);
const search = deps.searchLocalKnowledgePages ?? defaultSearchLocalKnowledgePages;
const results = await search(project, {
query: args.query,
userId: args.userId,
embeddingService,