feat: add catalog classification backfill (#2719)

* feat: add controlled catalog taxonomy and topics

* refactor: limit taxonomy input to publish surfaces

* fix: omit inactive official-first filter

* fix: preserve unsaved catalog metadata edits

* fix: enforce catalog taxonomy invariants

* fix: close catalog taxonomy review gaps

* fix: tolerate retired stored skill categories

* fix: bound catalog metadata filter scans

* fix: preserve comma-containing topic labels

* fix: tolerate retired stored plugin categories

* fix: ignore empty secret integration metadata

* fix: honor explicit categories in related skills

* fix: harden taxonomy rollout migration

* fix: harden taxonomy browse pagination

* fix: preserve skill topic recommendation fallback

* fix: scale curated skill category browse

* fix: preserve legacy plugin category filters

* fix: preserve taxonomy compatibility semantics

* fix: preserve legacy catalog browse links

* feat: make catalog metadata editing explicit

* test: update plugin manage context contract

* fix: address taxonomy review findings

* fix: preserve empty category publish flags

* fix: preserve catalog search and publish metadata

* chore: keep taxonomy migration operator-run

* docs: keep taxonomy migrations operator-run

* fix: reject inherited category aliases

* fix: preserve normalized topic search behavior

* test: cover full topic pagination cursors

* feat: add catalog classification backfill

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Jason (Json)
2026-06-17 17:58:26 -07:00
committed by GitHub
co-authored by Patrick Erichsen
parent 1a3fdd8f51
commit 4bc58e4939
28 changed files with 4634 additions and 18 deletions
+8
View File
@@ -10,6 +10,8 @@
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 cliDeviceAuth from "../cliDeviceAuth.js";
import type * as crons from "../crons.js";
import type * as depRegistryScan from "../depRegistryScan.js";
@@ -45,6 +47,8 @@ import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
import type * as lib_artifactModeration from "../lib/artifactModeration.js";
import type * as lib_badges from "../lib/badges.js";
import type * as lib_batching from "../lib/batching.js";
import type * as lib_catalogClassification from "../lib/catalogClassification.js";
import type * as lib_catalogClassifier from "../lib/catalogClassifier.js";
import type * as lib_changelog from "../lib/changelog.js";
import type * as lib_clawpack from "../lib/clawpack.js";
import type * as lib_contentTypes from "../lib/contentTypes.js";
@@ -155,6 +159,8 @@ import type {
declare const fullApi: ApiFromModules<{
appMeta: typeof appMeta;
auth: typeof auth;
catalogClassification: typeof catalogClassification;
catalogClassificationNode: typeof catalogClassificationNode;
cliDeviceAuth: typeof cliDeviceAuth;
crons: typeof crons;
depRegistryScan: typeof depRegistryScan;
@@ -190,6 +196,8 @@ declare const fullApi: ApiFromModules<{
"lib/artifactModeration": typeof lib_artifactModeration;
"lib/badges": typeof lib_badges;
"lib/batching": typeof lib_batching;
"lib/catalogClassification": typeof lib_catalogClassification;
"lib/catalogClassifier": typeof lib_catalogClassifier;
"lib/changelog": typeof lib_changelog;
"lib/clawpack": typeof lib_clawpack;
"lib/contentTypes": typeof lib_contentTypes;
+149
View File
@@ -0,0 +1,149 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { getCatalogClassificationPageInternalHandler } from "./catalogClassification";
import { classifyCatalogInternalHandler } from "./catalogClassificationNode";
describe("catalog classification runner", () => {
it("loads bounded latest skill evidence without scanning historical versions", async () => {
const paginate = vi.fn().mockResolvedValue({
page: [
{
_id: "skills:demo",
slug: "web-research",
displayName: "Web Research",
summary: "Search the web for current sources",
latestVersionId: "skillVersions:v1",
},
],
isDone: true,
continueCursor: "done",
});
const get = vi.fn().mockResolvedValue({
_id: "skillVersions:v1",
skillId: "skills:demo",
files: [
{
path: "SKILL.md",
size: 100,
storageId: "storage:skill",
},
{
path: "archive/README.md",
size: 100,
storageId: "storage:readme",
},
],
});
const result = await getCatalogClassificationPageInternalHandler(
{
db: {
query: vi.fn(() => ({
order: vi.fn(() => ({ paginate })),
})),
get,
},
} as never,
{ targetKind: "skill", batchSize: 10 },
);
expect(result.items).toEqual([
expect.objectContaining({
kind: "skill",
skillId: "skills:demo",
skillVersionId: "skillVersions:v1",
textFile: { path: "SKILL.md", storageId: "storage:skill" },
}),
]);
expect(get).toHaveBeenCalledTimes(1);
});
it("stores preview classifications without changing source artifacts", async () => {
const runMutation = vi.fn().mockResolvedValue({ ok: true, upserted: 1 });
const result = await classifyCatalogInternalHandler(
{
runQuery: vi.fn().mockResolvedValue({
items: [
{
kind: "skill",
skillId: "skills:demo",
skillVersionId: "skillVersions:v1",
slug: "web-research",
displayName: "Web Research",
summary: "Search the web for current research sources",
textFile: { path: "SKILL.md", storageId: "storage:skill" },
},
],
cursor: "done",
isDone: true,
}),
runMutation,
storage: {
get: vi
.fn()
.mockResolvedValue(
new Blob([
"---\nname: web-research\ndescription: Web search for current research sources.\n---\n# Web Research",
]),
),
},
scheduler: { runAfter: vi.fn() },
} as never,
{ targetKind: "skill", batchSize: 10 },
);
expect(result).toMatchObject({
ok: true,
targetKind: "skill",
scanned: 1,
classified: 1,
skipped: 0,
failed: 0,
isDone: true,
scheduledNext: false,
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
results: [
expect.objectContaining({
targetKind: "skill",
skillId: "skills:demo",
skillVersionId: "skillVersions:v1",
categories: ["research"],
classifierVersion: "taxonomy-prototype-v9",
}),
],
}),
);
});
it("skips skill-family packages in the plugin classification lane", async () => {
const paginate = vi.fn().mockResolvedValue({
page: [
{
_id: "packages:skill",
family: "skill",
latestReleaseId: "packageReleases:v1",
},
],
isDone: true,
continueCursor: "done",
});
const get = vi.fn();
const result = await getCatalogClassificationPageInternalHandler(
{
db: {
query: vi.fn(() => ({
order: vi.fn(() => ({ paginate })),
})),
get,
},
} as never,
{ targetKind: "plugin", batchSize: 10 },
);
expect(result.items).toEqual([{ kind: "skip", targetKind: "plugin", reason: "not-plugin" }]);
expect(get).not.toHaveBeenCalled();
});
});
+333
View File
@@ -0,0 +1,333 @@
import { ConvexError, v } from "convex/values";
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
import { action, internalMutation, internalQuery } from "./_generated/server";
import { assertRole, requireUserFromAction } from "./lib/access";
import type {
CatalogClassificationConfidence,
CatalogClassifierResult,
} from "./lib/catalogClassification";
const DEFAULT_CLASSIFICATION_BATCH_SIZE = 10;
const MAX_CLASSIFICATION_BATCH_SIZE = 25;
const MAX_STATIC_TEXT_FILE_SIZE = 512_000;
const MAX_PLUGIN_TEXT_FILES = 8;
const targetKindValidator = v.union(v.literal("skill"), v.literal("plugin"));
const confidenceValidator = v.union(v.literal("high"), v.literal("medium"), v.literal("low"));
const categoryCandidateValidator = v.object({
category: v.string(),
score: v.number(),
sources: v.array(v.string()),
evidence: v.array(v.string()),
strongEvidence: v.optional(v.boolean()),
primaryEvidence: v.optional(v.boolean()),
strongPrimaryEvidence: v.optional(v.boolean()),
primaryEvidenceCount: v.optional(v.number()),
});
const topicCandidateValidator = v.object({
topic: v.string(),
slug: v.string(),
score: v.number(),
sources: v.array(v.string()),
evidence: v.array(v.string()),
primaryEvidence: v.boolean(),
primarySourceCount: v.number(),
strongEvidence: v.boolean(),
confidence: confidenceValidator,
suppressedBy: v.optional(v.string()),
});
const classificationResultInputValidator = v.object({
targetKind: targetKindValidator,
skillId: v.optional(v.id("skills")),
packageId: v.optional(v.id("packages")),
skillVersionId: v.optional(v.id("skillVersions")),
packageReleaseId: v.optional(v.id("packageReleases")),
categories: v.array(v.string()),
topics: v.array(v.string()),
categoryCandidates: v.array(categoryCandidateValidator),
topicCandidates: v.array(topicCandidateValidator),
categoryCandidateCount: v.number(),
topicCandidateCount: v.number(),
categoryConfidence: confidenceValidator,
topicConfidence: confidenceValidator,
categoryNeedsReview: v.boolean(),
topicNeedsReview: v.boolean(),
unknownSignals: v.array(v.string()),
classifierVersion: v.string(),
topicClassifierVersion: v.string(),
inputHash: v.string(),
topicInputHash: v.string(),
});
type CatalogTextFile = {
path: string;
storageId: Id<"_storage">;
};
export type CatalogClassificationPageItem =
| {
kind: "skill";
skillId: Id<"skills">;
skillVersionId: Id<"skillVersions">;
slug: string;
displayName: string;
summary?: string;
categories?: string[];
topics?: string[];
textFile?: CatalogTextFile;
}
| {
kind: "plugin";
packageId: Id<"packages">;
packageReleaseId: Id<"packageReleases">;
name: string;
displayName: string;
summary?: string;
categories?: string[];
topics?: string[];
pluginManifest?: unknown;
packageJson?: unknown;
bundleManifest?: unknown;
textFiles: CatalogTextFile[];
}
| {
kind: "skip";
targetKind: "skill" | "plugin";
reason: "soft-deleted" | "not-plugin" | "missing-latest-version" | "missing-latest-release";
};
export type CatalogClassificationPageResult = {
items: CatalogClassificationPageItem[];
cursor: string | null;
isDone: boolean;
};
export type CatalogClassificationActionResult = {
ok: true;
targetKind: "skill" | "plugin";
scanned: number;
classified: number;
skipped: number;
failed: number;
confidence: Record<CatalogClassificationConfidence, number>;
topicConfidence: Record<CatalogClassificationConfidence, number>;
cursor: string | null;
isDone: boolean;
scheduledNext: boolean;
};
function clampBatchSize(value: number | undefined) {
const integer = Number.isFinite(value)
? Math.floor(value ?? 0)
: DEFAULT_CLASSIFICATION_BATCH_SIZE;
return Math.max(1, Math.min(MAX_CLASSIFICATION_BATCH_SIZE, integer));
}
function findSkillTextFile(version: Doc<"skillVersions">): CatalogTextFile | undefined {
const file = version.files.find((candidate) => {
const path = candidate.path.toLowerCase();
return (
candidate.size <= MAX_STATIC_TEXT_FILE_SIZE &&
(path === "skill.md" || path === "skills.md" || path.endsWith("/skill.md"))
);
});
return file ? { path: file.path, storageId: file.storageId } : undefined;
}
function findPluginTextFiles(release: Doc<"packageReleases">): CatalogTextFile[] {
return release.files
.filter((file) => {
if (file.size > MAX_STATIC_TEXT_FILE_SIZE) return false;
const name = file.path.split("/").at(-1)?.toLowerCase();
return name === "readme.md" || name === "skill.md" || name === "skills.md";
})
.slice(0, MAX_PLUGIN_TEXT_FILES)
.map((file) => ({ path: file.path, storageId: file.storageId }));
}
async function getSkillClassificationPage(
ctx: Pick<QueryCtx, "db">,
cursor: string | undefined,
batchSize: number,
): Promise<CatalogClassificationPageResult> {
const { page, isDone, continueCursor } = await ctx.db
.query("skills")
.order("asc")
.paginate({ cursor: cursor ?? null, numItems: batchSize });
const items: CatalogClassificationPageItem[] = [];
for (const skill of page) {
if (skill.softDeletedAt) {
items.push({ kind: "skip", targetKind: "skill", reason: "soft-deleted" });
continue;
}
if (!skill.latestVersionId) {
items.push({ kind: "skip", targetKind: "skill", reason: "missing-latest-version" });
continue;
}
const version = await ctx.db.get(skill.latestVersionId);
if (!version || version.softDeletedAt || version.skillId !== skill._id) {
items.push({ kind: "skip", targetKind: "skill", reason: "missing-latest-version" });
continue;
}
items.push({
kind: "skill",
skillId: skill._id,
skillVersionId: version._id,
slug: skill.slug,
displayName: skill.displayName,
summary: skill.summary,
categories: skill.categories,
topics: skill.topics,
textFile: findSkillTextFile(version),
});
}
return { items, cursor: continueCursor, isDone };
}
async function getPluginClassificationPage(
ctx: Pick<QueryCtx, "db">,
cursor: string | undefined,
batchSize: number,
): Promise<CatalogClassificationPageResult> {
const { page, isDone, continueCursor } = await ctx.db
.query("packages")
.order("asc")
.paginate({ cursor: cursor ?? null, numItems: batchSize });
const items: CatalogClassificationPageItem[] = [];
for (const pkg of page) {
if (pkg.family === "skill") {
items.push({ kind: "skip", targetKind: "plugin", reason: "not-plugin" });
continue;
}
if (pkg.softDeletedAt) {
items.push({ kind: "skip", targetKind: "plugin", reason: "soft-deleted" });
continue;
}
if (!pkg.latestReleaseId) {
items.push({ kind: "skip", targetKind: "plugin", reason: "missing-latest-release" });
continue;
}
const release = await ctx.db.get(pkg.latestReleaseId);
if (!release || release.softDeletedAt || release.packageId !== pkg._id) {
items.push({ kind: "skip", targetKind: "plugin", reason: "missing-latest-release" });
continue;
}
items.push({
kind: "plugin",
packageId: pkg._id,
packageReleaseId: release._id,
name: pkg.name,
displayName: pkg.displayName,
summary: pkg.summary,
categories: pkg.categories,
topics: pkg.topics,
pluginManifest: release.extractedPluginManifest,
packageJson: release.extractedPackageJson,
bundleManifest: release.normalizedBundleManifest,
textFiles: findPluginTextFiles(release),
});
}
return { items, cursor: continueCursor, isDone };
}
export async function getCatalogClassificationPageInternalHandler(
ctx: Pick<QueryCtx, "db">,
args: {
targetKind: "skill" | "plugin";
cursor?: string;
batchSize?: number;
},
): Promise<CatalogClassificationPageResult> {
const batchSize = clampBatchSize(args.batchSize);
return args.targetKind === "skill"
? getSkillClassificationPage(ctx, args.cursor, batchSize)
: getPluginClassificationPage(ctx, args.cursor, batchSize);
}
export const getCatalogClassificationPageInternal = internalQuery({
args: {
targetKind: targetKindValidator,
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
},
handler: getCatalogClassificationPageInternalHandler,
});
export const upsertCatalogClassificationResultsInternal = internalMutation({
args: { results: v.array(classificationResultInputValidator) },
returns: v.object({ ok: v.literal(true), upserted: v.number() }),
handler: async (ctx, args) => {
const classifiedAt = Date.now();
for (const result of args.results) {
const isSkill = result.targetKind === "skill";
if (
(isSkill && (!result.skillId || !result.skillVersionId || result.packageId)) ||
(!isSkill && (!result.packageId || !result.packageReleaseId || result.skillId))
) {
throw new ConvexError("Catalog classification result target is inconsistent");
}
const existing = isSkill
? await ctx.db
.query("catalogClassificationResults")
.withIndex("by_skill", (q) => q.eq("skillId", result.skillId))
.unique()
: await ctx.db
.query("catalogClassificationResults")
.withIndex("by_package", (q) => q.eq("packageId", result.packageId))
.unique();
const value = {
...result,
applyStatus: "preview" as const,
error: undefined,
classifiedAt,
appliedAt: undefined,
};
if (existing) await ctx.db.patch(existing._id, value);
else await ctx.db.insert("catalogClassificationResults", value);
}
return { ok: true as const, upserted: args.results.length };
},
});
export const classifyCatalog: ReturnType<typeof action> = action({
args: {
targetKind: targetKindValidator,
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
continueOnIncomplete: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<CatalogClassificationActionResult> => {
const { user } = await requireUserFromAction(ctx);
assertRole(user, ["admin"]);
return ctx.runAction(internal.catalogClassificationNode.classifyCatalogInternal, args);
},
});
export const applyCatalogClassifications: ReturnType<typeof action> = action({
args: {
dryRun: v.optional(v.boolean()),
minimumConfidence: v.union(v.literal("high"), v.literal("medium")),
confirm: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { user } = await requireUserFromAction(ctx);
assertRole(user, ["admin"]);
return ctx.runAction(internal.migrations.runCatalogClassificationApply, args);
},
});
export type StoredCatalogClassificationInput = ReturnType<
typeof import("./lib/catalogClassification").prepareCatalogClassificationResult
> & {
targetKind: "skill" | "plugin";
skillId?: Id<"skills">;
packageId?: Id<"packages">;
skillVersionId?: Id<"skillVersions">;
packageReleaseId?: Id<"packageReleases">;
};
export type CatalogClassifierFunctionResult = CatalogClassifierResult;
+229
View File
@@ -0,0 +1,229 @@
"use node";
import { v } from "convex/values";
import { internal } from "./_generated/api";
import type { ActionCtx } from "./_generated/server";
import { internalAction } from "./_generated/server";
import type {
CatalogClassificationActionResult,
CatalogClassificationPageItem,
CatalogClassificationPageResult,
StoredCatalogClassificationInput,
} from "./catalogClassification";
import {
prepareCatalogClassificationResult,
type CatalogClassificationConfidence,
} from "./lib/catalogClassification";
import { classifyPlugin, classifySkill } from "./lib/catalogClassifier.mjs";
const DEFAULT_MAX_BATCHES = 1;
const MAX_MAX_BATCHES = 20;
const MAX_CLASSIFICATION_TEXT_LENGTH = 40_000;
function clampMaxBatches(value: number | undefined) {
const integer = Number.isFinite(value) ? Math.floor(value ?? 0) : DEFAULT_MAX_BATCHES;
return Math.max(1, Math.min(MAX_MAX_BATCHES, integer));
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : "";
}
function stringArray(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}
async function readTextFiles(ctx: Pick<ActionCtx, "storage">, files: Array<{ storageId: never }>) {
const chunks: string[] = [];
for (const file of files) {
const blob = await ctx.storage.get(file.storageId);
if (!blob) continue;
chunks.push((await blob.text()).slice(0, MAX_CLASSIFICATION_TEXT_LENGTH));
if (chunks.join("\n").length >= MAX_CLASSIFICATION_TEXT_LENGTH) break;
}
return chunks.join("\n").slice(0, MAX_CLASSIFICATION_TEXT_LENGTH);
}
async function classifySkillItem(
ctx: Pick<ActionCtx, "storage">,
item: Extract<CatalogClassificationPageItem, { kind: "skill" }>,
): Promise<StoredCatalogClassificationInput> {
const storedText = item.textFile
? await readTextFiles(ctx, [{ storageId: item.textFile.storageId as never }])
: "";
const text =
storedText ||
`---\nname: ${item.displayName}\ndescription: ${item.summary ?? ""}\n---\n# ${item.displayName}`;
return {
targetKind: "skill",
skillId: item.skillId,
skillVersionId: item.skillVersionId,
...prepareCatalogClassificationResult(
classifySkill({
slug: item.slug,
text,
explicitCategories: item.categories,
explicitTopics: item.topics,
}),
),
};
}
async function classifyPluginItem(
ctx: Pick<ActionCtx, "storage">,
item: Extract<CatalogClassificationPageItem, { kind: "plugin" }>,
): Promise<StoredCatalogClassificationInput> {
const manifest = asRecord(item.pluginManifest);
const packageJson = asRecord(item.packageJson);
const bundleManifest = asRecord(item.bundleManifest);
const fileText = await readTextFiles(
ctx,
item.textFiles.map((file) => ({ storageId: file.storageId as never })),
);
const packageKeywords = stringArray(packageJson.keywords);
const primaryText = [
item.displayName,
item.summary,
stringValue(manifest.description),
stringValue(packageJson.description),
stringValue(bundleManifest.description),
]
.filter(Boolean)
.join("\n");
const text = [item.name, primaryText, packageKeywords.join(" "), fileText]
.filter(Boolean)
.join("\n")
.slice(0, MAX_CLASSIFICATION_TEXT_LENGTH);
const topicText = [primaryText, fileText]
.filter(Boolean)
.join("\n")
.slice(0, MAX_CLASSIFICATION_TEXT_LENGTH);
return {
targetKind: "plugin",
packageId: item.packageId,
packageReleaseId: item.packageReleaseId,
...prepareCatalogClassificationResult(
classifyPlugin({
manifest,
slug: item.name,
text,
topicText,
topicTags: packageKeywords,
explicitCategories: item.categories,
explicitTopics: item.topics,
}),
),
};
}
function emptyConfidenceCounts(): Record<CatalogClassificationConfidence, number> {
return { high: 0, medium: 0, low: 0 };
}
export async function classifyCatalogInternalHandler(
ctx: ActionCtx,
args: {
targetKind: "skill" | "plugin";
cursor?: string;
batchSize?: number;
maxBatches?: number;
continueOnIncomplete?: boolean;
},
): Promise<CatalogClassificationActionResult> {
const maxBatches = clampMaxBatches(args.maxBatches);
const confidence = emptyConfidenceCounts();
const topicConfidence = emptyConfidenceCounts();
let cursor = args.cursor ?? null;
let isDone = false;
let scanned = 0;
let classified = 0;
let skipped = 0;
let failed = 0;
for (let batch = 0; batch < maxBatches; batch += 1) {
const page: CatalogClassificationPageResult = await ctx.runQuery(
internal.catalogClassification.getCatalogClassificationPageInternal,
{
targetKind: args.targetKind,
cursor: cursor ?? undefined,
batchSize: args.batchSize,
},
);
scanned += page.items.length;
const results: StoredCatalogClassificationInput[] = [];
for (const item of page.items) {
if (item.kind === "skip") {
skipped += 1;
continue;
}
try {
const result =
item.kind === "skill"
? await classifySkillItem(ctx, item)
: await classifyPluginItem(ctx, item);
results.push(result);
confidence[result.categoryConfidence] += 1;
topicConfidence[result.topicConfidence] += 1;
classified += 1;
} catch (error) {
console.error("Catalog classification failed", {
targetKind: args.targetKind,
error: error instanceof Error ? error.message : String(error),
});
failed += 1;
}
}
if (results.length > 0) {
await ctx.runMutation(
internal.catalogClassification.upsertCatalogClassificationResultsInternal,
{
results,
},
);
}
cursor = page.cursor;
isDone = page.isDone;
if (page.isDone) break;
}
const scheduledNext = !isDone && Boolean(args.continueOnIncomplete);
if (scheduledNext) {
await ctx.scheduler.runAfter(0, internal.catalogClassificationNode.classifyCatalogInternal, {
...args,
cursor: cursor ?? undefined,
});
}
return {
ok: true,
targetKind: args.targetKind,
scanned,
classified,
skipped,
failed,
confidence,
topicConfidence,
cursor,
isDone,
scheduledNext,
};
}
export const classifyCatalogInternal = internalAction({
args: {
targetKind: v.union(v.literal("skill"), v.literal("plugin")),
cursor: v.optional(v.string()),
batchSize: v.optional(v.number()),
maxBatches: v.optional(v.number()),
continueOnIncomplete: v.optional(v.boolean()),
},
handler: classifyCatalogInternalHandler,
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import {
CATALOG_CATEGORY_CANDIDATE_STORAGE_LIMIT,
CATALOG_TOPIC_CANDIDATE_STORAGE_LIMIT,
CATALOG_UNKNOWN_SIGNAL_STORAGE_LIMIT,
prepareCatalogClassificationResult,
selectCatalogInference,
} from "./catalogClassification";
const baseResult = {
categories: ["development"],
topics: ["TypeScript", "Code Review"],
rawCandidates: [
{
category: "development",
score: 20,
sources: ["skill-text"],
evidence: ["software development"],
},
],
rawTopicCandidates: [
{
topic: "TypeScript",
slug: "typescript",
score: 12,
sources: ["skill-text-primary"],
evidence: ["skill primary: typescript"],
primaryEvidence: true,
primarySourceCount: 1,
strongEvidence: true,
confidence: "high" as const,
},
],
confidence: "high" as const,
topicConfidence: "medium" as const,
needsAi: false,
topicsNeedAi: true,
unknownSignals: [],
classifierVersion: "taxonomy-prototype-v9",
topicClassifierVersion: "topic-prototype-v1",
inputHash: "category-hash",
topicInputHash: "topic-hash",
};
describe("catalog classification persistence", () => {
it("applies only confidence lanes accepted by the operator threshold", () => {
expect(
selectCatalogInference({
currentSourceId: "version:1",
resultSourceId: "version:1",
result: baseResult,
minimumConfidence: "high",
}),
).toEqual({
status: "applied",
categories: ["development"],
topics: undefined,
});
expect(
selectCatalogInference({
currentSourceId: "version:1",
resultSourceId: "version:1",
result: baseResult,
minimumConfidence: "medium",
}),
).toEqual({
status: "applied",
categories: ["development"],
topics: ["TypeScript", "Code Review"],
});
});
it("never overwrites author metadata and treats explicit empty topics as authoritative", () => {
expect(
selectCatalogInference({
currentSourceId: "version:1",
resultSourceId: "version:1",
authorCategories: ["operations"],
authorTopics: [],
result: baseResult,
minimumConfidence: "medium",
}),
).toEqual({
status: "skipped-author",
categories: undefined,
topics: undefined,
});
});
it("rejects stale classifications before application", () => {
expect(
selectCatalogInference({
currentSourceId: "version:2",
resultSourceId: "version:1",
result: baseResult,
minimumConfidence: "medium",
}),
).toEqual({
status: "stale",
categories: undefined,
topics: undefined,
});
});
it("bounds persisted candidates while retaining original counts", () => {
const prepared = prepareCatalogClassificationResult({
...baseResult,
rawCandidates: Array.from(
{ length: CATALOG_CATEGORY_CANDIDATE_STORAGE_LIMIT + 3 },
(_, index) => ({
category: `category-${index}`,
score: index,
sources: ["test"],
evidence: ["test"],
}),
),
rawTopicCandidates: Array.from(
{ length: CATALOG_TOPIC_CANDIDATE_STORAGE_LIMIT + 7 },
(_, index) => ({
topic: `Topic ${index}`,
slug: `topic-${index}`,
score: index,
sources: ["test"],
evidence: ["test"],
primaryEvidence: true,
primarySourceCount: 1,
strongEvidence: false,
confidence: "medium" as const,
}),
),
unknownSignals: Array.from(
{ length: CATALOG_UNKNOWN_SIGNAL_STORAGE_LIMIT + 9 },
(_, index) => `unknown-${index}`,
),
});
expect(prepared.categoryCandidates).toHaveLength(CATALOG_CATEGORY_CANDIDATE_STORAGE_LIMIT);
expect(prepared.topicCandidates).toHaveLength(CATALOG_TOPIC_CANDIDATE_STORAGE_LIMIT);
expect(prepared.categoryCandidateCount).toBe(CATALOG_CATEGORY_CANDIDATE_STORAGE_LIMIT + 3);
expect(prepared.topicCandidateCount).toBe(CATALOG_TOPIC_CANDIDATE_STORAGE_LIMIT + 7);
expect(prepared.unknownSignals).toHaveLength(CATALOG_UNKNOWN_SIGNAL_STORAGE_LIMIT);
});
it("stores only the declared bounded review evidence contract", () => {
const prepared = prepareCatalogClassificationResult({
...baseResult,
rawCandidates: [
{
...baseResult.rawCandidates[0],
sources: Array.from({ length: 20 }, (_, index) => `source-${index}`),
evidence: Array.from({ length: 20 }, (_, index) => `evidence-${index}`),
primaryEvidenceTerms: ["typescript"],
bodyEvidenceTerms: ["development"],
} as (typeof baseResult.rawCandidates)[number],
],
});
expect(prepared.categoryCandidates[0]).toEqual({
category: "development",
score: 20,
sources: Array.from({ length: 8 }, (_, index) => `source-${index}`),
evidence: Array.from({ length: 12 }, (_, index) => `evidence-${index}`),
});
});
});
+162
View File
@@ -0,0 +1,162 @@
export const CATALOG_CATEGORY_CANDIDATE_STORAGE_LIMIT = 32;
export const CATALOG_TOPIC_CANDIDATE_STORAGE_LIMIT = 100;
export const CATALOG_UNKNOWN_SIGNAL_STORAGE_LIMIT = 100;
const CATALOG_CANDIDATE_SOURCE_STORAGE_LIMIT = 8;
const CATALOG_CANDIDATE_EVIDENCE_STORAGE_LIMIT = 12;
export type CatalogClassificationConfidence = "high" | "medium" | "low";
export type CatalogClassificationApplyStatus =
| "preview"
| "applied"
| "stale"
| "skipped-author"
| "error";
type CatalogCategoryCandidate = {
category: string;
score: number;
sources: string[];
evidence: string[];
strongEvidence?: boolean;
primaryEvidence?: boolean;
strongPrimaryEvidence?: boolean;
primaryEvidenceCount?: number;
};
type CatalogTopicCandidate = {
topic: string;
slug: string;
score: number;
sources: string[];
evidence: string[];
primaryEvidence: boolean;
primarySourceCount: number;
strongEvidence: boolean;
confidence: CatalogClassificationConfidence;
suppressedBy?: string;
};
export type CatalogClassifierResult = {
categories: string[];
topics: string[];
rawCandidates: CatalogCategoryCandidate[];
rawTopicCandidates: CatalogTopicCandidate[];
confidence: CatalogClassificationConfidence;
topicConfidence: CatalogClassificationConfidence;
needsAi: boolean;
topicsNeedAi: boolean;
unknownSignals: string[];
classifierVersion: string;
topicClassifierVersion: string;
inputHash: string;
topicInputHash: string;
};
export function prepareCatalogClassificationResult(result: CatalogClassifierResult) {
return {
categories: result.categories,
topics: result.topics,
categoryCandidates: result.rawCandidates
.slice(0, CATALOG_CATEGORY_CANDIDATE_STORAGE_LIMIT)
.map((candidate) => ({
category: candidate.category,
score: candidate.score,
sources: candidate.sources.slice(0, CATALOG_CANDIDATE_SOURCE_STORAGE_LIMIT),
evidence: candidate.evidence.slice(0, CATALOG_CANDIDATE_EVIDENCE_STORAGE_LIMIT),
...(candidate.strongEvidence === undefined
? {}
: { strongEvidence: candidate.strongEvidence }),
...(candidate.primaryEvidence === undefined
? {}
: { primaryEvidence: candidate.primaryEvidence }),
...(candidate.strongPrimaryEvidence === undefined
? {}
: { strongPrimaryEvidence: candidate.strongPrimaryEvidence }),
...(candidate.primaryEvidenceCount === undefined
? {}
: { primaryEvidenceCount: candidate.primaryEvidenceCount }),
})),
topicCandidates: result.rawTopicCandidates
.slice(0, CATALOG_TOPIC_CANDIDATE_STORAGE_LIMIT)
.map((candidate) => ({
topic: candidate.topic,
slug: candidate.slug,
score: candidate.score,
sources: candidate.sources.slice(0, CATALOG_CANDIDATE_SOURCE_STORAGE_LIMIT),
evidence: candidate.evidence.slice(0, CATALOG_CANDIDATE_EVIDENCE_STORAGE_LIMIT),
primaryEvidence: candidate.primaryEvidence,
primarySourceCount: candidate.primarySourceCount,
strongEvidence: candidate.strongEvidence,
confidence: candidate.confidence,
...(candidate.suppressedBy === undefined ? {} : { suppressedBy: candidate.suppressedBy }),
})),
categoryCandidateCount: result.rawCandidates.length,
topicCandidateCount: result.rawTopicCandidates.length,
categoryConfidence: result.confidence,
topicConfidence: result.topicConfidence,
categoryNeedsReview: result.needsAi,
topicNeedsReview: result.topicsNeedAi,
unknownSignals: result.unknownSignals.slice(0, CATALOG_UNKNOWN_SIGNAL_STORAGE_LIMIT),
classifierVersion: result.classifierVersion,
topicClassifierVersion: result.topicClassifierVersion,
inputHash: result.inputHash,
topicInputHash: result.topicInputHash,
};
}
const CONFIDENCE_RANK: Record<CatalogClassificationConfidence, number> = {
low: 0,
medium: 1,
high: 2,
};
function confidenceAtLeast(
value: CatalogClassificationConfidence,
minimum: CatalogClassificationConfidence,
) {
return CONFIDENCE_RANK[value] >= CONFIDENCE_RANK[minimum];
}
export function selectCatalogInference(input: {
currentSourceId?: string | null;
resultSourceId?: string | null;
authorCategories?: readonly string[] | null;
authorTopics?: readonly string[] | null;
result: Pick<CatalogClassifierResult, "categories" | "topics" | "confidence" | "topicConfidence">;
minimumConfidence: CatalogClassificationConfidence;
}): {
status: CatalogClassificationApplyStatus;
categories?: string[];
topics?: string[];
} {
if (
!input.currentSourceId ||
!input.resultSourceId ||
input.currentSourceId !== input.resultSourceId
) {
return { status: "stale", categories: undefined, topics: undefined };
}
const categories =
input.authorCategories === undefined &&
confidenceAtLeast(input.result.confidence, input.minimumConfidence)
? [...input.result.categories]
: undefined;
const topics =
input.authorTopics === undefined &&
confidenceAtLeast(input.result.topicConfidence, input.minimumConfidence)
? [...input.result.topics]
: undefined;
const authorOwnsBoth = input.authorCategories !== undefined && input.authorTopics !== undefined;
return {
status:
categories !== undefined || topics !== undefined
? "applied"
: authorOwnsBoth
? "skipped-author"
: "preview",
categories,
topics,
};
}
+22
View File
@@ -0,0 +1,22 @@
import type { CatalogClassifierResult } from "./catalogClassification";
export const CLASSIFIER_VERSION: string;
export const TOPIC_CLASSIFIER_VERSION: string;
export function classifySkill(input?: {
slug?: string;
text?: string;
explicitCategories?: readonly string[];
explicitTopics?: readonly string[];
topicTags?: readonly string[];
}): CatalogClassifierResult;
export function classifyPlugin(input?: {
manifest?: Record<string, unknown>;
slug?: string;
text?: string;
topicText?: string;
explicitCategories?: readonly string[];
explicitTopics?: readonly string[];
topicTags?: readonly string[];
}): CatalogClassifierResult;
File diff suppressed because it is too large Load Diff
+863
View File
@@ -0,0 +1,863 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import {
classifyPlugin,
classifySkill,
PLUGIN_CATEGORY_SLUGS,
SKILL_CATEGORY_SLUGS,
TOPIC_CLASSIFIER_VERSION,
} from "./catalogClassifier.mjs";
test("category registries include visible Other fallbacks", () => {
assert.deepEqual(PLUGIN_CATEGORY_SLUGS, [
"channels",
"models",
"memory",
"context",
"voice",
"media",
"web",
"tools",
"runtime",
"gateway",
"security",
"other",
]);
assert.deepEqual(SKILL_CATEGORY_SLUGS, [
"integrations",
"automation",
"research",
"development",
"productivity",
"communication",
"creative",
"knowledge",
"agents",
"operations",
"security",
"finance",
"lifestyle",
"other",
]);
});
test("explicit plugin categories preserve author order after retained exclusive kinds", () => {
const result = classifyPlugin({
explicitCategories: ["web", "models", "media", "voice"],
manifest: {
kind: "memory",
providers: ["demo"],
contracts: { speechProviders: ["demo"] },
},
});
assert.deepEqual(result.categories, ["memory", "web", "models"]);
assert.equal(result.provenance, "author");
assert.equal(result.needsAi, false);
});
test("plugin context-engine kind is retained as Context rather than Memory", () => {
const result = classifyPlugin({
manifest: {
kind: "context-engine",
contracts: { tools: ["demo"] },
},
});
assert.deepEqual(result.categories, ["context", "tools"]);
});
test("plugin exclusive memory kind is retained when auto candidates exceed three", () => {
const result = classifyPlugin({
manifest: {
kind: "memory",
channels: ["demo"],
providers: ["demo"],
contracts: {
speechProviders: ["demo"],
imageGenerationProviders: ["demo"],
webSearchProviders: ["demo"],
},
},
});
assert.equal(result.categories.includes("memory"), true);
assert.equal(result.categories.length, 3);
assert.equal(result.rawCandidates.length > 3, true);
assert.equal(result.needsAi, true);
});
test("plugin contribution contracts map without inspecting runtime code", () => {
const result = classifyPlugin({
manifest: {
contracts: {
webSearchProviders: ["demo"],
externalAuthProviders: ["demo"],
gatewayMethodDispatch: ["authenticated-request"],
},
},
});
assert.deepEqual(result.categories, ["web", "gateway", "security"]);
assert.equal(result.confidence, "high");
assert.equal(result.needsAi, false);
});
test("generic skills-only plugin contribution maps to broad Tools and enters AI review", () => {
const result = classifyPlugin({
manifest: {
skills: ["skills/research/SKILL.md"],
},
});
assert.deepEqual(result.categories, ["tools"]);
assert.equal(result.rawCandidates[0].category, "tools");
assert.equal(result.confidence, "medium");
assert.equal(result.needsAi, true);
});
test("static plugin text classifies hook-only and missing-manifest plugins", () => {
const result = classifyPlugin({
slug: "clawguard",
text: "Security policy enforcement, permission checks, and prompt injection protection.",
manifest: {
kind: "hook-only",
configSchema: { type: "object" },
},
});
assert.deepEqual(result.categories, ["security", "runtime"]);
assert.equal(result.categories.includes("other"), false);
assert.equal(result.needsAi, true);
});
test("specific static plugin text suppresses broad weak Tools fallback", () => {
const result = classifyPlugin({
slug: "research-bundle",
text: "Web search and browser research toolkit.",
manifest: {
skills: ["skills/research/SKILL.md"],
},
});
assert.deepEqual(result.categories, ["web"]);
});
test("unknown plugin contracts are preserved and force review", () => {
const result = classifyPlugin({
manifest: {
contracts: {
webSearchProviders: ["demo"],
futureQuantumProviders: ["demo"],
},
},
});
assert.deepEqual(result.categories, ["web"]);
assert.deepEqual(result.unknownSignals, ["contracts.futureQuantumProviders"]);
assert.equal(result.confidence, "medium");
assert.equal(result.needsAi, true);
});
test("hook-only plugin uses weak Runtime fallback instead of Other", () => {
const result = classifyPlugin({
manifest: {
kind: "hook-only",
configSchema: { type: "object" },
},
});
assert.deepEqual(result.categories, ["runtime"]);
assert.equal(result.confidence, "medium");
assert.equal(result.needsAi, true);
});
test("high-confidence web search skill can skip AI", () => {
const result = classifySkill({
slug: "search-with-tavily",
text: [
"--- name: tavily-search description: Web search using Tavily API for current research. ---",
"# Tavily Search",
"Search the web, retrieve current news, and collect research sources.",
].join("\n"),
});
assert.deepEqual(result.categories, ["research"]);
assert.equal(result.confidence, "high");
assert.equal(result.needsAi, false);
});
test("incidental API authentication does not classify a skill as security", () => {
const result = classifySkill({
slug: "invoice-api-client",
text: [
"---",
"name: invoice-api-client",
"description: Fetch invoice records from a REST API and return JSON.",
"metadata:",
" requires:",
" env: [API_SECRET]",
"---",
"# Invoice API Client",
"Authenticate with the API token, then request invoice records.",
].join("\n"),
});
assert.equal(result.categories.includes("integrations"), true);
assert.equal(result.categories.includes("security"), false);
});
test("intent-level security skill is classified as security", () => {
const result = classifySkill({
slug: "dependency-security-audit",
text: [
"---",
"name: dependency-security-audit",
"description: Audit dependencies for vulnerabilities and credential leaks.",
"---",
"# Dependency Security Audit",
"Scan source dependencies and report remediation guidance.",
].join("\n"),
});
assert.equal(result.categories.includes("security"), true);
assert.equal(result.confidence, "high");
});
test("operations and security are separate skill categories", () => {
const result = classifySkill({
slug: "docker-deployer",
text: [
"---",
"name: docker-deployer",
"description: Deploy and monitor Docker services in production.",
"---",
"# Docker Deployer",
].join("\n"),
});
assert.equal(result.categories.includes("operations"), true);
assert.equal(result.categories.includes("security"), false);
});
test("frontmatter operational metadata is not treated as primary category intent", () => {
const result = classifySkill({
slug: "image-maker",
text: [
"---",
"name: image-maker",
"description: Generate and edit images for social media campaigns.",
"metadata:",
" openclaw:",
" requires:",
" env: [IMAGE_API_SECRET]",
"---",
"# Image Maker",
"Use authentication to call the hosted service.",
].join("\n"),
});
assert.equal(result.categories.includes("creative"), true);
assert.equal(result.categories.includes("security"), false);
});
test("inline frontmatter does not promote incidental body terms over the stated purpose", () => {
const result = classifySkill({
slug: "ffmpeg-master-pro",
text: [
"--- name: ffmpeg-master-pro description: 全能视频处理技能,支持视频转换、压缩和编辑。 ---",
"# 视频处理",
"Includes presets named wechat and social_media for output compatibility.",
].join("\n"),
});
assert.equal(result.categories.includes("creative"), true);
assert.equal(result.categories.includes("communication"), false);
});
test("body-only category evidence remains raw evidence instead of an exposed category", () => {
const result = classifySkill({
slug: "pentest-reference",
text: [
"--- name: pentest-reference description: Browse penetration testing resources for security audits. ---",
"# Pentest Reference",
"The implementation can be used in a larger automation pipeline and batch workflow.",
].join("\n"),
});
assert.equal(result.categories.includes("security"), true);
assert.equal(result.categories.includes("automation"), false);
assert.equal(
result.rawCandidates.some((candidate) => candidate.category === "automation"),
true,
);
});
test("inline frontmatter closes before a later Markdown separator", () => {
const result = classifySkill({
slug: "awesome-pentest",
text: [
"--- name: awesome-pentest description: Security audits and penetration testing reference. ---",
"# Awesome Pentest",
"Use this reference from an automation pipeline and batch workflow.",
"",
"---",
"Footer: automation pipeline workflow.",
].join("\n"),
});
assert.equal(result.categories.includes("security"), true);
assert.equal(result.categories.includes("automation"), false);
assert.equal(
result.rawCandidates.some((candidate) => candidate.category === "automation"),
true,
);
});
test("later Markdown separator does not promote communication platforms from the body", () => {
const result = classifySkill({
slug: "ffmpeg-master-pro",
text: [
"--- name: ffmpeg-master-pro description: 全能视频处理技能,支持视频转换、压缩和编辑。 ---",
"# 视频处理",
"输出兼容微信、抖音和小红书。",
"",
"---",
"更多平台预设。",
].join("\n"),
});
assert.equal(result.categories.includes("creative"), true);
assert.equal(result.categories.includes("communication"), false);
});
test("flattened inline frontmatter does not treat the entire body as a heading", () => {
const result = classifySkill({
slug: "find-stl",
text: "--- name: find-stl description: Search and download ready-to-print 3D model files. --- # find-stl This skill writes manifest.json files and uses GraphQL. ## Resources API integration details.",
});
assert.equal(result.categories.includes("creative"), true);
assert.equal(result.categories.includes("integrations"), false);
});
test("every exposed secondary skill category requires purpose-level primary evidence", () => {
const result = classifySkill({
slug: "video-editor",
text: "--- name: video-editor description: Edit and process videos with a simple API. --- # Video Editor ## Implementation Automate workflows and batch processing.",
});
assert.equal(result.categories.includes("creative"), true);
assert.equal(result.categories.includes("integrations"), false);
assert.equal(result.categories.includes("automation"), false);
assert.equal(
result.rawCandidates.some((candidate) => candidate.category === "automation"),
true,
);
});
test("a corroborated single primary signal may expose only a medium top candidate", () => {
const result = classifySkill({
slug: "desktime",
text: "--- name: desktime description: DeskTime integration for user and project records. --- # DeskTime ## Implementation Uses an API and returns JSON.",
});
assert.deepEqual(result.categories, ["integrations"]);
assert.equal(result.confidence, "medium");
assert.equal(result.needsAi, true);
});
test("owner handle words do not become skill category evidence", () => {
const result = classifySkill({
slug: "design-owner/local-service-booking",
text: "--- name: local-service-booking description: Find and book local plumbers and electricians. --- # Local Service Booking",
});
assert.equal(result.categories.includes("creative"), false);
});
test("a social-media destination does not outrank a creative media purpose", () => {
const result = classifySkill({
slug: "video-generator",
text: "--- name: video-generator description: Generate and edit videos ready to share on social media. --- # Video Generator",
});
assert.equal(result.categories.includes("creative"), true);
assert.equal(result.categories.includes("communication"), false);
});
test("a communication platform mention alone cannot create high confidence", () => {
const result = classifySkill({
slug: "prediction-market-creator",
text: "--- name: prediction-market-creator description: Create prediction markets by analyzing trending Twitter content. --- # Prediction Market Creator",
});
assert.notEqual(result.confidence, "high");
assert.equal(result.needsAi, true);
});
test("full-stack development intent outranks incidental deployment scope", () => {
const result = classifySkill({
slug: "fullstack-dev-engineer",
text: "--- name: fullstack-dev-engineer description: 全栈开发、前端开发、后端开发与运维部署指导。 --- # 全栈开发工程师",
});
assert.equal(result.categories[0], "development");
});
test("recurring domain-specific titles map to their purpose categories", () => {
const competitor = classifySkill({
slug: "competitor-monitoring",
text: "--- name: competitor-monitoring description: Track competitors with pricing alerts and positioning analysis. --- # Competitor Monitoring",
});
const privacy = classifySkill({
slug: "dpia-drafter",
text: "--- name: dpia-drafter description: Draft a GDPR data protection impact assessment for privacy counsel. --- # DPIA Drafter",
});
const document = classifySkill({
slug: "driving-license-recognition",
text: "--- name: driving-license-recognition description: OCR document recognition and information extraction for driving licenses. --- # License Recognition",
});
assert.equal(competitor.categories.includes("research"), true);
assert.equal(privacy.categories.includes("security"), true);
assert.equal(document.categories.includes("knowledge"), true);
});
test("remaining recurring purpose cues map without lowering global thresholds", () => {
const research = classifySkill({
slug: "arxiv-literature-review",
text: "--- name: arxiv-literature-review description: Read arXiv papers and prepare a literature review. ---",
});
const communication = classifySkill({
slug: "gmail-forward",
text: "--- name: gmail-forward description: Forward Gmail messages to new recipients. ---",
});
const integrations = classifySkill({
slug: "google-sheets",
text: "--- name: google-sheets description: Read and write spreadsheets in Google Sheets. ---",
});
const agents = classifySkill({
slug: "weekly-self-improve-loop",
text: "--- name: weekly-self-improve-loop description: Run a weekly self-improve review for the agent. ---",
});
const operations = classifySkill({
slug: "log-analyzer",
text: "--- name: log-analyzer description: Perform log analysis for running services. ---",
});
const security = classifySkill({
slug: "privacy-review",
text: "--- name: privacy-review description: Review privacy requirements and controls. ---",
});
assert.equal(research.categories.includes("research"), true);
assert.equal(communication.categories.includes("communication"), true);
assert.equal(integrations.categories.includes("integrations"), true);
assert.equal(agents.categories.includes("agents"), true);
assert.equal(operations.categories.includes("operations"), true);
assert.equal(security.categories.includes("security"), true);
});
test("crisis communication purpose maps to Communication", () => {
const result = classifySkill({
slug: "crisis-communication",
text: "--- name: crisis-communication description: Develop crisis communication scripts, media response strategies, and statement drafting. --- # Crisis Communication ## Operations Automate business workflows.",
});
assert.equal(result.categories.includes("communication"), true);
assert.equal(result.categories.includes("automation"), false);
});
test("delimiter-free flattened metadata uses the first declared description only", () => {
const result = classifySkill({
slug: "weather-skill",
text: "name: weather-skill description: Fetches current weather information for a specified location. name: weather-skill description: Send messages through Feishu. # Implementation",
});
assert.equal(result.categories.includes("lifestyle"), true);
assert.equal(result.categories.includes("communication"), false);
});
test("delimiter-free flattened heading does not promote later body categories", () => {
const result = classifySkill({
slug: "weather-query",
text: "# Weather Query Fetch current weather information for a city. ## Implementation Send messages, publish social media posts, and automate workflows.",
});
assert.equal(result.categories.includes("lifestyle"), true);
assert.equal(result.categories.includes("communication"), false);
assert.equal(result.categories.includes("automation"), false);
});
test("generic monitoring language does not imply local systems operations", () => {
const result = classifySkill({
slug: "competitor-monitoring",
text: [
"---",
"name: competitor-monitoring",
"description: Monitor competitors, pricing, positioning, and marketing campaigns.",
"---",
"# Competitor Monitoring",
].join("\n"),
});
assert.equal(result.categories.includes("operations"), false);
});
test("generic primary keywords cannot create a high-confidence skill result", () => {
const result = classifySkill({
slug: "business-helper",
text: [
"---",
"name: business-helper",
"description: Help with business projects and tasks.",
"---",
"# Business Helper",
].join("\n"),
});
assert.notEqual(result.confidence, "high");
assert.equal(result.needsAi, true);
});
test("Chinese creative intent classifies video scripting as Creative", () => {
const result = classifySkill({
slug: "ai-video-script",
text: "--- name: ai-video-script description: AI视频脚本生成器,支持视频策划、分镜、配音文案和短视频创作。 --- # AI 视频脚本生成器",
});
assert.equal(result.categories.includes("creative"), true);
});
test("Chinese productivity intent classifies recurring work reports as Productivity", () => {
const result = classifySkill({
slug: "report-summary-builder",
text: "--- name: report-summary-builder description: 基于已有日报自动汇总生成周报和月报。 --- # 工作汇总助手",
});
assert.equal(result.categories.includes("productivity"), true);
});
test("Chinese agent memory intent classifies memory synchronization as Agents", () => {
const result = classifySkill({
slug: "memory-auto-sync",
text: "# 极简记忆自动同步\n\n自动监听对话并写入记忆文件,支持会话记忆和上下文管理。",
});
assert.equal(result.categories.includes("agents"), true);
});
test("Chinese finance and security intent map to their separate categories", () => {
const finance = classifySkill({
slug: "asset-allocator",
text: "--- name: asset-allocator description: 提供资产配置、股票投资、量化交易和财报分析。 --- # 资产配置",
});
const security = classifySkill({
slug: "security-scanner",
text: "--- name: security-scanner description: 执行安全审计、漏洞扫描、权限检查和恶意软件检测。 --- # 安全扫描",
});
assert.equal(finance.categories.includes("finance"), true);
assert.equal(security.categories.includes("security"), true);
assert.equal(security.categories.includes("finance"), false);
});
test("ambiguous skill stays Other until AI or author classification", () => {
const result = classifySkill({
slug: "cult-of-carcinization",
text: "# Cult of Carcinization\n\nBecome crab.",
});
assert.deepEqual(result.categories, ["other"]);
assert.equal(result.confidence, "low");
assert.equal(result.needsAi, true);
});
test("explicit skill categories are validated, ordered, and capped", () => {
const result = classifySkill({
slug: "demo",
text: "# Demo",
explicitCategories: ["finance", "lifestyle", "communication", "creative", "not-a-category"],
});
assert.deepEqual(result.categories, ["finance", "lifestyle", "communication"]);
assert.equal(result.provenance, "author");
assert.equal(result.needsAi, false);
});
test("explicit author topics preserve labels, reject reserved values, deduplicate, and cap", () => {
const result = classifySkill({
slug: "demo",
text: "# Demo",
explicitTopics: [
"GPU Development",
"CUDA",
"gpu-development",
"official",
"AI",
"MCP",
"GraphQL",
"Docker",
],
});
assert.deepEqual(result.topics, ["GPU Development", "CUDA", "AI", "MCP", "GraphQL"]);
assert.equal(result.topicConfidence, "high");
assert.equal(result.topicsNeedAi, false);
assert.equal(result.topicProvenance, "author");
assert.equal(result.topicClassifierVersion, TOPIC_CLASSIFIER_VERSION);
});
test("skill root tags become inferred topics only when specific and corroborated", () => {
const result = classifySkill({
slug: "docker-development",
text: [
"---",
"name: docker-development",
"description: Build and optimize Docker containers and Dockerfiles.",
"tags:",
" - docker",
" - development",
" - openclaw",
" - latest",
"---",
"# Docker Development",
].join("\n"),
});
assert.deepEqual(result.topics, ["Docker"]);
assert.equal(result.topicConfidence, "high");
assert.equal(result.topicsNeedAi, false);
assert.equal(
result.rawTopicCandidates.some((candidate) => candidate.slug === "development"),
false,
);
assert.equal(
result.rawTopicCandidates.some((candidate) => candidate.slug === "openclaw"),
false,
);
});
test("an uncorroborated skill tag remains a medium-confidence review candidate", () => {
const result = classifySkill({
slug: "infrastructure-helper",
text: [
"---",
"name: infrastructure-helper",
"description: Help manage infrastructure configuration.",
"tags: [terraform]",
"---",
"# Infrastructure Helper",
].join("\n"),
});
assert.deepEqual(result.topics, ["Terraform"]);
assert.equal(result.topicConfidence, "medium");
assert.equal(result.topicsNeedAi, true);
});
test("specific primary purpose phrases may become medium topic suggestions without tags", () => {
const result = classifySkill({
slug: "current-research",
text: [
"---",
"name: current-research",
"description: Run web search for current research sources.",
"---",
"# Current Research",
].join("\n"),
});
assert.equal(result.topics.includes("Web Search"), true);
assert.equal(result.topicConfidence, "medium");
assert.equal(result.topicsNeedAi, true);
});
test("body-only topic evidence remains raw evidence instead of an exposed topic", () => {
const result = classifySkill({
slug: "project-planner",
text: [
"---",
"name: project-planner",
"description: Organize project milestones and task lists.",
"---",
"# Project Planner",
"The implementation can deploy Docker containers and Kubernetes workloads.",
].join("\n"),
});
assert.equal(result.topics.includes("Docker"), false);
assert.equal(
result.rawTopicCandidates.some((candidate) => candidate.slug === "docker"),
true,
);
});
test("structured plugin contributions create high-confidence specific topics", () => {
const result = classifyPlugin({
manifest: {
channels: ["discord"],
providers: ["openai"],
contracts: {
webSearchProviders: ["tavily"],
mcpServers: ["demo"],
},
},
});
assert.deepEqual(result.topics, ["Discord", "MCP", "OpenAI", "Tavily", "Web Search"]);
assert.equal(result.topicConfidence, "high");
assert.equal(result.topicsNeedAi, false);
});
test("plugin package tags require corroboration before becoming high confidence", () => {
const result = classifyPlugin({
slug: "docker-runner",
topicTags: ["docker", "plugin", "latest"],
});
assert.deepEqual(result.topics, ["Docker"]);
assert.equal(result.topicConfidence, "high");
assert.equal(result.topicsNeedAi, false);
});
test("topic aliases deduplicate into a stable canonical label", () => {
const result = classifySkill({
slug: "postgresql-helper",
text: [
"---",
"name: postgresql-helper",
"description: Inspect PostgreSQL databases and queries.",
"tags: [postgres, postgresql]",
"---",
"# PostgreSQL Helper",
].join("\n"),
});
assert.deepEqual(result.topics, ["PostgreSQL"]);
});
test("topic aliases merge platform rebrands and redundant package labels", () => {
const result = classifyPlugin({
topicTags: ["x", "twitter", "mcp-server", "mcp", "mongodb", "crm"],
});
assert.deepEqual(result.topics, ["CRM", "MCP", "MongoDB", "Twitter"]);
});
test("known inferred topic labels preserve conventional brand and acronym casing", () => {
const result = classifyPlugin({
topicTags: ["ffmpeg", "http", "linkedin", "oauth", "url"],
});
assert.deepEqual(result.topics, ["FFmpeg", "HTTP", "LinkedIn", "OAuth", "URL"]);
});
test("a more specific inferred topic suppresses its broad fragment", () => {
const result = classifySkill({
slug: "code-review",
text: [
"---",
"name: code-review",
"description: Run a code review and report actionable quality findings.",
"tags: [review, code-review, quality]",
"---",
"# Code Review",
].join("\n"),
});
assert.deepEqual(result.topics, ["Code Review"]);
assert.equal(
result.rawTopicCandidates.some(
(candidate) => candidate.slug === "review" && candidate.suppressedBy === "code-review",
),
true,
);
});
test("generic lifecycle and category-like compound values never become inferred topics", () => {
const result = classifyPlugin({
slug: "demo",
topicText: "Deprecated reference plugin with status checks for an AI agent model provider.",
topicTags: [
"deprecated",
"reference",
"status",
"check",
"quality",
"ai-agent",
"model-provider",
"search-provider",
"runner",
"test",
"项目",
],
});
assert.deepEqual(result.topics, []);
});
test("more than five supported topics is capped and forced into review", () => {
const result = classifyPlugin({
manifest: {
channels: ["discord", "slack", "telegram", "whatsapp", "signal", "matrix"],
},
});
assert.equal(result.topics.length, 5);
assert.equal(result.topicCandidateCountBeforeCap, 6);
assert.equal(result.topicConfidence, "medium");
assert.equal(result.topicsNeedAi, true);
});
test("skill slugs cannot self-corroborate an unrelated inferred topic", () => {
const result = classifySkill({
slug: "humanizer-backup",
text: [
"---",
"name: humanizer",
"description: Rewrite generated text so it sounds natural and human.",
"---",
"# Humanizer",
].join("\n"),
});
assert.equal(result.topics.includes("Backup"), false);
});
test("packaging terms and broad category synonyms never become inferred topics", () => {
const plugin = classifyPlugin({
slug: "@demo/openclaw-youtube-plugin",
topicText: "OpenClaw plugin provider for YouTube.",
topicTags: ["openclaw-plugin", "provider", "youtube"],
});
const skill = classifySkill({
slug: "video-editing",
text: [
"---",
"name: video-editing",
"description: Edit videos into polished clips.",
"tags: [video, creative]",
"---",
"# Video Editing",
].join("\n"),
});
assert.deepEqual(plugin.topics, ["YouTube"]);
assert.equal(skill.topics.includes("Video"), false);
assert.equal(skill.topics.includes("Video Editing"), true);
});
test("arbitrary plugin tool names remain review candidates instead of trusted topics", () => {
const result = classifyPlugin({
manifest: {
contracts: {
tools: ["requirement_bootstrap"],
},
},
});
assert.deepEqual(result.topics, ["Requirement Bootstrap"]);
assert.equal(result.topicConfidence, "medium");
assert.equal(result.topicsNeedAi, true);
});
+28
View File
@@ -63,6 +63,34 @@ describe("packageSearchDigest", () => {
expect(digest.pluginCategoryTags).toEqual(["other"]);
});
it("projects current inferred plugin metadata when author metadata is omitted", () => {
const digest = extractPackageDigestFields({
_id: "packages:inferred",
latestReleaseId: "packageReleases:v1",
inferredFromReleaseId: "packageReleases:v1",
inferredCategories: ["models", "voice"],
inferredTopics: ["OpenAI", "Speech-to-Text"],
family: "code-plugin",
name: "@openclaw/inferred",
normalizedName: "@openclaw/inferred",
displayName: "Inferred",
channel: "community",
isOfficial: false,
ownerUserId: "users:owner",
compatibility: {},
verification: {},
scanStatus: "clean",
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
tags: {},
createdAt: 1,
updatedAt: 2,
} as never);
expect(digest.categories).toEqual(["models", "voice"]);
expect(digest.pluginCategoryTags).toEqual(["models", "voice"]);
expect(digest.topics).toEqual(["OpenAI", "Speech-to-Text"]);
});
it("decrements the public plugin count when deleting a public plugin digest", async () => {
const patch = vi.fn();
const deleteDoc = vi.fn();
+15 -5
View File
@@ -1,4 +1,8 @@
import { getCatalogTopicSlugs, resolveStoredPluginCategories } from "clawhub-schema";
import {
getCatalogTopicSlugs,
resolveCatalogTopics,
resolveStoredPluginCategories,
} from "clawhub-schema";
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import { adjustGlobalPublicPluginsCount, getPublicPluginVisibilityDelta } from "./globalStats";
@@ -111,15 +115,21 @@ type PackageTopicSearchDigestFields = Pick<
};
export function extractPackageDigestFields(pkg: Doc<"packages">): PackageSearchDigestFields {
const categories = resolveStoredPluginCategories(pkg);
const inferenceCurrent =
Boolean(pkg.latestReleaseId) && pkg.latestReleaseId === pkg.inferredFromReleaseId;
return {
...pick(pkg, [...SHARED_KEYS]),
categories,
topics: resolveCatalogTopics({
declared: pkg.topics,
inferred: pkg.inferredTopics,
inferenceCurrent,
}),
packageId: pkg._id,
latestVersion: pkg.latestVersionSummary?.version,
verificationTier: pkg.verification?.tier,
pluginCategoryTags: resolveStoredPluginCategories({
family: pkg.family,
categories: pkg.categories,
}),
pluginCategoryTags: categories,
};
}
+31
View File
@@ -134,6 +134,37 @@ describe("extractDigestFields", () => {
expect(digest.recommendedScoreVersion).toBe(RECOMMENDATION_SCORE_VERSION);
});
it("projects current inferred catalog metadata when author metadata is omitted", () => {
const digest = extractDigestFields(
makeSkillDoc({
categories: undefined,
topics: undefined,
inferredCategories: ["development"],
inferredTopics: ["TypeScript", "Code Review"],
inferredFromVersionId: "skillVersions:v1",
}) as never,
);
expect(digest.categories).toEqual(["development"]);
expect(digest.topics).toEqual(["TypeScript", "Code Review"]);
});
it("does not project stale inferred catalog metadata", () => {
const digest = extractDigestFields(
makeSkillDoc({
latestVersionId: "skillVersions:v2",
categories: undefined,
topics: undefined,
inferredCategories: ["development"],
inferredTopics: ["TypeScript"],
inferredFromVersionId: "skillVersions:v1",
}) as never,
);
expect(digest.categories).toEqual(["other"]);
expect(digest.topics).toEqual([]);
});
it("omits large fields not needed for search", () => {
const skill = makeSkillDoc({
moderationEvidence: [
+13 -1
View File
@@ -1,4 +1,8 @@
import { getCatalogTopicSlugs } from "clawhub-schema";
import {
getCatalogTopicSlugs,
resolveCatalogTopics,
resolveStoredSkillCategories,
} from "clawhub-schema";
import type { Doc, Id } from "../_generated/dataModel";
import type { MutationCtx } from "../_generated/server";
import type { HydratableSkill, PublicPublisher } from "./public";
@@ -75,8 +79,16 @@ export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFiel
const statsStars = readCanonicalStat(skill, "stars");
const statsInstallsCurrent = readCanonicalStat(skill, "installsCurrent");
const statsInstallsAllTime = readCanonicalStat(skill, "installsAllTime");
const inferenceCurrent =
Boolean(skill.latestVersionId) && skill.latestVersionId === skill.inferredFromVersionId;
return {
...pick(skill, [...SHARED_KEYS]),
categories: resolveStoredSkillCategories(skill),
topics: resolveCatalogTopics({
declared: skill.topics,
inferred: skill.inferredTopics,
inferenceCurrent,
}),
statsDownloads,
statsStars,
statsInstallsCurrent,
+31 -1
View File
@@ -2,12 +2,19 @@
import { describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
import { runCatalogTaxonomyPrerequisites } from "./migrations";
import { runCatalogClassificationApply, runCatalogTaxonomyPrerequisites } from "./migrations";
type WrappedHandler = {
_handler: (ctx: unknown, args: { dryRun?: boolean }) => Promise<unknown>;
};
type ClassificationApplyWrappedHandler = {
_handler: (
ctx: unknown,
args: { dryRun?: boolean; minimumConfidence: "high" | "medium"; confirm?: string },
) => Promise<unknown>;
};
describe("catalog taxonomy migrations", () => {
it("dry-runs both tracked digest migrations", async () => {
const runMutation = vi.fn().mockResolvedValue({});
@@ -26,4 +33,27 @@ describe("catalog taxonomy migrations", () => {
reset: true,
});
});
it("dry-runs the selected classification apply migration", async () => {
const runMutation = vi.fn().mockResolvedValue({});
const handler = (runCatalogClassificationApply as unknown as ClassificationApplyWrappedHandler)
._handler;
await handler({ runMutation }, { minimumConfidence: "medium" });
expect(runMutation).toHaveBeenCalledWith(internal.migrations.run, {
fn: "migrations:applyMediumConfidenceCatalogClassifications",
dryRun: true,
reset: true,
});
});
it("requires an explicit confidence-specific confirmation before applying", async () => {
const handler = (runCatalogClassificationApply as unknown as ClassificationApplyWrappedHandler)
._handler;
await expect(
handler({ runMutation: vi.fn() }, { dryRun: false, minimumConfidence: "high" }),
).rejects.toThrow('Pass confirm="apply-high-confidence-catalog-classifications" to apply.');
});
});
+199 -1
View File
@@ -1,11 +1,25 @@
import { Migrations, runToCompletion } from "@convex-dev/migrations";
import { v } from "convex/values";
import {
normalizeInferredCatalogTopics,
normalizePluginCategories,
normalizeSkillCategories,
} from "clawhub-schema";
import { ConvexError, v } from "convex/values";
import { components, internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { internalAction } from "./_generated/server";
import { syncPackageSearchDigestForPackageId } from "./functions";
import {
selectCatalogInference,
type CatalogClassificationConfidence,
} from "./lib/catalogClassification";
import { syncSkillSearchDigestForSkill } from "./lib/skillSearchDigest";
import schema from "./schema";
const APPLY_HIGH_CONFIDENCE_CONFIRM = "apply-high-confidence-catalog-classifications";
const APPLY_MEDIUM_CONFIDENCE_CONFIRM = "apply-medium-confidence-catalog-classifications";
export const migrations = new Migrations(components.migrations, {
schema,
defaultBatchSize: 25,
@@ -25,8 +39,192 @@ export const rebuildCatalogTaxonomySkillDigests = migrations.define({
},
});
async function applyCatalogClassification(
ctx: Pick<MutationCtx, "db">,
result: Doc<"catalogClassificationResults">,
minimumConfidence: CatalogClassificationConfidence,
) {
const now = Date.now();
if (result.targetKind === "skill" && result.skillId) {
const skill = await ctx.db.get(result.skillId);
if (!skill) {
await ctx.db.patch(result._id, {
applyStatus: "stale",
error: "Skill no longer exists",
appliedAt: undefined,
});
return;
}
const selection = selectCatalogInference({
currentSourceId: skill.latestVersionId,
resultSourceId: result.skillVersionId,
authorCategories: skill.categories,
authorTopics: skill.topics,
result: {
categories: result.categories,
topics: result.topics,
confidence: result.categoryConfidence,
topicConfidence: result.topicConfidence,
},
minimumConfidence,
});
if (selection.status !== "applied") {
await ctx.db.patch(result._id, {
applyStatus: selection.status,
error: undefined,
appliedAt: undefined,
});
return;
}
const patch = {
inferredCategories: selection.categories
? normalizeSkillCategories(selection.categories)
: undefined,
inferredTopics: selection.topics
? normalizeInferredCatalogTopics(selection.topics)
: undefined,
inferredFromVersionId: result.skillVersionId,
inferredCategoryConfidence: selection.categories ? result.categoryConfidence : undefined,
inferredTopicConfidence: selection.topics ? result.topicConfidence : undefined,
inferredClassifierVersion: result.classifierVersion,
inferredTopicClassifierVersion: result.topicClassifierVersion,
inferredInputHash: result.inputHash,
inferredTopicInputHash: result.topicInputHash,
inferredAt: now,
};
await ctx.db.patch(skill._id, patch);
await syncSkillSearchDigestForSkill(ctx, { ...skill, ...patch });
await ctx.db.patch(result._id, {
applyStatus: "applied",
error: undefined,
appliedAt: now,
});
return;
}
if (result.targetKind === "plugin" && result.packageId) {
const pkg = await ctx.db.get(result.packageId);
if (!pkg) {
await ctx.db.patch(result._id, {
applyStatus: "stale",
error: "Package no longer exists",
appliedAt: undefined,
});
return;
}
const selection = selectCatalogInference({
currentSourceId: pkg.latestReleaseId,
resultSourceId: result.packageReleaseId,
authorCategories: pkg.categories,
authorTopics: pkg.topics,
result: {
categories: result.categories,
topics: result.topics,
confidence: result.categoryConfidence,
topicConfidence: result.topicConfidence,
},
minimumConfidence,
});
if (selection.status !== "applied") {
await ctx.db.patch(result._id, {
applyStatus: selection.status,
error: undefined,
appliedAt: undefined,
});
return;
}
await ctx.db.patch(pkg._id, {
inferredCategories: selection.categories
? normalizePluginCategories(selection.categories)
: undefined,
inferredTopics: selection.topics
? normalizeInferredCatalogTopics(selection.topics)
: undefined,
inferredFromReleaseId: result.packageReleaseId,
inferredCategoryConfidence: selection.categories ? result.categoryConfidence : undefined,
inferredTopicConfidence: selection.topics ? result.topicConfidence : undefined,
inferredClassifierVersion: result.classifierVersion,
inferredTopicClassifierVersion: result.topicClassifierVersion,
inferredInputHash: result.inputHash,
inferredTopicInputHash: result.topicInputHash,
inferredAt: now,
});
await syncPackageSearchDigestForPackageId(ctx, pkg._id);
await ctx.db.patch(result._id, {
applyStatus: "applied",
error: undefined,
appliedAt: now,
});
return;
}
await ctx.db.patch(result._id, {
applyStatus: "error",
error: "Classification target is inconsistent",
appliedAt: undefined,
});
}
export const applyHighConfidenceCatalogClassifications = migrations.define({
table: "catalogClassificationResults",
batchSize: 10,
migrateOne: (ctx, result) => applyCatalogClassification(ctx, result, "high"),
});
export const applyMediumConfidenceCatalogClassifications = migrations.define({
table: "catalogClassificationResults",
batchSize: 10,
migrateOne: (ctx, result) => applyCatalogClassification(ctx, result, "medium"),
});
export const run = migrations.runner();
export const runCatalogClassificationApply = internalAction({
args: {
dryRun: v.optional(v.boolean()),
minimumConfidence: v.union(v.literal("high"), v.literal("medium")),
confirm: v.optional(v.string()),
},
returns: v.object({
ok: v.literal(true),
dryRun: v.boolean(),
minimumConfidence: v.union(v.literal("high"), v.literal("medium")),
confirmRequired: v.optional(v.string()),
}),
handler: async (ctx, args) => {
const dryRun = args.dryRun !== false;
const confirmRequired =
args.minimumConfidence === "high"
? APPLY_HIGH_CONFIDENCE_CONFIRM
: APPLY_MEDIUM_CONFIDENCE_CONFIRM;
if (!dryRun && args.confirm !== confirmRequired) {
throw new ConvexError(`Pass confirm="${confirmRequired}" to apply.`);
}
const migration =
args.minimumConfidence === "high"
? internal.migrations.applyHighConfidenceCatalogClassifications
: internal.migrations.applyMediumConfidenceCatalogClassifications;
if (dryRun) {
await ctx.runMutation(internal.migrations.run, {
fn:
args.minimumConfidence === "high"
? "migrations:applyHighConfidenceCatalogClassifications"
: "migrations:applyMediumConfidenceCatalogClassifications",
dryRun: true,
reset: true,
});
} else {
await runToCompletion(ctx, components.migrations, migration);
}
return {
ok: true as const,
dryRun,
minimumConfidence: args.minimumConfidence,
confirmRequired: dryRun ? confirmRequired : undefined,
};
},
});
export const runCatalogTaxonomyPrerequisites = internalAction({
args: { dryRun: v.optional(v.boolean()) },
returns: v.null(),
+91
View File
@@ -598,6 +598,44 @@ const packageFilesValidator = v.array(
}),
);
const catalogClassificationConfidenceValidator = v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
);
const catalogClassificationApplyStatusValidator = v.union(
v.literal("preview"),
v.literal("applied"),
v.literal("stale"),
v.literal("skipped-author"),
v.literal("error"),
);
const catalogCategoryCandidateValidator = v.object({
category: v.string(),
score: v.number(),
sources: v.array(v.string()),
evidence: v.array(v.string()),
strongEvidence: v.optional(v.boolean()),
primaryEvidence: v.optional(v.boolean()),
strongPrimaryEvidence: v.optional(v.boolean()),
primaryEvidenceCount: v.optional(v.number()),
});
const catalogTopicCandidateValidator = v.object({
topic: v.string(),
slug: v.string(),
score: v.number(),
sources: v.array(v.string()),
evidence: v.array(v.string()),
primaryEvidence: v.boolean(),
primarySourceCount: v.number(),
strongEvidence: v.boolean(),
confidence: catalogClassificationConfidenceValidator,
suppressedBy: v.optional(v.string()),
});
const skillScanRequestSourceKindValidator = v.union(
v.literal("upload"),
v.literal("published"),
@@ -638,6 +676,16 @@ const skills = defineTable({
tags: v.record(v.string(), v.id("skillVersions")),
categories: v.optional(v.array(v.string())),
topics: v.optional(v.array(v.string())),
inferredCategories: v.optional(v.array(v.string())),
inferredTopics: v.optional(v.array(v.string())),
inferredFromVersionId: v.optional(v.id("skillVersions")),
inferredCategoryConfidence: v.optional(catalogClassificationConfidenceValidator),
inferredTopicConfidence: v.optional(catalogClassificationConfidenceValidator),
inferredClassifierVersion: v.optional(v.string()),
inferredTopicClassifierVersion: v.optional(v.string()),
inferredInputHash: v.optional(v.string()),
inferredTopicInputHash: v.optional(v.string()),
inferredAt: v.optional(v.number()),
softDeletedAt: v.optional(v.number()),
badges: badgesValidator,
moderationStatus: moderationStatusValidator,
@@ -1247,6 +1295,16 @@ const packages = defineTable({
tags: v.record(v.string(), v.id("packageReleases")),
categories: v.optional(v.array(v.string())),
topics: v.optional(v.array(v.string())),
inferredCategories: v.optional(v.array(v.string())),
inferredTopics: v.optional(v.array(v.string())),
inferredFromReleaseId: v.optional(v.id("packageReleases")),
inferredCategoryConfidence: v.optional(catalogClassificationConfidenceValidator),
inferredTopicConfidence: v.optional(catalogClassificationConfidenceValidator),
inferredClassifierVersion: v.optional(v.string()),
inferredTopicClassifierVersion: v.optional(v.string()),
inferredInputHash: v.optional(v.string()),
inferredTopicInputHash: v.optional(v.string()),
inferredAt: v.optional(v.number()),
compatibility: packageCompatibilityValidator,
verification: packageVerificationValidator,
scanStatus: packageScanStatusValidator,
@@ -1417,6 +1475,38 @@ const packageReleases = defineTable({
.index("by_package_version", ["packageId", "version"])
.index("by_sha256hash", ["sha256hash"]);
const catalogClassificationResults = defineTable({
targetKind: v.union(v.literal("skill"), v.literal("plugin")),
skillId: v.optional(v.id("skills")),
packageId: v.optional(v.id("packages")),
skillVersionId: v.optional(v.id("skillVersions")),
packageReleaseId: v.optional(v.id("packageReleases")),
categories: v.array(v.string()),
topics: v.array(v.string()),
categoryCandidates: v.array(catalogCategoryCandidateValidator),
topicCandidates: v.array(catalogTopicCandidateValidator),
categoryCandidateCount: v.number(),
topicCandidateCount: v.number(),
categoryConfidence: catalogClassificationConfidenceValidator,
topicConfidence: catalogClassificationConfidenceValidator,
categoryNeedsReview: v.boolean(),
topicNeedsReview: v.boolean(),
unknownSignals: v.array(v.string()),
classifierVersion: v.string(),
topicClassifierVersion: v.string(),
inputHash: v.string(),
topicInputHash: v.string(),
applyStatus: catalogClassificationApplyStatusValidator,
error: v.optional(v.string()),
classifiedAt: v.number(),
appliedAt: v.optional(v.number()),
})
.index("by_skill", ["skillId"])
.index("by_package", ["packageId"])
.index("by_apply_status", ["applyStatus", "classifiedAt"])
.index("by_category_confidence", ["categoryConfidence", "classifiedAt"])
.index("by_topic_confidence", ["topicConfidence", "classifiedAt"]);
const packageInspectorWarnings = defineTable({
packageId: v.id("packages"),
releaseId: v.id("packageReleases"),
@@ -2613,6 +2703,7 @@ export default defineSchema({
skillSlugAliases,
packages,
packageReleases,
catalogClassificationResults,
packageInspectorWarnings,
packageInspectorFindingNotifications,
packageInspectorScanCursors,
+8
View File
@@ -173,9 +173,17 @@ export declare function inferSkillCategories(input: {
export declare function normalizeCatalogTopic(value: string): string | undefined;
export declare function normalizeCatalogTopics(values: readonly string[] | null | undefined): string[];
export declare function normalizeInferredCatalogTopics(values: readonly string[] | null | undefined): string[];
export declare function resolveCatalogTopics(input: {
declared?: readonly string[] | null;
inferred?: readonly string[] | null;
inferenceCurrent?: boolean;
}): string[];
export declare function getCatalogTopicSlugs(values: readonly string[] | null | undefined): string[];
type SkillCategoryCandidate = {
categories?: readonly string[] | null;
inferredCategories?: readonly string[] | null;
latestVersionId?: string | null;
inferredFromVersionId?: string | null;
slug: string;
displayName: string;
summary?: string | null;
+12 -1
View File
@@ -317,6 +317,13 @@ export function normalizeInferredCatalogTopics(values) {
return [];
}
}
export function resolveCatalogTopics(input) {
if (input.declared !== undefined)
return input.declared ? [...input.declared] : [];
if (!input.inferenceCurrent)
return [];
return normalizeInferredCatalogTopics(input.inferred);
}
export function getCatalogTopicSlugs(values) {
const slugs = [];
const seenSlugs = new Set();
@@ -347,6 +354,10 @@ export function resolveStoredSkillCategories(skill) {
catch {
declared = undefined;
}
return resolveSkillCategories({ declared });
const inferenceCurrent = Boolean(skill.latestVersionId) && skill.latestVersionId === skill.inferredFromVersionId;
return resolveSkillCategories({
declared,
inferred: inferenceCurrent ? skill.inferredCategories : undefined,
});
}
//# sourceMappingURL=catalogMetadata.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -10,5 +10,7 @@ export declare function derivePluginCategoryTags(input: {
displayName?: string;
runtimeId?: string;
summary?: string;
latestReleaseId?: string | null;
inferredFromReleaseId?: string | null;
}): PluginCategorySlug[];
export declare function resolveStoredPluginCategories(input: Parameters<typeof derivePluginCategoryTags>[0]): PluginCategorySlug[];
+5 -1
View File
@@ -69,7 +69,11 @@ export function resolveStoredPluginCategories(input) {
if (input.family === "skill")
return [];
try {
return resolvePluginCategories({ declared: input.categories });
const inferenceCurrent = Boolean(input.latestReleaseId) && input.latestReleaseId === input.inferredFromReleaseId;
return resolvePluginCategories({
declared: input.categories,
inferred: inferenceCurrent ? input.inferredCategories : undefined,
});
}
catch {
return resolvePluginCategories({});
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"pluginCategories.js","sourceRoot":"","sources":["../src/pluginCategories.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"pluginCategories.js","sourceRoot":"","sources":["../src/pluginCategories.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAA2B,MAAM,sBAAsB,CAAC;AAExF,OAAO,EACL,oBAAoB,EACpB,2BAA2B,EAC3B,qBAAqB,GAEtB,MAAM,sBAAsB,CAAC;AAI9B,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;AAC9E,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;AAClD,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,iCAAiC,CAAC,QAAiB;IACjE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,CAAC;IAEnC,MAAM,UAAU,GAAyB,EAAE,CAAC;IAC5C,MAAM,GAAG,GAAG,CAAC,QAA4B,EAAE,EAAE;QAC3C,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChE,CAAC,CAAC;IACF,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC7E,MAAM,SAAS,GAAG,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;IAEzE,IAAI,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAClD,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,CAAC,QAAQ,CAAC,WAAW,CAAC;QAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IACpF,IAAI,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,SAAS,CAAC,SAAS,CAAC,kBAAkB,CAAC;QAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IACvF,IAAI,KAAK,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC,EAAE,CAAC;QACtF,GAAG,CAAC,SAAS,CAAC,CAAC;IACjB,CAAC;IACD,IACE,SAAS,CAAC,SAAS,CAAC,eAAe,CAAC;QACpC,SAAS,CAAC,SAAS,CAAC,8BAA8B,CAAC;QACnD,SAAS,CAAC,SAAS,CAAC,sBAAsB,CAAC;QAC3C,SAAS,CAAC,SAAS,CAAC,yBAAyB,CAAC,EAC9C,CAAC;QACD,GAAG,CAAC,OAAO,CAAC,CAAC;IACf,CAAC;IACD,IACE,SAAS,CAAC,SAAS,CAAC,2BAA2B,CAAC;QAChD,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC;QAC7C,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC;QAC7C,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC,EAC7C,CAAC;QACD,GAAG,CAAC,OAAO,CAAC,CAAC;IACf,CAAC;IACD,IAAI,SAAS,CAAC,SAAS,CAAC,iBAAiB,CAAC,IAAI,SAAS,CAAC,SAAS,CAAC,kBAAkB,CAAC,EAAE,CAAC;QACtF,GAAG,CAAC,KAAK,CAAC,CAAC;IACb,CAAC;IACD,IAAI,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,QAAQ,CAAC,cAAc,CAAC;QAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IACnF,IACE,SAAS,CAAC,SAAS,CAAC,0BAA0B,CAAC;QAC/C,SAAS,CAAC,SAAS,CAAC,yBAAyB,CAAC,EAC9C,CAAC;QACD,GAAG,CAAC,SAAS,CAAC,CAAC;IACjB,CAAC;IACD,IAAI,SAAS,CAAC,SAAS,CAAC,qBAAqB,CAAC;QAAE,GAAG,CAAC,SAAS,CAAC,CAAC;IAC/D,IACE,SAAS,CAAC,SAAS,CAAC,qBAAqB,CAAC;QAC1C,aAAa,CAAC,QAAQ,CAAC,0BAA0B,CAAC,EAClD,CAAC;QACD,GAAG,CAAC,UAAU,CAAC,CAAC;IAClB,CAAC;IAED,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,KAWxC;IACC,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;QAAE,OAAO,EAAE,CAAC;IACxC,OAAO,uBAAuB,CAAC;QAC7B,QAAQ,EAAE,KAAK,CAAC,UAAU;QAC1B,QAAQ,EAAE,KAAK,CAAC,kBAAkB,IAAI,iCAAiC,CAAC,KAAK,CAAC,cAAc,CAAC;KAC9F,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,6BAA6B,CAC3C,KAAqD;IAErD,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;QAAE,OAAO,EAAE,CAAC;IACxC,IAAI,CAAC;QACH,MAAM,gBAAgB,GACpB,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,eAAe,KAAK,KAAK,CAAC,qBAAqB,CAAC;QAC1F,OAAO,uBAAuB,CAAC;YAC7B,QAAQ,EAAE,KAAK,CAAC,UAAU;YAC1B,QAAQ,EAAE,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS;SAClE,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,uBAAuB,CAAC,EAAE,CAAC,CAAC;IACrC,CAAC;AACH,CAAC"}
@@ -8,6 +8,7 @@ import {
normalizePluginCategories,
normalizeSkillCategories,
PLUGIN_CATEGORY_DEFINITIONS,
resolveCatalogTopics,
resolvePluginCategories,
resolveSkillCategories,
resolveStoredSkillCategories,
@@ -119,6 +120,39 @@ describe("catalog metadata", () => {
).toEqual(["other"]);
});
it("uses current inferred skill categories only when author categories are omitted", () => {
expect(
resolveStoredSkillCategories({
slug: "todoist-workflows",
displayName: "Todoist Workflows",
categories: undefined,
inferredCategories: ["automation", "productivity"],
latestVersionId: "version:current",
inferredFromVersionId: "version:current",
}),
).toEqual(["automation", "productivity"]);
expect(
resolveStoredSkillCategories({
slug: "todoist-workflows",
displayName: "Todoist Workflows",
categories: ["other"],
inferredCategories: ["automation"],
latestVersionId: "version:current",
inferredFromVersionId: "version:current",
}),
).toEqual(["other"]);
expect(
resolveStoredSkillCategories({
slug: "todoist-workflows",
displayName: "Todoist Workflows",
categories: undefined,
inferredCategories: ["automation"],
latestVersionId: "version:new",
inferredFromVersionId: "version:old",
}),
).toEqual(["other"]);
});
it("preserves topic display values while deriving normalized lookup slugs", () => {
const topics = normalizeCatalogTopics([
" GPU Development ",
@@ -130,6 +164,35 @@ describe("catalog metadata", () => {
expect(getCatalogTopicSlugs(topics)).toEqual(["gpu-development", "travel-planning"]);
});
it("resolves current inferred topics only when author topics are omitted", () => {
expect(
resolveCatalogTopics({
inferred: ["Docker", "Kubernetes"],
inferenceCurrent: true,
}),
).toEqual(["Docker", "Kubernetes"]);
expect(
resolveCatalogTopics({
declared: ["Calendar", "Official"],
inferred: ["Docker"],
inferenceCurrent: true,
}),
).toEqual(["Calendar", "Official"]);
expect(
resolveCatalogTopics({
declared: [],
inferred: ["Docker"],
inferenceCurrent: true,
}),
).toEqual([]);
expect(
resolveCatalogTopics({
inferred: ["Docker"],
inferenceCurrent: false,
}),
).toEqual([]);
});
it("drops invalid stored topics while deriving bounded lookup slugs", () => {
expect(
getCatalogTopicSlugs([
+19 -1
View File
@@ -374,6 +374,16 @@ export function normalizeInferredCatalogTopics(
}
}
export function resolveCatalogTopics(input: {
declared?: readonly string[] | null;
inferred?: readonly string[] | null;
inferenceCurrent?: boolean;
}): string[] {
if (input.declared !== undefined) return input.declared ? [...input.declared] : [];
if (!input.inferenceCurrent) return [];
return normalizeInferredCatalogTopics(input.inferred);
}
export function getCatalogTopicSlugs(values: readonly string[] | null | undefined): string[] {
const slugs: string[] = [];
const seenSlugs = new Set<string>();
@@ -395,6 +405,9 @@ export function getCatalogTopicSlugs(values: readonly string[] | null | undefine
type SkillCategoryCandidate = {
categories?: readonly string[] | null;
inferredCategories?: readonly string[] | null;
latestVersionId?: string | null;
inferredFromVersionId?: string | null;
slug: string;
displayName: string;
summary?: string | null;
@@ -408,5 +421,10 @@ export function resolveStoredSkillCategories(skill: SkillCategoryCandidate): Ski
} catch {
declared = undefined;
}
return resolveSkillCategories({ declared });
const inferenceCurrent =
Boolean(skill.latestVersionId) && skill.latestVersionId === skill.inferredFromVersionId;
return resolveSkillCategories({
declared,
inferred: inferenceCurrent ? skill.inferredCategories : undefined,
});
}
@@ -121,6 +121,34 @@ describe("plugin categories", () => {
).toEqual(["other"]);
});
it("uses current inferred plugin categories only when author categories are omitted", () => {
expect(
resolveStoredPluginCategories({
family: "code-plugin",
inferredCategories: ["models", "voice"],
latestReleaseId: "release:current",
inferredFromReleaseId: "release:current",
}),
).toEqual(["models", "voice"]);
expect(
resolveStoredPluginCategories({
family: "code-plugin",
categories: ["other"],
inferredCategories: ["models"],
latestReleaseId: "release:current",
inferredFromReleaseId: "release:current",
}),
).toEqual(["other"]);
expect(
resolveStoredPluginCategories({
family: "code-plugin",
inferredCategories: ["models"],
latestReleaseId: "release:new",
inferredFromReleaseId: "release:old",
}),
).toEqual(["other"]);
});
it("validates public category slugs", () => {
expect(isPluginCategorySlug("security")).toBe(true);
expect(isPluginCategorySlug("other")).toBe(true);
+8 -1
View File
@@ -83,6 +83,8 @@ export function derivePluginCategoryTags(input: {
displayName?: string;
runtimeId?: string;
summary?: string;
latestReleaseId?: string | null;
inferredFromReleaseId?: string | null;
}): PluginCategorySlug[] {
if (input.family === "skill") return [];
return resolvePluginCategories({
@@ -96,7 +98,12 @@ export function resolveStoredPluginCategories(
): PluginCategorySlug[] {
if (input.family === "skill") return [];
try {
return resolvePluginCategories({ declared: input.categories });
const inferenceCurrent =
Boolean(input.latestReleaseId) && input.latestReleaseId === input.inferredFromReleaseId;
return resolvePluginCategories({
declared: input.categories,
inferred: inferenceCurrent ? input.inferredCategories : undefined,
});
} catch {
return resolvePluginCategories({});
}
+29 -4
View File
@@ -54,7 +54,32 @@
## Follow-Up
Corpus classification and resumable LLM suggestion tooling are separate follow-up work. Suggestions
must require an explicit author or operator acceptance step before they are persisted. The
operator-run digest rebuild in this PR only reprojects current stored package data into the
controlled taxonomy; it does not classify the corpus.
Corpus classification is a separate operator-run phase from digest projection:
- `taxonomy-prototype-v9` classifies categories and `topic-prototype-v1` classifies zero to five
topics from bounded static artifact evidence. The plugin lane covers code and bundle plugins only;
runtime plugin code is never imported or executed.
- Classification writes bounded preview rows to `catalogClassificationResults`. Preview generation
never changes skill/package taxonomy or search digests.
- Explicit author categories/topics always win. Explicit empty arrays remain authoritative.
- Applied inferred values remain separate in `inferredCategories` and `inferredTopics`. They are
eligible for discovery only while their recorded source version/release is still latest.
- The preview runner is cursor-batched and resumable. It uses an action instead of
`@convex-dev/migrations` because it must read immutable storage blobs; the source-changing apply
phase uses the migrations component.
- Apply defaults to a dry run and requires a confidence-specific confirmation string. High
confidence is the default production lane. Medium confidence requires an explicit operator
decision after reviewing the generated corpus report.
Operator sequence:
```bash
bunx convex run catalogClassificationNode:classifyCatalogInternal \
'{"targetKind":"skill","maxBatches":20,"continueOnIncomplete":true}' --prod
bunx convex run catalogClassificationNode:classifyCatalogInternal \
'{"targetKind":"plugin","maxBatches":20,"continueOnIncomplete":true}' --prod
bunx convex run migrations:runCatalogClassificationApply \
'{"minimumConfidence":"high","dryRun":true}' --prod
bunx convex run migrations:runCatalogClassificationApply \
'{"minimumConfidence":"high","dryRun":false,"confirm":"apply-high-confidence-catalog-classifications"}' --prod
```
+4
View File
@@ -37,6 +37,10 @@ Production deploy notes:
`bunx convex run migrations:runCatalogTaxonomyPrerequisites --prod` after Convex deploy and verify
completion before considering the deployment complete. The migration is idempotent, resumable,
and a no-op after it completes.
- Catalog classification/backfill is also operator-run. Generate skill and plugin preview rows with
`catalogClassificationNode:classifyCatalogInternal`, review the resulting confidence lanes, then
dry-run and explicitly confirm `migrations:runCatalogClassificationApply`. See
`specs/catalog-taxonomy.md` for the exact commands and confidence-specific confirmation strings.
- Deploy targets:
- `full`: deploy Convex, verify contract, wait for the matching Vercel production deploy, then run smoke tests
- `backend`: deploy Convex, verify contract, then run smoke tests against current production