Skip to content

Commit ccf073d

Browse files
committed
feat(leaderboard): implement composite scoring with precomputed metrics
Implement composite leaderboard scoring combining Bayesian rating, trending score, and engagement metrics. Add precomputed columns (composite_score, bayesian_rating, trending_score, favorite_count) to skills table with indexing for performance. Refactor search algorithms to support query aliases and improved boosting. Add signal badge component and leaderboard utility functions. Update migration and seed data.
1 parent 9c1b837 commit ccf073d

45 files changed

Lines changed: 4372 additions & 209 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Code Reviewer Memory - SkillX Project
2+
3+
## Project Context
4+
- SkillX.sh: AI agent skills marketplace (React Router v7 + Cloudflare Workers + D1/SQLite)
5+
- Monorepo: `apps/web` (main app), `packages/cli`
6+
- DB: Drizzle ORM with `mode: "timestamp_ms"` (stores integers, returns Date objects)
7+
- Cache: KV with `getCached()` pattern (no explicit invalidation helper exists)
8+
9+
## Key Patterns Discovered
10+
- **Date in raw SQL:** Drizzle `timestamp_ms` columns store integer ms. Typed queries handle conversion, but raw `sql` template literals do NOT auto-convert `Date` to ms. Always use `.getTime()` in raw SQL.
11+
- **KV cache key collisions:** Multiple loaders can share cache key prefixes. If they select different column sets, the first writer poisons the cache for others. Always ensure same-key = same-shape.
12+
- **Pre-existing TS errors:** ~20+ TS errors exist in search, embed, auth, and other routes. These are NOT from new changes. Don't flag them as regressions.
13+
- **No cache invalidation utility:** `kv-cache.ts` only has `getCached()`. There's no `invalidate()` or `deleteCached()`. Cache relies purely on TTL expiry.
14+
15+
## Recurring Patterns to Watch
16+
- Synchronous DB-heavy operations in request path (should use `waitUntil()` on Workers)
17+
- Code duplication across SSR loader and API route for same data
18+
- `getOrderColumn` switch-case duplicated between page route and API route
19+
- Badge computation logic duplicated between API and page loaders
20+
21+
## Architecture Notes
22+
- Leaderboard uses precomputed score columns updated on write events
23+
- Search uses real-time boost scoring (different weights than leaderboard)
24+
- Two weight systems: search boost (7 signals incl. RRF) vs leaderboard composite (6 signals)
25+
- Scoring utils shared: `logNormalize`, `recencyScore` in `~/lib/scoring-utils.ts`
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Code Reviewer Memory - SkillX Project
2+
3+
## Project Context
4+
- SkillX.sh: AI agent skills marketplace (React Router v7 + Vite + SSR on Cloudflare Workers)
5+
- DB: Cloudflare D1 (SQLite) + Drizzle ORM
6+
- Search: FTS5 + Vectorize (768-dim) + RRF fusion + 7-signal boost scoring
7+
- Max 200 LOC per file rule enforced
8+
9+
## Key Patterns Observed
10+
11+
### Search Architecture
12+
- FTS5 virtual table: `skills_fts(name, description, content)` with BM25 weights (10/5/1)
13+
- Vectorize: `bge-base-en-v1.5`, 768-dim, cosine similarity
14+
- RRF fusion constant k=60 (standard)
15+
- Boost: 50% RRF, 15% rating, 10% stars, 8% usage, 7% success, 5% recency, 5% favorite
16+
- Pre-filtering pushed to retrieval (FTS5 WHERE + Vectorize metadata filter)
17+
- Fallback chain: hybrid -> FTS5-only -> empty
18+
19+
### Recurring Issues
20+
- FTS5 fallback logic duplicated 4x across 3 files (DRY violation)
21+
- `api.search.ts` has implicit `any[]` TS errors on `results` variable
22+
- Drizzle timestamp columns use `mode: "timestamp_ms"` but some routes pass raw numbers
23+
- Pre-existing TS errors in non-search routes (api.skill-rate, api.skill-review, etc.)
24+
25+
### SQL Injection Safety
26+
- FTS5 sanitization: `query.replace(/[^\w\s]/g, '')` strips specials before MATCH
27+
- All Drizzle queries use parameterized API
28+
- Raw SQL uses `sql` tagged template literals
29+
30+
### Edge Cases to Watch
31+
- Multi-word aliases (cli, gcp, devops) expand to individual OR terms, not phrases
32+
- `Math.max(...largeArray)` can stack overflow but currently bounded by limit*2
33+
- Vectorize metadata filter types must match index schema (is_paid as int vs bool)
34+
35+
## File Locations
36+
- Search modules: `apps/web/app/lib/search/`
37+
- DB schema: `apps/web/app/lib/db/schema.ts`
38+
- FTS5 migration: `apps/web/drizzle/migrations/0001_fts5-fulltext-search.sql`
39+
- API search route: `apps/web/app/routes/api.search.ts`
40+
- Page search route: `apps/web/app/routes/search.tsx`
41+
42+
## Typecheck Command
43+
- `pnpm typecheck` from `apps/web/` directory
44+
- 25+ pre-existing TS errors in non-search routes (timestamp/type issues)

