mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
feat: move topic discovery into search and category browse (#2732)
* feat: search categories and topics * fix: remove topic filters from browse sidebars * feat: show top topics for selected categories * fix(deps): resolve static audit advisories * fix(search): scope skill recall by category * fix(search): keep scoped recall within Convex limits * fix(search): avoid Convex pagination fan-out
This commit is contained in:
@@ -150,7 +150,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"ast-v8-to-istanbul": "1.0.4",
|
||||
"dompurify": "3.4.10",
|
||||
"dompurify": "3.4.11",
|
||||
"esbuild": "0.28.1",
|
||||
"next": "16.2.6",
|
||||
"postcss": "8.5.12",
|
||||
@@ -1158,7 +1158,7 @@
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="],
|
||||
"dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
|
||||
Vendored
+2
@@ -12,6 +12,7 @@ import type * as appMeta from "../appMeta.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as catalogClassification from "../catalogClassification.js";
|
||||
import type * as catalogClassificationNode from "../catalogClassificationNode.js";
|
||||
import type * as catalogTopics from "../catalogTopics.js";
|
||||
import type * as cliDeviceAuth from "../cliDeviceAuth.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as depRegistryScan from "../depRegistryScan.js";
|
||||
@@ -161,6 +162,7 @@ declare const fullApi: ApiFromModules<{
|
||||
auth: typeof auth;
|
||||
catalogClassification: typeof catalogClassification;
|
||||
catalogClassificationNode: typeof catalogClassificationNode;
|
||||
catalogTopics: typeof catalogTopics;
|
||||
cliDeviceAuth: typeof cliDeviceAuth;
|
||||
crons: typeof crons;
|
||||
depRegistryScan: typeof depRegistryScan;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listTopByCategory, rankTopCatalogTopics } from "./catalogTopics";
|
||||
|
||||
type WrappedHandler<TArgs, TResult> = {
|
||||
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
||||
};
|
||||
|
||||
const listTopByCategoryHandler = (
|
||||
listTopByCategory as unknown as WrappedHandler<
|
||||
{ kind: "skill" | "plugin"; category: string },
|
||||
string[]
|
||||
>
|
||||
)._handler;
|
||||
|
||||
function makeQueryCtx(rowsByTable: Record<string, Array<Record<string, unknown>>>) {
|
||||
const indexNames: string[] = [];
|
||||
const filters: Array<{ field: string; value: unknown }> = [];
|
||||
|
||||
return {
|
||||
indexNames,
|
||||
filters,
|
||||
db: {
|
||||
query: vi.fn((table: string) => ({
|
||||
withIndex: vi.fn(
|
||||
(
|
||||
indexName: string,
|
||||
build: (query: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
indexNames.push(indexName);
|
||||
const query = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
filters.push({ field, value });
|
||||
return query;
|
||||
},
|
||||
};
|
||||
build(query);
|
||||
return {
|
||||
order: vi.fn(() => ({
|
||||
take: vi.fn(async (limit: number) => (rowsByTable[table] ?? []).slice(0, limit)),
|
||||
})),
|
||||
};
|
||||
},
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSkillDigest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
skillId: "skills:demo",
|
||||
slug: "demo",
|
||||
displayName: "Demo",
|
||||
ownerUserId: "users:owner",
|
||||
forkOf: undefined,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
moderationStatus: "active",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePluginCategoryDigest(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
packageId: "packages:demo",
|
||||
name: "demo",
|
||||
normalizedName: "demo",
|
||||
displayName: "Demo",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
pluginCategory: "runtime",
|
||||
scanStatus: "clean",
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("catalog topic ranking", () => {
|
||||
it("returns the five most frequent normalized topics and excludes the selected category", () => {
|
||||
expect(
|
||||
rankTopCatalogTopics(
|
||||
[
|
||||
{ topics: ["TypeScript", "Development", "Docker"] },
|
||||
{ topics: ["typescript", "GitHub", "Debugging"] },
|
||||
{ topics: ["docker", "typescript", "Coding"] },
|
||||
{ topics: ["Automation", "GitHub"] },
|
||||
],
|
||||
"development",
|
||||
),
|
||||
).toEqual(["typescript", "docker", "github", "debugging", "coding"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listTopByCategory", () => {
|
||||
it("returns top public skill topics from the selected category", async () => {
|
||||
const ctx = makeQueryCtx({
|
||||
skillSearchDigest: [
|
||||
makeSkillDigest({
|
||||
categories: ["development"],
|
||||
topics: ["TypeScript", "Docker"],
|
||||
}),
|
||||
makeSkillDigest({
|
||||
skillId: "skills:second",
|
||||
slug: "second",
|
||||
categories: ["development"],
|
||||
topics: ["typescript", "GitHub"],
|
||||
}),
|
||||
makeSkillDigest({
|
||||
skillId: "skills:other",
|
||||
slug: "other",
|
||||
categories: ["productivity"],
|
||||
topics: ["notes"],
|
||||
}),
|
||||
makeSkillDigest({
|
||||
skillId: "skills:blocked",
|
||||
slug: "blocked",
|
||||
categories: ["development"],
|
||||
topics: ["malware"],
|
||||
moderationStatus: "rejected",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
listTopByCategoryHandler(ctx, { kind: "skill", category: "development" }),
|
||||
).resolves.toEqual(["typescript", "docker", "github"]);
|
||||
expect(ctx.indexNames).toEqual(["by_active_recommended_score"]);
|
||||
expect(ctx.filters).toContainEqual({ field: "softDeletedAt", value: undefined });
|
||||
});
|
||||
|
||||
it("collects a category sample beyond the first global topic sample", async () => {
|
||||
const globallyHigherRanked = Array.from({ length: 240 }, (_, index) =>
|
||||
makeSkillDigest({
|
||||
skillId: `skills:global-${index}`,
|
||||
slug: `global-${index}`,
|
||||
categories: ["productivity"],
|
||||
topics: ["notes"],
|
||||
}),
|
||||
);
|
||||
const ctx = makeQueryCtx({
|
||||
skillSearchDigest: [
|
||||
...globallyHigherRanked,
|
||||
makeSkillDigest({
|
||||
skillId: "skills:development",
|
||||
slug: "development",
|
||||
categories: ["development"],
|
||||
topics: ["TypeScript", "Docker"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
listTopByCategoryHandler(ctx, { kind: "skill", category: "development" }),
|
||||
).resolves.toEqual(["typescript", "docker"]);
|
||||
});
|
||||
|
||||
it("uses the plugin category index and excludes private or blocked plugins", async () => {
|
||||
const ctx = makeQueryCtx({
|
||||
packagePluginCategorySearchDigest: [
|
||||
makePluginCategoryDigest({ topics: ["Docker", "TypeScript"] }),
|
||||
makePluginCategoryDigest({
|
||||
packageId: "packages:second",
|
||||
name: "second",
|
||||
normalizedName: "second",
|
||||
topics: ["docker", "GitHub"],
|
||||
}),
|
||||
makePluginCategoryDigest({
|
||||
packageId: "packages:private",
|
||||
name: "private",
|
||||
normalizedName: "private",
|
||||
topics: ["secret"],
|
||||
channel: "private",
|
||||
}),
|
||||
makePluginCategoryDigest({
|
||||
packageId: "packages:blocked",
|
||||
name: "blocked",
|
||||
normalizedName: "blocked",
|
||||
topics: ["malware"],
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
listTopByCategoryHandler(ctx, { kind: "plugin", category: "runtime" }),
|
||||
).resolves.toEqual(["docker", "typescript", "github"]);
|
||||
expect(ctx.indexNames).toEqual(["by_active_category_installs"]);
|
||||
expect(ctx.filters).toContainEqual({ field: "pluginCategory", value: "runtime" });
|
||||
});
|
||||
|
||||
it("rejects categories that do not belong to the requested catalog kind", async () => {
|
||||
const ctx = makeQueryCtx({});
|
||||
|
||||
await expect(
|
||||
listTopByCategoryHandler(ctx, { kind: "skill", category: "runtime" }),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
listTopByCategoryHandler(ctx, { kind: "plugin", category: "development" }),
|
||||
).resolves.toEqual([]);
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
getCatalogTopicSlugs,
|
||||
isPluginCategorySlug,
|
||||
isSkillCategorySlug,
|
||||
resolveStoredSkillCategories,
|
||||
} from "clawhub-schema";
|
||||
import { v } from "convex/values";
|
||||
import type { Doc } from "./_generated/dataModel";
|
||||
import type { QueryCtx } from "./_generated/server";
|
||||
import { query } from "./functions";
|
||||
import { isPublicPluginDoc, isPublicSkillDoc } from "./lib/globalStats";
|
||||
|
||||
const TOP_CATEGORY_TOPIC_LIMIT = 5;
|
||||
const TOP_CATEGORY_TOPIC_SAMPLE_LIMIT = 240;
|
||||
const TOP_SKILL_CATEGORY_TOPIC_SCAN_LIMIT = TOP_CATEGORY_TOPIC_SAMPLE_LIMIT * 10;
|
||||
|
||||
type CatalogTopicSource = {
|
||||
topics?: readonly string[] | null;
|
||||
};
|
||||
|
||||
export function rankTopCatalogTopics(
|
||||
sources: readonly CatalogTopicSource[],
|
||||
selectedCategory: string,
|
||||
limit = TOP_CATEGORY_TOPIC_LIMIT,
|
||||
) {
|
||||
const counts = new Map<string, { count: number; firstSeen: number }>();
|
||||
let firstSeen = 0;
|
||||
|
||||
for (const source of sources) {
|
||||
for (const topic of getCatalogTopicSlugs(source.topics)) {
|
||||
if (topic === selectedCategory) continue;
|
||||
const existing = counts.get(topic);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
counts.set(topic, { count: 1, firstSeen });
|
||||
firstSeen += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.sort(
|
||||
([leftTopic, left], [rightTopic, right]) =>
|
||||
right.count - left.count ||
|
||||
left.firstSeen - right.firstSeen ||
|
||||
leftTopic.localeCompare(rightTopic),
|
||||
)
|
||||
.slice(0, Math.max(0, limit))
|
||||
.map(([topic]) => topic);
|
||||
}
|
||||
|
||||
async function listTopSkillTopics(ctx: QueryCtx, category: string) {
|
||||
if (!isSkillCategorySlug(category)) return [];
|
||||
const digests = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_recommended_score", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc")
|
||||
.take(TOP_SKILL_CATEGORY_TOPIC_SCAN_LIMIT);
|
||||
const matching: Doc<"skillSearchDigest">[] = [];
|
||||
// Skill categories are multi-valued, so collect a bounded category sample from the ranked digest.
|
||||
for (const digest of digests) {
|
||||
if (!isPublicSkillDoc(digest)) continue;
|
||||
if (!resolveStoredSkillCategories(digest).includes(category)) continue;
|
||||
matching.push(digest);
|
||||
if (matching.length >= TOP_CATEGORY_TOPIC_SAMPLE_LIMIT) break;
|
||||
}
|
||||
return rankTopCatalogTopics(matching, category);
|
||||
}
|
||||
|
||||
async function listTopPluginTopics(ctx: QueryCtx, category: string) {
|
||||
if (!isPluginCategorySlug(category)) return [];
|
||||
const digests: Doc<"packagePluginCategorySearchDigest">[] = await ctx.db
|
||||
.query("packagePluginCategorySearchDigest")
|
||||
.withIndex("by_active_category_installs", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("pluginCategory", category),
|
||||
)
|
||||
.order("desc")
|
||||
.take(TOP_CATEGORY_TOPIC_SAMPLE_LIMIT);
|
||||
return rankTopCatalogTopics(digests.filter(isPublicPluginDoc), category);
|
||||
}
|
||||
|
||||
export const listTopByCategory = query({
|
||||
args: {
|
||||
kind: v.union(v.literal("skill"), v.literal("plugin")),
|
||||
category: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return args.kind === "skill"
|
||||
? await listTopSkillTopics(ctx, args.category)
|
||||
: await listTopPluginTopics(ctx, args.category);
|
||||
},
|
||||
});
|
||||
+194
-13
@@ -1425,7 +1425,57 @@ function makeDigestCtx(options: {
|
||||
}
|
||||
tableNames.push(table);
|
||||
return {
|
||||
withIndex: (indexName: string) => withIndex(table, indexName),
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
builder?: (q: {
|
||||
eq: (field: string, value: unknown) => unknown;
|
||||
gte: (field: string, value: string) => unknown;
|
||||
lt: (field: string, value: string) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
if (table !== "packageTopicSearchDigest" || indexName !== "by_active_topic_updated") {
|
||||
return withIndex(table, indexName);
|
||||
}
|
||||
let exactTopic = "";
|
||||
let lowerBound = "";
|
||||
let upperBound = "";
|
||||
const queryBuilder = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
if (field === "topic" && typeof value === "string") exactTopic = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
gte: (field: string, value: string) => {
|
||||
if (field === "topic") lowerBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
lt: (field: string, value: string) => {
|
||||
if (field === "topic") upperBound = value;
|
||||
return queryBuilder;
|
||||
},
|
||||
};
|
||||
builder?.(queryBuilder);
|
||||
const baseQuery = withIndex(table, indexName);
|
||||
return {
|
||||
...baseQuery,
|
||||
order: () => {
|
||||
const ordered = baseQuery.order();
|
||||
return {
|
||||
...ordered,
|
||||
take: async (limit: number) => {
|
||||
take(limit);
|
||||
const rows = rowsByTable.get(table) ?? [];
|
||||
return rows
|
||||
.filter((row) => {
|
||||
const rowTopic = typeof row.topic === "string" ? row.topic : "";
|
||||
if (exactTopic) return rowTopic === exactTopic;
|
||||
return rowTopic >= lowerBound && rowTopic < upperBound;
|
||||
})
|
||||
.slice(0, limit);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
@@ -3283,11 +3333,124 @@ describe("packages public queries", () => {
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["needle-plugin"]);
|
||||
expect(paginate).not.toHaveBeenCalled();
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(take).toHaveBeenCalledWith(20);
|
||||
expect(take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it("recalls plugins whose category matches the query", async () => {
|
||||
const { ctx, tableNames } = makeDigestCtx({
|
||||
categoryPages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("focused-helper", {
|
||||
displayName: "Focused Helper",
|
||||
summary: "Keeps projects tidy.",
|
||||
pluginCategory: "runtime",
|
||||
pluginCategoryTags: ["runtime"],
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "runtime",
|
||||
family: "code-plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
|
||||
expect(tableNames).toContain("packagePluginCategorySearchDigest");
|
||||
});
|
||||
|
||||
it("does not use the fallback other category as search evidence", async () => {
|
||||
const { ctx, tableNames } = makeDigestCtx({
|
||||
categoryPages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("focused-helper", {
|
||||
displayName: "Focused Helper",
|
||||
summary: "Keeps projects tidy.",
|
||||
pluginCategory: "other",
|
||||
pluginCategoryTags: ["other"],
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "other",
|
||||
family: "code-plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(tableNames).not.toContain("packagePluginCategorySearchDigest");
|
||||
});
|
||||
|
||||
it("uses partial author topics as plugin search evidence", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
topicPages: [
|
||||
{
|
||||
page: [
|
||||
makeDigest("focused-helper", {
|
||||
displayName: "Focused Helper",
|
||||
summary: "Keeps projects tidy.",
|
||||
topic: "gpu-development",
|
||||
topics: ["GPU development"],
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "gpu",
|
||||
family: "code-plugin",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
|
||||
});
|
||||
|
||||
it("prioritizes exact plugin topics ahead of bounded prefix recall", async () => {
|
||||
const prefixTopics = Array.from({ length: 100 }, (_, index) =>
|
||||
makeDigest(`react-prefix-${index}`, {
|
||||
topic: `react-${index}`,
|
||||
topics: [`React ${index}`],
|
||||
}),
|
||||
);
|
||||
const exactTopic = makeDigest("react-exact", {
|
||||
topic: "react",
|
||||
topics: ["React"],
|
||||
});
|
||||
const { ctx } = makeDigestCtx({
|
||||
topicPages: [
|
||||
{
|
||||
page: [...prefixTopics, exactTopic],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchPublicHandler(ctx, {
|
||||
query: "react",
|
||||
family: "code-plugin",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["react-exact"]);
|
||||
});
|
||||
|
||||
it("does not let official status make unrelated packages eligible for search", async () => {
|
||||
const { ctx } = makeDigestCtx({
|
||||
pages: [
|
||||
@@ -3496,9 +3659,15 @@ describe("packages public queries", () => {
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["tools-demo"]);
|
||||
expect(new Set(tableNames)).toEqual(
|
||||
new Set(["packageSearchDigest", "packageTopicSearchDigest"]),
|
||||
new Set([
|
||||
"packageSearchDigest",
|
||||
"packageTopicSearchDigest",
|
||||
"packagePluginCategorySearchDigest",
|
||||
]),
|
||||
);
|
||||
expect(new Set(indexNames)).toEqual(
|
||||
new Set(["by_active_topic_updated", "by_active_category_updated", "by_active_updated"]),
|
||||
);
|
||||
expect(new Set(indexNames)).toEqual(new Set(["by_active_topic_updated", "by_active_updated"]));
|
||||
});
|
||||
|
||||
it("uses plugin category digests for category-filtered listings", async () => {
|
||||
@@ -4234,7 +4403,7 @@ describe("packages public queries", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(paginate).not.toHaveBeenCalled();
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(take).toHaveBeenCalledWith(20);
|
||||
expect(take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
@@ -4260,7 +4429,7 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-plugin"]);
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
@@ -4287,7 +4456,7 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["runtime-demo"]);
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
@@ -4312,7 +4481,7 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-prefix"]);
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.db.query).toHaveBeenCalledWith("packageSearchDigest");
|
||||
});
|
||||
|
||||
@@ -4367,7 +4536,7 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["demo-alpha", "demo-beta"]);
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(take).toHaveBeenCalledWith(20);
|
||||
expect(take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
@@ -4465,7 +4634,7 @@ describe("packages public queries", () => {
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(paginate).not.toHaveBeenCalled();
|
||||
expect(take).toHaveBeenCalledTimes(2);
|
||||
expect(take).toHaveBeenCalledTimes(3);
|
||||
expect(take).toHaveBeenCalledWith(20);
|
||||
expect(take).toHaveBeenCalledWith(200);
|
||||
});
|
||||
@@ -4488,7 +4657,11 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["official-demo"]);
|
||||
expect(indexNames).toEqual(["by_active_topic_updated", "by_active_official_updated"]);
|
||||
expect(indexNames).toEqual([
|
||||
"by_active_topic_updated",
|
||||
"by_active_topic_updated",
|
||||
"by_active_official_updated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the channel index for no-family channel search filters", async () => {
|
||||
@@ -4509,7 +4682,11 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["community-demo"]);
|
||||
expect(indexNames).toEqual(["by_active_topic_updated", "by_active_channel_updated"]);
|
||||
expect(indexNames).toEqual([
|
||||
"by_active_topic_updated",
|
||||
"by_active_topic_updated",
|
||||
"by_active_channel_updated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the combined channel and official index when both filters are set", async () => {
|
||||
@@ -4536,7 +4713,11 @@ describe("packages public queries", () => {
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["official-community-demo"]);
|
||||
expect(indexNames).toEqual(["by_active_topic_updated", "by_active_channel_official_updated"]);
|
||||
expect(indexNames).toEqual([
|
||||
"by_active_topic_updated",
|
||||
"by_active_topic_updated",
|
||||
"by_active_channel_official_updated",
|
||||
]);
|
||||
});
|
||||
|
||||
it("blocks anonymous reads of private packages", async () => {
|
||||
|
||||
+50
-4
@@ -3,6 +3,7 @@ import {
|
||||
derivePluginCategoryTags,
|
||||
getCatalogTopicSlugs,
|
||||
getPackageScopeOwnerMismatch,
|
||||
INTERNAL_UNCATEGORIZED_CATEGORY,
|
||||
isPluginCategorySlug,
|
||||
normalizeCatalogTopic,
|
||||
normalizeCatalogTopics,
|
||||
@@ -1536,10 +1537,23 @@ function packageSearchMatch(
|
||||
setMatch(1, 35);
|
||||
}
|
||||
|
||||
const topicQuery = normalizeCatalogTopic(queryText);
|
||||
if (topicQuery && getCatalogTopicSlugs(digest.topics).includes(topicQuery)) {
|
||||
const taxonomyQuery = normalizeCatalogTopic(queryText);
|
||||
const categories = (digest.pluginCategoryTags ?? []).filter(
|
||||
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
|
||||
);
|
||||
const topicSlugs = getCatalogTopicSlugs(digest.topics);
|
||||
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
|
||||
setMatch(2, 25);
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
[...categories, ...(digest.topics ?? [])],
|
||||
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
setMatch(2, 20);
|
||||
}
|
||||
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
@@ -1594,9 +1608,13 @@ async function resolveDirectPackageSearchDigests(
|
||||
): Promise<PackageDigestLike[]> {
|
||||
const normalizedQuery = maybeNormalizePackageQuery(queryText);
|
||||
const topicQuery = normalizeCatalogTopic(queryText);
|
||||
const categoryQuery =
|
||||
topicQuery !== INTERNAL_UNCATEGORIZED_CATEGORY && isPluginCategorySlug(topicQuery)
|
||||
? topicQuery
|
||||
: undefined;
|
||||
const queryTokens = tokenize(queryText).filter((token) => token.length > 1);
|
||||
const runtimePrefix = queryTokens.length === 1 ? queryTokens[0] : queryText;
|
||||
const [nameDigests, runtimeDigests, topicDigests] = await Promise.all([
|
||||
const [nameDigests, runtimeDigests, exactTopicDigests, categoryDigests] = await Promise.all([
|
||||
normalizedQuery
|
||||
? ctx.db
|
||||
.query("packageSearchDigest")
|
||||
@@ -1628,8 +1646,36 @@ async function resolveDirectPackageSearchDigests(
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
||||
: Promise.resolve([]),
|
||||
categoryQuery
|
||||
? ctx.db
|
||||
.query("packagePluginCategorySearchDigest")
|
||||
.withIndex("by_active_category_updated", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("pluginCategory", categoryQuery),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return [...nameDigests, ...runtimeDigests, ...topicDigests].filter(
|
||||
const prefixTopicDigests =
|
||||
topicQuery && exactTopicDigests.length < MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES
|
||||
? await ctx.db
|
||||
.query("packageTopicSearchDigest")
|
||||
.withIndex("by_active_topic_updated", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("topic", topicQuery)
|
||||
.lt("topic", prefixUpperBound(topicQuery)),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_PACKAGE_SEARCH_CANDIDATES - exactTopicDigests.length)
|
||||
: [];
|
||||
return [
|
||||
...nameDigests,
|
||||
...runtimeDigests,
|
||||
...exactTopicDigests,
|
||||
...prefixTopicDigests,
|
||||
...categoryDigests,
|
||||
].filter(
|
||||
(digest, index, all) =>
|
||||
all.findIndex((candidate) => candidate?.packageId === digest?.packageId) === index,
|
||||
) as PackageDigestLike[];
|
||||
|
||||
+528
-35
@@ -165,6 +165,89 @@ describe("search helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes normalized selected categories through every skill recall path", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
const development = {
|
||||
embeddingId: "skillEmbeddings:development",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:development",
|
||||
slug: "development-helper",
|
||||
displayName: "Development Helper",
|
||||
categories: ["development"],
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
};
|
||||
const automation = {
|
||||
embeddingId: "skillEmbeddings:automation",
|
||||
skill: makePublicSkill({
|
||||
id: "skills:automation",
|
||||
slug: "automation-helper",
|
||||
displayName: "Automation Helper",
|
||||
categories: ["automation"],
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "owner",
|
||||
owner: null,
|
||||
};
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce([development, automation]) // directPrefixSkillMatches
|
||||
.mockResolvedValueOnce([development, automation]) // hydrateResults
|
||||
.mockResolvedValueOnce([development, automation]); // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn().mockResolvedValue([
|
||||
{ _id: "skillEmbeddings:development", _score: 0.8 },
|
||||
{ _id: "skillEmbeddings:automation", _score: 0.9 },
|
||||
]),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "helper", categorySlug: "Development", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-helper"]);
|
||||
for (const [, args] of runQuery.mock.calls) {
|
||||
expect(args).toEqual(expect.objectContaining({ categorySlug: "development" }));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses stored categories as skill search evidence", async () => {
|
||||
generateEmbeddingMock.mockRejectedValueOnce(new Error("API unavailable"));
|
||||
const fallback = [
|
||||
{
|
||||
skill: makePublicSkill({
|
||||
id: "skills:category-match",
|
||||
slug: "focused-helper",
|
||||
displayName: "Focused Helper",
|
||||
summary: "Keeps projects tidy.",
|
||||
categories: ["development"],
|
||||
}),
|
||||
version: null,
|
||||
ownerHandle: "steipete",
|
||||
owner: null,
|
||||
},
|
||||
];
|
||||
const runQuery = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // getExactSkillSlugMatch
|
||||
.mockResolvedValueOnce([]) // directPrefixSkillMatches
|
||||
.mockResolvedValueOnce(fallback); // lexicalFallbackSkills
|
||||
|
||||
const result = await searchSkillsHandler(
|
||||
{
|
||||
vectorSearch: vi.fn(),
|
||||
runQuery,
|
||||
},
|
||||
{ query: "dev", limit: 10 },
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["focused-helper"]);
|
||||
});
|
||||
|
||||
it("uses normalized prefix matches so lowercase name queries do not depend on vector recall", async () => {
|
||||
const scienceClawSkills = [
|
||||
"ScienceClaw: Query (Dry Run)",
|
||||
@@ -265,6 +348,49 @@ describe("search helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("recalls author topics by normalized prefix through the indexed topic digest", async () => {
|
||||
const skill = makeSkillDoc({
|
||||
id: "skills:gpu-helper",
|
||||
slug: "accelerated-helper",
|
||||
displayName: "Accelerated Helper",
|
||||
topics: ["GPU development"],
|
||||
});
|
||||
const ctx = makeDirectPrefixCtx([skill]);
|
||||
|
||||
const result = await directPrefixSkillMatchesHandler(ctx, {
|
||||
query: "gpu",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["accelerated-helper"]);
|
||||
expect(ctx.usedIndexes).toEqual(
|
||||
expect.arrayContaining(["by_active_topic_updated", "by_skill"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("prioritizes exact author-topic recall ahead of prefix expansion", async () => {
|
||||
const prefixSkill = makeSkillDoc({
|
||||
id: "skills:react-native-helper",
|
||||
slug: "mobile-helper",
|
||||
displayName: "Mobile Helper",
|
||||
topics: ["React Native"],
|
||||
});
|
||||
const exactSkill = makeSkillDoc({
|
||||
id: "skills:react-helper",
|
||||
slug: "web-helper",
|
||||
displayName: "Web Helper",
|
||||
topics: ["React"],
|
||||
});
|
||||
const ctx = makeDirectPrefixCtx([prefixSkill, exactSkill]);
|
||||
|
||||
const result = await directPrefixSkillMatchesHandler(ctx, {
|
||||
query: "react",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["web-helper", "mobile-helper"]);
|
||||
});
|
||||
|
||||
it("recalls text matches from the selected topic digest", async () => {
|
||||
const skill = makeSkillDoc({
|
||||
id: "skills:calendar-helper",
|
||||
@@ -287,6 +413,120 @@ describe("search helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("continues topic recall past globally capped rows before category filtering", async () => {
|
||||
const distractors = Array.from({ length: 100 }, (_, index) =>
|
||||
makeSkillDoc({
|
||||
id: `skills:scheduling-${index}`,
|
||||
slug: `temporal-${index}`,
|
||||
displayName: `Temporal ${index}`,
|
||||
summary: "Coordinates events.",
|
||||
categories: ["automation"],
|
||||
topics: ["scheduling"],
|
||||
}),
|
||||
);
|
||||
const development = makeSkillDoc({
|
||||
id: "skills:calendar-helper",
|
||||
slug: "temporal-helper",
|
||||
displayName: "Temporal Helper",
|
||||
summary: "Coordinates calendar events.",
|
||||
categories: ["development"],
|
||||
topics: ["scheduling"],
|
||||
});
|
||||
const ctx = makeDirectPrefixCtx([...distractors, development]);
|
||||
|
||||
const result = await directPrefixSkillMatchesHandler(ctx, {
|
||||
query: "calendar",
|
||||
categorySlug: "development",
|
||||
topic: "scheduling",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["temporal-helper"]);
|
||||
});
|
||||
|
||||
it("filters direct prefix matches by the selected category", async () => {
|
||||
const development = makeSkillDoc({
|
||||
id: "skills:development-helper",
|
||||
slug: "development-helper",
|
||||
displayName: "Development Helper",
|
||||
categories: ["development"],
|
||||
});
|
||||
const automation = makeSkillDoc({
|
||||
id: "skills:automation-helper",
|
||||
slug: "automation-helper",
|
||||
displayName: "Automation Helper",
|
||||
categories: ["automation"],
|
||||
});
|
||||
const ctx = makeDirectPrefixCtx([development, automation]);
|
||||
|
||||
const result = await directPrefixSkillMatchesHandler(ctx, {
|
||||
query: "helper",
|
||||
categorySlug: "development",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-helper"]);
|
||||
});
|
||||
|
||||
it("continues direct recall past globally capped matches for the selected category", async () => {
|
||||
const distractors = Array.from({ length: 150 }, (_, index) =>
|
||||
makeSkillDoc({
|
||||
id: `skills:automation-${index}`,
|
||||
slug: `helper-automation-${index}`,
|
||||
displayName: `Helper Automation ${index}`,
|
||||
categories: ["automation"],
|
||||
}),
|
||||
);
|
||||
const development = makeSkillDoc({
|
||||
id: "skills:development-helper",
|
||||
slug: "helper-development",
|
||||
displayName: "Helper Development",
|
||||
categories: ["development"],
|
||||
});
|
||||
const ctx = makeDirectPrefixCtx([...distractors, development]);
|
||||
|
||||
const result = await directPrefixSkillMatchesHandler(ctx, {
|
||||
query: "helper",
|
||||
categorySlug: "development",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["helper-development"]);
|
||||
expect(ctx.paginateCalls).toBe(0);
|
||||
expect(Math.max(...ctx.takeLimits)).toBeLessThanOrEqual(250);
|
||||
expect(ctx.takeLimits.reduce((total, limit) => total + limit, 0)).toBeLessThanOrEqual(2_500);
|
||||
});
|
||||
|
||||
it("does not let unhighlighted scoped matches consume featured recall", async () => {
|
||||
const distractors = Array.from({ length: 150 }, (_, index) =>
|
||||
makeSkillDoc({
|
||||
id: `skills:development-${index}`,
|
||||
slug: `helper-development-${index}`,
|
||||
displayName: `Helper Development ${index}`,
|
||||
categories: ["development"],
|
||||
}),
|
||||
);
|
||||
const highlighted = {
|
||||
...makeSkillDoc({
|
||||
id: "skills:highlighted-development",
|
||||
slug: "helper-highlighted-development",
|
||||
displayName: "Helper Highlighted Development",
|
||||
categories: ["development"],
|
||||
}),
|
||||
badges: { highlighted: { byUserId: "users:mod", at: 1 } },
|
||||
};
|
||||
const ctx = makeDirectPrefixCtx([...distractors, highlighted]);
|
||||
|
||||
const result = await directPrefixSkillMatchesHandler(ctx, {
|
||||
query: "helper",
|
||||
categorySlug: "development",
|
||||
highlightedOnly: true,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["helper-highlighted-development"]);
|
||||
});
|
||||
|
||||
it("does not return suspicious skills via full-text search when nonSuspiciousOnly is set", async () => {
|
||||
// Even though the full-text search would token-match the suspicious
|
||||
// skill, the filterField `isSuspicious=false` plus the post-hydration
|
||||
@@ -584,6 +824,59 @@ describe("search helpers", () => {
|
||||
expect(result.map((entry) => entry.skill._id)).toEqual(["skills:alice-demo"]);
|
||||
});
|
||||
|
||||
it("filters duplicate exact slug matches by category", async () => {
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkills: [
|
||||
makeSkillDoc({
|
||||
id: "skills:development-demo",
|
||||
slug: "demo",
|
||||
displayName: "Development Demo",
|
||||
ownerPublisherId: "publishers:development",
|
||||
categories: ["development"],
|
||||
}),
|
||||
makeSkillDoc({
|
||||
id: "skills:automation-demo",
|
||||
slug: "demo",
|
||||
displayName: "Automation Demo",
|
||||
ownerPublisherId: "publishers:automation",
|
||||
categories: ["automation"],
|
||||
}),
|
||||
],
|
||||
recentSkills: [],
|
||||
});
|
||||
|
||||
const result = await getExactSkillSlugMatchHandler(ctx, {
|
||||
slug: "demo",
|
||||
categorySlug: "development",
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill._id)).toEqual(["skills:development-demo"]);
|
||||
});
|
||||
|
||||
it("preserves resolved inferred categories on exact slug results", async () => {
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkills: [
|
||||
makeSkillDoc({
|
||||
id: "skills:development-demo",
|
||||
slug: "demo",
|
||||
displayName: "Development Demo",
|
||||
inferredCategories: ["development"],
|
||||
inferredFromVersionId: "skillVersions:1",
|
||||
}),
|
||||
],
|
||||
recentSkills: [],
|
||||
});
|
||||
|
||||
const result = await getExactSkillSlugMatchHandler(ctx, {
|
||||
slug: "demo",
|
||||
categorySlug: "development",
|
||||
});
|
||||
|
||||
const [entry] = result;
|
||||
if (!entry) throw new Error("Expected an exact slug result");
|
||||
expect((entry.skill as { categories?: string[] }).categories).toEqual(["development"]);
|
||||
});
|
||||
|
||||
it("includes duplicate exact slug matches from by_slug when recent scan is empty", async () => {
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkills: [
|
||||
@@ -646,6 +939,100 @@ describe("search helpers", () => {
|
||||
expect(result.map((entry) => entry.skill._id)).toEqual(["skills:alice-demo"]);
|
||||
});
|
||||
|
||||
it("filters lexical fallback matches by the selected category", async () => {
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkill: null,
|
||||
recentSkills: [
|
||||
makeSkillDoc({
|
||||
id: "skills:development",
|
||||
slug: "development-helper",
|
||||
displayName: "Development Helper",
|
||||
categories: ["development"],
|
||||
}),
|
||||
makeSkillDoc({
|
||||
id: "skills:automation",
|
||||
slug: "automation-helper",
|
||||
displayName: "Automation Helper",
|
||||
categories: ["automation"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(ctx, {
|
||||
query: "helper",
|
||||
queryTokens: ["helper"],
|
||||
categorySlug: "development",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-helper"]);
|
||||
});
|
||||
|
||||
it("continues fallback recall past global rows for the selected category", async () => {
|
||||
const distractors = Array.from({ length: 25 }, (_, index) =>
|
||||
makeSkillDoc({
|
||||
id: `skills:automation-${index}`,
|
||||
slug: `automation-${index}`,
|
||||
displayName: `Automation ${index}`,
|
||||
summary: "Helper workflow",
|
||||
categories: ["automation"],
|
||||
}),
|
||||
);
|
||||
const development = makeSkillDoc({
|
||||
id: "skills:development",
|
||||
slug: "development-tool",
|
||||
displayName: "Development Tool",
|
||||
summary: "Helper workflow",
|
||||
categories: ["development"],
|
||||
});
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkill: null,
|
||||
recentSkills: [...distractors, development],
|
||||
});
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(ctx, {
|
||||
query: "helper",
|
||||
queryTokens: ["helper"],
|
||||
categorySlug: "development",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-tool"]);
|
||||
expect(ctx.paginateCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("does not let unrelated scoped rows consume fallback recall", async () => {
|
||||
const distractors = Array.from({ length: 25 }, (_, index) =>
|
||||
makeSkillDoc({
|
||||
id: `skills:development-${index}`,
|
||||
slug: `development-${index}`,
|
||||
displayName: `Development ${index}`,
|
||||
summary: "Unrelated workflow",
|
||||
categories: ["development"],
|
||||
}),
|
||||
);
|
||||
const target = makeSkillDoc({
|
||||
id: "skills:development-target",
|
||||
slug: "development-target",
|
||||
displayName: "Development Target",
|
||||
summary: "Helper workflow",
|
||||
categories: ["development"],
|
||||
});
|
||||
const ctx = makeLexicalCtx({
|
||||
exactSlugSkill: null,
|
||||
recentSkills: [...distractors, target],
|
||||
});
|
||||
|
||||
const result = await lexicalFallbackSkillsHandler(ctx, {
|
||||
query: "helper",
|
||||
queryTokens: ["helper"],
|
||||
categorySlug: "development",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result.map((entry) => entry.skill.slug)).toEqual(["development-target"]);
|
||||
});
|
||||
|
||||
it("dedupes overlap and enforces rank + limit across vector and fallback", async () => {
|
||||
generateEmbeddingMock.mockResolvedValueOnce([0, 1, 2]);
|
||||
const vectorEntries = [
|
||||
@@ -1137,6 +1524,40 @@ describe("search helpers", () => {
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters vector results by the selected category", 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: "automation-helper",
|
||||
displayName: "Automation Helper",
|
||||
categories: ["automation"],
|
||||
});
|
||||
}
|
||||
if (id === "users:owner") return { _id: "users:owner", handle: "owner" };
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: () => ({ unique: vi.fn().mockResolvedValue(null) }),
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ embeddingIds: ["skillEmbeddings:1"], categorySlug: "development" },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("excludes soft-deleted skills from vector search results (#29)", async () => {
|
||||
const result = await hydrateResultsHandler(
|
||||
{
|
||||
@@ -1771,6 +2192,7 @@ function makePublicSkill(params: {
|
||||
ownerPublisherId?: string;
|
||||
installsAllTime?: number;
|
||||
stars?: number;
|
||||
categories?: string[];
|
||||
topics?: string[];
|
||||
}) {
|
||||
return {
|
||||
@@ -1785,6 +2207,7 @@ function makePublicSkill(params: {
|
||||
forkOf: undefined,
|
||||
latestVersionId: "skillVersions:1",
|
||||
tags: {},
|
||||
categories: params.categories,
|
||||
topics: params.topics,
|
||||
badges: {},
|
||||
stats: {
|
||||
@@ -1809,7 +2232,10 @@ function makeSkillDoc(params: {
|
||||
moderationFlags?: string[];
|
||||
moderationReason?: string;
|
||||
softDeletedAt?: number;
|
||||
categories?: string[];
|
||||
topics?: string[];
|
||||
inferredCategories?: string[];
|
||||
inferredFromVersionId?: string;
|
||||
}) {
|
||||
return {
|
||||
...makePublicSkill(params),
|
||||
@@ -1818,9 +2244,25 @@ function makeSkillDoc(params: {
|
||||
moderationFlags: params.moderationFlags ?? [],
|
||||
moderationReason: params.moderationReason,
|
||||
softDeletedAt: params.softDeletedAt as number | undefined,
|
||||
inferredCategories: params.inferredCategories,
|
||||
inferredFromVersionId: params.inferredFromVersionId,
|
||||
};
|
||||
}
|
||||
|
||||
function makePaginatedRows<T>(rows: T[], onPaginate?: () => void) {
|
||||
return vi.fn(async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
|
||||
onPaginate?.();
|
||||
const start = cursor ? Number(cursor) : 0;
|
||||
const page = rows.slice(start, start + numItems);
|
||||
const next = start + page.length;
|
||||
return {
|
||||
page,
|
||||
isDone: next >= rows.length,
|
||||
continueCursor: String(next),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeLexicalCtx(params: {
|
||||
exactSlugSkill?: ReturnType<typeof makeSkillDoc> | null;
|
||||
exactSlugSkills?: Array<ReturnType<typeof makeSkillDoc>>;
|
||||
@@ -1843,9 +2285,13 @@ function makeLexicalCtx(params: {
|
||||
const digestByCreated = toDigestRows(params.recentByCreated ?? []);
|
||||
const usedIndexes: string[] = [];
|
||||
const takeLimits: number[] = [];
|
||||
let paginateCalls = 0;
|
||||
return {
|
||||
usedIndexes,
|
||||
takeLimits,
|
||||
get paginateCalls() {
|
||||
return paginateCalls;
|
||||
},
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skills") {
|
||||
@@ -1878,6 +2324,9 @@ function makeLexicalCtx(params: {
|
||||
takeLimits.push(limit);
|
||||
return Promise.resolve(digestByUpdated);
|
||||
}),
|
||||
paginate: makePaginatedRows(digestByUpdated, () => {
|
||||
paginateCalls += 1;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1888,6 +2337,9 @@ function makeLexicalCtx(params: {
|
||||
takeLimits.push(limit);
|
||||
return Promise.resolve(digestByCreated);
|
||||
}),
|
||||
paginate: makePaginatedRows(digestByCreated, () => {
|
||||
paginateCalls += 1;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1942,35 +2394,62 @@ function makeDirectPrefixCtx(skills: Array<ReturnType<typeof makeSkillDoc>>) {
|
||||
}));
|
||||
const usedIndexes: string[] = [];
|
||||
const usedSearchIndexes: string[] = [];
|
||||
const takeLimits: number[] = [];
|
||||
let paginateCalls = 0;
|
||||
return {
|
||||
usedIndexes,
|
||||
usedSearchIndexes,
|
||||
takeLimits,
|
||||
get paginateCalls() {
|
||||
return paginateCalls;
|
||||
},
|
||||
db: {
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "skillTopicSearchDigest") {
|
||||
return {
|
||||
withIndex: (
|
||||
index: string,
|
||||
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
builder: (q: {
|
||||
eq: (field: string, value: unknown) => unknown;
|
||||
gte: (field: string, value: unknown) => unknown;
|
||||
lt: (field: string, value: unknown) => unknown;
|
||||
}) => unknown,
|
||||
) => {
|
||||
usedIndexes.push(index);
|
||||
let topic = "";
|
||||
let topicPrefix = "";
|
||||
const q = {
|
||||
eq: (field: string, value: unknown) => {
|
||||
if (field === "topic") topic = String(value);
|
||||
return q;
|
||||
},
|
||||
gte: (field: string, value: unknown) => {
|
||||
if (field === "topic") topicPrefix = String(value);
|
||||
return q;
|
||||
},
|
||||
lt: () => q,
|
||||
};
|
||||
builder(q);
|
||||
const rows = digestRows
|
||||
.filter((digest) =>
|
||||
digest.topics?.some((value) => {
|
||||
const topicSlug = tokenize(value).join("-");
|
||||
return topic ? topicSlug === topic : topicSlug.startsWith(topicPrefix);
|
||||
}),
|
||||
)
|
||||
.map((digest) => ({
|
||||
skillId: digest.skillId,
|
||||
topic: topic || topicPrefix,
|
||||
}));
|
||||
return {
|
||||
order: () => ({
|
||||
take: vi.fn(async () =>
|
||||
digestRows
|
||||
.filter((digest) =>
|
||||
digest.topics?.some((value) => tokenize(value).join("-") === topic),
|
||||
)
|
||||
.map((digest) => ({ skillId: digest.skillId, topic })),
|
||||
),
|
||||
take: vi.fn(async (limit: number) => {
|
||||
takeLimits.push(limit);
|
||||
return rows.slice(0, limit);
|
||||
}),
|
||||
paginate: makePaginatedRows(rows, () => {
|
||||
paginateCalls += 1;
|
||||
}),
|
||||
}),
|
||||
};
|
||||
},
|
||||
@@ -2002,17 +2481,24 @@ function makeDirectPrefixCtx(skills: Array<ReturnType<typeof makeSkillDoc>>) {
|
||||
),
|
||||
};
|
||||
}
|
||||
const rows = digestRows.filter((digest) => {
|
||||
const field = index.includes("first_token")
|
||||
? index.includes("slug")
|
||||
? "normalizedSlugFirstToken"
|
||||
: "normalizedDisplayNameFirstToken"
|
||||
: index.includes("slug")
|
||||
? "normalizedSlug"
|
||||
: "normalizedDisplayName";
|
||||
const prefix = range[field] ?? "";
|
||||
return (digest[field] ?? "").startsWith(prefix);
|
||||
});
|
||||
return {
|
||||
take: vi.fn(async () => {
|
||||
const field = index.includes("first_token")
|
||||
? index.includes("slug")
|
||||
? "normalizedSlugFirstToken"
|
||||
: "normalizedDisplayNameFirstToken"
|
||||
: index.includes("slug")
|
||||
? "normalizedSlug"
|
||||
: "normalizedDisplayName";
|
||||
const prefix = range[field] ?? "";
|
||||
return digestRows.filter((digest) => (digest[field] ?? "").startsWith(prefix));
|
||||
take: vi.fn(async (limit: number) => {
|
||||
takeLimits.push(limit);
|
||||
return rows.slice(0, limit);
|
||||
}),
|
||||
paginate: makePaginatedRows(rows, () => {
|
||||
paginateCalls += 1;
|
||||
}),
|
||||
};
|
||||
},
|
||||
@@ -2045,24 +2531,31 @@ function makeDirectPrefixCtx(skills: Array<ReturnType<typeof makeSkillDoc>>) {
|
||||
},
|
||||
};
|
||||
builder(q);
|
||||
return {
|
||||
take: vi.fn(async () => {
|
||||
const queryTokens = new Set(tokensOf(searchQuery));
|
||||
if (queryTokens.size === 0) return [];
|
||||
return digestRows.filter((digest) => {
|
||||
for (const filter of filters) {
|
||||
if ((digest as Record<string, unknown>)[filter.field] !== filter.value) {
|
||||
return false;
|
||||
const queryTokens = new Set(tokensOf(searchQuery));
|
||||
const rows =
|
||||
queryTokens.size === 0
|
||||
? []
|
||||
: digestRows.filter((digest) => {
|
||||
for (const filter of filters) {
|
||||
if ((digest as Record<string, unknown>)[filter.field] !== filter.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fieldValue =
|
||||
(digest as unknown as Record<string, string | undefined>)[searchField] ?? "";
|
||||
const fieldTokens = new Set(tokensOf(fieldValue));
|
||||
for (const token of queryTokens) {
|
||||
if (fieldTokens.has(token)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const fieldValue =
|
||||
(digest as unknown as Record<string, string | undefined>)[searchField] ?? "";
|
||||
const fieldTokens = new Set(tokensOf(fieldValue));
|
||||
for (const token of queryTokens) {
|
||||
if (fieldTokens.has(token)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return {
|
||||
take: vi.fn(async (limit: number) => {
|
||||
takeLimits.push(limit);
|
||||
return rows.slice(0, limit);
|
||||
}),
|
||||
paginate: makePaginatedRows(rows, () => {
|
||||
paginateCalls += 1;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
+372
-187
@@ -1,4 +1,11 @@
|
||||
import { getCatalogTopicSlugs, normalizeCatalogTopic } from "clawhub-schema";
|
||||
import {
|
||||
getCatalogTopicSlugs,
|
||||
INTERNAL_UNCATEGORIZED_CATEGORY,
|
||||
isSkillCategorySlug,
|
||||
normalizeCatalogTopic,
|
||||
resolveStoredSkillCategories,
|
||||
type SkillCategorySlug,
|
||||
} from "clawhub-schema";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
@@ -82,6 +89,9 @@ const MIN_STABLE_SEARCH_RECALL_LIMIT = 100;
|
||||
const MAX_DIRECT_SKILL_SEARCH_CANDIDATES = 100;
|
||||
const MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES = 40;
|
||||
const MAX_DIRECT_SKILL_TOPIC_CANDIDATES = 100;
|
||||
// Scoped direct recall fans out across up to nine indexed reads in one query.
|
||||
// Keep each source small enough that the aggregate stays below Convex read limits.
|
||||
const MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES = 250;
|
||||
const MIN_VECTOR_SEARCH_CANDIDATES = 50;
|
||||
const MAX_VECTOR_SEARCH_CANDIDATES = 128;
|
||||
const MAX_EXACT_SLUG_MATCHES = 25;
|
||||
@@ -146,7 +156,7 @@ function scoreSkillResult(
|
||||
function classifySkillMatch(
|
||||
query: string,
|
||||
queryTokens: string[],
|
||||
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "topics">,
|
||||
skill: Pick<HydratableSkill, "displayName" | "slug" | "summary" | "categories" | "topics">,
|
||||
): SearchMatch | null {
|
||||
const needle = query.toLowerCase();
|
||||
const normalizedSlugQuery = queryTokens.join("-");
|
||||
@@ -170,8 +180,21 @@ function classifySkillMatch(
|
||||
if (matchesAllTokens(queryTokens, [...slugTokens, ...displayTokens], (a, b) => a.startsWith(b))) {
|
||||
return { rankTier: 1 };
|
||||
}
|
||||
const topicQuery = normalizeCatalogTopic(query);
|
||||
if (topicQuery && getCatalogTopicSlugs(skill.topics).includes(topicQuery)) {
|
||||
const taxonomyQuery = normalizeCatalogTopic(query);
|
||||
const categories = (skill.categories ?? []).filter(
|
||||
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
|
||||
);
|
||||
const topicSlugs = getCatalogTopicSlugs(skill.topics);
|
||||
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
|
||||
return { rankTier: 2 };
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
[...categories, ...(skill.topics ?? [])],
|
||||
EXPLORATORY_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
return { rankTier: 2 };
|
||||
}
|
||||
if (
|
||||
@@ -206,6 +229,49 @@ function matchesCatalogTopic(skill: Pick<HydratableSkill, "topics">, topic: stri
|
||||
return !topic || getCatalogTopicSlugs(skill.topics).includes(topic);
|
||||
}
|
||||
|
||||
function normalizeSkillCategoryFilter(categorySlug: string | undefined) {
|
||||
if (categorySlug === undefined) return undefined;
|
||||
const normalized = categorySlug.trim().toLowerCase();
|
||||
return isSkillCategorySlug(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function matchesCatalogFilters(
|
||||
skill: Parameters<typeof resolveStoredSkillCategories>[0] & Pick<HydratableSkill, "topics">,
|
||||
categorySlug: SkillCategorySlug | undefined,
|
||||
topic: string | undefined,
|
||||
) {
|
||||
return (
|
||||
(!categorySlug || resolveStoredSkillCategories(skill).includes(categorySlug)) &&
|
||||
matchesCatalogTopic(skill, topic)
|
||||
);
|
||||
}
|
||||
|
||||
function toPublicSearchSkill(skill: HydratableSkill) {
|
||||
return toPublicSkill({
|
||||
...skill,
|
||||
categories: resolveStoredSkillCategories(skill),
|
||||
});
|
||||
}
|
||||
|
||||
type SkillDigestCandidateQuery = {
|
||||
take: (limit: number) => Promise<Doc<"skillSearchDigest">[]>;
|
||||
};
|
||||
type SkillDigestCandidateQueryFactory = () => SkillDigestCandidateQuery;
|
||||
|
||||
async function collectFilteredSkillDigestCandidates(
|
||||
createQuery: SkillDigestCandidateQueryFactory,
|
||||
opts: {
|
||||
limit: number;
|
||||
scanLimit: number;
|
||||
matches: (digest: Doc<"skillSearchDigest">) => boolean;
|
||||
},
|
||||
) {
|
||||
// Convex permits only one paginated read per query function. Use one bounded
|
||||
// take so the several recall indexes can be searched in the same transaction.
|
||||
const candidates = await createQuery().take(opts.scanLimit);
|
||||
return candidates.filter(opts.matches).slice(0, opts.limit);
|
||||
}
|
||||
|
||||
function isSlugLikeQuery(query: string) {
|
||||
// Lenient shape check used by the read path: pattern + upper length cap only.
|
||||
// The min-length floor and reserved-word blocklist are intentionally omitted
|
||||
@@ -224,11 +290,14 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
limit: v.optional(v.number()),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
categorySlug: v.optional(v.string()),
|
||||
topic: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<PublicSearchResult[]> => {
|
||||
const query = args.query.trim();
|
||||
if (!query) return [];
|
||||
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
||||
if (categorySlug === null) return [];
|
||||
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
||||
if (args.topic !== undefined && !topic) return [];
|
||||
const queryTokens = tokenize(query);
|
||||
@@ -237,6 +306,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
? ((await ctx.runQuery(internal.search.getExactSkillSlugMatch, {
|
||||
slug: query.toLowerCase(),
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
categorySlug,
|
||||
topic,
|
||||
})) as SkillSearchEntry[] | SkillSearchEntry | null)
|
||||
: [];
|
||||
@@ -251,6 +321,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
query,
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
categorySlug,
|
||||
topic,
|
||||
})) as SkillSearchEntry[];
|
||||
let vector: number[] | null;
|
||||
@@ -295,6 +366,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
const newEntries = (await ctx.runQuery(internal.search.hydrateResults, {
|
||||
embeddingIds: newEmbeddingIds,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
categorySlug,
|
||||
topic,
|
||||
})) as SkillSearchEntry[];
|
||||
hydrated = [...hydrated, ...newEntries];
|
||||
@@ -321,6 +393,7 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
entry.skill.displayName,
|
||||
entry.skill.slug,
|
||||
entry.skill.summary,
|
||||
...(entry.skill.categories ?? []),
|
||||
...(entry.skill.topics ?? []),
|
||||
]),
|
||||
);
|
||||
@@ -354,10 +427,11 @@ export const searchSkills: ReturnType<typeof action> = action({
|
||||
highlightedOnly: args.highlightedOnly,
|
||||
nonSuspiciousOnly: args.nonSuspiciousOnly,
|
||||
skipExactSlugLookup: true,
|
||||
categorySlug,
|
||||
topic,
|
||||
})) as SkillSearchEntry[]);
|
||||
const mergedMatches = mergeUniqueBySkillId(primaryMatches, fallbackMatches).filter((entry) =>
|
||||
matchesCatalogTopic(entry.skill, topic),
|
||||
matchesCatalogFilters(entry.skill, categorySlug, topic),
|
||||
);
|
||||
|
||||
const rankedMatches = mergedMatches
|
||||
@@ -399,9 +473,12 @@ export const getExactSkillSlugMatch = internalQuery({
|
||||
args: {
|
||||
slug: v.string(),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
categorySlug: v.optional(v.string()),
|
||||
topic: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
||||
if (categorySlug === null) return [];
|
||||
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
||||
if (args.topic !== undefined && !topic) return [];
|
||||
const skills = await ctx.db
|
||||
@@ -414,10 +491,10 @@ export const getExactSkillSlugMatch = internalQuery({
|
||||
skills.map(async (skill) => {
|
||||
if (skill.softDeletedAt) return null;
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
||||
if (!matchesCatalogTopic(skill, topic)) return null;
|
||||
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
|
||||
|
||||
const resolved = await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
const publicSkill = toPublicSearchSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
|
||||
const entry: SkillSearchEntry = {
|
||||
@@ -439,9 +516,12 @@ export const directPrefixSkillMatches = internalQuery({
|
||||
query: v.string(),
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
categorySlug: v.optional(v.string()),
|
||||
topic: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
||||
if (categorySlug === null) return [];
|
||||
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
||||
if (args.topic !== undefined && !topic) return [];
|
||||
const normalizedQuery = normalizeSkillSearchText(args.query);
|
||||
@@ -449,28 +529,85 @@ export const directPrefixSkillMatches = internalQuery({
|
||||
const firstToken = getFirstSearchToken(args.query);
|
||||
const queryTokens = tokenize(args.query);
|
||||
const topicQuery = normalizeCatalogTopic(args.query);
|
||||
const recallTopics = [
|
||||
const exactRecallTopics = [
|
||||
...new Set([topicQuery, topic].filter((value): value is string => !!value)),
|
||||
];
|
||||
const loadTopicRows = (recallTopic: string) =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillTopicSearchDigest")
|
||||
.withIndex("by_nonsuspicious_topic_updated", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("isSuspicious", false).eq("topic", recallTopic),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_SKILL_TOPIC_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillTopicSearchDigest")
|
||||
.withIndex("by_active_topic_updated", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("topic", recallTopic),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_SKILL_TOPIC_CANDIDATES);
|
||||
const passesAllQueryTokens = (digest: Doc<"skillSearchDigest">) =>
|
||||
queryTokens.length === 0 ||
|
||||
matchesExactTokens(queryTokens, [
|
||||
digest.displayName,
|
||||
digest.slug,
|
||||
digest.summary,
|
||||
...(digest.categories ?? []),
|
||||
...(digest.topics ?? []),
|
||||
]);
|
||||
const matchesDirectRecallFilters = (digest: Doc<"skillSearchDigest">) =>
|
||||
(!args.highlightedOnly || isSkillHighlighted(digestToHydratableSkill(digest))) &&
|
||||
passesAllQueryTokens(digest) &&
|
||||
matchesCatalogFilters(digest, categorySlug, topic);
|
||||
const needsExpandedRecall = Boolean(
|
||||
categorySlug || topic || args.highlightedOnly || queryTokens.length > 1,
|
||||
);
|
||||
const directScanLimit = (candidateLimit: number) =>
|
||||
needsExpandedRecall ? MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES : candidateLimit;
|
||||
const loadTopicDigests = async (recallTopic: string, usePrefix: boolean, limit: number) => {
|
||||
const createQuery = () =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillTopicSearchDigest")
|
||||
.withIndex("by_nonsuspicious_topic_updated", (q) =>
|
||||
usePrefix
|
||||
? q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("topic", recallTopic)
|
||||
.lt("topic", prefixUpperBound(recallTopic))
|
||||
: q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.eq("topic", recallTopic),
|
||||
)
|
||||
.order("desc")
|
||||
: ctx.db
|
||||
.query("skillTopicSearchDigest")
|
||||
.withIndex("by_active_topic_updated", (q) =>
|
||||
usePrefix
|
||||
? q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("topic", recallTopic)
|
||||
.lt("topic", prefixUpperBound(recallTopic))
|
||||
: q.eq("softDeletedAt", undefined).eq("topic", recallTopic),
|
||||
)
|
||||
.order("desc");
|
||||
const scanLimit = needsExpandedRecall ? MAX_FILTERED_DIRECT_SKILL_SCAN_CANDIDATES : limit;
|
||||
const rows = await createQuery().take(scanLimit);
|
||||
const digests = await Promise.all(
|
||||
rows.map((row) =>
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", row.skillId))
|
||||
.unique(),
|
||||
),
|
||||
);
|
||||
return digests
|
||||
.filter(
|
||||
(digest): digest is Doc<"skillSearchDigest"> =>
|
||||
digest !== null && matchesDirectRecallFilters(digest),
|
||||
)
|
||||
.slice(0, limit);
|
||||
};
|
||||
|
||||
const upperBound = prefixUpperBound(normalizedQuery);
|
||||
const firstTokenUpperBound = firstToken ? prefixUpperBound(firstToken) : null;
|
||||
const collectDirectCandidates = (
|
||||
createQuery: SkillDigestCandidateQueryFactory,
|
||||
limit: number,
|
||||
) =>
|
||||
collectFilteredSkillDigestCandidates(createQuery, {
|
||||
limit,
|
||||
scanLimit: directScanLimit(limit),
|
||||
matches: matchesDirectRecallFilters,
|
||||
});
|
||||
const [
|
||||
slugDigests,
|
||||
displayNameDigests,
|
||||
@@ -478,153 +615,164 @@ export const directPrefixSkillMatches = internalQuery({
|
||||
displayNameFirstTokenDigests,
|
||||
ftDisplayNameDigests,
|
||||
ftSlugDigests,
|
||||
topicRowPages,
|
||||
exactTopicDigestPages,
|
||||
] = await Promise.all([
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_slug", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedSlug", normalizedQuery)
|
||||
.lt("normalizedSlug", upperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_slug", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedSlug", normalizedQuery)
|
||||
.lt("normalizedSlug", upperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES),
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_display_name", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedDisplayName", normalizedQuery)
|
||||
.lt("normalizedDisplayName", upperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_display_name", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedDisplayName", normalizedQuery)
|
||||
.lt("normalizedDisplayName", upperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES),
|
||||
collectDirectCandidates(
|
||||
() =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_slug", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedSlug", normalizedQuery)
|
||||
.lt("normalizedSlug", upperBound),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_slug", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedSlug", normalizedQuery)
|
||||
.lt("normalizedSlug", upperBound),
|
||||
),
|
||||
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
||||
),
|
||||
collectDirectCandidates(
|
||||
() =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_display_name", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedDisplayName", normalizedQuery)
|
||||
.lt("normalizedDisplayName", upperBound),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_display_name", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedDisplayName", normalizedQuery)
|
||||
.lt("normalizedDisplayName", upperBound),
|
||||
),
|
||||
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
||||
),
|
||||
firstTokenUpperBound
|
||||
? args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_slug_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedSlugFirstToken", firstToken)
|
||||
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_slug_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedSlugFirstToken", firstToken)
|
||||
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
|
||||
? collectDirectCandidates(
|
||||
() =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_slug_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedSlugFirstToken", firstToken)
|
||||
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_slug_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedSlugFirstToken", firstToken)
|
||||
.lt("normalizedSlugFirstToken", firstTokenUpperBound),
|
||||
),
|
||||
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
firstTokenUpperBound
|
||||
? args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_display_name_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedDisplayNameFirstToken", firstToken)
|
||||
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_display_name_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedDisplayNameFirstToken", firstToken)
|
||||
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_SEARCH_CANDIDATES)
|
||||
? collectDirectCandidates(
|
||||
() =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_normalized_display_name_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false)
|
||||
.gte("normalizedDisplayNameFirstToken", firstToken)
|
||||
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_normalized_display_name_first_token", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("normalizedDisplayNameFirstToken", firstToken)
|
||||
.lt("normalizedDisplayNameFirstToken", firstTokenUpperBound),
|
||||
),
|
||||
MAX_DIRECT_SKILL_SEARCH_CANDIDATES,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
// Full-text search on displayName — matches any token at any position.
|
||||
// Resolves Bug (non-first-token undiscoverable) by leveraging the
|
||||
// Convex inverted index added in `search_by_display_name`.
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_display_name", (q) =>
|
||||
q
|
||||
.search("displayName", args.query)
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_display_name", (q) =>
|
||||
q.search("displayName", args.query).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES),
|
||||
collectDirectCandidates(
|
||||
() =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_display_name", (q) =>
|
||||
q
|
||||
.search("displayName", args.query)
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_display_name", (q) =>
|
||||
q.search("displayName", args.query).eq("softDeletedAt", undefined),
|
||||
),
|
||||
MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES,
|
||||
),
|
||||
// Full-text search on slug — same rationale, covers slug middle/tail tokens
|
||||
// (e.g. "yijian" or "vision" inside "baidu-yijian-vision").
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_slug", (q) =>
|
||||
q.search("slug", args.query).eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_slug", (q) =>
|
||||
q.search("slug", args.query).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.take(MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES),
|
||||
Promise.all(recallTopics.map(loadTopicRows)),
|
||||
]);
|
||||
const topicRows = topicRowPages.flat();
|
||||
const topicDigests = (
|
||||
await Promise.all(
|
||||
topicRows.map((row) =>
|
||||
ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", row.skillId))
|
||||
.unique(),
|
||||
collectDirectCandidates(
|
||||
() =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_slug", (q) =>
|
||||
q
|
||||
.search("slug", args.query)
|
||||
.eq("softDeletedAt", undefined)
|
||||
.eq("isSuspicious", false),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withSearchIndex("search_by_slug", (q) =>
|
||||
q.search("slug", args.query).eq("softDeletedAt", undefined),
|
||||
),
|
||||
MAX_DIRECT_SKILL_FULL_TEXT_CANDIDATES,
|
||||
),
|
||||
Promise.all(
|
||||
exactRecallTopics.map((recallTopic) =>
|
||||
loadTopicDigests(recallTopic, false, MAX_DIRECT_SKILL_TOPIC_CANDIDATES),
|
||||
),
|
||||
)
|
||||
).filter((digest): digest is Doc<"skillSearchDigest"> => digest !== null);
|
||||
// Mirrors the `matchesExactTokens` filter the vector path applies on
|
||||
// hydrated results, so every recall path shares one literal-match
|
||||
// contract. For single-token queries this gate is a no-op against the
|
||||
// existing prefix paths, since any prefix match also implies a token
|
||||
// match.
|
||||
const passesAllQueryTokens = (digest: Doc<"skillSearchDigest">) =>
|
||||
queryTokens.length === 0 ||
|
||||
matchesExactTokens(queryTokens, [
|
||||
digest.displayName,
|
||||
digest.slug,
|
||||
digest.summary,
|
||||
...(digest.topics ?? []),
|
||||
]);
|
||||
|
||||
),
|
||||
]);
|
||||
const queryExactTopicDigests = topicQuery
|
||||
? (exactTopicDigestPages[exactRecallTopics.indexOf(topicQuery)] ?? [])
|
||||
: [];
|
||||
const prefixTopicDigests =
|
||||
topicQuery && queryExactTopicDigests.length < MAX_DIRECT_SKILL_TOPIC_CANDIDATES
|
||||
? await loadTopicDigests(
|
||||
topicQuery,
|
||||
true,
|
||||
MAX_DIRECT_SKILL_TOPIC_CANDIDATES - queryExactTopicDigests.length,
|
||||
)
|
||||
: [];
|
||||
const topicDigests = [...exactTopicDigestPages.flat(), ...prefixTopicDigests]
|
||||
.flat()
|
||||
.filter(
|
||||
(digest, index, all) =>
|
||||
all.findIndex((candidate) => candidate.skillId === digest.skillId) === index,
|
||||
);
|
||||
const digests = [
|
||||
...slugDigests,
|
||||
...displayNameDigests,
|
||||
@@ -647,12 +795,12 @@ export const directPrefixSkillMatches = internalQuery({
|
||||
const skill = digestToHydratableSkill(digest);
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
||||
if (args.highlightedOnly && !isSkillHighlighted(skill)) return null;
|
||||
if (!matchesCatalogTopic(skill, topic)) return null;
|
||||
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
|
||||
const preResolved = digestToOwnerInfo(digest);
|
||||
const resolved = preResolved?.owner
|
||||
? preResolved
|
||||
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
const publicSkill = toPublicSearchSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
return {
|
||||
skill: publicSkill,
|
||||
@@ -671,9 +819,12 @@ export const hydrateResults = internalQuery({
|
||||
args: {
|
||||
embeddingIds: v.array(v.id("skillEmbeddings")),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
categorySlug: v.optional(v.string()),
|
||||
topic: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
||||
if (categorySlug === null) return [];
|
||||
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
||||
if (args.topic !== undefined && !topic) return [];
|
||||
// Only used as fallback when digest doesn't have owner data.
|
||||
@@ -701,14 +852,14 @@ export const hydrateResults = internalQuery({
|
||||
: await ctx.db.get(skillId);
|
||||
if (!skill || skill.softDeletedAt) return null;
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) return null;
|
||||
if (!matchesCatalogTopic(skill, topic)) return null;
|
||||
if (!matchesCatalogFilters(skill, categorySlug, topic)) return null;
|
||||
// Use pre-resolved owner from digest to avoid reading the users table.
|
||||
// Fall back to live lookup when digest owner is null (deactivated/deleted user).
|
||||
const preResolved = digest ? digestToOwnerInfo(digest) : null;
|
||||
const resolved = preResolved?.owner
|
||||
? preResolved
|
||||
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
const publicSkill = toPublicSearchSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
return {
|
||||
embeddingId,
|
||||
@@ -732,9 +883,12 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
highlightedOnly: v.optional(v.boolean()),
|
||||
nonSuspiciousOnly: v.optional(v.boolean()),
|
||||
skipExactSlugLookup: v.optional(v.boolean()),
|
||||
categorySlug: v.optional(v.string()),
|
||||
topic: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<SkillSearchEntry[]> => {
|
||||
const categorySlug = normalizeSkillCategoryFilter(args.categorySlug);
|
||||
if (categorySlug === null) return [];
|
||||
const topic = args.topic === undefined ? undefined : normalizeCatalogTopic(args.topic);
|
||||
if (args.topic !== undefined && !topic) return [];
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 10), FALLBACK_SCAN_LIMIT);
|
||||
@@ -762,7 +916,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
if (
|
||||
!exactSlugSkill.softDeletedAt &&
|
||||
(!args.nonSuspiciousOnly || !isSkillSuspicious(exactSlugSkill)) &&
|
||||
matchesCatalogTopic(exactSlugSkill, topic)
|
||||
matchesCatalogFilters(exactSlugSkill, categorySlug, topic)
|
||||
) {
|
||||
seenSkillIds.add(exactSlugSkill._id);
|
||||
candidates.push(exactSlugSkill);
|
||||
@@ -773,28 +927,58 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
// Scan recent active digests (~800 bytes each) instead of full skill docs (~3-5KB).
|
||||
// Use updatedAt and createdAt windows so newly published skills are visible even
|
||||
// when they are not in the most recently updated slice.
|
||||
const recentByUpdatedQuery = args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_updated", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined));
|
||||
const recentByCreatedQuery = args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_created", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
||||
)
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined));
|
||||
const createRecentByUpdatedQuery = () =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_updated", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
||||
)
|
||||
.order("desc")
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_updated", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc");
|
||||
const createRecentByCreatedQuery = () =>
|
||||
args.nonSuspiciousOnly
|
||||
? ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_nonsuspicious_created", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("isSuspicious", false),
|
||||
)
|
||||
.order("desc")
|
||||
: ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined))
|
||||
.order("desc");
|
||||
|
||||
const filteredScanLimit =
|
||||
categorySlug || topic || args.highlightedOnly ? FALLBACK_SCAN_LIMIT : scanLimit;
|
||||
const matchesFallbackRecallFilters = (digest: Doc<"skillSearchDigest">) => {
|
||||
const skill = digestToHydratableSkill(digest);
|
||||
return (
|
||||
(!args.highlightedOnly || isSkillHighlighted(skill)) &&
|
||||
matchesCatalogFilters(skill, categorySlug, topic) &&
|
||||
matchesExactTokens(args.queryTokens, [
|
||||
skill.displayName,
|
||||
skill.slug,
|
||||
skill.summary,
|
||||
...(skill.categories ?? []),
|
||||
...(skill.topics ?? []),
|
||||
])
|
||||
);
|
||||
};
|
||||
const [recentByUpdated, recentByCreated] = await Promise.all([
|
||||
recentByUpdatedQuery.order("desc").take(scanLimit),
|
||||
recentByCreatedQuery.order("desc").take(scanLimit),
|
||||
collectFilteredSkillDigestCandidates(createRecentByUpdatedQuery, {
|
||||
limit: scanLimit,
|
||||
scanLimit: filteredScanLimit,
|
||||
matches: matchesFallbackRecallFilters,
|
||||
}),
|
||||
collectFilteredSkillDigestCandidates(createRecentByCreatedQuery, {
|
||||
limit: scanLimit,
|
||||
scanLimit: filteredScanLimit,
|
||||
matches: matchesFallbackRecallFilters,
|
||||
}),
|
||||
]);
|
||||
|
||||
const addDigestCandidates = (digests: typeof recentByUpdated) => {
|
||||
@@ -802,7 +986,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
if (seenSkillIds.has(digest.skillId)) continue;
|
||||
const skill = digestToHydratableSkill(digest);
|
||||
if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) continue;
|
||||
if (!matchesCatalogTopic(skill, topic)) continue;
|
||||
if (!matchesCatalogFilters(skill, categorySlug, topic)) continue;
|
||||
seenSkillIds.add(digest.skillId);
|
||||
candidates.push(skill);
|
||||
// Pre-resolve owner from digest to avoid users table reads.
|
||||
@@ -818,6 +1002,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
skill.displayName,
|
||||
skill.slug,
|
||||
skill.summary,
|
||||
...(skill.categories ?? []),
|
||||
...(skill.topics ?? []),
|
||||
]),
|
||||
);
|
||||
@@ -832,7 +1017,7 @@ export const lexicalFallbackSkills = internalQuery({
|
||||
const resolved = preResolved?.owner
|
||||
? preResolved
|
||||
: await getOwnerInfo(skill.ownerUserId, skill.ownerPublisherId);
|
||||
const publicSkill = toPublicSkill(skill);
|
||||
const publicSkill = toPublicSearchSkill(skill);
|
||||
if (!publicSkill || !resolved.owner) return null;
|
||||
return {
|
||||
skill: publicSkill,
|
||||
|
||||
@@ -233,6 +233,7 @@ function makeTopicCtx(
|
||||
field,
|
||||
value,
|
||||
eq: () => ({ eq: () => ({}), lt: () => ({}) }),
|
||||
gte: () => ({ lt: () => ({}) }),
|
||||
lt: () => ({}),
|
||||
};
|
||||
},
|
||||
@@ -246,6 +247,7 @@ function makeTopicCtx(
|
||||
field,
|
||||
value,
|
||||
eq: () => ({ eq: () => ({}), lt: () => ({}) }),
|
||||
gte: () => ({ lt: () => ({}) }),
|
||||
lt: () => ({}),
|
||||
}),
|
||||
});
|
||||
@@ -806,6 +808,30 @@ describe("skills package catalog queries", () => {
|
||||
expect(result[0]?.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses stored categories as skill package search evidence", async () => {
|
||||
const result = await searchPackageCatalogPublicHandler(
|
||||
makeCtx([
|
||||
{
|
||||
page: [
|
||||
makeDigest("focused-helper", {
|
||||
displayName: "Focused Helper",
|
||||
summary: "Keeps projects tidy.",
|
||||
categories: ["development"],
|
||||
}),
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
]),
|
||||
{
|
||||
query: "dev",
|
||||
limit: 5,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
|
||||
});
|
||||
|
||||
it("matches author topics in unfiltered skill package search", async () => {
|
||||
const topicSkill = makeDigest("render-helper", {
|
||||
displayName: "Render Helper",
|
||||
@@ -841,6 +867,38 @@ describe("skills package catalog queries", () => {
|
||||
expect(indexNames).toContain("by_active_topic_updated");
|
||||
});
|
||||
|
||||
it("uses partial author topics as skill package search evidence", async () => {
|
||||
const topicSkill = makeDigest("focused-helper", {
|
||||
displayName: "Focused Helper",
|
||||
summary: "Keeps projects tidy.",
|
||||
topics: ["GPU development"],
|
||||
});
|
||||
const result = await searchPackageCatalogPublicHandler(
|
||||
makeTopicCtx(
|
||||
[
|
||||
{
|
||||
page: [
|
||||
{
|
||||
skillId: topicSkill.skillId,
|
||||
topic: "gpu-development",
|
||||
updatedAt: topicSkill.updatedAt,
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
},
|
||||
],
|
||||
[topicSkill],
|
||||
),
|
||||
{
|
||||
query: "gpu",
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.map((entry) => entry.package.name)).toEqual(["focused-helper"]);
|
||||
});
|
||||
|
||||
it("normalizes and filters skill package catalog search topics", async () => {
|
||||
const indexNames: string[] = [];
|
||||
const calendarSkill = makeDigest("calendar-demo", { topics: ["calendar"] });
|
||||
|
||||
+38
-3
@@ -1,6 +1,7 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import {
|
||||
getCatalogTopicSlugs,
|
||||
INTERNAL_UNCATEGORIZED_CATEGORY,
|
||||
isSkillCategorySlug,
|
||||
normalizeCatalogTopic,
|
||||
normalizeCatalogTopics,
|
||||
@@ -6363,10 +6364,23 @@ function skillCatalogSearchMatch(
|
||||
setMatch(1, 35);
|
||||
}
|
||||
|
||||
const topicQuery = normalizeCatalogTopic(queryText);
|
||||
if (topicQuery && getCatalogTopicSlugs(digest.topics).includes(topicQuery)) {
|
||||
const taxonomyQuery = normalizeCatalogTopic(queryText);
|
||||
const categories = (digest.categories ?? []).filter(
|
||||
(category) => category !== INTERNAL_UNCATEGORIZED_CATEGORY,
|
||||
);
|
||||
const topicSlugs = getCatalogTopicSlugs(digest.topics);
|
||||
if (taxonomyQuery && (categories.includes(taxonomyQuery) || topicSlugs.includes(taxonomyQuery))) {
|
||||
setMatch(2, 25);
|
||||
}
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
queryTokens,
|
||||
[...categories, ...(digest.topics ?? [])],
|
||||
EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH,
|
||||
)
|
||||
) {
|
||||
setMatch(2, 20);
|
||||
}
|
||||
|
||||
if (
|
||||
matchesExploratoryTokenPrefixes(
|
||||
@@ -6538,6 +6552,10 @@ export const hasMissingPackageCatalogRecommendationScoresInternal = internalQuer
|
||||
|
||||
const EXPLORATORY_SKILL_CATALOG_SEARCH_MIN_TOKEN_LENGTH = 3;
|
||||
|
||||
function skillCatalogPrefixUpperBound(value: string) {
|
||||
return `${value}\uffff`;
|
||||
}
|
||||
|
||||
type SkillPackageCatalogSearchArgs = {
|
||||
query: string;
|
||||
limit?: number;
|
||||
@@ -6580,13 +6598,30 @@ async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalog
|
||||
if (!topic && matches.length < targetCount) {
|
||||
const directTopic = normalizeCatalogTopic(queryText);
|
||||
if (directTopic) {
|
||||
const topicDigests = await ctx.db
|
||||
const exactTopicDigests = await ctx.db
|
||||
.query("skillTopicSearchDigest")
|
||||
.withIndex("by_active_topic_updated", (q) =>
|
||||
q.eq("softDeletedAt", undefined).eq("topic", directTopic),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_SKILL_CATALOG_SEARCH_CANDIDATES);
|
||||
const prefixTopicDigests =
|
||||
exactTopicDigests.length < MAX_DIRECT_SKILL_CATALOG_SEARCH_CANDIDATES
|
||||
? await ctx.db
|
||||
.query("skillTopicSearchDigest")
|
||||
.withIndex("by_active_topic_updated", (q) =>
|
||||
q
|
||||
.eq("softDeletedAt", undefined)
|
||||
.gte("topic", directTopic)
|
||||
.lt("topic", skillCatalogPrefixUpperBound(directTopic)),
|
||||
)
|
||||
.order("desc")
|
||||
.take(MAX_DIRECT_SKILL_CATALOG_SEARCH_CANDIDATES - exactTopicDigests.length)
|
||||
: [];
|
||||
const topicDigests = [...exactTopicDigests, ...prefixTopicDigests].filter(
|
||||
(digest, index, all) =>
|
||||
all.findIndex((candidate) => candidate.skillId === digest.skillId) === index,
|
||||
);
|
||||
for (const topicDigest of topicDigests) {
|
||||
const digest = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"ast-v8-to-istanbul": "1.0.4",
|
||||
"dompurify": "3.4.10",
|
||||
"dompurify": "3.4.11",
|
||||
"esbuild": "0.28.1",
|
||||
"next": "16.2.6",
|
||||
"postcss": "8.5.12",
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
- Authors may supply up to five topics through CLI or UI publish and edit surfaces.
|
||||
- Stored topics preserve author-facing labels. Lookup uses normalized topic slugs.
|
||||
- Reserved platform trust labels such as `official`, `featured`, and `verified` are rejected.
|
||||
- Topics are separate from release tags and are available to browse and exact-topic global search.
|
||||
- Topics are separate from release tags and remain available to search.
|
||||
- Browse sidebars do not enumerate the global topic space because it is open-ended and
|
||||
author-facing labels may use different casing. Selecting a category reveals at most five
|
||||
normalized top-topic chips from a bounded sample of that category's highest-ranked public items.
|
||||
Exact normalized topic browse links remain supported.
|
||||
- Authors can edit categories and topics from skill and plugin settings.
|
||||
- Settings expose Generate as an explicit category action. Clearing categories saves `other`.
|
||||
- Backports and non-latest plugin releases do not replace current topics.
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
ClawHub search is a retrieval surface, not a browse fallback. A package, plugin, or skill can appear as a search match only when the query has evidence against that item:
|
||||
|
||||
- exact, prefix, or substring match in a navigational field such as name, slug, display name, normalized package name, or runtime id;
|
||||
- token-prefix match in exploratory fields such as summary or capability tags, using a minimum query-token length for every query token to avoid short-query noise.
|
||||
- exact or token-prefix match in taxonomy fields such as categories and author topics;
|
||||
- token-prefix match in exploratory fields such as summary, using a minimum query-token length for every query token to avoid short-query noise.
|
||||
|
||||
Trust and business signals are not relevance signals. `official`, verification tier, security status, downloads, stars, installs, highlighting, and recency may break ties between already eligible matches or appear as filters/badges, but they must not make an otherwise unrelated item eligible for search.
|
||||
|
||||
Generic fallback categories such as `other` are browse groupings, not search evidence.
|
||||
|
||||
Search ranking should be lexicographic before it is numeric:
|
||||
|
||||
1. exact full field match in name, slug, normalized package name, or runtime id;
|
||||
2. lexical field match in name, slug, normalized package name, display name, or runtime id;
|
||||
3. capability or tag match;
|
||||
3. category or topic match;
|
||||
4. summary match;
|
||||
|
||||
Numeric scores, trust state, popularity, and recency may order results inside those broad tiers, but must not move a weaker tier above a stronger tier.
|
||||
|
||||
The same contract applies across `/search`, the header typeahead, package/plugin catalog search, and skill-as-package catalog search.
|
||||
|
||||
Explicit browse filters such as category and topic must be applied during backend recall before
|
||||
result limits. Client-side filtering may remain as a defensive display check, but it must not be the
|
||||
only category or topic filter because limited global results can under-fill scoped search. Recall may
|
||||
stop at an explicit safety scan budget, but the result limit applies after scoped matches are found.
|
||||
|
||||
Search result counts in the web UI should describe what is known from the current request. Do not label a page-size-limited result length as a total corpus count. Prefer `N+`, "shown", or no count unless an indexed/materialized total is available.
|
||||
|
||||
@@ -74,6 +74,9 @@ vi.mock("convex/react", () => ({
|
||||
|
||||
vi.mock("../../convex/_generated/api", () => ({
|
||||
api: {
|
||||
catalogTopics: {
|
||||
listTopByCategory: "catalogTopics:listTopByCategory",
|
||||
},
|
||||
packages: {
|
||||
countPublicPlugins: "packages:countPublicPlugins",
|
||||
},
|
||||
@@ -666,7 +669,7 @@ describe("plugins route", () => {
|
||||
expect(screen.queryByText("321")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an active topic facet visible when it has no results", async () => {
|
||||
it("does not render an active topic in the sidebar when it has no results", async () => {
|
||||
searchMock = { topic: "postgres" };
|
||||
loaderDataMock = {
|
||||
items: [],
|
||||
@@ -679,10 +682,48 @@ describe("plugins route", () => {
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getByRole("radio", { name: "postgres" }).getAttribute("aria-checked")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.getByRole("radio", { name: "All topics" })).toBeTruthy();
|
||||
expect(screen.queryByRole("radio", { name: "postgres" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "All topics" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows category topic chips and filters plugins by the selected topic", async () => {
|
||||
searchMock = { category: "runtime" };
|
||||
loaderDataMock = {
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
rateLimited: false,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
convexReactMocks.useQuery.mockImplementation((_reference, args) => {
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"kind" in args &&
|
||||
(args as { kind?: string }).kind === "plugin"
|
||||
) {
|
||||
return ["docker", "typescript", "github", "debugging", "coding"];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(screen.getAllByRole("button", { name: /^#/ })).toHaveLength(5);
|
||||
fireEvent.click(screen.getByRole("button", { name: "#docker" }));
|
||||
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace?: boolean;
|
||||
};
|
||||
expect(lastCall.search({ category: "runtime" })).toEqual({
|
||||
category: "runtime",
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
topic: "docker",
|
||||
});
|
||||
expect(lastCall.replace).toBe(true);
|
||||
});
|
||||
|
||||
it("renders a label-only title without positive count data and switches to grid view", async () => {
|
||||
|
||||
@@ -309,6 +309,25 @@ describe("SkillsIndex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the selected category to backend skill search", async () => {
|
||||
searchMock = { q: "helper", category: "development" };
|
||||
const actionFn = vi.fn().mockResolvedValue([]);
|
||||
convexReactMocks.useAction.mockReturnValue(actionFn);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(actionFn).toHaveBeenCalledWith({
|
||||
query: "helper",
|
||||
highlightedOnly: false,
|
||||
categorySlug: "development",
|
||||
limit: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps recommended as the visible default search sort", async () => {
|
||||
searchMock = { q: "notion" };
|
||||
const actionFn = vi.fn().mockResolvedValue([]);
|
||||
@@ -614,7 +633,7 @@ describe("SkillsIndex", () => {
|
||||
expect(screen.queryByText(/\d+ loaded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes author topics to browse filtering and renders the active topic facet", async () => {
|
||||
it("passes author topics to browse filtering without rendering topic navigation", async () => {
|
||||
searchMock = { topic: "google-calendar" };
|
||||
convexHttpMock.query.mockResolvedValue({
|
||||
page: [
|
||||
@@ -634,13 +653,82 @@ describe("SkillsIndex", () => {
|
||||
topic: "google-calendar",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("radio", { name: "google-calendar" }).getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
expect(screen.getAllByText("google-calendar")).toHaveLength(2);
|
||||
expect(screen.queryByRole("radio", { name: "google-calendar" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "All topics" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an active topic facet visible when it has no results", async () => {
|
||||
it("shows the top five topics beneath the selected category and filters by chip", async () => {
|
||||
searchMock = { category: "development" };
|
||||
convexReactMocks.useQuery.mockImplementation((_reference, args) => {
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"kind" in args &&
|
||||
(args as { kind?: string }).kind === "skill"
|
||||
) {
|
||||
return ["typescript", "docker", "github", "debugging", "coding"];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const category = screen.getByRole("radio", { name: "Development" });
|
||||
const firstTopic = screen.getByRole("button", { name: "#typescript" });
|
||||
expect(
|
||||
Boolean(category.compareDocumentPosition(firstTopic) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||
).toBe(true);
|
||||
expect(screen.getAllByRole("button", { name: /^#/ })).toHaveLength(5);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "#docker" }));
|
||||
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
replace?: boolean;
|
||||
};
|
||||
expect(lastCall.search({ category: "development" })).toEqual({
|
||||
category: "development",
|
||||
topic: "docker",
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
});
|
||||
expect(lastCall.replace).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the active category topic when its chip is selected again", async () => {
|
||||
searchMock = { category: "development", topic: "docker" };
|
||||
convexReactMocks.useQuery.mockImplementation((_reference, args) => {
|
||||
if (
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
"kind" in args &&
|
||||
(args as { kind?: string }).kind === "skill"
|
||||
) {
|
||||
return ["docker"];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
const topic = screen.getByRole("button", { name: "#docker" });
|
||||
expect(topic.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(topic);
|
||||
|
||||
const lastCall = navigateMock.mock.calls.at(-1)?.[0] as {
|
||||
search: (prev: Record<string, unknown>) => Record<string, unknown>;
|
||||
};
|
||||
expect(lastCall.search({ category: "development", topic: "docker" })).toEqual({
|
||||
category: "development",
|
||||
topic: undefined,
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render an active topic in the sidebar when it has no results", async () => {
|
||||
searchMock = { topic: "google-calendar" };
|
||||
convexHttpMock.query.mockResolvedValue({
|
||||
page: [],
|
||||
@@ -651,10 +739,8 @@ describe("SkillsIndex", () => {
|
||||
render(<SkillsIndex />);
|
||||
await act(async () => {});
|
||||
|
||||
expect(
|
||||
screen.getByRole("radio", { name: "google-calendar" }).getAttribute("aria-checked"),
|
||||
).toBe("true");
|
||||
expect(screen.getByRole("radio", { name: "All topics" })).toBeTruthy();
|
||||
expect(screen.queryByRole("radio", { name: "google-calendar" })).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "All topics" })).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves backend official-first ordering on category pages", async () => {
|
||||
|
||||
@@ -51,6 +51,9 @@ type BrowseSidebarProps = {
|
||||
categories?: BrowseCategory[];
|
||||
activeCategory?: string;
|
||||
onCategoryChange?: (slug: string | undefined) => void;
|
||||
categoryTopics?: string[];
|
||||
activeTopic?: string;
|
||||
onTopicChange?: (topic: string | undefined) => void;
|
||||
sortOptions?: SortOption[];
|
||||
activeSort?: string;
|
||||
onSortChange?: (value: string) => void;
|
||||
@@ -91,6 +94,9 @@ export function BrowseSidebar({
|
||||
categories,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
categoryTopics = [],
|
||||
activeTopic,
|
||||
onTopicChange,
|
||||
sortOptions,
|
||||
activeSort,
|
||||
onSortChange,
|
||||
@@ -171,21 +177,43 @@ export function BrowseSidebar({
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.slug}
|
||||
className={`sidebar-option${activeCategory === cat.slug ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeCategory === cat.slug}
|
||||
onClick={() => onCategoryChange(cat.slug)}
|
||||
>
|
||||
<span className="sidebar-option-icon" aria-hidden="true">
|
||||
{getCategoryIcon(cat.icon)}
|
||||
</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
{categories.map((cat) => {
|
||||
const isActive = activeCategory === cat.slug;
|
||||
return (
|
||||
<div key={cat.slug} className="sidebar-category">
|
||||
<button
|
||||
className={`sidebar-option${isActive ? " is-active" : ""}`}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => onCategoryChange(cat.slug)}
|
||||
>
|
||||
<span className="sidebar-option-icon" aria-hidden="true">
|
||||
{getCategoryIcon(cat.icon)}
|
||||
</span>
|
||||
{cat.label}
|
||||
</button>
|
||||
{isActive && categoryTopics.length > 0 && onTopicChange ? (
|
||||
<div className="sidebar-category-topics" aria-label={`${cat.label} top topics`}>
|
||||
{categoryTopics.slice(0, 5).map((topic) => {
|
||||
const isActiveTopic = activeTopic === topic;
|
||||
return (
|
||||
<button
|
||||
key={topic}
|
||||
className={`sidebar-topic-chip${isActiveTopic ? " is-active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={isActiveTopic}
|
||||
onClick={() => onTopicChange(isActiveTopic ? undefined : topic)}
|
||||
>
|
||||
#{topic}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -393,6 +393,15 @@ function PluginsIndex() {
|
||||
]);
|
||||
|
||||
const activeCategory = search.category;
|
||||
const categoryTopics = useQuery(
|
||||
api.catalogTopics.listTopByCategory,
|
||||
activeCategory
|
||||
? {
|
||||
kind: "plugin",
|
||||
category: activeCategory,
|
||||
}
|
||||
: "skip",
|
||||
);
|
||||
|
||||
const activeSort: PluginSort =
|
||||
search.sort === "relevance" || search.sort === "newest" || search.sort === "name"
|
||||
@@ -401,30 +410,6 @@ function PluginsIndex() {
|
||||
const visibleItems = useMemo(() => {
|
||||
return hasQuery ? sortPluginSearchItems(items, activeSort) : items;
|
||||
}, [activeSort, hasQuery, items]);
|
||||
const availableTopics = useMemo(() => {
|
||||
const topics = new Map<string, { label: string; count: number }>();
|
||||
for (const item of items) {
|
||||
for (const label of item.topics ?? []) {
|
||||
const slug = normalizeCatalogTopic(label);
|
||||
if (!slug) continue;
|
||||
const current = topics.get(slug);
|
||||
topics.set(slug, { label: current?.label ?? label, count: (current?.count ?? 0) + 1 });
|
||||
}
|
||||
}
|
||||
const visibleTopics = [...topics.entries()]
|
||||
.sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label))
|
||||
.slice(0, 8)
|
||||
.map(([slug, value]) => ({ slug, label: value.label }));
|
||||
const activeTopic = search.topic ? normalizeCatalogTopic(search.topic) : undefined;
|
||||
if (!activeTopic || visibleTopics.some((topic) => topic.slug === activeTopic)) {
|
||||
return visibleTopics;
|
||||
}
|
||||
return [
|
||||
{ slug: activeTopic, label: topics.get(activeTopic)?.label ?? activeTopic },
|
||||
...visibleTopics,
|
||||
].slice(0, 8);
|
||||
}, [items, search.topic]);
|
||||
|
||||
const handleFilterToggle = (key: string) => {
|
||||
if (key === "official") {
|
||||
void navigate({
|
||||
@@ -483,6 +468,7 @@ function PluginsIndex() {
|
||||
search: (prev: PluginSearchState) => ({
|
||||
...prev,
|
||||
cursor: undefined,
|
||||
family: undefined,
|
||||
topic,
|
||||
}),
|
||||
replace: true,
|
||||
@@ -617,30 +603,14 @@ function PluginsIndex() {
|
||||
categories={PLUGIN_CATEGORIES}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
categoryTopics={categoryTopics ?? []}
|
||||
activeTopic={search.topic}
|
||||
onTopicChange={handleTopicChange}
|
||||
sortOptions={PLUGIN_SORT_OPTIONS}
|
||||
activeSort={activeSort}
|
||||
onSortChange={handleSortChange}
|
||||
filters={[{ key: "official", label: "Official only", active: search.official ?? false }]}
|
||||
onFilterToggle={handleFilterToggle}
|
||||
radioGroups={
|
||||
availableTopics.length
|
||||
? [
|
||||
{
|
||||
title: "Topics",
|
||||
ariaLabel: "Topic filter",
|
||||
activeValue: search.topic,
|
||||
onChange: handleTopicChange,
|
||||
options: [
|
||||
{ value: undefined, label: "All topics" },
|
||||
...availableTopics.map((topic) => ({
|
||||
value: topic.slug,
|
||||
label: topic.label,
|
||||
})),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<div className="browse-results">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -90,7 +90,7 @@ export function useSkillsBrowseModel({
|
||||
const listSort = toListSort(sort);
|
||||
const dir = sort === "relevance" ? "desc" : parseDir(search.dir, sort);
|
||||
const searchKey = hasQuery
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeTopic ?? ""}`
|
||||
? `${trimmedQuery}::${featuredOnly ? "1" : "0"}::${activeCategory?.slug ?? ""}::${activeTopic ?? ""}`
|
||||
: "";
|
||||
|
||||
// One-shot paginated fetches (no reactive subscription)
|
||||
@@ -215,6 +215,7 @@ export function useSkillsBrowseModel({
|
||||
const data = (await searchSkills({
|
||||
query: trimmedQuery,
|
||||
highlightedOnly: featuredOnly,
|
||||
categorySlug: activeCategory?.slug,
|
||||
topic: activeTopic,
|
||||
limit: searchLimit,
|
||||
})) as Array<SkillSearchEntry>;
|
||||
@@ -229,7 +230,15 @@ export function useSkillsBrowseModel({
|
||||
})();
|
||||
}, 220);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [activeTopic, hasQuery, featuredOnly, searchLimit, searchSkills, trimmedQuery]);
|
||||
}, [
|
||||
activeCategory?.slug,
|
||||
activeTopic,
|
||||
hasQuery,
|
||||
featuredOnly,
|
||||
searchLimit,
|
||||
searchSkills,
|
||||
trimmedQuery,
|
||||
]);
|
||||
|
||||
const baseItems = useMemo(() => {
|
||||
if (hasQuery) {
|
||||
@@ -296,29 +305,6 @@ export function useSkillsBrowseModel({
|
||||
return results;
|
||||
}, [activeCategory, activeTopic, baseItems, dir, hasQuery, sort]);
|
||||
|
||||
const availableTopics = useMemo(() => {
|
||||
const topics = new Map<string, { label: string; count: number }>();
|
||||
for (const entry of baseItems) {
|
||||
for (const label of entry.skill.topics ?? []) {
|
||||
const slug = normalizeCatalogTopic(label);
|
||||
if (!slug) continue;
|
||||
const current = topics.get(slug);
|
||||
topics.set(slug, { label: current?.label ?? label, count: (current?.count ?? 0) + 1 });
|
||||
}
|
||||
}
|
||||
const visibleTopics = [...topics.entries()]
|
||||
.sort((a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label))
|
||||
.slice(0, 8)
|
||||
.map(([slug, value]) => ({ slug, label: value.label }));
|
||||
if (!activeTopic || visibleTopics.some((topic) => topic.slug === activeTopic)) {
|
||||
return visibleTopics;
|
||||
}
|
||||
return [
|
||||
{ slug: activeTopic, label: topics.get(activeTopic)?.label ?? activeTopic },
|
||||
...visibleTopics,
|
||||
].slice(0, 8);
|
||||
}, [activeTopic, baseItems]);
|
||||
|
||||
const isLoadingSkills = hasQuery ? isSearching && searchResults.length === 0 : isLoadingList;
|
||||
const canLoadMore = hasQuery
|
||||
? !isSearching && searchResults.length === searchLimit && searchResults.length > 0
|
||||
@@ -485,24 +471,10 @@ export function useSkillsBrowseModel({
|
||||
const activeFilters: string[] = [];
|
||||
if (featuredOnly) activeFilters.push("featured");
|
||||
|
||||
const onTopicChange = useCallback(
|
||||
(value: string | undefined) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
topic: value,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return {
|
||||
activeFilters,
|
||||
activeCategory: activeCategory?.slug,
|
||||
activeTopic,
|
||||
availableTopics,
|
||||
canAutoLoad,
|
||||
canLoadMore,
|
||||
dir,
|
||||
@@ -519,7 +491,6 @@ export function useSkillsBrowseModel({
|
||||
onToggleDir,
|
||||
onToggleFeatured,
|
||||
onToggleView,
|
||||
onTopicChange,
|
||||
query,
|
||||
sort,
|
||||
sorted,
|
||||
|
||||
+27
-19
@@ -77,6 +77,15 @@ export function SkillsIndex() {
|
||||
: model.sort;
|
||||
const hasActiveFilters = model.hasQuery || Boolean(model.activeCategory) || model.featuredOnly;
|
||||
const totalSkillsCount = useQuery(api.skills.countPublicSkills, {});
|
||||
const categoryTopics = useQuery(
|
||||
api.catalogTopics.listTopByCategory,
|
||||
model.activeCategory
|
||||
? {
|
||||
kind: "skill",
|
||||
category: model.activeCategory,
|
||||
}
|
||||
: "skip",
|
||||
);
|
||||
const formattedCount = !hasActiveFilters ? formatBrowseCount(totalSkillsCount) : null;
|
||||
|
||||
const handleSortChange = useCallback(
|
||||
@@ -133,6 +142,21 @@ export function SkillsIndex() {
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleTopicChange = useCallback(
|
||||
(topic: string | undefined) => {
|
||||
void navigate({
|
||||
search: (prev: SkillsSearchState) => ({
|
||||
...prev,
|
||||
topic,
|
||||
featured: undefined,
|
||||
highlighted: undefined,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="browse-page">
|
||||
<div className="browse-page-header">
|
||||
@@ -196,28 +220,12 @@ export function SkillsIndex() {
|
||||
categories={SKILL_CATEGORIES}
|
||||
activeCategory={model.activeCategory}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
categoryTopics={categoryTopics ?? []}
|
||||
activeTopic={model.activeTopic}
|
||||
onTopicChange={handleTopicChange}
|
||||
sortOptions={SKILLS_SORT_OPTIONS}
|
||||
activeSort={activeSort}
|
||||
onSortChange={handleSortChange}
|
||||
radioGroups={
|
||||
model.availableTopics.length
|
||||
? [
|
||||
{
|
||||
title: "Topics",
|
||||
ariaLabel: "Topic filter",
|
||||
activeValue: model.activeTopic,
|
||||
onChange: model.onTopicChange,
|
||||
options: [
|
||||
{ value: undefined, label: "All topics" },
|
||||
...model.availableTopics.map((topic) => ({
|
||||
value: topic.slug,
|
||||
label: topic.label,
|
||||
})),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<div className="browse-results">
|
||||
<SkillsResults
|
||||
|
||||
@@ -11956,6 +11956,48 @@ a.agentic-risk-finding-title:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-category-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
padding: 1px 0 8px 23px;
|
||||
}
|
||||
|
||||
.sidebar-topic-chip {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 84%, transparent);
|
||||
border-radius: var(--r-pill);
|
||||
background: color-mix(in srgb, var(--surface-muted) 72%, transparent);
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 550;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sidebar-topic-chip:hover {
|
||||
border-color: color-mix(in srgb, var(--ink-soft) 40%, var(--line));
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.sidebar-topic-chip.is-active {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.sidebar-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user