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

@@ -144,7 +144,7 @@ export async function POST(request: NextRequest) {
const newStatus = r.set_verified ? 'verified' : 'ai-reviewed';
if (r.set_verified) verifiedCount++;
await skillReviewQueries.updateSkillReviewStatus(db, r.skill_id, newStatus);
await skillReviewQueries.updateSkillReviewStatus(db, r.skill_id, newStatus, r.ai_score, new Date());
}
return NextResponse.json(

View File

@@ -1,5 +1,5 @@
import { NextResponse, type NextRequest } from 'next/server';
import { createDb, skillQueries } from '@skillhub/db';
import { createDb, skillQueries, skillReviewQueries } from '@skillhub/db';
import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache';
// Create database connection
@@ -35,12 +35,19 @@ export async function GET(
const { id } = await params;
const skillId = id.join('/');
// Get skill from database (cached 1h)
const skill = await getOrSetCache(
cacheKeys.skill(skillId),
cacheTTL.skill,
() => skillQueries.getById(db, skillId)
);
// Get skill and latest review from database (cached 1h)
const [skill, review] = await Promise.all([
getOrSetCache(
cacheKeys.skill(skillId),
cacheTTL.skill,
() => skillQueries.getById(db, skillId)
),
getOrSetCache(
cacheKeys.skillReview(skillId),
cacheTTL.skill,
() => skillReviewQueries.getLatestBySkillId(db, skillId)
),
]);
if (!skill) {
return NextResponse.json(
@@ -83,9 +90,21 @@ export async function GET(
triggers: skill.triggers,
rawContent: skill.rawContent,
sourceFormat: skill.sourceFormat || 'skill.md',
reviewStatus: skill.reviewStatus,
aiScore: skill.latestAiScore,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
indexedAt: skill.indexedAt,
review: review ? {
aiScore: review.aiScore,
instructionQuality: review.instructionQuality,
descriptionPrecision: review.descriptionPrecision,
usefulness: review.usefulness,
technicalSoundness: review.technicalSoundness,
reviewNotes: review.reviewNotes,
reviewer: review.reviewer,
reviewedAt: review.reviewedAt,
} : null,
});
} catch (error) {
console.error('Error fetching skill:', error);

View File

@@ -67,6 +67,8 @@ export async function GET(request: NextRequest) {
securityStatus: skill.securityStatus,
isVerified: skill.isVerified,
compatibility: skill.compatibility,
reviewStatus: skill.reviewStatus,
aiScore: skill.latestAiScore,
})),
};

View File

@@ -97,8 +97,14 @@ export async function GET(request: NextRequest) {
const useMeilisearch = await isMeilisearchHealthy();
if (useMeilisearch) {
// Use Meilisearch for full-text search with relevance ranking
// Note: category filter not supported in Meilisearch, handled in PostgreSQL fallback
// For 'recommended' sort, fetch more results from Meilisearch (by relevance),
// then post-process to boost ai-reviewed skills to the top
const isRecommended = sort === 'recommended';
const meiliSort = isRecommended ? undefined : sort as 'stars' | 'downloads' | 'rating' | 'recent';
// Fetch extra results for recommended so we have enough after re-ranking
const meiliLimit = isRecommended ? Math.min(limit * 3, 60) : limit;
const meiliOffset = isRecommended ? 0 : offset;
const meiliResult = await meilisearchSearch({
query,
filters: {
@@ -106,13 +112,13 @@ export async function GET(request: NextRequest) {
minStars: minStars > 0 ? minStars : undefined,
verified: verified ? true : undefined,
},
sort: sort as 'stars' | 'downloads' | 'rating' | 'recent',
limit,
offset,
sort: meiliSort,
limit: meiliLimit,
offset: meiliOffset,
});
if (meiliResult) {
const skills = meiliResult.hits.map((hit) => ({
let skills = meiliResult.hits.map((hit) => ({
id: restoreIdFromMeili(hit.id),
name: hit.name,
description: hit.description,
@@ -121,13 +127,31 @@ export async function GET(request: NextRequest) {
githubStars: hit.githubStars,
downloadCount: hit.downloadCount,
securityScore: hit.securityScore,
securityStatus: null, // Not available in Meilisearch yet
securityStatus: hit.securityStatus || null,
rating: hit.rating,
ratingCount: null, // Not available in Meilisearch yet
isVerified: hit.isVerified,
compatibility: { platforms: hit.platforms },
reviewStatus: hit.reviewStatus || null,
aiScore: hit.aiScore || null,
}));
// For recommended sort: boost ai-reviewed skills with score >= 75 to top,
// preserving Meilisearch relevance order within each group
if (isRecommended) {
const reviewed = skills.filter(s =>
s.aiScore && s.aiScore >= 75 &&
(s.reviewStatus === 'ai-reviewed' || s.reviewStatus === 'verified')
);
const rest = skills.filter(s =>
!(s.aiScore && s.aiScore >= 75 &&
(s.reviewStatus === 'ai-reviewed' || s.reviewStatus === 'verified'))
);
// Sort reviewed by score desc (relevance already handled by position)
reviewed.sort((a, b) => (b.aiScore || 0) - (a.aiScore || 0));
skills = [...reviewed, ...rest].slice(offset, offset + limit);
}
// Cache the result (5 minutes TTL)
await setCache(
cacheKey,
@@ -159,12 +183,14 @@ export async function GET(request: NextRequest) {
// Fall back to PostgreSQL search
// Map sort parameter to database column
const sortByMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded'> = {
const sortByMap: Record<string, 'stars' | 'downloads' | 'rating' | 'updated' | 'lastDownloaded' | 'aiScore' | 'recommended'> = {
stars: 'stars',
downloads: 'downloads',
rating: 'rating',
recent: 'updated',
lastDownloaded: 'lastDownloaded',
aiScore: 'aiScore',
recommended: 'recommended',
security: 'stars', // Use stars as fallback
};
const sortBy = sortByMap[sort] || 'downloads';
@@ -209,6 +235,8 @@ export async function GET(request: NextRequest) {
isVerified: skill.isVerified,
compatibility: skill.compatibility,
updatedAt: skill.updatedAt,
reviewStatus: skill.reviewStatus,
aiScore: skill.latestAiScore,
}));
// Cache the result (5 minutes TTL)