@kaifronsdal/transcript-viewer
Version:
A web-based viewer for AI conversation transcripts with rollback support
196 lines (171 loc) • 5.38 kB
text/typescript
import { compileExpression } from 'filtrex';
import type { TranscriptDisplay } from './types';
/**
* Compile and evaluate filter expression safely
*/
export function createFilterFunction(expression: string): (transcript: TranscriptDisplay) => boolean {
// Return true for empty expressions (no filtering)
if (!expression || !expression.trim()) {
return () => true;
}
try {
// Add custom functions for common string operations
const options = {
extraFunctions: {
startsWith: (str: string, prefix: string) => str?.startsWith(prefix) || false,
endsWith: (str: string, suffix: string) => str?.endsWith(suffix) || false,
contains: (str: string, substring: string) => str?.includes(substring) || false,
toLowerCase: (str: string) => str?.toLowerCase() || '',
toUpperCase: (str: string) => str?.toUpperCase() || '',
}
};
// Compile the expression
const compiledExpression = compileExpression(expression, options);
return (transcript: TranscriptDisplay) => {
try {
// Flatten the transcript object to make fields accessible
const context = {
// Basic fields
id: transcript.id,
model: transcript.model,
split: transcript.split,
concerningScore: transcript.concerningScore,
summary: transcript.summary,
judgeSummary: transcript.judgeSummary,
justification: transcript.justification,
// Individual score fields (scores.scoreA becomes scoreA)
...Object.fromEntries(
Object.entries(transcript.scores || {}).map(([key, value]) => [key, value])
),
// Also keep scores object for scores.scoreA syntax
scores: transcript.scores || {}
};
return Boolean(compiledExpression(context));
} catch (error) {
// If evaluation fails for this transcript, exclude it
console.warn('Filter evaluation failed for transcript:', transcript.id, error);
return false;
}
};
} catch (error) {
// If compilation fails, return a function that shows all transcripts
console.warn('Filter compilation failed:', error);
return () => true;
}
}
/**
* Get validation errors for a filter expression
*/
export function validateFilterExpression(expression: string): string | null {
if (!expression || !expression.trim()) {
return null; // Empty expressions are valid
}
try {
// Try to compile with some dummy custom functions
const options = {
extraFunctions: {
startsWith: () => true,
endsWith: () => true,
contains: () => true,
toLowerCase: () => '',
toUpperCase: () => '',
}
};
compileExpression(expression, options);
return null; // No errors
} catch (error) {
return error instanceof Error ? error.message : 'Invalid expression';
}
}
/**
* Get example filter expressions for help text
*/
export function getFilterExamples(): string[] {
return [
'scoreA < 5',
'scoreA < 5 and scoreB > 3',
'1 < scoreA < 4',
'scoreA in (1, 3, 5)',
'split == "scheming"',
'split in ("scheming", "power_seeking")',
'startsWith(model, "gpt")',
'contains(summary, "refusal")',
'concerningScore > 7 and scoreA < 3',
'(scoreA == 1 or scoreA == 3) and scoreB < 6'
];
}
/**
* Get all available field names for autocomplete
*/
export function getAvailableFields(scoreTypes: string[]): string[] {
const baseFields = [
'id',
'model',
'split',
'concerningScore',
'summary',
'judgeSummary'
];
const customFunctions = [
'startsWith',
'endsWith',
'contains',
'toLowerCase',
'toUpperCase'
];
return [
...baseFields,
...scoreTypes,
...customFunctions
];
}
/**
* Get current word being typed and its position
*/
export function getCurrentWord(text: string, cursorPosition: number): { word: string, startPos: number, endPos: number } {
// Find word boundaries (letters, numbers, underscores)
const wordPattern = /[a-zA-Z_][a-zA-Z0-9_]*/g;
let match;
while ((match = wordPattern.exec(text)) !== null) {
const startPos = match.index;
const endPos = match.index + match[0].length;
if (cursorPosition >= startPos && cursorPosition <= endPos) {
return {
word: match[0],
startPos,
endPos
};
}
}
// If cursor is not in a word, find the start of a potential new word
const beforeCursor = text.slice(0, cursorPosition);
const wordStart = beforeCursor.search(/[a-zA-Z_][a-zA-Z0-9_]*$/);
if (wordStart !== -1) {
const partialWord = beforeCursor.slice(wordStart);
return {
word: partialWord,
startPos: wordStart,
endPos: cursorPosition
};
}
return { word: '', startPos: cursorPosition, endPos: cursorPosition };
}
/**
* Get autocomplete suggestions for current word
*/
export function getAutocompleteSuggestions(currentWord: string, availableFields: string[]): string[] {
if (!currentWord) return [];
const lowerCurrentWord = currentWord.toLowerCase();
return availableFields
.filter(field => field.toLowerCase().includes(lowerCurrentWord))
.sort((a, b) => {
// Prioritize exact matches at the start
const aStartsWithCurrent = a.toLowerCase().startsWith(lowerCurrentWord);
const bStartsWithCurrent = b.toLowerCase().startsWith(lowerCurrentWord);
if (aStartsWithCurrent && !bStartsWithCurrent) return -1;
if (!aStartsWithCurrent && bStartsWithCurrent) return 1;
// Then sort alphabetically
return a.localeCompare(b);
})
.slice(0, 10); // Limit to 10 suggestions
}