Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 9 additions & 12 deletions sdks/typescript/src/evaluators/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import type { LLMProvider } from '../providers/index.js';
*/
export const VALIDATION_LIMITS = {
/** Minimum text length in characters */
MIN_TEXT_LENGTH: 10,
/** Maximum text length in characters (100K chars ≈ 25K tokens) */
MAX_TEXT_LENGTH: 100_000,
MIN_TEXT_LENGTH: 1,
/** Maximum text length in characters */
MAX_TEXT_LENGTH: 10_000,
} as const;

/**
Expand Down Expand Up @@ -460,23 +460,20 @@ export abstract class BaseEvaluator {
textLength: text.length,
});

// Check if text is empty or only whitespace
const trimmedText = text.trim();
if (!trimmedText) {
// Rejected, not repaired — the bounds below measure the text as sent.
if (!text.trim()) {
throw new InputValidationError('Text cannot be empty or contain only whitespace');
}

// Check minimum length
if (trimmedText.length < VALIDATION_LIMITS.MIN_TEXT_LENGTH) {
if (text.length < VALIDATION_LIMITS.MIN_TEXT_LENGTH) {
throw new InputValidationError(
`Text is too short. Minimum length is ${VALIDATION_LIMITS.MIN_TEXT_LENGTH} characters, received ${trimmedText.length} characters`
`Text is too short. Minimum length is ${VALIDATION_LIMITS.MIN_TEXT_LENGTH} characters, received ${text.length} characters`
);
}

// Check maximum length
if (trimmedText.length > VALIDATION_LIMITS.MAX_TEXT_LENGTH) {
if (text.length > VALIDATION_LIMITS.MAX_TEXT_LENGTH) {
throw new InputValidationError(
`Text is too long. Maximum length is ${VALIDATION_LIMITS.MAX_TEXT_LENGTH.toLocaleString()} characters, received ${trimmedText.length.toLocaleString()} characters`
`Text is too long. Maximum length is ${VALIDATION_LIMITS.MAX_TEXT_LENGTH.toLocaleString()} characters, received ${text.length.toLocaleString()} characters`
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,8 @@ describe('GradeLevelAppropriatenessEvaluator - Evaluation Flow', () => {
.rejects.toThrow(InputValidationError);
});

it('should throw InputValidationError for text that is too short', async () => {
await expect(evaluator.evaluate('Hi'))
it('should throw InputValidationError for whitespace-only text', async () => {
await expect(evaluator.evaluate(' '))
.rejects.toThrow(InputValidationError);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ describe('IntertextualityEvaluator - Validation', () => {
expect(mockProvider.generateStructured).not.toHaveBeenCalled();
});

it('rejects text below minimum length', async () => {
await expect(evaluator.evaluate('Short', '5')).rejects.toThrow();
it('rejects whitespace-only text', async () => {
await expect(evaluator.evaluate(' ', '5')).rejects.toThrow(/empty or contain only whitespace/);
expect(mockProvider.generateStructured).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ describe('OrganizationalStructureEvaluator - Validation', () => {
expect(mockProvider.generateStructured).not.toHaveBeenCalled();
});

it('rejects text below minimum length', async () => {
await expect(evaluator.evaluate('Short', '5')).rejects.toThrow();
it('rejects whitespace-only text', async () => {
await expect(evaluator.evaluate(' ', '5')).rejects.toThrow(/empty or contain only whitespace/);
expect(mockProvider.generateStructured).not.toHaveBeenCalled();
});

Expand Down
4 changes: 2 additions & 2 deletions sdks/typescript/tests/unit/evaluators/purpose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ describe('PurposeEvaluator - Validation', () => {
expect(mockProvider.generateStructured).not.toHaveBeenCalled();
});

it('rejects text below minimum length', async () => {
await expect(evaluator.evaluate('Short', '5')).rejects.toThrow();
it('rejects whitespace-only text', async () => {
await expect(evaluator.evaluate(' ', '5')).rejects.toThrow(/empty or contain only whitespace/);
expect(mockProvider.generateStructured).not.toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@

describe('evaluate()', () => {
let evaluator: TextComplexityEvaluator;
let vocabSpy: any;

Check warning on line 97 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
let sentenceSpy: any;

Check warning on line 98 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
let smkSpy: any;

Check warning on line 99 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
let conventionalitySpy: any;

Check warning on line 100 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type

beforeEach(() => {
evaluator = new TextComplexityEvaluator({
Expand All @@ -107,7 +107,7 @@
});

// Mock the child evaluators' evaluate methods
vocabSpy = vi.spyOn((evaluator as any).vocabularyEvaluator, 'evaluate').mockResolvedValue({

Check warning on line 110 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
score: 'Moderately complex',
reasoning: 'Vocabulary test reasoning',
metadata: {
Expand All @@ -119,7 +119,7 @@
_internal: {},
});

sentenceSpy = vi.spyOn((evaluator as any).sentenceStructureEvaluator, 'evaluate').mockResolvedValue({

Check warning on line 122 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
score: 'Moderately complex',
reasoning: 'Sentence structure test reasoning',
metadata: {
Expand All @@ -131,7 +131,7 @@
_internal: {},
});

smkSpy = vi.spyOn((evaluator as any).smkEvaluator, 'evaluate').mockResolvedValue({

Check warning on line 134 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
score: 'Moderately complex',
reasoning: 'Subject matter knowledge test reasoning',
metadata: {
Expand All @@ -143,7 +143,7 @@
_internal: {},
});

conventionalitySpy = vi.spyOn((evaluator as any).conventionalityEvaluator, 'evaluate').mockResolvedValue({

Check warning on line 146 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
score: 'Moderately complex',
reasoning: 'Conventionality test reasoning',
metadata: {
Expand Down Expand Up @@ -182,9 +182,6 @@
await expect(evaluator.evaluate(' ', '5')).rejects.toThrow(
'Text cannot be empty or contain only whitespace'
);
await expect(evaluator.evaluate('abc', '5')).rejects.toThrow(
'Text is too short'
);
});

it('should validate grade input', async () => {
Expand Down Expand Up @@ -431,7 +428,7 @@
});

// Check that limit is defined
expect((evaluator as any).limit).toBeDefined();

Check warning on line 431 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type

const text = 'The cat sat on the mat.';
const grade = '5';
Expand All @@ -439,7 +436,7 @@
await evaluator.evaluate(text, grade);

// The limit should have been used (both calls go through it)
expect((evaluator as any).limit).toBeDefined();

Check warning on line 439 in sdks/typescript/tests/unit/evaluators/text-complexity.test.ts

View workflow job for this annotation

GitHub Actions / 👖 Lint

Unexpected any. Specify a different type
});
});
});
127 changes: 127 additions & 0 deletions sdks/typescript/tests/unit/evaluators/text-length-reporting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConventionalityEvaluator } from '../../../src/evaluators/conventionality.js';
import { VALIDATION_LIMITS } from '../../../src/evaluators/base.js';
import { InputValidationError } from '../../../src/errors.js';
import type { LLMProvider } from '../../../src/providers/base.js';

/**
* One length, measured on the caller's text, used for the bounds, the log line,
* the telemetry event and the prompt. The SDK does not normalize input: padding
* is the caller's to remove, not ours to repair behind their back.
*/

