Skip to content

Commit bf382b0

Browse files
committed
refactor(ai): coerce model output through the shared paste parsers
1 parent 7c7e1b6 commit bf382b0

1 file changed

Lines changed: 25 additions & 57 deletions

File tree

‎lib/ai/coerce.ts‎

Lines changed: 25 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
1-
// Value coercion for the AI validator: the parsing rules of clipboard paste (3.4.0 design, Part B),
2-
// as small pure functions. Each takes the raw value (usually text) and the column and returns the
3-
// typed value or a reason. Empty input is valid: null, or '' for text columns.
4-
import { toDate } from '../utils/values';
1+
// Value coercion for the AI validator. Text goes through the same parsers as clipboard paste
2+
// (`lib/utils/parsing.ts`), so a value the grid accepts from Excel is accepted from a model and the
3+
// other way round. Model output is JSON, so typed values (a number, a boolean, a Date) are taken as
4+
// they are. Empty input is valid: null, or '' for text columns.
5+
import {
6+
parseBooleanText,
7+
parseDateText,
8+
parseNumberText,
9+
parseSingleSelectText,
10+
type GridParsedValue,
11+
} from '../utils/parsing';
512

613
export type CoerceResult<T = unknown> = { ok: true; value: T } | { ok: false; reason: 'invalidValue' };
714

@@ -14,10 +21,8 @@ export interface CoerceColumn {
1421
const ok = <T>(value: T): CoerceResult<T> => ({ ok: true, value });
1522
const INVALID: CoerceResult<never> = { ok: false, reason: 'invalidValue' };
1623

17-
const SPACE = /\s/;
18-
const CURRENCY = /\p{Sc}/u;
19-
const NUMBER_CHARS = /^[0-9.eE+-]+$/;
20-
const DIGIT = /[0-9]/;
24+
const fromParsed = <T>(parsed: GridParsedValue): CoerceResult<T> =>
25+
parsed.ok ? ok(parsed.value as T) : INVALID;
2126

2227
function isBlank(text: unknown): boolean {
2328
return text == null || (typeof text === 'string' && text.trim() === '');
@@ -31,78 +36,41 @@ export function coerceString(text: unknown): CoerceResult<string> {
3136
return INVALID;
3237
}
3338

34-
/**
35-
* `number` columns: `1234.5`, `1,234.5`, `-12`, `12%` (0.12). Currency symbols and spaces are
36-
* stripped; a comma is a thousands separator and may only appear before the decimal point.
37-
*/
39+
/** `number` columns: a finite number, or text in the paste format (`1,234.5`, `12%`, `$40`). */
3840
export function coerceNumber(text: unknown): CoerceResult<number | null> {
3941
if (isBlank(text)) return ok(null);
4042
if (typeof text === 'number') return Number.isFinite(text) ? ok(text) : INVALID;
4143
if (typeof text !== 'string') return INVALID;
42-
let source = text.trim();
43-
const percent = source.endsWith('%');
44-
if (percent) source = source.slice(0, -1);
45-
let digits = '';
46-
let seenPoint = false;
47-
// A plain loop, not a regex with adjacent quantifiers: this runs on model output of any length.
48-
for (const ch of source) {
49-
if (ch === ',') {
50-
if (seenPoint) return INVALID;
51-
continue;
52-
}
53-
if (SPACE.test(ch) || CURRENCY.test(ch)) continue;
54-
if (ch === '.') seenPoint = true;
55-
digits += ch;
56-
}
57-
if (!NUMBER_CHARS.test(digits) || !DIGIT.test(digits)) return INVALID;
58-
const n = Number(digits);
59-
if (!Number.isFinite(n)) return INVALID;
60-
return ok(percent ? n / 100 : n);
44+
return fromParsed(parseNumberText(text));
6145
}
6246

63-
/**
64-
* `date` columns: ISO `2026-10-07` (a local calendar day), and whatever `Date.parse` accepts.
65-
* Date objects and epoch milliseconds are taken as they are.
66-
*/
47+
/** `date` columns: a Date, epoch milliseconds, or text in the paste format (ISO `2026-10-07` …). */
6748
export function coerceDate(text: unknown): CoerceResult<Date | null> {
6849
if (isBlank(text)) return ok(null);
69-
if (typeof text !== 'string' && typeof text !== 'number' && !(text instanceof Date)) return INVALID;
70-
const date = toDate(text);
71-
return date ? ok(date) : INVALID;
50+
if (text instanceof Date) return Number.isNaN(text.getTime()) ? INVALID : ok(text);
51+
if (typeof text === 'number') return Number.isFinite(text) ? ok(new Date(text)) : INVALID;
52+
if (typeof text !== 'string') return INVALID;
53+
return fromParsed(parseDateText(text));
7254
}
7355

74-
const TRUE_WORDS = new Set(['true', 'yes', '1']);
75-
const FALSE_WORDS = new Set(['false', 'no', '0']);
76-
77-
/** `boolean` columns: `true`/`false`, `yes`/`no`, `1`/`0`, case-insensitive. */
56+
/** `boolean` columns: a boolean, or `true`/`false`, `yes`/`no`, `1`/`0` (case-insensitive). */
7857
export function coerceBoolean(text: unknown): CoerceResult<boolean | null> {
7958
if (isBlank(text)) return ok(null);
8059
if (typeof text === 'boolean') return ok(text);
8160
if (typeof text !== 'string' && typeof text !== 'number') return INVALID;
82-
const word = String(text).trim().toLowerCase();
83-
if (TRUE_WORDS.has(word)) return ok(true);
84-
if (FALSE_WORDS.has(word)) return ok(false);
85-
return INVALID;
61+
return fromParsed(parseBooleanText(String(text)));
8662
}
8763

8864
/**
89-
* `singleSelect` columns: an option value, or an option label, case-insensitive. Returns the
90-
* option's value. Without `valueOptions`, any text is accepted.
65+
* `singleSelect` columns: an option value or label, case-insensitive; returns the option's value.
66+
* Without `valueOptions` the model has nothing to choose from, so any scalar is kept as it is.
9167
*/
9268
export function coerceSingleSelect(text: unknown, colDef: CoerceColumn): CoerceResult<unknown> {
9369
if (isBlank(text)) return ok(null);
9470
if (typeof text !== 'string' && typeof text !== 'number' && typeof text !== 'boolean') return INVALID;
9571
const options = colDef.valueOptions;
9672
if (!options || options.length === 0) return ok(text);
97-
const wanted = String(text).trim().toLowerCase();
98-
for (const option of options) {
99-
const value = option !== null && typeof option === 'object' ? option.value : option;
100-
if (Object.is(value, text) || (value != null && String(value).toLowerCase() === wanted)) return ok(value);
101-
}
102-
for (const option of options) {
103-
if (option !== null && typeof option === 'object' && String(option.label).toLowerCase() === wanted) return ok(option.value);
104-
}
105-
return INVALID;
73+
return fromParsed(parseSingleSelectText(String(text), options));
10674
}
10775

10876
/** Coerces `text` for the column's `type`. */

0 commit comments

Comments
 (0)