Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type * as httpApiV1_packagePublishRecoveryV1 from "../httpApiV1/packagePu
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
import type * as httpApiV1_promotionsV1 from "../httpApiV1/promotionsV1.js";
import type * as httpApiV1_publishersV1 from "../httpApiV1/publishersV1.js";
import type * as httpApiV1_searchInsightsV1 from "../httpApiV1/searchInsightsV1.js";
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
import type * as httpApiV1_skillsShCatalogV1 from "../httpApiV1/skillsShCatalogV1.js";
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
Expand Down Expand Up @@ -132,6 +133,7 @@ import type * as lib_reservedHandles from "../lib/reservedHandles.js";
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
import type * as lib_retentionPolicy from "../lib/retentionPolicy.js";
import type * as lib_rolloutCapabilities from "../lib/rolloutCapabilities.js";
import type * as lib_searchInsights from "../lib/searchInsights.js";
import type * as lib_searchRanking from "../lib/searchRanking.js";
import type * as lib_searchText from "../lib/searchText.js";
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
Expand Down Expand Up @@ -195,6 +197,8 @@ import type * as rateLimits from "../rateLimits.js";
import type * as retention from "../retention.js";
import type * as rolloutCapabilities from "../rolloutCapabilities.js";
import type * as search from "../search.js";
import type * as searchInsights from "../searchInsights.js";
import type * as searchInsightsFixtures from "../searchInsightsFixtures.js";
import type * as searchTestFixtures from "../searchTestFixtures.js";
import type * as securityDataset from "../securityDataset.js";
import type * as securityDatasetNode from "../securityDatasetNode.js";
Expand Down Expand Up @@ -271,6 +275,7 @@ declare const fullApi: ApiFromModules<{
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
"httpApiV1/promotionsV1": typeof httpApiV1_promotionsV1;
"httpApiV1/publishersV1": typeof httpApiV1_publishersV1;
"httpApiV1/searchInsightsV1": typeof httpApiV1_searchInsightsV1;
"httpApiV1/shared": typeof httpApiV1_shared;
"httpApiV1/skillsShCatalogV1": typeof httpApiV1_skillsShCatalogV1;
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
Expand Down Expand Up @@ -359,6 +364,7 @@ declare const fullApi: ApiFromModules<{
"lib/reservedSlugs": typeof lib_reservedSlugs;
"lib/retentionPolicy": typeof lib_retentionPolicy;
"lib/rolloutCapabilities": typeof lib_rolloutCapabilities;
"lib/searchInsights": typeof lib_searchInsights;
"lib/searchRanking": typeof lib_searchRanking;
"lib/searchText": typeof lib_searchText;
"lib/securityPrompt": typeof lib_securityPrompt;
Expand Down Expand Up @@ -422,6 +428,8 @@ declare const fullApi: ApiFromModules<{
retention: typeof retention;
rolloutCapabilities: typeof rolloutCapabilities;
search: typeof search;
searchInsights: typeof searchInsights;
searchInsightsFixtures: typeof searchInsightsFixtures;
searchTestFixtures: typeof searchTestFixtures;
securityDataset: typeof securityDataset;
securityDatasetNode: typeof securityDatasetNode;
Expand Down
4 changes: 4 additions & 0 deletions convex/crons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ vi.mock("convex/server", () => ({

vi.mock("./_generated/api", () => ({
internal: {
searchInsights: {
aggregateInternal: Symbol("search-insights-aggregate"),
pruneExpiredInternal: Symbol("search-insights-retention"),
},
canonicalTrending: {
materializeInternal: mocks.canonicalTrendingMaterializeRef,
pruneExpiredActionInternal: mocks.canonicalTrendingPruneRef,
Expand Down
12 changes: 12 additions & 0 deletions convex/crons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ import { RETENTION_STANDARD_BATCH_SIZE } from "./lib/retentionPolicy";
const crons = cronJobs();

if (process.env.CLAWHUB_DISABLE_CRONS !== "1" && process.env.CLAWHUB_PREVIEW !== "1") {
crons.interval(
"search-insights-aggregate",
{ hours: 1 },
internal.searchInsights.aggregateInternal,
{},
);
crons.interval(
"search-insights-retention",
{ hours: 24 },
internal.searchInsights.pruneExpiredInternal,
{},
);
crons.interval(
"github-skill-source-sync",
{ minutes: 15 },
Expand Down
2 changes: 2 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
searchSkillsHttp,
} from "./httpApi";
import {
searchInsightsV1Http,
exportSkillsV1Http,
exportPluginsV1Http,
listBundlePluginsV1Http,
Expand Down Expand Up @@ -82,6 +83,7 @@ import { skillPresentationAssetHttp } from "./skillPresentationAssetsHttp";
const http = installRateLimitedRoutes(httpRouter());

auth.addHttpRoutes(http);
http.route({ path: "/api/v1/search-insights", method: "GET", handler: searchInsightsV1Http });

http.route({
pathPrefix: "/api/v1/skill-icons/",
Expand Down
3 changes: 3 additions & 0 deletions convex/httpApiV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
promotionsPostRouterV1Handler,
} from "./httpApiV1/promotionsV1";
import { createPublisherV1Handler } from "./httpApiV1/publishersV1";
import { searchInsightsV1Handler } from "./httpApiV1/searchInsightsV1";
import {
skillsShCatalogPublicV1Handler,
skillsShCatalogTestV1Handler,
Expand Down Expand Up @@ -61,6 +62,8 @@ import {
} from "./httpApiV1/usersV1";
import { whoamiV1Handler } from "./httpApiV1/whoamiV1";

export const searchInsightsV1Http = httpAction(searchInsightsV1Handler);

export const listPackagesV1Http = httpAction(listPackagesV1Handler);
export const listPluginsV1Http = httpAction(listPluginsV1Handler);
export const listPluginCategoriesV1Http = httpAction(listPluginCategoriesV1Handler);
Expand Down
2 changes: 1 addition & 1 deletion convex/httpApiV1/packagesV1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ function compareCatalogItemsForSort(
return compareCatalogItems(a, b);
}

function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
export function compareCatalogSearchEntries(a: CatalogSearchEntry, b: CatalogSearchEntry) {
return (
Number(
isCuratedSearchResult({
Expand Down
48 changes: 48 additions & 0 deletions convex/httpApiV1/searchInsightsV1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { internal } from "../_generated/api";
import type { ActionCtx } from "../_generated/server";
import type { SearchInsightArgs } from "../lib/searchInsights";
import { json, requireApiTokenUserOrResponse, requireModeratorOrResponse, text } from "./shared";

export async function searchInsightsV1Handler(ctx: ActionCtx, request: Request) {
const headers = { "Cache-Control": "private, no-store" };
const auth = await requireApiTokenUserOrResponse(ctx, request, headers);
if (!auth.ok) return auth.response;
const staff = requireModeratorOrResponse(auth.user, headers);
if (!staff.ok) return staff.response;
const params = new URL(request.url).searchParams;
const args: SearchInsightArgs = {};
const source = params.get("source");
if (source !== null) {
if (source !== "clawhub-web" && source !== "openclaw-control-ui")
return text("Invalid source", 400, headers);
args.source = source;
}
const intent = params.get("intentKind");
if (intent !== null) {
if (intent !== "company_product" && intent !== "generic_capability" && intent !== "ambiguous")
return text("Invalid intentKind", 400, headers);
args.intentKind = intent;
}
for (const key of ["endDay", "limit", "window"] as const) {
const value = params.get(key);
if (value === null) continue;
if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
return text(`Invalid ${key}`, 400, headers);
const number = Number(value);
if (key === "window") {
if (number !== 7 && number !== 30) return text("window must be 7 or 30", 400, headers);
args.window = number;
} else args[key] = number;
}
const officialGap = params.get("officialGap");
if (officialGap !== null) {
if (officialGap !== "true" && officialGap !== "false")
return text("Invalid officialGap", 400, headers);
args.officialGap = officialGap === "true";
}
if (args.limit !== undefined && (args.limit < 1 || args.limit > 100))
return text("limit must be between 1 and 100", 400, headers);
if (args.endDay !== undefined && args.endDay % 86_400_000 !== 0)
return text("endDay must be a UTC day boundary", 400, headers);
return json(await ctx.runAction(internal.searchInsights.getInternal, args), 200, headers);
}
27 changes: 27 additions & 0 deletions convex/lib/retentionPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,33 @@ const ephemeral = (
});

export const RETENTION_POLICIES = {
searchAggregateStates: permanent(
"One ingestion cursor and query-free coverage bounds; no identities.",
),
searchDailyAggregates: ephemeral("Daily anonymous search facts; no historical log backfill.", {
expirationField: "expirationTime",
expirationIndex: "by_expirationTime",
prune: "searchInsights.pruneExpiredInternal",
retention: "13 calendar months after the UTC day.",
}),
searchClassificationRuns: ephemeral(
"Query-free weekly classification completion/failure status.",
{
expirationField: "expirationTime",
expirationIndex: "by_expirationTime",
prune: "searchInsights.pruneExpiredInternal",
retention: "13 calendar months after the week.",
},
),
searchWeeklyClassifications: ephemeral(
"Advisory weekly intent only; never official provenance.",
{
expirationField: "expirationTime",
expirationIndex: "by_expirationTime",
prune: "searchInsights.pruneExpiredInternal",
retention: "13 calendar months after the week.",
},
),
users: permanent("Canonical user profiles and account state."),
authSessions: ephemeral("Convex Auth sessions expire after their total session duration.", {
expirationField: "expirationTime",
Expand Down
120 changes: 120 additions & 0 deletions convex/lib/searchInsights.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { v, type Infer } from "convex/values";

export const SEARCH_DAY_MS = 86_400_000;
export const SEARCH_INTENT_CONFIDENCE = 0.8;
export const searchInsightSource = v.union(
v.literal("clawhub-web"),
v.literal("openclaw-control-ui"),
);
export const searchIntentKind = v.union(
v.literal("company_product"),
v.literal("generic_capability"),
v.literal("ambiguous"),
);
export const searchInsightArgs = {
endDay: v.optional(v.number()),
includeCurrentResults: v.optional(v.boolean()),
order: v.optional(
v.union(v.literal("searches"), v.literal("change"), v.literal("official-gaps")),
),
source: v.optional(searchInsightSource),
window: v.optional(v.union(v.literal(7), v.literal(30))),
officialGap: v.optional(v.boolean()),
intentKind: v.optional(searchIntentKind),
limit: v.optional(v.number()),
};
export const searchClassification = v.object({
weekStart: v.number(),
weekEnd: v.number(),
query: v.string(),
intentKind: searchIntentKind,
companyProductName: v.optional(v.string()),
confidence: v.number(),
model: v.string(),
modelVersion: v.string(),
processedAt: v.number(),
});
export type SearchClassification = Infer<typeof searchClassification>;
export type SearchInsightSource = Infer<typeof searchInsightSource>;
export type SearchInsightArgs = Infer<ReturnType<typeof argsValidator>>;
function argsValidator() {
return v.object(searchInsightArgs);
}
export type SearchInsightRow = {
query: string;
searches7d: number;
searchesPrevious7d: number;
searches30d: number;
officialGaps7d: number;
officialGaps30d: number;
zeroResults7d: number;
change7d: number;
changePercent: number | null;
sources7d: Record<SearchInsightSource, number>;
classification: SearchClassification | null;
companyOpportunity: boolean;
currentResults: SearchCurrentResult[];
featuredCandidate: SearchCurrentResult | null;
searchUrl: string;
};
export type SearchInsightReport = {
window: {
endDay: number;
start7d: number;
startPrevious7d: number;
start30d: number;
days: 7 | 30;
};
source: SearchInsightSource | null;
generatedAt: number;
metadataCheckedAt: number | null;
currentMetadataStatus: "available" | "unavailable";
coverage: {
dataThrough: number | null;
collectionStartedAt: number | null;
gapStart: number | null;
gapEnd: number | null;
};
totalQueries: number;
totalSearches7d: number;
sources7d: Record<SearchInsightSource, number>;
truncated: boolean;
classificationStatus: "available" | "partial" | "unavailable";
classificationRun: {
weekStart: number;
weekEnd: number;
processedAt: number;
expectedQualified: number;
classifiedCount: number;
truncated: boolean;
model: string;
modelVersion: string;
failureCode?: string;
} | null;
rows: SearchInsightRow[];
};

// Calendar months, clamped at month-end (not a fixed 390-day approximation).
export function searchAggregateExpiration(day: number) {
const date = new Date(day);
const target = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 13, 1));
const lastDay = new Date(
Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0),
).getUTCDate();
target.setUTCDate(Math.min(date.getUTCDate(), lastDay));
return target.getTime();
}
export type SearchCurrentResult = {
name: string;
displayName: string;
summary: string | null;
version: string | null;
url: string;
isOfficial: boolean;
isFeatured: boolean;
eligibleForFeatured: boolean;
};
export type SearchCurrentResults = {
metadataCheckedAt: number;
rows: Array<{ query: string; results: SearchCurrentResult[] }>;
};
57 changes: 57 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
canonicalTrendingSourceRefValidator,
} from "./lib/canonicalTrending";
import { EMBEDDING_DIMENSIONS } from "./lib/embeddings";
import { searchClassification, searchInsightSource } from "./lib/searchInsights";

const PLATFORM_SKILL_LICENSE = "MIT-0" as const;

Expand Down Expand Up @@ -4484,7 +4485,63 @@ const skillOwnershipTransfers = defineTable({
.index("by_from_user_status", ["fromUserId", "status"])
.index("by_skill_status", ["skillId", "status"]);

const searchAggregateStates = defineTable({
key: v.literal("plugin"),
cursor: v.union(v.string(), v.null()),
processedThrough: v.number(),
revision: v.number(),
coverageStart: v.number(),
coverageGapStart: v.optional(v.number()),
coverageGapEnd: v.optional(v.number()),
}).index("by_key", ["key"]);
const searchDailyAggregates = defineTable({
dayStart: v.number(),
query: v.string(),
source: searchInsightSource,
artifactKind: v.literal("plugin"),
category: v.string(),
intent: v.string(),
searches: v.number(),
officialGaps: v.number(),
zeroResults: v.number(),
expirationTime: v.number(),
})
.index("by_dayStart_and_source_and_query_and_category_and_intent", [
"dayStart",
"source",
"query",
"category",
"intent",
])
.index("by_source_and_dayStart", ["source", "dayStart"])
.index("by_expirationTime", ["expirationTime"]);
const searchClassificationRuns = defineTable({
weekStart: v.number(),
weekEnd: v.number(),
processedAt: v.number(),
status: v.union(v.literal("available"), v.literal("unavailable")),
expectedQualified: v.number(),
classifiedCount: v.number(),
truncated: v.optional(v.boolean()),
model: v.string(),
modelVersion: v.string(),
failureCode: v.optional(v.string()),
expirationTime: v.number(),
})
.index("by_weekEnd", ["weekEnd"])
.index("by_expirationTime", ["expirationTime"]);
const searchWeeklyClassifications = defineTable(
searchClassification.extend({ expirationTime: v.number() }),
)
.index("by_query_and_weekEnd", ["query", "weekEnd"])
.index("by_weekEnd", ["weekEnd"])
.index("by_expirationTime", ["expirationTime"]);

export default defineSchema({
searchAggregateStates,
searchDailyAggregates,
searchWeeklyClassifications,
searchClassificationRuns,
...authTables,
authSessions,
authRefreshTokens,
Expand Down
Loading
Loading