const mockProvider: LLMProvider = {
label: 'google:gemini-3-flash-preview',
generateStructured: vi.fn(),
generateText: vi.fn(),
};

vi.mock('../../../src/providers/index.js', () => ({
createProvider: vi.fn(() => mockProvider),
}));

// Captures what would go on the wire, rather than the argument passed in.
const sent: Array<Record<string, unknown>> = [];
vi.mock('../../../src/telemetry/client.js', () => ({
TelemetryClient: class MockTelemetryClient {
send = vi.fn(async (event: Record<string, unknown>) => {
sent.push(event);
});
},
}));

const CORE = 'The author sustains irony throughout to critique civilized society.';
const evaluator = () => new ConventionalityEvaluator({ googleApiKey: 'k' });

beforeEach(() => {
vi.clearAllMocks();
sent.length = 0;
vi.mocked(mockProvider.generateStructured).mockResolvedValue({
data: { complexity_level: 'Very complex', reasoning: 'irony' },
model: 'gemini-3-flash-preview',
usage: { inputTokens: 10, outputTokens: 5 },
latencyMs: 1,
});
});

describe('text_length_chars is the length of what the caller sent', () => {
it('counts padding, because the model receives it too', async () => {
const padded = `\n\n ${CORE} \n\n`;
await evaluator().evaluate(padded, '10');

expect(sent).toHaveLength(1);
expect(sent[0].text_length_chars).toBe(padded.length);
expect(sent[0].text_length_chars).not.toBe(CORE.length);
});

it('reports the same length on the failure path', async () => {
vi.mocked(mockProvider.generateStructured).mockRejectedValue(new Error('upstream'));
const padded = ` ${CORE} `;

await evaluator().evaluate(padded, '10').catch(() => undefined);

expect(sent).toHaveLength(1);
expect(sent[0].status).toBe('error');
expect(sent[0].text_length_chars).toBe(padded.length);
});

// The reported number has to describe the payload, so it must agree with what
// the prompt actually carried.
it('agrees with the text sent to the model', async () => {
const padded = ` ${CORE} `;
await evaluator().evaluate(padded, '10');

const prompt = vi.mocked(mockProvider.generateStructured).mock.calls[0][0].messages
.map((m) => m.content)
.join('');
expect(prompt).toContain(padded);
expect(sent[0].text_length_chars).toBe(padded.length);
});
});

