fix(pos-match): our size check backs the AI's "exact"; rank fixes from the live POS

- confirmPosMatch: an AI "exact" whose parsed sizes really conflict
  (thread M22 vs M16, length 65 vs 60) becomes "closest", so no price
  is written; a field the POS item just doesn't state is not a conflict
- ranking, checked read-only against the live POS: an item that doesn't
  name its type ("M12 X 40 ALLEN CAP") no longer drops behind wrong
  lengths; a same-thread item with no length (washers) sits behind
  same-kind items that have one; non-fasteners rank on their dimensions
  (150mm) with a conflicting size below none
- allen cap / socket cap / cap head read as cap screw, WSHR as washer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 23:22:47 +02:00
parent 33813b4264
commit fa68b14388
5 changed files with 148 additions and 19 deletions

View file

@ -13,6 +13,7 @@ import {
compareSizes,
describeSize,
parseItemSize,
sizeRank,
SIZE_RANK_EXACT,
SIZE_RANK_KIND,
SIZE_RANK_NEAR_BASE,
@ -330,3 +331,34 @@ describe("describeSize", () => {
expect(describeSize(parseItemSize("Widget"))).toBe("");
});
});
describe("sizeRank — seen on the live POS 2026-10-24", () => {
it("ranks an item that doesn't name its type above a wrong length", () => {
const line = parseItemSize("M12 x 40 cap screw 12.9");
const allen = sizeRank(line, parseItemSize("M12 X 40 ALLEN CAP"));
const shorter = sizeRank(line, parseItemSize("M12 x 30 cap screw"));
expect(allen).toBeLessThan(shorter);
});
it("ranks a same-thread item with no length behind shorter same-kind items", () => {
const line = parseItemSize("M12 x 40 cap screw 12.9");
expect(parseItemSize("M12 WSHR 4.8 ZP").kind).toBe("washer");
const washer = sizeRank(line, parseItemSize("M12 WSHR 4.8 ZP"));
const shorter = sizeRank(line, parseItemSize("M12 x 30 cap screw"));
expect(shorter).toBeLessThan(washer);
});
it("reads allen cap / socket cap as a cap screw", () => {
expect(parseItemSize("M12 X 40 ALLEN CAP").kind).toBe("cap_screw");
expect(parseItemSize("M10x25 socket cap").kind).toBe("cap_screw");
});
it("ranks a matching dimension above a conflicting one for non-fasteners", () => {
const line = parseItemSize('Funnel 150mm PVC round 2 piece Flexible ("6") Round');
const brush = sizeRank(line, parseItemSize("PAINT BRUSH, 150mm BLONDIE"));
const trimmer = sizeRank(line, parseItemSize("HUSQVARNA TRIMMER LINE 2.0MM X 15M"));
const plain = sizeRank(line, parseItemSize("PVC FUNNEL"));
expect(brush).toBeLessThan(plain);
expect(plain).toBeLessThan(trimmer);
});
});

View file