apps/web/app/components/filter-tabs.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ interface FilterTabsProps {
44
}
55

66
const TABS = [
7-
{ id: "all", label: "All Time" },
7+
{ id: "best", label: "Best" },
8+
{ id: "rating", label: "Top Rated" },
9+
{ id: "installs", label: "Most Installed" },
810
{ id: "trending", label: "Trending" },
9-
{ id: "top", label: "Top Rated" },
10-
{ id: "new", label: "New" },
11+
{ id: "newest", label: "Newest" },
1112
];
1213

1314
export function FilterTabs({ activeTab, onTabChange }: FilterTabsProps) {

apps/web/app/components/home-leaderboard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function HomeLeaderboard({
3737

3838
try {
3939
const res = await fetch(
40-
`/api/leaderboard?sort=installs&offset=${entriesLengthRef.current}&limit=${PAGE_SIZE}`
40+
`/api/leaderboard?sort=best&offset=${entriesLengthRef.current}&limit=${PAGE_SIZE}`
4141
);
4242
const data = (await res.json()) as {
4343
entries: LeaderboardEntry[];

apps/web/app/components/leaderboard-table.tsx

Lines changed: 26 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,42 @@
1-
import { ArrowUpDown, ExternalLink } from "lucide-react";
1+
import { ExternalLink } from "lucide-react";
22
import { Link } from "react-router";
33
import { RatingBadge } from "./rating-badge";
4+
import { SignalBadge } from "./signal-badge";
45

5-
interface LeaderboardEntry {
6+
export interface LeaderboardEntry {
67
rank: number;
78
slug: string;
89
name: string;
910
author: string;
1011
installs: number;
1112
rating: number;
13+
badges?: string[];
1214
}
1315

1416
interface LeaderboardTableProps {
1517
entries: LeaderboardEntry[];
16-
onSort?: (column: string) => void;
1718
}
1819

19-
export function LeaderboardTable({ entries, onSort }: LeaderboardTableProps) {
20+
export function LeaderboardTable({ entries }: LeaderboardTableProps) {
2021
return (
2122
<div className="overflow-x-auto rounded-lg border border-sx-border">
2223
<table className="w-full">
2324
<thead className="sticky top-0 bg-sx-bg-elevated">
2425
<tr className="border-b border-sx-border">
25-
<th className="px-4 py-3 text-left">
26-
<button
27-
onClick={() => onSort?.("rank")}
28-
className="flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-sx-fg-muted hover:text-sx-fg"
29-
>
30-
Rank
31-
<ArrowUpDown size={12} />
32-
</button>
26+
<th className="px-4 py-3 text-left font-mono text-xs uppercase tracking-wide text-sx-fg-muted">
27+
Rank
3328
</th>
34-
<th className="px-4 py-3 text-left">
35-
<button
36-
onClick={() => onSort?.("skill")}
37-
className="flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-sx-fg-muted hover:text-sx-fg"
38-
>
39-
Skill
40-
<ArrowUpDown size={12} />
41-
</button>
29+
<th className="px-4 py-3 text-left font-mono text-xs uppercase tracking-wide text-sx-fg-muted">
30+
Skill
4231
</th>
43-
<th className="px-4 py-3 text-left">
44-
<button
45-
onClick={() => onSort?.("author")}
46-
className="flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-sx-fg-muted hover:text-sx-fg"
47-
>
48-
Author
49-
<ArrowUpDown size={12} />
50-
</button>
32+
<th className="px-4 py-3 text-left font-mono text-xs uppercase tracking-wide text-sx-fg-muted">
33+
Author
5134
</th>
52-
<th className="px-4 py-3 text-left">
53-
<button
54-
onClick={() => onSort?.("installs")}
55-
className="flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-sx-fg-muted hover:text-sx-fg"
56-
>
57-
Installs
58-
<ArrowUpDown size={12} />
59-
</button>
35+
<th className="px-4 py-3 text-left font-mono text-xs uppercase tracking-wide text-sx-fg-muted">
36+
Installs
6037
</th>
61-
<th className="px-4 py-3 text-left">
62-
<button
63-
onClick={() => onSort?.("rating")}
64-
className="flex items-center gap-1 font-mono text-xs uppercase tracking-wide text-sx-fg-muted hover:text-sx-fg"
65-
>
66-
Rating
67-
<ArrowUpDown size={12} />
68-
</button>
38+
<th className="px-4 py-3 text-left font-mono text-xs uppercase tracking-wide text-sx-fg-muted">
39+
Rating
6940
</th>
7041
<th className="px-4 py-3 text-right font-mono text-xs uppercase tracking-wide text-sx-fg-muted">
7142
Actions
@@ -96,12 +67,17 @@ export function LeaderboardTable({ entries, onSort }: LeaderboardTableProps) {
9667
</span>
9768
</td>
9869
<td className="px-4 py-3">
99-
<Link
100-
to={`/skills/${entry.slug}`}
101-
className="font-medium text-sx-fg hover:text-sx-accent"
102-
>
103-
{entry.name}
104-
</Link>
70+
<div className="flex flex-wrap items-center">
71+
<Link
72+
to={`/skills/${entry.slug}`}
73+
className="font-medium text-sx-fg hover:text-sx-accent"
74+
>
75+
{entry.name}
76+
</Link>
77+
{entry.badges?.map((b) => (
78+
<SignalBadge key={b} type={b} />
79+
))}
80+
</div>
10581
</td>
10682
<td className="px-4 py-3 text-sm text-sx-fg-muted">
10783
{entry.author}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/** Small pill badge showing quality signals on leaderboard entries */
2+
3+
const BADGE_CONFIG: Record<string, { label: string; className: string }> = {
4+
"top-rated": { label: "Top Rated", className: "bg-yellow-500/10 text-yellow-400" },
5+
"popular": { label: "Popular", className: "bg-sx-accent-muted text-sx-accent" },
6+
"trending": { label: "Trending", className: "bg-orange-500/10 text-orange-400" },
7+
"well-maintained": { label: "Maintained", className: "bg-blue-500/10 text-blue-400" },
8+
"community-pick": { label: "Community Pick", className: "bg-pink-500/10 text-pink-400" },
9+
};
10+
11+
interface SignalBadgeProps {
12+
type: string;
13+
}
14+
15+
export function SignalBadge({ type }: SignalBadgeProps) {
16+
const config = BADGE_CONFIG[type];
17+
if (!config) return null;
18+
19+
return (
20+
<span
21+
className={`ml-1.5 inline-flex rounded-full px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide ${config.className}`}
22+
>
23+
{config.label}
24+
</span>
25+
);
26+
}

apps/web/app/lib/db/schema.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,20 @@ export const skills = sqliteTable(
2020
rating_count: integer("rating_count").default(0),
2121
github_stars: integer("github_stars").default(0),
2222
install_count: integer("install_count").default(0),
23+
// Precomputed leaderboard scores (updated on write events)
24+
composite_score: real("composite_score").default(0),
25+
bayesian_rating: real("bayesian_rating").default(0),
26+
trending_score: real("trending_score").default(0),
27+
favorite_count: integer("favorite_count").default(0),
2328
created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),
2429
updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
2530
},
2631
(table) => [
2732
index("idx_skills_category").on(table.category),
2833
index("idx_skills_author").on(table.author),
2934
index("idx_skills_avg_rating").on(table.avg_rating),
35+
index("idx_skills_composite_score").on(table.composite_score),
36+
index("idx_skills_trending_score").on(table.trending_score),
3037
]
3138
);
3239

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Pure scoring functions for leaderboard composite ranking.
3+
* Bayesian average, composite score (6 weighted signals), trending velocity.
4+
*/
5+
6+
import { logNormalize, recencyScore } from "~/lib/scoring-utils";
7+
8+
/** Leaderboard composite weights — must sum to 1.0 */
9+
const WEIGHTS = {
10+
bayesianRating: 0.35,
11+
installs: 0.25,
12+
stars: 0.15,
13+
success: 0.10,
14+
recency: 0.10,
15+
favorites: 0.05,
16+
} as const;
17+
18+
/** Default confidence threshold for Bayesian average */
19+
const DEFAULT_CONFIDENCE = 10;
20+
21+
/**
22+
* Bayesian average rating — pulls low-sample skills toward global mean.
23+
* Formula: (C * m + avg * n) / (C + n)
24+
*/
25+
export function computeBayesianRating(
26+
avgRating: number,
27+
ratingCount: number,
28+
globalAvgRating: number,
29+
confidence = DEFAULT_CONFIDENCE,
30+
): number {
31+
return (confidence * globalAvgRating + avgRating * ratingCount) /
32+
(confidence + ratingCount);
33+
}
34+
35+
export interface CompositeInputs {
36+
bayesianRating: number;
37+
installCount: number;
38+
githubStars: number;
39+
successRate: number;
40+
updatedAt: Date | null;
41+
favoriteCount: number;
42+
maxInstalls: number;
43+
maxStars: number;
44+
maxFavorites: number;
45+
}
46+
47+
/**
48+
* Composite leaderboard score combining 6 normalized signals.
49+
* Returns value in 0-1 range.
50+
*/
51+
export function computeCompositeScore(inputs: CompositeInputs): number {
52+
const normalizedRating = inputs.bayesianRating / 10; // scale is 0-10
53+
const normalizedInstalls = logNormalize(inputs.installCount, inputs.maxInstalls);
54+
const normalizedStars = logNormalize(inputs.githubStars, inputs.maxStars);
55+
const normalizedFavorites = logNormalize(inputs.favoriteCount, inputs.maxFavorites);
56+
const normalizedRecency = recencyScore(inputs.updatedAt);
57+
58+
return (
59+
normalizedRating * WEIGHTS.bayesianRating +
60+
normalizedInstalls * WEIGHTS.installs +
61+
normalizedStars * WEIGHTS.stars +
62+
inputs.successRate * WEIGHTS.success +
63+
normalizedRecency * WEIGHTS.recency +
64+
normalizedFavorites * WEIGHTS.favorites
65+
);
66+
}
67+
68+
/**
69+
* Trending score based on 7-day activity velocity.
70+
* Ratings weighted 2x over usage events.
71+
*/
72+
export function computeTrendingScore(
73+
recentRatings7d: number,
74+
recentUsage7d: number,
75+
maxTrendingRaw: number,
76+
): number {
77+
const raw = recentRatings7d * 2 + recentUsage7d;
78+
return logNormalize(raw, maxTrendingRaw);
79+
}

0 commit comments

Comments
 (0)