fix: a tag may not accuse on weak evidence, nor name a distant product
Three findings from the deep review, all about the tag channel claiming more than it can support. 1. The exact signal is a barcode. The reconciliation compared `tag.jan` against the catalog's JAN, but that field held the regular price - a number compared against a product code. It now reads a `barcode` field (populated only by a close-up decode) and the printed number is compared as a price. 2. A price-mismatch violation required "an anchor", which was `tag.jan` - a price. The anchor is now the tag's own arithmetic agreeing, or a name that actually resembles the product, and the name signal carries its score as a field instead of being parsed back out of a human-readable note string. 3. Tag identity could reach anywhere along a row: a facing whose own tag was not detected inherited whichever readable tag was nearest, which can be a different product several columns away. Assignment is now capped at the product's own run (2.5 facing widths, at least 200 px), and a tag may only overrule the packaging channel when it is corroborated or its name matches at 0.6 rather than 0.4. 162 tests pass. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
3a960b1471
commit
99bce4d7bf
3 changed files with 86 additions and 21 deletions
|
|
@ -34,6 +34,15 @@ RECONCILED_TAG_ONLY = "tag_only"
|
|||
|
||||
RULE_MISSING_TAG = "missing_price_tag"
|
||||
RULE_MISSING_TAG_GROUP = "missing_tag_group"
|
||||
|
||||
# How far along a row a tag may name facings: enough to cover the product's own run
|
||||
# of facings (measured: up to four facings, 70-120 px each), not enough to reach
|
||||
# across the shelf. The cap exists because a product whose own tag was not detected
|
||||
# would otherwise inherit whichever readable tag happens to be nearest.
|
||||
TAG_REACH_FACINGS = 2.5
|
||||
TAG_REACH_PX = 200
|
||||
# The name similarity a tag needs before it may overrule the packaging channel.
|
||||
TAG_OVERRIDE_SIMILARITY = 0.6
|
||||
RULE_ORPHAN_TAG = "orphan_tag"
|
||||
RULE_PRICE_MISMATCH = "price_mismatch"
|
||||
RULE_DUPLICATE_TAG = "duplicate_tag"
|
||||
|
|
@ -81,16 +90,19 @@ def longest_common_substring_ratio(a: str, b: str) -> float:
|
|||
|
||||
@dataclass
|
||||
class Signal:
|
||||
kind: str # "jan" | "name" | "price"
|
||||
kind: str # "barcode" | "name" | "price"
|
||||
agrees: bool
|
||||
item: Any
|
||||
tag: Any
|
||||
note: Optional[str] = None
|
||||
score: Optional[float] = None
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
payload = {"kind": self.kind, "agrees": self.agrees, "item": self.item, "tag": self.tag}
|
||||
if self.note:
|
||||
payload["note"] = self.note
|
||||
if self.score is not None:
|
||||
payload["score"] = round(float(self.score), 3)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
@ -125,11 +137,14 @@ def reconcile(
|
|||
|
||||
signals: List[Signal] = []
|
||||
|
||||
# JAN is the only exact signal; a checksummed barcode is not a guess.
|
||||
item_jan = str((product or {}).get("jan") or "").strip()
|
||||
tag_jan = str((tag or {}).get("jan") or "").strip()
|
||||
if item_jan and tag_jan:
|
||||
signals.append(Signal("jan", item_jan == tag_jan, item_jan, tag_jan))
|
||||
# A barcode is the only exact signal; a checksummed code is not a guess. The
|
||||
# number a tag prints large beside the sale price is NOT a barcode - it is the
|
||||
# regular price (verified against the manufacturer: 4901577092381 for a tag
|
||||
# printing 357), so it is compared as a price below.
|
||||
item_barcode = str((product or {}).get("jan") or "").strip()
|
||||
tag_barcode = str((tag or {}).get("barcode") or "").strip()
|
||||
if item_barcode and tag_barcode:
|
||||
signals.append(Signal("barcode", item_barcode == tag_barcode, item_barcode, tag_barcode))
|
||||
|
||||
item_name = normalize_name((product or {}).get("product_name"))
|
||||
tag_name = normalize_name((tag or {}).get("name"))
|
||||
|
|
@ -137,7 +152,7 @@ def reconcile(
|
|||
ratio = longest_common_substring_ratio(item_name, tag_name)
|
||||
signals.append(
|
||||
Signal("name", ratio >= NAME_MATCH_THRESHOLD, item_name[:32], tag_name[:32],
|
||||
note=f"match {ratio:.2f}")
|
||||
note=f"match {ratio:.2f}", score=ratio)
|
||||
)
|
||||
|
||||
item_price = (product or {}).get("price")
|
||||
|
|
@ -155,8 +170,8 @@ def reconcile(
|
|||
"message": "tag read, but nothing comparable in the catalog",
|
||||
}
|
||||
|
||||
if any(signal.kind == "jan" for signal in signals):
|
||||
decided = [signal for signal in signals if signal.kind == "jan"][0]
|
||||
if any(signal.kind == "barcode" for signal in signals):
|
||||
decided = [signal for signal in signals if signal.kind == "barcode"][0]
|
||||
state = RECONCILED_CONFIRMED if decided.agrees else RECONCILED_MISMATCH
|
||||
elif any(signal.agrees for signal in signals):
|
||||
state = RECONCILED_CONFIRMED
|
||||
|
|
@ -479,9 +494,12 @@ def evaluate_compliance(
|
|||
name_signal = next(
|
||||
(signal for signal in signals if signal.get("kind") == "name"), None
|
||||
)
|
||||
has_anchor = bool(tag_for_track.get("jan")) or bool(
|
||||
name_signal and float(name_signal.get("note", "0").split()[-1] or 0) >= 0.35
|
||||
)
|
||||
# The anchor is the tag's own arithmetic agreeing, or a name that actually
|
||||
# resembles the product. (It used to be `tag.jan`, which held a *price*.)
|
||||
flags = set(tag_for_track.get("flags") or [])
|
||||
corroborated = bool({"tax_consistent", "discount_consistent"} & flags)
|
||||
name_score = float(name_signal.get("score") or 0.0) if name_signal else 0.0
|
||||
has_anchor = corroborated or name_score >= 0.35
|
||||
if not has_anchor:
|
||||
findings.append(
|
||||
Finding(
|
||||
|
|
@ -663,8 +681,20 @@ def identities_from_tags(
|
|||
for track in row:
|
||||
box = track.get("box") or track.get("raw_box")
|
||||
center = (box[0] + box[2]) / 2.0
|
||||
facing_width = max(1, box[2] - box[0])
|
||||
# A tag names the product at its own position on the rail. Without a
|
||||
# distance cap a facing whose own tag was not detected inherits the
|
||||
# nearest readable tag's product, which can belong to a different
|
||||
# product several columns away.
|
||||
limit = max(TAG_REACH_FACINGS * facing_width, TAG_REACH_PX)
|
||||
candidates = [
|
||||
tag for tag in row_tags
|
||||
if abs(center - (tag["box"][0] + tag["box"][2]) / 2.0) <= limit
|
||||
]
|
||||
if not candidates:
|
||||
continue
|
||||
nearest = min(
|
||||
row_tags,
|
||||
candidates,
|
||||
key=lambda tag: abs(center - (tag["box"][0] + tag["box"][2]) / 2.0),
|
||||
)
|
||||
sku_id = nearest.get("sku_from_name")
|
||||
|
|
@ -737,6 +767,20 @@ def attach_tags_to_boxes(
|
|||
current = track["identity"] or {}
|
||||
if current.get("state") == "recognized":
|
||||
if current.get("sku_id") != derived["sku_id"]:
|
||||
# Only a corroborated tag, or a name that closely resembles the
|
||||
# product, may overrule the packaging channel. A weak 0.40 name
|
||||
# match used to be enough, which is not evidence.
|
||||
tag_flags = set((tag_by_track.get(int(track["track_id"])) or {}).get("flags") or [])
|
||||
strong = bool({"tax_consistent", "discount_consistent"} & tag_flags) or \
|
||||
float(derived.get("score") or 0.0) >= TAG_OVERRIDE_SIMILARITY
|
||||
if not strong:
|
||||
track["identity_conflict"] = {
|
||||
"packaging": current.get("sku_id"),
|
||||
"tag": derived["sku_id"],
|
||||
"packaging_score": current.get("score"),
|
||||
"note": "tag name match too weak to overrule packaging",
|
||||
}
|
||||
continue
|
||||
# Two channels disagree. Measured on the demo shelf: where both
|
||||
# named the same facing they agreed only 69% of the time, and the
|
||||
# packaging side of every disagreement carried a low score - so
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ def tag(state=TAG_STATE_READABLE, **overrides):
|
|||
"price": None,
|
||||
"regular_price": None,
|
||||
"name": None,
|
||||
"barcode": None,
|
||||
"flags": [],
|
||||
"confidence": 0.8,
|
||||
"observations": 3,
|
||||
"settled": True,
|
||||
|
|
@ -96,12 +98,14 @@ class ReconcileTests(unittest.TestCase):
|
|||
result = reconcile(accepted(), tag(name="Tropicana エッセンシャルズ"), product(price=None, name="Tropicana エッセンシャルズ マルチビタミン"))
|
||||
self.assertEqual(result["state"], RECONCILED_CONFIRMED)
|
||||
|
||||
def test_jan_disagreement_is_a_mismatch(self):
|
||||
result = reconcile(accepted(), tag(price=168, jan="4902105000001"), product(price=168.0, jan="4902105000002"))
|
||||
def test_barcode_disagreement_is_a_mismatch(self):
|
||||
result = reconcile(accepted(), tag(price=168, barcode="4902105000001"),
|
||||
product(price=168.0, jan="4902105000002"))
|
||||
self.assertEqual(result["state"], RECONCILED_MISMATCH)
|
||||
|
||||
def test_jan_agreement_confirms(self):
|
||||
result = reconcile(accepted(), tag(price=None, jan="4902105000001"), product(price=None, jan="4902105000001"))
|
||||
def test_barcode_agreement_confirms(self):
|
||||
result = reconcile(accepted(), tag(price=None, barcode="4902105000001"),
|
||||
product(price=None, jan="4902105000001"))
|
||||
self.assertEqual(result["state"], RECONCILED_CONFIRMED)
|
||||
|
||||
def test_tag_read_but_nothing_comparable(self):
|
||||
|
|
@ -284,7 +288,8 @@ class ComplianceTests(unittest.TestCase):
|
|||
# A reportable mismatch carries an anchor: the tag's JAN was read, so we
|
||||
# know which tag we are comparing.
|
||||
tracks = [track(1, [10, 100, 90, 300])]
|
||||
tags = {1: tag(state=TAG_STATE_READABLE, price=269, jan="357")}
|
||||
# The anchor is the tag's own arithmetic agreeing, not a price field.
|
||||
tags = {1: tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])}
|
||||
reconciliations = {1: reconcile(accepted(), tags[1], product(price=168.0))}
|
||||
findings = evaluate_compliance(tracks, tags, [tags[1]], BANDS, reconciliations)
|
||||
mismatches = [finding for finding in findings if finding.rule == RULE_PRICE_MISMATCH]
|
||||
|
|
@ -308,8 +313,8 @@ class ComplianceTests(unittest.TestCase):
|
|||
findings = [
|
||||
evaluate_compliance(
|
||||
[track(1, [10, 100, 90, 300])],
|
||||
{1: tag(state=TAG_STATE_READABLE, price=269, jan="357")},
|
||||
[tag(state=TAG_STATE_READABLE, price=269, jan="357")],
|
||||
{1: tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])},
|
||||
[tag(state=TAG_STATE_READABLE, price=269, flags=["tax_consistent"])],
|
||||
BANDS,
|
||||
{1: reconcile(accepted(), tag(price=269), product(price=168.0))},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ def facing(track_id, box, sku=None):
|
|||
|
||||
|
||||
def tag(box, name=None, state=TAG_STATE_READABLE):
|
||||
return {"state": state, "box": box, "name": name, "jan": None, "price": None}
|
||||
return {"state": state, "box": box, "name": name, "regular_price": None, "price": None,
|
||||
"barcode": None, "flags": []}
|
||||
|
||||
|
||||
class NameMatchTests(unittest.TestCase):
|
||||
|
|
@ -71,6 +72,21 @@ class AssignmentTests(unittest.TestCase):
|
|||
self.assertEqual(len(found), 4, "one tag covers the whole run of facings")
|
||||
self.assertTrue(all(info["sku_id"] == "sku_l05" for info in found.values()))
|
||||
|
||||
def test_a_distant_facing_does_not_inherit_a_tag(self):
|
||||
"""A product whose own tag was not detected must not take its neighbour's.
|
||||
|
||||
Without a reach limit the nearest readable tag names it, which is how a
|
||||
facing several columns away can acquire the wrong product.
|
||||
"""
|
||||
tracks = [
|
||||
facing(0, [10, 100, 100, 300]),
|
||||
facing(1, [1600, 100, 1690, 300]), # far along the same row
|
||||
]
|
||||
tags = [tag([20, 320, 180, 380], name="キューピー ゼロ ノンコレステロール 310g")]
|
||||
found = identities_from_tags(tracks, {}, CATALOG, all_tags=tags)
|
||||
self.assertIn(0, found)
|
||||
self.assertNotIn(1, found, "out of reach of that tag")
|
||||
|
||||
def test_the_nearest_tag_wins_along_a_row(self):
|
||||
# Two products side by side, one tag each.
|
||||
tracks = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue