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.
This commit is contained in:
clawsweeper[bot]
2026-04-29 20:36:31 +01:00
committed by GitHub
parent 4808361fca
commit 8a3507e310
5 changed files with 135 additions and 38 deletions

View File

@@ -15,6 +15,9 @@ function createPage(params: {
aliases: [],
sourceIds: [],
linkTargets: [],
relationships: [],
bestUsedFor: [],
notEnoughFor: [],
claims: [],
contradictions: params.contradictions,
questions: [],

View File

@@ -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");

View File

@@ -21,6 +21,55 @@ const CLAIMS_DIGEST_PATH = ".openclaw-wiki/cache/claims.jsonl";
const RELATED_BLOCK_PATTERN =
/<!-- openclaw:wiki:related:start -->[\s\S]*?<!-- openclaw:wiki:related:end -->/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<WikiPageSummary, "entityType" | "pageType" | "personCard">,
): 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;
}

View File

@@ -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<string, unknown> {
expect(value).toEqual(expect.any(Object));
return value as Record<string, unknown>;
}
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 });
});
});

View File

@@ -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 },