diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index a29efc7c..cd237ec2 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; diff --git a/convex/catalogClassification.test.ts b/convex/catalogClassification.test.ts new file mode 100644 index 00000000..c8690fae --- /dev/null +++ b/convex/catalogClassification.test.ts @@ -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(); + }); +}); diff --git a/convex/catalogClassification.ts b/convex/catalogClassification.ts new file mode 100644 index 00000000..3f538276 --- /dev/null +++ b/convex/catalogClassification.ts @@ -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; + topicConfidence: Record; + 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, + cursor: string | undefined, + batchSize: number, +): Promise { + 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, + cursor: string | undefined, + batchSize: number, +): Promise { + 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, + args: { + targetKind: "skill" | "plugin"; + cursor?: string; + batchSize?: number; + }, +): Promise { + 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 = 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 => { + const { user } = await requireUserFromAction(ctx); + assertRole(user, ["admin"]); + return ctx.runAction(internal.catalogClassificationNode.classifyCatalogInternal, args); + }, +}); + +export const applyCatalogClassifications: ReturnType = 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; diff --git a/convex/catalogClassificationNode.ts b/convex/catalogClassificationNode.ts new file mode 100644 index 00000000..ffa08dd1 --- /dev/null +++ b/convex/catalogClassificationNode.ts @@ -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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +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, 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, + item: Extract, +): Promise { + 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, + item: Extract, +): Promise { + 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 { + 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 { + 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, +}); diff --git a/convex/lib/catalogClassification.test.ts b/convex/lib/catalogClassification.test.ts new file mode 100644 index 00000000..591755e6 --- /dev/null +++ b/convex/lib/catalogClassification.test.ts @@ -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}`), + }); + }); +}); diff --git a/convex/lib/catalogClassification.ts b/convex/lib/catalogClassification.ts new file mode 100644 index 00000000..3a845b68 --- /dev/null +++ b/convex/lib/catalogClassification.ts @@ -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 = { + 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; + 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, + }; +} diff --git a/convex/lib/catalogClassifier.d.mts b/convex/lib/catalogClassifier.d.mts new file mode 100644 index 00000000..d6b97300 --- /dev/null +++ b/convex/lib/catalogClassifier.d.mts @@ -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; + slug?: string; + text?: string; + topicText?: string; + explicitCategories?: readonly string[]; + explicitTopics?: readonly string[]; + topicTags?: readonly string[]; +}): CatalogClassifierResult; diff --git a/convex/lib/catalogClassifier.mjs b/convex/lib/catalogClassifier.mjs new file mode 100644 index 00000000..587a5ba9 --- /dev/null +++ b/convex/lib/catalogClassifier.mjs @@ -0,0 +1,2115 @@ +"use node"; + +import { createHash } from "node:crypto"; + +export const CLASSIFIER_VERSION = "taxonomy-prototype-v9"; +export const TOPIC_CLASSIFIER_VERSION = "topic-prototype-v1"; + +const TOPIC_LIMIT = 5; +const TOPIC_MAX_LENGTH = 48; +const RESERVED_TOPIC_SLUGS = new Set([ + "featured", + "official", + "recommended", + "staff-pick", + "trusted-publisher", + "verified", +]); + +export const PLUGIN_CATEGORIES = [ + ["channels", "Channels"], + ["models", "Models"], + ["memory", "Memory"], + ["context", "Context"], + ["voice", "Voice"], + ["media", "Media"], + ["web", "Web"], + ["tools", "Tools"], + ["runtime", "Runtime"], + ["gateway", "Gateway"], + ["security", "Security"], + ["other", "Other"], +]; + +export const SKILL_CATEGORIES = [ + ["integrations", "Integrations"], + ["automation", "Automation"], + ["research", "Research"], + ["development", "Development"], + ["productivity", "Productivity"], + ["communication", "Communication"], + ["creative", "Creative"], + ["knowledge", "Knowledge"], + ["agents", "Agents"], + ["operations", "Operations"], + ["security", "Security"], + ["finance", "Finance"], + ["lifestyle", "Lifestyle"], + ["other", "Other"], +]; + +export const PLUGIN_CATEGORY_SLUGS = PLUGIN_CATEGORIES.map(([slug]) => slug); +export const SKILL_CATEGORY_SLUGS = SKILL_CATEGORIES.map(([slug]) => slug); + +const PLUGIN_CATEGORY_SET = new Set(PLUGIN_CATEGORY_SLUGS); +const SKILL_CATEGORY_SET = new Set(SKILL_CATEGORY_SLUGS); +const PLUGIN_ORDER = new Map(PLUGIN_CATEGORY_SLUGS.map((slug, index) => [slug, index])); +const SKILL_ORDER = new Map(SKILL_CATEGORY_SLUGS.map((slug, index) => [slug, index])); + +const INFERRED_TOPIC_BLOCKLIST = new Set([ + ...PLUGIN_CATEGORY_SLUGS, + ...SKILL_CATEGORY_SLUGS, + "ai", + "assistant", + "assistants", + "agent", + "agents", + "api", + "app", + "application", + "ai-agent", + "ai-agents", + "automate", + "bundle", + "bundles", + "channel", + "code", + "clawhub", + "demo", + "design", + "example", + "extension", + "extensions", + "free", + "check", + "checks", + "deprecated", + "deprecation", + "helper", + "helpers", + "image", + "infrastructure", + "integration", + "latest", + "llm", + "model", + "model-provider", + "openclaw", + "plugin", + "plugins", + "project", + "provider", + "quality", + "reference", + "references", + "runner", + "runners", + "search", + "search-provider", + "service", + "services", + "skill", + "skills", + "social", + "status", + "task", + "test", + "tests", + "tool", + "tools", + "utility", + "utilities", + "video", + "workflow", + "代码", + "任务", + "图片", + "搜索", + "自动化", + "视频", + "设计", + "项目", +]); + +const INFERRED_TOPIC_BLOCKED_TOKENS = new Set([ + "bundle", + "bundles", + "clawhub", + "openclaw", + "plugin", + "plugins", + "skill", + "skills", +]); + +const TOPIC_ALIAS_SLUGS = new Map([ + ["github-action", "github-actions"], + ["github-actions", "github-actions"], + ["google-calendar-api", "google-calendar"], + ["k8s", "kubernetes"], + ["mcp-server", "mcp"], + ["mcp-servers", "mcp"], + ["postgres", "postgresql"], + ["postgresql", "postgresql"], + ["speech-to-text", "speech-to-text"], + ["stt", "speech-to-text"], + ["text-to-speech", "text-to-speech"], + ["tts", "text-to-speech"], + ["x", "twitter"], + ["x-twitter", "twitter"], +]); + +const TOPIC_CANONICAL_LABELS = new Map([ + ["3d-model", "3D Models"], + ["3d-models", "3D Models"], + ["anthropic", "Anthropic"], + ["aisa", "AIsa"], + ["cli", "CLI"], + ["crm", "CRM"], + ["csv", "CSV"], + ["deepseek", "DeepSeek"], + ["discord", "Discord"], + ["docker", "Docker"], + ["ffmpeg", "FFmpeg"], + ["gemini", "Gemini"], + ["github", "GitHub"], + ["github-actions", "GitHub Actions"], + ["gitlab", "GitLab"], + ["google-calendar", "Google Calendar"], + ["graphql", "GraphQL"], + ["http", "HTTP"], + ["kubernetes", "Kubernetes"], + ["linkedin", "LinkedIn"], + ["matrix", "Matrix"], + ["mcp", "MCP"], + ["microsoft-teams", "Microsoft Teams"], + ["mongodb", "MongoDB"], + ["mysql", "MySQL"], + ["nostr", "Nostr"], + ["ocr", "OCR"], + ["ollama", "Ollama"], + ["openai", "OpenAI"], + ["openoffice", "OpenOffice"], + ["oauth", "OAuth"], + ["pdf", "PDF"], + ["postgresql", "PostgreSQL"], + ["rss", "RSS"], + ["signal", "Signal"], + ["slack", "Slack"], + ["speech-to-text", "Speech-to-Text"], + ["sql", "SQL"], + ["tavily", "Tavily"], + ["telegram", "Telegram"], + ["terraform", "Terraform"], + ["text-to-speech", "Text-to-Speech"], + ["twitter", "Twitter"], + ["url", "URL"], + ["web-search", "Web Search"], + ["whatsapp", "WhatsApp"], + ["wechat", "WeChat"], + ["wordpress", "WordPress"], + ["xlsx", "XLSX"], + ["youtube", "YouTube"], +]); + +const STRONG_CONTRACT_VALUE_KEYS = new Set([ + "speechProviders", + "realtimeTranscriptionProviders", + "realtimeVoiceProviders", + "voiceProviders", + "mediaUnderstandingProviders", + "transcriptSourceProviders", + "documentExtractors", + "imageGenerationProviders", + "videoGenerationProviders", + "musicGenerationProviders", + "webContentExtractors", + "webFetchProviders", + "webSearchProviders", + "webSearch", + "embeddingProviders", + "memoryEmbeddingProviders", + "memoryCorpusSupplements", + "externalAuthProviders", +]); + +const CONTRACT_TOPIC_LABELS = { + commands: "CLI", + mcpServers: "MCP", + speechProviders: "Text-to-Speech", + realtimeTranscriptionProviders: "Speech-to-Text", + realtimeVoiceProviders: "Voice Calls", + voiceProviders: "Voice", + mediaUnderstandingProviders: "Media Understanding", + transcriptSourceProviders: "Transcription", + documentExtractors: "Document Extraction", + imageGenerationProviders: "Image Generation", + videoGenerationProviders: "Video Generation", + musicGenerationProviders: "Music Generation", + webContentExtractors: "Web Extraction", + webFetchProviders: "Web Fetch", + webSearchProviders: "Web Search", + webSearch: "Web Search", + embeddingProviders: "Embeddings", + memoryEmbeddingProviders: "Embeddings", + memoryCorpusSupplements: "Knowledge Retrieval", + externalAuthProviders: "Authentication", + trustedToolPolicies: "Policy Enforcement", + migrationProviders: "Migrations", + gatewayMethodDispatch: "Gateway Extensions", + routes: "Routing", + agentToolResultMiddleware: "Middleware", + hooks: "Hooks", +}; + +const PLUGIN_CATEGORY_PRIORITY = { + memory: 1000, + context: 990, + channels: 900, + models: 850, + voice: 800, + media: 750, + web: 700, + gateway: 680, + security: 670, + runtime: 660, + tools: 650, +}; + +const CONTRACT_CATEGORY = { + tools: "tools", + commands: "tools", + mcpServers: "tools", + cli: "tools", + + speechProviders: "voice", + realtimeTranscriptionProviders: "voice", + realtimeVoiceProviders: "voice", + voiceProviders: "voice", + + mediaUnderstandingProviders: "media", + transcriptSourceProviders: "media", + documentExtractors: "media", + imageGenerationProviders: "media", + videoGenerationProviders: "media", + musicGenerationProviders: "media", + + webContentExtractors: "web", + webFetchProviders: "web", + webSearchProviders: "web", + webSearch: "web", + + embeddingProviders: "memory", + memoryEmbeddingProviders: "memory", + memoryCorpusSupplements: "memory", + + externalAuthProviders: "security", + trustedToolPolicies: "security", + + migrationProviders: "gateway", + gatewayMethodDispatch: "gateway", + routes: "gateway", + + embeddedExtensionFactories: "runtime", + agentToolResultMiddleware: "runtime", + hooks: "runtime", +}; + +const PLUGIN_KIND_CATEGORY = { + channel: "channels", + "bundled-channel-entry": "channels", + provider: "models", + tool: "tools", + tools: "tools", + skill: "tools", + integration: "tools", + hook: "runtime", + "hook-only": "runtime", + runtime: "runtime", + lifecycle: "runtime", + security: "security", + "preflight-governance": "security", +}; + +const PLUGIN_TEXT_RULES = { + channels: { + strong: [ + "channel plugin", + "messaging channel", + "communication channel", + "discord", + "slack", + "telegram", + "whatsapp", + "signal", + "matrix", + "microsoft teams", + "mattermost", + "feishu", + "lark", + "wechat", + "imessage", + "zalo", + "nostr", + ], + keywords: ["channel", "messaging", "chat"], + }, + models: { + strong: [ + "model provider", + "llm provider", + "inference provider", + "model routing", + "model router", + "language model", + "openai", + "anthropic", + "mistral", + "ollama", + "deepseek", + "gemini", + "groq", + "bedrock", + "minimax", + "xai", + ], + keywords: ["model", "inference", "llm"], + }, + memory: { + strong: [ + "agent memory", + "long term memory", + "memory store", + "memory system", + "semantic memory", + "episodic memory", + "vector database", + "vector store", + "memory embedding", + ], + keywords: ["memory", "recall", "embedding", "vector"], + }, + context: { + strong: [ + "context engine", + "context management", + "context window", + "context compaction", + "conversation context", + "session context", + "context guardian", + "context topics", + ], + keywords: ["context", "compaction"], + }, + voice: { + strong: [ + "voice call", + "voice assistant", + "text to speech", + "speech to text", + "speech recognition", + "speech synthesis", + "audio transcription", + "realtime voice", + "transcription", + ], + keywords: ["voice", "speech", "tts", "stt", "transcription"], + }, + media: { + strong: [ + "image generation", + "video generation", + "media generation", + "image understanding", + "media understanding", + "vision model", + "image processing", + "video processing", + "image editing", + "video editing", + "music generation", + "audio generation", + ], + keywords: ["image", "video", "media", "vision", "music"], + }, + web: { + strong: [ + "web search", + "search provider", + "browser automation", + "web browser", + "web scraping", + "web fetch", + "web research", + "website crawler", + ], + keywords: ["browser", "search", "web", "scrape", "crawl"], + }, + tools: { + strong: [ + "mcp server", + "tool plugin", + "tool provider", + "external api", + "api integration", + "integration plugin", + "workflow tool", + "skills bundle", + "skill bundle", + "toolkit", + "command line", + ], + keywords: ["mcp", "integration", "toolkit"], + }, + runtime: { + strong: [ + "agent runtime", + "runtime plugin", + "plugin runtime", + "runtime extension", + "plugin hook", + "runtime hook", + "middleware", + "telemetry", + "tracing", + "observability", + "diagnostics", + "scheduler", + "reliability", + "health checks", + ], + keywords: [ + "runtime", + "hook", + "middleware", + "tracing", + "telemetry", + "extension", + "scheduler", + "diagnostics", + ], + }, + gateway: { + strong: [ + "gateway plugin", + "gateway operations", + "gateway method", + "gateway route", + "gateway config", + "gateway health", + "gateway manager", + "gateway proxy", + ], + keywords: ["gateway", "proxy"], + }, + security: { + strong: [ + "security plugin", + "security audit", + "authentication provider", + "authorization provider", + "access control", + "permission checks", + "policy enforcement", + "prompt injection", + "secret management", + "credential management", + "security policy", + "sandbox", + "compliance", + ], + keywords: ["security", "permission", "policy", "guard", "sandbox", "compliance"], + }, +}; + +const SKILL_RULES = { + integrations: { + strong: [ + "api integration", + "data integration", + "database", + "data pipeline", + "data warehouse", + "data analysis", + "data processing", + "data extraction", + "sql query", + "rest api", + "graphql", + "webhook", + "postgres", + "mysql", + "sqlite", + "mongodb", + "spreadsheet", + "spreadsheets", + "google sheets", + "google forms", + "csv", + "etl", + "数据分析", + "数据处理", + "数据提取", + "数据库", + "接口调用", + "数据集成", + "数据同步", + "数据可视化", + ], + keywords: [ + "api", + "dataset", + "database", + "sql", + "json", + "csv", + "integration", + "sync", + "接口", + "数据库", + ], + }, + automation: { + strong: [ + "automation", + "automate", + "automated workflow", + "workflow automation", + "automation workflow", + "automate workflow", + "scheduled task", + "task scheduler", + "cron job", + "batch processing", + "orchestration", + "n8n", + "zapier", + "自动化", + "工作流自动化", + "任务调度", + "定时任务", + "批量处理", + "流程编排", + ], + keywords: [ + "automate", + "workflow", + "cron", + "schedule", + "pipeline", + "orchestrate", + "batch", + "工作流", + "调度", + "定时", + "批量", + ], + }, + research: { + strong: [ + "web search", + "search the web", + "browser automation", + "web browser", + "web scraping", + "scrape website", + "crawl website", + "online research", + "market research", + "literature review", + "arxiv", + "competitor monitoring", + "competitor analysis", + "competitive intelligence", + "current research", + "current information", + "current news", + "rss feed", + "playwright", + "selenium", + "网页搜索", + "网络搜索", + "浏览器自动化", + "网页抓取", + "市场研究", + "市场调研", + "新闻检索", + "舆情分析", + ], + keywords: [ + "browser", + "research", + "scrape", + "crawl", + "website", + "news", + "rss", + "search", + "搜索", + "调研", + "研究", + "新闻", + ], + }, + development: { + strong: [ + "code review", + "software development", + "full stack development", + "fullstack development", + "frontend development", + "backend development", + "full stack developer", + "fullstack developer", + "developer tool", + "developer workflow", + "debug code", + "debugging", + "unit test", + "integration test", + "test driven development", + "pull request", + "git repository", + "source code", + "command line interface", + "typescript", + "javascript", + "python code", + "代码审查", + "软件开发", + "代码开发", + "全栈开发", + "前端开发", + "后端开发", + "全栈工程师", + "单元测试", + "编程", + "源码", + "技术开发", + ], + keywords: [ + "code", + "coding", + "developer", + "debug", + "test", + "git", + "github", + "repository", + "sdk", + "代码", + "开发", + "调试", + "测试", + "编程", + ], + }, + productivity: { + strong: [ + "project management", + "task management", + "business analysis", + "business operations", + "sales pipeline", + "customer relationship management", + "meeting notes", + "meeting assistant", + "calendar management", + "jira", + "notion", + "crm", + "human resources", + "marketing", + "项目管理", + "任务管理", + "客户管理", + "销售管理", + "会议纪要", + "日报", + "周报", + "月报", + "工作汇总", + "营销", + ], + keywords: [ + "business", + "productivity", + "project", + "task", + "meeting", + "calendar", + "sales", + "marketing", + "crm", + "jira", + "项目", + "任务", + "会议", + "销售", + "营销", + "汇总", + ], + }, + communication: { + strong: [ + "social media management", + "social media publishing", + "social media posting", + "publish social media", + "post to social media", + "send message", + "send email", + "email management", + "community management", + "content publishing", + "crisis communication", + "public relations", + "media relations", + "press release", + "customer support", + "customer service", + "gmail", + "post tweets", + "发送消息", + "发送邮件", + "邮件管理", + "社区运营", + "内容发布", + "家校沟通", + ], + keywords: [ + "message", + "messaging", + "email", + "social", + "tweet", + "twitter", + "discord", + "slack", + "telegram", + "whatsapp", + "wechat", + "feishu", + "linkedin", + "消息", + "邮件", + "社交", + "沟通", + "社交媒体", + "微信", + "飞书", + "钉钉", + "小红书", + "抖音", + ], + }, + creative: { + strong: [ + "image generation", + "video generation", + "music generation", + "audio generation", + "image editing", + "video editing", + "video editor", + "video processing", + "edit videos", + "process videos", + "audio editing", + "graphic design", + "creative writing", + "content creation", + "3d model", + "animation", + "podcast", + "speech to text", + "text to speech", + "transcription", + "图像生成", + "图片生成", + "视频生成", + "视频编辑", + "视频处理", + "音频处理", + "音乐生成", + "创意写作", + "内容创作", + "小说写作", + "平面设计", + "视频脚本", + "分镜", + "剪辑", + "配音", + "文案", + ], + keywords: [ + "image", + "video", + "audio", + "music", + "media", + "creative", + "design", + "animation", + "podcast", + "transcribe", + "图像", + "图片", + "视频", + "音频", + "音乐", + "创作", + "设计", + ], + }, + knowledge: { + strong: [ + "documentation", + "knowledge base", + "knowledge management", + "document analysis", + "document processing", + "document recognition", + "document extraction", + "optical character recognition", + "information extraction", + "ocr", + "pdf document", + "learning assistant", + "study guide", + "education", + "exam preparation", + "course material", + "research paper", + "summarize document", + "retrieval augmented generation", + "知识库", + "知识管理", + "文档分析", + "文档处理", + "文档识别", + "证件识别", + "信息抽取", + "智能识别", + "学习助手", + "教育助手", + "研究论文", + "学习笔记", + "课程", + "总结文档", + "知识问答", + ], + keywords: [ + "document", + "docs", + "knowledge", + "learn", + "learning", + "study", + "exam", + "education", + "pdf", + "summarize", + "rag", + "文档", + "知识", + "学习", + "教育", + "论文", + "课程", + "笔记", + "总结", + ], + }, + agents: { + strong: [ + "agent memory", + "long term memory", + "memory system", + "context management", + "prompt engineering", + "system prompt", + "agent behavior", + "agent persona", + "multi agent", + "subagent", + "self improvement", + "self improving", + "self improve", + "智能体记忆", + "记忆系统", + "上下文管理", + "提示词工程", + "系统提示词", + "多智能体", + "子智能体", + "智能体行为", + "会话记忆", + "记忆同步", + ], + keywords: [ + "memory", + "context", + "prompt", + "persona", + "subagent", + "multiagent", + "agentic", + "记忆", + "上下文", + "提示词", + "智能体", + "会话", + ], + }, + operations: { + strong: [ + "system administration", + "local system", + "local files", + "infrastructure as code", + "kubernetes", + "docker", + "deployment", + "system monitoring", + "service monitoring", + "infrastructure monitoring", + "uptime monitoring", + "observability", + "log analysis", + "log analyzer", + "backup", + "系统管理", + "系统运维", + "运维部署", + "系统监控", + "服务监控", + "基础设施", + "容器管理", + "日志分析", + "备份恢复", + "服务器管理", + ], + keywords: [ + "docker", + "kubernetes", + "deploy", + "backup", + "observability", + "infrastructure", + "运维", + "部署", + "基础设施", + "容器", + "备份", + "服务器", + ], + }, + security: { + strong: [ + "security audit", + "security audits", + "security scanning", + "penetration testing", + "data protection", + "privacy compliance", + "privacy assessment", + "privacy impact assessment", + "privacy", + "gdpr", + "dpia", + "vulnerability", + "malware", + "access control", + "identity management", + "credential management", + "secret management", + "network security", + "permission checks", + "policy enforcement", + "prompt injection", + "安全审计", + "安全扫描", + "漏洞扫描", + "恶意软件", + "访问控制", + "身份管理", + "凭据管理", + "密钥管理", + "权限检查", + "策略执行", + "提示词注入", + "安全防护", + ], + keywords: [ + "security", + "vulnerability", + "malware", + "permission", + "policy", + "安全", + "漏洞", + "权限", + "凭据", + "密钥", + ], + }, + finance: { + strong: [ + "financial analysis", + "stock analysis", + "stock market", + "investment", + "portfolio management", + "cryptocurrency", + "crypto trading", + "trading strategy", + "payment processing", + "e commerce operations", + "online store", + "expense tracking", + "accounting", + "invoice", + "wallet", + "金融分析", + "股票分析", + "股票市场", + "投资组合", + "量化交易", + "加密货币", + "支付处理", + "电子商务", + "财务分析", + "费用跟踪", + "会计", + "发票", + "钱包", + "资产配置", + "财报分析", + ], + keywords: [ + "finance", + "financial", + "stock", + "investment", + "trading", + "crypto", + "payment", + "commerce", + "expense", + "invoice", + "accounting", + "wallet", + "金融", + "股票", + "投资", + "交易", + "支付", + "财务", + "资产", + "财报", + ], + }, + lifestyle: { + strong: [ + "travel planning", + "trip planning", + "weather forecast", + "current weather", + "weather information", + "weather query", + "fitness", + "workout", + "health tracking", + "meal planning", + "recipe", + "home automation", + "smart home", + "shopping assistant", + "buying guide", + "personal assistant", + "game", + "entertainment", + "旅行规划", + "旅游规划", + "天气预报", + "天气查询", + "健身", + "锻炼", + "健康管理", + "膳食计划", + "食谱", + "智能家居", + "购物助手", + "生活助手", + "穿搭", + "美妆", + "游戏", + "娱乐", + ], + keywords: [ + "travel", + "weather", + "fitness", + "health", + "food", + "recipe", + "home", + "shopping", + "personal", + "game", + "entertainment", + "旅行", + "旅游", + "天气", + "健康", + "购物", + "生活", + ], + }, +}; + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function nonEmptyArray(value) { + return Array.isArray(value) && value.length > 0; +} + +function validExplicitCategories(values, allowed) { + const seen = new Set(); + const output = []; + for (const value of Array.isArray(values) ? values : []) { + if (!allowed.has(value) || value === "other" || seen.has(value)) continue; + seen.add(value); + output.push(value); + } + return output; +} + +export function normalizeTopicSlug(value) { + return String(value ?? "") + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, ""); +} + +function canonicalTopicSlug(value) { + const slug = normalizeTopicSlug(value); + return TOPIC_ALIAS_SLUGS.get(slug) ?? slug; +} + +function cleanTopicLabel(value) { + return String(value ?? "") + .normalize("NFKC") + .trim() + .replace(/^[-*]\s*/, "") + .replace(/^["']|["']$/g, "") + .replace(/\s+/g, " "); +} + +function validExplicitTopics(values) { + const seen = new Set(); + const topics = []; + for (const rawValue of Array.isArray(values) ? values : []) { + const label = cleanTopicLabel(rawValue); + const slug = normalizeTopicSlug(label); + if (!label || label.length > TOPIC_MAX_LENGTH || !slug || RESERVED_TOPIC_SLUGS.has(slug)) { + continue; + } + if (seen.has(slug)) continue; + seen.add(slug); + topics.push(label); + if (topics.length >= TOPIC_LIMIT) break; + } + return topics; +} + +function formatInferredTopicLabel(value, slug) { + const canonical = TOPIC_CANONICAL_LABELS.get(slug); + if (canonical) return canonical; + const cleaned = cleanTopicLabel(value).replace(/[_-]+/g, " "); + if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(cleaned)) { + return cleaned; + } + return cleaned + .split(/\s+/) + .filter(Boolean) + .map((word) => { + const lower = word.toLowerCase(); + return ( + TOPIC_CANONICAL_LABELS.get(lower) ?? `${lower.slice(0, 1).toUpperCase()}${lower.slice(1)}` + ); + }) + .join(" "); +} + +export function isAllowedInferredTopic(value) { + const label = cleanTopicLabel(value); + const slug = canonicalTopicSlug(label); + if (!label || label.length > TOPIC_MAX_LENGTH || !slug) return false; + if (RESERVED_TOPIC_SLUGS.has(slug) || INFERRED_TOPIC_BLOCKLIST.has(slug)) return false; + if (slug.split("-").some((token) => INFERRED_TOPIC_BLOCKED_TOKENS.has(token))) return false; + if (!/[\p{L}]/u.test(label)) return false; + if (/^(?:v|version-?)?\d+(?:[.-]\d+){1,}$/i.test(slug)) return false; + if (/https?:|www\.|@|\.(?:com|org|net|ai)$/i.test(label)) return false; + if (slug.split("-").length > 5) return false; + return true; +} + +function createTopicCandidateCollector() { + const candidates = new Map(); + + function add( + value, + { source, evidence = value, score, primaryEvidence = false, strongEvidence = false }, + ) { + if (!isAllowedInferredTopic(value)) return; + const slug = canonicalTopicSlug(value); + const existing = candidates.get(slug) ?? { + topic: formatInferredTopicLabel(value, slug), + slug, + sourceScores: new Map(), + sources: [], + evidence: [], + primarySources: new Set(), + strongEvidence: false, + }; + existing.sourceScores.set(source, Math.max(existing.sourceScores.get(source) ?? 0, score)); + if (!existing.sources.includes(source)) existing.sources.push(source); + if (!existing.evidence.includes(evidence)) existing.evidence.push(evidence); + if (primaryEvidence) existing.primarySources.add(source); + existing.strongEvidence ||= strongEvidence; + candidates.set(slug, existing); + } + + function values() { + return [...candidates.values()] + .map((candidate) => { + const primarySourceCount = candidate.primarySources.size; + const score = + [...candidate.sourceScores.values()].reduce((sum, value) => sum + value, 0) + + Math.max(0, primarySourceCount - 1) * 4; + const confidence = + candidate.strongEvidence || (score >= 12 && primarySourceCount >= 2) + ? "high" + : primarySourceCount > 0 && score >= 6 + ? "medium" + : "low"; + return { + topic: candidate.topic, + slug: candidate.slug, + score, + sources: candidate.sources, + evidence: candidate.evidence.slice(0, 12), + primaryEvidence: primarySourceCount > 0, + primarySourceCount, + strongEvidence: candidate.strongEvidence, + confidence, + }; + }) + .sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug)); + } + + return { add, values }; +} + +function annotateRedundantTopicCandidates(rawCandidates) { + const supported = rawCandidates.filter((candidate) => candidate.confidence !== "low"); + return rawCandidates.map((candidate) => { + const tokens = candidate.slug.split("-").filter(Boolean); + if (candidate.confidence === "low" || tokens.length !== 1) return candidate; + const moreSpecific = supported.find( + (other) => + other.slug !== candidate.slug && + other.primaryEvidence && + other.slug.split("-").includes(candidate.slug), + ); + return moreSpecific ? { ...candidate, suppressedBy: moreSpecific.slug } : candidate; + }); +} + +function buildTopicResult({ explicitTopics, rawCandidates, topicInputHash }) { + if (explicitTopics !== undefined) { + const topics = validExplicitTopics(explicitTopics); + return { + topics, + rawTopicCandidates: topics.map((topic, index) => ({ + topic, + slug: normalizeTopicSlug(topic), + score: 1000 - index, + sources: ["author"], + evidence: ["explicit topic"], + primaryEvidence: true, + primarySourceCount: 1, + strongEvidence: true, + confidence: "high", + })), + topicConfidence: "high", + topicsNeedAi: false, + topicProvenance: "author", + topicClassifierVersion: TOPIC_CLASSIFIER_VERSION, + topicCandidateCountBeforeCap: topics.length, + topicInputHash, + }; + } + + const annotatedCandidates = annotateRedundantTopicCandidates(rawCandidates); + const accepted = annotatedCandidates.filter( + (candidate) => candidate.confidence !== "low" && !candidate.suppressedBy, + ); + const topics = accepted.slice(0, TOPIC_LIMIT).map((candidate) => candidate.topic); + const high = + topics.length > 0 && + accepted.length <= TOPIC_LIMIT && + accepted.every((candidate) => candidate.confidence === "high"); + return { + topics, + rawTopicCandidates: annotatedCandidates, + topicConfidence: topics.length === 0 ? "low" : high ? "high" : "medium", + topicsNeedAi: !high, + topicProvenance: "deterministic-topic-v1", + topicClassifierVersion: TOPIC_CLASSIFIER_VERSION, + topicCandidateCountBeforeCap: accepted.length, + topicInputHash, + }; +} + +function attachTopics(result, topicResult) { + return { ...result, ...topicResult }; +} + +function buildResult({ + family, + categories, + rawCandidates, + confidence, + needsAi, + provenance, + unknownSignals = [], + candidateCountBeforeCap = rawCandidates.length, + inputHash, +}) { + return { + family, + categories: categories.length > 0 ? categories : ["other"], + rawCandidates, + confidence, + needsAi, + provenance, + classifierVersion: CLASSIFIER_VERSION, + unknownSignals, + candidateCountBeforeCap, + inputHash, + }; +} + +function pluginAutoCandidates(manifest, slug, text) { + const candidates = new Map(); + const unknownSignals = []; + const retained = new Set(); + + function add( + category, + source, + evidence, + strongEvidence = true, + score = PLUGIN_CATEGORY_PRIORITY[category] ?? 0, + ) { + const existing = candidates.get(category) ?? { + category, + score, + sources: [], + evidence: [], + strongEvidence: false, + }; + existing.score = Math.max(existing.score, score); + if (!existing.sources.includes(source)) existing.sources.push(source); + if (!existing.evidence.includes(evidence)) existing.evidence.push(evidence); + existing.strongEvidence ||= strongEvidence; + candidates.set(category, existing); + } + + const rawKinds = Array.isArray(manifest?.kind) + ? manifest.kind + : manifest?.kind + ? [manifest.kind] + : []; + for (const kind of rawKinds) { + if (kind === "memory") { + add("memory", "plugin-manifest", "kind:memory"); + retained.add("memory"); + } else if (kind === "context-engine") { + add("context", "plugin-manifest", "kind:context-engine"); + retained.add("context"); + } else { + unknownSignals.push(`kind:${String(kind)}`); + const weakCategory = PLUGIN_KIND_CATEGORY[kind]; + if (weakCategory) add(weakCategory, "plugin-manifest", `kind:${kind}`, false, 380); + } + } + + if (nonEmptyArray(manifest?.channels)) add("channels", "plugin-manifest", "channels"); + if (nonEmptyArray(manifest?.providers)) add("models", "plugin-manifest", "providers"); + if (nonEmptyArray(manifest?.cliBackends) || nonEmptyArray(manifest?.qaRunners)) { + add( + "runtime", + "plugin-manifest", + nonEmptyArray(manifest?.cliBackends) ? "cliBackends" : "qaRunners", + ); + } + if (nonEmptyArray(manifest?.skills)) add("tools", "plugin-manifest", "skills", false, 300); + if (nonEmptyArray(manifest?.hooks)) add("runtime", "plugin-manifest", "hooks"); + + if ( + manifest?.contracts && + typeof manifest.contracts === "object" && + !Array.isArray(manifest.contracts) + ) { + for (const [key, value] of Object.entries(manifest.contracts)) { + if (!nonEmptyArray(value)) continue; + const category = CONTRACT_CATEGORY[key]; + if (category) { + add(category, "plugin-manifest", `contracts.${key}`); + } else { + unknownSignals.push(`contracts.${key}`); + } + } + } + + for (const candidate of pluginTextCandidates(slug, text)) { + for (const evidence of candidate.evidence) { + add(candidate.category, "plugin-text", evidence, false, 400 + candidate.score); + } + } + + return { + candidates: [...candidates.values()].sort( + (a, b) => + b.score - a.score || + (PLUGIN_ORDER.get(a.category) ?? 999) - (PLUGIN_ORDER.get(b.category) ?? 999), + ), + retained, + unknownSignals: [...new Set(unknownSignals)].sort(), + }; +} + +export function classifyPlugin({ + manifest = {}, + slug = "", + text = "", + topicText = text, + explicitCategories, + explicitTopics, + topicTags = [], +} = {}) { + const inputHash = sha256(JSON.stringify({ manifest, slug, text, explicitCategories })); + const auto = pluginAutoCandidates(manifest, slug, text); + const explicit = validExplicitCategories(explicitCategories, PLUGIN_CATEGORY_SET); + const topicResult = classifyPluginTopics({ + manifest, + slug, + topicText, + explicitTopics, + topicTags, + }); + + if (explicit.length > 0) { + const retained = [...auto.retained]; + const categories = [ + ...retained, + ...explicit.filter((category) => !auto.retained.has(category)), + ].slice(0, 3); + return attachTopics( + buildResult({ + family: "plugin", + categories, + rawCandidates: explicit.map((category, index) => ({ + category, + score: 1000 - index, + sources: ["author"], + evidence: ["explicit category"], + })), + confidence: "high", + needsAi: false, + provenance: "author", + candidateCountBeforeCap: explicit.length + retained.length, + inputHash, + }), + topicResult, + ); + } + + const selected = []; + for (const category of auto.retained) selected.push(category); + const hasSpecificCandidate = auto.candidates.some((candidate) => candidate.category !== "tools"); + const acceptedCandidates = auto.candidates.filter( + (candidate) => + candidate.category !== "tools" || candidate.strongEvidence || !hasSpecificCandidate, + ); + for (const candidate of acceptedCandidates) { + if (!selected.includes(candidate.category)) selected.push(candidate.category); + if (selected.length >= 3) break; + } + + const recognized = acceptedCandidates.length > 0; + const overloaded = acceptedCandidates.length > 3; + const hasUnknown = auto.unknownSignals.length > 0; + const hasWeak = acceptedCandidates.some((candidate) => !candidate.strongEvidence); + const confidence = !recognized ? "low" : overloaded || hasUnknown || hasWeak ? "medium" : "high"; + + return attachTopics( + buildResult({ + family: "plugin", + categories: selected, + rawCandidates: auto.candidates, + confidence, + needsAi: confidence !== "high", + provenance: "deterministic-v9", + unknownSignals: auto.unknownSignals, + candidateCountBeforeCap: acceptedCandidates.length, + inputHash, + }), + topicResult, + ); +} + +function normalizeForMatching(value) { + return ` ${value + .normalize("NFKC") + .toLowerCase() + .replace(/```[\s\S]*?```/g, " ") + .replace(/https?:\/\/\S+/g, " ") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .replace(/\s+/g, " ") + .trim()} `; +} + +function hasTerm(normalized, term) { + const needle = normalizeForMatching(term).trim(); + if (!needle) return false; + if (/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(needle)) { + return normalized.includes(needle); + } + return normalized.includes(` ${needle} `); +} + +function scoreTextCategory(category, rules, primary, body, source) { + let score = 0; + const evidence = []; + let primaryEvidence = false; + let strongPrimaryEvidence = false; + const primaryTerms = new Set(); + const strongPrimaryTerms = new Set(); + const bodyTerms = new Set(); + const strongBodyTerms = new Set(); + + for (const term of rules.strong) { + if (!primaryTerms.has(term) && hasTerm(primary, term)) { + score += 8; + primaryEvidence = true; + strongPrimaryEvidence = true; + primaryTerms.add(term); + strongPrimaryTerms.add(term); + evidence.push(term); + } + if (!bodyTerms.has(term) && hasTerm(body, term)) { + score += 3; + bodyTerms.add(term); + strongBodyTerms.add(term); + if (!evidence.includes(term)) evidence.push(term); + } + } + for (const term of rules.keywords) { + if (!primaryTerms.has(term) && hasTerm(primary, term)) { + score += 4; + primaryEvidence = true; + primaryTerms.add(term); + if (!evidence.includes(term)) evidence.push(term); + } + if (!bodyTerms.has(term) && hasTerm(body, term)) { + score += 1; + bodyTerms.add(term); + if (!evidence.includes(term)) evidence.push(term); + } + } + + return { + category, + score, + sources: [source], + evidence: evidence.slice(0, 12), + primaryEvidence, + strongPrimaryEvidence, + primaryEvidenceCount: primaryTerms.size, + primaryEvidenceTerms: [...primaryTerms], + strongPrimaryEvidenceTerms: [...strongPrimaryTerms], + bodyEvidenceTerms: [...bodyTerms], + strongBodyEvidenceTerms: [...strongBodyTerms], + }; +} + +function pluginTextCandidates(slug, text) { + if (!slug && !text) return []; + const primary = normalizeForMatching(`${slug} ${text.slice(0, 2400)}`); + const body = normalizeForMatching(text.slice(2400, 24000)); + const scored = Object.entries(PLUGIN_TEXT_RULES) + .map(([category, rules]) => scoreTextCategory(category, rules, primary, body, "plugin-text")) + .filter((candidate) => candidate.score > 0) + .sort( + (a, b) => + b.score - a.score || + (PLUGIN_ORDER.get(a.category) ?? 999) - (PLUGIN_ORDER.get(b.category) ?? 999), + ); + const top = scored[0]; + if (!top || top.score < 4) return []; + return scored.filter((candidate) => candidate.score >= 4 && candidate.score >= top.score * 0.5); +} + +const SKILL_INTENT_FIELDS = new Set([ + "name", + "displayname", + "description", + "summary", + "tags", + "keywords", + "category", + "categories", + "triggers", + "use_when", + "when_to_use", +]); + +function extractFirstHeading(text) { + const match = text.match(/(?:^|\r?\n)(#{1,2})\s+([^\r\n]+)/); + if (!match) return ""; + const nextHeading = match[2].search(/\s+#{1,6}\s+/); + const heading = (nextHeading >= 0 ? match[2].slice(0, nextHeading) : match[2]).trim(); + return `${match[1]} ${heading.slice(0, 160)}`; +} + +function extractLooseDescription(text) { + const prefix = text.slice(0, 2400); + const field = prefix.match(/\bdescription\s*:\s*/i); + if (!field) return ""; + const rest = prefix.slice(field.index + field[0].length); + const boundary = rest.search( + /\s+(?:name|description|version|author|homepage|metadata)\s*:|\s+#{1,6}\s+/i, + ); + return (boundary >= 0 ? rest.slice(0, boundary) : rest).replace(/^["']|["']$/g, "").slice(0, 600); +} + +function fallbackSkillPrimary(slug, text) { + const withoutCode = text.replace(/```[\s\S]*?```/g, " "); + const firstHeading = extractFirstHeading(withoutCode); + const looseDescription = extractLooseDescription(withoutCode); + const firstProse = looseDescription + ? "" + : (withoutCode + .split(/\r?\n\s*\r?\n/) + .map((block) => block.trim()) + .find((block) => block && !block.startsWith("#") && !block.startsWith("---")) ?? ""); + return normalizeForMatching( + `${slug} ${looseDescription} ${firstHeading} ${firstProse.slice(0, 400)}`, + ); +} + +function splitSkillFrontmatter(text) { + const trimmed = text.trimStart(); + if (!trimmed.startsWith("---")) return null; + const afterOpening = trimmed.slice(3); + const blockClosing = afterOpening.match(/\r?\n---(?:\r?\n|$)/); + const inlineClosing = afterOpening.match(/\s---(?:\s|$)/); + const blockIndex = blockClosing?.index ?? Number.POSITIVE_INFINITY; + const inlineIndex = inlineClosing?.index ?? Number.POSITIVE_INFINITY; + const closingIndex = Math.min(blockIndex, inlineIndex); + if (!Number.isFinite(closingIndex) || closingIndex > 5000) return null; + + if (blockIndex <= inlineIndex) { + return { + frontmatter: afterOpening.slice(0, blockClosing.index), + bodyText: afterOpening.slice(blockClosing.index + blockClosing[0].length), + inline: false, + }; + } + + return { + frontmatter: afterOpening.slice(0, inlineClosing.index), + bodyText: afterOpening.slice(inlineClosing.index + inlineClosing[0].length), + inline: true, + }; +} + +function extractFrontmatterEntries(frontmatter, inline) { + const entries = []; + if (inline) { + const fields = [...frontmatter.matchAll(/\b([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*/g)]; + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index]; + const start = field.index + field[0].length; + const end = fields[index + 1]?.index ?? frontmatter.length; + entries.push({ + field: field[1].toLowerCase(), + rawField: field[1], + value: frontmatter.slice(start, end).trim(), + }); + } + return entries; + } + + let current = null; + for (const line of frontmatter.split(/\r?\n/)) { + const topLevel = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/); + if (topLevel) { + current = { + field: topLevel[1].toLowerCase(), + rawField: topLevel[1], + value: topLevel[2], + }; + entries.push(current); + continue; + } + if (current && /^\s+/.test(line)) current.value += `\n${line.trim()}`; + } + return entries; +} + +function parseTopicTags(value) { + const cleaned = String(value ?? "") + .replace(/^\s*\[|\]\s*$/g, "") + .replace(/^\s*-\s*/gm, "") + .trim(); + if (!cleaned) return []; + return cleaned + .split(/\s*(?:,|;|\r?\n|\s+-\s+)\s*/) + .map(cleanTopicLabel) + .filter(Boolean); +} + +function extractSkillZones(slug, text) { + const artifactSlug = slug.split("/").filter(Boolean).at(-1) ?? slug; + const frontmatter = splitSkillFrontmatter(text); + if (!frontmatter) { + const primary = fallbackSkillPrimary(artifactSlug, text); + return { + artifactSlug, + primary, + topicPrimary: fallbackSkillPrimary("", text), + topicTags: [], + body: normalizeForMatching(text.slice(0, 16000)), + }; + } + + const entries = extractFrontmatterEntries(frontmatter.frontmatter, frontmatter.inline); + const intent = entries + .filter((entry) => SKILL_INTENT_FIELDS.has(entry.field)) + .map((entry) => `${entry.rawField}: ${entry.value}`); + const topicIntent = entries + .filter( + (entry) => + SKILL_INTENT_FIELDS.has(entry.field) && + !["tags", "keywords", "category", "categories"].includes(entry.field), + ) + .map((entry) => `${entry.rawField}: ${entry.value}`); + const topicTags = entries + .filter((entry) => ["tags", "keywords"].includes(entry.field)) + .flatMap((entry) => parseTopicTags(entry.value)); + const firstHeading = + frontmatter.inline && !/[\r\n]/.test(frontmatter.bodyText) + ? "" + : extractFirstHeading(frontmatter.bodyText); + return { + artifactSlug, + primary: normalizeForMatching(`${artifactSlug} ${intent.join("\n")} ${firstHeading}`), + topicPrimary: normalizeForMatching(`${topicIntent.join("\n")} ${firstHeading}`), + topicTags, + body: normalizeForMatching(frontmatter.bodyText.slice(0, 16000)), + }; +} + +function addTopicTextEvidence(collector, candidates, primary, body, sourcePrefix) { + for (const candidate of candidates) { + const strongPrimary = new Set(candidate.strongPrimaryEvidenceTerms ?? []); + const strongBody = new Set(candidate.strongBodyEvidenceTerms ?? []); + for (const term of candidate.primaryEvidenceTerms ?? []) { + if (!hasTerm(primary, term)) continue; + collector.add(term, { + source: `${sourcePrefix}-primary`, + evidence: `${sourcePrefix} primary: ${term}`, + score: strongPrimary.has(term) ? 8 : 5, + primaryEvidence: true, + }); + } + for (const term of candidate.bodyEvidenceTerms ?? []) { + if (!hasTerm(body, term)) continue; + collector.add(term, { + source: `${sourcePrefix}-body`, + evidence: `${sourcePrefix} body: ${term}`, + score: strongBody.has(term) ? 3 : 1, + }); + } + } +} + +function addSlugTopicCorroboration(collector, slug, source) { + const normalizedSlug = normalizeForMatching(slug); + for (const candidate of collector.values()) { + if (!hasTerm(normalizedSlug, candidate.topic)) continue; + collector.add(candidate.topic, { + source, + evidence: `${source}: ${candidate.slug}`, + score: 6, + primaryEvidence: true, + }); + } +} + +function structuredTopicValues(value) { + const output = []; + for (const entry of Array.isArray(value) ? value : value == null ? [] : [value]) { + if (typeof entry === "string") { + output.push(entry); + continue; + } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + for (const key of ["id", "name", "slug", "provider", "channel", "type"]) { + if (typeof entry[key] === "string") output.push(entry[key]); + } + } + return output; +} + +function classifyPluginTopics({ manifest, slug, topicText, explicitTopics, topicTags }) { + const topicInputHash = sha256( + JSON.stringify({ manifest, slug, topicText, explicitTopics, topicTags }), + ); + if (explicitTopics !== undefined) { + return buildTopicResult({ explicitTopics, rawCandidates: [], topicInputHash }); + } + + const collector = createTopicCandidateCollector(); + const artifactSlug = slug.split("/").filter(Boolean).at(-1) ?? slug; + const publisherSlug = slug.includes("/") ? slug.split("/").filter(Boolean)[0] : ""; + for (const value of Array.isArray(topicTags) ? topicTags : []) { + if (canonicalTopicSlug(value) === canonicalTopicSlug(publisherSlug)) continue; + collector.add(value, { + source: "plugin-tag", + evidence: `package keyword: ${value}`, + score: 8, + primaryEvidence: true, + }); + } + + for (const field of ["channels", "providers", "cliBackends", "qaRunners"]) { + const strongEvidence = ["channels", "providers"].includes(field); + for (const value of structuredTopicValues(manifest?.[field])) { + collector.add(value, { + source: "plugin-structured", + evidence: `${field}: ${value}`, + score: strongEvidence ? 12 : 8, + primaryEvidence: true, + strongEvidence, + }); + } + } + + if ( + manifest?.contracts && + typeof manifest.contracts === "object" && + !Array.isArray(manifest.contracts) + ) { + for (const [key, value] of Object.entries(manifest.contracts)) { + if (!nonEmptyArray(value)) continue; + const contractTopic = CONTRACT_TOPIC_LABELS[key]; + if (contractTopic) { + collector.add(contractTopic, { + source: "plugin-contract", + evidence: `contracts.${key}`, + score: 12, + primaryEvidence: true, + strongEvidence: true, + }); + } + for (const topic of structuredTopicValues(value)) { + const strongEvidence = STRONG_CONTRACT_VALUE_KEYS.has(key); + collector.add(topic, { + source: "plugin-contract", + evidence: `contracts.${key}: ${topic}`, + score: strongEvidence ? 12 : 8, + primaryEvidence: true, + strongEvidence, + }); + } + } + } + + const topicPrimary = normalizeForMatching(String(topicText ?? "").slice(0, 2400)); + const topicBody = normalizeForMatching(String(topicText ?? "").slice(2400, 24000)); + addTopicTextEvidence( + collector, + pluginTextCandidates("", topicText ?? ""), + topicPrimary, + topicBody, + "plugin-text", + ); + for (const tag of Array.isArray(topicTags) ? topicTags : []) { + if (!hasTerm(topicPrimary, tag)) continue; + collector.add(tag, { + source: "plugin-primary", + evidence: `plugin primary: ${tag}`, + score: 5, + primaryEvidence: true, + }); + } + addSlugTopicCorroboration(collector, artifactSlug, "plugin-slug"); + + return buildTopicResult({ + explicitTopics, + rawCandidates: collector.values(), + topicInputHash, + }); +} + +function classifySkillTopics({ slug, text, explicitTopics, topicTags, zones, categoryCandidates }) { + const allTopicTags = [...zones.topicTags, ...(Array.isArray(topicTags) ? topicTags : [])]; + const topicInputHash = sha256( + `${slug}\0${text}\0${JSON.stringify(explicitTopics ?? null)}\0${JSON.stringify(allTopicTags)}`, + ); + if (explicitTopics !== undefined) { + return buildTopicResult({ explicitTopics, rawCandidates: [], topicInputHash }); + } + + const collector = createTopicCandidateCollector(); + for (const tag of allTopicTags) { + collector.add(tag, { + source: "skill-tag", + evidence: `root tag: ${tag}`, + score: 8, + primaryEvidence: true, + }); + if (hasTerm(zones.topicPrimary, tag)) { + collector.add(tag, { + source: "skill-primary", + evidence: `skill primary: ${tag}`, + score: 5, + primaryEvidence: true, + }); + } + } + + addTopicTextEvidence(collector, categoryCandidates, zones.topicPrimary, zones.body, "skill-text"); + addSlugTopicCorroboration(collector, zones.artifactSlug, "skill-slug"); + + return buildTopicResult({ + explicitTopics, + rawCandidates: collector.values(), + topicInputHash, + }); +} + +function scoreSkillCategory(category, primary, body) { + return scoreTextCategory(category, SKILL_RULES[category], primary, body, "skill-text"); +} + +export function classifySkill({ + slug = "", + text = "", + explicitCategories, + explicitTopics, + topicTags = [], +} = {}) { + const inputHash = sha256(`${slug}\0${text}\0${JSON.stringify(explicitCategories ?? [])}`); + const explicit = validExplicitCategories(explicitCategories, SKILL_CATEGORY_SET); + const zones = extractSkillZones(slug, text); + const scored = Object.keys(SKILL_RULES) + .map((category) => scoreSkillCategory(category, zones.primary, zones.body)) + .filter((candidate) => candidate.score > 0) + .sort( + (a, b) => + b.score - a.score || + (SKILL_ORDER.get(a.category) ?? 999) - (SKILL_ORDER.get(b.category) ?? 999), + ); + const topicResult = classifySkillTopics({ + slug, + text, + explicitTopics, + topicTags, + zones, + categoryCandidates: scored, + }); + + if (explicit.length > 0) { + return attachTopics( + buildResult({ + family: "skill", + categories: explicit.slice(0, 3), + rawCandidates: explicit.map((category, index) => ({ + category, + score: 1000 - index, + sources: ["author"], + evidence: ["explicit category"], + })), + confidence: "high", + needsAi: false, + provenance: "author", + candidateCountBeforeCap: explicit.length, + inputHash, + }), + topicResult, + ); + } + + const primaryScored = scored.filter((candidate) => candidate.primaryEvidence); + const top = primaryScored[0]; + if (!top || top.score < 5) { + return attachTopics( + buildResult({ + family: "skill", + categories: [], + rawCandidates: scored, + confidence: "low", + needsAi: true, + provenance: "deterministic-v9", + candidateCountBeforeCap: 0, + inputHash, + }), + topicResult, + ); + } + + const purposeScored = primaryScored.filter( + (candidate) => candidate.strongPrimaryEvidence || candidate.primaryEvidenceCount >= 2, + ); + const candidates = [ + top, + ...purposeScored.filter( + (candidate) => + candidate.category !== top.category && + candidate.score >= 7 && + candidate.score >= top.score * 0.55, + ), + ]; + const runnerUp = primaryScored[1]?.score ?? 0; + const high = + (top.strongPrimaryEvidence || top.primaryEvidenceCount >= 2) && + top.score >= 12 && + top.score - runnerUp >= 4; + + return attachTopics( + buildResult({ + family: "skill", + categories: candidates.slice(0, 3).map((candidate) => candidate.category), + rawCandidates: scored, + confidence: high ? "high" : "medium", + needsAi: !high, + provenance: "deterministic-v9", + candidateCountBeforeCap: candidates.length, + inputHash, + }), + topicResult, + ); +} diff --git a/convex/lib/catalogClassifier.test.mjs b/convex/lib/catalogClassifier.test.mjs new file mode 100644 index 00000000..19203367 --- /dev/null +++ b/convex/lib/catalogClassifier.test.mjs @@ -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); +}); diff --git a/convex/lib/packageSearchDigest.test.ts b/convex/lib/packageSearchDigest.test.ts index 2e81e1a6..6901dacd 100644 --- a/convex/lib/packageSearchDigest.test.ts +++ b/convex/lib/packageSearchDigest.test.ts @@ -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(); diff --git a/convex/lib/packageSearchDigest.ts b/convex/lib/packageSearchDigest.ts index 1f4f2973..2f364881 100644 --- a/convex/lib/packageSearchDigest.ts +++ b/convex/lib/packageSearchDigest.ts @@ -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, }; } diff --git a/convex/lib/skillSearchDigest.test.ts b/convex/lib/skillSearchDigest.test.ts index 1a2391d2..85515f93 100644 --- a/convex/lib/skillSearchDigest.test.ts +++ b/convex/lib/skillSearchDigest.test.ts @@ -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: [ diff --git a/convex/lib/skillSearchDigest.ts b/convex/lib/skillSearchDigest.ts index b93e2a5f..477e24c4 100644 --- a/convex/lib/skillSearchDigest.ts +++ b/convex/lib/skillSearchDigest.ts @@ -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, diff --git a/convex/migrations.test.ts b/convex/migrations.test.ts index 762894e4..0ca9646d 100644 --- a/convex/migrations.test.ts +++ b/convex/migrations.test.ts @@ -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; }; +type ClassificationApplyWrappedHandler = { + _handler: ( + ctx: unknown, + args: { dryRun?: boolean; minimumConfidence: "high" | "medium"; confirm?: string }, + ) => Promise; +}; + 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.'); + }); }); diff --git a/convex/migrations.ts b/convex/migrations.ts index 9dd094a5..78756565 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -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, + 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(), diff --git a/convex/schema.ts b/convex/schema.ts index 84d89144..acac0500 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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, diff --git a/packages/schema/dist/catalogMetadata.d.ts b/packages/schema/dist/catalogMetadata.d.ts index 36809cf1..b2f61a42 100644 --- a/packages/schema/dist/catalogMetadata.d.ts +++ b/packages/schema/dist/catalogMetadata.d.ts @@ -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; diff --git a/packages/schema/dist/catalogMetadata.js b/packages/schema/dist/catalogMetadata.js index d3da818f..3cf5b36a 100644 --- a/packages/schema/dist/catalogMetadata.js +++ b/packages/schema/dist/catalogMetadata.js @@ -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 \ No newline at end of file diff --git a/packages/schema/dist/catalogMetadata.js.map b/packages/schema/dist/catalogMetadata.js.map index db92b285..e2f23cf2 100644 --- a/packages/schema/dist/catalogMetadata.js.map +++ b/packages/schema/dist/catalogMetadata.js.map @@ -1 +1 @@ -{"version":3,"file":"catalogMetadata.js","sourceRoot":"","sources":["../src/catalogMetadata.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"catalogMetadata.js","sourceRoot":"","sources":["../src/catalogMetadata.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/dist/pluginCategories.d.ts b/packages/schema/dist/pluginCategories.d.ts index beeae777..61a7031c 100644 --- a/packages/schema/dist/pluginCategories.d.ts +++ b/packages/schema/dist/pluginCategories.d.ts @@ -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[0]): PluginCategorySlug[]; diff --git a/packages/schema/dist/pluginCategories.js b/packages/schema/dist/pluginCategories.js index b9a3e113..c7bd2260 100644 --- a/packages/schema/dist/pluginCategories.js +++ b/packages/schema/dist/pluginCategories.js @@ -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({}); diff --git a/packages/schema/dist/pluginCategories.js.map b/packages/schema/dist/pluginCategories.js.map index b6bf0688..326a6b4f 100644 --- a/packages/schema/dist/pluginCategories.js.map +++ b/packages/schema/dist/pluginCategories.js.map @@ -1 +1 @@ -{"version":3,"file":"pluginCategories.js","sourceRoot":"","sources":["../src/pluginCategories.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"pluginCategories.js","sourceRoot":"","sources":["../src/pluginCategories.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAA2B,MAAM,sBAAsB,CAAC;AAExF,OAAO,EACL,oBAAoB,EACpB,2BAA2B,EAC3B,qBAAqB,GAEtB,MAAM,sBAAsB,CAAC;AAI9B,SAAS,QAAQ,CAAC,KAAc;IAC9B,OAAO,OAAO,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC;AAC9E,CAAC;AAED,SAAS,SAAS,CAAC,KAAc;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;AAClD,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,iCAAiC,CAAC,QAAiB;IACjE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,OAAO,EAAE,CAAC;IAEnC,MAAM,UAAU,GAAyB,EAAE,CAAC;IAC5C,MAAM,GAAG,GAAG,CAAC,QAA4B,EAAE,EAAE;QAC3C,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAAE,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChE,CAAC,CAAC;IACF,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC7E,MAAM,SAAS,GAAG,QAAQ,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;IAEzE,IAAI,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,GAAG,CAAC,UAAU,CAAC,CAAC;IAClD,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,SAAS,CAAC,QAAQ,CAAC,WAAW,CAAC;QAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IACpF,IAAI,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,SAAS,CAAC,SAAS,CAAC,kBAAkB,CAAC;QAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IACvF,IAAI,KAAK,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC,EAAE,CAAC;QACtF,GAAG,CAAC,SAAS,CAAC,CAAC;IACjB,CAAC;IACD,IACE,SAAS,CAAC,SAAS,CAAC,eAAe,CAAC;QACpC,SAAS,CAAC,SAAS,CAAC,8BAA8B,CAAC;QACnD,SAAS,CAAC,SAAS,CAAC,sBAAsB,CAAC;QAC3C,SAAS,CAAC,SAAS,CAAC,yBAAyB,CAAC,EAC9C,CAAC;QACD,GAAG,CAAC,OAAO,CAAC,CAAC;IACf,CAAC;IACD,IACE,SAAS,CAAC,SAAS,CAAC,2BAA2B,CAAC;QAChD,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC;QAC7C,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC;QAC7C,SAAS,CAAC,SAAS,CAAC,wBAAwB,CAAC,EAC7C,CAAC;QACD,GAAG,CAAC,OAAO,CAAC,CAAC;IACf,CAAC;IACD,IAAI,SAAS,CAAC,SAAS,CAAC,iBAAiB,CAAC,IAAI,SAAS,CAAC,SAAS,CAAC,kBAAkB,CAAC,EAAE,CAAC;QACtF,GAAG,CAAC,KAAK,CAAC,CAAC;IACb,CAAC;IACD,IAAI,SAAS,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,SAAS,CAAC,QAAQ,CAAC,cAAc,CAAC;QAAE,GAAG,CAAC,OAAO,CAAC,CAAC;IACnF,IACE,SAAS,CAAC,SAAS,CAAC,0BAA0B,CAAC;QAC/C,SAAS,CAAC,SAAS,CAAC,yBAAyB,CAAC,EAC9C,CAAC;QACD,GAAG,CAAC,SAAS,CAAC,CAAC;IACjB,CAAC;IACD,IAAI,SAAS,CAAC,SAAS,CAAC,qBAAqB,CAAC;QAAE,GAAG,CAAC,SAAS,CAAC,CAAC;IAC/D,IACE,SAAS,CAAC,SAAS,CAAC,qBAAqB,CAAC;QAC1C,aAAa,CAAC,QAAQ,CAAC,0BAA0B,CAAC,EAClD,CAAC;QACD,GAAG,CAAC,UAAU,CAAC,CAAC;IAClB,CAAC;IAED,OAAO,UAAU,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,KAWxC;IACC,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;QAAE,OAAO,EAAE,CAAC;IACxC,OAAO,uBAAuB,CAAC;QAC7B,QAAQ,EAAE,KAAK,CAAC,UAAU;QAC1B,QAAQ,EAAE,KAAK,CAAC,kBAAkB,IAAI,iCAAiC,CAAC,KAAK,CAAC,cAAc,CAAC;KAC9F,CAAC,CAAC;AACL,CAAC;AAED,MAAM,UAAU,6BAA6B,CAC3C,KAAqD;IAErD,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;QAAE,OAAO,EAAE,CAAC;IACxC,IAAI,CAAC;QACH,MAAM,gBAAgB,GACpB,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,eAAe,KAAK,KAAK,CAAC,qBAAqB,CAAC;QAC1F,OAAO,uBAAuB,CAAC;YAC7B,QAAQ,EAAE,KAAK,CAAC,UAAU;YAC1B,QAAQ,EAAE,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS;SAClE,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,uBAAuB,CAAC,EAAE,CAAC,CAAC;IACrC,CAAC;AACH,CAAC"} \ No newline at end of file diff --git a/packages/schema/src/catalogMetadata.test.ts b/packages/schema/src/catalogMetadata.test.ts index d012399d..cb9e6365 100644 --- a/packages/schema/src/catalogMetadata.test.ts +++ b/packages/schema/src/catalogMetadata.test.ts @@ -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([ diff --git a/packages/schema/src/catalogMetadata.ts b/packages/schema/src/catalogMetadata.ts index cd016d60..aa4b4db2 100644 --- a/packages/schema/src/catalogMetadata.ts +++ b/packages/schema/src/catalogMetadata.ts @@ -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(); @@ -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, + }); } diff --git a/packages/schema/src/pluginCategories.test.ts b/packages/schema/src/pluginCategories.test.ts index 956fb403..d92c4240 100644 --- a/packages/schema/src/pluginCategories.test.ts +++ b/packages/schema/src/pluginCategories.test.ts @@ -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); diff --git a/packages/schema/src/pluginCategories.ts b/packages/schema/src/pluginCategories.ts index 60a5055d..d4f709dc 100644 --- a/packages/schema/src/pluginCategories.ts +++ b/packages/schema/src/pluginCategories.ts @@ -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({}); } diff --git a/specs/catalog-taxonomy.md b/specs/catalog-taxonomy.md index 5d492597..dee3ebd6 100644 --- a/specs/catalog-taxonomy.md +++ b/specs/catalog-taxonomy.md @@ -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 +``` diff --git a/specs/deploy.md b/specs/deploy.md index 7ff0b816..546fa32e 100644 --- a/specs/deploy.md +++ b/specs/deploy.md @@ -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