Turn a messy CSV into usable data
Load a CSV file received from outside, without knowing its encoding, its delimiter or the type of its columns.
No AI needed
FreeInstantYour data never leaves
The verdict
N0 reads the file as it is, and above all it names what it refuses: the line, the column, the reason, and the fields as they were read. That journal is what makes the two levels above it usable — N1 guesses the schema N0 asks for, N3 works only on the rows N0 rejected — and neither replaces the cleaning. A file of a hundred thousand rows with three bad ones does not justify a hundred thousand calls.
Dialect detection, encoding normalisation, typed coercion with a rejection journal
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Read a messy CSV: detect its dialect, normalise its encoding, coerce its
types, and write down every row it refuses.
Rung N0. Standard library only.
Three jobs, in that order, because each one needs the previous one done.
Bytes have to become text before a delimiter can be counted, and a delimiter
has to be known before a column can be typed.
The third job is the point of the whole snippet. A cleaner that silently drops
the rows it does not understand is worse than no cleaner at all: it hands back
a tidy file and hides the part you needed to look at. Every refusal here says
which line, which column, and why.
"""
import codecs
import csv
import io
import re
from datetime import date
DELIMITERS = (",", ";", "\t", "|")
SAMPLE_LINES = 20
# ---------------------------------------------------------------------------
# 1. Encoding
# ---------------------------------------------------------------------------
BOMS = (
(codecs.BOM_UTF8, "utf-8-sig"),
(codecs.BOM_UTF16_LE, "utf-16"),
(codecs.BOM_UTF16_BE, "utf-16"),
)
def decode_text(data: bytes) -> str:
"""
Turn bytes into text, guessing only when there is nothing else to go on.
A byte order mark is a statement about the file, so it wins. Failing that,
strict UTF-8 either succeeds, and the file is read as UTF-8, or
fails, and the file is read as cp1252, the Windows single-byte encoding
for Western European languages. cp1252 and not Latin-1, which has no euro
sign: a price column would come back with a control character in it.
UTF-32, and UTF-16 without a mark, are not decoded: they come out full of
NUL characters, and `clean_csv` refuses such a file in its journal.
"""
for bom, encoding in BOMS:
if data.startswith(bom):
return data.decode(encoding, errors="replace")
try:
return data.decode("utf-8")
except UnicodeDecodeError:
# Five byte values are undefined in cp1252; replacing them keeps the
# rest of the file readable and marks the damage where it happened.
return data.decode("cp1252", errors="replace")
# ---------------------------------------------------------------------------
# 2. Dialect
# ---------------------------------------------------------------------------
def _count_outside_quotes(line: str, delimiter: str, quote: str) -> int:
"""Count delimiters that separate fields, not those sitting inside one."""
count, inside = 0, False
for char in line:
if char == quote:
inside = not inside
elif char == delimiter and not inside:
count += 1
return count
def detect_dialect(text: str) -> tuple[str, str]:
"""
Guess the delimiter first, then the quote character.
In that order, and not the other way round: counting delimiters needs to
know what a quoted field looks like, but a quote character cannot be
recognised without a delimiter to anchor it against. So the count assumes
the usual double quote, and the quote character is then looked for with
the delimiter in hand.
The candidate that wins is the one whose count is the same on the most
lines. A file separated by semicolons whose free-text column is full of
commas still lands on its feet, because the comma count varies from line
to line while the semicolon count does not.
The sample is twenty physical lines, not twenty records, because the
records cannot be cut out before the dialect is known. A quoted field that
holds line breaks — an address, a comment — is therefore counted line by
line: a file made of such fields is judged on very few of its records.
"""
sample = [line for line in text.split("\n")[:SAMPLE_LINES] if line.strip()]
delimiter, best = ",", (-1.0, -1)
for candidate in DELIMITERS:
counts = [_count_outside_quotes(line, candidate, '"') for line in sample]
if not counts or counts[0] == 0:
continue # absent from the header, so it separates nothing
score = (sum(1 for c in counts if c == counts[0]) / len(counts), counts[0])
if score > best:
delimiter, best = candidate, score
# A quote character only counts when it both opens fields (at the start of
# a line, or straight after the delimiter) and closes them (straight before
# the delimiter, or at the end of a line). A lone apostrophe in front of a
# value opens and closes nothing.
wraps = {}
for quote in ('"', "'"):
lines = [line.rstrip("\r") for line in sample]
opens = sum(line.startswith(quote) + line.count(delimiter + quote) for line in lines)
closes = sum(line.endswith(quote) + line.count(quote + delimiter) for line in lines)
wraps[quote] = min(opens, closes)
return delimiter, "'" if wraps["'"] > wraps['"'] else '"'
# ---------------------------------------------------------------------------
# 3. Types, and the journal of what did not fit
# ---------------------------------------------------------------------------
# ASCII digits only, as in JavaScript: "١٢" is refused rather than read as 12.
_SPACES = re.compile(r"[\s\u00a0\u202f]")
_INTEGER = re.compile(r"[+-]?[0-9]+")
_NUMBER = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)")
_ISO_DATE = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})")
_DAY_FIRST = re.compile(r"([0-9]{2})[/.]([0-9]{2})[/.]([0-9]{4})")
# "1,234": one mark, three digits after it, at most three before. Reads as a
# thousands group or as a decimal, and nothing in the value says which.
_GROUPED = re.compile(r"[+-]?[0-9]{1,3}[,.][0-9]{3}")
TRUE_WORDS = frozenset({"true", "yes", "y", "1", "vrai", "oui", "o"})
FALSE_WORDS = frozenset({"false", "no", "n", "0", "faux", "non"})
def _to_integer(raw: str) -> int:
text = _SPACES.sub("", raw)
if not _INTEGER.fullmatch(text):
raise ValueError("not an integer")
return int(text)
def _to_number(raw: str, decimal: str | None = None) -> float:
"""
Read a number, and refuse rather than guess when the mark is ambiguous.
`decimal` is the convention of the file, declared by the caller: "," or
".". Three cases, in order:
- both marks present, or the same mark twice: the shape settles it. The
last mark of the two is the decimal one, a mark repeated only groups;
- one mark, followed by exactly three digits, with at most three before:
"12,500" is 12.5 under one convention and 12500 under the other. Without
`decimal`, the value goes to the journal rather than being divided by a
thousand in silence;
- one mark that cannot group, because the digits do not fall in threes:
"12,50" is 12.5 whoever wrote it.
A mark that contradicts the declared convention is refused too: under
`decimal="."`, "12,50" is not a number, it is a file read with the wrong
convention.
"""
text = _SPACES.sub("", raw)
if decimal not in (None, ",", "."):
raise ValueError("decimal must be ',' or '.'")
marks = [mark for mark in (",", ".") if mark in text]
if len(marks) == 2:
point = "," if text.rindex(",") > text.rindex(".") else "."
elif len(marks) == 1 and text.count(marks[0]) > 1:
point = None # repeated, so it groups thousands: "1,234,567"
elif len(marks) == 1 and _GROUPED.fullmatch(text):
if decimal is None:
raise ValueError("ambiguous decimal mark: declare decimal=',' or decimal='.'")
point = marks[0] if marks[0] == decimal else None
else:
point = marks[0] if marks else None
if decimal is not None and point is not None and point != decimal:
raise ValueError(f"decimal mark is not the {decimal!r} declared for the file")
grouping = {",", "."} - {point}
for mark in grouping:
text = text.replace(mark, "")
if point:
text = text.replace(point, ".")
if not _NUMBER.fullmatch(text):
raise ValueError("not a number")
return float(text)
def _to_date(raw: str) -> str:
"""Return an ISO date, or refuse. Day-first is assumed outside ISO form."""
text = raw.strip()
iso, day_first = _ISO_DATE.fullmatch(text), _DAY_FIRST.fullmatch(text)
if iso:
year, month, day = iso.groups()
elif day_first:
day, month, year = day_first.groups()
else:
raise ValueError("not a date")
try:
date(int(year), int(month), int(day)) # rejects 31 February and month 13
except ValueError as error:
raise ValueError("not a real date") from error
return f"{year}-{month}-{day}"
def _to_boolean(raw: str) -> bool:
text = raw.strip().lower()
if text in TRUE_WORDS:
return True
if text in FALSE_WORDS:
return False
raise ValueError("not a true or false value")
# Every coercer takes the declared decimal mark, so that the signature does
# not depend on the type: only `_to_number` has anything to do with it.
COERCERS = {
"text": lambda raw, decimal: raw.strip(),
"integer": lambda raw, decimal: _to_integer(raw),
"number": _to_number,
"date": lambda raw, decimal: _to_date(raw),
"boolean": lambda raw, decimal: _to_boolean(raw),
}
def _journal(line: int, column: str, reason: str, fields: list[str]) -> dict:
"""One entry of the journal: where, which column, why, and what was read."""
return {"line": line, "column": column, "reason": reason, "fields": fields}
class Rejected(Exception):
"""One value the schema refuses, carrying the column and the reason."""
def __init__(self, column: str, reason: str) -> None:
super().__init__(f"{column}: {reason}")
self.column = column
self.reason = reason
def coerce_row(
header: list[str], fields: list[str], schema: dict, decimal: str | None = None
) -> dict:
"""
Coerce one row, or refuse it at the first value that does not fit.
First failure wins: a row is refused once, naming the column that caused
it. Whoever repairs the file then has one thing to look at rather than a
list of consequences.
"""
row = {}
for name, raw in zip(header, fields):
value = raw.strip()
if value == "":
row[name] = None # an empty cell is missing, not malformed
continue
try:
row[name] = COERCERS[schema.get(name, "text")](value, decimal)
except ValueError as error:
raise Rejected(name, str(error)) from error
return row
def _unreadable(error: csv.Error) -> str:
"""Name what the csv module could not read, in words both versions share."""
message = str(error)
if "end of data" in message:
return "quote opened and never closed"
if "field limit" in message:
return f"field longer than {csv.field_size_limit()} characters"
return "text after a closing quote"
def read_records(text: str, delimiter: str, quote: str):
"""
Yield `(line, fields, problem)` for each record, `problem` being None when
the record was read.
A record the csv module cannot read is refused with the line it starts on,
and reading resumes on the next line. Without that, one quote opened and
never closed swallows the rest of the file into a single field.
"""
lines = io.StringIO(text, newline="").readlines()
done = 0
while done < len(lines):
rest = (lines[k] for k in range(done, len(lines)))
reader = csv.reader(rest, delimiter=delimiter, quotechar=quote, strict=True)
previous = 0
try:
for fields in reader:
yield done + previous + 1, fields, None
previous = reader.line_num
return
except csv.Error as error:
start = done + previous
yield start + 1, [lines[start].rstrip("\r\n")], _unreadable(error)
done = start + 1
def clean_csv(data: bytes, schema: dict, decimal: str | None = None) -> dict:
"""
Return the rows that survived, and a journal of everything refused.
`data` is the raw bytes of the file and `schema` maps a column name to one
of the keys of COERCERS. A column absent from the schema is kept as text.
`decimal` declares the decimal mark of the file, "," or "."; without it a
value that the two conventions read differently is refused to the journal.
The journal is the whole point. Each entry carries the line, the column
and the reason, plus the fields as they were read, so the refusal can be
acted on without opening the file again. Hand it back to whoever produced
the file, or to rung N3, which repairs those rows and only those.
"""
text = decode_text(data)
delimiter, quote = detect_dialect(text)
if "\x00" in text:
reason = "NUL characters: UTF-16 without a byte order mark, or UTF-32, not read"
return {"columns": [], "delimiter": delimiter, "quote": quote, "rows": [],
"rejects": [_journal(1, "", reason, [])]}
header, rows, rejects, twice = None, [], [], []
for line, fields, problem in read_records(text, delimiter, quote):
if problem:
rejects.append(_journal(line, "", problem, fields))
continue
if not fields or fields == [""]:
continue # a blank line carries nothing, in any dialect
if header is None:
header = [name.strip() for name in fields]
twice = sorted({name for name in header if header.count(name) > 1})
continue
if twice: # a row keyed by name would lose one of the two values
rejects.append(_journal(line, twice[0], "column name used twice", fields))
continue
if len(fields) != len(header):
plural = "" if len(header) == 1 else "s"
reason = f"expected {len(header)} field{plural}, found {len(fields)}"
rejects.append(_journal(line, "", reason, fields))
continue
try:
rows.append(coerce_row(header, fields, schema, decimal))
except Rejected as refusal:
rejects.append(_journal(line, refusal.column, refusal.reason, fields))
return {
"columns": header or [],
"delimiter": delimiter,
"quote": quote,
"rows": rows,
"rejects": rejects,
}JavaScript
/**
* Read a messy CSV: detect its dialect, normalise its encoding, coerce its
* types, and write down every row it refuses.
*
* Rung N0. Node's standard library only, parser included.
*
* Three jobs, in that order, because each one needs the previous one done.
* Bytes have to become text before a delimiter can be counted, and a delimiter
* has to be known before a column can be typed.
*
* The third job is the point of the whole snippet. A cleaner that silently
* drops the rows it does not understand is worse than no cleaner at all: it
* hands back a tidy file and hides the part you needed to look at. Every
* refusal here says which line, which column, and why.
*/
const DELIMITERS = [',', ';', '\t', '|'];
const SAMPLE_LINES = 20;
// ---------------------------------------------------------------------------
// 1. Encoding
// ---------------------------------------------------------------------------
const BOMS = [
[[0xef, 0xbb, 0xbf], 'utf-8'],
[[0xff, 0xfe], 'utf-16le'],
[[0xfe, 0xff], 'utf-16be'],
];
// The five byte values cp1252 leaves undefined. Python's codec refuses them,
// so they are mapped to the replacement character here too: both versions of
// this snippet have to return the same text for the same bytes.
const UNDEFINED_IN_CP1252 = /[\u0081\u008d\u008f\u0090\u009d]/g;
/**
* Turn bytes into text, guessing only when there is nothing else to go on.
*
* A byte order mark is a statement about the file, so it wins. Failing that,
* strict UTF-8 either succeeds, and the file is read as UTF-8, or
* fails, and the file is read as cp1252, the Windows single-byte encoding for
* Western European languages. cp1252 and not Latin-1, which has no euro
* sign: a price column would come back with a control character in it.
*
* UTF-32, and UTF-16 without a mark, are not decoded: they come out full of
* NUL characters, and `cleanCsv` refuses such a file in its journal.
*
* @param {Uint8Array} data
* @returns {string}
*/
export function decodeText(data) {
for (const [bom, encoding] of BOMS) {
if (bom.every((byte, i) => data[i] === byte)) {
// The decoder drops the mark itself, which is what we want.
return new TextDecoder(encoding).decode(data);
}
}
try {
return new TextDecoder('utf-8', { fatal: true }).decode(data);
} catch {
return new TextDecoder('windows-1252').decode(data).replace(UNDEFINED_IN_CP1252, '\ufffd');
}
}
// ---------------------------------------------------------------------------
// 2. Dialect
// ---------------------------------------------------------------------------
/** Count delimiters that separate fields, not those sitting inside one. */
function countOutsideQuotes(line, delimiter, quote) {
let count = 0;
let inside = false;
for (const char of line) {
if (char === quote) inside = !inside;
else if (char === delimiter && !inside) count += 1;
}
return count;
}
/**
* Guess the delimiter first, then the quote character.
*
* In that order, and not the other way round: counting delimiters needs to
* know what a quoted field looks like, but a quote character cannot be
* recognised without a delimiter to anchor it against. So the count assumes
* the usual double quote, and the quote character is then looked for with the
* delimiter in hand.
*
* The candidate that wins is the one whose count is the same on the most
* lines. A file separated by semicolons whose free-text column is full of
* commas still lands on its feet, because the comma count varies from line to
* line while the semicolon count does not.
*
* The sample is twenty physical lines, not twenty records, because the records
* cannot be cut out before the dialect is known. A quoted field that holds line
* breaks — an address, a comment — is therefore counted line by line: a file
* made of such fields is judged on very few of its records.
*
* @returns {{delimiter: string, quote: string}}
*/
export function detectDialect(text) {
const sample = text.split('\n').slice(0, SAMPLE_LINES).filter((line) => line.trim() !== '');
let delimiter = ',';
let best = [-1, -1];
for (const candidate of DELIMITERS) {
const counts = sample.map((line) => countOutsideQuotes(line, candidate, '"'));
if (counts.length === 0 || counts[0] === 0) continue; // separates nothing
const agree = counts.filter((c) => c === counts[0]).length / counts.length;
const score = [agree, counts[0]];
if (score[0] > best[0] || (score[0] === best[0] && score[1] > best[1])) {
delimiter = candidate;
best = score;
}
}
// A quote character only counts when it both opens fields (at the start of
// a line, or straight after the delimiter) and closes them (straight before
// the delimiter, or at the end of a line). A lone apostrophe in front of a
// value opens and closes nothing.
const occurrences = (line, part) => line.split(part).length - 1;
const wraps = {};
for (const quote of ['"', "'"]) {
const lines = sample.map((line) => line.replace(/\r$/, ''));
const opens = lines.reduce((n, line) => n + line.startsWith(quote) + occurrences(line, delimiter + quote), 0);
const closes = lines.reduce((n, line) => n + line.endsWith(quote) + occurrences(line, quote + delimiter), 0);
wraps[quote] = Math.min(opens, closes);
}
return { delimiter, quote: wraps["'"] > wraps['"'] ? "'" : '"' };
}
// ---------------------------------------------------------------------------
// 3. The parser
// ---------------------------------------------------------------------------
// The default field limit of Python's csv module, so that both versions of this
// snippet refuse the same fields.
const FIELD_LIMIT = 131072;
const LINE_BREAK = /\r\n|\r|\n/g;
/**
* Read records, with the line each one starts on and the problem, if any.
*
* Quoted fields, doubled quotes, delimiters and newlines inside a quoted
* field. A quote only opens a field at the start of one, which is what lets
* `a"b` stay the three characters somebody actually typed.
*
* A record that cannot be read — a quote opened and never closed, text after a
* closing quote, a field over the limit — is refused with the line it starts
* on, and reading resumes on the next line. Without that, one quote opened and
* never closed swallows the rest of the file into a single field.
*
* @returns {Array<[number, string[], string?]>} the problem only on a refused record
*/
export function parseRecords(text, delimiter, quote) {
const records = [];
let fields = [];
let field = '';
let quoted = false;
let closed = false;
let atFieldStart = true;
let line = 1;
let start = 1;
let recordAt = 0;
const begin = (at, lineNumber) => {
[fields, field, quoted, closed, atFieldStart] = [[], '', false, false, true];
[recordAt, line, start] = [at, lineNumber, lineNumber];
};
// Refuse the record being read, and return where reading resumes.
const refuse = (problem) => {
LINE_BREAK.lastIndex = recordAt;
const found = LINE_BREAK.exec(text);
const end = found ? found.index : text.length;
records.push([start, [text.slice(recordAt, end)], problem]);
begin(found ? end + found[0].length : text.length, start + 1);
return recordAt;
};
for (let i = 0; i <= text.length; i += 1) {
if (i === text.length) {
if (!quoted) break;
i = refuse('quote opened and never closed') - 1;
continue;
}
const char = text[i];
if (quoted) {
if (char === quote && text[i + 1] === quote) {
field += quote; // a doubled quote is one literal quote
i += 1;
} else if (char === quote) {
quoted = false;
closed = true;
} else {
if (char === '\n') line += 1;
field += char;
}
} else if (closed && char !== delimiter && char !== '\n' && char !== '\r') {
i = refuse('text after a closing quote') - 1;
continue;
} else if (char === quote && atFieldStart) {
quoted = true;
atFieldStart = false;
} else if (char === delimiter) {
fields.push(field);
field = '';
closed = false;
atFieldStart = true;
} else if (char === '\n' || char === '\r') {
if (char === '\r' && text[i + 1] === '\n') i += 1;
fields.push(field);
records.push([start, fields]);
begin(i + 1, line + 1);
} else {
field += char;
atFieldStart = false;
}
if (field.length > FIELD_LIMIT) {
i = refuse(`field longer than ${FIELD_LIMIT} characters`) - 1;
}
}
// A file that does not end with a newline still ends with a record.
if (field !== '' || fields.length > 0 || closed) {
fields.push(field);
records.push([start, fields]);
}
return records;
}
// ---------------------------------------------------------------------------
// 4. Types, and the journal of what did not fit
// ---------------------------------------------------------------------------
const SPACES = /[\s\u00a0\u202f]/g;
const INTEGER = /^[+-]?\d+$/;
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)$/;
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const DAY_FIRST = /^(\d{2})[/.](\d{2})[/.](\d{4})$/;
// '1,234': one mark, three digits after it, at most three before. Reads as a
// thousands group or as a decimal, and nothing in the value says which.
const GROUPED = /^[+-]?\d{1,3}[,.]\d{3}$/;
const MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const TRUE_WORDS = new Set(['true', 'yes', 'y', '1', 'vrai', 'oui', 'o']);
const FALSE_WORDS = new Set(['false', 'no', 'n', '0', 'faux', 'non']);
function toInteger(raw) {
const text = raw.replace(SPACES, '');
if (!INTEGER.test(text)) throw new TypeError('not an integer');
// Past 2^53 a Number is rounded without a word: keep such an integer exact.
const value = Number(text);
return Number.isSafeInteger(value) ? value : BigInt(text);
}
/**
* Read a number, and refuse rather than guess when the mark is ambiguous.
*
* `decimal` is the convention of the file, declared by the caller: ',' or '.'.
* Three cases, in order:
*
* - both marks present, or the same mark twice: the shape settles it. The last
* mark of the two is the decimal one, a mark repeated only groups;
* - one mark, followed by exactly three digits, with at most three before:
* '12,500' is 12.5 under one convention and 12500 under the other. Without
* `decimal`, the value goes to the journal rather than being divided by a
* thousand in silence;
* - one mark that cannot group, because the digits do not fall in threes:
* '12,50' is 12.5 whoever wrote it.
*
* A mark that contradicts the declared convention is refused too: under
* `decimal = '.'`, '12,50' is not a number, it is a file read with the wrong
* convention.
*/
function toNumber(raw, decimal = null) {
let text = raw.replace(SPACES, '');
if (decimal !== null && decimal !== ',' && decimal !== '.') {
throw new TypeError("decimal must be ',' or '.'");
}
const marks = [',', '.'].filter((mark) => text.includes(mark));
const repeated = (mark) => text.indexOf(mark) !== text.lastIndexOf(mark);
let point;
if (marks.length === 2) {
point = text.lastIndexOf(',') > text.lastIndexOf('.') ? ',' : '.';
} else if (marks.length === 1 && repeated(marks[0])) {
point = null; // repeated, so it groups thousands: '1,234,567'
} else if (marks.length === 1 && GROUPED.test(text)) {
if (decimal === null) {
throw new TypeError("ambiguous decimal mark: declare decimal=',' or decimal='.'");
}
point = marks[0] === decimal ? marks[0] : null;
} else {
point = marks.length === 1 ? marks[0] : null;
}
if (decimal !== null && point !== null && point !== decimal) {
throw new TypeError(`decimal mark is not the '${decimal}' declared for the file`);
}
for (const mark of [',', '.']) {
if (mark !== point) text = text.split(mark).join('');
}
if (point) text = text.split(point).join('.');
if (!NUMBER.test(text)) throw new TypeError('not a number');
return Number(text);
}
function daysInMonth(year, month) {
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
return month === 2 && leap ? 29 : MONTH_LENGTHS[month - 1];
}
/** Return an ISO date, or refuse. Day-first is assumed outside ISO form. */
function toDate(raw) {
const iso = ISO_DATE.exec(raw.trim());
const dayFirst = DAY_FIRST.exec(raw.trim());
if (!iso && !dayFirst) throw new TypeError('not a date');
const [year, month, day] = iso ? iso.slice(1) : [dayFirst[3], dayFirst[2], dayFirst[1]];
// Built-in date objects roll 31 February over to 2 March instead of refusing
// it, so the calendar is checked by hand.
const [y, m, d] = [year, month, day].map(Number);
if (y < 1 || m < 1 || m > 12 || d < 1 || d > daysInMonth(y, m)) {
throw new TypeError('not a real date');
}
return `${year}-${month}-${day}`;
}
function toBoolean(raw) {
const text = raw.trim().toLowerCase();
if (TRUE_WORDS.has(text)) return true;
if (FALSE_WORDS.has(text)) return false;
throw new TypeError('not a true or false value');
}
// Every coercer takes the declared decimal mark, so that the signature does
// not depend on the type: only `toNumber` has anything to do with it.
export const COERCERS = {
text: (raw) => raw.trim(),
integer: (raw) => toInteger(raw),
number: toNumber,
date: (raw) => toDate(raw),
boolean: (raw) => toBoolean(raw),
};
/** One value the schema refuses, carrying the column and the reason. */
export class Rejected extends Error {
constructor(column, reason) {
super(`${column}: ${reason}`);
this.column = column;
this.reason = reason;
}
}
/**
* Coerce one row, or refuse it at the first value that does not fit.
*
* First failure wins: a row is refused once, naming the column that caused it.
* Whoever repairs the file then has one thing to look at rather than a list of
* consequences.
*/
export function coerceRow(header, fields, schema, decimal = null) {
const row = {};
header.forEach((name, i) => {
const value = (fields[i] ?? '').trim();
const coerce = COERCERS[schema[name] ?? 'text'];
if (!coerce) throw new Error(`unknown type ${schema[name]} for column ${name}`);
// An empty cell is missing, not malformed.
if (value === '') row[name] = null;
else {
try {
row[name] = coerce(value, decimal);
} catch (error) {
throw new Rejected(name, error.message);
}
}
});
return row;
}
/**
* Return the rows that survived, and a journal of everything refused.
*
* `data` is the raw bytes of the file and `schema` maps a column name to one
* of the keys of COERCERS. A column absent from the schema is kept as text.
* `decimal` declares the decimal mark of the file, ',' or '.'; without it a
* value that the two conventions read differently is refused to the journal.
*
* The journal is the whole point. Each entry carries the line, the column and
* the reason, plus the fields as they were read, so the refusal can be acted
* on without opening the file again. Hand it back to whoever produced the
* file, or to rung N3, which repairs those rows and only those.
*
* @param {Uint8Array} data
* @param {Record<string,string>} schema
* @param {','|'.'|null} decimal the decimal mark declared for the file
*/
export function cleanCsv(data, schema, decimal = null) {
const text = decodeText(data);
const { delimiter, quote } = detectDialect(text);
if (text.includes('\0')) {
const reason = 'NUL characters: UTF-16 without a byte order mark, or UTF-32, not read';
return { columns: [], delimiter, quote, rows: [], rejects: [{ line: 1, column: '', reason, fields: [] }] };
}
let header = null;
let twice = [];
const rows = [];
const rejects = [];
for (const [line, fields, problem] of parseRecords(text, delimiter, quote)) {
if (problem) {
rejects.push({ line, column: '', reason: problem, fields });
continue;
}
// A blank line carries nothing, in any dialect.
if (fields.length === 0 || (fields.length === 1 && fields[0] === '')) continue;
if (header === null) {
header = fields.map((name) => name.trim());
twice = [...new Set(header.filter((name, i) => header.indexOf(name) !== i))].sort();
} else if (twice.length > 0) {
// A row keyed by name would lose one of the two values.
rejects.push({ line, column: twice[0], reason: 'column name used twice', fields });
} else if (fields.length !== header.length) {
const plural = header.length === 1 ? '' : 's';
const reason = `expected ${header.length} field${plural}, found ${fields.length}`;
rejects.push({ line, column: '', reason, fields });
} else {
try {
rows.push(coerceRow(header, fields, schema, decimal));
} catch (refusal) {
if (!(refusal instanceof Rejected)) throw refusal;
rejects.push({ line, column: refusal.column, reason: refusal.reason, fields });
}
}
}
return { columns: header ?? [], delimiter, quote, rows, rejects };
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
The expected columns are id, client, date, montant, payee. A refused line keeps its place and its reason: that is what this snippet does best.
Or start from an example
Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.
What it returns
| Line | id | client | date | montant | payee | Journal |
|---|---|---|---|---|---|---|
| 2 | 41 | Boulangerie Martin | 2023-04-12 | 12.5 | yes | kept |
| 3 | 42 | Café de la Gare | 2023-05-01 | 1234.56 | no | kept |
| 4 | 43 | Menuiserie Dubois | 2023-06-30 | 0.99 | yes | kept |
Delimiter detected: “;”. 3 rows kept, 0 refused.
Here it breaksSee the level above
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- No provider in the loop
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the file is read where it already sits
- The rejection journal copies the fields exactly as they were read, and so inherits whatever the source file holds
Breaking point
A column that changes meaning partway through the file without changing shape. An export that switches from day-first to month-first returns « 07/04/2023 » twice, read twice as the seventh of April, with nothing in the journal: only « 12/25/2023 » is rejected, and that is luck, because no month has twenty-five days. A second export appended below the first with a different delimiter fares better: the dialect is decided once, from the top, and every one of its lines is refused with « expected 3 fields, found 1 ».
When to move up a level
You open an export whose columns nobody documented, and writing its schema by hand never happens.
The four options
- RecommendedN0Dialect detection, encoding normalisation, typed coercion with a rejection journalFree · Instant
- N1Column type inference by logistic regression on sample traitsnégligeable · Instant
- N2Not worth it here
- N3Repairing only the rows N0 rejected, through a general-purpose modelélevé · About a second
The other options
N1Column type inference by logistic regression on sample traitsnégligeable · Instant
Column type inference by logistic regression on sample traits
Lightweight classic modelN1négligeableInstant
Python
"""
Guess what each column holds, instead of writing the schema by hand.
Rung N1. Rung N0 needs a schema: somebody has to declare that `joined` holds
dates and `amount` holds numbers. On a file with three columns that takes a
minute. On the two hundred columns of an export nobody documented, it does not
happen, and the file gets loaded as text.
The classifier never looks at a value on its own. It looks at eight traits of
a column taken as a whole — how often a value is all digits, how often it
carries a decimal mark, how many distinct values there are — and learns which
combination goes with which type, from columns labelled by hand.
The output is exactly the schema `clean_csv` of rung N0 takes as its second
argument. This rung replaces the typing, not the cleaning.
"""
import re
import numpy as np
from sklearn.linear_model import LogisticRegression
_SPACES = re.compile(r"[\s\u00a0\u202f]")
_ALL_DIGITS = re.compile(r"[+-]?[0-9]+")
_DECIMAL = re.compile(r"[+-]?[0-9]*[.,][0-9]+")
_DATE_SHAPE = re.compile(r"[0-9]{2,4}[-/.][0-9]{1,2}[-/.][0-9]{2,4}")
_LETTER = re.compile(r"[^\W\d_]")
BOOLEAN_WORDS = frozenset(
{"true", "false", "yes", "no", "y", "n", "0", "1", "vrai", "faux", "oui", "non"}
)
LONG_VALUE = 20 # a length past which a column reads as free text
FEATURE_NAMES = (
"all digits",
"decimal mark",
"date shape",
"contains a letter",
"boolean word",
"distinct ratio",
"mean length",
"empty ratio",
)
def column_features(values: list[str]) -> list[float]:
"""
Describe one column with eight numbers, all between zero and one.
Blank values are set aside before the proportions are taken, and counted
separately: a column that is mostly empty is a fact about the file, not
about the type.
The distinct ratio depends on the size of the sample: it falls as the
sample grows. Small integers repeated, 0 to 3, read as integer on eight
rows and as boolean on two hundred, and rung N0 then refuses every 2 and
every 3, in its journal.
"""
filled = [value.strip() for value in values if value.strip()]
if not filled:
return [0.0] * len(FEATURE_NAMES)
bare = [_SPACES.sub("", value) for value in filled]
count = len(filled)
return [
sum(1 for v in bare if _ALL_DIGITS.fullmatch(v)) / count,
sum(1 for v in bare if _DECIMAL.fullmatch(v)) / count,
sum(1 for v in bare if _DATE_SHAPE.fullmatch(v)) / count,
sum(1 for v in filled if _LETTER.search(v)) / count,
sum(1 for v in filled if v.lower() in BOOLEAN_WORDS) / count,
len(set(filled)) / count,
min(sum(len(v) for v in filled) / count, LONG_VALUE) / LONG_VALUE,
(len(values) - count) / len(values),
]
def train(columns: list[list[str]], labels: list[str]):
"""
`columns` is a list of column samples, `labels` the type name of each.
The type names are the ones rung N0 coerces to: text, integer, number,
date, boolean.
"""
model = LogisticRegression(max_iter=1000, C=10.0)
model.fit(np.array([column_features(column) for column in columns]), labels)
return model
def classify(model, values: list[str]) -> str:
"""
The type of one column.
A column with nothing in it is text, and the model is not consulted:
there is no evidence to weigh, and text is the type that loses nothing.
"""
if not any(value.strip() for value in values):
return "text"
return str(model.predict(np.array([column_features(values)]))[0])
def infer_schema(model, header: list[str], rows: list[list[str]], sample: int = 200) -> dict:
"""
Build the schema that `clean_csv` of rung N0 asks for.
Only the first `sample` rows are read. On a file sorted, or one that
changes as it goes, they are not the whole column: whole amounts on the
first two hundred rows and decimals after give integer, and rung N0 then
refuses every decimal, in its journal.
"""
columns = [[row[i] if i < len(row) else "" for row in rows[:sample]] for i in range(len(header))]
schema = dict.fromkeys(header, "text") # a column with nothing in it stays text
filled = [i for i, values in enumerate(columns) if any(value.strip() for value in values)]
if filled:
# One call to the model for the whole file, not one per column.
kinds = model.predict(np.array([column_features(columns[i]) for i in filled]))
for i, kind in zip(filled, kinds):
schema[header[i]] = str(kind)
return schemaJavaScript
/**
* Guess what each column holds, instead of writing the schema by hand.
*
* Rung N1. Rung N0 needs a schema: somebody has to declare that `joined` holds
* dates and `amount` holds numbers. On a file with three columns that takes a
* minute. On the two hundred columns of an export nobody documented, it does
* not happen, and the file gets loaded as text.
*
* The classifier never looks at a value on its own. It looks at eight traits
* of a column taken as a whole — how often a value is all digits, how often it
* carries a decimal mark, how many distinct values there are — and learns
* which combination goes with which type, from columns labelled by hand.
*
* Written out rather than pulled from a library: one logistic regression per
* type, fitted by gradient descent.
*
* The output is exactly the schema `cleanCsv` of rung N0 takes as its second
* argument. This rung replaces the typing, not the cleaning.
*/
const SPACES = /[\s\u00a0\u202f]/g;
const ALL_DIGITS = /^[+-]?\d+$/;
const DECIMAL = /^[+-]?\d*[.,]\d+$/;
const DATE_SHAPE = /^\d{2,4}[-/.]\d{1,2}[-/.]\d{2,4}$/;
const LETTER = /\p{L}/u;
const BOOLEAN_WORDS = new Set(
['true', 'false', 'yes', 'no', 'y', 'n', '0', '1', 'vrai', 'faux', 'oui', 'non'],
);
const LONG_VALUE = 20; // a length past which a column reads as free text
export const FEATURE_NAMES = [
'all digits',
'decimal mark',
'date shape',
'contains a letter',
'boolean word',
'distinct ratio',
'mean length',
'empty ratio',
];
/**
* Describe one column with eight numbers, all between zero and one.
*
* Blank values are set aside before the proportions are taken, and counted
* separately: a column that is mostly empty is a fact about the file, not
* about the type.
*
* The distinct ratio depends on the size of the sample: it falls as the
* sample grows. Small integers repeated, 0 to 3, read as integer on eight rows
* and as boolean on two hundred, and rung N0 then refuses every 2 and every 3,
* in its journal.
*/
export function columnFeatures(values) {
const filled = values.map((value) => value.trim()).filter((value) => value !== '');
if (filled.length === 0) return new Array(FEATURE_NAMES.length).fill(0);
const bare = filled.map((value) => value.replace(SPACES, ''));
const count = filled.length;
const share = (list, test) => list.filter(test).length / count;
return [
share(bare, (v) => ALL_DIGITS.test(v)),
share(bare, (v) => DECIMAL.test(v)),
share(bare, (v) => DATE_SHAPE.test(v)),
share(filled, (v) => LETTER.test(v)),
share(filled, (v) => BOOLEAN_WORDS.has(v.toLowerCase())),
new Set(filled).size / count,
Math.min(filled.reduce((total, v) => total + v.length, 0) / count, LONG_VALUE) / LONG_VALUE,
(values.length - count) / values.length,
];
}
/** One logistic regression, fitted by gradient descent on a tiny data set. */
function fitOne(rows, targets, epochs, rate) {
const weights = new Float64Array(rows[0].length);
let bias = 0;
for (let epoch = 0; epoch < epochs; epoch += 1) {
for (let i = 0; i < rows.length; i += 1) {
let z = bias;
for (let j = 0; j < weights.length; j += 1) z += weights[j] * rows[i][j];
const error = 1 / (1 + Math.exp(-z)) - targets[i];
for (let j = 0; j < weights.length; j += 1) weights[j] -= rate * error * rows[i][j];
bias -= rate * error;
}
}
return { weights, bias };
}
/**
* `columns` is a list of column samples, `labels` the type name of each.
*
* The type names are the ones rung N0 coerces to: text, integer, number,
* date, boolean. One regression is fitted per type, each answering "is it
* this one?", and the strongest answer wins.
*/
export function train(columns, labels, { epochs = 600, rate = 0.5 } = {}) {
const rows = columns.map(columnFeatures);
const kinds = [...new Set(labels)].sort();
// As scikit-learn does: with fewer than two types there is nothing to learn.
if (kinds.length < 2) throw new RangeError('training needs columns of at least two types');
const models = kinds.map((kind) =>
fitOne(rows, labels.map((label) => (label === kind ? 1 : 0)), epochs, rate),
);
return { kinds, models };
}
/**
* The type of one column.
*
* A column with nothing in it is text, and the model is not consulted: there
* is no evidence to weigh, and text is the type that loses nothing.
*/
export function classify(model, values) {
if (!values.some((value) => value.trim() !== '')) return 'text';
const features = columnFeatures(values);
let best = model.kinds[0];
let bestScore = -Infinity;
model.kinds.forEach((kind, k) => {
const { weights, bias } = model.models[k];
let z = bias;
for (let j = 0; j < weights.length; j += 1) z += weights[j] * features[j];
if (z > bestScore) {
bestScore = z;
best = kind;
}
});
return best;
}
/**
* Build the schema that `cleanCsv` of rung N0 asks for.
*
* Only the first `sample` rows are read. On a file sorted, or one that changes
* as it goes, they are not the whole column: whole amounts on the first two
* hundred rows and decimals after give integer, and rung N0 then refuses every
* decimal, in its journal.
*/
export function inferSchema(model, header, rows, sample = 200) {
const schema = {};
header.forEach((name, index) => {
const values = rows.slice(0, sample).map((row) => row[index] ?? '');
schema[name] = classify(model, values);
});
return schema;
}What this means for you
- Your data stays on your own machines
- Always the same result
- Measured on a sample, not case by case
- Depends on a library
- Low footprint
What this adds to your regulatory scope
- The training set is made of columns taken from real files: it belongs in your record of processing activities, where you keep one, as soon as it holds personal data
- Nothing leaves your infrastructure: the model is trained and queried in place
Breaking point
An identifier made of digits. A column of postcodes — 01000, 06400, 75014 — carries every trait of an integer column, the classifier answers « integer », and the coercion of N0 returns 1000 and 6400. The leading zero is gone, and nothing is rejected, because nothing failed: the classifier is right about the shape and has no way to be right about the meaning.
When to move up a level
The rejection journal grows with every import, nobody repairs those rows by hand, and the file can no longer be asked back from whoever produced it.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
Beside the point here: a column’s type is readable from the shape of its values, and that is what N1 reads, on eight traits. A self-hosted model to guess that a column holds dates would add a model to load and keep running.
N3Repairing only the rows N0 rejected, through a general-purpose modelélevé · About a second
Repairing only the rows N0 rejected, through a general-purpose model
General-purpose LLM APIN3élevéAbout a second
Python
"""
Repair the rows rung N0 refused, and only those, by asking a model.
Rung N3. The word that makes this rung defensible on this entry is "only".
The file may hold a hundred thousand rows; the journal of rung N0 holds the
handful that did not fit. One call per refused row, and none at all for the
rest. Hand the whole file to a model instead and you have paid for the
ninety-nine per cent that a regular expression had already dealt with.
Note what this code has to do that rung N0 did not: build a prompt, retry a
failed call, parse an answer that is only probably valid JSON, and put the
answer back through the coercion of N0 before believing a word of it. That
plumbing is the real cost of this rung, and it is the part the tests have to
cover, because the model itself is not testable.
"""
from __future__ import annotations
import json
from n0 import Rejected, coerce_row
PROMPT = (
"A row of a CSV file was refused by a type check. Repair it.\n"
"Columns, in order, with the type each one expects:\n"
"{schema}\n"
"The row was refused because: {reason}\n"
"Its fields, as they were read: {fields}\n\n"
"Answer with JSON only: one object, one key per column, every value a\n"
"string in the expected format. Dates are written YYYY-MM-DD. If the row\n"
"cannot be repaired, answer with an empty object."
)
# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini" # an example id: check the parameters your model accepts
class ProviderClient:
"""The one call this snippet makes, on top of the provider's SDK."""
def __init__(self, sdk=None, model: str = MODEL):
if sdk is None: # pragma: no cover - needs a key and a network
from openai import OpenAI
sdk = OpenAI()
self.sdk, self.model = sdk, model
def complete(self, *, prompt: str, temperature: float) -> str:
response = self.sdk.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
)
return response.choices[0].message.content
def repair_rejected_rows(
header, rejects, schema, client=None, *, attempts: int = 3, decimal: str | None = None
) -> dict:
"""
Return the rows that were repaired, and the ones that were not.
`rejects` is the journal returned by `clean_csv` of rung N0. Nothing else
from the file is read: one call per entry of that journal, and up to
`attempts` for an entry whose calls fail.
A row that cannot be repaired comes back in `unrepairable`, carrying its
original line, column and fields, plus the reason the repair failed. It is
never dropped: this rung exists because rung N0 refused to drop it either.
`decimal` is the decimal mark declared for the file, passed on to the
coercion of rung N0: a repair is read under the same convention as the
rest of the file.
`client` is injected so this function can be tested without a network
call. In production it defaults to a real provider client.
"""
if not rejects:
return {"rows": [], "unrepairable": []}
client = client or ProviderClient()
described = "\n".join(f"- {name}: {schema.get(name, 'text')}" for name in header)
rows, unrepairable = [], []
for reject in rejects:
prompt = PROMPT.format(
schema=described,
reason=reject["reason"],
fields=json.dumps(reject["fields"], ensure_ascii=False),
)
answer = _ask(client, prompt, attempts)
if answer is None:
unrepairable.append({**reject, "reason": "the model did not return a usable object"})
elif not answer:
unrepairable.append({**reject, "reason": "the model could not repair the row"})
else:
# The answer is only a proposal. Every column has to come back as
# text, and the refused value cannot come back empty: coercion
# would read an empty cell as missing and let the row through.
fields = [answer.get(name) for name in header]
if not all(isinstance(value, str) for value in fields):
unrepairable.append({**reject, "reason": "the answer does not give every column as text"})
continue
if reject["column"] and not answer[reject["column"]].strip():
unrepairable.append({**reject, "reason": "the answer empties the refused value"})
continue
try:
rows.append(coerce_row(header, fields, schema, decimal))
except Rejected as refusal:
unrepairable.append(
{
**reject,
"column": refusal.column,
"reason": f"the repair was refused too: {refusal.reason}",
}
)
return {"rows": rows, "unrepairable": unrepairable}
def _ask(client, prompt: str, attempts: int):
"""
Return the decoded object, or None when nothing usable came back.
A failed call is retried; an unusable answer is not, and its row goes to
`unrepairable` for a person to look at.
"""
for _ in range(attempts):
try:
# The lowest temperature: the SDK documents lower values as more
# focused and deterministic.
answer = client.complete(prompt=prompt, temperature=0)
except Exception: # noqa: BLE001 - any provider failure is worth one more try
continue
if not isinstance(answer, str): # a refusal comes back as no content
return None
text = answer.strip()
# A JSON answer wrapped whole in one code fence is read; nothing else is.
if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
text = text[3:-3].removeprefix("json")
try:
parsed = json.loads(text)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
return NoneJavaScript
/**
* Repair the rows rung N0 refused, and only those, by asking a model.
*
* Rung N3. The word that makes this rung defensible on this entry is "only".
* The file may hold a hundred thousand rows; the journal of rung N0 holds the
* handful that did not fit. One call per refused row, and none at all for the
* rest. Hand the whole file to a model instead and you have paid for the
* ninety-nine per cent that a regular expression had already dealt with.
*
* Note what this code has to do that rung N0 did not: build a prompt, retry a
* failed call, parse an answer that is only probably valid JSON, and put the
* answer back through the coercion of N0 before believing a word of it. That
* plumbing is the real cost of this rung, and it is the part the tests have to
* cover, because the model itself is not testable.
*/
import { Rejected, coerceRow } from './n0.js';
const PROMPT = [
'A row of a CSV file was refused by a type check. Repair it.',
'Columns, in order, with the type each one expects:',
'{schema}',
'The row was refused because: {reason}',
'Its fields, as they were read: {fields}',
'',
'Answer with JSON only: one object, one key per column, every value a',
'string in the expected format. Dates are written YYYY-MM-DD. If the row',
'cannot be repaired, answer with an empty object.',
].join('\n');
// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts
export async function providerClient(sdk, model = MODEL) {
if (!sdk) {
const { OpenAI } = await import('openai');
sdk = new OpenAI();
}
return {
async complete({ prompt, temperature }) {
const response = await sdk.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
temperature,
});
return response.choices[0].message.content;
},
};
}
/**
* Return the rows that were repaired, and the ones that were not.
*
* `rejects` is the journal returned by `cleanCsv` of rung N0. Nothing else
* from the file is read: one call per entry of that journal, and up to
* `attempts` for an entry whose calls fail.
*
* A row that cannot be repaired comes back in `unrepairable`, carrying its
* original line, column and fields, plus the reason the repair failed. It is
* never dropped: this rung exists because rung N0 refused to drop it either.
*
* `decimal` is the decimal mark declared for the file, passed on to the
* coercion of rung N0: a repair is read under the same convention as the rest
* of the file.
*
* @param {string[]} header
* @param {Array<object>} rejects the journal from rung N0
* @param {Record<string,string>} schema
* @param {object} options
* @param {{complete: Function}} [options.client] injected so this can be
* tested without a network call; defaults to a real provider client
* @param {number} [options.attempts]
* @param {','|'.'|null} [options.decimal]
*/
export async function repairRejectedRows(header, rejects, schema, { client, attempts = 3, decimal = null } = {}) {
if (rejects.length === 0) return { rows: [], unrepairable: [] };
client ??= await providerClient();
const described = header.map((name) => `- ${name}: ${schema[name] ?? 'text'}`).join('\n');
const rows = [];
const unrepairable = [];
for (const reject of rejects) {
// The replacements are given as functions: a `$` in a field would
// otherwise be read as a back-reference by the string form of `replace`.
const prompt = PROMPT.replace('{schema}', () => described)
.replace('{reason}', () => reject.reason)
.replace('{fields}', () => JSON.stringify(reject.fields));
const answer = await ask(client, prompt, attempts);
if (answer === null) {
unrepairable.push({ ...reject, reason: 'the model did not return a usable object' });
} else if (Object.keys(answer).length === 0) {
unrepairable.push({ ...reject, reason: 'the model could not repair the row' });
} else {
// The answer is only a proposal. Every column has to come back as text,
// and the refused value cannot come back empty: coercion would read an
// empty cell as missing and let the row through.
const fields = header.map((name) => answer[name]);
if (!fields.every((value) => typeof value === 'string')) {
unrepairable.push({ ...reject, reason: 'the answer does not give every column as text' });
continue;
}
if (reject.column && !answer[reject.column].trim()) {
unrepairable.push({ ...reject, reason: 'the answer empties the refused value' });
continue;
}
try {
rows.push(coerceRow(header, fields, schema, decimal));
} catch (refusal) {
if (!(refusal instanceof Rejected)) throw refusal;
unrepairable.push({
...reject,
column: refusal.column,
reason: `the repair was refused too: ${refusal.reason}`,
});
}
}
}
return { rows, unrepairable };
}
/**
* Return the decoded object, or null when nothing usable came back.
*
* A failed call is retried; an unusable answer is not, and its row goes to
* `unrepairable` for a person to look at.
*/
async function ask(client, prompt, attempts) {
for (let i = 0; i < attempts; i += 1) {
let answer;
try {
// The lowest temperature: the SDK documents lower values as more
// focused and deterministic.
answer = await client.complete({ prompt, temperature: 0 });
} catch {
continue; // any provider failure is worth one more try
}
if (typeof answer !== 'string') return null; // a refusal comes back as no content
let text = answer.trim();
// A JSON answer wrapped whole in one code fence is read; nothing else is.
if (text.startsWith('```') && text.endsWith('```') && text.split('```').length === 3) {
text = text.slice(3, -3).replace(/^json/, '');
}
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
return null;
}What this means for you
- Your data goes to a third party
- The result varies from one call to the next
- Hard to test
- Depends on an outside provider
- High footprint
What this adds to your regulatory scope
- Transfer of the content of the rejected rows to a processor, with the contractual framing that implies
- Processing location to be confirmed with the provider
- Does not excuse you from keeping track of where a repaired value came from: it is produced by the model, not read from the file
Breaking point
The well-formed invention. A refused row that carried « in the spring » where a date was expected comes back as « 2024-03-01 »: the value passes coercion, joins the clean rows, and nothing tells it apart from a value that was in the file. A malformed answer is caught and recorded; this one is not, because a date is a date.
When to move up a level
There is no level above this one.
Further reading
- RFC 4180 — Common Format and MIME Type for CSV Files
- Python — module csv, lecture et dialectes
- Unicode — FAQ sur les marques d’ordre des octets
- W3C — Model for Tabular Data and Metadata on the Web
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.