mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
feat(cli): clean up wiki and sl commands
This commit is contained in:
parent
e1e9c4af91
commit
67b587f5d0
18 changed files with 311 additions and 585 deletions
|
|
@ -3,19 +3,6 @@ import { z } from 'zod';
|
|||
const projectDirSchema = z.string().min(1);
|
||||
const stringArraySchema = z.array(z.string());
|
||||
|
||||
export const wikiWriteCommandSchema = z.object({
|
||||
command: z.literal('write'),
|
||||
projectDir: projectDirSchema,
|
||||
key: z.string().min(1),
|
||||
scope: z.enum(['GLOBAL', 'USER']),
|
||||
userId: z.string().min(1),
|
||||
summary: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
tags: stringArraySchema,
|
||||
refs: stringArraySchema,
|
||||
slRefs: stringArraySchema,
|
||||
});
|
||||
|
||||
const orderBySchema = z.union([
|
||||
z.string().min(1),
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { type Command, Option } from '@commander-js/extra-typings';
|
||||
import { type Command } from '@commander-js/extra-typings';
|
||||
import {
|
||||
collectOption,
|
||||
type KtxCliCommandContext,
|
||||
parsePositiveIntegerOption,
|
||||
resolveCommandProjectDir,
|
||||
} from '../cli-program.js';
|
||||
import { wikiWriteCommandSchema } from '../command-schemas.js';
|
||||
import type { KtxKnowledgeArgs } from '../knowledge.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
|
||||
|
|
@ -19,7 +17,7 @@ async function runKnowledgeArgs(context: KtxCliCommandContext, args: KtxKnowledg
|
|||
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const wiki = program
|
||||
.command('wiki')
|
||||
.description('List, read, search, or write local wiki pages')
|
||||
.description('List or search local wiki pages')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -40,22 +38,6 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('read')
|
||||
.description('Read one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.action(async (key: string, options: { userId: string; json?: boolean }, command) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
userId: options.userId,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search local wiki pages')
|
||||
|
|
@ -73,31 +55,4 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
wiki
|
||||
.command('write')
|
||||
.description('Write one local wiki page')
|
||||
.argument('<key>', 'Wiki page key')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.addOption(new Option('--scope <scope>', 'global or user').choices(['global', 'user']).default('global'))
|
||||
.requiredOption('--summary <summary>', 'Wiki summary')
|
||||
.requiredOption('--content <content>', 'Wiki content')
|
||||
.option('--tag <tag>', 'Wiki tag; repeatable', collectOption, [])
|
||||
.option('--ref <ref>', 'Wiki ref; repeatable', collectOption, [])
|
||||
.option('--sl-ref <ref>', 'Semantic-layer ref; repeatable', collectOption, [])
|
||||
.action(async (key: string, options, command) => {
|
||||
const args = wikiWriteCommandSchema.parse({
|
||||
command: 'write',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
key,
|
||||
scope: options.scope === 'user' ? 'USER' : 'GLOBAL',
|
||||
userId: options.userId,
|
||||
summary: options.summary,
|
||||
content: options.content,
|
||||
tags: options.tag,
|
||||
refs: options.ref,
|
||||
slRefs: options.slRef,
|
||||
});
|
||||
await runKnowledgeArgs(context, args);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ async function runSlArgs(context: KtxCliCommandContext, args: KtxSlArgs): Promis
|
|||
export function registerSlCommands(program: Command, context: KtxCliCommandContext, commandName = 'sl'): void {
|
||||
const sl = program
|
||||
.command(commandName)
|
||||
.description('List, read, validate, query, or write local semantic-layer sources')
|
||||
.description('List, search, validate, or query local semantic-layer sources')
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
|
|
@ -51,7 +51,31 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.option('--query <text>', 'Search source names and descriptions')
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (options: { connectionId?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
sl.command('search')
|
||||
.description('Search semantic-layer sources')
|
||||
.argument('<query>', 'Search query')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
|
|
@ -62,35 +86,22 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (
|
||||
options: { connectionId?: string; query?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
|
||||
query: string,
|
||||
options: { connectionId?: string; limit?: number; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
|
||||
command,
|
||||
) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
query: options.query,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
await runSlArgs(context, {
|
||||
command: 'search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
query,
|
||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
sl.command('read')
|
||||
.description('Read a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KTX connection id')
|
||||
.option('--json', 'Print JSON output', false)
|
||||
.action(async (sourceName: string, options: { connectionId: string; json?: boolean }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'read',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
sourceName,
|
||||
json: options.json,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('validate')
|
||||
.description('Validate a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
|
|
@ -104,21 +115,6 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
});
|
||||
});
|
||||
|
||||
sl.command('write')
|
||||
.description('Write a semantic-layer source')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KTX connection id')
|
||||
.requiredOption('--yaml <yaml>', 'Semantic-layer source YAML')
|
||||
.action(async (sourceName: string, options: { connectionId: string; yaml: string }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'write',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
sourceName,
|
||||
yaml: options.yaml,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('query')
|
||||
.description('Compile or execute a semantic-layer query')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
|
|
|
|||
|
|
@ -79,12 +79,6 @@ describe('standalone local warehouse example', () => {
|
|||
parseJsonOutput<{ data: { items: Array<{ key: string; summary: string }> } }>(knowledgeList.stdout).data.items,
|
||||
).toContainEqual(expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }));
|
||||
|
||||
const knowledgeRead = await runBuiltCli(['wiki', 'read', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
expect(knowledgeRead).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(parseJsonOutput<{ data: { content: string } }>(knowledgeRead.stdout).data.content).toContain(
|
||||
'Revenue is paid order amount after refund adjustments.',
|
||||
);
|
||||
|
||||
const slList = await runBuiltCli(['sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
|
||||
expect(slList).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(
|
||||
|
|
@ -93,9 +87,9 @@ describe('standalone local warehouse example', () => {
|
|||
).data.items,
|
||||
).toContainEqual(expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }));
|
||||
|
||||
const slRead = await runBuiltCli([
|
||||
const slSearch = await runBuiltCli([
|
||||
'sl',
|
||||
'read',
|
||||
'search',
|
||||
'orders',
|
||||
'--json',
|
||||
'--connection-id',
|
||||
|
|
@ -103,8 +97,10 @@ describe('standalone local warehouse example', () => {
|
|||
'--project-dir',
|
||||
projectDir,
|
||||
]);
|
||||
expect(slRead).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(parseJsonOutput<{ data: { yaml: string } }>(slRead.stdout).data.yaml).toContain('name: orders');
|
||||
expect(slSearch).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(
|
||||
parseJsonOutput<{ data: { items: Array<{ connectionId: string; name: string }> } }>(slSearch.stdout).data.items,
|
||||
).toContainEqual(expect.objectContaining({ connectionId: 'warehouse', name: 'orders' }));
|
||||
|
||||
const ingest = await runBuiltCli([
|
||||
'ingest',
|
||||
|
|
|
|||
|
|
@ -139,6 +139,56 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('rejects removed public wiki and sl read/write commands', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
const knowledge = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['--project-dir', tempDir, 'wiki', 'read', 'revenue'],
|
||||
['--project-dir', tempDir, 'wiki', 'write', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
|
||||
['--project-dir', tempDir, 'sl', 'read', 'orders', '--connection-id', 'warehouse'],
|
||||
['--project-dir', tempDir, 'sl', 'write', 'orders', '--connection-id', 'warehouse', '--yaml', 'name: orders'],
|
||||
]) {
|
||||
const io = makeIo();
|
||||
await expect(runKtxCli(argv, io.io, { knowledge, sl })).resolves.toBe(1);
|
||||
expect(io.stderr()).toMatch(/unknown command|error:/);
|
||||
}
|
||||
|
||||
expect(knowledge).not.toHaveBeenCalled();
|
||||
expect(sl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes sl search and rejects the old sl list --query flag', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'sl', 'search', 'revenue', '--connection-id', 'warehouse', '--limit', '5', '--json'],
|
||||
searchIo.io,
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(sl).toHaveBeenCalledWith(
|
||||
{
|
||||
command: 'search',
|
||||
projectDir: tempDir,
|
||||
connectionId: 'warehouse',
|
||||
query: 'revenue',
|
||||
limit: 5,
|
||||
json: true,
|
||||
output: undefined,
|
||||
},
|
||||
searchIo.io,
|
||||
);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'sl', 'list', '--query', 'revenue'], listIo.io, { sl }),
|
||||
).resolves.toBe(1);
|
||||
expect(listIo.stderr()).toContain("unknown option '--query'");
|
||||
});
|
||||
|
||||
it('routes runtime management commands with the CLI package version', async () => {
|
||||
const runtime = vi.fn(async () => 0);
|
||||
const installIo = makeIo();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { initKtxProject } from '@ktx/context/project';
|
||||
import type { KtxEmbeddingPort } from '@ktx/context';
|
||||
import { type LocalKnowledgeScope, writeLocalKnowledgePage } from '@ktx/context/wiki';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { runKtxKnowledge } from './knowledge.js';
|
||||
|
||||
|
|
@ -40,6 +41,29 @@ class FakeEmbeddingPort implements KtxEmbeddingPort {
|
|||
}
|
||||
}
|
||||
|
||||
async function seedKnowledgePage(input: {
|
||||
projectDir: string;
|
||||
key: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
scope?: LocalKnowledgeScope;
|
||||
tags?: string[];
|
||||
refs?: string[];
|
||||
slRefs?: string[];
|
||||
}): Promise<void> {
|
||||
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
|
||||
await writeLocalKnowledgePage(project, {
|
||||
key: input.key,
|
||||
scope: input.scope ?? 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: input.summary,
|
||||
content: input.content,
|
||||
tags: input.tags ?? [],
|
||||
refs: input.refs ?? [],
|
||||
slRefs: input.slRefs ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
describe('runKtxKnowledge', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -51,36 +75,16 @@ describe('runKtxKnowledge', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes, reads, lists, and searches knowledge pages', async () => {
|
||||
it('lists and searches knowledge pages', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(writeIo.stdout()).toContain('Wrote knowledge/global/metrics-revenue.md');
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(readIo.stdout()).toContain('# metrics-revenue');
|
||||
expect(readIo.stdout()).toContain('Revenue is paid order value.');
|
||||
await seedKnowledgePage({
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
slRefs: ['orders'],
|
||||
});
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
|
||||
|
|
@ -93,27 +97,16 @@ describe('runKtxKnowledge', () => {
|
|||
expect(searchIo.stdout()).toContain('metrics-revenue');
|
||||
});
|
||||
|
||||
it('prints wiki list, search, and read as public JSON envelopes', async () => {
|
||||
it('prints wiki list and search as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
refs: [],
|
||||
slRefs: ['orders'],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await seedKnowledgePage({
|
||||
projectDir,
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
tags: ['finance'],
|
||||
slRefs: ['orders'],
|
||||
});
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
|
||||
|
|
@ -137,48 +130,6 @@ describe('runKtxKnowledge', () => {
|
|||
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
|
||||
meta: { command: 'wiki search' },
|
||||
});
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'wiki.page',
|
||||
data: {
|
||||
key: 'metrics-revenue',
|
||||
summary: 'Revenue',
|
||||
content: 'Revenue is paid order value.',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'orbit/company-overview',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Orbit',
|
||||
content: 'Orbit overview.',
|
||||
tags: [],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(writeIo.stderr()).toContain(
|
||||
'Invalid wiki key "orbit/company-overview". Wiki keys must be flat; use "orbit-company-overview".',
|
||||
);
|
||||
expect(writeIo.stdout()).toBe('');
|
||||
});
|
||||
|
||||
it('explains empty search results for a project without wiki pages', async () => {
|
||||
|
|
@ -192,30 +143,19 @@ describe('runKtxKnowledge', () => {
|
|||
|
||||
expect(searchIo.stdout()).toBe('');
|
||||
expect(searchIo.stderr()).toContain('No local wiki pages found');
|
||||
expect(searchIo.stderr()).toContain('ktx wiki write');
|
||||
expect(searchIo.stderr()).toContain('Run ingest');
|
||||
expect(searchIo.stderr()).not.toContain('ktx wiki write');
|
||||
});
|
||||
|
||||
it('uses configured embeddings for semantic wiki search', async () => {
|
||||
const projectDir = join(tempDir, 'semantic-project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxKnowledge(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
scope: 'GLOBAL',
|
||||
userId: 'local',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
refs: [],
|
||||
slRefs: [],
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
await seedKnowledgePage({
|
||||
projectDir,
|
||||
key: 'active-contract-arr-open-tickets',
|
||||
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
|
||||
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
|
||||
tags: ['historic-sql'],
|
||||
});
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -4,31 +4,12 @@ import {
|
|||
type KtxEmbeddingPort,
|
||||
} from '@ktx/context';
|
||||
import { loadKtxProject } from '@ktx/context/project';
|
||||
import {
|
||||
type LocalKnowledgeScope,
|
||||
listLocalKnowledgePages,
|
||||
readLocalKnowledgePage,
|
||||
searchLocalKnowledgePages,
|
||||
writeLocalKnowledgePage,
|
||||
} from '@ktx/context/wiki';
|
||||
import { listLocalKnowledgePages, searchLocalKnowledgePages } from '@ktx/context/wiki';
|
||||
import { writeJsonResult } from './io/print-list.js';
|
||||
|
||||
export type KtxKnowledgeArgs =
|
||||
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
|
||||
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number }
|
||||
| {
|
||||
command: 'write';
|
||||
projectDir: string;
|
||||
key: string;
|
||||
scope: LocalKnowledgeScope;
|
||||
userId: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
refs: string[];
|
||||
slRefs: string[];
|
||||
};
|
||||
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number };
|
||||
|
||||
interface KtxKnowledgeIo {
|
||||
stdout: { write(chunk: string): void };
|
||||
|
|
@ -75,25 +56,6 @@ export async function runKtxKnowledge(
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'read') {
|
||||
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
|
||||
if (!page) {
|
||||
throw new Error(`Knowledge page "${args.key}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'wiki.page',
|
||||
data: page,
|
||||
meta: { command: 'wiki read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(`# ${page.key}\n\n`);
|
||||
io.stdout.write(`Scope: ${page.scope}\n`);
|
||||
io.stdout.write(`Summary: ${page.summary}\n\n`);
|
||||
io.stdout.write(`${page.content}\n`);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'search') {
|
||||
const results = await searchLocalKnowledgePages(project, {
|
||||
query: args.query,
|
||||
|
|
@ -113,7 +75,7 @@ export async function runKtxKnowledge(
|
|||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||
if (pages.length === 0) {
|
||||
io.stderr.write(
|
||||
`No local wiki pages found in ${project.projectDir}. Create one with \`ktx wiki write <key> --summary <summary> --content <content>\` or run ingest.\n`,
|
||||
`No local wiki pages found in ${project.projectDir}. Run ingest to capture wiki context, then retry the search.\n`,
|
||||
);
|
||||
} else {
|
||||
io.stderr.write(
|
||||
|
|
@ -127,19 +89,8 @@ export async function runKtxKnowledge(
|
|||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const write = await writeLocalKnowledgePage(project, {
|
||||
key: args.key,
|
||||
scope: args.scope,
|
||||
userId: args.userId,
|
||||
summary: args.summary,
|
||||
content: args.content,
|
||||
tags: args.tags,
|
||||
refs: args.refs,
|
||||
slRefs: args.slRefs,
|
||||
});
|
||||
io.stdout.write(`Wrote ${write.path}\n`);
|
||||
return 0;
|
||||
const _exhaustive: never = args;
|
||||
throw new Error(`Unsupported wiki command: ${JSON.stringify(_exhaustive)}`);
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -138,8 +138,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'',
|
||||
`- \`${ktxCommandLine(input.launcher, ['status', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs, '--query', '<text>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'read', '<sourceName>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'search', '<text>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, [
|
||||
'sl',
|
||||
'query',
|
||||
|
|
@ -153,7 +152,6 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'100',
|
||||
])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...projectDirArgs, '--limit', '10'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
|
||||
'',
|
||||
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -38,6 +38,22 @@ function makeIo() {
|
|||
};
|
||||
}
|
||||
|
||||
async function seedSlSource(input: {
|
||||
projectDir: string;
|
||||
connectionId?: string;
|
||||
sourceName?: string;
|
||||
yaml?: string;
|
||||
}): Promise<void> {
|
||||
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
|
||||
await project.fileStore.writeFile(
|
||||
`semantic-layer/${input.connectionId ?? 'warehouse'}/${input.sourceName ?? 'orders'}.yaml`,
|
||||
input.yaml ?? ORDERS_YAML,
|
||||
'ktx',
|
||||
'ktx@example.com',
|
||||
'Add semantic-layer source',
|
||||
);
|
||||
}
|
||||
|
||||
describe('runKtxSl', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
|
@ -49,24 +65,9 @@ describe('runKtxSl', () => {
|
|||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes, validates, reads, and lists semantic-layer sources', async () => {
|
||||
it('validates, lists, and searches semantic-layer sources', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
yaml: ORDERS_YAML,
|
||||
},
|
||||
writeIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(writeIo.stdout()).toContain('Wrote semantic-layer/warehouse/orders.yaml');
|
||||
await seedSlSource({ projectDir });
|
||||
|
||||
const validateIo = makeIo();
|
||||
await expect(
|
||||
|
|
@ -74,62 +75,49 @@ describe('runKtxSl', () => {
|
|||
).resolves.toBe(0);
|
||||
expect(validateIo.stdout()).toContain('Valid semantic-layer source: warehouse/orders');
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(runKtxSl({ command: 'read', projectDir, connectionId: 'warehouse', sourceName: 'orders' }, readIo.io))
|
||||
.resolves.toBe(0);
|
||||
expect(readIo.stdout()).toContain('name: orders');
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxSl({ command: 'list', projectDir, connectionId: 'warehouse' }, listIo.io)).resolves.toBe(0);
|
||||
expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0');
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
runKtxSl({ command: 'search', projectDir, connectionId: 'warehouse', query: 'order', json: true }, searchIo.io),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(searchIo.stdout())).toMatchObject({
|
||||
kind: 'list',
|
||||
data: {
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
connectionId: 'warehouse',
|
||||
name: 'orders',
|
||||
score: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
},
|
||||
meta: { command: 'sl search' },
|
||||
});
|
||||
});
|
||||
|
||||
it('prints semantic-layer reads and searched lists as public JSON envelopes', async () => {
|
||||
it('prints semantic-layer list and search as public JSON envelopes', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{
|
||||
command: 'write',
|
||||
projectDir,
|
||||
connectionId: 'warehouse',
|
||||
sourceName: 'orders',
|
||||
yaml: [
|
||||
'name: orders',
|
||||
'table: public.orders',
|
||||
'description: Paid order facts',
|
||||
'grain: [order_id]',
|
||||
'columns:',
|
||||
' - name: order_id',
|
||||
' type: string',
|
||||
'',
|
||||
].join('\n'),
|
||||
},
|
||||
makeIo().io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
||||
const readIo = makeIo();
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{ command: 'read', projectDir, connectionId: 'warehouse', sourceName: 'orders', json: true },
|
||||
readIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
expect(JSON.parse(readIo.stdout())).toMatchObject({
|
||||
kind: 'sl.source',
|
||||
data: {
|
||||
connectionId: 'warehouse',
|
||||
name: 'orders',
|
||||
yaml: expect.stringContaining('name: orders'),
|
||||
},
|
||||
await seedSlSource({
|
||||
projectDir,
|
||||
yaml: [
|
||||
'name: orders',
|
||||
'table: public.orders',
|
||||
'description: Paid order facts',
|
||||
'grain: [order_id]',
|
||||
'columns:',
|
||||
' - name: order_id',
|
||||
' type: string',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(
|
||||
runKtxSl(
|
||||
{ command: 'list', projectDir, connectionId: 'warehouse', query: 'paid', json: true },
|
||||
{ command: 'search', projectDir, connectionId: 'warehouse', query: 'paid', json: true },
|
||||
listIo.io,
|
||||
),
|
||||
).resolves.toBe(0);
|
||||
|
|
@ -145,7 +133,7 @@ describe('runKtxSl', () => {
|
|||
}),
|
||||
],
|
||||
},
|
||||
meta: { command: 'sl list' },
|
||||
meta: { command: 'sl search' },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -566,13 +554,7 @@ joins: []
|
|||
|
||||
it('emits sl list as a JSON envelope when output=json', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await runKtxSl(
|
||||
{ command: 'write', projectDir, connectionId: 'warehouse', sourceName: 'orders', yaml: ORDERS_YAML },
|
||||
writeIo.io,
|
||||
);
|
||||
await seedSlSource({ projectDir });
|
||||
|
||||
const listIo = makeIo();
|
||||
const code = await runKtxSl(
|
||||
|
|
@ -604,13 +586,7 @@ joins: []
|
|||
|
||||
it('emits sl list with grouping and Clack-style framing when output=pretty', async () => {
|
||||
const projectDir = join(tempDir, 'project');
|
||||
await initKtxProject({ projectDir, projectName: 'warehouse' });
|
||||
|
||||
const writeIo = makeIo();
|
||||
await runKtxSl(
|
||||
{ command: 'write', projectDir, connectionId: 'warehouse', sourceName: 'orders', yaml: ORDERS_YAML },
|
||||
writeIo.io,
|
||||
);
|
||||
await seedSlSource({ projectDir });
|
||||
|
||||
const listIo = makeIo();
|
||||
const code = await runKtxSl(
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ import {
|
|||
readLocalSlSource,
|
||||
searchLocalSlSources,
|
||||
validateLocalSlSource,
|
||||
writeLocalSlSource,
|
||||
type LocalSlSourceSearchResult,
|
||||
type LocalSlSourceSummary,
|
||||
type SemanticLayerQueryInput,
|
||||
} from '@ktx/context/sl';
|
||||
import { writeJsonResult } from './io/print-list.js';
|
||||
import {
|
||||
createManagedPythonSemanticLayerComputePort,
|
||||
type KtxManagedPythonInstallPolicy,
|
||||
|
|
@ -28,10 +28,17 @@ profileMark('module:sl');
|
|||
type SlQueryFormat = 'json' | 'sql';
|
||||
|
||||
export type KtxSlArgs =
|
||||
| { command: 'list'; projectDir: string; connectionId?: string; query?: string; output?: string; json?: boolean }
|
||||
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string; json?: boolean }
|
||||
| { command: 'list'; projectDir: string; connectionId?: string; output?: string; json?: boolean }
|
||||
| {
|
||||
command: 'search';
|
||||
projectDir: string;
|
||||
connectionId?: string;
|
||||
query: string;
|
||||
limit?: number;
|
||||
output?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
|
||||
| { command: 'write'; projectDir: string; connectionId: string; sourceName: string; yaml: string }
|
||||
| {
|
||||
command: 'query';
|
||||
projectDir: string;
|
||||
|
|
@ -73,6 +80,35 @@ function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): Kt
|
|||
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
|
||||
}
|
||||
|
||||
async function printSlSources(input: {
|
||||
rows: ReadonlyArray<LocalSlSourceSummary | LocalSlSourceSearchResult>;
|
||||
command: 'sl list' | 'sl search';
|
||||
output?: string;
|
||||
json?: boolean;
|
||||
io: KtxSlIo;
|
||||
emptyMessage: string;
|
||||
}): Promise<void> {
|
||||
const { resolveOutputMode } = await import('./io/mode.js');
|
||||
const { printList } = await import('./io/print-list.js');
|
||||
const mode = resolveOutputMode({ explicit: input.output, json: input.json, io: input.io });
|
||||
printList({
|
||||
rows: input.rows,
|
||||
columns: [
|
||||
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
|
||||
{ key: 'name', label: 'NAME', plain: '' },
|
||||
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
|
||||
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
|
||||
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
|
||||
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
|
||||
],
|
||||
groupBy: 'connectionId',
|
||||
emptyMessage: input.emptyMessage,
|
||||
command: input.command,
|
||||
mode,
|
||||
io: input.io,
|
||||
});
|
||||
}
|
||||
|
||||
async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
|
|
@ -85,51 +121,32 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
try {
|
||||
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
|
||||
if (args.command === 'list') {
|
||||
const sources = args.query
|
||||
? await searchLocalSlSources(project, {
|
||||
connectionId: args.connectionId,
|
||||
query: args.query,
|
||||
embeddingService: slSearchEmbeddingService(project, deps),
|
||||
})
|
||||
: await listLocalSlSources(project, { connectionId: args.connectionId });
|
||||
const { resolveOutputMode } = await import('./io/mode.js');
|
||||
const { printList } = await import('./io/print-list.js');
|
||||
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
||||
printList({
|
||||
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
|
||||
await printSlSources({
|
||||
rows: sources,
|
||||
columns: [
|
||||
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
|
||||
{ key: 'name', label: 'NAME', plain: '' },
|
||||
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
|
||||
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
|
||||
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
|
||||
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
|
||||
],
|
||||
groupBy: 'connectionId',
|
||||
emptyMessage: `No semantic-layer sources found in ${project.projectDir}`,
|
||||
command: 'sl list',
|
||||
mode,
|
||||
output: args.output,
|
||||
json: args.json,
|
||||
io,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'read') {
|
||||
const source = await readLocalSlSource(project, {
|
||||
if (args.command === 'search') {
|
||||
const sources = await searchLocalSlSources(project, {
|
||||
connectionId: args.connectionId,
|
||||
sourceName: args.sourceName,
|
||||
query: args.query,
|
||||
embeddingService: slSearchEmbeddingService(project, deps),
|
||||
limit: args.limit,
|
||||
});
|
||||
await printSlSources({
|
||||
rows: sources,
|
||||
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,
|
||||
command: 'sl search',
|
||||
output: args.output,
|
||||
json: args.json,
|
||||
io,
|
||||
});
|
||||
if (!source) {
|
||||
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
|
||||
}
|
||||
if (args.json) {
|
||||
writeJsonResult(io, {
|
||||
kind: 'sl.source',
|
||||
data: source,
|
||||
meta: { command: 'sl read' },
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
io.stdout.write(source.yaml);
|
||||
return 0;
|
||||
}
|
||||
if (args.command === 'validate') {
|
||||
|
|
@ -178,14 +195,8 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const write = await writeLocalSlSource(project, {
|
||||
connectionId: args.connectionId,
|
||||
sourceName: args.sourceName,
|
||||
yaml: args.yaml,
|
||||
});
|
||||
io.stdout.write(`Wrote ${write.path}\n`);
|
||||
return 0;
|
||||
const _exhaustive: never = args;
|
||||
throw new Error(`Unsupported sl command: ${JSON.stringify(_exhaustive)}`);
|
||||
} catch (error) {
|
||||
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue