mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
co-authored by
Patrick Erichsen
parent
1a3fdd8f51
commit
4bc58e4939
Vendored
+8
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
@@ -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
@@ -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
@@ -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":"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"}
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user