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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,21 @@ trueup.reconcile(Table.file("statement.csv"), Table.file("receiving.csv"), new R

Each call to `reconcile` or `reconcileFiles` 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.

```java
MatchResult result = trueup.match(Table.file("invoice.csv"), Table.file("catalog.csv"));
System.out.println(result.headline);
// 4 of 5 records in invoice.csv matched to catalog.csv (0 unsure); 1 have no counterpart.
for (Models.Finding f : result.findings) System.out.println(f.kind + " " + f.subject + " " + f.detail);
// 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 `Table.file`, `Table.content` or `Table.rows`; `matchFiles` picks the pair; `matchStored` works on stored files (with a saved model); and `details.weights` can be passed back to `match(left, right, 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:
Expand Down Expand Up @@ -147,7 +162,7 @@ TrueUp.builder()
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
```

Expand Down
23 changes: 23 additions & 0 deletions src/main/java/io/github/merchantprotocol/trueup/Models.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ public static final class ReconcileResult {
public String run_id;
}

/** The answer to a match call. Findings' kind: match, unsure_match (a person should check), only_left, only_right. */
public static final class MatchResult {
public String analysis;
public String title;
public String headline;
public Map<String, Double> stats;
public List<Finding> findings;
public MatchDetails details;
public List<String> inputs;
public String engine;
/** The kept run, for {@link TrueUp#matchStored}; null otherwise. */
public String run_id;
}

/** How the columns lined up, every pair ([left id, right id, confidence]), and what was learned. */
public static final class MatchDetails {
public JsonObject columns;
public List<List<Object>> pairs;
public JsonObject model;
/** Pass back as the {@code weights} of {@link TrueUp#match} to match the same way without learning. */
public JsonObject weights;
}

/** A file stored in the team (uploaded through the API or the dashboard). */
public static final class StoredFile {
public String id;
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/io/github/merchantprotocol/trueup/TrueUp.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.google.gson.JsonParser;
import io.github.merchantprotocol.trueup.Models.Account;
import io.github.merchantprotocol.trueup.Models.Answers;
import io.github.merchantprotocol.trueup.Models.MatchResult;
import io.github.merchantprotocol.trueup.Models.Model;
import io.github.merchantprotocol.trueup.Models.RunDetail;
import io.github.merchantprotocol.trueup.Models.RunPage;
Expand Down Expand Up @@ -149,6 +150,47 @@ public ReconcileResult reconcileFiles(List<Table> files, ReconcileOptions option
return upload(fields, files, options != null ? options : new ReconcileOptions());
}

// ---------------------------------------------------------------- match

/**
* Match two lists that describe the same things in different words (two catalogs, a price book and an invoice):
* each record on {@code left} (the list to go through) is paired with its counterpart on {@code right} (the list
* to search), or reported as having none. One analysis.
*/
public MatchResult match(Table left, Table right) {
return match(left, right, null);
}

/** Match two lists, applying {@code weights} (details.weights of an earlier match) instead of learning. */
public MatchResult match(Table left, Table right, JsonObject weights) {
if (left.isRows() && right.isRows()) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("left", Map.of("name", left.getName(), "rows", left.getRows()));
body.put("right", Map.of("name", right.getName(), "rows", right.getRows()));
if (weights != null) body.put("weights", weights);
return gson.fromJson(request("POST", "/v1/match", gson.toJson(body).getBytes(StandardCharsets.UTF_8), "application/json"), MatchResult.class);
}
byte[] json = multipart("/v1/match", List.of("left", "right"), List.of(left, right), new ReconcileOptions().weights(weights));
return gson.fromJson(new String(json, StandardCharsets.UTF_8), MatchResult.class);
}

/** Send two or more lists; TrueUp picks the pair to match and puts the shorter on the left. One analysis. */
public MatchResult matchFiles(List<Table> files, JsonObject weights) {
List<String> fields = new ArrayList<>();
for (int i = 0; i < files.size(); i++) fields.add("files");
byte[] json = multipart("/v1/match", fields, files, new ReconcileOptions().weights(weights));
return gson.fromJson(new String(json, StandardCharsets.UTF_8), MatchResult.class);
}

/** Match two lists already stored in the team, by id. {@code model} (a saved match model id) may be null. The run is kept. */
public MatchResult matchStored(String leftFileId, String rightFileId, String model) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("left_file_id", leftFileId);
body.put("right_file_id", rightFileId);
if (model != null) body.put("model", model);
return gson.fromJson(request("POST", "/v1/match", gson.toJson(body).getBytes(StandardCharsets.UTF_8), "application/json"), MatchResult.class);
}

// ---------------------------------------------------------------- stored files, runs, saved models

/** Upload one or more files to the team. Each comes back with its id, rows, columns and roles. */
Expand Down
18 changes: 17 additions & 1 deletion src/test/java/io/github/merchantprotocol/trueup/ApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import io.github.merchantprotocol.trueup.Models.MatchResult;
import io.github.merchantprotocol.trueup.Models.ReconcileOptions;
import io.github.merchantprotocol.trueup.Models.RunDetail;
import io.github.merchantprotocol.trueup.Models.RunPage;
Expand All @@ -20,11 +21,12 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;

/**
* 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`).
*/
class ApiTest {
private static final Path FIXTURES = Path.of("src/test/resources/fixtures");
Expand Down Expand Up @@ -136,4 +138,18 @@ void storedFilesRunsAndModels() throws IOException {
}
assertThrows(TrueUpException.NotFoundException.class, () -> tu.getFile(statement.id));
}

@Test
void matchTwoListsThenReuseTheLearning() throws IOException {
assumeTrue(live(), "needs TRUEUP_API_KEY");
TrueUp tu = TrueUp.builder().build();
List<List<String>> want = List.of(List.of("1", "1"), List.of("2", "2"), List.of("3", "3"), List.of("4", "5"));
MatchResult result = tu.match(Table.file(FIXTURES.resolve("invoice.csv")), Table.file(FIXTURES.resolve("catalog.csv")));
assertEquals("match", result.analysis);
assertEquals(want, result.details.pairs.stream().map(p -> List.of((String) p.get(0), (String) p.get(1))).collect(Collectors.toList()));
assertEquals(List.of("5"), result.findings.stream().filter(f -> f.kind.equals("only_left")).map(f -> f.subject).collect(Collectors.toList()));
MatchResult again = tu.match(Table.rows("invoice.csv", rows("invoice.csv")), Table.rows("catalog.csv", rows("catalog.csv")), result.details.weights);
assertEquals(want, again.details.pairs.stream().map(p -> List.of((String) p.get(0), (String) p.get(1))).collect(Collectors.toList()));
assertFalse(again.details.model.get("learned").getAsBoolean());
}
}
7 changes: 7 additions & 0 deletions src/test/resources/fixtures/catalog.csv
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions src/test/resources/fixtures/invoice.csv
Original file line number Diff line number Diff line change
@@ -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
Loading