sync: AI review scores, reviewed page, search improvements, score filter

- Surface AI review scores across skill pages, browse, and search
- Add dedicated /reviewed page with score filtering
- Add recommended sort using Meilisearch relevance + AI scores
- Fix search sort defaulting to stars instead of recommended
- Fix CLI TypeScript null check for aiScore in search command
- Adjust cache TTL for reviewed content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
airano
2026-03-14 05:05:37 +03:30
parent 4212b5ba47
commit 3e22d2e238
34 changed files with 929 additions and 194 deletions

View File

@@ -28,6 +28,8 @@ export interface MeiliSkillDocument {
securityStatus: 'pass' | 'warning' | 'fail' | null;
isFeatured: boolean;
isVerified: boolean;
reviewStatus: string | null;
aiScore: number;
indexedAt: string;
}
@@ -43,7 +45,7 @@ export interface MeiliSearchOptions {
verified?: boolean;
featured?: boolean;
};
sort?: 'stars' | 'downloads' | 'rating' | 'recent';
sort?: 'stars' | 'downloads' | 'rating' | 'recent' | 'aiScore';
limit?: number;
offset?: number;
}
@@ -154,6 +156,8 @@ export async function initializeSkillsIndex(): Promise<void> {
'isFeatured',
'securityScore',
'githubStars',
'reviewStatus',
'aiScore',
]);
// Configure sortable attributes
@@ -162,6 +166,7 @@ export async function initializeSkillsIndex(): Promise<void> {
'downloadCount',
'rating',
'indexedAt',
'aiScore',
]);
// Configure ranking rules (relevance + custom)
@@ -279,6 +284,9 @@ export async function searchSkills(options: MeiliSearchOptions): Promise<MeiliSe
case 'recent':
sort.push('indexedAt:desc');
break;
case 'aiScore':
sort.push('aiScore:desc');
break;
}
const searchResult: SearchResponse<MeiliSkillDocument> = await index.search(options.query, {

View File

@@ -68,7 +68,7 @@ export const skillQueries = {
verified?: boolean;
limit?: number;
offset?: number;
sortBy?: 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded';
sortBy?: 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded' | 'aiScore' | 'recommended';
sortOrder?: 'asc' | 'desc';
}
) => {
@@ -167,6 +167,8 @@ export const skillQueries = {
rating: skills.rating,
updated: skills.updatedAt,
lastDownloaded: skills.lastDownloadedAt,
aiScore: skills.latestAiScore,
recommended: skills.downloadCount, // fallback column, actual ordering uses custom SQL
}[sortBy];
// Secondary sort: when primary values are tied/zero, fall back to another metric
@@ -176,14 +178,21 @@ export const skillQueries = {
rating: skills.githubStars,
updated: skills.githubStars,
lastDownloaded: skills.downloadCount,
aiScore: skills.downloadCount,
recommended: skills.githubStars,
}[sortBy];
const orderFn = sortOrder === 'asc' ? asc : desc;
// For lastDownloaded sort, use NULLS LAST so never-downloaded skills
// For lastDownloaded and aiScore sorts, use NULLS LAST so null-valued skills
// don't dominate the first pages (PostgreSQL puts NULLs first in DESC by default)
const primaryOrder = sortBy === 'lastDownloaded'
// 'recommended' sort: ai-reviewed skills with score >= 75 first (by score desc), then rest by downloads
const primaryOrder = sortBy === 'recommended'
? sql`CASE WHEN ${skills.reviewStatus} IN ('ai-reviewed', 'verified') AND ${skills.latestAiScore} >= 75 THEN 0 ELSE 1 END ASC, CASE WHEN ${skills.reviewStatus} IN ('ai-reviewed', 'verified') AND ${skills.latestAiScore} >= 75 THEN ${skills.latestAiScore} ELSE 0 END DESC, ${skills.downloadCount} DESC`
: sortBy === 'lastDownloaded'
? sql`${skills.lastDownloadedAt} DESC NULLS LAST`
: sortBy === 'aiScore'
? sql`${skills.latestAiScore} DESC NULLS LAST`
: orderFn(orderByColumn);
// If filtering by category, use JOIN with skillCategories
@@ -458,6 +467,49 @@ export const skillQueries = {
return result[0]?.count ?? 0;
},
/**
* Get AI-reviewed skills with pagination
*/
getReviewedSkills: async (db: DB, sortBy: 'reviewDate' | 'aiScore' = 'reviewDate', limit = 12, offset = 0, minScore = 50) => {
const order = sortBy === 'aiScore'
? sql`${skills.latestAiScore} DESC NULLS LAST, ${skills.latestReviewDate} DESC NULLS LAST`
: sql`${skills.latestReviewDate} DESC NULLS LAST, ${skills.latestAiScore} DESC NULLS LAST`;
const conditions = [
eq(skills.isBlocked, false),
eq(skills.sourceFormat, 'skill.md'),
browseReadyFilter,
inArray(skills.reviewStatus, ['ai-reviewed', 'verified']),
];
if (minScore > 0) {
conditions.push(sql`${skills.latestAiScore} >= ${minScore}`);
}
return db.select().from(skills)
.where(and(...conditions))
.orderBy(order)
.limit(limit)
.offset(offset);
},
/**
* Count AI-reviewed skills
*/
countReviewedSkills: async (db: DB, minScore = 50) => {
const conditions = [
eq(skills.isBlocked, false),
eq(skills.sourceFormat, 'skill.md'),
browseReadyFilter,
inArray(skills.reviewStatus, ['ai-reviewed', 'verified']),
];
if (minScore > 0) {
conditions.push(sql`${skills.latestAiScore} >= ${minScore}`);
}
const result = await db
.select({ count: sql<number>`cast(count(*) as int)` })
.from(skills)
.where(and(...conditions));
return result[0]?.count ?? 0;
},
/**
* Get all skills for sitemap generation (lightweight: id, updatedAt, githubOwner only)
*/
@@ -530,12 +582,14 @@ export const skillQueries = {
.orderBy(
desc(
sql`(
-- Quality Score (0-60 points)
-- Quality Score (0-80 points: base 60 + review bonus 20)
(
CASE WHEN LENGTH(COALESCE(${skills.description}, '')) > 200 THEN 30
ELSE LENGTH(COALESCE(${skills.description}, '')) / 10 END +
CASE WHEN ${skills.securityStatus} = 'pass' THEN 20 ELSE 0 END +
CASE WHEN ${skills.rawContent} IS NOT NULL THEN 10 ELSE 0 END
CASE WHEN ${skills.rawContent} IS NOT NULL THEN 10 ELSE 0 END +
CASE WHEN ${skills.latestAiScore} IS NOT NULL AND ${skills.reviewStatus} IN ('ai-reviewed', 'verified')
THEN LEAST(${skills.latestAiScore} / 5, 20) ELSE 0 END
) * ${wQuality} +
-- Freshness Score (0-50 points)
@@ -2782,11 +2836,17 @@ export const skillReviewQueries = {
updateSkillReviewStatus: async (
db: DB,
skillId: string,
reviewStatus: string
reviewStatus: string,
aiScore?: number,
reviewDate?: Date
) => {
return db
.update(skills)
.set({ reviewStatus })
.set({
reviewStatus,
...(aiScore !== undefined ? { latestAiScore: aiScore } : {}),
...(reviewDate ? { latestReviewDate: reviewDate } : {}),
})
.where(eq(skills.id, skillId));
},
};

View File

@@ -86,6 +86,8 @@ export const skills = pgTable(
canonicalSkillId: text('canonical_skill_id'), // points to the "original" if this is a duplicate
repoSkillCount: integer('repo_skill_count'), // cached count of skills in same repo
reviewStatus: text('review_status').default('unreviewed'), // unreviewed → auto-scored → ai-reviewed → verified (or needs-re-review)
latestAiScore: integer('latest_ai_score'), // Denormalized from skill_reviews for efficient sorting
latestReviewDate: timestamp('latest_review_date', { withTimezone: true }), // When last AI-reviewed
// Content (cached)
contentHash: text('content_hash'),
@@ -129,6 +131,7 @@ export const skills = pgTable(
duplicateIdx: index('idx_skills_duplicate').on(table.isDuplicate),
contentHashIdx: index('idx_skills_content_hash').on(table.contentHash),
staleIdx: index('idx_skills_stale').on(table.isStale),
aiScoreIdx: index('idx_skills_ai_score').on(table.latestAiScore),
})
);