From 3e22d2e238865f4d896751e4e0ca985ffc71f911 Mon Sep 17 00:00:00 2001 From: airano Date: Sat, 14 Mar 2026 05:05:37 +0330 Subject: [PATCH] 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 --- apps/cli/package.json | 2 +- apps/cli/src/commands/install.ts | 7 +- apps/cli/src/commands/search.ts | 17 +- apps/cli/src/index.ts | 2 +- apps/cli/src/utils/api.ts | 2 + apps/web/app/[locale]/browse/page.tsx | 21 +- apps/web/app/[locale]/docs/api/page.tsx | 20 +- apps/web/app/[locale]/docs/cli/page.tsx | 3 + .../[locale]/docs/getting-started/page.tsx | 5 +- apps/web/app/[locale]/page.tsx | 28 +- apps/web/app/[locale]/reviewed/page.tsx | 152 +++++++++ apps/web/app/[locale]/skill/[...id]/page.tsx | 312 +++++++++++------- apps/web/app/api/review/submit/route.ts | 2 +- apps/web/app/api/skills/[...id]/route.ts | 33 +- apps/web/app/api/skills/featured/route.ts | 2 + apps/web/app/api/skills/route.ts | 44 ++- apps/web/app/globals.css | 16 + apps/web/components/BrowseFilters.tsx | 19 +- apps/web/components/Footer.tsx | 1 + apps/web/components/HeroSearch.tsx | 2 +- apps/web/components/ReviewedSortSelector.tsx | 70 ++++ apps/web/components/SkillCard.tsx | 26 +- apps/web/lib/cache.ts | 3 + apps/web/messages/en.json | 39 ++- apps/web/messages/fa.json | 39 ++- docker-compose.yml | 6 - packages/core/src/index.ts | 4 + packages/core/src/review-notes-parser.ts | 141 ++++++++ packages/db/src/meilisearch.ts | 10 +- packages/db/src/queries.ts | 74 ++++- packages/db/src/schema.ts | 3 + scripts/init-db.sql | 5 + services/indexer/src/crawl.ts | 2 +- services/indexer/src/meilisearch-sync.ts | 11 + 34 files changed, 929 insertions(+), 194 deletions(-) create mode 100644 apps/web/app/[locale]/reviewed/page.tsx create mode 100644 apps/web/components/ReviewedSortSelector.tsx create mode 100644 packages/core/src/review-notes-parser.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index f7d307b..0d3463e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "skillhub", - "version": "0.2.5", + "version": "0.2.9", "description": "CLI tool for managing AI Agent skills - search, install, and update skills for Claude, Codex, Copilot, and more", "author": "SkillHub Contributors", "license": "MIT", diff --git a/apps/cli/src/commands/install.ts b/apps/cli/src/commands/install.ts index 24527a8..9f607c9 100644 --- a/apps/cli/src/commands/install.ts +++ b/apps/cli/src/commands/install.ts @@ -39,7 +39,12 @@ export async function install(skillId: string, options: InstallOptions): Promise try { skillInfo = await getSkill(skillId); if (skillInfo) { - spinner.succeed(`Found in registry: ${skillInfo.name}`); + const reviewBadge = skillInfo.aiScore && skillInfo.reviewStatus === 'ai-reviewed' + ? chalk.dim(` | AI: ${skillInfo.aiScore}/100`) + : skillInfo.reviewStatus === 'verified' + ? chalk.green(` | AI: ${skillInfo.aiScore}/100 ✓`) + : ''; + spinner.succeed(`Found in registry: ${skillInfo.name}${reviewBadge}`); spinner.start('Preparing installation...'); } } catch (error) { diff --git a/apps/cli/src/commands/search.ts b/apps/cli/src/commands/search.ts index 48c25b6..c46709e 100644 --- a/apps/cli/src/commands/search.ts +++ b/apps/cli/src/commands/search.ts @@ -18,7 +18,7 @@ export async function search(query: string, options: SearchOptions): Promise 0 && skill.reviewStatus && skill.reviewStatus !== 'unreviewed' && skill.reviewStatus !== 'auto-scored'; + const aiPrefix = hasAiScore + ? `${chalk.magenta('AI')} ${(aiScore >= 75 ? chalk.green : aiScore >= 50 ? chalk.yellow : chalk.dim)(String(aiScore))} ` + : ''; console.log( - ` ⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, 55))}${skill.description.length > 55 ? '...' : ''}` + ` ${aiPrefix}⬇ ${formatNumber(skill.downloadCount).padStart(6)} ⭐ ${formatNumber(skill.githubStars).padStart(6)} ${chalk.dim(skill.description.slice(0, hasAiScore ? 45 : 55))}${skill.description.length > (hasAiScore ? 45 : 55) ? '...' : ''}` ); // Third line: Rating (only if ratingCount >= 3) @@ -83,8 +88,8 @@ export async function search(query: string, options: SearchOptions): Promise') .description('Search for skills in the registry') .option('-p, --platform ', 'Filter by platform') - .option('-s, --sort ', 'Sort by: downloads, stars, rating, recent', 'downloads') + .option('-s, --sort ', 'Sort by: recommended, aiScore, downloads, stars, rating, recent', 'recommended') .option('-l, --limit ', 'Number of results', '10') .option('--page ', 'Page number', '1') .action(async (query: string, options) => { diff --git a/apps/cli/src/utils/api.ts b/apps/cli/src/utils/api.ts index cbb07f6..35b7767 100644 --- a/apps/cli/src/utils/api.ts +++ b/apps/cli/src/utils/api.ts @@ -126,6 +126,8 @@ export interface SkillInfo { sourceFormat?: string; rating?: number | null; ratingCount?: number | null; + reviewStatus?: string | null; + aiScore?: number | null; isVerified: boolean; compatibility: { platforms: string[]; diff --git a/apps/web/app/[locale]/browse/page.tsx b/apps/web/app/[locale]/browse/page.tsx index 3f8b6fb..b41f7ed 100644 --- a/apps/web/app/[locale]/browse/page.tsx +++ b/apps/web/app/[locale]/browse/page.tsx @@ -38,12 +38,16 @@ async function getSkills(params: { const limit = 20; const page = parseInt(params.page || '1'); + // Default sort: lastDownloaded for browsing, recommended when searching + const defaultSort = params.q ? 'recommended' : 'lastDownloaded'; + const effectiveSort = params.sort || defaultSort; + const hash = hashSearchParams({ q: params.q, category: params.category, platform: params.platform && params.platform !== 'all' ? params.platform : undefined, format: params.format || 'skill.md', - sort: params.sort || 'lastDownloaded', + sort: effectiveSort, page, }); @@ -54,12 +58,14 @@ async function getSkills(params: { const db = createDb(); const offset = (page - 1) * limit; - const sortMap: Record = { + const sortMap: Record = { 'stars': 'stars', 'downloads': 'downloads', 'recent': 'updated', 'rating': 'rating', 'lastDownloaded': 'lastDownloaded', + 'aiScore': 'aiScore', + 'recommended': 'recommended', }; const filterOptions = { @@ -67,7 +73,7 @@ async function getSkills(params: { category: params.category, platform: params.platform && params.platform !== 'all' ? params.platform : undefined, sourceFormat: params.format || 'skill.md', - sortBy: sortMap[params.sort || 'lastDownloaded'] || 'lastDownloaded', + sortBy: sortMap[effectiveSort] || defaultSort, sortOrder: 'desc' as const, limit, offset, @@ -117,11 +123,13 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro const tCommon = await getTranslations('common'); const sortOptions = [ + { id: 'recommended', name: t('filters.sortOptions.recommended') }, { id: 'lastDownloaded', name: t('filters.sortOptions.lastDownloaded') }, { id: 'downloads', name: t('filters.sortOptions.downloads') }, - { id: 'stars', name: t('filters.sortOptions.stars') }, + { id: 'aiScore', name: t('filters.sortOptions.aiScore') }, { id: 'recent', name: t('filters.sortOptions.recent') }, { id: 'rating', name: t('filters.sortOptions.rating') }, + { id: 'stars', name: t('filters.sortOptions.stars') }, ]; // Fetch categories hierarchically for filter dropdown with translations (cached 12h) @@ -202,9 +210,10 @@ export default async function BrowsePage({ params, searchParams }: BrowsePagePro // Get category name for active filters display const currentCategory = searchParamsResolved.category; - const currentSort = searchParamsResolved.sort || 'lastDownloaded'; + const defaultSort = searchParamsResolved.q ? 'recommended' : 'lastDownloaded'; + const currentSort = searchParamsResolved.sort || defaultSort; const currentFormat = searchParamsResolved.format || ''; - const hasActiveFilters = !!(searchParamsResolved.q || currentCategory || (currentSort && currentSort !== 'lastDownloaded') || currentFormat); + const hasActiveFilters = !!(searchParamsResolved.q || currentCategory || (currentSort && currentSort !== defaultSort) || currentFormat); // Find category name from hierarchical categories let categoryName = ''; diff --git a/apps/web/app/[locale]/docs/api/page.tsx b/apps/web/app/[locale]/docs/api/page.tsx index a912b72..bcf5d7c 100644 --- a/apps/web/app/[locale]/docs/api/page.tsx +++ b/apps/web/app/[locale]/docs/api/page.tsx @@ -61,7 +61,7 @@ export default async function ApiDocsPage({ { name: 'format', type: 'string', required: false, description: t('api.params.format'), default: 'skill.md' }, { name: 'verified', type: 'boolean', required: false, description: t('api.params.verified') }, { name: 'minStars', type: 'number', required: false, description: t('api.params.minStars') }, - { name: 'sort', type: 'string', required: false, description: t('api.params.sort') + ' (stars, downloads, rating, recent)', default: 'downloads' }, + { name: 'sort', type: 'string', required: false, description: t('api.params.sort') + ' (recommended, aiScore, downloads, stars, rating, recent)', default: 'recommended' }, { name: 'page', type: 'number', required: false, description: t('api.params.page'), default: '1' }, { name: 'limit', type: 'number', required: false, description: t('api.params.limit'), default: '20' }, ], @@ -79,7 +79,9 @@ export default async function ApiDocsPage({ "rating": 4.5, "ratingCount": 23, "isVerified": true, - "compatibility": { "platforms": ["claude", "cursor"] } + "compatibility": { "platforms": ["claude", "cursor"] }, + "reviewStatus": "ai-reviewed", + "aiScore": 85 } ], "pagination": { @@ -88,7 +90,7 @@ export default async function ApiDocsPage({ "total": 150, "totalPages": 8 }, - "searchEngine": "meilisearch" + "searchEngine": "postgresql" }`, notes: t('api.notes.searchFallback'), }, @@ -115,7 +117,17 @@ export default async function ApiDocsPage({ "isVerified": true, "compatibility": { "platforms": ["claude"] }, "rawContent": "# Code Review\\n...", - "sourceFormat": "skill.md" + "sourceFormat": "skill.md", + "reviewStatus": "ai-reviewed", + "aiScore": 85, + "review": { + "ai_score": 85, + "instruction_quality": 90, + "description_precision": 80, + "usefulness": 85, + "technical_soundness": 85, + "review_notes": "RATIONALE: ..." + } }`, notes: t('api.notes.viewCount'), }, diff --git a/apps/web/app/[locale]/docs/cli/page.tsx b/apps/web/app/[locale]/docs/cli/page.tsx index 99a7ea9..73dec07 100644 --- a/apps/web/app/[locale]/docs/cli/page.tsx +++ b/apps/web/app/[locale]/docs/cli/page.tsx @@ -122,6 +122,9 @@ export default async function CliDocsPage({ $ npx skillhub search "code review" --sort stars --limit 5 + + # Sort options: recommended (default), aiScore, downloads, stars, rating, recent + {/* Update all */}
diff --git a/apps/web/app/[locale]/docs/getting-started/page.tsx b/apps/web/app/[locale]/docs/getting-started/page.tsx index 1a42fcd..46554f8 100644 --- a/apps/web/app/[locale]/docs/getting-started/page.tsx +++ b/apps/web/app/[locale]/docs/getting-started/page.tsx @@ -44,6 +44,7 @@ npx skillhub uninstall # when done Rules: - Only install skills with security status "Pass" +- Prefer skills with AI review scores (shown as "AI 75+" in results) - After installing, read the SKILL.md and follow its instructions - Search during planning phase for best results`, @@ -55,9 +56,9 @@ npx skillhub install --project npx skillhub uninstall \`\`\` -Search for unfamiliar tech or complex tasks. Only install "Pass" security status. Read SKILL.md after install.`, +Search for unfamiliar tech or complex tasks. Only install "Pass" security. Prefer "AI 75+" reviewed skills. Read SKILL.md after install.`, - oneshot: `You have access to SkillHub (${count} AI agent skills, ${siteUrl}). For unfamiliar or complex tasks: \`npx skillhub search "" --limit 5\` then \`npx skillhub install --project\`. Only "Pass" security. Read SKILL.md after install. Uninstall when done.`, + oneshot: `You have access to SkillHub (${count} AI agent skills, ${siteUrl}). For unfamiliar or complex tasks: \`npx skillhub search "" --limit 5\` then \`npx skillhub install --project\`. Only "Pass" security, prefer "AI 75+" reviewed. Read SKILL.md after install. Uninstall when done.`, }; } diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx index 6f256ad..65a94c8 100644 --- a/apps/web/app/[locale]/page.tsx +++ b/apps/web/app/[locale]/page.tsx @@ -5,7 +5,7 @@ import { Header } from '@/components/Header'; import { Footer } from '@/components/Footer'; import { HeroSearch } from '@/components/HeroSearch'; import { SkillCard } from '@/components/SkillCard'; -import { createDb, categoryQueries, skillQueries, skills, sql } from '@skillhub/db'; +import { createDb, skillQueries, skills, sql } from '@skillhub/db'; import { formatCompactNumber } from '@/lib/format-number'; import { getPageAlternates } from '@/lib/seo'; import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; @@ -24,7 +24,7 @@ async function getStats() { const browseReady = sql`${skills.isDuplicate} = false AND ${skills.isStale} = false`; // Run all independent count queries in parallel - const [skillsResult, downloadsResult, categories, contributorsResult, totalIndexedResult] = await Promise.all([ + const [skillsResult, downloadsResult, contributorsResult, totalIndexedResult, reviewedResult] = await Promise.all([ // Get total skills count (browse-ready, SKILL.md only) db.select({ count: sql`count(*)::int` }) .from(skills) @@ -32,8 +32,6 @@ async function getStats() { // Get total downloads (ALL skills — downloads are real user actions) db.select({ sum: sql`coalesce(sum(${skills.downloadCount}), 0)::int` }) .from(skills), - // Get total categories - categoryQueries.getAll(db), // Get unique contributors (browse-ready skills only) db.select({ count: sql`count(distinct ${skills.githubOwner})::int` }) .from(skills) @@ -42,14 +40,18 @@ async function getStats() { db.select({ count: sql`count(*)::int` }) .from(skills) .where(sql`${skills.isBlocked} = false`), + // Get AI-reviewed skills count + db.select({ count: sql`count(*)::int` }) + .from(skills) + .where(sql`${skills.sourceFormat} = 'skill.md' AND ${skills.isBlocked} = false AND ${browseReady} AND ${skills.reviewStatus} IN ('ai-reviewed', 'verified')`), ]); return { totalSkills: skillsResult[0]?.count ?? 0, totalDownloads: downloadsResult[0]?.sum ?? 0, - totalCategories: categories.length, totalContributors: contributorsResult[0]?.count ?? 0, totalIndexed: totalIndexedResult[0]?.count ?? 0, + totalReviewed: reviewedResult[0]?.count ?? 0, platforms: 5, }; }); @@ -110,10 +112,10 @@ export default async function HomePage({ ]); const stats = [ - { value: statsData ? formatCompactNumber(statsData.totalSkills, locale) : '۰', label: t('stats.skills'), icon: Layers }, - { value: statsData ? formatCompactNumber(statsData.totalDownloads, locale) : '۰', label: t('stats.downloads'), icon: Download }, - { value: statsData ? formatCompactNumber(statsData.totalContributors, locale) : '۰', label: t('stats.contributors'), icon: Users }, - { value: statsData ? formatCompactNumber(statsData.totalCategories || 8, locale) : '۸', label: t('stats.categories'), icon: Sparkles }, + { value: statsData ? formatCompactNumber(statsData.totalSkills, locale) : '۰', label: t('stats.skills'), icon: Layers, href: `/${locale}/browse` }, + { value: statsData ? formatCompactNumber(statsData.totalDownloads, locale) : '۰', label: t('stats.downloads'), icon: Download, href: `/${locale}/browse?sort=downloads` }, + { value: statsData ? formatCompactNumber(statsData.totalContributors, locale) : '۰', label: t('stats.contributors'), icon: Users, href: `/${locale}/attribution` }, + { value: statsData ? formatCompactNumber(statsData.totalReviewed ?? 0, locale) : '۰', label: t('stats.reviewed'), icon: Sparkles, href: `/${locale}/reviewed` }, ]; const steps = [ @@ -189,17 +191,17 @@ export default async function HomePage({
{stats.map((stat, index) => ( -
-
+ +
{stat.value}
-
+
{stat.label}
-
+ ))}

diff --git a/apps/web/app/[locale]/reviewed/page.tsx b/apps/web/app/[locale]/reviewed/page.tsx new file mode 100644 index 0000000..413efc3 --- /dev/null +++ b/apps/web/app/[locale]/reviewed/page.tsx @@ -0,0 +1,152 @@ +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { Header } from '@/components/Header'; +import { Footer } from '@/components/Footer'; +import { createDb, skillQueries } from '@skillhub/db'; +import { toPersianNumber } from '@/lib/format-number'; +import { Pagination } from '@/components/BrowseFilters'; +import { SkillCard } from '@/components/SkillCard'; +import { ReviewedSortSelector } from '@/components/ReviewedSortSelector'; +import { getPageAlternates } from '@/lib/seo'; +import { getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; +import { Sparkles } from 'lucide-react'; + + +// Force dynamic rendering to fetch fresh data from database +export const dynamic = 'force-dynamic'; + +interface ReviewedPageProps { + params: Promise<{ locale: string }>; + searchParams: Promise<{ page?: string; sort?: string; score?: string }>; +} + +async function getReviewedSkillsData(sort: string, page: number, limit: number, minScore: number) { + const validSort = (sort === 'aiScore' ? 'aiScore' : 'reviewDate') as 'reviewDate' | 'aiScore'; + try { + return await getOrSetCache(cacheKeys.reviewedPage(validSort, page, minScore), cacheTTL.reviewed, async () => { + const db = createDb(); + const offset = (page - 1) * limit; + const [skills, total] = await Promise.all([ + skillQueries.getReviewedSkills(db, validSort, limit, offset, minScore), + skillQueries.countReviewedSkills(db, minScore), + ]); + return { skills, total }; + }); + } catch (error) { + console.error('Error fetching reviewed skills:', error); + return { skills: [], total: 0 }; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + return { + alternates: getPageAlternates(locale, '/reviewed'), + }; +} + +export default async function ReviewedPage({ + params, + searchParams, +}: ReviewedPageProps) { + const { locale } = await params; + const searchParamsResolved = await searchParams; + setRequestLocale(locale); + const t = await getTranslations('reviewed'); + const tBrowse = await getTranslations('browse'); + + const limit = 12; + const page = parseInt(searchParamsResolved.page || '1'); + const sort = searchParamsResolved.sort || 'reviewDate'; + const minScore = searchParamsResolved.score === '0' ? 0 : 50; + const { skills: reviewedSkills, total } = await getReviewedSkillsData(sort, page, limit, minScore); + const totalPages = Math.ceil(total / limit); + + const startItem = (page - 1) * limit + 1; + const endItem = Math.min(page * limit, total); + + const paginationTranslations = { + previous: tBrowse('pagination.previous'), + next: tBrowse('pagination.next'), + page: tBrowse('pagination.page'), + of: tBrowse('pagination.of'), + }; + + const sortTranslations = { + sortBy: t('sortBy'), + reviewDate: t('sortOptions.reviewDate'), + aiScore: t('sortOptions.aiScore'), + scoreFilter: t('scoreFilter'), + scoreAbove50: t('scoreOptions.above50'), + scoreAll: t('scoreOptions.all'), + }; + + return ( +

+
+
+
+
+
+ +
+

{t('title')}

+

{t('subtitle')}

+

+ {t('explanation')} +

+
+
+ +
+
+ {/* Sort and results info */} +
+ {total > 0 && ( +

+ {tBrowse('resultsRange', { + start: locale === 'fa' ? toPersianNumber(startItem) : startItem, + end: locale === 'fa' ? toPersianNumber(endItem) : endItem, + total: locale === 'fa' ? toPersianNumber(total) : total + })} +

+ )} + +
+ +
+ {reviewedSkills.map((skill) => ( + + ))} +
+ + {total === 0 && ( +

+ {locale === 'fa' ? 'هنوز مهارتی بررسی نشده است.' : 'No reviewed skills yet.'} +

+ )} + + {/* Pagination */} + +
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/skill/[...id]/page.tsx b/apps/web/app/[locale]/skill/[...id]/page.tsx index 5ed0f8c..a1cf673 100644 --- a/apps/web/app/[locale]/skill/[...id]/page.tsx +++ b/apps/web/app/[locale]/skill/[...id]/page.tsx @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation'; import { headers } from 'next/headers'; import { Star, Download, Shield, CheckCircle, Copy, - ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye + ExternalLink, Github, Calendar, User, Tag, ChevronRight, Eye, Sparkles } from 'lucide-react'; import { Header } from '@/components/Header'; import { Footer } from '@/components/Footer'; @@ -12,8 +12,8 @@ import { FavoriteButton } from '@/components/FavoriteButton'; import { RatingStars } from '@/components/RatingStars'; import { InstallSection } from '@/components/InstallSection'; import { ShareButton } from '@/components/ShareButton'; -import { createDb, skillQueries } from '@skillhub/db'; -import { FORMAT_LABELS } from 'skillhub-core'; +import { createDb, skillQueries, skillReviewQueries } from '@skillhub/db'; +import { FORMAT_LABELS, parseReviewNotes } from 'skillhub-core'; import { formatCompactNumber } from '@/lib/format-number'; import { shouldCountView, getOrSetCache, cacheKeys, cacheTTL } from '@/lib/cache'; import type { Metadata } from 'next'; @@ -59,6 +59,36 @@ async function getSkill(skillId: string) { } } +// Get skill review with Redis caching (1 hour TTL) +async function getSkillReview(skillId: string) { + try { + return await getOrSetCache(cacheKeys.skillReview(skillId), cacheTTL.skill, async () => { + const db = createDb(); + return await skillReviewQueries.getLatestBySkillId(db, skillId); + }); + } catch { + return null; + } +} + +// Score bar component for the review section +function ScoreBar({ label, score }: { label: string; score: number | null | undefined }) { + if (score === null || score === undefined) return null; + const percentage = Math.min(score, 100); + const color = score >= 75 ? 'bg-success' : score >= 50 ? 'bg-gold' : 'bg-text-muted'; + return ( +
+
+ {label} + {score} +
+
+
+
+
+ ); +} + export default async function SkillPage({ params }: SkillPageProps) { const { locale, id } = await params; setRequestLocale(locale); @@ -68,8 +98,11 @@ export default async function SkillPage({ params }: SkillPageProps) { const skillId = id.join('/'); const isRTL = locale === 'fa'; - // Get skill from database - const dbSkill = await getSkill(skillId); + // Get skill and review data from database (in parallel) + const [dbSkill, review] = await Promise.all([ + getSkill(skillId), + getSkillReview(skillId), + ]); if (!dbSkill) { notFound(); @@ -124,6 +157,10 @@ export default async function SkillPage({ params }: SkillPageProps) { sourceFormat: dbSkill.sourceFormat || 'skill.md', }; + // Parse review notes for structured display + const parsedNotes = review?.reviewNotes ? parseReviewNotes(review.reviewNotes) : null; + const hasReview = review && review.aiScore && (dbSkill.reviewStatus === 'ai-reviewed' || dbSkill.reviewStatus === 'verified'); + // Content section title based on source format (uses FORMAT_LABELS from skillhub-core) const getContentTitle = (format: string) => { const label = FORMAT_LABELS[format as keyof typeof FORMAT_LABELS] || FORMAT_LABELS['skill.md']; @@ -255,12 +292,13 @@ export default async function SkillPage({ params }: SkillPageProps) {

{/* Author, Version, License & Last Update */} -
+
@@ -268,47 +306,66 @@ export default async function SkillPage({ params }: SkillPageProps) { @{skill.author} {skill.version && ( - <> - - - - v{skill.version} - - + + + v{skill.version} + )} - {skill.license} - - - + + {skill.updatedAt}
- {/* Right: Actions */} -
- - - - - + {/* Right: Actions + AI Score Summary */} +
+
+ + + + + + {skill.homepage && ( + + + + )} +
+ + {/* Compact AI Review Score */} + {hasReview && ( +
+ = 75 ? 'text-success' : 'text-gold'}`} /> + = 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> + {review.aiScore} + + {t('review.outOf100')} +
+ )}
@@ -369,8 +426,8 @@ export default async function SkillPage({ params }: SkillPageProps) { {/* Stats Bar */}
-
-
+
+
-
-
- +
+
+ {formatCompactNumber(skill.stars, locale)} - {tCommon('stars')} + {tCommon('stars')}
-
-
- +
+
+ {formatCompactNumber(skill.downloads, locale)} - {tCommon('downloads')} + {tCommon('downloads')}
-
-
- +
+
+ {formatCompactNumber(skill.views, locale)} - {tCommon('views')} + {tCommon('views')}
@@ -445,6 +502,27 @@ export default async function SkillPage({ params }: SkillPageProps) { />
+ {/* AI Review Card (Mobile) */} + {hasReview && ( +
+

+ + {t('review.title')} +

+
+
+ + + + +
+ {parsedNotes?.rationale && ( +

{parsedNotes.rationale}

+ )} +
+
+ )} + {/* README Section */}
@@ -455,58 +533,87 @@ export default async function SkillPage({ params }: SkillPageProps) {
-
+                    
                       {skill.longDescription}
                     
- {/* Source Links (Mobile) */} -
{/* Right: Sidebar (Desktop) */}
+ {/* AI Review Card (Desktop) — above Install for visibility */} + {hasReview && ( +
+

+ + {t('review.title')} +

+ + {/* Overall Score */} +
+
= 75 ? 'text-success' : review.aiScore! >= 50 ? 'text-gold' : 'text-text-primary'}`}> + {review.aiScore} +
+
{t('review.outOf100')}
+
+ + {/* 4-axis score bars */} +
+ + + + +
+ + {/* Rationale */} + {parsedNotes?.rationale && ( +
+

{parsedNotes.rationale}

+
+ )} + + {/* Tags: Audience, Maturity, Complexity, Use Cases */} + {(parsedNotes?.maturity || parsedNotes?.complexity || (parsedNotes?.audience && parsedNotes.audience.length > 0) || (parsedNotes?.useCases && parsedNotes.useCases.length > 0)) && ( +
+
+ {parsedNotes?.maturity && ( + + {parsedNotes.maturity} + + )} + {parsedNotes?.complexity && ( + + {parsedNotes.complexity} + + )} + {parsedNotes?.audience?.map((a: string) => ( + + {a.trim()} + + ))} + {parsedNotes?.useCases?.map((uc: string) => ( + + {uc.trim()} + + ))} +
+
+ )} + + {/* Reviewer info */} +
+ {t('review.reviewedBy', { reviewer: review.reviewer || 'AI' })} + {review.reviewedAt && ( + <> {t('review.reviewedOn', { date: new Date(review.reviewedAt).toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US') })} + )} +
+
+ )} + {/* Install Section */} - - {/* Links Card (Desktop) */} - {skill.homepage && ( -
-

- {isRTL ? 'لینک‌ها' : 'Links'} -

- - - {t('meta.homepage')} - - -
- )}
diff --git a/apps/web/app/api/review/submit/route.ts b/apps/web/app/api/review/submit/route.ts index 86e07af..8625e58 100644 --- a/apps/web/app/api/review/submit/route.ts +++ b/apps/web/app/api/review/submit/route.ts @@ -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( diff --git a/apps/web/app/api/skills/[...id]/route.ts b/apps/web/app/api/skills/[...id]/route.ts index c6f0ffd..630aee9 100644 --- a/apps/web/app/api/skills/[...id]/route.ts +++ b/apps/web/app/api/skills/[...id]/route.ts @@ -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); diff --git a/apps/web/app/api/skills/featured/route.ts b/apps/web/app/api/skills/featured/route.ts index bc5ec14..26a164f 100644 --- a/apps/web/app/api/skills/featured/route.ts +++ b/apps/web/app/api/skills/featured/route.ts @@ -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, })), }; diff --git a/apps/web/app/api/skills/route.ts b/apps/web/app/api/skills/route.ts index ca32743..82e0350 100644 --- a/apps/web/app/api/skills/route.ts +++ b/apps/web/app/api/skills/route.ts @@ -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 = { + const sortByMap: Record = { 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) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index ed89ec8..11ab6b8 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -261,6 +261,22 @@ @apply bg-surface-subtle text-text-primary; } + /* Thin scrollbar */ + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; + } + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + } + .scrollbar-thin::-webkit-scrollbar-track { + background: transparent; + } + .scrollbar-thin::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; + } + /* Card */ .card { @apply bg-surface-elevated rounded-2xl; diff --git a/apps/web/components/BrowseFilters.tsx b/apps/web/components/BrowseFilters.tsx index 9f9a57f..52661c1 100644 --- a/apps/web/components/BrowseFilters.tsx +++ b/apps/web/components/BrowseFilters.tsx @@ -58,13 +58,15 @@ export function BrowseFilters({ // Get current values from URL const currentCategory = searchParams.get('category') || ''; - const currentSort = searchParams.get('sort') || 'lastDownloaded'; + const hasQuery = !!searchParams.get('q'); + const defaultSort = hasQuery ? 'recommended' : 'lastDownloaded'; + const currentSort = searchParams.get('sort') || defaultSort; const currentFormat = searchParams.get('format') || ''; // Count active filters for mobile badge const activeFilterCount = [ currentCategory ? 1 : 0, - currentSort !== 'lastDownloaded' ? 1 : 0, + currentSort !== defaultSort ? 1 : 0, currentFormat ? 1 : 0, ].reduce((a, b) => a + b, 0); @@ -98,7 +100,7 @@ export function BrowseFilters({ // Handle sort change const handleSortChange = (e: React.ChangeEvent) => { - updateParams({ sort: e.target.value === 'lastDownloaded' ? null : e.target.value }); + updateParams({ sort: e.target.value === defaultSort ? null : e.target.value }); }; // Handle format change @@ -272,9 +274,9 @@ export function SearchBar({ placeholder, defaultValue = '' }: SearchBarProps) { if (searchQuery) { params.set('q', searchQuery); - // Default to sorting by stars when searching - if (!params.get('sort')) { - params.set('sort', 'stars'); + // Remove sort param so server-side defaults to 'recommended' for searches + if (!params.get('sort') || params.get('sort') === 'lastDownloaded') { + params.delete('sort'); } } else { params.delete('q'); @@ -361,7 +363,8 @@ export function ActiveFilters({ const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); - const hasFilters = query || categoryId || (sortBy && sortBy !== 'lastDownloaded'); + const defaultSort = query ? 'recommended' : 'lastDownloaded'; + const hasFilters = query || categoryId || (sortBy && sortBy !== defaultSort); if (!hasFilters) return null; @@ -410,7 +413,7 @@ export function ActiveFilters({ )} - {sortBy && sortBy !== 'lastDownloaded' && sortName && ( + {sortBy && sortBy !== defaultSort && sortName && ( {sortName} diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx index 539c7e5..929f9b3 100644 --- a/apps/web/components/Footer.tsx +++ b/apps/web/components/Footer.tsx @@ -16,6 +16,7 @@ export function Footer() { { name: t('links.categories'), href: `/${locale}/categories` }, { name: t('links.featured'), href: `/${locale}/featured` }, { name: t('links.newSkills'), href: `/${locale}/new` }, + { name: t('links.reviewedSkills'), href: `/${locale}/reviewed` }, ], resources: [ { name: t('links.documentation'), href: `/${locale}/docs` }, diff --git a/apps/web/components/HeroSearch.tsx b/apps/web/components/HeroSearch.tsx index d23b2ca..5b77773 100644 --- a/apps/web/components/HeroSearch.tsx +++ b/apps/web/components/HeroSearch.tsx @@ -17,7 +17,7 @@ export function HeroSearch({ placeholder, locale }: HeroSearchProps) { e.preventDefault(); window.dispatchEvent(new Event('progressbar:start')); if (query.trim()) { - router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}&sort=stars`); + router.push(`/${locale}/browse?q=${encodeURIComponent(query.trim())}`); } else { router.push(`/${locale}/browse`); } diff --git a/apps/web/components/ReviewedSortSelector.tsx b/apps/web/components/ReviewedSortSelector.tsx new file mode 100644 index 0000000..6b577f2 --- /dev/null +++ b/apps/web/components/ReviewedSortSelector.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useTransition } from 'react'; + +interface ReviewedSortSelectorProps { + locale: string; + translations: { + sortBy: string; + reviewDate: string; + aiScore: string; + scoreFilter: string; + scoreAbove50: string; + scoreAll: string; + }; +} + +export function ReviewedSortSelector({ locale, translations }: ReviewedSortSelectorProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + const currentSort = searchParams.get('sort') || 'reviewDate'; + const currentScore = searchParams.get('score') || '50'; + + const navigate = (updates: Record) => { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(updates)) { + params.set(key, value); + } + params.delete('page'); + startTransition(() => { + router.push(`/${locale}/reviewed?${params.toString()}`); + }); + }; + + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/apps/web/components/SkillCard.tsx b/apps/web/components/SkillCard.tsx index 067d3e3..9430316 100644 --- a/apps/web/components/SkillCard.tsx +++ b/apps/web/components/SkillCard.tsx @@ -1,5 +1,5 @@ import Link from 'next/link'; -import { Star, Download, Shield, CheckCircle, Clock, RefreshCw } from 'lucide-react'; +import { Star, Download, Shield, CheckCircle, Clock, RefreshCw, Sparkles } from 'lucide-react'; import { formatCompactNumber } from '@/lib/format-number'; const FORMAT_BADGE_LABELS: Record = { @@ -21,6 +21,9 @@ interface SkillCardProps { ratingCount: number | null; securityStatus: string | null; isVerified: boolean | null; + reviewStatus?: string | null; + aiScore?: number | null; + latestAiScore?: number | null; sourceFormat?: string | null; createdAt?: Date | string | null; updatedAt?: Date | string | null; @@ -53,6 +56,19 @@ export function SkillCard({ skill, locale, showTimeBadge, formatTimeAgo }: Skill const showRating = (skill.ratingCount ?? 0) >= 3; + // AI review score badge (only for reviewed skills with score >= 50) + const getAiScoreBadge = () => { + const rs = skill.reviewStatus; + if (!rs || rs === 'unreviewed' || rs === 'auto-scored') return null; + const score = skill.aiScore ?? skill.latestAiScore; + if (!score || score < 50) return null; + const color = score >= 75 + ? 'text-success bg-success/10 border-success/20' + : 'text-gold bg-gold/10 border-gold/20'; + return { score, color }; + }; + const aiScoreBadge = getAiScoreBadge(); + return ( - {/* Metadata Row: Stars + Downloads + Rating */} + {/* Metadata Row: AI Score + Stars + Downloads + Rating */}
+ {aiScoreBadge && ( + + + {aiScoreBadge.score} + + )} {formatCompactNumber(skill.githubStars || 0, locale)} diff --git a/apps/web/lib/cache.ts b/apps/web/lib/cache.ts index 933b9d2..e00ec97 100644 --- a/apps/web/lib/cache.ts +++ b/apps/web/lib/cache.ts @@ -168,7 +168,9 @@ export const cacheKeys = { skill: (id: string) => `skill:${id.replace(/\//g, ':')}`, skillView: (skillId: string, ip: string) => `view:${skillId.replace(/\//g, ':')}:${ip}`, skillDownload: (skillId: string, ip: string) => `download:${skillId.replace(/\//g, ':')}:${ip}`, + skillReview: (id: string) => `review:skill:${id.replace(/\//g, ':')}`, reviewStats: () => 'review:stats', + reviewedPage: (sort: string, page: number, minScore = 50) => `page:reviewed:${sort}:s${minScore}:${page}`, }; // TTL values in seconds @@ -186,6 +188,7 @@ export const cacheTTL = { view: 60 * 60, // 1 hour - same IP can only count as 1 view per hour download: 5 * 60, // 5 minutes - same IP can only count as 1 download per 5 min reviewStats: 60, // 1 minute - admin review stats + reviewed: 60 * 60, // 1 hour - reviewed skills page }; /** diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index ea21c9d..24d3e68 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -50,7 +50,7 @@ "skills": "Curated Skills", "downloads": "Downloads", "contributors": "Contributors", - "categories": "Categories", + "reviewed": "AI Reviewed", "curationNote": "Every skill is deduplicated and quality-filtered from {totalIndexed}+ indexed repositories" }, "featured": { @@ -87,11 +87,13 @@ "allCategories": "All Categories", "sort": "Sort by", "sortOptions": { + "recommended": "Recommended", "lastDownloaded": "Recently downloaded", "stars": "Most stars", "downloads": "Most downloads", "recent": "Recently updated", - "rating": "Highest rated" + "rating": "Highest rated", + "aiScore": "AI Review Score" }, "minStars": "Min stars", "minSecurity": "Min security score", @@ -178,6 +180,21 @@ "homepage": "Homepage", "lastUpdate": "Last updated", "createdAt": "Created" + }, + "review": { + "title": "AI Review", + "aiScore": "AI Score", + "outOf100": "out of 100", + "instructionQuality": "Instruction Quality", + "descriptionPrecision": "Description Precision", + "usefulness": "Usefulness", + "technicalSoundness": "Technical Soundness", + "audience": "Audience", + "maturity": "Maturity", + "complexity": "Complexity", + "useCases": "Use Cases", + "reviewedBy": "Reviewed by {reviewer}", + "reviewedOn": "on {date}" } }, "footer": { @@ -202,7 +219,8 @@ "privacyPolicy": "Privacy Policy", "termsOfService": "Terms of Service", "attribution": "Attribution", - "manageSkills": "Manage Skills" + "manageSkills": "Manage Skills", + "reviewedSkills": "Reviewed Skills" }, "sourceCode": "Source Code", "copyright": "© {year} SkillHub. Open source under MIT license." @@ -538,6 +556,21 @@ "title": "Featured Skills", "subtitle": "Top skills by popularity and community engagement" }, + "reviewed": { + "title": "AI Reviewed Skills", + "subtitle": "Skills evaluated and scored by AI for quality and usefulness", + "explanation": "Each skill has been evaluated by an AI reviewer on a structured rubric: instruction quality, usefulness, technical soundness, and description precision. Scores range from 0 to 100 — skills scoring 75+ (green badge) are high quality, 50–74 (gold badge) are solid with targeted value.", + "sortBy": "Sort by", + "sortOptions": { + "reviewDate": "Recently reviewed", + "aiScore": "Highest score" + }, + "scoreFilter": "Score", + "scoreOptions": { + "above50": "50+ only", + "all": "All scores" + } + }, "owner": { "title": "@{username}'s Skills", "notFound": "Owner not found", diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index db91659..f0927b3 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -50,7 +50,7 @@ "skills": "مهارت برگزیده", "downloads": "دانلود", "contributors": "مشارکت‌کننده", - "categories": "دسته‌بندی", + "reviewed": "بررسی‌شده با هوش مصنوعی", "curationNote": "هر مهارت از میان {totalIndexed}+ مخزن ایندکس‌شده، حذف تکراری و فیلتر کیفیت شده است" }, "featured": { @@ -87,11 +87,13 @@ "allCategories": "همه دسته‌بندی‌ها", "sort": "مرتب‌سازی", "sortOptions": { + "recommended": "پیشنهادی", "lastDownloaded": "اخیراً دانلود شده", "stars": "بیشترین ستاره", "downloads": "بیشترین دانلود", "recent": "جدید‌ترین", - "rating": "بالاترین امتیاز" + "rating": "بالاترین امتیاز", + "aiScore": "امتیاز بررسی هوش مصنوعی" }, "minStars": "حداقل ستاره", "minSecurity": "حداقل امتیاز امنیتی", @@ -178,6 +180,21 @@ "homepage": "وب‌سایت", "lastUpdate": "آخرین به‌روزرسانی", "createdAt": "تاریخ ایجاد" + }, + "review": { + "title": "بررسی هوش مصنوعی", + "aiScore": "امتیاز AI", + "outOf100": "از ۱۰۰", + "instructionQuality": "کیفیت دستورالعمل", + "descriptionPrecision": "دقت توضیحات", + "usefulness": "کاربردی بودن", + "technicalSoundness": "صحت فنی", + "audience": "مخاطبان", + "maturity": "بلوغ", + "complexity": "پیچیدگی", + "useCases": "موارد استفاده", + "reviewedBy": "بررسی توسط {reviewer}", + "reviewedOn": "در {date}" } }, "footer": { @@ -202,7 +219,8 @@ "privacyPolicy": "حریم خصوصی", "termsOfService": "شرایط استفاده", "attribution": "تقدیر و تشکر", - "manageSkills": "مدیریت مهارت‌ها" + "manageSkills": "مدیریت مهارت‌ها", + "reviewedSkills": "مهارت‌های بررسی‌شده" }, "sourceCode": "کد منبع", "copyright": "© {year} SkillHub. کد متن‌باز تحت مجوز MIT." @@ -538,6 +556,21 @@ "title": "مهارت‌های ویژه", "subtitle": "برترین مهارت‌ها بر اساس محبوبیت و مشارکت جامعه" }, + "reviewed": { + "title": "مهارت‌های بررسی‌شده توسط هوش مصنوعی", + "subtitle": "مهارت‌هایی که توسط هوش مصنوعی ارزیابی و امتیازدهی شده‌اند", + "explanation": "هر مهارت در این فهرست توسط یک ارزیاب هوش مصنوعی بر اساس معیارهای ساختاریافته شامل کیفیت دستورالعمل، کاربرد، درستی فنی و دقت توضیحات بررسی شده است. امتیازها از ۰ تا ۱۰۰ هستند — مهارت‌های با امتیاز ۷۵+ (نشان سبز) باکیفیت بالا، ۵۰ تا ۷۴ (نشان طلایی) مستحکم با ارزش هدفمند هستند.", + "sortBy": "مرتب‌سازی", + "sortOptions": { + "reviewDate": "آخرین بررسی‌شده", + "aiScore": "بالاترین امتیاز" + }, + "scoreFilter": "امتیاز", + "scoreOptions": { + "above50": "فقط ۵۰+", + "all": "همه امتیازها" + } + }, "owner": { "title": "مهارت‌های @{username}", "notFound": "مالک یافت نشد", diff --git a/docker-compose.yml b/docker-compose.yml index a191184..2475c1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,14 +121,8 @@ services: volumes: postgres_data: - name: skillhub-public_postgres_data - external: true redis_data: - name: skillhub-public_redis_data - external: true meilisearch_data: - name: skillhub-public_meilisearch_data - external: true networks: default: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb4318f..2e90ae2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -34,6 +34,10 @@ export { // Validator export { validateSkill, isValidSkill, formatValidationSummary } from './validator.js'; +// Review Notes Parser +export type { ParsedReviewNotes } from './review-notes-parser.js'; +export { parseReviewNotes } from './review-notes-parser.js'; + // Security Scanner export { scanSecurity, diff --git a/packages/core/src/review-notes-parser.ts b/packages/core/src/review-notes-parser.ts new file mode 100644 index 0000000..151f0a1 --- /dev/null +++ b/packages/core/src/review-notes-parser.ts @@ -0,0 +1,141 @@ +/** + * Parser for structured review_notes from the AI review pipeline. + * + * Review notes use tagged sections like: + * RATIONALE: ... + * USE-CASES: csv-cleaning, pdf-generation + * AUDIENCE: data-analysts + * MATURITY: production + * COMPLEXITY: complex + * DEPENDENCIES: node, bash + * PLATFORM: cross-platform + * COMPLEMENTS: context-manager + * SEO-EN: One-line summary + * BUNDLE-FIT: productivity + * FRAMEWORK-LOCK: none + * CONTRIBUTING-REPO: none + */ + +export interface ParsedReviewNotes { + /** Free-text summary before the first structured tag */ + summary: string; + rationale: string | null; + useCases: string[]; + audience: string[]; + complements: string[]; + seoEn: string | null; + bundleFit: string | null; + frameworkLock: string | null; + contributingRepo: string | null; + maturity: 'prototype' | 'beta' | 'production' | null; + complexity: 'simple' | 'moderate' | 'complex' | null; + dependencies: string[]; + platform: string | null; +} + +const TAG_KEYS = [ + 'RATIONALE', + 'USE-CASES', + 'AUDIENCE', + 'COMPLEMENTS', + 'SEO-EN', + 'BUNDLE-FIT', + 'FRAMEWORK-LOCK', + 'CONTRIBUTING-REPO', + 'MATURITY', + 'COMPLEXITY', + 'DEPENDENCIES', + 'PLATFORM', +] as const; + +// Build regex that matches any tag at the start of a segment +const TAG_REGEX = new RegExp(`(${TAG_KEYS.join('|')}):\\s*`, 'g'); + +function splitComma(val: string): string[] { + return val + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +function normalizeNone(val: string): string | null { + const lower = val.trim().toLowerCase(); + return lower === 'none' || lower === 'n/a' || lower === '' ? null : val.trim(); +} + +export function parseReviewNotes(notes: string | null | undefined): ParsedReviewNotes { + const empty: ParsedReviewNotes = { + summary: '', + rationale: null, + useCases: [], + audience: [], + complements: [], + seoEn: null, + bundleFit: null, + frameworkLock: null, + contributingRepo: null, + maturity: null, + complexity: null, + dependencies: [], + platform: null, + }; + + if (!notes) return empty; + + // Normalize escaped newlines to real newlines + const normalized = notes.replace(/\\n/g, '\n'); + + // Extract tags and their values + const tags: Record = {}; + let summary = ''; + + // Find the first tag position + TAG_REGEX.lastIndex = 0; + const firstMatch = TAG_REGEX.exec(normalized); + const firstTagPos = firstMatch ? firstMatch.index : normalized.length; + + // Everything before the first tag is the summary + summary = normalized.slice(0, firstTagPos).trim(); + + // Parse all tag: value pairs + const tagMatches: Array<{ tag: string; start: number; valueStart: number }> = []; + TAG_REGEX.lastIndex = 0; + let match; + while ((match = TAG_REGEX.exec(normalized)) !== null) { + tagMatches.push({ + tag: match[1], + start: match.index, + valueStart: match.index + match[0].length, + }); + } + + for (let i = 0; i < tagMatches.length; i++) { + const current = tagMatches[i]; + const nextStart = i + 1 < tagMatches.length ? tagMatches[i + 1].start : normalized.length; + const value = normalized.slice(current.valueStart, nextStart).trim(); + tags[current.tag] = value; + } + + const maturityVal = tags['MATURITY']?.trim().toLowerCase(); + const complexityVal = tags['COMPLEXITY']?.trim().toLowerCase(); + + return { + summary, + rationale: tags['RATIONALE']?.trim() || null, + useCases: tags['USE-CASES'] ? splitComma(tags['USE-CASES']) : [], + audience: tags['AUDIENCE'] ? splitComma(tags['AUDIENCE']) : [], + complements: tags['COMPLEMENTS'] ? splitComma(tags['COMPLEMENTS']) : [], + seoEn: normalizeNone(tags['SEO-EN'] || ''), + bundleFit: normalizeNone(tags['BUNDLE-FIT'] || ''), + frameworkLock: normalizeNone(tags['FRAMEWORK-LOCK'] || ''), + contributingRepo: normalizeNone(tags['CONTRIBUTING-REPO'] || ''), + maturity: maturityVal === 'prototype' || maturityVal === 'beta' || maturityVal === 'production' + ? maturityVal + : null, + complexity: complexityVal === 'simple' || complexityVal === 'moderate' || complexityVal === 'complex' + ? complexityVal + : null, + dependencies: tags['DEPENDENCIES'] ? splitComma(tags['DEPENDENCIES']) : [], + platform: normalizeNone(tags['PLATFORM'] || ''), + }; +} diff --git a/packages/db/src/meilisearch.ts b/packages/db/src/meilisearch.ts index afe5bed..4b484b1 100644 --- a/packages/db/src/meilisearch.ts +++ b/packages/db/src/meilisearch.ts @@ -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 { 'isFeatured', 'securityScore', 'githubStars', + 'reviewStatus', + 'aiScore', ]); // Configure sortable attributes @@ -162,6 +166,7 @@ export async function initializeSkillsIndex(): Promise { 'downloadCount', 'rating', 'indexedAt', + 'aiScore', ]); // Configure ranking rules (relevance + custom) @@ -279,6 +284,9 @@ export async function searchSkills(options: MeiliSearchOptions): Promise = await index.search(options.query, { diff --git a/packages/db/src/queries.ts b/packages/db/src/queries.ts index ededdc2..ba004ca 100644 --- a/packages/db/src/queries.ts +++ b/packages/db/src/queries.ts @@ -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`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)); }, }; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b1b0a28..ce1ecad 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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), }) ); diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 44a8f31..c036a44 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -568,6 +568,11 @@ ALTER TABLE skills ADD COLUMN IF NOT EXISTS stale_since TIMESTAMP WITH TIME ZONE ALTER TABLE skills ADD COLUMN IF NOT EXISTS stale_check_count INTEGER DEFAULT 0; CREATE INDEX IF NOT EXISTS idx_skills_stale ON skills(is_stale) WHERE is_stale = TRUE; +-- Denormalized AI review scores (March 2026) +ALTER TABLE skills ADD COLUMN IF NOT EXISTS latest_ai_score INTEGER; +ALTER TABLE skills ADD COLUMN IF NOT EXISTS latest_review_date TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_skills_ai_score ON skills(latest_ai_score) WHERE latest_ai_score IS NOT NULL; + -- Grant permissions GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO postgres; GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO postgres; diff --git a/services/indexer/src/crawl.ts b/services/indexer/src/crawl.ts index 3069e48..9d6b4aa 100644 --- a/services/indexer/src/crawl.ts +++ b/services/indexer/src/crawl.ts @@ -7,7 +7,7 @@ import { INSTRUCTION_FILE_PATTERNS, type SourceFormat } from 'skillhub-core'; import { scheduleFullCrawl, scheduleIncrementalCrawl, getQueueStats, getQueue } from './queue.js'; import { syncAllSkillsToMeilisearch, checkMeilisearchHealth } from './meilisearch-sync.js'; -import { createDb, skillQueries, categoryQueries, discoveredRepoQueries, awesomeListQueries, addRequestQueries, userQueries, skills, sql } from '@skillhub/db'; +import { createDb, skillQueries, categoryQueries, discoveredRepoQueries, awesomeListQueries, addRequestQueries, userQueries, sql } from '@skillhub/db'; import { createStrategyOrchestrator, createDeepScanCrawler, createAwesomeListCrawler, createPopularReposCrawler, createCommitsSearchCrawler } from './strategies/index.js'; import { createCrawler } from './crawler.js'; import { indexSkill } from './skill-indexer.js'; diff --git a/services/indexer/src/meilisearch-sync.ts b/services/indexer/src/meilisearch-sync.ts index 792d70c..7ecc6ee 100644 --- a/services/indexer/src/meilisearch-sync.ts +++ b/services/indexer/src/meilisearch-sync.ts @@ -92,6 +92,8 @@ async function initializeIndex(): Promise { 'isFeatured', 'securityScore', 'githubStars', + 'reviewStatus', + 'aiScore', ]); // Configure sortable attributes @@ -100,6 +102,7 @@ async function initializeIndex(): Promise { 'downloadCount', 'rating', 'indexedAt', + 'aiScore', ]); // Configure ranking rules @@ -144,6 +147,8 @@ export async function syncSkillToMeilisearch(skill: { securityStatus?: 'pass' | 'warning' | 'fail' | null; isFeatured?: boolean | null; isVerified?: boolean | null; + reviewStatus?: string | null; + latestAiScore?: number | null; indexedAt?: Date | null; }): Promise { const meili = getMeilisearchClient(); @@ -171,6 +176,8 @@ export async function syncSkillToMeilisearch(skill: { securityStatus: skill.securityStatus || null, isFeatured: skill.isFeatured || false, isVerified: skill.isVerified || false, + reviewStatus: skill.reviewStatus || null, + aiScore: skill.latestAiScore || 0, indexedAt: skill.indexedAt?.toISOString() || new Date().toISOString(), }; @@ -254,6 +261,8 @@ export async function syncAllSkillsToMeilisearch( securityStatus?: 'pass' | 'warning' | 'fail' | null; isFeatured?: boolean | null; isVerified?: boolean | null; + reviewStatus?: string | null; + latestAiScore?: number | null; indexedAt?: Date | null; }> ): Promise<{ success: number; failed: number }> { @@ -284,6 +293,8 @@ export async function syncAllSkillsToMeilisearch( securityStatus: skill.securityStatus || null, isFeatured: skill.isFeatured || false, isVerified: skill.isVerified || false, + reviewStatus: skill.reviewStatus || null, + aiScore: skill.latestAiScore || 0, indexedAt: skill.indexedAt?.toISOString() || new Date().toISOString(), }));