reconcile() compares one accepted identity with its tag and the catalog entry and publishes a state plus the individual signals (JAN exact, name by longest common substring, price against either the sale or the regular price). It never changes the product identity: a disagreement is a second opinion shown next to the first, and only a checksummed barcode outranks the matcher. evaluate_compliance() is the first rule family: missing_price_tag, the owner's missing_tag_group (>= 3 consecutive facings without a tag), orphan_tag, price_mismatch, plus an unreadable_tags hint. Every rule fails closed on evidence quality - a row whose tags could not be read produces "move closer", never a violation. Findings must appear in 3 of the last 5 frames before they publish, so the UI never flickers. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""
|
||
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"],
|
||
}
|