mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* fix: Japanese searches skip the category and summary result tiers The pre-split in tokenize() treats U+30FC (ー) and U+3005 (々) as separators, so a katakana word is torn into fragments before Intl.Segmenter can segment it: "データベース" tokenizes as ["デ", "タベ", "ス"]. Two consequences follow. Exploratory search requires every query token to be at least three characters (EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH in search.ts, skills.ts and packages.ts), so katakana queries never reach the category, topic and summary tiers. And getFirstSearchToken feeds normalizedDisplayNameFirstToken, an indexed range-scan bound, which collapses to the single character "デ". detectCJKLanguage in the same file already counts ー as katakana when it picks a segmenter; the pre-split now agrees with it. * fix: resynchronize digest first tokens and keep marks in the fallback Widening the CJK class moves the first token of any name containing a prolonged sound mark or an iteration mark. skillSearchDigest rows recompute that field only when their skill is written, so already-stored rows keep the old one-character token while search uses the new longer token as a range index bound - the row stays on disk and out of recall. Add a cursor-paginated resynchronization next to the existing digest backfills in maintenance.ts, and stop the no-Segmenter fallback from emitting those two marks as standalone tokens that exploratory matching discards. * fix: space the search digest backfill batches apart The catalog search page subscribes to skillSearchDigest, so a backfill that reschedules itself with no delay drives reactive re-reads back to back for the whole run. .agents/skills/clawhub-convex/SKILL.md asks for a delay between backfill batches that write reactively subscribed tables. The delay is an optional argument clamped the same way the batch size is, and it follows repairLegacyPublisherOwnershipForUserHandler, which is the one backfill in this file that already spaces its batches. * fix: reindex the mirrored catalog's first tokens too The skills.sh mirror persists its own normalizedSlugFirstToken and normalizedDisplayNameFirstToken, derived through the same tokenizer, and external candidate search range-scans both. Widening the katakana class therefore strands mirrored rows exactly the way it stranded native digest rows, and the previous backfill only paged skillSearchDigest. skillsShMirror.ts had its own copy of the first-token rule. Both callers now share getMirrorFirstSearchToken so the two cannot drift apart again. * fix: require confirmation before the first-token backfills write Both backfills defaulted dryRun to false, and their public admin actions forward omitted arguments straight through. A bare `npx convex run maintenance:backfillSkillSearchDigestFirstTokens` therefore patched skillSearchDigest and scheduled every remaining page, against a table catalog search subscribes to. An operator typo was an immediate production apply rather than a preview. Both now follow the contract the plugin catalog-digest resync already uses: preview unless dryRun is explicitly false, reject an apply whose confirm token does not match, and carry that token into the scheduled continuation so the run does not stall on its own guard after the first page. The native and mirror paths take separate tokens, so neither unlocks the other.
154 lines
5.5 KiB
TypeScript
154 lines
5.5 KiB
TypeScript
/// <reference types="vite/client" />
|
|
/* @vitest-environment edge-runtime */
|
|
import { convexTest } from "convex-test";
|
|
import { describe, expect, it } from "vitest";
|
|
import { internal } from "./_generated/api";
|
|
import { getFirstSearchToken } from "./lib/skillSearchDigest";
|
|
import schema from "./schema";
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
|
|
// What a row written before ー joined the CJK class holds for this display name.
|
|
const STALE_FIRST_TOKEN = "デ";
|
|
const DISPLAY_NAME = "データベース管理";
|
|
const SLUG = "database-kanri";
|
|
// The backfill previews unless an apply is confirmed, so the runtime cases that expect
|
|
// writes have to opt in the same way an operator does.
|
|
const APPLY = {
|
|
dryRun: false,
|
|
confirm: "backfill-skill-search-digest-first-tokens",
|
|
} as const;
|
|
|
|
async function insertDigestWithStaleFirstToken(t: ReturnType<typeof convexTest>) {
|
|
return await t.run(async (ctx) => {
|
|
const now = Date.now();
|
|
const userId = await ctx.db.insert("users", {
|
|
handle: "patrick",
|
|
displayName: "Patrick",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
const skillId = await ctx.db.insert("skills", {
|
|
slug: SLUG,
|
|
displayName: DISPLAY_NAME,
|
|
ownerUserId: userId,
|
|
tags: {},
|
|
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
const versionId = await ctx.db.insert("skillVersions", {
|
|
skillId,
|
|
version: "1.0.0",
|
|
changelog: "Initial",
|
|
files: [],
|
|
parsed: { frontmatter: {} },
|
|
createdBy: userId,
|
|
createdAt: now,
|
|
});
|
|
const digestId = await ctx.db.insert("skillSearchDigest", {
|
|
skillId,
|
|
slug: SLUG,
|
|
displayName: DISPLAY_NAME,
|
|
normalizedSlug: SLUG,
|
|
normalizedSlugFirstToken: "database",
|
|
normalizedDisplayName: DISPLAY_NAME,
|
|
normalizedDisplayNameFirstToken: STALE_FIRST_TOKEN,
|
|
ownerUserId: userId,
|
|
ownerHandle: "patrick",
|
|
ownerKind: "user",
|
|
ownerName: "patrick",
|
|
ownerDisplayName: "Patrick",
|
|
latestVersionId: versionId,
|
|
latestVersionSkillId: skillId,
|
|
publicVersion: { status: "available", versionId },
|
|
tags: {},
|
|
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
return { digestId, skillId };
|
|
});
|
|
}
|
|
|
|
describe("skillSearchDigest first-token resynchronization", () => {
|
|
it("repairs a pre-existing row the current tokenizer no longer agrees with", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { digestId } = await insertDigestWithStaleFirstToken(t);
|
|
|
|
const expected = getFirstSearchToken(DISPLAY_NAME);
|
|
expect(expected).not.toBe(STALE_FIRST_TOKEN);
|
|
|
|
const before = await t.run(async (ctx) => await ctx.db.get(digestId));
|
|
expect(before?.normalizedDisplayNameFirstToken).toBe(STALE_FIRST_TOKEN);
|
|
|
|
// An unconfirmed call reports the same repair without performing it.
|
|
const preview = await t.mutation(
|
|
internal.maintenance.backfillSkillSearchDigestFirstTokensInternal,
|
|
{},
|
|
);
|
|
expect(preview.patched).toBe(1);
|
|
expect(preview.dryRun).toBe(true);
|
|
const afterPreview = await t.run(async (ctx) => await ctx.db.get(digestId));
|
|
expect(afterPreview?.normalizedDisplayNameFirstToken).toBe(STALE_FIRST_TOKEN);
|
|
|
|
const result = await t.mutation(
|
|
internal.maintenance.backfillSkillSearchDigestFirstTokensInternal,
|
|
APPLY,
|
|
);
|
|
expect(result.patched).toBe(1);
|
|
expect(result.missingSkills).toBe(0);
|
|
|
|
const after = await t.run(async (ctx) => await ctx.db.get(digestId));
|
|
expect(after?.normalizedDisplayNameFirstToken).toBe(expected);
|
|
expect(after?.normalizedSlugFirstToken).toBe(getFirstSearchToken(SLUG));
|
|
});
|
|
|
|
it("makes the row reachable again through the index the search actually queries", async () => {
|
|
const t = convexTest(schema, modules);
|
|
await insertDigestWithStaleFirstToken(t);
|
|
|
|
const token = getFirstSearchToken(DISPLAY_NAME) as string;
|
|
const upperBound =
|
|
token.slice(0, -1) + String.fromCharCode(token.charCodeAt(token.length - 1) + 1);
|
|
const recall = async () =>
|
|
await t.run(
|
|
async (ctx) =>
|
|
await ctx.db
|
|
.query("skillSearchDigest")
|
|
.withIndex("by_active_normalized_display_name_first_token", (q) =>
|
|
q
|
|
.eq("softDeletedAt", undefined)
|
|
.gte("normalizedDisplayNameFirstToken", token)
|
|
.lt("normalizedDisplayNameFirstToken", upperBound),
|
|
)
|
|
.collect(),
|
|
);
|
|
|
|
// The row is on disk and matches the query the user typed, but the stored token
|
|
// predates the tokenizer, so the range bound the search computes never reaches it.
|
|
expect(await recall()).toHaveLength(0);
|
|
|
|
await t.mutation(internal.maintenance.backfillSkillSearchDigestFirstTokensInternal, APPLY);
|
|
|
|
expect(await recall()).toHaveLength(1);
|
|
});
|
|
|
|
it("leaves an already-current row untouched", async () => {
|
|
const t = convexTest(schema, modules);
|
|
const { digestId } = await insertDigestWithStaleFirstToken(t);
|
|
|
|
await t.mutation(internal.maintenance.backfillSkillSearchDigestFirstTokensInternal, APPLY);
|
|
const second = await t.mutation(
|
|
internal.maintenance.backfillSkillSearchDigestFirstTokensInternal,
|
|
APPLY,
|
|
);
|
|
|
|
expect(second.scanned).toBe(1);
|
|
expect(second.patched).toBe(0);
|
|
|
|
const row = await t.run(async (ctx) => await ctx.db.get(digestId));
|
|
expect(row?.normalizedDisplayNameFirstToken).toBe(getFirstSearchToken(DISPLAY_NAME));
|
|
});
|
|
});
|