From 8a3507e310c9bd1f1005c9df8b8f540ba7d8c819 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:36:31 +0100 Subject: [PATCH] fix(memory-wiki): route natural people questions Let route-question searches match people-routing metadata from natural-language prompts, and allow wiki_apply evidence provenance fields that the markdown parser already supports. --- .../memory-wiki/src/claim-health.test.ts | 3 + extensions/memory-wiki/src/query.test.ts | 3 +- extensions/memory-wiki/src/query.ts | 138 +++++++++++++----- extensions/memory-wiki/src/tool.test.ts | 26 ++++ extensions/memory-wiki/src/tool.ts | 3 + 5 files changed, 135 insertions(+), 38 deletions(-) create mode 100644 extensions/memory-wiki/src/tool.test.ts diff --git a/extensions/memory-wiki/src/claim-health.test.ts b/extensions/memory-wiki/src/claim-health.test.ts index 321c12e1339..0a197a4065a 100644 --- a/extensions/memory-wiki/src/claim-health.test.ts +++ b/extensions/memory-wiki/src/claim-health.test.ts @@ -15,6 +15,9 @@ function createPage(params: { aliases: [], sourceIds: [], linkTargets: [], + relationships: [], + bestUsedFor: [], + notEnoughFor: [], claims: [], contradictions: params.contradictions, questions: [], diff --git a/extensions/memory-wiki/src/query.test.ts b/extensions/memory-wiki/src/query.test.ts index b6af9cccf23..4f718669234 100644 --- a/extensions/memory-wiki/src/query.test.ts +++ b/extensions/memory-wiki/src/query.test.ts @@ -266,6 +266,7 @@ describe("searchMemoryWiki", () => { }), "utf8", ); + await compileMemoryWikiVault(config); const personResults = await searchMemoryWiki({ config, @@ -284,7 +285,7 @@ describe("searchMemoryWiki", () => { const routeResults = await searchMemoryWiki({ config, - query: "Teams Azure", + query: "who should I ask about Teams?", mode: "route-question", }); expect(routeResults[0]?.path).toBe("entities/brad.md"); diff --git a/extensions/memory-wiki/src/query.ts b/extensions/memory-wiki/src/query.ts index 52879b88e21..c6f7e1312b3 100644 --- a/extensions/memory-wiki/src/query.ts +++ b/extensions/memory-wiki/src/query.ts @@ -21,6 +21,55 @@ const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl"; const RELATED_BLOCK_PATTERN = /[\s\S]*?/g; const MARKDOWN_FRONTMATTER_PATTERN = /^\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/; +const ROUTE_QUESTION_STOP_WORDS = new Set([ + "a", + "about", + "am", + "an", + "are", + "ask", + "asking", + "be", + "been", + "being", + "can", + "could", + "did", + "do", + "does", + "for", + "help", + "how", + "i", + "in", + "is", + "know", + "knows", + "me", + "my", + "need", + "needs", + "of", + "on", + "or", + "our", + "question", + "questions", + "should", + "the", + "to", + "us", + "we", + "what", + "when", + "where", + "who", + "whom", + "whose", + "why", + "with", + "would", +]); export const WIKI_SEARCH_MODES = [ "auto", @@ -309,6 +358,12 @@ function buildQueryTokens(queryLower: string): string[] { ]; } +function buildRouteQuestionTokens(queryLower: string): string[] { + const tokens = buildQueryTokens(queryLower); + const routedTokens = tokens.filter((token) => !ROUTE_QUESTION_STOP_WORDS.has(token)); + return routedTokens.length > 0 ? routedTokens : tokens; +} + function lineMatchesQuery(lineLower: string, queryLower: string, queryTokens: string[]): boolean { if (queryLower.length > 0 && lineLower.includes(queryLower)) { return true; @@ -469,6 +524,39 @@ function hasAnyQueryMatch( return values.some((value) => hasQueryMatch(value, queryLower, queryTokens)); } +function buildPageRouteQuestionFields(page: QueryableWikiPage): string[] { + return [ + page.personCard?.lane, + ...(page.personCard?.askFor ?? []), + ...(page.personCard?.avoidAskingFor ?? []), + ...page.bestUsedFor, + ...page.notEnoughFor, + ...(page.personCard?.bestUsedFor ?? []), + ...(page.personCard?.notEnoughFor ?? []), + ...page.relationships.flatMap((relationship) => [ + relationship.kind, + relationship.targetTitle, + relationship.note, + ]), + ].filter((value): value is string => Boolean(value)); +} + +function buildDigestRouteQuestionFields(page: QueryDigestPage): string[] { + return [ + page.personCard?.lane, + ...(page.personCard?.askFor ?? []), + ...(page.personCard?.avoidAskingFor ?? []), + ...(page.bestUsedFor ?? []), + ...(page.notEnoughFor ?? []), + ...(page.personCard?.bestUsedFor ?? []), + ...(page.personCard?.notEnoughFor ?? []), + ].filter((value): value is string => Boolean(value)); +} + +function hasRouteQuestionMatch(values: readonly string[], queryLower: string): boolean { + return hasAnyQueryMatch(values, queryLower, buildRouteQuestionTokens(queryLower)); +} + function isPersonLikeSummary( page: Pick, ): boolean { @@ -516,26 +604,7 @@ function scorePageSearchModeBoost(params: { } case "route-question": { let score = isPersonLikeSummary(page) ? 14 : 0; - if ( - hasAnyQueryMatch( - [ - page.personCard?.lane, - ...(page.personCard?.askFor ?? []), - ...(page.personCard?.avoidAskingFor ?? []), - ...page.bestUsedFor, - ...page.notEnoughFor, - ...(page.personCard?.bestUsedFor ?? []), - ...(page.personCard?.notEnoughFor ?? []), - ...page.relationships.flatMap((relationship) => [ - relationship.kind, - relationship.targetTitle, - relationship.note, - ]), - ], - queryLower, - queryTokens, - ) - ) { + if (hasRouteQuestionMatch(buildPageRouteQuestionFields(page), queryLower)) { score += 32; } score += Math.min(8, page.relationships.length * 2); @@ -606,21 +675,7 @@ function scoreDigestSearchModeBoost(params: { } case "route-question": { let score = isPersonLikeSummary(page) ? 14 : 0; - if ( - hasAnyQueryMatch( - [ - page.personCard?.lane, - ...(page.personCard?.askFor ?? []), - ...(page.personCard?.avoidAskingFor ?? []), - ...(page.bestUsedFor ?? []), - ...(page.notEnoughFor ?? []), - ...(page.personCard?.bestUsedFor ?? []), - ...(page.personCard?.notEnoughFor ?? []), - ], - queryLower, - queryTokens, - ) - ) { + if (hasRouteQuestionMatch(buildDigestRouteQuestionFields(page), queryLower)) { score += 32; } score += Math.min(8, (page.relationshipCount ?? 0) * 2); @@ -673,7 +728,13 @@ function buildDigestCandidatePaths(params: { const metadataLower = normalizeLowercaseStringOrEmpty( buildDigestPageSearchText(page, claims), ); - if (!metadataLower.includes(queryLower)) { + if ( + !metadataLower.includes(queryLower) && + !( + params.mode === "route-question" && + hasRouteQuestionMatch(buildDigestRouteQuestionFields(page), queryLower) + ) + ) { return { path: page.path, score: 0 }; } let score = @@ -779,7 +840,10 @@ function scorePage(page: QueryableWikiPage, query: string, mode: WikiSearchMode) rawLower.includes(queryLower); const hasAllTokens = queryTokens.length > 0 && queryTokens.every((token) => combinedLower.includes(token)); - if (!hasExactMatch && !hasAllTokens) { + const hasModeMatch = + mode === "route-question" && + hasRouteQuestionMatch(buildPageRouteQuestionFields(page), queryLower); + if (!hasExactMatch && !hasAllTokens && !hasModeMatch) { return 0; } diff --git a/extensions/memory-wiki/src/tool.test.ts b/extensions/memory-wiki/src/tool.test.ts new file mode 100644 index 00000000000..6d3c2447825 --- /dev/null +++ b/extensions/memory-wiki/src/tool.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedMemoryWikiConfig } from "./config.js"; +import { createWikiApplyTool } from "./tool.js"; + +function asSchemaObject(value: unknown): Record { + expect(value).toEqual(expect.any(Object)); + return value as Record; +} + +describe("memory-wiki tools", () => { + it("allows provenance metadata in wiki_apply claim evidence", () => { + const tool = createWikiApplyTool({} as ResolvedMemoryWikiConfig); + const applyProperties = asSchemaObject(asSchemaObject(tool.parameters).properties); + const claimsSchema = asSchemaObject(applyProperties.claims); + const claimSchema = asSchemaObject(claimsSchema.items); + const claimProperties = asSchemaObject(claimSchema.properties); + const evidenceSchema = asSchemaObject(claimProperties.evidence); + const evidenceArraySchema = asSchemaObject(evidenceSchema.items); + const evidenceProperties = asSchemaObject(evidenceArraySchema.properties); + + expect(Object.keys(evidenceProperties)).toEqual( + expect.arrayContaining(["kind", "confidence", "privacyTier"]), + ); + expect(evidenceProperties.confidence).toMatchObject({ minimum: 0, maximum: 1 }); + }); +}); diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index ef691b867e3..f96ac7b4ad8 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -40,11 +40,14 @@ const WikiGetSchema = Type.Object( ); const WikiClaimEvidenceSchema = Type.Object( { + kind: Type.Optional(Type.String({ minLength: 1 })), sourceId: Type.Optional(Type.String({ minLength: 1 })), path: Type.Optional(Type.String({ minLength: 1 })), lines: Type.Optional(Type.String({ minLength: 1 })), weight: Type.Optional(Type.Number({ minimum: 0 })), note: Type.Optional(Type.String({ minLength: 1 })), + confidence: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), + privacyTier: Type.Optional(Type.String({ minLength: 1 })), updatedAt: Type.Optional(Type.String({ minLength: 1 })), }, { additionalProperties: false },