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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue