- 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>
142 lines
4.2 KiB
TypeScript
142 lines
4.2 KiB
TypeScript
/**
|
|
* 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'] || ''),
|
|
};
|
|
}
|