From 55f9b28a7558308af7f7a6e14051cb1129fbff02 Mon Sep 17 00:00:00 2001 From: Adnan Rashid Hussain Date: Mon, 9 Feb 2026 09:47:14 -0800 Subject: [PATCH 01/10] feat: Implement Batch Evaluator --- sdks/typescript/CHANGELOG.md | 8 + sdks/typescript/README.md | 15 + sdks/typescript/package-lock.json | 62 +- sdks/typescript/package.json | 31 +- sdks/typescript/src/batch/README.md | 150 +++ sdks/typescript/src/batch/cli.ts | 275 ++++++ sdks/typescript/src/batch/csv.ts | 73 ++ sdks/typescript/src/batch/evaluator.ts | 305 ++++++ sdks/typescript/src/batch/formatters.ts | 364 +++++++ sdks/typescript/src/batch/index.ts | 29 + sdks/typescript/src/batch/progress.ts | 167 ++++ .../typescript/src/batch/report-template.html | 914 ++++++++++++++++++ sdks/typescript/src/batch/types.ts | 86 ++ sdks/typescript/src/evaluators/base.ts | 2 +- sdks/typescript/src/types/html.d.ts | 4 + sdks/typescript/tests/fixtures/batch-test.csv | 3 + .../integration/batch.integration.test.ts | 146 +++ .../tests/unit/batch/csv-parsing.test.ts | 133 +++ .../tests/unit/batch/formatters.test.ts | 374 +++++++ .../tests/unit/batch/limits.test.ts | 80 ++ sdks/typescript/tsup.config.ts | 29 +- sdks/typescript/vitest.config.ts | 17 +- 22 files changed, 3250 insertions(+), 17 deletions(-) create mode 100644 sdks/typescript/src/batch/README.md create mode 100644 sdks/typescript/src/batch/cli.ts create mode 100644 sdks/typescript/src/batch/csv.ts create mode 100644 sdks/typescript/src/batch/evaluator.ts create mode 100644 sdks/typescript/src/batch/formatters.ts create mode 100644 sdks/typescript/src/batch/index.ts create mode 100644 sdks/typescript/src/batch/progress.ts create mode 100644 sdks/typescript/src/batch/report-template.html create mode 100644 sdks/typescript/src/batch/types.ts create mode 100644 sdks/typescript/src/types/html.d.ts create mode 100644 sdks/typescript/tests/fixtures/batch-test.csv create mode 100644 sdks/typescript/tests/integration/batch.integration.test.ts create mode 100644 sdks/typescript/tests/unit/batch/csv-parsing.test.ts create mode 100644 sdks/typescript/tests/unit/batch/formatters.test.ts create mode 100644 sdks/typescript/tests/unit/batch/limits.test.ts diff --git a/sdks/typescript/CHANGELOG.md b/sdks/typescript/CHANGELOG.md index 257faf5..5bcd473 100644 --- a/sdks/typescript/CHANGELOG.md +++ b/sdks/typescript/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the `@learning-commons/evaluators` TypeScript SDK will be documented in this file. +## [0.4.0] — 2026-03-23 + +### Added + +- **Batch CSV Evaluator** — CLI tool and programmatic API for evaluating multiple texts from a CSV file in parallel. Runs the `text-complexity` group (Vocabulary, Sentence Structure, and GLA) across up to 100 rows and produces CSV and HTML reports. Run with `npx @learning-commons/evaluators` or `evaluators-batch`. + +--- + ## [0.3.0] — 2026-03-20 ### Added diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md index c82d120..d10576e 100644 --- a/sdks/typescript/README.md +++ b/sdks/typescript/README.md @@ -350,6 +350,21 @@ await evaluator.evaluate(text: string) --- +## Batch CSV Evaluation + +For evaluating many texts at once, the SDK ships a CLI tool that reads a CSV file, runs all evaluators in a group, and produces CSV and HTML reports. + +```bash +# Run from the directory containing your CSV +npx @learning-commons/evaluators +``` + +The CLI will prompt for your CSV path, API keys, and output directory, then process all rows in parallel with real-time progress. + +See [`src/batch/README.md`](./src/batch/README.md) for full documentation. + +--- + ## Error Handling The SDK provides specific error types to help you handle different scenarios: diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json index d8815a0..69bd081 100644 --- a/sdks/typescript/package-lock.json +++ b/sdks/typescript/package-lock.json @@ -10,15 +10,21 @@ "license": "MIT", "dependencies": { "compromise": "^14.13.0", + "csv-parse": "^6.1.0", "p-limit": "^5.0.0", + "prompts": "^2.4.2", "syllable": "^5.0.1", "zod": "^3.22.4" }, + "bin": { + "evaluators-batch": "dist/batch/cli.js" + }, "devDependencies": { "@ai-sdk/anthropic": "^3.0.12", "@ai-sdk/google": "^3.0.7", "@ai-sdk/openai": "^3.0.9", "@types/node": "^20.11.5", + "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "@vitest/coverage-v8": "^4.0.17", @@ -32,7 +38,21 @@ "node": ">=20.19.0" }, "peerDependencies": { - "ai": ">=4.0.0" + "@ai-sdk/anthropic": ">=3.0.0", + "@ai-sdk/google": ">=3.0.0", + "@ai-sdk/openai": ">=3.0.0", + "ai": ">=6.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/anthropic": { + "optional": true + }, + "@ai-sdk/google": { + "optional": true + }, + "@ai-sdk/openai": { + "optional": true + } } }, "node_modules/@ai-sdk/anthropic": { @@ -1190,6 +1210,16 @@ "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.29.tgz", "integrity": "sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==" }, + "node_modules/@types/prompts": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", + "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "kleur": "^3.0.3" + } + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -1830,6 +1860,11 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz", + "integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2593,6 +2628,14 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3064,6 +3107,18 @@ "node": ">= 0.8.0" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3247,6 +3302,11 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 7e67685..3be94c3 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -9,9 +9,17 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./batch": { + "types": "./dist/batch/index.d.ts", + "import": "./dist/batch/index.js", + "require": "./dist/batch/index.cjs" } }, "sideEffects": false, + "bin": { + "evaluators-batch": "./dist/batch/cli.js" + }, "files": [ "dist", "README.md", @@ -29,7 +37,7 @@ "test:coverage": "vitest run --coverage", "test:ci": "npm run test:unit && npm run test:integration:dist", "typecheck": "tsc --noEmit", - "lint": "eslint src --ext .ts", + "lint": "eslint src tests --ext .ts", "prepublishOnly": "npm run build" }, "keywords": [ @@ -48,19 +56,27 @@ }, "homepage": "https://github.com/learning-commons-org/evaluators#readme", "peerDependencies": { - "ai": ">=6.0.0", - "@ai-sdk/openai": ">=3.0.0", + "@ai-sdk/anthropic": ">=3.0.0", "@ai-sdk/google": ">=3.0.0", - "@ai-sdk/anthropic": ">=3.0.0" + "@ai-sdk/openai": ">=3.0.0", + "ai": ">=6.0.0" }, "peerDependenciesMeta": { - "@ai-sdk/openai": { "optional": true }, - "@ai-sdk/google": { "optional": true }, - "@ai-sdk/anthropic": { "optional": true } + "@ai-sdk/openai": { + "optional": true + }, + "@ai-sdk/google": { + "optional": true + }, + "@ai-sdk/anthropic": { + "optional": true + } }, "dependencies": { "compromise": "^14.13.0", + "csv-parse": "^6.1.0", "p-limit": "^5.0.0", + "prompts": "^2.4.2", "syllable": "^5.0.1", "zod": "^3.22.4" }, @@ -69,6 +85,7 @@ "@ai-sdk/google": "^3.0.7", "@ai-sdk/openai": "^3.0.9", "@types/node": "^20.11.5", + "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "@vitest/coverage-v8": "^4.0.17", diff --git a/sdks/typescript/src/batch/README.md b/sdks/typescript/src/batch/README.md new file mode 100644 index 0000000..a3311d9 --- /dev/null +++ b/sdks/typescript/src/batch/README.md @@ -0,0 +1,150 @@ +# Batch CSV Evaluator + +Evaluate multiple texts from a CSV file using a group of evaluators, with results output in CSV and HTML formats. + +## Usage + +### Installation + +After publishing to npm: + +```bash +# Install globally +npm install -g @learning-commons/evaluators + +# Or run directly with npx +npx @learning-commons/evaluators +``` + +### Interactive Mode + +Run the batch evaluator interactively from any directory: + +```bash +# If installed globally +evaluators-batch + +# Or with npx +npx @learning-commons/evaluators +``` + +**Important:** Run this command from the directory containing your CSV file, or provide an absolute path to your CSV. + +The CLI will guide you through: +1. **CSV File Path**: Location of your input CSV file +2. **API Keys**: Enter required API keys (only prompted for keys the group requires) +3. **Output Directory**: Where to save results (default: timestamped folder in current directory) +4. **Confirmation**: Review summary before starting + +The output directory is automatically created with a human-readable timestamp: +``` +batch-results-2024-02-07_14-30-22/ +├── results.csv +└── results.html +``` + +### Input CSV Format + +Your CSV must have a `text` column and a `grade` column (both case-insensitive). Any additional columns are preserved as-is in the output. + +Example `input.csv`: +```csv +text,grade +"The cat sat on the mat.",3 +"Photosynthesis is the process by which plants convert sunlight into energy.",5 +"The mitochondria are the powerhouse of the cell.",8 +``` + +See `tests/fixtures/batch-test.csv` for a complete example. + +### Evaluator Groups + +The batch evaluator runs a fixed group of evaluators together. The current available group is: + +- **text-complexity**: Runs vocabulary complexity, sentence structure, and grade-level appropriateness evaluators together (requires both Google and OpenAI API keys). Maximum 100 input rows. If you exceed the limit, the CLI will exit with an error and suggest splitting into smaller batches. + +### Output Files + +Two files are generated: + +1. **CSV** (`results.csv`): + - Spreadsheet-compatible format + - Original CSV columns preserved, followed by `{evaluator}_score`, `{evaluator}_reasoning`, and `{evaluator}_status` columns for each evaluator + +2. **HTML** (`results.html`): + - Summary dashboard with grade-level distribution and text complexity charts + - Full results table with per-evaluator scores and reasoning + - Opens automatically in your default browser after evaluation completes + +During evaluation, real-time progress is displayed: + +``` +Processing evaluations... +████████████░░░░░░░░ 60% (18/30) + ✓ vocabulary: 6/10 successful + ✓ sentence-structure: 6/10 successful + ⏳ grade-level-appropriateness: 6/10 successful + +⏱ Elapsed: 2m 15s | Estimated remaining: 1m 30s +``` + +### API Keys + +You can provide API keys in two ways: +1. **Environment variables**: `GOOGLE_API_KEY`, `OPENAI_API_KEY` — used as defaults in the prompts +2. **Interactive prompts**: Enter when prompted (keys are masked) + +### Graceful Shutdown + +Press `Ctrl+C` during evaluation to gracefully shut down: + +1. **In-flight tasks complete**: Running evaluations finish processing +2. **New tasks cancelled**: Pending tasks are skipped +3. **Partial results saved**: All completed results are saved to `results-partial.*` files +4. **Progress preserved**: No loss of work done so far + +Example: +```bash +# Press Ctrl+C during a long batch evaluation + +⚠️ Shutdown requested. Saving partial results... + (Press Ctrl+C again to force quit) + +✓ Saved 15 results to: + ./batch-results-2024-02-07_14-30-22/ + ├── results-partial.csv + └── results-partial.html +``` + +Press `Ctrl+C` twice to force quit immediately (not recommended — may lose in-flight results). + +--- + +## Development & Testing + +### Running Locally (Before Publishing) + +```bash +# From the SDK root directory +cd sdks/typescript + +# Build the project +npm run build + +# Run the batch CLI directly +node dist/batch/cli.js +``` + +### Testing the Package Locally + +```bash +# Build and pack +npm run build +npm pack +# Creates: learning-commons-evaluators-x.x.x.tgz + +# Test installation in another directory +cd /tmp +npm install /path/to/learning-commons-evaluators-x.x.x.tgz +evaluators-batch +``` diff --git a/sdks/typescript/src/batch/cli.ts b/sdks/typescript/src/batch/cli.ts new file mode 100644 index 0000000..143d031 --- /dev/null +++ b/sdks/typescript/src/batch/cli.ts @@ -0,0 +1,275 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { exec } from 'child_process'; +import prompts from 'prompts'; +import { + BatchEvaluator, + getAvailableGroups, + parseCSV, + formatAsCSV, + formatAsHTML, + type BatchInput, + type ReportMeta, +} from './index.js'; +import { ProgressTracker } from './progress.js'; + +async function main() { + console.log('\n📊 Batch CSV Evaluator\n'); + console.log('This tool will evaluate multiple texts using one or more evaluators.\n'); + + try { + // Step 1: Get CSV file path — parse once inside validate, reuse result + let inputs: BatchInput[] = []; + const { csvPath } = await prompts({ + type: 'text', + name: 'csvPath', + message: 'Where is your CSV file?', + initial: './input.csv', + validate: (value) => { + try { + inputs = parseCSV(value); + return true; + } catch (error) { + return error instanceof Error ? error.message : 'Invalid CSV file'; + } + }, + }); + + if (!csvPath) { + console.log('Cancelled.'); + process.exit(0); + } + + console.log(`\n✓ Found ${inputs.length} rows in CSV\n`); + + // Step 2: Display the evaluator group that will run + // (Only one group currently; when more are added this becomes a selection prompt) + const group = getAvailableGroups()[0]; + console.log(`✓ Evaluator group: ${group.name}`); + console.log(` ${group.description}`); + console.log(` Row limit: ${group.maxInputRows}\n`); + + // Enforce row limit before asking for API keys + if (inputs.length > group.maxInputRows) { + console.error(`❌ Too many rows: ${inputs.length} (max ${group.maxInputRows} for this group)\n`); + console.log('Suggestions:'); + console.log(` • Trim the CSV to ${group.maxInputRows} rows`); + console.log(' • Split into multiple smaller batches\n'); + process.exit(1); + } + + // Step 3: Get API keys required by this group + let googleApiKey: string | undefined; + let openaiApiKey: string | undefined; + + if (group.requiresGoogleKey) { + const result = await prompts({ + type: 'password', + name: 'key', + message: 'Google API Key:', + initial: process.env.GOOGLE_API_KEY || '', + validate: (value) => (value ? true : 'Google API key is required'), + }); + + if (!result.key) { + console.log('Cancelled.'); + process.exit(0); + } + + googleApiKey = result.key; + } + + if (group.requiresOpenAIKey) { + const result = await prompts({ + type: 'password', + name: 'key', + message: 'OpenAI API Key:', + initial: process.env.OPENAI_API_KEY || '', + validate: (value) => (value ? true : 'OpenAI API key is required'), + }); + + if (!result.key) { + console.log('Cancelled.'); + process.exit(0); + } + + openaiApiKey = result.key; + } + + // Step 4: Get output directory (with human-readable timestamp in local time) + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; + const defaultOutputDir = path.join(process.cwd(), `batch-results-${timestamp}`); + + const { outputDir } = await prompts({ + type: 'text', + name: 'outputDir', + message: 'Output directory:', + initial: defaultOutputDir, + validate: (value) => { + const parentDir = path.dirname(value); + if (!fs.existsSync(parentDir)) { + return `Parent directory does not exist: ${parentDir}`; + } + try { + const testFile = path.join(parentDir, '.write-test'); + fs.writeFileSync(testFile, ''); + fs.unlinkSync(testFile); + return true; + } catch (error) { + if (error instanceof Error) { + if (error.message.includes('EACCES')) return `No write permission for directory: ${parentDir}`; + if (error.message.includes('EROFS')) return `Directory is read-only: ${parentDir}`; + return `Cannot write to directory: ${error.message}`; + } + return 'Cannot write to directory'; + } + }, + }); + + if (!outputDir) { + console.log('Cancelled.'); + process.exit(0); + } + + fs.mkdirSync(outputDir, { recursive: true }); + + // Build report metadata + const csvBasename = path.basename(csvPath, path.extname(csvPath)); + const reportMeta: ReportMeta = { + csvPath: path.resolve(csvPath), + groupId: group.id, + reportId: `${csvBasename.replace(/[^a-zA-Z0-9]/g, '_')}_${timestamp}`, + generatedAt: now, + totalInputRows: inputs.length, + }; + + // Step 5: Confirm and run + const totalTasks = inputs.length * group.evaluatorIds.length; + + console.log(`\n📝 Summary:`); + console.log(` Input rows: ${inputs.length}`); + console.log(` Evaluators: ${group.evaluatorIds.length}`); + console.log(` Total tasks: ${totalTasks}`); + console.log(` Output: ${outputDir}\n`); + + const { confirm } = await prompts({ + type: 'confirm', + name: 'confirm', + message: 'Start batch evaluation?', + initial: true, + }); + + if (!confirm) { + console.log('Cancelled.'); + process.exit(0); + } + + // Step 6: Run batch evaluation + console.log('\n' + '='.repeat(60)); + const tracker = new ProgressTracker(totalTasks); + const evaluationStartTime = Date.now(); + + const evaluator = new BatchEvaluator({ + googleApiKey, + openaiApiKey, + concurrency: 3, + maxRetries: 2, + telemetry: false, + }); + + // Handle Ctrl+C gracefully + let isShuttingDown = false; + const handleShutdown = () => { + if (isShuttingDown) { + console.log('\n\n⚠️ Force quit detected. Exiting immediately...'); + process.exit(1); + } + + isShuttingDown = true; + console.log('\n\n⚠️ Shutdown requested. Saving partial results...'); + console.log(' (Press Ctrl+C again to force quit)\n'); + + const partialResults = evaluator.cancel(); + + if (partialResults.length > 0) { + const durationMs = Date.now() - evaluationStartTime; + const partialOutput = { + results: partialResults, + summary: { + totalTasks: partialResults.length, + successful: partialResults.filter((r) => r.status === 'success').length, + failed: partialResults.filter((r) => r.status === 'error').length, + durationMs, + resultsPerEvaluator: {}, + }, + }; + + try { + fs.writeFileSync(path.join(outputDir, 'results-partial.csv'), formatAsCSV(partialOutput)); + fs.writeFileSync(path.join(outputDir, 'results-partial.html'), formatAsHTML(partialOutput, reportMeta)); + + console.log(`✓ Saved ${partialResults.length} results to:`); + console.log(` ${outputDir}/`); + console.log(` ├── results-partial.csv`); + console.log(` └── results-partial.html`); + console.log(); + } catch (error) { + console.error('❌ Error saving partial results:', error instanceof Error ? error.message : String(error)); + } + } else { + console.log('No results to save yet.\n'); + } + + process.exit(0); + }; + + process.on('SIGINT', handleShutdown); + process.on('SIGTERM', handleShutdown); + + let output; + try { + output = await evaluator.evaluate(inputs, group.id, (result) => { + tracker.update(result); + tracker.display(); + }); + } finally { + process.off('SIGINT', handleShutdown); + process.off('SIGTERM', handleShutdown); + } + + tracker.displaySummary(); + + // Step 7: Write output files + try { + fs.writeFileSync(path.join(outputDir, 'results.csv'), formatAsCSV(output)); + fs.writeFileSync(path.join(outputDir, 'results.html'), formatAsHTML(output, reportMeta)); + + console.log('📄 Output files generated:'); + console.log(` ${outputDir}/`); + console.log(` ├── results.csv`); + console.log(` └── results.html`); + console.log(); + + // Open the HTML report in the default browser + const htmlPath = path.join(outputDir, 'results.html'); + try { + const cmd = process.platform === 'win32' ? `start "" "${htmlPath}"` : `open "${htmlPath}"`; + exec(cmd); + } catch { + // Non-fatal — report is still saved + } + } catch (error) { + console.error('\n❌ Error writing output files:'); + if (error instanceof Error) console.error(` ${error.message}`); + console.error('\n⚠️ Evaluation completed but outputs could not be saved.'); + process.exit(1); + } + } catch (error) { + console.error('\n❌ Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +main(); diff --git a/sdks/typescript/src/batch/csv.ts b/sdks/typescript/src/batch/csv.ts new file mode 100644 index 0000000..59c3f82 --- /dev/null +++ b/sdks/typescript/src/batch/csv.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; +import { parse } from 'csv-parse/sync'; +import type { BatchInput } from './types.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function findColumn(row: Record, columnName: string): string | undefined { + const normalizedTarget = columnName.toLowerCase().trim(); + for (const key of Object.keys(row)) { + if (key.toLowerCase().trim() === normalizedTarget) { + return key; + } + } + return undefined; +} + +/** + * Parse a CSV file into BatchInput rows. + * + * Requires columns named "text" and "grade" (case-insensitive, whitespace-trimmed). + * Rows missing either value are silently skipped. + * + * @throws {Error} If the file does not exist, is empty, or is missing required columns + */ +export function parseCSV(csvPath: string): BatchInput[] { + if (!fs.existsSync(csvPath)) { + throw new Error(`CSV file not found: ${csvPath}`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const records = parse(fs.readFileSync(csvPath, 'utf-8'), { + columns: true, + skip_empty_lines: true, + trim: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as Record[]; + + if (records.length === 0) { + throw new Error('CSV file is empty'); + } + + const firstRow = records[0]; + const textColumn = findColumn(firstRow, 'text'); + const gradeColumn = findColumn(firstRow, 'grade'); + + if (!textColumn) { + throw new Error('CSV must have a "text" column (case-insensitive)'); + } + if (!gradeColumn) { + throw new Error('CSV must have a "grade" column (case-insensitive)'); + } + + const inputs: BatchInput[] = []; + + for (let i = 0; i < records.length; i++) { + const row = records[i]; + const text = row[textColumn]; + const grade = row[gradeColumn]; + + if (!text || !grade) { + console.warn(`Warning: skipping row ${i + 2} — missing text or grade`); + continue; + } + + inputs.push({ + text: String(text).trim(), + grade: String(grade).trim(), + rowIndex: i + 2, // 1-based, offset by 1 for the header row + originalRow: row, + }); + } + + return inputs; +} diff --git a/sdks/typescript/src/batch/evaluator.ts b/sdks/typescript/src/batch/evaluator.ts new file mode 100644 index 0000000..a1b5195 --- /dev/null +++ b/sdks/typescript/src/batch/evaluator.ts @@ -0,0 +1,305 @@ +import pLimit from 'p-limit'; +import { + VocabularyEvaluator, + SentenceStructureEvaluator, + GradeLevelAppropriatenessEvaluator, +} from '../evaluators/index.js'; +import type { BaseEvaluatorConfig } from '../evaluators/base.js'; +import type { EvaluationResult } from '../schemas/index.js'; +import type { + BatchInput, + BatchTask, + BatchResult, + BatchOutput, + BatchConfig, + BatchSummary, + EvaluatorGroup, +} from './types.js'; + +interface SimpleEvaluator { + evaluate(text: string, grade: string): Promise>; +} + +type EvaluatorConstructor = new (config: BaseEvaluatorConfig) => SimpleEvaluator; + +/** + * Map of evaluator IDs to their constructors — internal to this module. + */ +const EVALUATOR_MAP = new Map([ + [VocabularyEvaluator.metadata.id, VocabularyEvaluator], + [SentenceStructureEvaluator.metadata.id, SentenceStructureEvaluator], + [GradeLevelAppropriatenessEvaluator.metadata.id, GradeLevelAppropriatenessEvaluator], +]); + +/** + * Evaluator groups available for batch processing. + * Each group runs a fixed set of evaluators and maps to a specific HTML report format. + */ +const EVALUATOR_GROUPS: EvaluatorGroup[] = [ + { + id: 'text-complexity', + name: 'Text Complexity Analysis', + description: 'Evaluates vocabulary complexity, sentence structure, and grade-level appropriateness', + evaluatorIds: [ + VocabularyEvaluator.metadata.id, + SentenceStructureEvaluator.metadata.id, + GradeLevelAppropriatenessEvaluator.metadata.id, + ], + requiresGoogleKey: true, + requiresOpenAIKey: true, + maxInputRows: 100, + }, +]; + +/** + * Returns the available evaluator groups. + */ +export function getAvailableGroups(): EvaluatorGroup[] { + return [...EVALUATOR_GROUPS]; +} + +/** + * Batch evaluator class + * + * Processes multiple texts in parallel using all evaluators in a group. + */ +export class BatchEvaluator { + private config: BatchConfig; + private limit: ReturnType; + private evaluatorInstances = new Map(); + private isCancelled = false; + private completedResults: BatchResult[] = []; + + constructor(config: BatchConfig) { + this.config = { + concurrency: 3, + maxRetries: 2, + telemetry: false, + ...config, + }; + + this.limit = pLimit(this.config.concurrency!); + } + + /** + * Cancel ongoing evaluation. + * Returns partial results collected so far. + */ + cancel(): BatchResult[] { + this.isCancelled = true; + return [...this.completedResults]; + } + + /** + * Initialize evaluator instances for the given IDs + */ + private initializeEvaluators(evaluatorIds: readonly string[]): void { + for (const id of evaluatorIds) { + if (this.evaluatorInstances.has(id)) continue; + + const EvaluatorClass = EVALUATOR_MAP.get(id); + if (!EvaluatorClass) { + throw new Error(`Unknown evaluator: ${id}`); + } + + const evaluator = new EvaluatorClass({ + googleApiKey: this.config.googleApiKey, + openaiApiKey: this.config.openaiApiKey, + maxRetries: this.config.maxRetries, + telemetry: this.config.telemetry, + }); + + this.evaluatorInstances.set(id, evaluator); + } + } + + /** + * Create tasks from inputs and evaluator IDs + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private createTasks(inputs: BatchInput[], evaluatorIds: readonly string[]): Array }> { + const tasks: Array }> = []; + + for (const input of inputs) { + for (const evaluatorId of evaluatorIds) { + tasks.push({ + text: input.text, + grade: input.grade, + evaluatorId, + rowIndex: input.rowIndex, + originalRow: input.originalRow, + }); + } + } + + return tasks; + } + + /** + * Execute a single evaluation task + */ + private async executeTask( + task: BatchTask & { originalRow: Record }, + onProgress?: (result: BatchResult) => void + ): Promise { + // Check if cancelled before starting + if (this.isCancelled) { + const batchResult: BatchResult = { + rowIndex: task.rowIndex, + text: task.text, + grade: task.grade, + evaluatorId: task.evaluatorId, + status: 'error', + error: 'Cancelled by user', + processingTimeMs: 0, + originalRow: task.originalRow, + }; + return batchResult; + } + + const startTime = Date.now(); + const evaluator = this.evaluatorInstances.get(task.evaluatorId); + + if (!evaluator) { + const batchResult: BatchResult = { + rowIndex: task.rowIndex, + text: task.text, + grade: task.grade, + evaluatorId: task.evaluatorId, + status: 'error', + error: `Evaluator not initialized: ${task.evaluatorId}`, + processingTimeMs: 0, + originalRow: task.originalRow, + }; + this.completedResults.push(batchResult); + if (onProgress) onProgress(batchResult); + return batchResult; + } + + try { + const result = await evaluator.evaluate(task.text, task.grade); + + const batchResult: BatchResult = { + rowIndex: task.rowIndex, + text: task.text, + grade: task.grade, + evaluatorId: task.evaluatorId, + status: 'success', + score: result.score, + reasoning: result.reasoning, + processingTimeMs: Date.now() - startTime, + originalRow: task.originalRow, + }; + + // Store completed result + this.completedResults.push(batchResult); + + // Report progress + if (onProgress) onProgress(batchResult); + + return batchResult; + } catch (error) { + const batchResult: BatchResult = { + rowIndex: task.rowIndex, + text: task.text, + grade: task.grade, + evaluatorId: task.evaluatorId, + status: 'error', + error: error instanceof Error ? error.message : String(error), + processingTimeMs: Date.now() - startTime, + originalRow: task.originalRow, + }; + + // Store completed result (even errors) + this.completedResults.push(batchResult); + + // Report progress + if (onProgress) onProgress(batchResult); + + return batchResult; + } + } + + /** + * Calculate summary statistics + */ + private calculateSummary(results: BatchResult[], durationMs: number): BatchSummary { + const summary: BatchSummary = { + totalTasks: results.length, + successful: results.filter((r) => r.status === 'success').length, + failed: results.filter((r) => r.status === 'error').length, + durationMs, + resultsPerEvaluator: {}, + }; + + // Calculate per-evaluator stats + const evaluatorIds = Array.from(new Set(results.map((r) => r.evaluatorId))); + for (const id of evaluatorIds) { + const evalResults = results.filter((r) => r.evaluatorId === id); + summary.resultsPerEvaluator[id] = { + successful: evalResults.filter((r) => r.status === 'success').length, + failed: evalResults.filter((r) => r.status === 'error').length, + }; + } + + return summary; + } + + /** + * Run batch evaluation for an evaluator group. + * + * @param inputs - Array of input rows + * @param groupId - The evaluator group to run (see getAvailableGroups()) + * @param onProgress - Optional callback invoked after each task completes + * @returns Batch evaluation results and summary + */ + async evaluate( + inputs: BatchInput[], + groupId: string, + onProgress?: (result: BatchResult) => void + ): Promise { + const startTime = Date.now(); + + // Resolve group + const group = EVALUATOR_GROUPS.find((g) => g.id === groupId); + if (!group) { + throw new Error( + `Unknown evaluator group: "${groupId}". Available: ${EVALUATOR_GROUPS.map((g) => g.id).join(', ')}` + ); + } + + // Enforce per-group row limit + if (inputs.length > group.maxInputRows) { + throw new Error( + `Input exceeds limit for "${group.id}": ${inputs.length} rows (max ${group.maxInputRows}). Split into smaller batches.` + ); + } + + // Reset state + this.isCancelled = false; + this.completedResults = []; + + // Initialize evaluator instances + this.initializeEvaluators(group.evaluatorIds); + + // Create all tasks (flattened: inputs × evaluators) + const tasks = this.createTasks(inputs, group.evaluatorIds); + + // Execute all tasks with concurrency control + // Use allSettled to get partial results even if cancelled + const settledResults = await Promise.allSettled( + tasks.map((task) => this.limit(() => this.executeTask(task, onProgress))) + ); + + // Extract fulfilled results (skip rejected) + const results = settledResults + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); + + // Calculate summary + const durationMs = Date.now() - startTime; + const summary = this.calculateSummary(results, durationMs); + + return { results, summary }; + } +} diff --git a/sdks/typescript/src/batch/formatters.ts b/sdks/typescript/src/batch/formatters.ts new file mode 100644 index 0000000..3aef089 --- /dev/null +++ b/sdks/typescript/src/batch/formatters.ts @@ -0,0 +1,364 @@ +import type { BatchOutput, BatchResult } from './types.js'; +import reportTemplate from './report-template.html'; + +// ---- Constants ---- + +const GLA_EVALUATOR_ID = 'grade-level-appropriateness'; + +const GRADE_BANDS = ['K-1', '2-3', '4-5', '6-8', '9-10', '11-CCR'] as const; +type GradeBand = typeof GRADE_BANDS[number]; + +// Complexity string scores → numeric (supports both Title Case and lowercase from evaluators) +const COMPLEXITY_SCORE_MAP: Record = { + 'slightly complex': 1, + 'moderately complex': 2, + 'very complex': 3, + 'exceedingly complex': 4, +}; + +// ---- Helpers ---- + +function evaluatorDisplayName(id: string): string { + return id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +} + +/** Maps a raw grade string (K, 1, 2 … 12, CCR) to a GRADE_BANDS index (0–5). */ +function gradeToBandIndex(grade: string): number { + const g = String(grade).trim().toUpperCase().replace(/^0+/, ''); + if (g === 'K' || g === 'KINDERGARTEN') return 0; + if (g === '1') return 0; + if (g === '2' || g === '3') return 1; + if (g === '4' || g === '5') return 2; + if (g === '6' || g === '7' || g === '8') return 3; + if (g === '9' || g === '10') return 4; + if (g === '11' || g === '12' || g === 'CCR') return 5; + return -1; +} + +/** Maps a GLA score string (e.g. "4-5") to a GRADE_BANDS index. */ +function glaBandToIndex(band: string): number { + return GRADE_BANDS.indexOf(band as GradeBand); +} + +function getGLAStatus(inputGrade: string, glaBand: string): 'on-band' | 'adjacent' | 'off-target' { + const inputIdx = gradeToBandIndex(inputGrade); + const glaIdx = glaBandToIndex(glaBand); + if (inputIdx === -1 || glaIdx === -1) return 'off-target'; + const diff = Math.abs(inputIdx - glaIdx); + if (diff === 0) return 'on-band'; + if (diff === 1) return 'adjacent'; + return 'off-target'; +} + +function complexityToNumeric(score: string): number | undefined { + return COMPLEXITY_SCORE_MAP[score.toLowerCase().trim()]; +} + +function complexityScoreLabel(avg: number): string { + if (avg < 1.5) return 'Slightly Complex'; + if (avg < 2.5) return 'Moderately Complex'; + if (avg < 3.5) return 'Very Complex'; + return 'Exceedingly Complex'; +} + +/** Stub — returns hard-coded insights. Replace with real logic later. */ +function generateInsights(): string[] { + return [ + 'Review texts marked as Off Target — they may need content revision or grade-level adjustment before distribution.', + 'Texts evaluated as Adjacent may benefit from light scaffolding strategies such as vocabulary pre-teaching.', + 'Higher grade bands tend to show greater text complexity. Consider whether complexity aligns with instructional goals.', + ]; +} + +// ---- Shared grouping utility ---- + +function groupResultsByRow(results: BatchResult[]): Map { + const grouped = new Map(); + for (const result of results) { + if (!grouped.has(result.rowIndex)) { + grouped.set(result.rowIndex, []); + } + grouped.get(result.rowIndex)!.push(result); + } + return grouped; +} + +// ---- CSV Formatter ---- + +function formatEvaluatorPrefix(evaluatorId: string): string { + return evaluatorId.replace(/-/g, '_'); +} + +function escapeCSV(field: string): string { + if (field.includes(',') || field.includes('"') || field.includes('\n')) { + return `"${field.replace(/"/g, '""')}"`; + } + return field; +} + +export function formatAsCSV(output: BatchOutput): string { + if (output.results.length === 0) { + return ''; + } + + const groupedByRow = groupResultsByRow(output.results); + const evaluatorIds = Array.from(new Set(output.results.map(r => r.evaluatorId))).sort(); + const firstResult = output.results[0]; + const originalColumns = Object.keys(firstResult.originalRow); + + const evaluatorColumns: string[] = []; + for (const evalId of evaluatorIds) { + const prefix = formatEvaluatorPrefix(evalId); + evaluatorColumns.push(`${prefix}_score`); + evaluatorColumns.push(`${prefix}_reasoning`); + evaluatorColumns.push(`${prefix}_status`); + } + const headers = [...originalColumns, ...evaluatorColumns]; + + const rows: string[][] = []; + const sortedRowIndices = Array.from(groupedByRow.keys()).sort((a, b) => a - b); + + for (const rowIndex of sortedRowIndices) { + const resultsForRow = groupedByRow.get(rowIndex)!; + const firstResultForRow = resultsForRow[0]; + + const originalValues = originalColumns.map(col => + escapeCSV(String(firstResultForRow.originalRow[col] || '')) + ); + + const evaluatorValues: string[] = []; + for (const evalId of evaluatorIds) { + const result = resultsForRow.find(r => r.evaluatorId === evalId); + if (result) { + evaluatorValues.push(result.status === 'success' ? escapeCSV(result.score || '') : ''); + evaluatorValues.push(result.status === 'success' + ? escapeCSV(result.reasoning || '') + : escapeCSV(result.error || '')); + evaluatorValues.push(result.status); + } else { + evaluatorValues.push('', '', 'not_run'); + } + } + + rows.push([...originalValues, ...evaluatorValues]); + } + + return [headers, ...rows].map(row => row.join(',')).join('\n'); +} + +// ---- HTML Formatter ---- + +export interface ReportMeta { + csvPath: string; + groupId: string; + reportId: string; + generatedAt: Date; + totalInputRows: number; +} + +export function formatAsHTML(output: BatchOutput, meta: ReportMeta): string { + const { results } = output; + const byRow = groupResultsByRow(results); + const allRowIndices = Array.from(byRow.keys()).sort((a, b) => a - b); + + const allEvaluatorIds = Array.from(new Set(results.map(r => r.evaluatorId))).sort(); + const hasGLA = allEvaluatorIds.includes(GLA_EVALUATOR_ID); + const complexityIds = allEvaluatorIds.filter(id => id !== GLA_EVALUATOR_ID); + + // ---- Snapshot ---- + let processedRows = 0; + let erroredRows = 0; + for (const rowResults of byRow.values()) { + if (rowResults.some(r => r.status === 'error')) erroredRows++; + else processedRows++; + } + + // ---- GLA stats ---- + const glaCounts = { onBand: 0, adjacent: 0, offTarget: 0 }; + const rowGLAStatus = new Map(); + + if (hasGLA) { + for (const [rowIndex, rowResults] of byRow) { + const glaResult = rowResults.find(r => r.evaluatorId === GLA_EVALUATOR_ID); + if (glaResult && glaResult.status === 'success' && glaResult.score) { + const status = getGLAStatus(glaResult.grade, glaResult.score); + rowGLAStatus.set(rowIndex, { status, band: glaResult.score, reasoning: glaResult.reasoning || '' }); + if (status === 'on-band') glaCounts.onBand++; + else if (status === 'adjacent') glaCounts.adjacent++; + else glaCounts.offTarget++; + } + } + } + + const glaTotal = glaCounts.onBand + glaCounts.adjacent + glaCounts.offTarget; + const pct = (n: number) => glaTotal > 0 ? Math.round((n / glaTotal) * 100) : 0; + + // ---- Complexity stats per evaluator ---- + const complexityStats = complexityIds.map(evalId => { + const scores: number[] = []; + const distribution: [number, number, number, number] = [0, 0, 0, 0]; + + for (const rowResults of byRow.values()) { + const r = rowResults.find(x => x.evaluatorId === evalId); + if (r && r.status === 'success' && r.score) { + const num = complexityToNumeric(r.score); + if (num !== undefined) { + scores.push(num); + distribution[num - 1]++; + } + } + } + + const avg = scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0; + return { + evaluatorId: evalId, + name: evaluatorDisplayName(evalId), + average: Math.round(avg * 10) / 10, + label: avg > 0 ? complexityScoreLabel(avg) : 'N/A', + distribution, + }; + }); + + // ---- Grade band distribution (GLA status per input grade band) ---- + const bandDist = GRADE_BANDS.map(() => ({ onBand: 0, adjacent: 0, offTarget: 0, total: 0 })); + + for (const [rowIndex, rowResults] of byRow) { + const firstResult = rowResults[0]; + if (!firstResult) continue; + const bandIdx = gradeToBandIndex(firstResult.grade); + if (bandIdx === -1) continue; + + const glaStatus = rowGLAStatus.get(rowIndex); + if (glaStatus) { + bandDist[bandIdx].total++; + if (glaStatus.status === 'on-band') bandDist[bandIdx].onBand++; + else if (glaStatus.status === 'adjacent') bandDist[bandIdx].adjacent++; + else bandDist[bandIdx].offTarget++; + } + } + + // ---- Complexity heatmap: avg score per [grade band][evaluator] ---- + const hmSums: number[][] = GRADE_BANDS.map(() => complexityIds.map(() => 0)); + const hmCounts: number[][] = GRADE_BANDS.map(() => complexityIds.map(() => 0)); + + for (const rowResults of byRow.values()) { + const firstResult = rowResults[0]; + if (!firstResult) continue; + const bandIdx = gradeToBandIndex(firstResult.grade); + if (bandIdx === -1) continue; + + complexityIds.forEach((evalId, evalIdx) => { + const r = rowResults.find(x => x.evaluatorId === evalId); + if (r && r.status === 'success' && r.score) { + const num = complexityToNumeric(r.score); + if (num !== undefined) { + hmSums[bandIdx][evalIdx] += num; + hmCounts[bandIdx][evalIdx]++; + } + } + }); + } + + const heatmapValues: (number | null)[][] = GRADE_BANDS.map((_, bi) => + complexityIds.map((_, ei) => { + const count = hmCounts[bi][ei]; + return count > 0 ? Math.round((hmSums[bi][ei] / count) * 10) / 10 : null; + }) + ); + + // ---- Full results rows ---- + const firstRowResults = allRowIndices.length > 0 ? (byRow.get(allRowIndices[0]) ?? []) : []; + const originalColumns = firstRowResults.length > 0 ? Object.keys(firstRowResults[0].originalRow) : []; + + const fullResultsRows = allRowIndices.map(rowIndex => { + const rowResults = byRow.get(rowIndex)!; + const firstResult = rowResults[0]; + const row: Record = {}; + + for (const col of originalColumns) { + row[col] = String(firstResult.originalRow[col] ?? ''); + } + + const glaStatus = rowGLAStatus.get(rowIndex); + const glaLabels = { 'on-band': 'On Band', 'adjacent': 'Adjacent', 'off-target': 'Off Target' } as const; + row['__gla_status'] = glaStatus ? glaLabels[glaStatus.status] : (hasGLA ? 'Error' : ''); + row['__gla_band'] = glaStatus?.band ?? ''; + row['__gla_reasoning'] = glaStatus?.reasoning ?? ''; + + for (const evalId of complexityIds) { + const r = rowResults.find(x => x.evaluatorId === evalId); + const prefix = `__${evalId.replace(/-/g, '_')}`; + row[`${prefix}_score`] = r?.status === 'success' ? (r.score ?? '') : (r?.status === 'error' ? 'Error' : ''); + row[`${prefix}_reasoning`] = r?.status === 'success' ? (r.reasoning ?? '') : (r?.error ?? ''); + } + + return row; + }); + + // ---- Assemble report data ---- + const reportData = { + meta: { + reportId: meta.reportId, + generatedAt: meta.generatedAt.toLocaleString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true, + }), + csvPath: meta.csvPath, + groupId: meta.groupId, + evaluatorNames: allEvaluatorIds.map(evaluatorDisplayName), + totalRows: meta.totalInputRows, + processedRows, + erroredRows, + }, + gradeLevelStats: { + onBand: glaCounts.onBand, + adjacent: glaCounts.adjacent, + offTarget: glaCounts.offTarget, + onBandPct: pct(glaCounts.onBand), + adjacentPct: pct(glaCounts.adjacent), + offTargetPct: pct(glaCounts.offTarget), + hasData: glaTotal > 0, + }, + complexityStats, + gradeBandDistribution: { + bands: [...GRADE_BANDS], + data: bandDist, + }, + complexityHeatmap: { + bands: [...GRADE_BANDS], + evaluators: complexityIds.map(evaluatorDisplayName), + evaluatorIds: complexityIds, + values: heatmapValues, + }, + insights: generateInsights(), + fullResults: { + originalColumns, + hasGLA, + complexityEvaluators: complexityIds.map(id => ({ + evaluatorId: id, + name: evaluatorDisplayName(id), + prefix: id.replace(/-/g, '_'), + })), + rows: fullResultsRows, + }, + }; + + // Inject serialized data into the template. + // Unicode-escape < > & so the JSON is safe inside a injection). + const safeJson = JSON.stringify(reportData) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026'); + + const INJECTION_MARKER = 'var REPORT_DATA = null; // __REPLACED_BY_FORMATTER__'; + if (!reportTemplate.includes(INJECTION_MARKER)) { + throw new Error('Report template injection marker not found — template may be corrupted'); + } + + return reportTemplate.replace(INJECTION_MARKER, `var REPORT_DATA = ${safeJson};`); +} diff --git a/sdks/typescript/src/batch/index.ts b/sdks/typescript/src/batch/index.ts new file mode 100644 index 0000000..4cf2be9 --- /dev/null +++ b/sdks/typescript/src/batch/index.ts @@ -0,0 +1,29 @@ +/** + * Batch evaluation module + * + * Programmatic API for running an evaluator group over a set of texts. + * + * @example + * ```typescript + * import { BatchEvaluator, getAvailableGroups, parseCSV, formatAsCSV } from '@learning-commons/evaluators/batch'; + * + * const [group] = getAvailableGroups(); // 'text-complexity' + * const inputs = parseCSV('./texts.csv'); + * const evaluator = new BatchEvaluator({ googleApiKey, openaiApiKey }); + * const output = await evaluator.evaluate(inputs, group.id); + * console.log(formatAsCSV(output)); + * ``` + */ + +export { BatchEvaluator, getAvailableGroups } from './evaluator.js'; +export { parseCSV } from './csv.js'; +export { formatAsCSV, formatAsHTML } from './formatters.js'; +export type { ReportMeta } from './formatters.js'; +export type { + EvaluatorGroup, + BatchInput, + BatchResult, + BatchOutput, + BatchConfig, + BatchSummary, +} from './types.js'; diff --git a/sdks/typescript/src/batch/progress.ts b/sdks/typescript/src/batch/progress.ts new file mode 100644 index 0000000..5b9eb1b --- /dev/null +++ b/sdks/typescript/src/batch/progress.ts @@ -0,0 +1,167 @@ +import type { BatchResult } from './types.js'; + +/** + * Progress tracker for batch evaluation + */ +export class ProgressTracker { + private totalTasks: number; + private completed = 0; + private successful = 0; + private failed = 0; + private startTime: number; + private perEvaluator = new Map(); + + constructor(totalTasks: number) { + this.totalTasks = totalTasks; + this.startTime = Date.now(); + } + + /** + * Update progress with a new result + */ + update(result: BatchResult): void { + this.completed++; + + if (result.status === 'success') { + this.successful++; + } else { + this.failed++; + } + + // Track per-evaluator stats + if (!this.perEvaluator.has(result.evaluatorId)) { + this.perEvaluator.set(result.evaluatorId, { completed: 0, successful: 0, failed: 0 }); + } + + const stats = this.perEvaluator.get(result.evaluatorId)!; + stats.completed++; + if (result.status === 'success') { + stats.successful++; + } else { + stats.failed++; + } + } + + /** + * Get current progress percentage + */ + getPercentage(): number { + return Math.round((this.completed / this.totalTasks) * 100); + } + + /** + * Get elapsed time in seconds + */ + getElapsedSeconds(): number { + return Math.round((Date.now() - this.startTime) / 1000); + } + + /** + * Estimate remaining time in seconds + */ + getEstimatedRemainingSeconds(): number { + if (this.completed === 0) return 0; + + const elapsed = Date.now() - this.startTime; + const avgTimePerTask = elapsed / this.completed; + const remaining = this.totalTasks - this.completed; + + return Math.round((avgTimePerTask * remaining) / 1000); + } + + /** + * Format elapsed time as human-readable string + */ + formatElapsed(): string { + const seconds = this.getElapsedSeconds(); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } + + /** + * Format estimated remaining time as human-readable string + */ + formatEstimatedRemaining(): string { + const seconds = this.getEstimatedRemainingSeconds(); + if (seconds < 60) return `${seconds}s`; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + } + + /** + * Generate progress bar + */ + getProgressBar(width = 20): string { + const percentage = this.getPercentage(); + const filled = Math.round((percentage / 100) * width); + const empty = width - filled; + + return '█'.repeat(filled) + '░'.repeat(empty); + } + + /** + * Display progress in terminal + */ + display(): void { + // Clear previous lines (move cursor up and clear) + if (this.completed > 1) { + const linesToClear = 3 + this.perEvaluator.size; + process.stdout.write(`\x1b[${linesToClear}A`); // Move cursor up + process.stdout.write('\x1b[J'); // Clear from cursor to end of screen + } + + console.log('\nProcessing evaluations...'); + console.log( + `${this.getProgressBar()} ${this.getPercentage()}% (${this.completed}/${this.totalTasks})` + ); + + // Show per-evaluator progress + for (const [evalId, stats] of this.perEvaluator.entries()) { + const status = + stats.completed === stats.successful + ? '✓' + : stats.failed > 0 + ? '✗' + : '⏳'; + console.log( + ` ${status} ${evalId}: ${stats.successful}/${stats.completed} successful` + ); + } + + console.log( + `\n⏱ Elapsed: ${this.formatElapsed()} | Estimated remaining: ${this.formatEstimatedRemaining()}` + ); + } + + /** + * Display final summary + */ + displaySummary(): void { + // Clear progress display + const linesToClear = 3 + this.perEvaluator.size + 1; + process.stdout.write(`\x1b[${linesToClear}A`); + process.stdout.write('\x1b[J'); + + console.log('\n✅ Batch evaluation completed!\n'); + console.log(`Total tasks: ${this.totalTasks}`); + console.log(`Successful: ${this.successful} ✓`); + console.log(`Failed: ${this.failed} ✗`); + console.log(`Duration: ${this.formatElapsed()}`); + + // Show per-evaluator summary + if (this.perEvaluator.size > 1) { + console.log('\nResults per evaluator:'); + for (const [evalId, stats] of this.perEvaluator.entries()) { + console.log( + ` ${evalId}: ${stats.successful} successful, ${stats.failed} failed` + ); + } + } + console.log(); + } +} diff --git a/sdks/typescript/src/batch/report-template.html b/sdks/typescript/src/batch/report-template.html new file mode 100644 index 0000000..885a0a1 --- /dev/null +++ b/sdks/typescript/src/batch/report-template.html @@ -0,0 +1,914 @@ + + + + + + Evaluation Report + + + + + +
+ + + +
+
+ + + + diff --git a/sdks/typescript/src/batch/types.ts b/sdks/typescript/src/batch/types.ts new file mode 100644 index 0000000..ba3dea1 --- /dev/null +++ b/sdks/typescript/src/batch/types.ts @@ -0,0 +1,86 @@ +/** + * Batch evaluation types + */ +import type { TelemetryOptions } from '../evaluators/base.js'; + +/** + * Input row from CSV + */ +export interface BatchInput { + text: string; + grade: string; + rowIndex: number; + originalRow: Record; // Preserve all original CSV columns +} + +/** + * Individual evaluation task + */ +export interface BatchTask { + text: string; + grade: string; + evaluatorId: string; + rowIndex: number; +} + +/** + * Result from a single evaluation + */ +export interface BatchResult { + rowIndex: number; + text: string; + grade: string; + evaluatorId: string; + status: 'success' | 'error'; + score?: string; + reasoning?: string; + error?: string; + processingTimeMs: number; + originalRow: Record; // Preserve all original CSV columns +} + +/** + * Summary statistics for batch evaluation + */ +export interface BatchSummary { + totalTasks: number; + successful: number; + failed: number; + durationMs: number; + resultsPerEvaluator: Record; +} + +/** + * Complete batch evaluation output + */ +export interface BatchOutput { + results: BatchResult[]; + summary: BatchSummary; +} + +/** + * A named group of evaluators that run together and share an HTML report format. + * This is the unit of selection exposed to users. + */ +export interface EvaluatorGroup { + id: string; + name: string; + description: string; + /** IDs of the evaluators that belong to this group */ + evaluatorIds: readonly string[]; + requiresGoogleKey: boolean; + requiresOpenAIKey: boolean; + /** Maximum number of input rows allowed for this group */ + maxInputRows: number; +} + +/** + * Configuration for batch evaluation + */ +export interface BatchConfig { + googleApiKey?: string; + openaiApiKey?: string; + concurrency?: number; + maxRetries?: number; + telemetry?: boolean | TelemetryOptions; +} diff --git a/sdks/typescript/src/evaluators/base.ts b/sdks/typescript/src/evaluators/base.ts index d4e48d6..17fe05b 100644 --- a/sdks/typescript/src/evaluators/base.ts +++ b/sdks/typescript/src/evaluators/base.ts @@ -290,7 +290,7 @@ export abstract class BaseEvaluator { // Sort K first, then numerically if (a === 'K') return -1; if (b === 'K') return 1; - return parseInt(a) - parseInt(b); + return parseInt(a, 10) - parseInt(b, 10); }).join(', '); throw new ValidationError( diff --git a/sdks/typescript/src/types/html.d.ts b/sdks/typescript/src/types/html.d.ts new file mode 100644 index 0000000..448f7d1 --- /dev/null +++ b/sdks/typescript/src/types/html.d.ts @@ -0,0 +1,4 @@ +declare module '*.html' { + const content: string; + export default content; +} diff --git a/sdks/typescript/tests/fixtures/batch-test.csv b/sdks/typescript/tests/fixtures/batch-test.csv new file mode 100644 index 0000000..3a498a5 --- /dev/null +++ b/sdks/typescript/tests/fixtures/batch-test.csv @@ -0,0 +1,3 @@ +row_id,TEXT, Grade ,source,category +1,"The cat sat on the mat. It was a warm, sunny day.",3,textbook,simple +2,"The photosynthesis process converts light energy into chemical energy.",5,science,biology diff --git a/sdks/typescript/tests/integration/batch.integration.test.ts b/sdks/typescript/tests/integration/batch.integration.test.ts new file mode 100644 index 0000000..8ac2bfb --- /dev/null +++ b/sdks/typescript/tests/integration/batch.integration.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import * as path from 'path'; +import { BatchEvaluator, getAvailableGroups, parseCSV } from '../../src/batch/index.js'; +import type { BatchInput } from '../../src/batch/index.js'; + +/** + * Batch Evaluator Integration Tests + * + * Lightweight integration test with 2 rows and 1 evaluator (sentence-structure). + * Verifies the full batch evaluation flow works end-to-end with real API calls. + * + * To run: + * ```bash + * RUN_INTEGRATION_TESTS=true npm run test:integration + * ``` + */ + +// text-complexity group requires both keys +const SKIP_INTEGRATION = !process.env.RUN_INTEGRATION_TESTS || + !process.env.OPENAI_API_KEY || + !process.env.GOOGLE_API_KEY; + +const describeIntegration = SKIP_INTEGRATION ? describe.skip : describe; + +// Test timeout: 2 minutes (generous for API calls) +const TEST_TIMEOUT_MS = 2 * 60 * 1000; + +describeIntegration('Batch Evaluator - Integration', () => { + let evaluator: BatchEvaluator; + + beforeAll(() => { + if (SKIP_INTEGRATION) { + console.log('⏭️ Skipping batch integration tests (set RUN_INTEGRATION_TESTS=true and provide both GOOGLE_API_KEY and OPENAI_API_KEY)'); + return; + } + + evaluator = new BatchEvaluator({ + googleApiKey: process.env.GOOGLE_API_KEY!, + openaiApiKey: process.env.OPENAI_API_KEY!, + concurrency: 3, + maxRetries: 2, + telemetry: false, + }); + + const group = getAvailableGroups().find((g) => g.id === 'text-complexity')!; + console.log('\n' + '='.repeat(80)); + console.log('BATCH EVALUATOR - INTEGRATION TEST'); + console.log('='.repeat(80)); + console.log(`Group: ${group.name} (${group.evaluatorIds.join(', ')})`); + console.log('='.repeat(80)); + }); + + it( + 'should process sample CSV end-to-end', + async () => { + const csvPath = path.join(__dirname, '../fixtures/batch-test.csv'); + const inputs: BatchInput[] = parseCSV(csvPath); + + console.log(`\n📊 Processing ${inputs.length} rows...`); + + const startTime = Date.now(); + const group = getAvailableGroups().find((g) => g.id === 'text-complexity')!; + const output = await evaluator.evaluate(inputs, group.id, (result) => { + console.log(` ✓ Row ${result.rowIndex} [${result.evaluatorId}] - ${result.status}: ${result.score || result.error}`); + }); + const duration = Date.now() - startTime; + + console.log(`\n⏱ Completed in ${Math.round(duration / 1000)}s\n`); + + // Verify results structure + expect(output).toBeDefined(); + expect(output.results).toBeDefined(); + expect(output.summary).toBeDefined(); + + // Should have 2 rows × 3 evaluators = 6 results + expect(output.results).toHaveLength(inputs.length * group.evaluatorIds.length); + + // Verify each result has expected fields + for (const result of output.results) { + expect(result.rowIndex).toBeGreaterThan(0); + expect(result.text).toBeTruthy(); + expect(result.grade).toBeTruthy(); + expect(group.evaluatorIds).toContain(result.evaluatorId); + expect(result.status).toMatch(/success|error/); + expect(result.processingTimeMs).toBeGreaterThan(0); + + if (result.status === 'success') { + expect(result.score).toBeTruthy(); + expect(result.reasoning).toBeTruthy(); + } else { + expect(result.error).toBeTruthy(); + } + } + + // Verify summary — 2 rows × 3 evaluators = 6 tasks + const expectedTasks = inputs.length * group.evaluatorIds.length; + expect(output.summary.totalTasks).toBe(expectedTasks); + expect(output.summary.successful + output.summary.failed).toBe(expectedTasks); + expect(output.summary.durationMs).toBeGreaterThan(0); + for (const id of group.evaluatorIds) { + expect(output.summary.resultsPerEvaluator).toHaveProperty(id); + } + + // Log summary + console.log('📊 Summary:'); + console.log(` Total: ${output.summary.totalTasks}`); + console.log(` Successful: ${output.summary.successful} ✓`); + console.log(` Failed: ${output.summary.failed} ✗`); + console.log(` Duration: ${Math.round(output.summary.durationMs / 1000)}s`); + + // At least 1 should succeed (allow for occasional API issues) + expect(output.summary.successful).toBeGreaterThan(0); + }, + TEST_TIMEOUT_MS + ); + + it( + 'should run all evaluators in the group and include each in results', + async () => { + const group = getAvailableGroups().find((g) => g.id === 'text-complexity')!; + + // Single row — verify all group evaluators ran + const inputs: BatchInput[] = [ + { text: 'The cat sat on the mat.', grade: '3', rowIndex: 1, originalRow: { text: 'The cat sat on the mat.', grade: '3' } }, + ]; + + console.log(`\n📊 Processing 1 row with ${group.evaluatorIds.length} evaluators...`); + + const output = await evaluator.evaluate(inputs, group.id, (result) => { + console.log(` ✓ ${result.evaluatorId} - ${result.status}: ${result.score || result.error}`); + }); + + // Should have 1 result per evaluator in the group + expect(output.results).toHaveLength(group.evaluatorIds.length); + + // Verify every evaluator in the group produced a result + const ranEvaluatorIds = output.results.map((r) => r.evaluatorId); + for (const id of group.evaluatorIds) { + expect(ranEvaluatorIds).toContain(id); + } + + console.log('\n✅ All group evaluators ran\n'); + }, + TEST_TIMEOUT_MS + ); +}); diff --git a/sdks/typescript/tests/unit/batch/csv-parsing.test.ts b/sdks/typescript/tests/unit/batch/csv-parsing.test.ts new file mode 100644 index 0000000..4d28d05 --- /dev/null +++ b/sdks/typescript/tests/unit/batch/csv-parsing.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { parseCSV } from '../../../src/batch/index.js'; + +// ---- Helpers ---- + +function withTempCSV(content: string, fn: (csvPath: string) => void): void { + const tmpPath = path.join(os.tmpdir(), `batch-test-${Date.now()}.csv`); + fs.writeFileSync(tmpPath, content); + try { + fn(tmpPath); + } finally { + fs.unlinkSync(tmpPath); + } +} + +// ---- Tests ---- + +describe('parseCSV', () => { + describe('column detection', () => { + it('finds text and grade columns case-insensitively', () => { + // batch-test.csv has "TEXT" and " Grade " (with whitespace padding) + const csvPath = path.join(__dirname, '../../fixtures/batch-test.csv'); + const inputs = parseCSV(csvPath); + expect(inputs.length).toBeGreaterThan(0); + for (const input of inputs) { + expect(input.text).toBeTruthy(); + expect(input.grade).toBeTruthy(); + } + }); + + it('accepts uppercase TEXT and mixed-case Grade columns', () => { + withTempCSV('TEXT,Grade\nhello world,5', (p) => { + const inputs = parseCSV(p); + expect(inputs).toHaveLength(1); + expect(inputs[0].text).toBe('hello world'); + expect(inputs[0].grade).toBe('5'); + }); + }); + + it('accepts columns with surrounding whitespace in the header', () => { + withTempCSV(' text , grade \nhello world,5', (p) => { + const inputs = parseCSV(p); + expect(inputs).toHaveLength(1); + expect(inputs[0].text).toBe('hello world'); + }); + }); + }); + + describe('parsing the test fixture', () => { + const csvPath = path.join(__dirname, '../../fixtures/batch-test.csv'); + + it('returns one BatchInput per non-empty data row', () => { + expect(parseCSV(csvPath)).toHaveLength(2); + }); + + it('trims text and grade values', () => { + for (const input of parseCSV(csvPath)) { + expect(input.text).toBe(input.text.trim()); + expect(input.grade).toBe(input.grade.trim()); + } + }); + + it('assigns rowIndex matching the 1-based CSV line number (header is line 1)', () => { + const inputs = parseCSV(csvPath); + expect(inputs[0].rowIndex).toBe(2); // first data row is CSV line 2 + expect(inputs[1].rowIndex).toBe(3); // second data row is CSV line 3 + }); + + it('preserves all original CSV columns in originalRow', () => { + const inputs = parseCSV(csvPath); + // batch-test.csv has: row_id, TEXT, Grade, source, category + expect(inputs[0].originalRow).toHaveProperty('row_id'); + expect(inputs[0].originalRow).toHaveProperty('source'); + expect(inputs[0].originalRow).toHaveProperty('category'); + }); + }); + + describe('row filtering', () => { + it('skips rows where text is empty', () => { + withTempCSV('text,grade\nhello world,5\n,4\ngoodbye world,3', (p) => { + const inputs = parseCSV(p); + expect(inputs).toHaveLength(2); + expect(inputs[0].text).toBe('hello world'); + expect(inputs[1].text).toBe('goodbye world'); + }); + }); + + it('skips rows where grade is empty', () => { + withTempCSV('text,grade\nhello world,5\ngoodbye world,', (p) => { + const inputs = parseCSV(p); + expect(inputs).toHaveLength(1); + expect(inputs[0].text).toBe('hello world'); + }); + }); + + it('preserves the original CSV line number in rowIndex even when rows are skipped', () => { + // header=line1, first=line2, skipped=line3, second=line4 + withTempCSV('text,grade\nfirst,3\n,4\nsecond,5', (p) => { + const inputs = parseCSV(p); + expect(inputs).toHaveLength(2); + expect(inputs[0].rowIndex).toBe(2); + expect(inputs[1].rowIndex).toBe(4); // line 3 was skipped + }); + }); + }); + + describe('error cases', () => { + it('throws when file does not exist', () => { + expect(() => parseCSV('/no/such/file.csv')).toThrow('CSV file not found'); + }); + + it('throws when file is empty', () => { + withTempCSV('', (p) => { + expect(() => parseCSV(p)).toThrow('CSV file is empty'); + }); + }); + + it('throws when "text" column is missing', () => { + withTempCSV('grade,content\n5,hello world', (p) => { + expect(() => parseCSV(p)).toThrow('"text" column'); + }); + }); + + it('throws when "grade" column is missing', () => { + withTempCSV('text,level\nhello world,5', (p) => { + expect(() => parseCSV(p)).toThrow('"grade" column'); + }); + }); + }); +}); diff --git a/sdks/typescript/tests/unit/batch/formatters.test.ts b/sdks/typescript/tests/unit/batch/formatters.test.ts new file mode 100644 index 0000000..42d5df5 --- /dev/null +++ b/sdks/typescript/tests/unit/batch/formatters.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect } from 'vitest'; +import { + formatAsCSV, + formatAsHTML, + type ReportMeta, + type BatchOutput, + type BatchResult, +} from '../../../src/batch/index.js'; + +// ---- Test fixtures ---- + +function makeResult(overrides: Partial): BatchResult { + return { + rowIndex: 1, + text: 'Sample text.', + grade: '5', + evaluatorId: 'vocabulary', + status: 'success', + score: 'slightly complex', + reasoning: 'ok', + processingTimeMs: 100, + originalRow: { text: 'Sample text.', grade: '5' }, + ...overrides, + }; +} + +function makeOutput(results: BatchResult[]): BatchOutput { + return { + results, + summary: { + totalTasks: results.length, + successful: results.filter(r => r.status === 'success').length, + failed: results.filter(r => r.status === 'error').length, + durationMs: 1000, + resultsPerEvaluator: {}, + }, + }; +} + +function makeMeta(overrides?: Partial): ReportMeta { + return { + csvPath: '/data/input.csv', + groupId: 'text-complexity', + reportId: 'test_20260301T0000', + generatedAt: new Date('2026-03-01T00:00:00Z'), + totalInputRows: 1, + ...overrides, + }; +} + +/** + * Extracts and parses the REPORT_DATA JSON injected into the HTML by formatAsHTML. + * This lets us make assertions on actual computed values rather than raw string presence. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractReportData(html: string): any { + const marker = 'var REPORT_DATA = '; + const start = html.indexOf(marker) + marker.length; + const line = html.slice(start, html.indexOf('\n', start)); + const json = line.endsWith(';') ? line.slice(0, -1) : line; + return JSON.parse(json); +} + +// ============================================================ +// formatAsCSV +// ============================================================ + +describe('formatAsCSV', () => { + it('returns empty string for empty results', () => { + expect(formatAsCSV(makeOutput([]))).toBe(''); + }); + + it('produces one data row per input row, not per evaluator task', () => { + // Row 1 has two evaluators → should collapse into a single CSV row + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', score: 'slightly complex' }), + makeResult({ rowIndex: 1, evaluatorId: 'sentence-structure', score: 'Moderately Complex' }), + ]); + + const lines = formatAsCSV(output).split('\n'); + expect(lines).toHaveLength(2); // 1 header + 1 data row + }); + + it('places evaluator columns in alphabetical order after original columns', () => { + const output = makeOutput([ + makeResult({ evaluatorId: 'vocabulary', originalRow: { id: '1', text: 'txt', grade: '5' } }), + makeResult({ evaluatorId: 'sentence-structure', originalRow: { id: '1', text: 'txt', grade: '5' } }), + ]); + + const header = formatAsCSV(output).split('\n')[0]; + const cols = header.split(','); + + // Original columns come first + expect(cols[0]).toBe('id'); + // sentence-structure sorts before vocabulary alphabetically + expect(cols.indexOf('sentence_structure_score')).toBeLessThan(cols.indexOf('vocabulary_score')); + }); + + it('leaves score empty and puts error message in reasoning for failed evaluations', () => { + const output = makeOutput([ + makeResult({ status: 'error', error: 'API timeout', score: undefined }), + ]); + + const csv = formatAsCSV(output); + const dataRow = csv.split('\n')[1]; + const cols = dataRow.split(','); + const header = csv.split('\n')[0].split(','); + + const scoreIdx = header.indexOf('vocabulary_score'); + const reasoningIdx = header.indexOf('vocabulary_reasoning'); + const statusIdx = header.indexOf('vocabulary_status'); + + expect(cols[scoreIdx]).toBe(''); // score is blank for errors + expect(cols[reasoningIdx]).toBe('API timeout'); + expect(cols[statusIdx]).toBe('error'); + }); + + it('outputs not_run when an evaluator produced no result for a row', () => { + // Row 1: vocabulary ran; sentence-structure did not + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', originalRow: { text: 'x', grade: '5' } }), + ]); + // Manually add sentence-structure to the results so the column exists but not for row 1 + output.results.push(makeResult({ + rowIndex: 2, evaluatorId: 'sentence-structure', + originalRow: { text: 'y', grade: '5' }, + })); + + const csv = formatAsCSV(output); + const [header, row1] = csv.split('\n'); + const cols = header.split(','); + const ssStatusIdx = cols.indexOf('sentence_structure_status'); + + expect(row1.split(',')[ssStatusIdx]).toBe('not_run'); + }); + + it('wraps fields containing commas, quotes, or newlines in double-quotes', () => { + const output = makeOutput([ + makeResult({ + score: 'slightly complex', + reasoning: 'Has "quotes" and, comma', + originalRow: { text: 'Line1\nLine2', grade: '5' }, + }), + ]); + + const csv = formatAsCSV(output); + expect(csv).toContain('"Line1\nLine2"'); + expect(csv).toContain('"Has ""quotes"" and, comma"'); + }); +}); + +// ============================================================ +// formatAsHTML — computed report data +// ============================================================ + +describe('formatAsHTML', () => { + describe('snapshot counts', () => { + it('counts a row as errored if any of its evaluator results failed', () => { + // Row 1: vocabulary ok, sentence-structure errored → should be "errored" + // Row 2: both ok → should be "processed" + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', status: 'success' }), + makeResult({ rowIndex: 1, evaluatorId: 'sentence-structure', status: 'error', error: 'timeout' }), + makeResult({ rowIndex: 2, evaluatorId: 'vocabulary', status: 'success' }), + makeResult({ rowIndex: 2, evaluatorId: 'sentence-structure', status: 'success' }), + ]); + + const { meta } = extractReportData(formatAsHTML(output, makeMeta({ totalInputRows: 2 }))); + expect(meta.processedRows).toBe(1); + expect(meta.erroredRows).toBe(1); + }); + }); + + describe('GLA status classification', () => { + function glaOutput(inputGrade: string, glaBand: string) { + return makeOutput([makeResult({ + grade: inputGrade, + evaluatorId: 'grade-level-appropriateness', + score: glaBand, + })]); + } + + it('classifies on-band when input grade falls within the GLA band', () => { + const { gradeLevelStats } = extractReportData( + formatAsHTML(glaOutput('3', '2-3'), makeMeta()) + ); + expect(gradeLevelStats.onBand).toBe(1); + expect(gradeLevelStats.adjacent).toBe(0); + expect(gradeLevelStats.offTarget).toBe(0); + }); + + it('classifies adjacent when input grade is one band away from the GLA result', () => { + // Grade 4 → band index 2 (4-5); GLA "2-3" → band index 1; diff = 1 + const { gradeLevelStats } = extractReportData( + formatAsHTML(glaOutput('4', '2-3'), makeMeta()) + ); + expect(gradeLevelStats.onBand).toBe(0); + expect(gradeLevelStats.adjacent).toBe(1); + expect(gradeLevelStats.offTarget).toBe(0); + }); + + it('classifies off-target when input grade is two or more bands away', () => { + // Grade 8 → band index 3 (6-8); GLA "2-3" → band index 1; diff = 2 + const { gradeLevelStats } = extractReportData( + formatAsHTML(glaOutput('8', '2-3'), makeMeta()) + ); + expect(gradeLevelStats.onBand).toBe(0); + expect(gradeLevelStats.adjacent).toBe(0); + expect(gradeLevelStats.offTarget).toBe(1); + }); + + it('maps grade K and grade 1 to the same K-1 band (both on-band with K-1 GLA result)', () => { + for (const grade of ['K', '1']) { + const { gradeLevelStats } = extractReportData( + formatAsHTML(glaOutput(grade, 'K-1'), makeMeta()) + ); + expect(gradeLevelStats.onBand).toBe(1); + } + }); + + it('maps grade 11, 12, and CCR to the same 11-CCR band', () => { + for (const grade of ['11', '12', 'CCR']) { + const { gradeLevelStats } = extractReportData( + formatAsHTML(glaOutput(grade, '11-CCR'), makeMeta()) + ); + expect(gradeLevelStats.onBand).toBe(1); + } + }); + + it('treats an unrecognised grade as off-target (tests the -1 guard, not coincidental diff arithmetic)', () => { + // Grade '99' → gradeToBandIndex returns -1. GLA 'K-1' is index 0, so without + // the "inputIdx === -1" guard the diff would be |(-1) - 0| = 1 → 'adjacent'. + // The guard must fire for this to be 'off-target'. + const { gradeLevelStats } = extractReportData( + formatAsHTML(glaOutput('99', 'K-1'), makeMeta()) + ); + expect(gradeLevelStats.offTarget).toBe(1); + }); + }); + + describe('complexity stats', () => { + it('normalises score strings case-insensitively (Title Case and lowercase both map to the same numeric value)', () => { + // vocabulary returns lowercase; sentence-structure returns Title Case + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', score: 'slightly complex' }), + makeResult({ rowIndex: 1, evaluatorId: 'sentence-structure', score: 'Slightly Complex' }), + ]); + + const { complexityStats } = extractReportData( + formatAsHTML(output, makeMeta()) + ); + + // Both evaluators must appear — verifies GLA is excluded and neither evaluator was silently dropped + expect(complexityStats).toHaveLength(2); + for (const stat of complexityStats) { + expect(stat.average).toBe(1.0); + expect(stat.label).toBe('Slightly Complex'); + expect(stat.distribution[0]).toBe(1); // one score of 1 + } + }); + + it('excludes GLA from complexity stats even when it runs alongside complexity evaluators', () => { + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'grade-level-appropriateness', score: '4-5' }), + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', score: 'slightly complex' }), + ]); + + const { complexityStats } = extractReportData( + formatAsHTML(output, makeMeta()) + ); + + expect(complexityStats).toHaveLength(1); + expect(complexityStats[0].evaluatorId).toBe('vocabulary'); + }); + + it('computes average and distribution correctly across multiple rows', () => { + // scores: 1, 2, 3 → avg 2.0 + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', score: 'slightly complex' }), + makeResult({ rowIndex: 2, evaluatorId: 'vocabulary', score: 'moderately complex' }), + makeResult({ rowIndex: 3, evaluatorId: 'vocabulary', score: 'very complex' }), + ]); + + const { complexityStats } = extractReportData(formatAsHTML(output, makeMeta({ totalInputRows: 3 }))); + const vocab = complexityStats[0]; + + expect(vocab.average).toBe(2.0); + expect(vocab.label).toBe('Moderately Complex'); + expect(vocab.distribution).toEqual([1, 1, 1, 0]); // one each of scores 1, 2, 3 + }); + + it('labels average >= 3.5 as Exceedingly Complex', () => { + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', score: 'exceedingly complex' }), + makeResult({ rowIndex: 2, evaluatorId: 'vocabulary', score: 'exceedingly complex' }), + ]); + + const { complexityStats } = extractReportData(formatAsHTML(output, makeMeta({ totalInputRows: 2 }))); + expect(complexityStats[0].label).toBe('Exceedingly Complex'); + expect(complexityStats[0].distribution).toEqual([0, 0, 0, 2]); + }); + + it('excludes error results from complexity averages', () => { + const output = makeOutput([ + makeResult({ rowIndex: 1, evaluatorId: 'vocabulary', status: 'success', score: 'very complex' }), + makeResult({ rowIndex: 2, evaluatorId: 'vocabulary', status: 'error', error: 'timeout' }), + ]); + + const { complexityStats } = extractReportData(formatAsHTML(output, makeMeta({ totalInputRows: 2 }))); + expect(complexityStats[0].average).toBe(3.0); // only the successful score counts + expect(complexityStats[0].distribution).toEqual([0, 0, 1, 0]); + }); + }); + + describe('grade band distribution', () => { + it('groups by the INPUT grade band, not the GLA result band', () => { + // Grade 3 → "2-3" bucket (index 1). GLA says "9-10" (off-target, diff=3). + const output = makeOutput([makeResult({ + grade: '3', + evaluatorId: 'grade-level-appropriateness', + score: '9-10', + })]); + + const { gradeBandDistribution } = extractReportData( + formatAsHTML(output, makeMeta()) + ); + + const band23 = gradeBandDistribution.data[1]; // index 1 = "2-3" (input grade) + const band910 = gradeBandDistribution.data[4]; // index 4 = "9-10" (GLA result) + + expect(band23.total).toBe(1); // row belongs to the "2-3" input bucket + expect(band23.offTarget).toBe(1); + expect(band910.total).toBe(0); // NOT in the GLA result's bucket + }); + }); + + describe('complexity heatmap', () => { + it('produces null for grade bands that have no data', () => { + // Only grade 5 rows → only "4-5" band (index 2) has data; others are null + const output = makeOutput([ + makeResult({ grade: '5', evaluatorId: 'vocabulary', score: 'moderately complex' }), + ]); + + const { complexityHeatmap } = extractReportData(formatAsHTML(output, makeMeta())); + const k1Values = complexityHeatmap.values[0]; // K-1 band + expect(k1Values[0]).toBeNull(); + }); + + it('computes the correct per-cell average', () => { + // Two grade-5 rows: scores 1 and 3 → average 2.0 + const output = makeOutput([ + makeResult({ rowIndex: 1, grade: '5', evaluatorId: 'vocabulary', score: 'slightly complex' }), + makeResult({ rowIndex: 2, grade: '5', evaluatorId: 'vocabulary', score: 'very complex' }), + ]); + + const { complexityHeatmap } = extractReportData(formatAsHTML(output, makeMeta({ totalInputRows: 2 }))); + const band45Values = complexityHeatmap.values[2]; // "4-5" is index 2 + expect(band45Values[0]).toBe(2.0); + }); + }); + + describe('XSS safety', () => { + it('Unicode-escapes < > & so injected data cannot break out of the script tag', () => { + const output = makeOutput([makeResult({ + text: '', + originalRow: { text: '', grade: '5' }, + })]); + + const html = formatAsHTML(output, makeMeta()); + expect(html).not.toContain(' -
- - - -
-
+
+
+
+
+
+
+
+
From ba6950b6eda9f168529aa6726093ddb6797c0910 Mon Sep 17 00:00:00 2001 From: Adnan Rashid Hussain Date: Mon, 16 Mar 2026 22:45:22 -0700 Subject: [PATCH 03/10] load test and limit to 50 --- sdks/typescript/src/batch/README.md | 2 +- sdks/typescript/src/batch/evaluator.ts | 2 +- sdks/typescript/src/batch/progress.ts | 1 - .../typescript/src/batch/report-template.html | 75 ++++++++++++++----- .../tests/fixtures/batch-load-test.csv | 51 +++++++++++++ 5 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 sdks/typescript/tests/fixtures/batch-load-test.csv diff --git a/sdks/typescript/src/batch/README.md b/sdks/typescript/src/batch/README.md index a3311d9..f85253f 100644 --- a/sdks/typescript/src/batch/README.md +++ b/sdks/typescript/src/batch/README.md @@ -61,7 +61,7 @@ See `tests/fixtures/batch-test.csv` for a complete example. The batch evaluator runs a fixed group of evaluators together. The current available group is: -- **text-complexity**: Runs vocabulary complexity, sentence structure, and grade-level appropriateness evaluators together (requires both Google and OpenAI API keys). Maximum 100 input rows. If you exceed the limit, the CLI will exit with an error and suggest splitting into smaller batches. +- **text-complexity**: Runs vocabulary complexity, sentence structure, and grade-level appropriateness evaluators together (requires both Google and OpenAI API keys). Maximum 50 input rows. If you exceed the limit, the CLI will exit with an error and suggest splitting into smaller batches. ### Output Files diff --git a/sdks/typescript/src/batch/evaluator.ts b/sdks/typescript/src/batch/evaluator.ts index a1b5195..1c59823 100644 --- a/sdks/typescript/src/batch/evaluator.ts +++ b/sdks/typescript/src/batch/evaluator.ts @@ -47,7 +47,7 @@ const EVALUATOR_GROUPS: EvaluatorGroup[] = [ ], requiresGoogleKey: true, requiresOpenAIKey: true, - maxInputRows: 100, + maxInputRows: 50, }, ]; diff --git a/sdks/typescript/src/batch/progress.ts b/sdks/typescript/src/batch/progress.ts index 5b9eb1b..850cc38 100644 --- a/sdks/typescript/src/batch/progress.ts +++ b/sdks/typescript/src/batch/progress.ts @@ -115,7 +115,6 @@ export class ProgressTracker { process.stdout.write('\x1b[J'); // Clear from cursor to end of screen } - console.log('\nProcessing evaluations...'); console.log( `${this.getProgressBar()} ${this.getPercentage()}% (${this.completed}/${this.totalTasks})` ); diff --git a/sdks/typescript/src/batch/report-template.html b/sdks/typescript/src/batch/report-template.html index 6e23527..58b41f7 100644 --- a/sdks/typescript/src/batch/report-template.html +++ b/sdks/typescript/src/batch/report-template.html @@ -174,7 +174,7 @@ } table.data-table td { padding: 10px 16px; border-bottom: 1px solid var(--border); - vertical-align: top; max-width: 280px; white-space: normal; + vertical-align: top; white-space: normal; color: var(--primary); } table.data-table tr:last-child td { border-bottom: none; } @@ -189,11 +189,23 @@ table.data-table td.frozen-last { border-right: 2px solid var(--border); } table.data-table th.group-start, table.data-table td.group-start { border-left: 2px solid var(--border); } - /* Reasoning cells */ - table.data-table .cell-reasoning { - font-size: 12px; color: var(--secondary); - max-width: 280px; white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; cursor: help; + /* Text cell — 3-line clamp, full text on hover */ + table.data-table .cell-text { cursor: help; } + table.data-table .cell-text-inner { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; + } + /* Reasoning cells — 2-line clamp, full text on hover */ + table.data-table .cell-reasoning { font-size: 12px; color: var(--secondary); cursor: help; } + table.data-table .cell-reasoning-inner { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + white-space: normal; } /* ── Empty / no-data ── */ @@ -635,13 +647,28 @@

Evaluation Report

const { fullResults } = REPORT_DATA; const { originalColumns, hasGLA, complexityEvaluators, rows } = fullResults; - const COL_MIN_WIDTH = 160; - const frozenCount = originalColumns.length; - - const thOriginal = originalColumns.map((col, i) => { - const isLast = i === frozenCount - 1; - return `${esc(col)}`; + const KEPT_COLS = ['row_id', 'text', 'grade']; + const visibleColumns = originalColumns.filter(col => KEPT_COLS.includes(col.toLowerCase())); + const frozenCount = 1; // only Row # is sticky + + function colLabel(col) { + const c = col.toLowerCase(); + if (c === 'row_id') return 'Row #'; + if (c === 'text') return 'Text'; + if (c === 'grade') return 'Grade'; + return col; + } + function colWidth(col) { + const c = col.toLowerCase(); + if (c === 'row_id') return 60; + if (c === 'text') return 340; + if (c === 'grade') return 70; + return 160; + } + + const thOriginal = visibleColumns.map((col, i) => { + const isFrozen = i === 0; + return `${colLabel(col)}`; }).join(''); const thGLA = hasGLA ? ` @@ -656,24 +683,32 @@

Evaluation Report

`).join(''); const bodyRows = rows.map(row => { - const tdOriginal = originalColumns.map((col, i) => { - const isLast = i === frozenCount - 1; - return `${esc(row[col])}`; + const tdOriginal = visibleColumns.map((col, i) => { + const isFrozen = i === 0; + const isTextCol = col.toLowerCase() === 'text'; + const content = isTextCol + ? `
${esc(row[col])}
` + : esc(row[col]); + const classes = [ + isFrozen ? 'frozen frozen-last' : '', + isTextCol ? 'cell-text' : '', + ].filter(Boolean).join(' '); + const style = `min-width:${colWidth(col)}px;${isFrozen ? ' left:0; z-index:2;' : ''}`; + return `${content}`; }).join(''); const glaStatus = row['__gla_status'] || ''; const tdGLA = hasGLA ? ` ${statusBadge(glaStatus)} ${esc(row['__gla_band'])} - ${esc(row['__gla_reasoning'])} +
${esc(row['__gla_reasoning'])}
` : ''; const tdCX = complexityEvaluators.map(e => { const prefix = `__${e.prefix}`; return ` ${esc(row[prefix + '_score'])} - ${esc(row[prefix + '_reasoning'])} +
${esc(row[prefix + '_reasoning'])}
`; }).join(''); @@ -691,7 +726,7 @@

Evaluation Report

`; - applyFrozenOffsets('results-table', frozenCount); + requestAnimationFrame(() => applyFrozenOffsets('results-table', frozenCount)); } /** diff --git a/sdks/typescript/tests/fixtures/batch-load-test.csv b/sdks/typescript/tests/fixtures/batch-load-test.csv new file mode 100644 index 0000000..07449cb --- /dev/null +++ b/sdks/typescript/tests/fixtures/batch-load-test.csv @@ -0,0 +1,51 @@ +row_id,TEXT,Grade,source +1,"The hoisting gear consists of a double system of chains 13/16 in. in diameter placed side by side; each chain is anchored by an adjustable screw to the end of the jib, and, passing round the traveling carriage and down to the falling block, is taken along the jib over a sliding pulley which leads it on to the grooved barrel, 3 ft. 9 in. in diameter. In front of the barrel is placed an automatic winder which insures a proper coiling of the chain in the grooves. The motive power is derived from two cylinders 10 in. in diameter and 16 in. stroke, one being bolted to each side frame; these cylinders, which are provided with link motion and reversing gear, drive a steel crank shaft 2¾ in. in diameter; on this shaft is a steel sliding pinion which drives the barrel by a double purchase.",3,SS3 +2,"Before corals bleach, they do not show many other signs of feeling stressed. So, if we want to understand a coral's health, we have to study its cells. Inside cells we have a lot of information, including DNA, RNA, and proteins. These molecules can help us find clues about the communication between the coral and the algae. But also, these molecules can teach us how to know when corals are stressed. When an organism is stressed, every cell in its body will react. Everything will do its best to survive! In response to stress, the cell will use its DNA to make RNA, so that it can then make proteins that will fight off the stress. If an organism has been stressed before, it can respond to the stress faster and better. Think of it like visiting a city: the first time you visit, you will need a map to find your hotel. The more often you visit the city, the less you will need the map because you will remember, and you will get back to the hotel faster.",4,SS4 +3,"Mesopotamia, located in present-day Iraq, is known as the 'Cradle of Civilization' because it was home to some of the earliest civilizations in the world. The region got its name from the ancient Greek words for 'land between the rivers,' referring to the Tigris and Euphrates rivers. These rivers provided water for the fertile land, making it perfect for farming. The regular flooding of the rivers made the land around them ideal for growing crops, which helped people settle down and form permanent villages. These villages eventually grew into cities, where people developed many of the characteristics of civilization, like organized government, complex buildings, and different social classes. The first civilizations in Mesopotamia were the Sumerians, who lived around 5,000 years ago. They invented the world's first written language, called cuneiform, which they used to keep track of things like food supplies and trade. They also developed a system of numbers, which helped them with math and measurement. The Sumerians built impressive cities like Ur, Eridu, and Uruk, which had populations of over 50,000 people. These cities were centers of learning and culture, and they helped spread knowledge and ideas throughout the region. Over time, other civilizations rose and fell in Mesopotamia, including the Akkadians, Babylonians, and Assyrians. Each civilization made its own contributions to the development of human society. The Babylonians are famous for their code of laws, which was one of the first written legal systems in the world. The Assyrians were known for their powerful military and their impressive palaces. Mesopotamia's history is full of amazing inventions and innovations that shaped the world we live in today. The development of civilization in Mesopotamia was not just about the fertile land and the rivers. Changes in climate and the environment also played a role. People had to become more organized and work together to survive. This led to the development of complex societies and governments. Mesopotamia's story is a reminder of how human ingenuity and adaptability can lead to amazing achievements. The 'Cradle of Civilization' is a term that refers to the regions where the earliest known human civilizations emerged. Mesopotamia is a prime example of this, as it was a place where people learned to live together, build cities, and develop new technologies that changed the course of human history. The innovations that came from Mesopotamia, like writing, mathematics, and agriculture, continue to influence our lives today. By studying ancient Mesopotamia, we can learn about the origins of our own civilization and the challenges and triumphs of early humans.",5,SS5 +4,"Benjamin Franklin was a very important person in American history. He was born in Boston, Massachusetts in 1706. He was one of 17 children. Franklin did not go to school for very long. He learned to be a printer from his brother. Franklin was a very smart man. He invented many things, like bifocals, the Franklin stove, and the lightning rod. He also started the first public library in Philadelphia. Franklin was a writer, too. He wrote a book called *Poor Richard's Almanack*. It had many famous sayings, like ""Lost Time is never found again."" Franklin was also a politician. He helped write the Declaration of Independence. He was a diplomat, too. He helped the United States get help from France during the Revolutionary War. He was a very busy man! Franklin was a scientist, a writer, a politician, and an inventor. He was a very important person in American history. Franklin was a very interesting person. He was a scientist who did experiments with electricity. He was a writer who wrote a book of sayings. He was a politician who helped the United States become independent. He was a diplomat who helped the United States get help from other countries. He was a very busy man! Franklin was a very smart man. He was a self-taught man who learned a lot on his own. He was a very creative man who invented many things. He was a very kind man who helped others. He was a very important man who helped shape the United States. Franklin was a very influential person. He was a leader who helped people. He was a thinker who came up with new ideas. He was a writer who shared his thoughts with others. He was a scientist who helped people understand the world. He was a very important person who helped make the United States what it is today.",6,SS6 +5,"Civil rights are rights that all people in a country have. The civil rights of a country apply to all the citizens within its borders. These rights are given by the laws of the country. Civil rights are sometimes thought to be the same as natural rights. In many countries civil rights include freedom of speech, freedom of the press, freedom of religion, and freedom of assembly. Civil rights also include the right to own property and the right to get fair and equal treatment from the government, from other citizens, and from private groups.",3,V3 +6,"Bluetooth is a protocol for wireless communication over short distances. It was developed in the 1990s, to reduce the number of cables. Devices such as mobile phones, laptops, PCs, printers, digital cameras and video game consoles can connect to each other, and exchange information. This is done using radio waves. It can be done securely. Bluetooth is only used for relatively short distances, like a few metres. There are different standards. Data rates vary. Currently, they are at 1-3 MBit per second.",4,V4 +7,"The scientific method is a way to learn about the world around us. It helps us figure out how things work. Scientists use the scientific method to test their ideas. They start by making observations and asking questions. Then, they make a guess, or a hypothesis, about what might be the answer. They use their hypothesis to make predictions about what will happen in an experiment. Scientists then test their predictions by doing experiments. If the results of the experiment match their predictions, then their hypothesis is supported. If the results don't match, then they need to change their hypothesis. Scientists repeat this process many times to make sure their hypothesis is correct. The scientific method is important because it helps us learn new things. It helps us understand the world around us. Scientists use the scientific method to make new discoveries and solve problems.",5,V5 +8,"Chicago in 1871 was a city ready to burn. The city boasted having 59,500 buildings, many of them—such as the Courthouse and the Tribune Building—large and ornately decorated. The trouble was that about two-thirds of all these structures were made entirely of wood. Many of the remaining buildings (even the ones proclaimed to be 'fireproof') looked solid, but were actually jerrybuilt affairs; the stone or brick exteriors hid wooden frames and floors, all topped with highly flammable tar or shingle roofs. It was also a common practice to disguise wood as another kind of building material. The fancy exterior decorations on just about every building were carved from wood, then painted to look like stone or marble.",6,V6 +9,"The scientific method is a way of learning about the world around us. It's a process that helps us understand how things work and why they happen. It's not just for scientists; we all use the scientific method in our everyday lives, even if we don't realize it. The scientific method starts with an observation. We notice something interesting and want to know more about it. For example, you might notice that your plant is wilting. You might wonder why this is happening. Next, we form a hypothesis, which is a possible explanation for our observation. In our plant example, you might hypothesize that the plant is wilting because it needs more water. Then, we test our hypothesis by doing an experiment. We change something in our experiment to see if it affects the outcome. In our plant example, you could water the plant and see if it recovers. Based on the results of our experiment, we can either support or reject our hypothesis. If the plant recovers after being watered, then your hypothesis is supported. If the plant doesn't recover, then you need to come up with a new hypothesis. The scientific method is a powerful tool for learning and understanding the world around us. It's a process of asking questions, testing ideas, and drawing conclusions based on evidence. It's a way of thinking that helps us to be curious, to be critical, and to be open to new ideas.",7,V7 +10,"The American Revolution was a war for independence between the thirteen American colonies and Great Britain. The war started in 1775 and ended in 1783. The colonists wanted to be free from British rule. They wanted to make their own laws and govern themselves. The colonists were angry about new taxes that the British Parliament imposed on them. They felt that they were being taxed without having a say in how the money was spent. The colonists also felt that the British government was not treating them fairly. The war began with the Battles of Lexington and Concord in April 1775. The colonists, led by General George Washington, fought against the British army. The war was long and difficult, but the colonists eventually won. The colonists won the war because they had the support of the French. The French helped the colonists by providing them with soldiers, ships, and money. The colonists also had a strong leader in George Washington. He was a skilled military leader and he inspired the colonists to fight for their freedom. The American Revolution was a turning point in history. It showed that colonies could break free from their mother countries and become independent nations. The American Revolution also inspired other revolutions around the world.",8,V8 +11,"Mr. President: I would like to speak briefly and simply about a serious national condition. It is a national feeling of fear and frustration that could result in national suicide and the end of everything that we Americans hold dear. It is a condition that comes from the lack of effective leadership in either the Legislative Branch or the Executive Branch of our Government. That leadership is so lacking that serious and responsible proposals are being made that national advisory commissions be appointed to provide such critically needed leadership. I speak as briefly as possible because too much harm has already been done with irresponsible words of bitterness and selfish political opportunism. I speak as briefly as possible because the issue is too great to be obscured by eloquence. I speak simply and briefly in the hope that my words will be taken to heart. I speak as a Republican. I speak as a woman. I speak as a United States Senator. I speak as an American. The United States Senate has long enjoyed worldwide respect as the greatest deliberative body in the world. But recently that deliberative character has too often been debased to the level of a forum of hate and character assassination sheltered by the shield of congressional immunity. Surely we should be able to take the same kind of character attacks that we ""dish out"" to outsiders. The American people are sick and tired of being afraid to speak their minds lest they be politically smeared as ""Communists"" or ""Fascists"" by their opponents. Freedom of speech is not what it used to be in America. As a woman, I wonder how the mothers, wives, sisters, and daughters feel about the way in which members of their families have been politically mangled in the Senate debate—and I use the word ""debate"" advisedly. As an American, I am shocked at the way Republicans and Democrats alike are playing directly into the Communist design of ""confuse, divide, and conquer."" As an American, I don't want a Democratic Administration ""whitewash"" or ""cover-up"" any more than I want a Republican smear or witch hunt. As an American, I condemn a Republican ""Fascist"" just as much I condemn a Democratic ""Communist."" I condemn a Democrat ""Fascist"" just as much as I condemn a Republican ""Communist."" They are equally dangerous to you and me and to our country. It is with these thoughts that I have drafted what I call a ""Declaration of Conscience.""",9,V9 +12,"The hoisting gear consists of a double system of chains 13/16 in. in diameter placed side by side; each chain is anchored by an adjustable screw to the end of the jib, and, passing round the traveling carriage and down to the falling block, is taken along the jib over a sliding pulley which leads it on to the grooved barrel, 3 ft. 9 in. in diameter. In front of the barrel is placed an automatic winder which insures a proper coiling of the chain in the grooves. The motive power is derived from two cylinders 10 in. in diameter and 16 in. stroke, one being bolted to each side frame; these cylinders, which are provided with link motion and reversing gear, drive a steel crank shaft 2¾ in. in diameter; on this shaft is a steel sliding pinion which drives the barrel by a double purchase.",3,SS3 +13,"Before corals bleach, they do not show many other signs of feeling stressed. So, if we want to understand a coral's health, we have to study its cells. Inside cells we have a lot of information, including DNA, RNA, and proteins. These molecules can help us find clues about the communication between the coral and the algae. But also, these molecules can teach us how to know when corals are stressed. When an organism is stressed, every cell in its body will react. Everything will do its best to survive! In response to stress, the cell will use its DNA to make RNA, so that it can then make proteins that will fight off the stress. If an organism has been stressed before, it can respond to the stress faster and better. Think of it like visiting a city: the first time you visit, you will need a map to find your hotel. The more often you visit the city, the less you will need the map because you will remember, and you will get back to the hotel faster.",4,SS4 +14,"Mesopotamia, located in present-day Iraq, is known as the 'Cradle of Civilization' because it was home to some of the earliest civilizations in the world. The region got its name from the ancient Greek words for 'land between the rivers,' referring to the Tigris and Euphrates rivers. These rivers provided water for the fertile land, making it perfect for farming. The regular flooding of the rivers made the land around them ideal for growing crops, which helped people settle down and form permanent villages. These villages eventually grew into cities, where people developed many of the characteristics of civilization, like organized government, complex buildings, and different social classes. The first civilizations in Mesopotamia were the Sumerians, who lived around 5,000 years ago. They invented the world's first written language, called cuneiform, which they used to keep track of things like food supplies and trade. They also developed a system of numbers, which helped them with math and measurement. The Sumerians built impressive cities like Ur, Eridu, and Uruk, which had populations of over 50,000 people. These cities were centers of learning and culture, and they helped spread knowledge and ideas throughout the region. Over time, other civilizations rose and fell in Mesopotamia, including the Akkadians, Babylonians, and Assyrians. Each civilization made its own contributions to the development of human society. The Babylonians are famous for their code of laws, which was one of the first written legal systems in the world. The Assyrians were known for their powerful military and their impressive palaces. Mesopotamia's history is full of amazing inventions and innovations that shaped the world we live in today. The development of civilization in Mesopotamia was not just about the fertile land and the rivers. Changes in climate and the environment also played a role. People had to become more organized and work together to survive. This led to the development of complex societies and governments. Mesopotamia's story is a reminder of how human ingenuity and adaptability can lead to amazing achievements. The 'Cradle of Civilization' is a term that refers to the regions where the earliest known human civilizations emerged. Mesopotamia is a prime example of this, as it was a place where people learned to live together, build cities, and develop new technologies that changed the course of human history. The innovations that came from Mesopotamia, like writing, mathematics, and agriculture, continue to influence our lives today. By studying ancient Mesopotamia, we can learn about the origins of our own civilization and the challenges and triumphs of early humans.",5,SS5 +15,"Benjamin Franklin was a very important person in American history. He was born in Boston, Massachusetts in 1706. He was one of 17 children. Franklin did not go to school for very long. He learned to be a printer from his brother. Franklin was a very smart man. He invented many things, like bifocals, the Franklin stove, and the lightning rod. He also started the first public library in Philadelphia. Franklin was a writer, too. He wrote a book called *Poor Richard's Almanack*. It had many famous sayings, like ""Lost Time is never found again."" Franklin was also a politician. He helped write the Declaration of Independence. He was a diplomat, too. He helped the United States get help from France during the Revolutionary War. He was a very busy man! Franklin was a scientist, a writer, a politician, and an inventor. He was a very important person in American history. Franklin was a very interesting person. He was a scientist who did experiments with electricity. He was a writer who wrote a book of sayings. He was a politician who helped the United States become independent. He was a diplomat who helped the United States get help from other countries. He was a very busy man! Franklin was a very smart man. He was a self-taught man who learned a lot on his own. He was a very creative man who invented many things. He was a very kind man who helped others. He was a very important man who helped shape the United States. Franklin was a very influential person. He was a leader who helped people. He was a thinker who came up with new ideas. He was a writer who shared his thoughts with others. He was a scientist who helped people understand the world. He was a very important person who helped make the United States what it is today.",6,SS6 +16,"Civil rights are rights that all people in a country have. The civil rights of a country apply to all the citizens within its borders. These rights are given by the laws of the country. Civil rights are sometimes thought to be the same as natural rights. In many countries civil rights include freedom of speech, freedom of the press, freedom of religion, and freedom of assembly. Civil rights also include the right to own property and the right to get fair and equal treatment from the government, from other citizens, and from private groups.",3,V3 +17,"Bluetooth is a protocol for wireless communication over short distances. It was developed in the 1990s, to reduce the number of cables. Devices such as mobile phones, laptops, PCs, printers, digital cameras and video game consoles can connect to each other, and exchange information. This is done using radio waves. It can be done securely. Bluetooth is only used for relatively short distances, like a few metres. There are different standards. Data rates vary. Currently, they are at 1-3 MBit per second.",4,V4 +18,"The scientific method is a way to learn about the world around us. It helps us figure out how things work. Scientists use the scientific method to test their ideas. They start by making observations and asking questions. Then, they make a guess, or a hypothesis, about what might be the answer. They use their hypothesis to make predictions about what will happen in an experiment. Scientists then test their predictions by doing experiments. If the results of the experiment match their predictions, then their hypothesis is supported. If the results don't match, then they need to change their hypothesis. Scientists repeat this process many times to make sure their hypothesis is correct. The scientific method is important because it helps us learn new things. It helps us understand the world around us. Scientists use the scientific method to make new discoveries and solve problems.",5,V5 +19,"Chicago in 1871 was a city ready to burn. The city boasted having 59,500 buildings, many of them—such as the Courthouse and the Tribune Building—large and ornately decorated. The trouble was that about two-thirds of all these structures were made entirely of wood. Many of the remaining buildings (even the ones proclaimed to be 'fireproof') looked solid, but were actually jerrybuilt affairs; the stone or brick exteriors hid wooden frames and floors, all topped with highly flammable tar or shingle roofs. It was also a common practice to disguise wood as another kind of building material. The fancy exterior decorations on just about every building were carved from wood, then painted to look like stone or marble.",6,V6 +20,"The scientific method is a way of learning about the world around us. It's a process that helps us understand how things work and why they happen. It's not just for scientists; we all use the scientific method in our everyday lives, even if we don't realize it. The scientific method starts with an observation. We notice something interesting and want to know more about it. For example, you might notice that your plant is wilting. You might wonder why this is happening. Next, we form a hypothesis, which is a possible explanation for our observation. In our plant example, you might hypothesize that the plant is wilting because it needs more water. Then, we test our hypothesis by doing an experiment. We change something in our experiment to see if it affects the outcome. In our plant example, you could water the plant and see if it recovers. Based on the results of our experiment, we can either support or reject our hypothesis. If the plant recovers after being watered, then your hypothesis is supported. If the plant doesn't recover, then you need to come up with a new hypothesis. The scientific method is a powerful tool for learning and understanding the world around us. It's a process of asking questions, testing ideas, and drawing conclusions based on evidence. It's a way of thinking that helps us to be curious, to be critical, and to be open to new ideas.",7,V7 +21,"The American Revolution was a war for independence between the thirteen American colonies and Great Britain. The war started in 1775 and ended in 1783. The colonists wanted to be free from British rule. They wanted to make their own laws and govern themselves. The colonists were angry about new taxes that the British Parliament imposed on them. They felt that they were being taxed without having a say in how the money was spent. The colonists also felt that the British government was not treating them fairly. The war began with the Battles of Lexington and Concord in April 1775. The colonists, led by General George Washington, fought against the British army. The war was long and difficult, but the colonists eventually won. The colonists won the war because they had the support of the French. The French helped the colonists by providing them with soldiers, ships, and money. The colonists also had a strong leader in George Washington. He was a skilled military leader and he inspired the colonists to fight for their freedom. The American Revolution was a turning point in history. It showed that colonies could break free from their mother countries and become independent nations. The American Revolution also inspired other revolutions around the world.",8,V8 +22,"Mr. President: I would like to speak briefly and simply about a serious national condition. It is a national feeling of fear and frustration that could result in national suicide and the end of everything that we Americans hold dear. It is a condition that comes from the lack of effective leadership in either the Legislative Branch or the Executive Branch of our Government. That leadership is so lacking that serious and responsible proposals are being made that national advisory commissions be appointed to provide such critically needed leadership. I speak as briefly as possible because too much harm has already been done with irresponsible words of bitterness and selfish political opportunism. I speak as briefly as possible because the issue is too great to be obscured by eloquence. I speak simply and briefly in the hope that my words will be taken to heart. I speak as a Republican. I speak as a woman. I speak as a United States Senator. I speak as an American. The United States Senate has long enjoyed worldwide respect as the greatest deliberative body in the world. But recently that deliberative character has too often been debased to the level of a forum of hate and character assassination sheltered by the shield of congressional immunity. Surely we should be able to take the same kind of character attacks that we ""dish out"" to outsiders. The American people are sick and tired of being afraid to speak their minds lest they be politically smeared as ""Communists"" or ""Fascists"" by their opponents. Freedom of speech is not what it used to be in America. As a woman, I wonder how the mothers, wives, sisters, and daughters feel about the way in which members of their families have been politically mangled in the Senate debate—and I use the word ""debate"" advisedly. As an American, I am shocked at the way Republicans and Democrats alike are playing directly into the Communist design of ""confuse, divide, and conquer."" As an American, I don't want a Democratic Administration ""whitewash"" or ""cover-up"" any more than I want a Republican smear or witch hunt. As an American, I condemn a Republican ""Fascist"" just as much I condemn a Democratic ""Communist."" I condemn a Democrat ""Fascist"" just as much as I condemn a Republican ""Communist."" They are equally dangerous to you and me and to our country. It is with these thoughts that I have drafted what I call a ""Declaration of Conscience.""",9,V9 +23,"The hoisting gear consists of a double system of chains 13/16 in. in diameter placed side by side; each chain is anchored by an adjustable screw to the end of the jib, and, passing round the traveling carriage and down to the falling block, is taken along the jib over a sliding pulley which leads it on to the grooved barrel, 3 ft. 9 in. in diameter. In front of the barrel is placed an automatic winder which insures a proper coiling of the chain in the grooves. The motive power is derived from two cylinders 10 in. in diameter and 16 in. stroke, one being bolted to each side frame; these cylinders, which are provided with link motion and reversing gear, drive a steel crank shaft 2¾ in. in diameter; on this shaft is a steel sliding pinion which drives the barrel by a double purchase.",3,SS3 +24,"Before corals bleach, they do not show many other signs of feeling stressed. So, if we want to understand a coral's health, we have to study its cells. Inside cells we have a lot of information, including DNA, RNA, and proteins. These molecules can help us find clues about the communication between the coral and the algae. But also, these molecules can teach us how to know when corals are stressed. When an organism is stressed, every cell in its body will react. Everything will do its best to survive! In response to stress, the cell will use its DNA to make RNA, so that it can then make proteins that will fight off the stress. If an organism has been stressed before, it can respond to the stress faster and better. Think of it like visiting a city: the first time you visit, you will need a map to find your hotel. The more often you visit the city, the less you will need the map because you will remember, and you will get back to the hotel faster.",4,SS4 +25,"Mesopotamia, located in present-day Iraq, is known as the 'Cradle of Civilization' because it was home to some of the earliest civilizations in the world. The region got its name from the ancient Greek words for 'land between the rivers,' referring to the Tigris and Euphrates rivers. These rivers provided water for the fertile land, making it perfect for farming. The regular flooding of the rivers made the land around them ideal for growing crops, which helped people settle down and form permanent villages. These villages eventually grew into cities, where people developed many of the characteristics of civilization, like organized government, complex buildings, and different social classes. The first civilizations in Mesopotamia were the Sumerians, who lived around 5,000 years ago. They invented the world's first written language, called cuneiform, which they used to keep track of things like food supplies and trade. They also developed a system of numbers, which helped them with math and measurement. The Sumerians built impressive cities like Ur, Eridu, and Uruk, which had populations of over 50,000 people. These cities were centers of learning and culture, and they helped spread knowledge and ideas throughout the region. Over time, other civilizations rose and fell in Mesopotamia, including the Akkadians, Babylonians, and Assyrians. Each civilization made its own contributions to the development of human society. The Babylonians are famous for their code of laws, which was one of the first written legal systems in the world. The Assyrians were known for their powerful military and their impressive palaces. Mesopotamia's history is full of amazing inventions and innovations that shaped the world we live in today. The development of civilization in Mesopotamia was not just about the fertile land and the rivers. Changes in climate and the environment also played a role. People had to become more organized and work together to survive. This led to the development of complex societies and governments. Mesopotamia's story is a reminder of how human ingenuity and adaptability can lead to amazing achievements. The 'Cradle of Civilization' is a term that refers to the regions where the earliest known human civilizations emerged. Mesopotamia is a prime example of this, as it was a place where people learned to live together, build cities, and develop new technologies that changed the course of human history. The innovations that came from Mesopotamia, like writing, mathematics, and agriculture, continue to influence our lives today. By studying ancient Mesopotamia, we can learn about the origins of our own civilization and the challenges and triumphs of early humans.",5,SS5 +26,"Benjamin Franklin was a very important person in American history. He was born in Boston, Massachusetts in 1706. He was one of 17 children. Franklin did not go to school for very long. He learned to be a printer from his brother. Franklin was a very smart man. He invented many things, like bifocals, the Franklin stove, and the lightning rod. He also started the first public library in Philadelphia. Franklin was a writer, too. He wrote a book called *Poor Richard's Almanack*. It had many famous sayings, like ""Lost Time is never found again."" Franklin was also a politician. He helped write the Declaration of Independence. He was a diplomat, too. He helped the United States get help from France during the Revolutionary War. He was a very busy man! Franklin was a scientist, a writer, a politician, and an inventor. He was a very important person in American history. Franklin was a very interesting person. He was a scientist who did experiments with electricity. He was a writer who wrote a book of sayings. He was a politician who helped the United States become independent. He was a diplomat who helped the United States get help from other countries. He was a very busy man! Franklin was a very smart man. He was a self-taught man who learned a lot on his own. He was a very creative man who invented many things. He was a very kind man who helped others. He was a very important man who helped shape the United States. Franklin was a very influential person. He was a leader who helped people. He was a thinker who came up with new ideas. He was a writer who shared his thoughts with others. He was a scientist who helped people understand the world. He was a very important person who helped make the United States what it is today.",6,SS6 +27,"Civil rights are rights that all people in a country have. The civil rights of a country apply to all the citizens within its borders. These rights are given by the laws of the country. Civil rights are sometimes thought to be the same as natural rights. In many countries civil rights include freedom of speech, freedom of the press, freedom of religion, and freedom of assembly. Civil rights also include the right to own property and the right to get fair and equal treatment from the government, from other citizens, and from private groups.",3,V3 +28,"Bluetooth is a protocol for wireless communication over short distances. It was developed in the 1990s, to reduce the number of cables. Devices such as mobile phones, laptops, PCs, printers, digital cameras and video game consoles can connect to each other, and exchange information. This is done using radio waves. It can be done securely. Bluetooth is only used for relatively short distances, like a few metres. There are different standards. Data rates vary. Currently, they are at 1-3 MBit per second.",4,V4 +29,"The scientific method is a way to learn about the world around us. It helps us figure out how things work. Scientists use the scientific method to test their ideas. They start by making observations and asking questions. Then, they make a guess, or a hypothesis, about what might be the answer. They use their hypothesis to make predictions about what will happen in an experiment. Scientists then test their predictions by doing experiments. If the results of the experiment match their predictions, then their hypothesis is supported. If the results don't match, then they need to change their hypothesis. Scientists repeat this process many times to make sure their hypothesis is correct. The scientific method is important because it helps us learn new things. It helps us understand the world around us. Scientists use the scientific method to make new discoveries and solve problems.",5,V5 +30,"Chicago in 1871 was a city ready to burn. The city boasted having 59,500 buildings, many of them—such as the Courthouse and the Tribune Building—large and ornately decorated. The trouble was that about two-thirds of all these structures were made entirely of wood. Many of the remaining buildings (even the ones proclaimed to be 'fireproof') looked solid, but were actually jerrybuilt affairs; the stone or brick exteriors hid wooden frames and floors, all topped with highly flammable tar or shingle roofs. It was also a common practice to disguise wood as another kind of building material. The fancy exterior decorations on just about every building were carved from wood, then painted to look like stone or marble.",6,V6 +31,"The scientific method is a way of learning about the world around us. It's a process that helps us understand how things work and why they happen. It's not just for scientists; we all use the scientific method in our everyday lives, even if we don't realize it. The scientific method starts with an observation. We notice something interesting and want to know more about it. For example, you might notice that your plant is wilting. You might wonder why this is happening. Next, we form a hypothesis, which is a possible explanation for our observation. In our plant example, you might hypothesize that the plant is wilting because it needs more water. Then, we test our hypothesis by doing an experiment. We change something in our experiment to see if it affects the outcome. In our plant example, you could water the plant and see if it recovers. Based on the results of our experiment, we can either support or reject our hypothesis. If the plant recovers after being watered, then your hypothesis is supported. If the plant doesn't recover, then you need to come up with a new hypothesis. The scientific method is a powerful tool for learning and understanding the world around us. It's a process of asking questions, testing ideas, and drawing conclusions based on evidence. It's a way of thinking that helps us to be curious, to be critical, and to be open to new ideas.",7,V7 +32,"The American Revolution was a war for independence between the thirteen American colonies and Great Britain. The war started in 1775 and ended in 1783. The colonists wanted to be free from British rule. They wanted to make their own laws and govern themselves. The colonists were angry about new taxes that the British Parliament imposed on them. They felt that they were being taxed without having a say in how the money was spent. The colonists also felt that the British government was not treating them fairly. The war began with the Battles of Lexington and Concord in April 1775. The colonists, led by General George Washington, fought against the British army. The war was long and difficult, but the colonists eventually won. The colonists won the war because they had the support of the French. The French helped the colonists by providing them with soldiers, ships, and money. The colonists also had a strong leader in George Washington. He was a skilled military leader and he inspired the colonists to fight for their freedom. The American Revolution was a turning point in history. It showed that colonies could break free from their mother countries and become independent nations. The American Revolution also inspired other revolutions around the world.",8,V8 +33,"Mr. President: I would like to speak briefly and simply about a serious national condition. It is a national feeling of fear and frustration that could result in national suicide and the end of everything that we Americans hold dear. It is a condition that comes from the lack of effective leadership in either the Legislative Branch or the Executive Branch of our Government. That leadership is so lacking that serious and responsible proposals are being made that national advisory commissions be appointed to provide such critically needed leadership. I speak as briefly as possible because too much harm has already been done with irresponsible words of bitterness and selfish political opportunism. I speak as briefly as possible because the issue is too great to be obscured by eloquence. I speak simply and briefly in the hope that my words will be taken to heart. I speak as a Republican. I speak as a woman. I speak as a United States Senator. I speak as an American. The United States Senate has long enjoyed worldwide respect as the greatest deliberative body in the world. But recently that deliberative character has too often been debased to the level of a forum of hate and character assassination sheltered by the shield of congressional immunity. Surely we should be able to take the same kind of character attacks that we ""dish out"" to outsiders. The American people are sick and tired of being afraid to speak their minds lest they be politically smeared as ""Communists"" or ""Fascists"" by their opponents. Freedom of speech is not what it used to be in America. As a woman, I wonder how the mothers, wives, sisters, and daughters feel about the way in which members of their families have been politically mangled in the Senate debate—and I use the word ""debate"" advisedly. As an American, I am shocked at the way Republicans and Democrats alike are playing directly into the Communist design of ""confuse, divide, and conquer."" As an American, I don't want a Democratic Administration ""whitewash"" or ""cover-up"" any more than I want a Republican smear or witch hunt. As an American, I condemn a Republican ""Fascist"" just as much I condemn a Democratic ""Communist."" I condemn a Democrat ""Fascist"" just as much as I condemn a Republican ""Communist."" They are equally dangerous to you and me and to our country. It is with these thoughts that I have drafted what I call a ""Declaration of Conscience.""",9,V9 +34,"The hoisting gear consists of a double system of chains 13/16 in. in diameter placed side by side; each chain is anchored by an adjustable screw to the end of the jib, and, passing round the traveling carriage and down to the falling block, is taken along the jib over a sliding pulley which leads it on to the grooved barrel, 3 ft. 9 in. in diameter. In front of the barrel is placed an automatic winder which insures a proper coiling of the chain in the grooves. The motive power is derived from two cylinders 10 in. in diameter and 16 in. stroke, one being bolted to each side frame; these cylinders, which are provided with link motion and reversing gear, drive a steel crank shaft 2¾ in. in diameter; on this shaft is a steel sliding pinion which drives the barrel by a double purchase.",3,SS3 +35,"Before corals bleach, they do not show many other signs of feeling stressed. So, if we want to understand a coral's health, we have to study its cells. Inside cells we have a lot of information, including DNA, RNA, and proteins. These molecules can help us find clues about the communication between the coral and the algae. But also, these molecules can teach us how to know when corals are stressed. When an organism is stressed, every cell in its body will react. Everything will do its best to survive! In response to stress, the cell will use its DNA to make RNA, so that it can then make proteins that will fight off the stress. If an organism has been stressed before, it can respond to the stress faster and better. Think of it like visiting a city: the first time you visit, you will need a map to find your hotel. The more often you visit the city, the less you will need the map because you will remember, and you will get back to the hotel faster.",4,SS4 +36,"Mesopotamia, located in present-day Iraq, is known as the 'Cradle of Civilization' because it was home to some of the earliest civilizations in the world. The region got its name from the ancient Greek words for 'land between the rivers,' referring to the Tigris and Euphrates rivers. These rivers provided water for the fertile land, making it perfect for farming. The regular flooding of the rivers made the land around them ideal for growing crops, which helped people settle down and form permanent villages. These villages eventually grew into cities, where people developed many of the characteristics of civilization, like organized government, complex buildings, and different social classes. The first civilizations in Mesopotamia were the Sumerians, who lived around 5,000 years ago. They invented the world's first written language, called cuneiform, which they used to keep track of things like food supplies and trade. They also developed a system of numbers, which helped them with math and measurement. The Sumerians built impressive cities like Ur, Eridu, and Uruk, which had populations of over 50,000 people. These cities were centers of learning and culture, and they helped spread knowledge and ideas throughout the region. Over time, other civilizations rose and fell in Mesopotamia, including the Akkadians, Babylonians, and Assyrians. Each civilization made its own contributions to the development of human society. The Babylonians are famous for their code of laws, which was one of the first written legal systems in the world. The Assyrians were known for their powerful military and their impressive palaces. Mesopotamia's history is full of amazing inventions and innovations that shaped the world we live in today. The development of civilization in Mesopotamia was not just about the fertile land and the rivers. Changes in climate and the environment also played a role. People had to become more organized and work together to survive. This led to the development of complex societies and governments. Mesopotamia's story is a reminder of how human ingenuity and adaptability can lead to amazing achievements. The 'Cradle of Civilization' is a term that refers to the regions where the earliest known human civilizations emerged. Mesopotamia is a prime example of this, as it was a place where people learned to live together, build cities, and develop new technologies that changed the course of human history. The innovations that came from Mesopotamia, like writing, mathematics, and agriculture, continue to influence our lives today. By studying ancient Mesopotamia, we can learn about the origins of our own civilization and the challenges and triumphs of early humans.",5,SS5 +37,"Benjamin Franklin was a very important person in American history. He was born in Boston, Massachusetts in 1706. He was one of 17 children. Franklin did not go to school for very long. He learned to be a printer from his brother. Franklin was a very smart man. He invented many things, like bifocals, the Franklin stove, and the lightning rod. He also started the first public library in Philadelphia. Franklin was a writer, too. He wrote a book called *Poor Richard's Almanack*. It had many famous sayings, like ""Lost Time is never found again."" Franklin was also a politician. He helped write the Declaration of Independence. He was a diplomat, too. He helped the United States get help from France during the Revolutionary War. He was a very busy man! Franklin was a scientist, a writer, a politician, and an inventor. He was a very important person in American history. Franklin was a very interesting person. He was a scientist who did experiments with electricity. He was a writer who wrote a book of sayings. He was a politician who helped the United States become independent. He was a diplomat who helped the United States get help from other countries. He was a very busy man! Franklin was a very smart man. He was a self-taught man who learned a lot on his own. He was a very creative man who invented many things. He was a very kind man who helped others. He was a very important man who helped shape the United States. Franklin was a very influential person. He was a leader who helped people. He was a thinker who came up with new ideas. He was a writer who shared his thoughts with others. He was a scientist who helped people understand the world. He was a very important person who helped make the United States what it is today.",6,SS6 +38,"Civil rights are rights that all people in a country have. The civil rights of a country apply to all the citizens within its borders. These rights are given by the laws of the country. Civil rights are sometimes thought to be the same as natural rights. In many countries civil rights include freedom of speech, freedom of the press, freedom of religion, and freedom of assembly. Civil rights also include the right to own property and the right to get fair and equal treatment from the government, from other citizens, and from private groups.",3,V3 +39,"Bluetooth is a protocol for wireless communication over short distances. It was developed in the 1990s, to reduce the number of cables. Devices such as mobile phones, laptops, PCs, printers, digital cameras and video game consoles can connect to each other, and exchange information. This is done using radio waves. It can be done securely. Bluetooth is only used for relatively short distances, like a few metres. There are different standards. Data rates vary. Currently, they are at 1-3 MBit per second.",4,V4 +40,"The scientific method is a way to learn about the world around us. It helps us figure out how things work. Scientists use the scientific method to test their ideas. They start by making observations and asking questions. Then, they make a guess, or a hypothesis, about what might be the answer. They use their hypothesis to make predictions about what will happen in an experiment. Scientists then test their predictions by doing experiments. If the results of the experiment match their predictions, then their hypothesis is supported. If the results don't match, then they need to change their hypothesis. Scientists repeat this process many times to make sure their hypothesis is correct. The scientific method is important because it helps us learn new things. It helps us understand the world around us. Scientists use the scientific method to make new discoveries and solve problems.",5,V5 +41,"Chicago in 1871 was a city ready to burn. The city boasted having 59,500 buildings, many of them—such as the Courthouse and the Tribune Building—large and ornately decorated. The trouble was that about two-thirds of all these structures were made entirely of wood. Many of the remaining buildings (even the ones proclaimed to be 'fireproof') looked solid, but were actually jerrybuilt affairs; the stone or brick exteriors hid wooden frames and floors, all topped with highly flammable tar or shingle roofs. It was also a common practice to disguise wood as another kind of building material. The fancy exterior decorations on just about every building were carved from wood, then painted to look like stone or marble.",6,V6 +42,"The scientific method is a way of learning about the world around us. It's a process that helps us understand how things work and why they happen. It's not just for scientists; we all use the scientific method in our everyday lives, even if we don't realize it. The scientific method starts with an observation. We notice something interesting and want to know more about it. For example, you might notice that your plant is wilting. You might wonder why this is happening. Next, we form a hypothesis, which is a possible explanation for our observation. In our plant example, you might hypothesize that the plant is wilting because it needs more water. Then, we test our hypothesis by doing an experiment. We change something in our experiment to see if it affects the outcome. In our plant example, you could water the plant and see if it recovers. Based on the results of our experiment, we can either support or reject our hypothesis. If the plant recovers after being watered, then your hypothesis is supported. If the plant doesn't recover, then you need to come up with a new hypothesis. The scientific method is a powerful tool for learning and understanding the world around us. It's a process of asking questions, testing ideas, and drawing conclusions based on evidence. It's a way of thinking that helps us to be curious, to be critical, and to be open to new ideas.",7,V7 +43,"The American Revolution was a war for independence between the thirteen American colonies and Great Britain. The war started in 1775 and ended in 1783. The colonists wanted to be free from British rule. They wanted to make their own laws and govern themselves. The colonists were angry about new taxes that the British Parliament imposed on them. They felt that they were being taxed without having a say in how the money was spent. The colonists also felt that the British government was not treating them fairly. The war began with the Battles of Lexington and Concord in April 1775. The colonists, led by General George Washington, fought against the British army. The war was long and difficult, but the colonists eventually won. The colonists won the war because they had the support of the French. The French helped the colonists by providing them with soldiers, ships, and money. The colonists also had a strong leader in George Washington. He was a skilled military leader and he inspired the colonists to fight for their freedom. The American Revolution was a turning point in history. It showed that colonies could break free from their mother countries and become independent nations. The American Revolution also inspired other revolutions around the world.",8,V8 +44,"Mr. President: I would like to speak briefly and simply about a serious national condition. It is a national feeling of fear and frustration that could result in national suicide and the end of everything that we Americans hold dear. It is a condition that comes from the lack of effective leadership in either the Legislative Branch or the Executive Branch of our Government. That leadership is so lacking that serious and responsible proposals are being made that national advisory commissions be appointed to provide such critically needed leadership. I speak as briefly as possible because too much harm has already been done with irresponsible words of bitterness and selfish political opportunism. I speak as briefly as possible because the issue is too great to be obscured by eloquence. I speak simply and briefly in the hope that my words will be taken to heart. I speak as a Republican. I speak as a woman. I speak as a United States Senator. I speak as an American. The United States Senate has long enjoyed worldwide respect as the greatest deliberative body in the world. But recently that deliberative character has too often been debased to the level of a forum of hate and character assassination sheltered by the shield of congressional immunity. Surely we should be able to take the same kind of character attacks that we ""dish out"" to outsiders. The American people are sick and tired of being afraid to speak their minds lest they be politically smeared as ""Communists"" or ""Fascists"" by their opponents. Freedom of speech is not what it used to be in America. As a woman, I wonder how the mothers, wives, sisters, and daughters feel about the way in which members of their families have been politically mangled in the Senate debate—and I use the word ""debate"" advisedly. As an American, I am shocked at the way Republicans and Democrats alike are playing directly into the Communist design of ""confuse, divide, and conquer."" As an American, I don't want a Democratic Administration ""whitewash"" or ""cover-up"" any more than I want a Republican smear or witch hunt. As an American, I condemn a Republican ""Fascist"" just as much I condemn a Democratic ""Communist."" I condemn a Democrat ""Fascist"" just as much as I condemn a Republican ""Communist."" They are equally dangerous to you and me and to our country. It is with these thoughts that I have drafted what I call a ""Declaration of Conscience.""",9,V9 +45,"The hoisting gear consists of a double system of chains 13/16 in. in diameter placed side by side; each chain is anchored by an adjustable screw to the end of the jib, and, passing round the traveling carriage and down to the falling block, is taken along the jib over a sliding pulley which leads it on to the grooved barrel, 3 ft. 9 in. in diameter. In front of the barrel is placed an automatic winder which insures a proper coiling of the chain in the grooves. The motive power is derived from two cylinders 10 in. in diameter and 16 in. stroke, one being bolted to each side frame; these cylinders, which are provided with link motion and reversing gear, drive a steel crank shaft 2¾ in. in diameter; on this shaft is a steel sliding pinion which drives the barrel by a double purchase.",3,SS3 +46,"Before corals bleach, they do not show many other signs of feeling stressed. So, if we want to understand a coral's health, we have to study its cells. Inside cells we have a lot of information, including DNA, RNA, and proteins. These molecules can help us find clues about the communication between the coral and the algae. But also, these molecules can teach us how to know when corals are stressed. When an organism is stressed, every cell in its body will react. Everything will do its best to survive! In response to stress, the cell will use its DNA to make RNA, so that it can then make proteins that will fight off the stress. If an organism has been stressed before, it can respond to the stress faster and better. Think of it like visiting a city: the first time you visit, you will need a map to find your hotel. The more often you visit the city, the less you will need the map because you will remember, and you will get back to the hotel faster.",4,SS4 +47,"Mesopotamia, located in present-day Iraq, is known as the 'Cradle of Civilization' because it was home to some of the earliest civilizations in the world. The region got its name from the ancient Greek words for 'land between the rivers,' referring to the Tigris and Euphrates rivers. These rivers provided water for the fertile land, making it perfect for farming. The regular flooding of the rivers made the land around them ideal for growing crops, which helped people settle down and form permanent villages. These villages eventually grew into cities, where people developed many of the characteristics of civilization, like organized government, complex buildings, and different social classes. The first civilizations in Mesopotamia were the Sumerians, who lived around 5,000 years ago. They invented the world's first written language, called cuneiform, which they used to keep track of things like food supplies and trade. They also developed a system of numbers, which helped them with math and measurement. The Sumerians built impressive cities like Ur, Eridu, and Uruk, which had populations of over 50,000 people. These cities were centers of learning and culture, and they helped spread knowledge and ideas throughout the region. Over time, other civilizations rose and fell in Mesopotamia, including the Akkadians, Babylonians, and Assyrians. Each civilization made its own contributions to the development of human society. The Babylonians are famous for their code of laws, which was one of the first written legal systems in the world. The Assyrians were known for their powerful military and their impressive palaces. Mesopotamia's history is full of amazing inventions and innovations that shaped the world we live in today. The development of civilization in Mesopotamia was not just about the fertile land and the rivers. Changes in climate and the environment also played a role. People had to become more organized and work together to survive. This led to the development of complex societies and governments. Mesopotamia's story is a reminder of how human ingenuity and adaptability can lead to amazing achievements. The 'Cradle of Civilization' is a term that refers to the regions where the earliest known human civilizations emerged. Mesopotamia is a prime example of this, as it was a place where people learned to live together, build cities, and develop new technologies that changed the course of human history. The innovations that came from Mesopotamia, like writing, mathematics, and agriculture, continue to influence our lives today. By studying ancient Mesopotamia, we can learn about the origins of our own civilization and the challenges and triumphs of early humans.",5,SS5 +48,"Benjamin Franklin was a very important person in American history. He was born in Boston, Massachusetts in 1706. He was one of 17 children. Franklin did not go to school for very long. He learned to be a printer from his brother. Franklin was a very smart man. He invented many things, like bifocals, the Franklin stove, and the lightning rod. He also started the first public library in Philadelphia. Franklin was a writer, too. He wrote a book called *Poor Richard's Almanack*. It had many famous sayings, like ""Lost Time is never found again."" Franklin was also a politician. He helped write the Declaration of Independence. He was a diplomat, too. He helped the United States get help from France during the Revolutionary War. He was a very busy man! Franklin was a scientist, a writer, a politician, and an inventor. He was a very important person in American history. Franklin was a very interesting person. He was a scientist who did experiments with electricity. He was a writer who wrote a book of sayings. He was a politician who helped the United States become independent. He was a diplomat who helped the United States get help from other countries. He was a very busy man! Franklin was a very smart man. He was a self-taught man who learned a lot on his own. He was a very creative man who invented many things. He was a very kind man who helped others. He was a very important man who helped shape the United States. Franklin was a very influential person. He was a leader who helped people. He was a thinker who came up with new ideas. He was a writer who shared his thoughts with others. He was a scientist who helped people understand the world. He was a very important person who helped make the United States what it is today.",6,SS6 +49,"Civil rights are rights that all people in a country have. The civil rights of a country apply to all the citizens within its borders. These rights are given by the laws of the country. Civil rights are sometimes thought to be the same as natural rights. In many countries civil rights include freedom of speech, freedom of the press, freedom of religion, and freedom of assembly. Civil rights also include the right to own property and the right to get fair and equal treatment from the government, from other citizens, and from private groups.",3,V3 +50,"Bluetooth is a protocol for wireless communication over short distances. It was developed in the 1990s, to reduce the number of cables. Devices such as mobile phones, laptops, PCs, printers, digital cameras and video game consoles can connect to each other, and exchange information. This is done using radio waves. It can be done securely. Bluetooth is only used for relatively short distances, like a few metres. There are different standards. Data rates vary. Currently, they are at 1-3 MBit per second.",4,V4 From 039c42f61a2e41bae30d2d05e5e91b25adf23350 Mon Sep 17 00:00:00 2001 From: Adnan Rashid Hussain Date: Mon, 16 Mar 2026 22:51:59 -0700 Subject: [PATCH 04/10] fix limit in tests --- sdks/typescript/tests/unit/batch/limits.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/typescript/tests/unit/batch/limits.test.ts b/sdks/typescript/tests/unit/batch/limits.test.ts index d4709b5..c317de2 100644 --- a/sdks/typescript/tests/unit/batch/limits.test.ts +++ b/sdks/typescript/tests/unit/batch/limits.test.ts @@ -48,9 +48,9 @@ describe('getAvailableGroups', () => { expect(group.requiresOpenAIKey).toBe(true); }); - it('text-complexity group enforces a row limit of 100', () => { + it('text-complexity group enforces a row limit of 50', () => { const group = getAvailableGroups().find((g) => g.id === 'text-complexity')!; - expect(group.maxInputRows).toBe(100); + expect(group.maxInputRows).toBe(50); }); }); From bdcf4f6b73ab07a129ada9963d0f09fad35a6da5 Mon Sep 17 00:00:00 2001 From: Adnan Rashid Hussain Date: Tue, 17 Mar 2026 15:32:24 -0700 Subject: [PATCH 05/10] template design updates --- .../typescript/src/batch/report-template.html | 227 ++++++++++-------- 1 file changed, 127 insertions(+), 100 deletions(-) diff --git a/sdks/typescript/src/batch/report-template.html b/sdks/typescript/src/batch/report-template.html index 58b41f7..6c90f8d 100644 --- a/sdks/typescript/src/batch/report-template.html +++ b/sdks/typescript/src/batch/report-template.html @@ -8,20 +8,14 @@ :root { --primary: #242423; --secondary: #6B6A64; - --favorable: #125B3A; - --favorable-bg: #C2ECD9; - --warning: #7E6701; - --warning-bg: #FFFC83; - --critical: #9D1F18; - --critical-bg: #F9DFDF; --informational: #125B3A; --informational-bg: #E0F5EC; --border: #e2e8f0; --neutral-bg: #f1f5f9; --neutral-muted: #64748b; --on-band: #177A4D; - --adjacent: #FFFA55; - --off-target: #9D1F18; + --within-reach: #B79F15; + --off-target: #C4352D; --card-bg: rgba(255,255,255,0.6); --card-shadow: 0 4px 10px 0 rgba(36,36,35,0.1); --radius: 8px; @@ -46,7 +40,7 @@ /* ── Cards ── */ .card { background: var(--card-bg); border-radius: var(--radius); box-shadow: var(--card-shadow); margin-bottom: 20px; overflow: hidden; } - .card-body { padding: 24px; } + .card-body { padding: 16px 20px; } .card-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--secondary); margin-bottom: 16px; } .card-label-sm { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--secondary); } @@ -54,10 +48,10 @@ .tabs { display: flex; border-bottom: 1px solid var(--border); } .tab-btn { padding: 12px 20px; font-size: 14px; font-weight: 500; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; color: var(--secondary); transition: color 0.15s, border-color 0.15s; } .tab-btn:hover { color: var(--primary); } - .tab-btn.active { color: var(--primary); border-bottom-color: var(--favorable); } + .tab-btn.active { color: var(--primary); border-bottom-color: var(--on-band); } .tab-panel { display: none; } .tab-panel.active { display: block; } - .tab-content { padding: 32px 40px; } + .tab-content { padding: 20px 28px; } /* ── Layout grids ── */ .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; } @@ -67,32 +61,28 @@ /* ── Tags ── */ .tag { display: inline-block; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; } - .tag-favorable { background: var(--favorable-bg); color: var(--favorable); } - .tag-warning { background: var(--warning-bg); color: var(--warning); } - .tag-critical { background: var(--critical-bg); color: var(--critical); } - .tag-informational{ background: var(--informational-bg); color: var(--informational); } + .tag-on-band { background: var(--on-band); color: #fff; } + .tag-within-reach { background: var(--within-reach); color: #fff; } + .tag-off-target { background: var(--off-target); color: #fff; } + .tag-informational{ background: var(--neutral-bg); color: var(--neutral-muted); } /* ── GLA stat cards ── */ .gla-card .top-stripe { height: 4px; } - .gla-card.on-band .top-stripe { background: var(--favorable-bg); } - .gla-card.adjacent .top-stripe { background: var(--warning-bg); } - .gla-card.off-target .top-stripe{ background: var(--critical-bg); } - .gla-card.on-band .card-label-sm { color: var(--favorable); } - .gla-card.adjacent .card-label-sm { color: var(--warning); } - .gla-card.off-target .card-label-sm{ color: var(--critical); } + .gla-card.on-band .top-stripe { background: var(--on-band); } + .gla-card.within-reach .top-stripe { background: var(--within-reach); } + .gla-card.off-target .top-stripe { background: var(--off-target); } + .gla-card.on-band .card-label-sm { color: var(--on-band); } + .gla-card.within-reach .card-label-sm { color: var(--within-reach); } + .gla-card.off-target .card-label-sm { color: var(--off-target); } .gla-card .big-num { font-size: 28px; font-weight: 700; margin: 6px 0; } - .gla-card.on-band .big-num { color: var(--favorable); } - .gla-card.adjacent .big-num { color: var(--warning); } - .gla-card.off-target .big-num{ color: var(--critical); } + .gla-card.on-band .big-num { color: var(--on-band); } + .gla-card.within-reach .big-num { color: var(--within-reach); } + .gla-card.off-target .big-num { color: var(--off-target); } .gla-card .desc { font-size: 13px; color: var(--secondary); } /* ── Complexity dimension summary bars ── */ - .cx-dim-row { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; } - .cx-dim-row:last-child { margin-bottom: 0; } - .cx-dim-name { width: 148px; font-size: 13px; font-weight: 500; color: var(--primary); flex-shrink: 0; } - .cx-dim-track { flex: 1; height: 10px; background: var(--border); border-radius: 99px; overflow: hidden; } + .cx-dim-track { height: 10px; background: var(--border); border-radius: 99px; overflow: hidden; } .cx-dim-fill { height: 100%; border-radius: 99px; background: var(--neutral-muted); transition: width 0.4s ease; } - .cx-dim-label { font-size: 12px; color: var(--secondary); white-space: nowrap; min-width: 200px; } /* ── Insights ── */ .number-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--primary); color: #fff; font-size: 14px; font-weight: 600; flex-shrink: 0; } @@ -106,10 +96,12 @@ .dist-row .band { width: 60px; font-size: 12px; font-weight: 600; color: var(--primary); flex-shrink: 0; } .dist-row .bars { flex: 1; height: 24px; display: flex; } .dist-row .bar-on { background: var(--on-band); } - .dist-row .bar-adj { background: var(--adjacent); } + .dist-row .bar-wr { background: var(--within-reach); } .dist-row .bar-off { background: var(--off-target); } .dist-row .bars > .bar-seg:first-child { border-radius: 4px 0 0 4px; } .dist-row .bars > .bar-seg:last-child { border-radius: 0 4px 4px 4px; } + .dist-row .bars > .bar-seg.bar-off:last-child { border-radius: 0 4px 4px 0; } + .dist-row .bars > .bar-seg.bar-off:only-child { border-radius: 4px; } .dist-row .bars > .bar-seg:only-child { border-radius: 4px; } .dist-legend { display: flex; justify-content: center; gap: 24px; margin-top: 16px; font-size: 12px; color: var(--primary); } .dist-legend span { display: inline-flex; align-items: center; gap: 6px; } @@ -157,11 +149,7 @@ .heatmap-table td:first-child { font-weight: 600; color: var(--primary); } .heatmap-table .cell-num { text-align: center; } .heatmap-table tr:last-child td { border-bottom: none; } - .heatmap-cell { display: inline-block; padding: 5px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; } - .heatmap-cell.favorable { background: var(--favorable-bg); color: var(--favorable); } - .heatmap-cell.warning { background: var(--warning-bg); color: var(--warning); } - .heatmap-cell.critical { background: var(--critical-bg); color: var(--critical); } - .heatmap-cell.neutral { background: var(--neutral-bg); color: var(--neutral-muted); } + .heatmap-cell { display: inline-block; padding: 5px 12px; border-radius: 6px; font-size: 13px; font-weight: 600; background: var(--neutral-bg); color: var(--neutral-muted); } /* ── Full results table ── */ .results-scroll { overflow-x: auto; overflow-y: auto; max-height: 600px; } @@ -179,7 +167,7 @@ } table.data-table tr:last-child td { border-bottom: none; } table.data-table tbody tr:hover td { background: rgba(0,0,0,0.02); } - table.data-table tbody tr:hover td.frozen { background: #f1f5f9; } + table.data-table tbody tr:hover td.frozen { background: var(--neutral-bg); } /* Frozen (sticky-left) columns */ table.data-table th.frozen, table.data-table td.frozen { position: sticky; background: var(--neutral-bg); z-index: 2; } @@ -248,12 +236,12 @@ complexityStats: [ { evaluatorId: 'vocabulary', name: 'Vocabulary', - average: 2.4, label: 'Moderately Complex', + average: 2.4, label: 'Moderately complex', distribution: [45, 120, 95, 27], }, { evaluatorId: 'sentence-structure', name: 'Sentence Structure', - average: 1.9, label: 'Moderately Complex', + average: 1.9, label: 'Slightly complex', distribution: [88, 105, 72, 22], }, ], @@ -299,9 +287,9 @@ text: 'The water cycle describes how water evaporates from surfaces, rises into the atmosphere, cools and condenses into clouds, and falls back to the ground as precipitation.', __gla_status: 'On Band', __gla_band: '4-5', __gla_reasoning: 'Uses grade-appropriate science vocabulary with a clear explanatory structure suitable for grades 4–5.', - __vocabulary_score: 'moderately complex', + __vocabulary_score: 'Moderately complex', __vocabulary_reasoning: 'Contains domain-specific terms (evaporates, condenses, precipitation) that require pre-teaching for grade 5 students.', - __sentence_structure_score: 'Slightly Complex', + __sentence_structure_score: 'Slightly complex', __sentence_structure_reasoning: 'Primarily compound sentences with clear connective structure appropriate for grade 5.', }, { @@ -309,9 +297,9 @@ text: 'Photosynthesis is the process by which green plants use sunlight, water and carbon dioxide to produce food and oxygen.', __gla_status: 'Adjacent', __gla_band: '4-5', __gla_reasoning: 'Content is accessible but slightly below typical grade 6 complexity expectations.', - __vocabulary_score: 'moderately complex', + __vocabulary_score: 'Moderately complex', __vocabulary_reasoning: 'Key scientific terms are present but relatively straightforward for grade 6 readers.', - __sentence_structure_score: 'Slightly Complex', + __sentence_structure_score: 'Slightly complex', __sentence_structure_reasoning: 'Single main clause with a relative clause; well within grade 6 reading ability.', }, { @@ -319,9 +307,9 @@ text: 'The mitochondria, often described as the powerhouse of the cell, are organelles found in the cytoplasm of eukaryotic cells, where they generate most of the adenosine triphosphate used for cellular energy.', __gla_status: 'Off Target', __gla_band: '11-CCR', __gla_reasoning: 'Text uses advanced biochemical terminology (adenosine triphosphate, eukaryotic) more appropriate for upper secondary or college-level readers.', - __vocabulary_score: 'exceedingly complex', + __vocabulary_score: 'Exceedingly complex', __vocabulary_reasoning: 'High density of Tier 3 domain-specific words significantly exceeds typical grade 8 vocabulary expectations.', - __sentence_structure_score: 'Very Complex', + __sentence_structure_score: 'Very complex', __sentence_structure_reasoning: 'Long, embedded clauses with multiple modifying phrases create significant syntactic complexity for grade 8.', }, { @@ -329,9 +317,9 @@ text: 'Rain falls from clouds when tiny water droplets join together and become heavy enough to fall to the ground.', __gla_status: 'On Band', __gla_band: '2-3', __gla_reasoning: 'Simple vocabulary and sentence structure are appropriate for grades 2–3.', - __vocabulary_score: 'slightly complex', + __vocabulary_score: 'Slightly complex', __vocabulary_reasoning: 'Common everyday vocabulary with no domain-specific terms requiring pre-teaching.', - __sentence_structure_score: 'Slightly Complex', + __sentence_structure_score: 'Slightly complex', __sentence_structure_reasoning: 'Short simple sentences with basic connective structure.', }, { @@ -339,9 +327,9 @@ text: 'Ecosystems are communities of organisms that interact with each other and their physical environment, shaped by both biotic and abiotic factors that influence population dynamics over time.', __gla_status: 'On Band', __gla_band: '9-10', __gla_reasoning: 'Appropriate complexity and terminology for a grade 9–10 biology curriculum.', - __vocabulary_score: 'very complex', + __vocabulary_score: 'Very complex', __vocabulary_reasoning: 'Multiple Tier 3 terms (biotic, abiotic, population dynamics) require strong background knowledge.', - __sentence_structure_score: 'Moderately Complex', + __sentence_structure_score: 'Moderately complex', __sentence_structure_reasoning: 'Compound-complex sentence with a relative clause; manageable for grade 9 readers.', }, { @@ -349,9 +337,9 @@ text: 'Ancient Egyptians built pyramids as tombs for their pharaohs and used a picture-based writing system called hieroglyphics.', __gla_status: 'On Band', __gla_band: '4-5', __gla_reasoning: 'Vocabulary and sentence length are well-matched to grade 4–5 social studies content.', - __vocabulary_score: 'moderately complex', + __vocabulary_score: 'Moderately complex', __vocabulary_reasoning: 'Domain-specific proper nouns (pharaohs, hieroglyphics) may need brief glossing.', - __sentence_structure_score: 'Slightly Complex', + __sentence_structure_score: 'Slightly complex', __sentence_structure_reasoning: 'Two coordinated independent clauses; clear and accessible structure.', }, { @@ -359,9 +347,9 @@ text: 'Shakespeare\'s use of dramatic irony in Othello functions as a mechanism of tragic inevitability, positioning the audience as unwilling witnesses to the protagonist\'s epistemological collapse.', __gla_status: 'On Band', __gla_band: '11-CCR', __gla_reasoning: 'Sophisticated literary analysis vocabulary and complex syntax are well-suited to grades 11–CCR.', - __vocabulary_score: 'exceedingly complex', + __vocabulary_score: 'Exceedingly complex', __vocabulary_reasoning: 'Tier 3 literary and philosophical vocabulary (epistemological, dramatic irony, tragic inevitability) demands high reading proficiency.', - __sentence_structure_score: 'Exceedingly Complex', + __sentence_structure_score: 'Exceedingly complex', __sentence_structure_reasoning: 'Noun phrase and participial phrase stacking creates a dense, highly embedded syntactic structure.', }, { @@ -369,9 +357,9 @@ text: 'The Industrial Revolution transformed European societies by shifting labor from farms to factories, driving rapid urban growth and fundamentally changing how goods were produced and traded.', __gla_status: 'Adjacent', __gla_band: '9-10', __gla_reasoning: 'Vocabulary and conceptual density exceed typical grade 7 expectations; better suited for grades 9–10.', - __vocabulary_score: 'very complex', + __vocabulary_score: 'Very complex', __vocabulary_reasoning: 'Abstract economic and historical vocabulary (urban growth, fundamentally) adds significant reading demand.', - __sentence_structure_score: 'Moderately Complex', + __sentence_structure_score: 'Moderately complex', __sentence_structure_reasoning: 'Participial phrases and coordinated verb phrases add structural complexity but remain readable.', }, ], @@ -385,6 +373,47 @@ var REPORT_DATA = null; // __REPLACED_BY_FORMATTER__ REPORT_DATA = REPORT_DATA || MOCK_REPORT_DATA; + // --------------------------------------------------------------------------- + // Canonical evaluator order + // --------------------------------------------------------------------------- + + const EVALUATOR_ORDER = [ + 'grade-level-appropriateness', + 'subject-matter-knowledge', + 'vocabulary', + 'sentence-structure', + 'conventions', + ]; + + function evalSortIndex(id) { + const i = EVALUATOR_ORDER.indexOf(id); + return i === -1 ? 999 : i; + } + + // Sort meta.evaluatorIds / evaluatorNames in tandem + const _metaPairs = REPORT_DATA.meta.evaluatorIds + .map((id, i) => ({ id, name: REPORT_DATA.meta.evaluatorNames[i] })) + .sort((a, b) => evalSortIndex(a.id) - evalSortIndex(b.id)); + REPORT_DATA.meta.evaluatorIds = _metaPairs.map(x => x.id); + REPORT_DATA.meta.evaluatorNames = _metaPairs.map(x => x.name); + + // Sort complexityStats + REPORT_DATA.complexityStats = [...REPORT_DATA.complexityStats] + .sort((a, b) => evalSortIndex(a.evaluatorId) - evalSortIndex(b.evaluatorId)); + + // Sort complexityHeatmap evaluators and their value columns + const _hmPairs = REPORT_DATA.complexityHeatmap.evaluators + .map((name, i) => ({ name, id: REPORT_DATA.complexityHeatmap.evaluatorIds[i], i })) + .sort((a, b) => evalSortIndex(a.id) - evalSortIndex(b.id)); + REPORT_DATA.complexityHeatmap.evaluators = _hmPairs.map(x => x.name); + REPORT_DATA.complexityHeatmap.evaluatorIds = _hmPairs.map(x => x.id); + REPORT_DATA.complexityHeatmap.values = REPORT_DATA.complexityHeatmap.values + .map(row => _hmPairs.map(x => row[x.i])); + + // Sort fullResults.complexityEvaluators + REPORT_DATA.fullResults.complexityEvaluators = [...REPORT_DATA.fullResults.complexityEvaluators] + .sort((a, b) => evalSortIndex(a.evaluatorId) - evalSortIndex(b.evaluatorId)); + // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- @@ -397,20 +426,14 @@ .replace(/"/g, '"'); } - function hmClass(val) { - if (val === null || val === undefined) return 'neutral'; - if (val < 1.5) return 'favorable'; - if (val < 2.5) return 'warning'; - return 'critical'; - } - function statusBadge(status) { const cls = { - 'On Band': 'tag-favorable', - 'Adjacent': 'tag-warning', - 'Off Target':'tag-critical', + 'On Band': 'tag-on-band', + 'Adjacent': 'tag-within-reach', + 'Off Target':'tag-off-target', }[status] || ''; - return `${esc(status)}`; + const display = status === 'Adjacent' ? 'Within Reach' : status; + return `${esc(display)}`; } // --------------------------------------------------------------------------- @@ -462,24 +485,26 @@

Evaluation Report

Snapshot
-
+
Evaluators
${meta.evaluatorNames.map(n => `${esc(n)}`).join('')}
-
- Rows Processed - ${meta.processedRows} of ${meta.totalRows} -
-
- Rows Errored - ${meta.erroredRows} -
-
- Source File - ${esc(meta.csvPath)} +
+
+ Rows Processed + ${meta.processedRows} of ${meta.totalRows} +
+
+ Errors / Skipped + ${meta.erroredRows} +
+
+ Source File + ${esc(meta.csvPath)} +
@@ -494,15 +519,15 @@

Evaluation Report

On Band
${gls.onBandPct}%
-
${gls.onBand} texts match the target grade band
+
${gls.onBand} of ${meta.processedRows} rows where the evaluated grade band matches intended
-
+
-
Adjacent
+
Within Reach
${gls.adjacentPct}%
-
${gls.adjacent} texts within one band of target
+
${gls.adjacent} rows where the alternative grade band aligns with intended
@@ -510,7 +535,7 @@

Evaluation Report

Off Target
${gls.offTargetPct}%
-
${gls.offTarget} texts need review
+
${gls.offTarget} rows where neither evaluated nor alternative matches intended
@@ -519,16 +544,16 @@

Evaluation Report

// ── Complexity dimension summary card ── const cxHtml = complexityStats.length > 0 ? (() => { const totalRows = meta.processedRows || meta.totalRows; - const rows = complexityStats.map(cs => { + const items = complexityStats.map(cs => { const pct = Math.round((cs.average / 4.0) * 100); const labelText = cs.average > 0 ? `${esc(cs.label)} (${cs.average.toFixed(1)} / 4.0)` : '—'; return ` -
- ${esc(cs.name)} +
+ ${esc(cs.name)}
- ${labelText} + ${labelText}
`; }).join(''); @@ -536,7 +561,9 @@

Evaluation Report

Text Complexity Dimensions (avg. across ${totalRows} rows)
- ${rows} +
+ ${items} +
`; @@ -544,11 +571,11 @@

Evaluation Report

// ── Insights ── const insightsHtml = ` -
+