Prefill: classify unknown cultivars into a reference group so the ledger still computes
Test and deploy / test-and-deploy (push) Successful in 59s
Test and deploy / test-and-deploy (push) Successful in 59s
The extraction schema gains cultivarGroup (typica/bourbon/ethiopian/ hybrid, classified by the LLM from coffee genetics); when the named variety isn't in the cultivar table, deriveFields now falls back to the group's first-crack anchor (number still comes only from the reference table) instead of leaving 1.4 blank and breaking every downstream calculation. deriveFields exported + unit-tested. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f781930832
commit
c130efb180
+17
-2
@@ -24,6 +24,7 @@ Schema:
|
|||||||
{
|
{
|
||||||
"coffeeName": string|null,
|
"coffeeName": string|null,
|
||||||
"cultivar": string|null, // e.g. "Bourbon", "Gesha" — the single named variety, if any
|
"cultivar": string|null, // e.g. "Bourbon", "Gesha" — the single named variety, if any
|
||||||
|
"cultivarGroup": string|null, // one of: "typica","bourbon","ethiopian","hybrid" — the variety's genetic/roast-behavior family. Use your knowledge of coffee genetics to classify the named variety even when it is obscure (e.g. Wush Wush -> "ethiopian", Villa Sarchi -> "bourbon", Ruiru 11 -> "hybrid"). null only if no variety is stated or you genuinely cannot classify it
|
||||||
"origin": string|null, // country/region as stated
|
"origin": string|null, // country/region as stated
|
||||||
"producer": string|null,
|
"producer": string|null,
|
||||||
"process": string|null, // one of: "washed","yellow-honey","red-black-honey","natural","anaerobic" — pick the closest match, or null
|
"process": string|null, // one of: "washed","yellow-honey","red-black-honey","natural","anaerobic" — pick the closest match, or null
|
||||||
@@ -132,8 +133,9 @@ function parseModelJson(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic mapping from extracted page facts -> worksheet field IDs. The model never
|
/** Deterministic mapping from extracted page facts -> worksheet field IDs. The model never
|
||||||
* supplies a time/duration directly — every number here comes from shared/reference-data.js. */
|
* supplies a time/duration directly — every number here comes from shared/reference-data.js.
|
||||||
function deriveFields(extracted) {
|
* Exported for unit tests. */
|
||||||
|
export function deriveFields(extracted) {
|
||||||
const fields = {};
|
const fields = {};
|
||||||
const provenance = {};
|
const provenance = {};
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
@@ -159,9 +161,22 @@ function deriveFields(extracted) {
|
|||||||
set("1.7", formatSigned(row.devModS), "cultivar-table");
|
set("1.7", formatSigned(row.devModS), "cultivar-table");
|
||||||
} else {
|
} else {
|
||||||
set("1.1", extracted.cultivar, "page");
|
set("1.1", extracted.cultivar, "page");
|
||||||
|
// Unknown cultivar: fall back to its GROUP's first-crack anchor so the ledger still
|
||||||
|
// computes, instead of leaving 1.4 blank and silently breaking every downstream number.
|
||||||
|
// The group comes from the model's genetics classification, but the NUMBER still only
|
||||||
|
// ever comes from the reference table.
|
||||||
|
const group = findGroup(extracted.cultivarGroup);
|
||||||
|
const groupAnchorS = group ? parseRangeMidpoint(group.fcAnchor) : null;
|
||||||
|
if (group && groupAnchorS !== null) {
|
||||||
|
set("1.2", group.key, "group-table");
|
||||||
|
set("1.4", formatDuration(groupAnchorS), "group-table");
|
||||||
|
set("1.7", "+0:00", "group-table");
|
||||||
|
warnings.push(`Cultivar "${extracted.cultivar}" is not in the reference table — using the ${group.label} group anchor (${group.fcAnchor}) as the first-crack target. Adjust field 1.4 if you know this variety behaves differently.`);
|
||||||
|
} else {
|
||||||
warnings.push(`Cultivar "${extracted.cultivar}" is not in the reference table — fields 1.2/1.4/1.5/1.7 left blank; pick the nearest group by hand.`);
|
warnings.push(`Cultivar "${extracted.cultivar}" is not in the reference table — fields 1.2/1.4/1.5/1.7 left blank; pick the nearest group by hand.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (extracted.process) {
|
if (extracted.process) {
|
||||||
const proc = findProcess(extracted.process);
|
const proc = findProcess(extracted.process);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { deriveFields } from "../server/prefill.js";
|
||||||
|
import { computeLedger } from "../shared/ledger.js";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
coffeeName: "Test Coffee",
|
||||||
|
cultivar: null,
|
||||||
|
cultivarGroup: null,
|
||||||
|
origin: "Colombia",
|
||||||
|
producer: null,
|
||||||
|
process: "washed",
|
||||||
|
roastLevel: "light",
|
||||||
|
tastingNotes: [],
|
||||||
|
altitudeMasl: null,
|
||||||
|
screen: null,
|
||||||
|
moisturePct: null,
|
||||||
|
isBlend: false,
|
||||||
|
blendComponents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("known cultivar uses the cultivar table row", () => {
|
||||||
|
const { fields, provenance } = deriveFields({ ...base, cultivar: "Gesha" });
|
||||||
|
assert.equal(fields["1.1"], "Gesha");
|
||||||
|
assert.equal(fields["1.2"], "ethiopian");
|
||||||
|
assert.equal(fields["1.4"], "7:30");
|
||||||
|
assert.equal(provenance["1.4"], "cultivar-table");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown cultivar with a classified group falls back to the group anchor so the ledger computes", () => {
|
||||||
|
const { fields, provenance, warnings } = deriveFields({
|
||||||
|
...base,
|
||||||
|
cultivar: "Wush Wush",
|
||||||
|
cultivarGroup: "ethiopian",
|
||||||
|
});
|
||||||
|
assert.equal(fields["1.1"], "Wush Wush");
|
||||||
|
assert.equal(fields["1.2"], "ethiopian");
|
||||||
|
assert.equal(fields["1.4"], "7:25"); // midpoint of the ethiopian group anchor 7:20-7:30
|
||||||
|
assert.equal(provenance["1.4"], "group-table");
|
||||||
|
assert.match(warnings.join(" "), /group anchor/);
|
||||||
|
// The whole point: first crack is now computable
|
||||||
|
const ledger = computeLedger({ fields });
|
||||||
|
assert.equal(ledger.A !== null, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown cultivar in the unparseable hybrid group still degrades gracefully", () => {
|
||||||
|
const { fields, warnings } = deriveFields({
|
||||||
|
...base,
|
||||||
|
cultivar: "Mystery F1",
|
||||||
|
cultivarGroup: "hybrid", // group anchor is "follows parent" — not a number
|
||||||
|
});
|
||||||
|
assert.equal(fields["1.1"], "Mystery F1");
|
||||||
|
assert.equal(fields["1.4"], undefined);
|
||||||
|
assert.match(warnings.join(" "), /pick the nearest group by hand/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown cultivar with no group keeps the old blank-fields warning", () => {
|
||||||
|
const { fields, warnings } = deriveFields({ ...base, cultivar: "Zebra SP" });
|
||||||
|
assert.equal(fields["1.4"], undefined);
|
||||||
|
assert.match(warnings.join(" "), /pick the nearest group by hand/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user