@ -97,6 +97,10 @@ export const SIZE_RANK_THREAD = 1000;
export const SIZE_RANK_KIND = 2000;
/** Nothing comparable. */
export const SIZE_RANK_OTHER = 3000;
/** Length gap used for a same-thread candidate that states no length. */
const NO_LENGTH_GAP = 500;
/** Added to SIZE_RANK_OTHER when a stated dimension conflicts. */
export const SIZE_RANK_CONFLICT = 500;
// ─── Word lists ────────────────────────────────────────────────────────
@ -112,10 +116,12 @@ const KIND_PHRASES: Array<{ re: RegExp; kind: ItemSizeKind }> = [
{ re: /\bhex\s*head\s*bolts?\b/, kind: "bolt" },
{ re: /\bhex\s*bolts?\b/, kind: "bolt" },
{ re: /\bbolts?\b/, kind: "bolt" },
{ re: /\bwashers?\b/, kind: "washer" },
{ re: /\b(?:washers?|wshrs?|wash)\b/, kind: "washer" },
{ re: /\bset\s*screws?\b/, kind: "setscrew" },
{ re: /\bset\s*scr\b/, kind: "setscrew" },
{ re: /\bgrub\s*screws?\b/, kind: "setscrew" },
{ re: /\b(?:allen|socket)\s*caps?\b/, kind: "cap_screw" },
{ re: /\bcap\s*heads?\b/, kind: "cap_screw" },
{ re: /\bcap\s*screws?\b/, kind: "cap_screw" },
{ re: /\bsocket\s*head\s*cap\s*screws?\b/, kind: "cap_screw" },
{ re: /\bstuds?\b/, kind: "stud" },
@ -406,22 +412,52 @@ export function compareSizes(line: ItemSize, cand: ItemSize): SizeCompare {
export function sizeRank(line: ItemSize, cand: ItemSize): number {
if (!hasSizeInfo(line)) return SIZE_RANK_OTHER;
const kindSame = line.kind !== null && cand.kind !== null && cand.kind === line.kind;
// A type the candidate doesn't state ("M12 X 40 ALLEN CAP" before the
// parser knew the word) is compatible, just behind a stated match; a
// stated different type is not.
const kindKnownSame = line.kind !== null && cand.kind === line.kind;
const kindOk = line.kind === null || cand.kind === null || cand.kind === line.kind;
const kindPenalty = kindKnownSame || line.kind === null ? 0 : 1;
const threadSame =
line.thread !== null && cand.thread !== null && cand.thread === line.thread;
const lengthSame =
line.length !== null && cand.length !== null && cand.length === line.length;
if (kindSame && threadSame && lengthSame) return SIZE_RANK_EXACT;
if (kindSame && threadSame) {
const gap =
line.length !== null && cand.length !== null
? Math.min(999, Math.abs(line.length - cand.length))
: 0;
return SIZE_RANK_NEAR_BASE + gap;
if (line.thread !== null) {
if (kindOk && threadSame && lengthSame) return SIZE_RANK_EXACT + kindPenalty;
if (kindOk && threadSame) {
// A candidate with no length at all (a washer, a nut) sits behind
// every same-thread item that has one.
const gap =
line.length === null
? 0
: cand.length === null
? NO_LENGTH_GAP
: Math.min(999, Math.abs(line.length - cand.length));
return SIZE_RANK_NEAR_BASE + kindPenalty + gap;
}
if (threadSame) return SIZE_RANK_THREAD;
if (kindKnownSame) return SIZE_RANK_KIND;
return SIZE_RANK_OTHER;
}
if (threadSame) return SIZE_RANK_THREAD;
if (kindSame) return SIZE_RANK_KIND;
// No thread: rank on the other dimensions (150mm, 20L, 6in). Every
// stated dimension matching is best; a conflicting one (2mm vs 150mm)
// is worse than the candidate not stating it at all.
if (line.dims.length > 0) {
let matched = 0;
let conflicts = 0;
for (const dim of line.dims) {
const same = cand.dims.find((d) => d.unit === dim.unit);
if (!same) continue;
if (same.value === dim.value) matched++;
else conflicts++;
}
if (conflicts === 0 && matched === line.dims.length) return SIZE_RANK_EXACT + kindPenalty;
if (conflicts === 0 && matched > 0) return SIZE_RANK_NEAR_BASE + kindPenalty;
if (conflicts > 0) return SIZE_RANK_OTHER + SIZE_RANK_CONFLICT;
}
if (kindKnownSame) return SIZE_RANK_KIND;
return SIZE_RANK_OTHER;
}

View file

@ -384,6 +384,27 @@ export interface PickPosDeps {
type DecideInput = import("./quote-assistant-ai").DecideInput;
type DecideResult = import("./quote-assistant-ai").DecideResult;
/**
* The AI's "exact" only stands when our own size check agrees. If the
* parsed sizes of the line and the chosen item really conflict (length
* 65 vs 60, thread M22 vs M16 — both sides known), the pick is downgraded
* to "closest" so no price is written for it, and the conflicts are added
* to `differs`. A field the item simply doesn't state ("?") is not a
* conflict: the AI may know better than the parser there.
*/
export function confirmPosMatch(
lineDescription: string,
chosen: Pick<StockCandidate, "name" | "description" | "sku"> | undefined,
match: PosMatchKind,
differs: string[],
): { match: PosMatchKind; differs: string[] } {
if (match !== "exact" || !chosen) return { match, differs };
const conflicts = compareSizes(parseItemSize(lineDescription), parseCandidateSize(chosen))
.differs.filter((d) => !d.includes(" ? ") && !d.includes("? vs"));
if (conflicts.length === 0) return { match, differs };
return { match: "closest", differs: [...new Set([...differs, ...conflicts])] };
}
/**
* Let the AI pick a POS item for each line.
*
@ -433,12 +454,20 @@ export async function pickPosItems(
});
model = out.model || model;
for (const [lineId, decision] of out.decisions) {
const sent = decideLines.find((l) => l.quote_item_id === lineId);
const chosen = sent?.candidates.find((c) => c.id === decision.stock_item_id);
const confirmed = confirmPosMatch(
sent?.description ?? "",
chosen,
// An old response with no `match` meant "exact".
decision.match ?? (decision.stock_item_id ? "exact" : "none"),
decision.differs ?? [],
);
picks.set(lineId, {
line_id: lineId,
stock_item_id: decision.stock_item_id,
// An old response with no `match` meant "exact".
match: decision.match ?? (decision.stock_item_id ? "exact" : "none"),
differs: decision.differs ?? [],
match: confirmed.match,
differs: confirmed.differs,
confidence: decision.confidence,
reason: decision.reason,
});

View file

@ -297,6 +297,31 @@ describe("buildLinePatch", () => {
expect(fill.closest).toBeUndefined();
});
it("writes no price when the AI says exact but the sizes really conflict", () => {
const patch = buildLinePatch({
...base,
item: line({ description: "BOLT HEX M16X60 GR8.8 ZP" }),
decision: decision({ match: "exact", confidence: "high", differs: [] }),
candidates: [cand({ name: "M22x65 BOLT 8.8 ZP", description: "M22x65 BOLT 8.8 ZP" })],
});
expect(patch).not.toHaveProperty("cost_price");
const fill = patch.ai_fill as QuoteItemAiFill;
expect(fill.status).toBe("no_match");
expect(fill.closest?.differs).toEqual(
expect.arrayContaining(["thread M22 vs M16", "length 65 vs 60"]),
);
});
it("keeps an exact pick when the item just doesn't state a size", () => {
const patch = buildLinePatch({
...base,
item: line({ description: "BOLT HEX M16X60 GR8.8 ZP" }),
decision: decision({ match: "exact", confidence: "high" }),
candidates: [cand({ name: "HB1660ZP", description: "", sku: "HB1660ZP" })],
});
expect(patch).toHaveProperty("cost_price");
});
it("writes the price for an exact match", () => {
const patch = buildLinePatch({
...base,

View file

@ -19,7 +19,7 @@ import type {
QuoteItem,
QuoteItemAiFill,
} from "@/lib/types";
import type { StockCandidate } from "@/lib/server/pos-match";
import { confirmPosMatch, type StockCandidate } from "@/lib/server/pos-match";
import {
DEFAULT_MARKUP_MULTIPLIER,
DEFAULT_MARKUP_PERCENT,
@ -211,8 +211,15 @@ export function buildLinePatch(args: {
const cost = chosen ? round2(Number(chosen.cost_price) || 0) : 0;
// Only an EXACT match may write a price. A missing `match` on an old
// response reads as "exact" (see `asMatchKind`).
const isExact = (decision?.match ?? "exact") === "exact";
// response reads as "exact" (see `asMatchKind`), and an "exact" whose
// parsed sizes really conflict is downgraded to "closest".
const confirmed = confirmPosMatch(
item.description || "",
chosen,
decision?.match ?? "exact",
decision?.differs ?? [],
);
const isExact = confirmed.match === "exact";
if (chosen && isExact && cost > 0 && meetsConfidence(decision?.confidence ?? null, minConfidence)) {
const posSelling = round2(Number(chosen.selling_price) || 0);
@ -255,14 +262,14 @@ export function buildLinePatch(args: {
// The closest item — recorded, but never priced onto the line.
const closest =
!status && chosen && decision?.match === "closest"
!status && chosen && confirmed.match === "closest"
? {
stock_item_id: chosen.id,
name: chosen.name,
sku: chosen.sku,
cost_price: cost,
selling_price: round2(Number(chosen.selling_price) || 0),
differs: decision.differs ?? [],
differs: confirmed.differs,
}
: undefined;