describe('validation measures the caller\'s text, unmodified', () => {
it('is 10,000 characters', () => {
expect(VALIDATION_LIMITS.MAX_TEXT_LENGTH).toBe(10_000);
});

it('accepts text exactly at the bound', async () => {
const atBound = 'a'.repeat(VALIDATION_LIMITS.MAX_TEXT_LENGTH);
await expect(evaluator().evaluate(atBound, '10')).resolves.toBeDefined();
});

it('rejects one character past the bound', async () => {
const overBound = 'a'.repeat(VALIDATION_LIMITS.MAX_TEXT_LENGTH + 1);
await expect(evaluator().evaluate(overBound, '10')).rejects.toThrow(InputValidationError);
});

// The deliberate consequence: padding is not silently absorbed. Text that
// only fits once trimmed is rejected, and the caller is told the real length.
it('rejects text pushed past the bound by padding alone', async () => {
const atBound = 'a'.repeat(VALIDATION_LIMITS.MAX_TEXT_LENGTH);
await expect(evaluator().evaluate(` ${atBound} `, '10')).rejects.toThrow(
/Maximum length is 10,000 characters, received 10,004 characters/
);
});

// The minimum carries no product opinion: it excludes empty input and nothing
// else, so padding has nothing to sneak past. Meaningful minimums belong to
// each evaluator's input schema.
it('has a minimum of 1', () => {
expect(VALIDATION_LIMITS.MIN_TEXT_LENGTH).toBe(1);
});

it('accepts a single character', async () => {
await expect(evaluator().evaluate('a', '10')).resolves.toBeDefined();
});

// Trim survives as a validity test only: whitespace-only is rejected outright
// rather than being measured as content.
it.each([' ', '\n\t\n', ' '.repeat(VALIDATION_LIMITS.MIN_TEXT_LENGTH + 5)])(
'rejects whitespace-only input (%j)',
async (blank) => {
await expect(evaluator().evaluate(blank, '10')).rejects.toThrow(
'Text cannot be empty or contain only whitespace'
);
}
);
});
12 changes: 7 additions & 5 deletions sdks/typescript/tests/unit/evaluators/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { VocabularyEvaluator } from '../../../src/evaluators/vocabulary.js';
import { SmkEvaluator } from '../../../src/evaluators/smk.js';
import { VALIDATION_LIMITS, Provider, BaseEvaluator } from '../../../src/evaluators/base.js';
import { ConfigurationError } from '../../../src/errors.js';
import { ConfigurationError, InputValidationError } from '../../../src/errors.js';
import type { LLMProvider } from '../../../src/providers/base.js';
import { createProvider } from '../../../src/providers/index.js';

Expand Down Expand Up @@ -189,10 +189,12 @@ describe('Input Validation - Text Validation', () => {
});

describe('Minimum length validation', () => {
it(`should reject text shorter than ${VALIDATION_LIMITS.MIN_TEXT_LENGTH} characters`, async () => {
const shortText = 'Hello wo'; // 8 chars after trim
await expect(evaluator.evaluate(shortText, '5'))
.rejects.toThrow(`Text is too short. Minimum length is ${VALIDATION_LIMITS.MIN_TEXT_LENGTH} characters, received 8 characters`);
// The SDK minimum excludes empty input only, so short-but-real text is no
// longer a validation failure. Meaningful minimums are declared in each
// evaluator's input schema.
it('does not reject short text as invalid input', async () => {
const error = await evaluator.evaluate('Hello wo', '5').catch((e) => e);
expect(error).not.toBeInstanceOf(InputValidationError);
});
});

Expand Down