""" Reconciliation (item vs price tag) and the first compliance rule family. Two pure layers, no I/O: - `reconcile()` compares one accepted identity with its tag and the catalog entry, and publishes a state plus the individual signals. It never changes the product identity: a disagreement is a second opinion, displayed next to the first. - `evaluate_compliance()` turns settled evidence into findings. Every rule fails closed on evidence quality: a row whose tags are merely unreadable produces a capture hint, never a violation. """ from __future__ import annotations import os import re import sys import unicodedata from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple current_dir = os.path.dirname(os.path.abspath(__file__)) if current_dir not in sys.path: sys.path.insert(0, current_dir) from tags import TAG_STATE_ABSENT, TAG_STATE_READABLE, TAG_STATE_UNREADABLE # noqa: E402 RECONCILED_CONFIRMED = "confirmed" RECONCILED_MISMATCH = "mismatch" RECONCILED_UNVERIFIED = "unverified" RECONCILED_NO_TAG = "no_tag" RECONCILED_TAG_ONLY = "tag_only" RULE_MISSING_TAG = "missing_price_tag" RULE_MISSING_TAG_GROUP = "missing_tag_group" RULE_ORPHAN_TAG = "orphan_tag" RULE_PRICE_MISMATCH = "price_mismatch" RULE_DUPLICATE_TAG = "duplicate_tag" RULE_UNREADABLE_TAGS = "unreadable_tags" NAME_MATCH_THRESHOLD = 0.4 GROUP_MIN_FACINGS = 3 STABILITY_FRAMES = 3 STABILITY_WINDOW = 5 # --------------------------------------------------------------------------- # text matching # --------------------------------------------------------------------------- def normalize_name(text: Optional[str]) -> str: if not text: return "" text = unicodedata.normalize("NFKC", text) text = re.sub(r"<[^>]*>", "", text) text = re.sub(r"[\s・、。,.\-–—_/]+", "", text) return text.lower() def longest_common_substring_ratio(a: str, b: str) -> float: """Length of the longest common substring over the shorter string.""" if not a or not b: return 0.0 shorter, longer = (a, b) if len(a) <= len(b) else (b, a) best = 0 for start in range(len(shorter)): for end in range(start + best + 1, len(shorter) + 1): if shorter[start:end] in longer: best = end - start else: break return best / len(shorter) # --------------------------------------------------------------------------- # reconciliation # --------------------------------------------------------------------------- @dataclass class Signal: kind: str # "jan" | "name" | "price" agrees: bool item: Any tag: Any note: Optional[str] = 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 return payload def reconcile( identity: Optional[Dict[str, Any]], tag: Optional[Dict[str, Any]], product: Optional[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: """Compare one facing with its tag. Returns None when there is nothing to reconcile (no accepted identity and no readable tag). A tag with no product above it is `tag_only`. """ identity_state = (identity or {}).get("state") tag_state = (tag or {}).get("state", TAG_STATE_ABSENT) accepted = identity_state == "recognized" readable_tag = tag_state == TAG_STATE_READABLE if not accepted and not readable_tag: return None if accepted and tag_state == TAG_STATE_ABSENT: return {"state": RECONCILED_NO_TAG, "signals": [], "message": None} if accepted and tag_state == TAG_STATE_UNREADABLE: return { "state": RECONCILED_UNVERIFIED, "signals": [], "message": "tag found but its text was not read", } if not accepted and readable_tag: return {"state": RECONCILED_TAG_ONLY, "signals": [], "message": None} 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)) item_name = normalize_name((product or {}).get("product_name")) tag_name = normalize_name((tag or {}).get("name")) if item_name and tag_name: 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}") ) item_price = (product or {}).get("price") tag_price = (tag or {}).get("price") tag_regular = (tag or {}).get("regular_price") if item_price is not None and (tag_price is not None or tag_regular is not None): candidates = [value for value in (tag_price, tag_regular) if value is not None] agrees = any(abs(float(item_price) - float(value)) < 0.5 for value in candidates) signals.append(Signal("price", agrees, item_price, tag_price if tag_price is not None else tag_regular)) if not signals: return { "state": RECONCILED_UNVERIFIED, "signals": [], "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] state = RECONCILED_CONFIRMED if decided.agrees else RECONCILED_MISMATCH elif any(signal.agrees for signal in signals): state = RECONCILED_CONFIRMED elif all(not signal.agrees for signal in signals): state = RECONCILED_MISMATCH else: state = RECONCILED_UNVERIFIED message = None if state == RECONCILED_MISMATCH: price_signal = next((signal for signal in signals if signal.kind == "price"), None) name_signal = next((signal for signal in signals if signal.kind == "name"), None) if price_signal is not None: message = f"tag says {price_signal.tag}, catalog says {price_signal.item}" elif name_signal is not None: message = "tag name does not match the catalog entry" return {"state": state, "signals": [signal.as_dict() for signal in signals], "message": message} # --------------------------------------------------------------------------- # compliance # --------------------------------------------------------------------------- @dataclass class ComplianceSettings: group_min_facings: int = GROUP_MIN_FACINGS stability_frames: int = STABILITY_FRAMES stability_window: int = STABILITY_WINDOW @dataclass class Finding: rule: str severity: str # "violation" | "hint" row_index: Optional[int] box: List[int] count: int = 1 skus: List[str] = field(default_factory=list) detail: Optional[str] = None def key(self) -> Tuple[Any, ...]: return (self.rule, self.row_index, self.box[0] // 32, self.box[1] // 32, self.count) def as_dict(self) -> Dict[str, Any]: payload = { "rule": self.rule, "severity": self.severity, "row_index": self.row_index, "box": list(self.box), "count": self.count, "skus": list(self.skus), } if self.detail: payload["detail"] = self.detail return payload class ComplianceTracker: """Keeps findings only while they persist, so the UI never flickers.""" def __init__(self, settings: Optional[ComplianceSettings] = None): self.settings = settings or ComplianceSettings() self._history: List[set] = [] def update(self, findings: Sequence[Finding]) -> List[Finding]: current = {finding.key(): finding for finding in findings} self._history.append(set(current)) if len(self._history) > self.settings.stability_window: self._history.pop(0) stable: List[Finding] = [] for key, finding in current.items(): seen = sum(1 for frame in self._history if key in frame) if seen >= self.settings.stability_frames: stable.append(finding) return stable def reset(self) -> None: self._history.clear() def evaluate_compliance( tracks: Sequence[Dict[str, Any]], tag_by_track: Dict[int, Dict[str, Any]], all_tags: Sequence[Dict[str, Any]], bands: Sequence[Any], reconciliations: Optional[Dict[int, Dict[str, Any]]] = None, settings: Optional[ComplianceSettings] = None, ) -> List[Finding]: """Findings from settled evidence. Rules with unreadable evidence stay silent. `tag_by_track` is the per-facing attachment; `all_tags` is every tag the pass found, so a tag nobody claimed can be reported as an orphan. """ settings = settings or ComplianceSettings() findings: List[Finding] = [] by_row: Dict[int, List[Dict[str, Any]]] = {} for track in tracks: row_index = _row_for(track.get("box") or track.get("raw_box"), bands) by_row.setdefault(row_index, []).append(track) for row_index, row_tracks in by_row.items(): row_tracks.sort(key=lambda track: (track.get("box") or [0])[0]) row_tags = [tag_by_track.get(int(track["track_id"]), {}) for track in row_tracks] readable = [tag for tag in row_tags if tag.get("state") == TAG_STATE_READABLE] if not readable: # Fail closed: nothing in this row could be read, so nothing can be # claimed about missing tags here. Report the capture problem instead. if any(tag.get("state") == TAG_STATE_UNREADABLE for tag in row_tags): findings.append( Finding( rule=RULE_UNREADABLE_TAGS, severity="hint", row_index=row_index, box=_row_box(row_tracks), count=len(row_tracks), detail="move closer: the price tags in this row could not be read", ) ) continue # missing_price_tag, per facing missing: List[Dict[str, Any]] = [] for track, tag in zip(row_tracks, row_tags): if tag.get("state") == TAG_STATE_ABSENT: missing.append(track) for track in missing: findings.append( Finding( rule=RULE_MISSING_TAG, severity="violation", row_index=row_index, box=list(track.get("box") or track.get("raw_box") or [0, 0, 0, 0]), count=1, skus=[_sku_of(track)], ) ) # missing_tag_group: consecutive facings without a tag run: List[Dict[str, Any]] = [] for track, tag in zip(row_tracks, row_tags): if tag.get("state") == TAG_STATE_ABSENT: run.append(track) continue if len(run) >= settings.group_min_facings: findings.append(_group_finding(row_index, run)) run = [] if len(run) >= settings.group_min_facings: findings.append(_group_finding(row_index, run)) # price_mismatch from reconciliation for track in tracks: track_id = int(track["track_id"]) reconciliation = (reconciliations or {}).get(track_id) if not reconciliation or reconciliation.get("state") != RECONCILED_MISMATCH: continue if not any(signal.get("kind") == "price" for signal in reconciliation.get("signals", [])): continue findings.append( Finding( rule=RULE_PRICE_MISMATCH, severity="violation", row_index=_row_for(track.get("box") or track.get("raw_box"), bands), box=list(track.get("box") or track.get("raw_box") or [0, 0, 0, 0]), count=1, skus=[_sku_of(track)], detail=reconciliation.get("message"), ) ) # orphan_tag: a readable tag that no facing claims (a tag with no product above # it is either an empty slot with a leftover tag or a mis-clustered read). attached_ids = { tag.get("id") for tag in tag_by_track.values() if tag.get("id") } for tag in all_tags: if tag.get("state") != TAG_STATE_READABLE or not tag.get("id"): continue if tag["id"] in attached_ids: continue findings.append( Finding( rule=RULE_ORPHAN_TAG, severity="violation", row_index=None, box=list(tag.get("box") or [0, 0, 0, 0]), count=1, detail="price tag with no product above it", ) ) return findings def _group_finding(row_index: int, run: Sequence[Dict[str, Any]]) -> Finding: boxes = [track.get("box") or track.get("raw_box") or [0, 0, 0, 0] for track in run] x1 = min(box[0] for box in boxes) y1 = min(box[1] for box in boxes) x2 = max(box[2] for box in boxes) y2 = max(box[3] for box in boxes) return Finding( rule=RULE_MISSING_TAG_GROUP, severity="violation", row_index=row_index, box=[x1, y1, x2, y2], count=len(run), skus=[_sku_of(track) for track in run], detail=f"{len(run)} facings in a row without a price tag", ) def _row_box(tracks: Sequence[Dict[str, Any]]) -> List[int]: boxes = [track.get("box") or track.get("raw_box") or [0, 0, 0, 0] for track in tracks] return [ min(box[0] for box in boxes), min(box[1] for box in boxes), max(box[2] for box in boxes), max(box[3] for box in boxes), ] def _row_for(box: Optional[Sequence[int]], bands: Sequence[Any]) -> int: """Which rail band (i.e. row) a facing sits above.""" if not box or not bands: return 0 x_center = (box[0] + box[2]) / 2.0 y_bottom = box[3] best_index = 0 best_distance = None for band in bands: bx1, by1, bx2, by2 = band.box if not (bx1 - 40 <= x_center <= bx2 + 40): continue distance = abs(by1 - y_bottom) if best_distance is None or distance < best_distance: best_distance = distance best_index = band.row_index return best_index def _sku_of(track: Dict[str, Any]) -> str: identity = track.get("identity") or {} return str(identity.get("sku_id") or "") def summarize_findings(findings: Sequence[Finding]) -> Dict[str, Any]: by_rule: Dict[str, int] = {} for finding in findings: if finding.severity != "violation": continue by_rule[finding.rule] = by_rule.get(finding.rule, 0) + 1 return { "violations": sum(by_rule.values()), "by_rule": by_rule, "hints": [finding.as_dict() for finding in findings if finding.severity != "violation"], }