mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
1064 lines
32 KiB
TypeScript
1064 lines
32 KiB
TypeScript
/* @vitest-environment node */
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { tokenize } from "./lib/searchText";
|
|
import { __test, hydrateResults, lexicalFallbackSkills, searchSkills } from "./search";
|
|
|
|
const { generateEmbeddingMock } = vi.hoisted(() => ({
|
|
generateEmbeddingMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./lib/embeddings", () => ({
|
|
generateEmbedding: generateEmbeddingMock,
|
|
}));
|
|
|
|
vi.mock("./lib/badges", () => ({
|
|
isSkillHighlighted: (skill: { badges?: Record<string, unknown> }) =>
|
|
Boolean(skill.badges?.highlighted),
|
|
}));
|
|
|
|
type WrappedHandler<Result = { skill: { slug: string; _id: string } }> = {
|
|
_handler: (ctx: unknown, args: unknown) => Promise<Array<Result>>;
|
|
};
|
|
|
|
const searchSkillsHandler = (
|
|
searchSkills as unknown as WrappedHandler<{
|
|
skill: { slug: string; _id: string };
|
|
score: number;
|
|
}>
|
|
)._handler;
|
|
const lexicalFallbackSkillsHandler = (lexicalFallbackSkills as unknown as WrappedHandler)._handler;
|
|
const hydrateResultsHandler = (
|
|
hydrateResults as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: unknown,
|
|
) => Promise<Array<{ skill: { slug: string; _id: string }; ownerHandle: string | null }>>;
|
|
}
|
|
)._handler;
|
|
|
|
describe("search helpers", () => {
|
|
it("returns fallback results when vector candidates are empty", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
const fallback = [
|
|
{
|
|
skill: makePublicSkill({ id: "skills:orf", slug: "orf", displayName: "ORF" }),
|
|
version: null,
|
|
ownerHandle: "steipete",
|
|
owner: null,
|
|
},
|
|
];
|
|
// Slug-like queries now do an indexed exact-slug lookup before lexical fallback.
|
|
const runQuery = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(fallback);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([]),
|
|
runQuery,
|
|
},
|
|
{ query: "orf", limit: 10 },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("orf");
|
|
expect(runQuery).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ query: "orf", queryTokens: ["orf"] }),
|
|
);
|
|
});
|
|
|
|
it("applies highlightedOnly filtering in lexical fallback", async () => {
|
|
const highlighted = {
|
|
...makeSkillDoc({
|
|
id: "skills:hl",
|
|
slug: "orf-highlighted",
|
|
displayName: "ORF Highlighted",
|
|
}),
|
|
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
|
|
};
|
|
const plain = makeSkillDoc({ id: "skills:plain", slug: "orf-plain", displayName: "ORF Plain" });
|
|
|
|
const result = await lexicalFallbackSkillsHandler(
|
|
makeLexicalCtx({
|
|
exactSlugSkill: null,
|
|
recentSkills: [highlighted, plain],
|
|
}),
|
|
{ query: "orf", queryTokens: ["orf"], highlightedOnly: true, limit: 10 },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("orf-highlighted");
|
|
});
|
|
|
|
it("applies nonSuspiciousOnly filtering in lexical fallback", async () => {
|
|
const suspicious = makeSkillDoc({
|
|
id: "skills:suspicious",
|
|
slug: "orf-suspicious",
|
|
displayName: "ORF Suspicious",
|
|
moderationFlags: ["flagged.suspicious"],
|
|
});
|
|
const clean = makeSkillDoc({ id: "skills:clean", slug: "orf-clean", displayName: "ORF Clean" });
|
|
|
|
const result = await lexicalFallbackSkillsHandler(
|
|
makeLexicalCtx({
|
|
exactSlugSkill: null,
|
|
recentSkills: [suspicious, clean],
|
|
}),
|
|
{ query: "orf", queryTokens: ["orf"], nonSuspiciousOnly: true, limit: 10 },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("orf-clean");
|
|
});
|
|
|
|
it("includes exact slug match from by_slug even when recent scan is empty", async () => {
|
|
const exactSlugSkill = makeSkillDoc({ id: "skills:orf", slug: "orf", displayName: "ORF" });
|
|
const ctx = makeLexicalCtx({
|
|
exactSlugSkill,
|
|
recentSkills: [],
|
|
});
|
|
|
|
const result = await lexicalFallbackSkillsHandler(ctx, {
|
|
query: "orf",
|
|
queryTokens: ["orf"],
|
|
limit: 10,
|
|
});
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("orf");
|
|
expect(ctx.db.query).toHaveBeenCalledWith("skills");
|
|
expect(ctx.db.query).toHaveBeenCalledWith("skillSearchDigest");
|
|
});
|
|
|
|
it("dedupes overlap and enforces rank + limit across vector and fallback", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
const vectorEntries = [
|
|
{
|
|
embeddingId: "skillEmbeddings:a",
|
|
skill: makePublicSkill({
|
|
id: "skills:a",
|
|
slug: "foo-a",
|
|
displayName: "Foo Alpha",
|
|
downloads: 10,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "one",
|
|
owner: null,
|
|
},
|
|
{
|
|
embeddingId: "skillEmbeddings:b",
|
|
skill: makePublicSkill({
|
|
id: "skills:b",
|
|
slug: "foo-b",
|
|
displayName: "Foo Beta",
|
|
downloads: 2,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "two",
|
|
owner: null,
|
|
},
|
|
];
|
|
const fallbackEntries = [
|
|
{
|
|
skill: makePublicSkill({
|
|
id: "skills:a",
|
|
slug: "foo-a",
|
|
displayName: "Foo Alpha",
|
|
downloads: 10,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "one",
|
|
owner: null,
|
|
},
|
|
{
|
|
skill: makePublicSkill({
|
|
id: "skills:c",
|
|
slug: "foo-c",
|
|
displayName: "Foo Classic",
|
|
downloads: 1,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "three",
|
|
owner: null,
|
|
},
|
|
];
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
|
.mockResolvedValueOnce(vectorEntries) // hydrateResults
|
|
.mockResolvedValueOnce(fallbackEntries); // lexicalFallbackSkills
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([
|
|
{ _id: "skillEmbeddings:a", _score: 0.4 },
|
|
{ _id: "skillEmbeddings:b", _score: 0.9 },
|
|
]),
|
|
runQuery,
|
|
},
|
|
{ query: "foo", limit: 2 },
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].skill.slug).toBe("foo-b");
|
|
expect(new Set(result.map((entry: { skill: { _id: string } }) => entry.skill._id)).size).toBe(
|
|
2,
|
|
);
|
|
});
|
|
|
|
it("always includes an exact slug match even when vector exact matches already fill the limit", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const vectorEntries = Array.from({ length: 10 }, (_, index) => ({
|
|
embeddingId: `skillEmbeddings:${index}`,
|
|
skill: makePublicSkill({
|
|
id: `skills:${index}`,
|
|
slug: `downloader-${index}`,
|
|
displayName: `Skill Downloader ${index}`,
|
|
downloads: 100 - index,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
}));
|
|
|
|
const exactSlugEntry = {
|
|
skill: makePublicSkill({
|
|
id: "skills:exact",
|
|
slug: "skill-downloader",
|
|
displayName: "Skill Downloader",
|
|
downloads: 1,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "yyang100",
|
|
owner: null,
|
|
};
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(exactSlugEntry)
|
|
.mockResolvedValueOnce(vectorEntries);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue(
|
|
vectorEntries.map((entry, index) => ({
|
|
_id: entry.embeddingId,
|
|
_score: 0.9 - index * 0.01,
|
|
})),
|
|
),
|
|
runQuery,
|
|
},
|
|
{ query: "skill-downloader", limit: 10 },
|
|
);
|
|
|
|
expect(result).toHaveLength(10);
|
|
expect(result[0].skill.slug).toBe("skill-downloader");
|
|
expect(runQuery).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("omits exact slug injection when nonSuspiciousOnly excludes it", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const vectorEntries = [
|
|
{
|
|
embeddingId: "skillEmbeddings:1",
|
|
skill: makePublicSkill({
|
|
id: "skills:1",
|
|
slug: "downloader-1",
|
|
displayName: "Skill Downloader 1",
|
|
downloads: 50,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
];
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(vectorEntries)
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
|
runQuery,
|
|
},
|
|
{ query: "skill-downloader", limit: 10, nonSuspiciousOnly: true },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("downloader-1");
|
|
});
|
|
|
|
it("omits exact slug injection when highlightedOnly excludes it", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const exactSlugEntry = {
|
|
skill: makePublicSkill({
|
|
id: "skills:exact",
|
|
slug: "skill-downloader",
|
|
displayName: "Skill Downloader",
|
|
downloads: 1,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "yyang100",
|
|
owner: null,
|
|
};
|
|
|
|
const vectorEntries = [
|
|
{
|
|
embeddingId: "skillEmbeddings:1",
|
|
skill: {
|
|
...makePublicSkill({
|
|
id: "skills:1",
|
|
slug: "downloader-1",
|
|
displayName: "Skill Downloader 1",
|
|
downloads: 50,
|
|
}),
|
|
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
|
|
},
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
];
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(exactSlugEntry)
|
|
.mockResolvedValueOnce(vectorEntries)
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([{ _id: "skillEmbeddings:1", _score: 0.9 }]),
|
|
runQuery,
|
|
},
|
|
{ query: "skill-downloader", limit: 10, highlightedOnly: true },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("downloader-1");
|
|
});
|
|
|
|
it("filters vector search results by capability tag", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce([
|
|
{
|
|
embeddingId: "skillEmbeddings:crypto",
|
|
skill: makePublicSkill({
|
|
id: "skills:crypto",
|
|
slug: "wallet-helper",
|
|
displayName: "Wallet Helper",
|
|
capabilityTags: ["crypto", "requires-wallet"],
|
|
}),
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
{
|
|
embeddingId: "skillEmbeddings:oauth",
|
|
skill: makePublicSkill({
|
|
id: "skills:oauth",
|
|
slug: "x-poster",
|
|
displayName: "X Poster",
|
|
capabilityTags: ["requires-oauth-token", "posts-externally"],
|
|
}),
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([
|
|
{ _id: "skillEmbeddings:crypto", _score: 0.9 },
|
|
{ _id: "skillEmbeddings:oauth", _score: 0.8 },
|
|
]),
|
|
runQuery,
|
|
},
|
|
{ query: "helper", limit: 10, capabilityTag: "crypto" },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("wallet-helper");
|
|
});
|
|
|
|
it("deduplicates exact slug injection against vector exact matches", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const sharedSkill = makePublicSkill({
|
|
id: "skills:exact",
|
|
slug: "skill-downloader",
|
|
displayName: "Skill Downloader",
|
|
downloads: 100,
|
|
});
|
|
const exactSlugEntry = {
|
|
skill: sharedSkill,
|
|
version: null,
|
|
ownerHandle: "yyang100",
|
|
owner: null,
|
|
};
|
|
const vectorEntries = [
|
|
{
|
|
embeddingId: "skillEmbeddings:exact",
|
|
skill: sharedSkill,
|
|
version: null,
|
|
ownerHandle: "yyang100",
|
|
owner: null,
|
|
},
|
|
{
|
|
embeddingId: "skillEmbeddings:other",
|
|
skill: makePublicSkill({
|
|
id: "skills:other",
|
|
slug: "downloader-2",
|
|
displayName: "Skill Downloader 2",
|
|
downloads: 50,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
];
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(exactSlugEntry)
|
|
.mockResolvedValueOnce(vectorEntries)
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([
|
|
{ _id: "skillEmbeddings:exact", _score: 0.95 },
|
|
{ _id: "skillEmbeddings:other", _score: 0.8 },
|
|
]),
|
|
runQuery,
|
|
},
|
|
{ query: "skill-downloader", limit: 10 },
|
|
);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.filter((entry) => entry.skill._id === "skills:exact")).toHaveLength(1);
|
|
});
|
|
|
|
it("skips duplicate slug lookup inside lexical fallback when search action already did it", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const fallbackEntries = [
|
|
{
|
|
skill: makePublicSkill({
|
|
id: "skills:orf",
|
|
slug: "orf",
|
|
displayName: "ORF",
|
|
}),
|
|
version: null,
|
|
ownerHandle: "steipete",
|
|
owner: null,
|
|
},
|
|
];
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(null)
|
|
.mockImplementationOnce(async (_ref: unknown, args: { skipExactSlugLookup?: boolean }) => {
|
|
expect(args.skipExactSlugLookup).toBe(true);
|
|
return fallbackEntries;
|
|
});
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi.fn().mockResolvedValue([]),
|
|
runQuery,
|
|
},
|
|
{ query: "orf", limit: 10 },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("orf");
|
|
});
|
|
|
|
it("filters suspicious vector results in hydrateResults when requested", async () => {
|
|
const result = await hydrateResultsHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "skillEmbeddings:1") {
|
|
return {
|
|
_id: "skillEmbeddings:1",
|
|
skillId: "skills:1",
|
|
versionId: "skillVersions:1",
|
|
};
|
|
}
|
|
if (id === "skills:1") {
|
|
return makeSkillDoc({
|
|
id: "skills:1",
|
|
slug: "suspicious",
|
|
displayName: "Suspicious",
|
|
moderationFlags: ["flagged.suspicious"],
|
|
});
|
|
}
|
|
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
|
|
if (id === "skillVersions:1") return { _id: "skillVersions:1", version: "1.0.0" };
|
|
return null;
|
|
}),
|
|
query: vi.fn(() => ({
|
|
withIndex: () => ({ unique: vi.fn().mockResolvedValue(null) }),
|
|
})),
|
|
},
|
|
},
|
|
{ embeddingIds: ["skillEmbeddings:1"], nonSuspiciousOnly: true },
|
|
);
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes soft-deleted skills from vector search results (#29)", async () => {
|
|
const result = await hydrateResultsHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "skillEmbeddings:1") {
|
|
return {
|
|
_id: "skillEmbeddings:1",
|
|
skillId: "skills:1",
|
|
versionId: "skillVersions:1",
|
|
};
|
|
}
|
|
if (id === "skillEmbeddings:2") {
|
|
return {
|
|
_id: "skillEmbeddings:2",
|
|
skillId: "skills:2",
|
|
versionId: "skillVersions:2",
|
|
};
|
|
}
|
|
if (id === "skills:1") {
|
|
return {
|
|
...makeSkillDoc({ id: "skills:1", slug: "active-skill", displayName: "Active" }),
|
|
softDeletedAt: undefined,
|
|
};
|
|
}
|
|
if (id === "skills:2") {
|
|
return {
|
|
...makeSkillDoc({ id: "skills:2", slug: "deleted-skill", displayName: "Deleted" }),
|
|
softDeletedAt: 1700000000000,
|
|
};
|
|
}
|
|
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
|
|
if (id.startsWith("skillVersions:")) return { _id: id, version: "1.0.0" };
|
|
return null;
|
|
}),
|
|
query: vi.fn(() => ({
|
|
withIndex: () => ({ unique: vi.fn().mockResolvedValue(null) }),
|
|
})),
|
|
},
|
|
},
|
|
{ embeddingIds: ["skillEmbeddings:1", "skillEmbeddings:2"] },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("active-skill");
|
|
});
|
|
|
|
it("excludes skills whose owners are deleted or banned from vector search results", async () => {
|
|
const result = await hydrateResultsHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "skillEmbeddings:1") {
|
|
return {
|
|
_id: "skillEmbeddings:1",
|
|
skillId: "skills:1",
|
|
versionId: "skillVersions:1",
|
|
};
|
|
}
|
|
if (id === "skills:1") {
|
|
return {
|
|
...makeSkillDoc({
|
|
id: "skills:1",
|
|
slug: "ownerless-skill",
|
|
displayName: "Ownerless",
|
|
}),
|
|
softDeletedAt: undefined,
|
|
};
|
|
}
|
|
if (id === "users:owner") {
|
|
return { _id: "users:owner", handle: "owner", deletedAt: 1700000000000 };
|
|
}
|
|
if (id === "skillVersions:1") return { _id: "skillVersions:1", version: "1.0.0" };
|
|
return null;
|
|
}),
|
|
query: vi.fn(() => ({
|
|
withIndex: () => ({ unique: vi.fn().mockResolvedValue(null) }),
|
|
})),
|
|
},
|
|
},
|
|
{ embeddingIds: ["skillEmbeddings:1"] },
|
|
);
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("excludes soft-deleted exact slug match from lexical fallback (#29)", async () => {
|
|
const deletedSkill = makeSkillDoc({
|
|
id: "skills:deleted",
|
|
slug: "orf",
|
|
displayName: "ORF",
|
|
softDeletedAt: 1700000000000,
|
|
});
|
|
const ctx = makeLexicalCtx({
|
|
exactSlugSkill: deletedSkill,
|
|
recentSkills: [],
|
|
});
|
|
|
|
const result = await lexicalFallbackSkillsHandler(ctx, {
|
|
query: "orf",
|
|
queryTokens: ["orf"],
|
|
limit: 10,
|
|
});
|
|
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("advances candidate limit until max", () => {
|
|
expect(__test.getNextCandidateLimit(50, 1000)).toBe(100);
|
|
expect(__test.getNextCandidateLimit(800, 1000)).toBe(1000);
|
|
expect(__test.getNextCandidateLimit(1000, 1000)).toBeNull();
|
|
});
|
|
|
|
it("boosts exact slug/name matches over loose matches", () => {
|
|
const queryTokens = tokenize("notion");
|
|
const exactScore = __test.scoreSkillResult(queryTokens, 0.4, "Notion Sync", "notion-sync", 5);
|
|
const looseScore = __test.scoreSkillResult(queryTokens, 0.6, "Notes Sync", "notes-sync", 500);
|
|
expect(exactScore).toBeGreaterThan(looseScore);
|
|
});
|
|
|
|
it("adds a popularity prior for equally relevant matches", () => {
|
|
const queryTokens = tokenize("notion");
|
|
const lowDownloads = __test.scoreSkillResult(
|
|
queryTokens,
|
|
0.5,
|
|
"Notion Helper",
|
|
"notion-helper",
|
|
0,
|
|
);
|
|
const highDownloads = __test.scoreSkillResult(
|
|
queryTokens,
|
|
0.5,
|
|
"Notion Helper",
|
|
"notion-helper",
|
|
1000,
|
|
);
|
|
expect(highDownloads).toBeGreaterThan(lowDownloads);
|
|
});
|
|
|
|
it("uses digest doc instead of full skill doc in hydrateResults but revalidates the owner", async () => {
|
|
// Derive digest from makeSkillDoc so it stays in sync with schema changes.
|
|
const skillDoc = makeSkillDoc({
|
|
id: "skills:1",
|
|
slug: "digest-skill",
|
|
displayName: "Digest Skill",
|
|
});
|
|
const digestDoc = {
|
|
_id: "skillSearchDigest:d1",
|
|
_creationTime: 1,
|
|
skillId: skillDoc._id,
|
|
slug: skillDoc.slug,
|
|
displayName: skillDoc.displayName,
|
|
summary: skillDoc.summary,
|
|
ownerUserId: skillDoc.ownerUserId,
|
|
ownerHandle: "owner",
|
|
ownerName: "Owner",
|
|
ownerDisplayName: "Owner",
|
|
ownerImage: undefined,
|
|
canonicalSkillId: skillDoc.canonicalSkillId,
|
|
forkOf: skillDoc.forkOf,
|
|
latestVersionId: skillDoc.latestVersionId,
|
|
tags: skillDoc.tags,
|
|
badges: skillDoc.badges,
|
|
stats: skillDoc.stats,
|
|
statsDownloads: skillDoc.stats.downloads,
|
|
statsStars: skillDoc.stats.stars,
|
|
statsInstallsCurrent: skillDoc.stats.installsCurrent,
|
|
statsInstallsAllTime: skillDoc.stats.installsAllTime,
|
|
softDeletedAt: skillDoc.softDeletedAt,
|
|
moderationStatus: skillDoc.moderationStatus,
|
|
moderationFlags: skillDoc.moderationFlags,
|
|
moderationReason: skillDoc.moderationReason,
|
|
isSuspicious: false,
|
|
createdAt: skillDoc.createdAt,
|
|
updatedAt: skillDoc.updatedAt,
|
|
};
|
|
|
|
const getMock = vi.fn(async (id: string) => {
|
|
// Should NOT be called for skills:1 when digest exists
|
|
if (id === "skills:1") throw new Error("Should not read full skill doc");
|
|
if (id === "users:owner") {
|
|
return {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "owner",
|
|
name: "Owner",
|
|
displayName: "Owner",
|
|
image: undefined,
|
|
bio: undefined,
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
const result = await hydrateResultsHandler(
|
|
{
|
|
db: {
|
|
get: getMock,
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: (index: string) => ({
|
|
unique: vi.fn(async () => {
|
|
if (table === "embeddingSkillMap" && index === "by_embedding") {
|
|
return { embeddingId: "skillEmbeddings:1", skillId: "skills:1" };
|
|
}
|
|
if (table === "skillSearchDigest" && index === "by_skill") {
|
|
return digestDoc;
|
|
}
|
|
return null;
|
|
}),
|
|
}),
|
|
})),
|
|
},
|
|
},
|
|
{ embeddingIds: ["skillEmbeddings:1"] },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("digest-skill");
|
|
expect(result[0].skill._id).toBe("skills:1");
|
|
expect(result[0].ownerHandle).toBe("owner");
|
|
// Owner resolved from digest — users table should NOT be read
|
|
expect(getMock).not.toHaveBeenCalledWith("users:owner");
|
|
});
|
|
|
|
it("falls back to full skill doc when digest is missing", async () => {
|
|
const result = await hydrateResultsHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
|
|
if (id === "skills:1") {
|
|
return makeSkillDoc({
|
|
id: "skills:1",
|
|
slug: "fallback-skill",
|
|
displayName: "Fallback Skill",
|
|
});
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: (index: string) => ({
|
|
unique: vi.fn(async () => {
|
|
if (table === "embeddingSkillMap" && index === "by_embedding") {
|
|
return { embeddingId: "skillEmbeddings:1", skillId: "skills:1" };
|
|
}
|
|
// No digest exists — return null
|
|
return null;
|
|
}),
|
|
}),
|
|
})),
|
|
},
|
|
},
|
|
{ embeddingIds: ["skillEmbeddings:1"] },
|
|
);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].skill.slug).toBe("fallback-skill");
|
|
});
|
|
|
|
it("only hydrates new embedding IDs on subsequent iterations (incremental)", async () => {
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
// limit=10 → candidateLimit starts at 50, maxCandidate=200.
|
|
// First iteration must return exactly candidateLimit (50) to trigger expansion.
|
|
const firstBatch = Array.from({ length: 50 }, (_, i) => ({
|
|
_id: `skillEmbeddings:e${i}`,
|
|
_score: 0.5 - i * 0.001,
|
|
}));
|
|
// Second iteration returns 60 results (50 old + 10 new).
|
|
// 60 < next candidateLimit (100), so the loop breaks.
|
|
const secondBatch = [
|
|
...firstBatch,
|
|
...Array.from({ length: 10 }, (_, i) => ({
|
|
_id: `skillEmbeddings:n${i}`,
|
|
_score: 0.3 - i * 0.001,
|
|
})),
|
|
];
|
|
|
|
const vectorSearchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(firstBatch)
|
|
.mockResolvedValueOnce(secondBatch);
|
|
|
|
const hydrateCalls: string[][] = [];
|
|
const runQuery = vi.fn(
|
|
async (_ref: unknown, args: { embeddingIds?: string[]; query?: string; slug?: string }) => {
|
|
if (args.slug) {
|
|
return null; // getExactSkillSlugMatch
|
|
}
|
|
if (args.embeddingIds) {
|
|
hydrateCalls.push(args.embeddingIds);
|
|
return args.embeddingIds.map((embeddingId: string) => ({
|
|
embeddingId,
|
|
skill: makePublicSkill({
|
|
id: `skills:${embeddingId.split(":")[1]}`,
|
|
slug: `skill-${embeddingId.split(":")[1]}`,
|
|
displayName: `Skill ${embeddingId.split(":")[1]}`,
|
|
}),
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
}));
|
|
}
|
|
return []; // lexicalFallbackSkills
|
|
},
|
|
);
|
|
|
|
await searchSkillsHandler(
|
|
{ vectorSearch: vectorSearchMock, runQuery },
|
|
{ query: "test", limit: 10 },
|
|
);
|
|
|
|
// Should have been called twice, but second call should only have new IDs
|
|
expect(hydrateCalls).toHaveLength(2);
|
|
expect(hydrateCalls[0]).toHaveLength(50);
|
|
expect(hydrateCalls[1]).toHaveLength(10);
|
|
// Verify no overlap between the two hydrate calls
|
|
const firstSet = new Set(hydrateCalls[0]);
|
|
const overlap = hydrateCalls[1].filter((id) => firstSet.has(id));
|
|
expect(overlap).toHaveLength(0);
|
|
});
|
|
|
|
it("merges fallback matches without duplicate skill ids", () => {
|
|
const primary = [
|
|
{
|
|
embeddingId: "skillEmbeddings:1",
|
|
skill: { _id: "skills:1" },
|
|
},
|
|
] as unknown as Parameters<typeof __test.mergeUniqueBySkillId>[0];
|
|
const fallback = [
|
|
{
|
|
skill: { _id: "skills:1" },
|
|
},
|
|
{
|
|
skill: { _id: "skills:2" },
|
|
},
|
|
] as unknown as Parameters<typeof __test.mergeUniqueBySkillId>[1];
|
|
|
|
const merged = __test.mergeUniqueBySkillId(primary, fallback);
|
|
expect(merged).toHaveLength(2);
|
|
expect(merged.map((entry) => entry.skill._id)).toEqual(["skills:1", "skills:2"]);
|
|
});
|
|
|
|
it("preserves vector scores across candidate expansion iterations", async () => {
|
|
// Regression test for scoreById overwrite bug.
|
|
//
|
|
// Setup:
|
|
// limit=10 → candidateLimit starts at 50, maxCandidate=200
|
|
// Iteration 1: vectorSearch returns exactly 50 results (= candidateLimit)
|
|
// → results.length < candidateLimit is false → loop continues
|
|
// Iteration 2: vectorSearch returns 2 results (< 100) → loop exits
|
|
//
|
|
// skillA appears ONLY in iteration 1 (score 0.95).
|
|
// skillB appears ONLY in iteration 2 (score 0.5).
|
|
//
|
|
// With the BUG: scoreById = new Map(iter2_results) → skillA missing → vectorScore=0
|
|
// With the FIX: scoreById.set() merges → skillA retains 0.95
|
|
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
|
|
|
const skillA = makePublicSkill({
|
|
id: "skills:a",
|
|
slug: "baidu-yijian-vision",
|
|
displayName: "Baidu Yijian Vision",
|
|
downloads: 100,
|
|
});
|
|
const skillB = makePublicSkill({
|
|
id: "skills:b",
|
|
slug: "baidu-yijian-test",
|
|
displayName: "Baidu Yijian Test",
|
|
downloads: 50,
|
|
});
|
|
|
|
// Iteration 1: exactly 50 entries so the loop does NOT exit early.
|
|
// skillA is entry 0; entries 1-49 are fillers filtered out by hydrateResults.
|
|
const iter1Results = Array.from({ length: 50 }, (_, i) => ({
|
|
_id: i === 0 ? "skillEmbeddings:a" : `skillEmbeddings:filler${i}`,
|
|
_score: i === 0 ? 0.95 : 0.1,
|
|
}));
|
|
|
|
// Iteration 2: 2 entries, both new IDs (skillA is absent from this batch).
|
|
// results.length (2) < candidateLimit (100) → loop exits.
|
|
const iter2Results = [
|
|
{ _id: "skillEmbeddings:b", _score: 0.5 },
|
|
{ _id: "skillEmbeddings:filler50", _score: 0.08 },
|
|
];
|
|
|
|
const runQuery = vi
|
|
.fn()
|
|
// hydrateResults iteration 1: 50 new IDs → only skillA survives hydration
|
|
.mockResolvedValueOnce([
|
|
{
|
|
embeddingId: "skillEmbeddings:a",
|
|
skill: skillA,
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
])
|
|
// hydrateResults iteration 2: 2 new IDs → only skillB survives hydration
|
|
.mockResolvedValueOnce([
|
|
{
|
|
embeddingId: "skillEmbeddings:b",
|
|
skill: skillB,
|
|
version: null,
|
|
ownerHandle: "owner",
|
|
owner: null,
|
|
},
|
|
])
|
|
// lexicalFallbackSkills (exactMatches < limit after loop exits)
|
|
.mockResolvedValueOnce([]);
|
|
|
|
const result = await searchSkillsHandler(
|
|
{
|
|
vectorSearch: vi
|
|
.fn()
|
|
.mockResolvedValueOnce(iter1Results) // iteration 1: 50 results, loop continues
|
|
.mockResolvedValueOnce(iter2Results), // iteration 2: 2 results, loop exits
|
|
runQuery,
|
|
},
|
|
{ query: "baidu yijian", limit: 10 },
|
|
);
|
|
|
|
const resultA = result.find(
|
|
(r: { skill: { slug: string } }) => r.skill.slug === "baidu-yijian-vision",
|
|
);
|
|
expect(resultA).toBeDefined();
|
|
// With scoreById correctly merged: skillA retains vectorScore=0.95.
|
|
// With the bug (overwrite): skillA.embeddingId absent from iter2 map → vectorScore=0.
|
|
// Lexical boost for "baidu-yijian-vision" slug matching "baidu yijian" ≈ 0.8 (prefix).
|
|
// Fix: score ≈ 0.95 + 0.8 + popularity > 1.5; Bug: score ≈ 0 + 0.8 + popularity < 0.9.
|
|
expect(resultA!.score).toBeGreaterThan(1.0);
|
|
});
|
|
});
|
|
|
|
function makePublicSkill(params: {
|
|
id: string;
|
|
slug: string;
|
|
displayName: string;
|
|
downloads?: number;
|
|
capabilityTags?: string[];
|
|
}) {
|
|
return {
|
|
_id: params.id,
|
|
_creationTime: 1,
|
|
slug: params.slug,
|
|
displayName: params.displayName,
|
|
summary: `${params.displayName} summary`,
|
|
ownerUserId: "users:owner",
|
|
canonicalSkillId: undefined,
|
|
forkOf: undefined,
|
|
latestVersionId: "skillVersions:1",
|
|
tags: {},
|
|
capabilityTags: params.capabilityTags,
|
|
badges: {},
|
|
stats: {
|
|
downloads: params.downloads ?? 0,
|
|
installsCurrent: 0,
|
|
installsAllTime: 0,
|
|
stars: 0,
|
|
versions: 1,
|
|
comments: 0,
|
|
},
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
|
|
function makeSkillDoc(params: {
|
|
id: string;
|
|
slug: string;
|
|
displayName: string;
|
|
moderationFlags?: string[];
|
|
moderationReason?: string;
|
|
softDeletedAt?: number;
|
|
}) {
|
|
return {
|
|
...makePublicSkill(params),
|
|
_creationTime: 1,
|
|
moderationStatus: "active",
|
|
moderationFlags: params.moderationFlags ?? [],
|
|
moderationReason: params.moderationReason,
|
|
softDeletedAt: params.softDeletedAt as number | undefined,
|
|
};
|
|
}
|
|
|
|
function makeLexicalCtx(params: {
|
|
exactSlugSkill: ReturnType<typeof makeSkillDoc> | null;
|
|
recentSkills: Array<ReturnType<typeof makeSkillDoc>>;
|
|
}) {
|
|
// Convert skill docs to digest-shaped rows (add skillId + owner fields).
|
|
const digestRows = params.recentSkills.map((skill) => ({
|
|
...skill,
|
|
skillId: skill._id,
|
|
ownerHandle: "owner",
|
|
ownerName: "Owner",
|
|
ownerDisplayName: "Owner",
|
|
ownerImage: undefined,
|
|
}));
|
|
return {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: (index: string) => {
|
|
if (index === "by_slug") {
|
|
return {
|
|
unique: vi.fn().mockResolvedValue(params.exactSlugSkill),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected skills index ${index}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "skillSearchDigest") {
|
|
return {
|
|
withIndex: (index: string) => {
|
|
if (index === "by_active_updated") {
|
|
return {
|
|
order: () => ({
|
|
take: vi.fn().mockResolvedValue(digestRows),
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected digest index ${index}`);
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}),
|
|
get: vi.fn(async (id: string) => {
|
|
if (id.startsWith("users:")) return { _id: id, handle: "owner" };
|
|
if (id.startsWith("skillVersions:")) return { _id: id, version: "1.0.0" };
|
|
return null;
|
|
}),
|
|
},
|
|
};
|
|
}
|