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:
@@ -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,
|
||||
|
||||
141
packages/core/src/review-notes-parser.ts
Normal file
141
packages/core/src/review-notes-parser.ts
Normal 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'] || ''),
|
||||
};
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user