
How to Match CSV Columns and Values With Jev
A CSV importer takes a file from one of your users and turns it into rows your app can store. The file comes from another system and uses that system's names. Before any row is imported, the importer translates those names into yours. It does this in two steps.
Column matching connects each column of the file to a field of your schema. A supplier writes Art. No, and your catalog calls the same thing sku.
Value matching works inside one column. When a field accepts a fixed list of options, every spelling in the file has to map to one of them. A supplier writes pkt, bx and box, and your catalog calls all three pack.
Updog Importer is a CSV and Excel importer you embed in your app. By default its built-in matcher performs both steps with fuzzy string matching. Two optional hooks, onColumnMatch and onValueMatch, let your own logic answer first. In this article that logic asks Jev, a decision model from TypeSafe AI, and we measure the result on one supplier price list.
On that file, the built-in matcher resolved one header of five and four unit spellings of fifteen. With Jev behind the hooks, five headers of five and fourteen spellings of fifteen were matched before the person importing the file clicked anything.
For the demonstration we chose a price list that a hardware shop receives from a supplier.
| A | B | C | D | E | |
|---|---|---|---|---|---|
| 1 | Art. No | Item Desc | Mfr | UoM | List Price |
| 2 | IB-00001 | Hex bolt zinc plated M6x30 | Hartley | pkt | 168.01 |
| 3 | IB-00002 | Countersunk wood screw 5x60 | Ostrel | bx | 58.19 |
| 4 | IB-00003 | Nylon wall plug 8mm | Brennock | box | 38.1 |
| 1 rows not shown | |||||
| 6 | IB-00005 | Round wire nails 50mm | Ostrel | KG | 234.72 |
| 7 | IB-00006 | Claw hammer fibreglass shaft 16oz | Tamsin & Wye | ea | 35.16 |
| 2 rows not shown | |||||
| 10 | IB-00009 | Wood chisel bevel edge 25mm | Tamsin & Wye | pc | 221.15 |
| 3 rows not shown | |||||
| 14 | IB-00013 | Jigsaw variable speed 750W | Brennock | each | 86.04 |
| 15 | IB-00014 | SDS rotary hammer 800W | Hartley | Stk | 77.92 |
| 16 | IB-00015 | Twin and earth cable 1.5mm2 | Brennock | mtr | 11.24 |
1Art. No,Item Desc,Mfr,UoM,List Price2IB-00001,Hex bolt zinc plated M6x30,Hartley,pkt,168.013IB-00002,Countersunk wood screw 5x60,Ostrel,bx,58.194IB-00003,Nylon wall plug 8mm,Brennock,box,38.1⋮1 rows not shown6IB-00005,Round wire nails 50mm,Ostrel,KG,234.727IB-00006,Claw hammer fibreglass shaft 16oz,Tamsin & Wye,ea,35.16⋮2 rows not shown10IB-00009,Wood chisel bevel edge 25mm,Tamsin & Wye,pc,221.15⋮3 rows not shown14IB-00013,Jigsaw variable speed 750W,Brennock,each,86.0415IB-00014,SDS rotary hammer 800W,Hartley,Stk,77.9216IB-00015,Twin and earth cable 1.5mm2,Brennock,mtr,11.24The shop's catalog uses different names. It has five fields, and the unit field accepts five options. Every field and every option has a one-line description, which the hooks later pass to Jev.
import type { DataEditorColumn } from "@updog/data-editor";
export const UNIT_CRITERIA: Record<string, string> = { pcs: "Sold as single pieces", kg: "Sold by weight in kilograms", l: "Sold by volume in litres", m: "Sold by length in metres", pack: "Sold as a box, bag or packet of several pieces",};
export const FIELD_CRITERIA: Record<string, string> = { sku: "The supplier's article or part number", name: "The product description", brand: "The manufacturer or brand", unit: "The unit of measure the product is sold in", price: "The list price",};
export const UNITS = Object.keys(UNIT_CRITERIA);
export const columns: DataEditorColumn[] = [ { id: "sku", title: "SKU", validators: [{ type: "required" }, { type: "unique" }], }, { id: "name", title: "Product name", validators: [{ type: "required" }] }, { id: "brand", title: "Brand" }, { id: "unit", title: "Unit", editor: { type: "select", options: UNITS } }, { id: "price", title: "Price", editor: { type: "number" }, validators: [{ type: "number", min: 0, decimalPlaces: 2 }], },];UoM has to map to unit. Inside that column, ea, EA, pc, each and Stk all mean pcs. The full file writes the five units in fifteen spellings.
What both steps have in common
Column matching and value matching both ask a closed question. The input is open, because a supplier can write any header and any spelling. The answer is one item from a list your schema already holds, or none of them. UoM is one of five fields. Stk is one of five units.
The matching hooks in Updog
The built-in matcher compares strings, so it connects List Price to price and KG to kg. UoM and unit share a meaning and almost no letters, so string comparison does not connect them.
onColumnMatch and onValueMatch pass such cases to your code. onColumnMatch receives the headers of the file and the fields of your schema. onValueMatch receives the distinct values of every field that has a fixed list of options, together with those options. Your function returns the matches it is sure about, and the built-in matcher handles every header or value you leave out. If the function throws or runs past 30 seconds, the built-in matcher performs the whole step and the import continues.
The logic inside the hook can be a lookup table, a set of rules, or a model.
Bring Your Own AI to CSV and Excel Import explains why the model is yours and how the hooks keep Updog out of the data path.
What Jev is
On September 15, 2026, TypeSafe AI released Jev, the first of what the company calls System One models. The announcement describes the class as models "built to make fast, structured decisions that software can use directly". Jev "gives up string generation".
A request contains a state, which is the context, and a set of questions. The documentation lists three question types. This article uses the Choice, which picks one option from a list you define, with up to 255 options. Every Choice answer returns the selected option, a probability for each option, and a confidence between 0 and 1.
Why Jev fits matching
A Choice question is the closed question both steps ask. It returns one option from a list, and the hook expects one field of your schema back.
The option keys are the field ids of your schema, so the hook uses the returned choice as the field id without converting it. The confidence number gives the hook a rule for what to leave out. A match the hook leaves out goes to the built-in matcher, and then to the person at the matching step.
A language model with structured output can answer the same questions. There the closed list has to be written as a JSON schema, and the reply holds a choice without a confidence number unless the API exposes token probabilities. With Jev the closed list is the request format, and every answer includes confidence.
Setting it up
The hooks run in the browser, and the TypeSafe key cannot go there, because any user can read a key that ships to the browser. The browser calls POST /api/jev on your backend, and the backend adds the key and forwards the request.
To keep the example small, the backend here is a proxy inside the Vite dev server. The same code can live on your own backend.
import type { Plugin } from "vite";
const JEV_URL = "https://api.typesafe.ai/v1/systemone";
export const jevProxy = (apiKey: string | undefined): Plugin => { return { name: "jev-proxy", configureServer(server) { server.middlewares.use("/api/jev", async (request, response) => { try { const chunks: Buffer[] = []; for await (const chunk of request) { chunks.push(chunk); } const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); const upstream = await fetch(JEV_URL, { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ ...body, model: "jev-latest" }), }); response.statusCode = upstream.status; response.end(await upstream.text()); } catch { response.statusCode = 502; response.end(); } }); }, };};The headers of the file and the distinct values of the unit column go from the browser to TypeSafe. No other cell leaves the browser. If your users' data may not go to a third party, keep the built-in matcher or point the hook at a model you host.
On the frontend, both hooks call one client.
type Ask = { state: string; items: string[]; instructions: (item: string) => string; criteria: Record<string, string>; threshold: number; signal?: AbortSignal;};
type Answer = { choice: string; confidence: number;};
export const askJev = async ({ state, items, instructions, criteria, threshold, signal,}: Ask): Promise<Record<string, string>> => { const questions = Object.fromEntries( items.map((item) => { return [ item, { type: "choice", instructions: instructions(item), criteria }, ]; }), );
try { const response = await fetch("/api/jev", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state, questions }), signal, }); if (!response.ok) { return {}; } const { answers }: { answers: Record<string, Answer> } = await response.json();
const picked: Record<string, string> = {}; for (const [item, { choice, confidence }] of Object.entries(answers)) { if (confidence >= threshold && choice !== "none") { picked[item] = choice; } } return picked; } catch { return {}; }};askJev builds one Choice question per item, sends them in one request, and keeps the answers that pass the threshold and are not none. When the request fails it returns an empty object, so the built-in matcher performs the whole step and the person importing the file sees no error. The TypeSafe documentation asks clients to retry a 429 or a 529 with exponential backoff, and this example leaves that out.
Matching columns
The hook asks one Choice question per header, all in one request. The options are the five fields with a short description each, plus none for a column the catalog does not store. The TypeSafe documentation recommends such an option whenever an input may fit nothing on the list.
import { FIELD_CRITERIA } from "../catalog";import { askJev } from "./client";
export const matchColumns = ( headers: string[],): Promise<Record<string, string>> => { return askJev({ state: "A hardware shop imports a supplier price list into its product catalog.", items: headers, instructions: (header) => { return `Which catalog field does the supplier column "${header}" hold?`; }, criteria: { ...FIELD_CRITERIA, none: "The column holds something the catalog does not store", }, threshold: 0.9, });};The header goes inside the question, and the state holds one sentence of context. No question then has to point to an item of a list by its index, and the documentation says Jev does not count reliably.
The request for UoM alone shows what Jev receives and what it returns.
{ "model": "jev-latest", "state": "A hardware shop imports a supplier price list into its product catalog.", "questions": { "UoM": { "type": "choice", "instructions": "Which catalog field does the supplier column \"UoM\" hold?", "criteria": { "sku": "The supplier's article or part number", "name": "The product description", "brand": "The manufacturer or brand", "unit": "The unit of measure the product is sold in", "price": "The list price", "none": "The column holds something the catalog does not store" } } }}{ "model": "jev-1.13.0", "answers": { "UoM": { "type": "choice", "choice": "unit", "confidence": 1, "probabilities": { "sku": 0, "name": 0, "brand": 0, "unit": 1, "price": 0, "none": 0 } } }, "usage": { "input_tokens": 404, "output_tokens": 61 }}Jev put the whole probability on unit, and the hook returns that choice as the match.
All five answers were correct, each with a confidence of 0.99 or higher. The file has no extra column, so this run never tested the none option on headers.
Matching values
onValueMatch passes the distinct spellings of the unit column. The hook asks one question per spelling, again in one request.
import type { ValueMatchInput, ValueMatchOutput } from "@updog/data-editor";import { UNIT_CRITERIA } from "../catalog";import { askJev } from "./client";
export const matchValues = async ( valuesToMatch: Record<string, ValueMatchInput>,): Promise<ValueMatchOutput> => { const unit = await askJev({ state: "A hardware shop imports a supplier price list. Each supplier spelling has to land on one of the shop's allowed values.", items: valuesToMatch.unit.importedValues, instructions: (value) => { return `Which allowed "unit" value does the supplier spelling "${value}" mean?`; }, criteria: { ...UNIT_CRITERIA, none: "The spelling means none of the allowed values", }, threshold: 0.8, }); return { unit };};Jev picked the right unit for all fifteen spellings.
| Unit | Spelling and confidence |
|---|---|
pack |
pkt 0.97, bx 0.83, box 0.94 |
kg |
KG 0.96, kilo 0.88 |
pcs |
ea 0.98, EA 0.98, pc 0.95, each 0.89, Stk 0.54 |
m |
mtr 0.98, lin. m 0.97 |
l |
Ltr 0.98, L 0.53, litre 0.96 |
Thirteen answers passed the threshold of 0.8. The hook left out L and Stk, the German abbreviation for pieces. The built-in matcher then connected L to l, and Stk was left for the person to pick by hand. That makes fourteen of fifteen.
The none option covers values the list does not hold. We sent eight such values through the same hook, with none on the list and without it.
| Value | Without none |
With none |
|---|---|---|
pallet |
pack 0.82 |
none 0.47 |
n/a |
pcs 0.78 |
none 0.98 |
- |
pcs 0.39 |
none 0.73 |
roll |
m 0.81 |
m 0.47 |
pair |
pack 0.86 |
pack 0.69 |
TBC |
pcs 0.45 |
none 0.85 |
12 |
pcs 0.47 |
none 0.65 |
hour |
pcs 0.55 |
none 0.93 |
Without none, Jev has to choose a unit, and three of the eight answers pass 0.8. With none, six values go to none, which the hook drops. roll and pair still get a unit, at 0.47 and 0.69, and the threshold leaves them out. None of the eight is matched.
Pass both hooks to the importer as props.
<DataEditor<Product> apiKey="YOUR_API_KEY" open={open} onClose={() => setOpen(false)} columns={columns} primaryKey="sku" onColumnMatch={matchColumns} onValueMatch={matchValues} onComplete={(result) => { console.log(result); setOpen(false); }}/>How to Map CSV Values to Enums During Import covers what the value matching step does with the spellings no matcher resolves.
Choosing the threshold
The threshold decides which answers the hook returns. A wrong match appears at the matching step as a finished match, and the person has to notice it among the correct ones. A missing match appears as an empty slot, and the person fills it.
Treat 0.9 and 0.8 as the numbers of this example. The TypeSafe documentation says that "the correct threshold values depend on your domain and the performance of the model for your use case", and asks you to test with your own data. We measured three things that move confidence.
The none option lowers the confidence of correct answers. Without it, bx had 0.97, Stk 0.61 and L 0.63. With it, they had 0.83, 0.54 and 0.53. At a threshold of 0.9 the hook accepted thirteen spellings without none and ten with it, which is why the value hook uses 0.8 together with none.
The order of the options moves the number. We sent the unit questions without none again, with the five options in reverse order. Stk went from 0.61 to 0.92, and L went from 0.63 to 0.31. This is one reversal of one list, so the size of the effect on other lists is unknown.
Repeating a request moves it too. We sent identical requests again on the same day, and confidence changed by up to 0.08. Stk had 0.54 in one run and 0.46 in the next. An answer within 0.08 of the threshold is accepted in one import and left out in the next.
No choice changed in any of these runs. Pick the threshold after the option list and its order are final, and pick it on files from your own users.
What it costs
The two matching requests for this file took 929 and 2,330 input tokens, 3,259 in total. Calculated from the usage field of the responses at the published $0.042 per million input tokens, that is about $0.00014 for the import.
Every question contains the full option list, so the token count grows with the number of questions times the number of options. A question took 155 to 186 tokens here. The documentation gives one request a budget of about 32,000 tokens, shared by the state and the questions.
TypeSafe states an end-to-end response time of 70 to 500 ms and adds that its evaluations run from the West Coast of the United States, where the service is based. Our requests took between 1.2 and 1.6 seconds each from the United Arab Emirates against jev-1.13.0. We did not separate network time from model time. The person waits for one request per matching step.
What we think of Jev for matching
Jev is strongest where a task comes down to picking by meaning from a short fixed list, and both matching steps are such a task. In every run we made, with none and without it, in both option orders and on repeated requests, Jev picked the right field for all five headers and the right unit for all fifteen spellings. The whole import cost a fraction of a cent.
Read confidence as a relative number. It moved by up to 0.32 with the order of the options, by up to 0.14 when we added none, and by up to 0.08 between identical requests. A threshold holds for one option list in one order, so set it on files from your own users. On our file, every match the hook returned was correct.