From 9ae3d835e2e8b81e3fa63931cafdd6283ea90483 Mon Sep 17 00:00:00 2001 From: Jonathon Byrdziak Date: Sun, 27 Sep 2026 00:40:24 -0700 Subject: [PATCH] Match: pair two lists that describe the same things match (files, contents or rows, with saved weights), matchFiles and matchStored, calling POST /v1/match, and a live test on a small invoice and catalog. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++++++++++++++- lib/trueup.rb | 34 +++++++++++++++++++++++++++++++++- test/api_test.rb | 17 ++++++++++++++++- test/fixtures/catalog.csv | 7 +++++++ test/fixtures/invoice.csv | 6 ++++++ 5 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/catalog.csv create mode 100644 test/fixtures/invoice.csv diff --git a/README.md b/README.md index 45f1289..f4d5df8 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,21 @@ trueup.reconcile("statement.csv", "receiving.csv", answers: { Each call to `reconcile` or `reconcile_files` counts as one analysis on your plan. +## Match + +Two lists that describe the same things in different words (two catalogs, a supplier's price book and your invoice, two vendor lists): every record on the left is paired with its counterpart on the right, or reported as having none. Nothing is configured; the columns can have different names. + +```ruby +result = trueup.match("invoice.csv", "catalog.csv") +puts result["headline"] +# 4 of 5 records in invoice.csv matched to catalog.csv (0 unsure); 1 have no counterpart. +result["findings"].each { |f| puts [f["kind"], f["subject"], f["detail"]].join(" ") } +# match 4 ~ 5 4 · cheese puffs jumbo 8oz · 3.30 · 10 ↔ C-105 · Cheese Puffs Jumbo 8 oz · 3.25 +# only_left 5 5 · beef jerky teriyaki 2.5oz · 5.75 · 6 +``` + +`kind` is `match`, `unsure_match` (a person should check), `only_left` or `only_right`. `details["pairs"]` lists `[left id, right id, confidence]`. Like `reconcile`, it takes paths or `Table`s; `match_files([...])` picks the pair; `match_stored(left_id, right_id, model: ...)` works on stored files; and `details["weights"]` can be passed back as `weights:` to match next month's lists the same way. One analysis per call. + ## Stored files, runs and saved models Files uploaded to your team stay there (you'll also see them in the dashboard). Runs on stored files are kept, and what a run learned can be saved as a model: @@ -135,7 +150,7 @@ TrueUp::Client.new( The tests run in Docker against the live API: ```bash -export TRUEUP_API_KEY=tu_live_... # a key for a test team (each run uses 4 analyses) +export TRUEUP_API_KEY=tu_live_... # a key for a test team (each run uses 6 analyses) just test # or: docker compose run --rm test ``` diff --git a/lib/trueup.rb b/lib/trueup.rb index 1934e1e..238fadf 100644 --- a/lib/trueup.rb +++ b/lib/trueup.rb @@ -143,6 +143,33 @@ def reconcile_files(files, weights: nil, answers: nil) request(:post, "/v1/reconcile", parts: parts, fields: options(weights, answers)) end + # Match two lists that describe the same things in different words (two catalogs, a price book and an + # invoice): each record on +left+ (the list to go through) is paired with its counterpart on +right+ (the list to + # search), or reported as having none. A path or a Table. One analysis. + # + # weights:: details["weights"] from an earlier match, to apply instead of learning again + def match(left, right, weights: nil) + l = table(left) + r = table(right) + if l.rows? && r.rows? + body = { "left" => { "name" => l.name, "rows" => l.rows }, "right" => { "name" => r.name, "rows" => r.rows } } + body["weights"] = weights unless weights.nil? + return request(:post, "/v1/match", json: body) + end + request(:post, "/v1/match", parts: [["left", *l.to_file], ["right", *r.to_file]], fields: options(weights, nil)) + end + + # Send two or more lists; TrueUp picks the pair to match and puts the shorter on the left. One analysis. + def match_files(files, weights: nil) + request(:post, "/v1/match", parts: files.map { |f| ["files", *table(f).to_file] }, fields: options(weights, nil)) + end + + # Match lists already stored in the team, by id. +model:+ applies a saved match model. The run is kept + # ("run_id"). One analysis. + def match_stored(left_file_id = nil, right_file_id = nil, file_ids: nil, model: nil) + stored("/v1/match", left_file_id, right_file_id, file_ids, model, nil) + end + # Upload one or more files (paths or Tables) to the team. Each comes back with its "id", "rows", "columns" and # "roles" (what TrueUp read each column as). def upload_files(*files) @@ -174,6 +201,10 @@ def delete_file(id) # pick the pair. +model:+ applies a saved model instead of learning. The run is kept: its id is "run_id" in the # result. One analysis. def reconcile_stored(left_file_id = nil, right_file_id = nil, file_ids: nil, model: nil, answers: nil) + stored("/v1/reconcile", left_file_id, right_file_id, file_ids, model, answers) + end + + def stored(path, left_file_id, right_file_id, file_ids, model, answers) body = if file_ids { "file_ids" => file_ids.to_a } elsif left_file_id && right_file_id @@ -183,8 +214,9 @@ def reconcile_stored(left_file_id = nil, right_file_id = nil, file_ids: nil, mod end body["model"] = model unless model.nil? body["answers"] = answers unless answers.nil? - request(:post, "/v1/reconcile", json: body) + request(:post, path, json: body) end + private :stored # One page of runs on stored files, newest first: { "runs" => [...], "has_more" => bool }. # +limit+ is 1-100; +before+ a run id. diff --git a/test/api_test.rb b/test/api_test.rb index 4c1781b..e80d3f5 100644 --- a/test/api_test.rb +++ b/test/api_test.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # Integration tests against the live TrueUp API. Need TRUEUP_API_KEY (and optionally TRUEUP_BASE_URL). -# Each full run uses 4 analyses. Run in Docker: `just test` (or `docker compose run --rm test`). +# Each full run uses 6 analyses. Run in Docker: `just test` (or `docker compose run --rm test`). require "csv" require "minitest/autorun" @@ -101,4 +101,19 @@ def test_stored_files_runs_and_models end assert_raises(TrueUp::NotFoundError) { tu.get_file(statement["id"]) } end + + MATCHED = [%w[1 1], %w[2 2], %w[3 3], %w[4 5]].freeze + + def test_match_two_lists_then_reuse_the_learning + live! + tu = TrueUp::Client.new + result = tu.match(fixture("invoice.csv"), fixture("catalog.csv")) + assert_equal "match", result["analysis"] + assert_equal MATCHED, result["details"]["pairs"].map { |p| p[0, 2] } + assert_equal ["5"], result["findings"].select { |f| f["kind"] == "only_left" }.map { |f| f["subject"] } + again = tu.match(TrueUp::Table.rows("invoice.csv", rows("invoice.csv")), TrueUp::Table.rows("catalog.csv", rows("catalog.csv")), + weights: result["details"]["weights"]) + assert_equal MATCHED, again["details"]["pairs"].map { |p| p[0, 2] } + assert_equal false, again["details"]["model"]["learned"] + end end diff --git a/test/fixtures/catalog.csv b/test/fixtures/catalog.csv new file mode 100644 index 0000000..0db6491 --- /dev/null +++ b/test/fixtures/catalog.csv @@ -0,0 +1,7 @@ +sku,name,price +C-101,Hot Chips Fuego 17 oz,4.15 +C-102,Sour Cream & Onion Chips 10 oz,3.10 +C-103,Tortilla Rounds Family Size,2.95 +C-104,Pork Rinds Original 3 oz,2.40 +C-105,Cheese Puffs Jumbo 8 oz,3.25 +C-106,Kettle Chips Sea Salt 8 oz,3.60 diff --git a/test/fixtures/invoice.csv b/test/fixtures/invoice.csv new file mode 100644 index 0000000..bc75ee0 --- /dev/null +++ b/test/fixtures/invoice.csv @@ -0,0 +1,6 @@ +Line,Item Description,Unit Cost,Qty +1,hot chips fuego 17oz,4.15,24 +2,sour cream onion 10oz,3.10,12 +3,tortilla rounds family,2.95,36 +4,cheese puffs jumbo 8oz,3.30,10 +5,beef jerky teriyaki 2.5oz,5.75,6