mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-01 08:59:39 +02:00
fix: accept ingest wiki forward refs (#125)
This commit is contained in:
parent
74be832aea
commit
f49672ba5b
8 changed files with 403 additions and 66 deletions
|
|
@ -304,4 +304,40 @@ describe('WikiWriteTool', () => {
|
|||
expect(result.markdown).toMatch(/orbit-team-lanes-detail/);
|
||||
expect(wikiService.writePage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts forward refs during ingest sessions for post-pass validation', async () => {
|
||||
const { tool, wikiService } = makeTool({
|
||||
wikiService: {
|
||||
listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']),
|
||||
},
|
||||
});
|
||||
const session: ToolSession = {
|
||||
connectionId: 'conn-1',
|
||||
isWorktreeScoped: true,
|
||||
preHead: null,
|
||||
touchedSlSources: createTouchedSlSources(),
|
||||
actions: [],
|
||||
semanticLayerService: {} as any,
|
||||
wikiService: wikiService as any,
|
||||
configService: {} as any,
|
||||
gitService: {} as any,
|
||||
ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion' },
|
||||
};
|
||||
|
||||
const result = await tool.call(
|
||||
{
|
||||
key: 'orbit-how-we-work',
|
||||
summary: 'Operating norms',
|
||||
content: 'See [[orbit-team-lanes-detail]].',
|
||||
refs: ['orbit-company-overview', 'orbit-team-lanes-detail'],
|
||||
} as any,
|
||||
{ ...baseContext, session },
|
||||
);
|
||||
|
||||
expect(result.structured).toMatchObject({ success: true, key: 'orbit-how-we-work', action: 'created' });
|
||||
expect(wikiService.writePage).toHaveBeenCalledTimes(1);
|
||||
expect(session.actions).toContainEqual(
|
||||
expect.objectContaining({ target: 'wiki', type: 'created', key: 'orbit-how-we-work' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { KnowledgeEventPort } from '../ports.js';
|
|||
type BlockScope = 'GLOBAL' | 'USER';
|
||||
import { KnowledgeWikiService, type WikiFrontmatter } from '../index.js';
|
||||
import { validateFlatWikiKey } from '../keys.js';
|
||||
import { findMissingWikiRefs } from '../wiki-ref-validation.js';
|
||||
import { applySqlEdits } from '../../tools/sql-edit-replacer.js';
|
||||
import { BaseTool, type ToolContext, type ToolOutput, validateActionRawPaths } from '../../tools/index.js';
|
||||
|
||||
|
|
@ -69,71 +70,6 @@ function normalizeAccidentalEscapedMarkdownNewlines(content: string): string {
|
|||
return content.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n');
|
||||
}
|
||||
|
||||
function isWikiPageKeyRef(ref: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref);
|
||||
}
|
||||
|
||||
function extractInlineWikiRefs(content: string): string[] {
|
||||
const refs = new Set<string>();
|
||||
const re = /\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of content.matchAll(re)) {
|
||||
const target = match[1]?.split('|', 1)[0]?.trim();
|
||||
if (target && isWikiPageKeyRef(target)) {
|
||||
refs.add(target);
|
||||
}
|
||||
}
|
||||
return [...refs].sort();
|
||||
}
|
||||
|
||||
async function visibleWikiPageKeys(
|
||||
wikiService: KnowledgeWikiService,
|
||||
scope: BlockScope,
|
||||
scopeId: string | null,
|
||||
): Promise<Set<string>> {
|
||||
const keys = new Set<string>();
|
||||
if (scope === 'USER') {
|
||||
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
|
||||
keys.add(key);
|
||||
}
|
||||
for (const key of await wikiService.listPageKeys('USER', scopeId)) {
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function findMissingWikiRefs(input: {
|
||||
wikiService: KnowledgeWikiService;
|
||||
scope: BlockScope;
|
||||
scopeId: string | null;
|
||||
pageKey: string;
|
||||
refs?: string[];
|
||||
content: string;
|
||||
}): Promise<string[]> {
|
||||
const candidates = new Set<string>();
|
||||
for (const ref of input.refs ?? []) {
|
||||
if (isWikiPageKeyRef(ref)) {
|
||||
candidates.add(ref);
|
||||
}
|
||||
}
|
||||
for (const ref of extractInlineWikiRefs(input.content)) {
|
||||
candidates.add(ref);
|
||||
}
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId);
|
||||
available.add(input.pageKey);
|
||||
return [...candidates].filter((ref) => !available.has(ref)).sort();
|
||||
}
|
||||
|
||||
export class WikiWriteTool extends BaseTool<typeof wikiWriteInputSchema> {
|
||||
readonly name = 'wiki_write';
|
||||
|
||||
|
|
@ -253,7 +189,8 @@ Keys must be flat file names, not directory paths. Use tags/source frontmatter f
|
|||
refs: finalFm.refs,
|
||||
content: finalContent,
|
||||
});
|
||||
if (missingRefs.length > 0) {
|
||||
const deferMissingRefs = !!context.session?.ingest;
|
||||
if (!deferMissingRefs && missingRefs.length > 0) {
|
||||
return {
|
||||
markdown:
|
||||
`Error: wiki references target missing page(s): ${missingRefs.join(', ')}. ` +
|
||||
|
|
|
|||
74
packages/context/src/wiki/wiki-ref-validation.test.ts
Normal file
74
packages/context/src/wiki/wiki-ref-validation.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { findDanglingWikiRefsForActions } from './wiki-ref-validation.js';
|
||||
|
||||
function makeWikiService(pages: Record<string, { refs?: string[]; content?: string }>) {
|
||||
return {
|
||||
listPageKeys: vi.fn().mockResolvedValue(Object.keys(pages)),
|
||||
readPage: vi.fn().mockImplementation((_scope: string, _scopeId: string | null, pageKey: string) => {
|
||||
const page = pages[pageKey];
|
||||
if (!page) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return Promise.resolve({
|
||||
pageKey,
|
||||
frontmatter: { summary: pageKey, usage_mode: 'auto', refs: page.refs },
|
||||
content: page.content ?? '',
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('wiki ref validation', () => {
|
||||
it('allows circular refs once both touched pages exist', async () => {
|
||||
const wikiService = makeWikiService({
|
||||
'page-a': { refs: ['page-b'], content: 'See [[page-b]].' },
|
||||
'page-b': { refs: ['page-a'], content: 'See [[page-a]].' },
|
||||
});
|
||||
|
||||
const dangling = await findDanglingWikiRefsForActions({
|
||||
wikiService: wikiService as any,
|
||||
scope: 'GLOBAL',
|
||||
scopeId: null,
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'created', key: 'page-a', detail: 'Page A' },
|
||||
{ target: 'wiki', type: 'created', key: 'page-b', detail: 'Page B' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(dangling).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats removed pages as unavailable ref targets', async () => {
|
||||
const wikiService = makeWikiService({
|
||||
'page-a': { refs: ['page-b'], content: 'See [[page-b]].' },
|
||||
});
|
||||
|
||||
const dangling = await findDanglingWikiRefsForActions({
|
||||
wikiService: wikiService as any,
|
||||
scope: 'GLOBAL',
|
||||
scopeId: null,
|
||||
actions: [
|
||||
{ target: 'wiki', type: 'updated', key: 'page-a', detail: 'Page A' },
|
||||
{ target: 'wiki', type: 'removed', key: 'page-b', detail: 'Page B' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(dangling).toEqual(['page-a -> page-b']);
|
||||
});
|
||||
|
||||
it('does not validate existing dangling refs on untouched pages', async () => {
|
||||
const wikiService = makeWikiService({
|
||||
'page-a': { refs: [], content: '' },
|
||||
'old-page': { refs: ['missing-page'], content: 'See [[missing-page]].' },
|
||||
});
|
||||
|
||||
const dangling = await findDanglingWikiRefsForActions({
|
||||
wikiService: wikiService as any,
|
||||
scope: 'GLOBAL',
|
||||
scopeId: null,
|
||||
actions: [{ target: 'wiki', type: 'updated', key: 'page-a', detail: 'Page A' }],
|
||||
});
|
||||
|
||||
expect(dangling).toEqual([]);
|
||||
});
|
||||
});
|
||||
109
packages/context/src/wiki/wiki-ref-validation.ts
Normal file
109
packages/context/src/wiki/wiki-ref-validation.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { MemoryAction } from '../tools/index.js';
|
||||
import { isFlatWikiKey } from './keys.js';
|
||||
import type { KnowledgeWikiService } from './knowledge-wiki.service.js';
|
||||
import type { WikiScope } from './types.js';
|
||||
|
||||
function isWikiPageKeyRef(ref: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref);
|
||||
}
|
||||
|
||||
function extractInlineWikiRefs(content: string): string[] {
|
||||
const refs = new Set<string>();
|
||||
const re = /\[\[([^\]\n]+)\]\]/g;
|
||||
for (const match of content.matchAll(re)) {
|
||||
const target = match[1]?.split('|', 1)[0]?.trim();
|
||||
if (target && isWikiPageKeyRef(target)) {
|
||||
refs.add(target);
|
||||
}
|
||||
}
|
||||
return [...refs].sort();
|
||||
}
|
||||
|
||||
async function visibleWikiPageKeys(
|
||||
wikiService: KnowledgeWikiService,
|
||||
scope: WikiScope,
|
||||
scopeId: string | null,
|
||||
): Promise<Set<string>> {
|
||||
const keys = new Set<string>();
|
||||
if (scope === 'USER') {
|
||||
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
|
||||
keys.add(key);
|
||||
}
|
||||
for (const key of await wikiService.listPageKeys('USER', scopeId)) {
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export async function findMissingWikiRefs(input: {
|
||||
wikiService: KnowledgeWikiService;
|
||||
scope: WikiScope;
|
||||
scopeId: string | null;
|
||||
pageKey: string;
|
||||
refs?: string[];
|
||||
content: string;
|
||||
}): Promise<string[]> {
|
||||
const candidates = new Set<string>();
|
||||
for (const ref of input.refs ?? []) {
|
||||
if (isWikiPageKeyRef(ref)) {
|
||||
candidates.add(ref);
|
||||
}
|
||||
}
|
||||
for (const ref of extractInlineWikiRefs(input.content)) {
|
||||
candidates.add(ref);
|
||||
}
|
||||
|
||||
if (candidates.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId);
|
||||
available.add(input.pageKey);
|
||||
return [...candidates].filter((ref) => !available.has(ref)).sort();
|
||||
}
|
||||
|
||||
export async function findDanglingWikiRefsForActions(input: {
|
||||
wikiService: KnowledgeWikiService;
|
||||
scope: WikiScope;
|
||||
scopeId: string | null;
|
||||
actions: MemoryAction[];
|
||||
}): Promise<string[]> {
|
||||
const latestWikiActionByKey = new Map<string, MemoryAction['type']>();
|
||||
for (const action of input.actions) {
|
||||
if (action.target === 'wiki' && isFlatWikiKey(action.key)) {
|
||||
latestWikiActionByKey.set(action.key, action.type);
|
||||
}
|
||||
}
|
||||
|
||||
const dangling: string[] = [];
|
||||
for (const [pageKey, actionType] of [...latestWikiActionByKey.entries()].sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
if (actionType === 'removed') {
|
||||
continue;
|
||||
}
|
||||
const page = await input.wikiService.readPage(input.scope, input.scopeId, pageKey);
|
||||
if (!page) {
|
||||
dangling.push(`${pageKey} -> (missing page)`);
|
||||
continue;
|
||||
}
|
||||
const missingRefs = await findMissingWikiRefs({
|
||||
wikiService: input.wikiService,
|
||||
scope: input.scope,
|
||||
scopeId: input.scopeId,
|
||||
pageKey,
|
||||
refs: page.frontmatter.refs,
|
||||
content: page.content,
|
||||
});
|
||||
for (const missingRef of missingRefs) {
|
||||
dangling.push(`${pageKey} -> ${missingRef}`);
|
||||
}
|
||||
}
|
||||
return dangling;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue