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

@@ -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,

View File

@@ -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<string, string> = {};
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'] || ''),
};
}

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),
})
);