feat(review): #90 wood primary category + derived thickness band

This commit is contained in:
Ty
2026-09-21 02:41:20 -07:00
parent e1b928ccbd
commit 0329052d37
13 changed files with 719 additions and 42 deletions
+177
View File
@@ -0,0 +1,177 @@
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
synced band term. We map thickness_in into the first half-open band [low, high)
that contains it, derived only from the live synced term names/slugs — never a
hardcoded Woo term id.
Round-up semantics: bands are half-open on the upper bound, so a value that sits
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
value (1"–1 1/2"), not the band that ends there (3/4"–1").
"""
from __future__ import annotations
import re
from typing import Any, Iterable, Optional
# Mixed number / fraction / decimal inch token, optional trailing quote.
_INCH_RE = re.compile(
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
re.IGNORECASE,
)
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
def parse_inch_token(text: str) -> Optional[float]:
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
t = (text or "").strip().rstrip("\"'").strip()
if not t:
return None
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
if not m:
return None
if m.group("a") is not None:
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
if m.group("n2") is not None:
return float(m.group("n2")) / float(m.group("d2"))
if m.group("dec") is not None:
return float(m.group("dec"))
return None
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
"""Return half-open [low, high) inches from a Woo thickness term name."""
raw = (name or "").strip()
low_s = raw.lower()
if not raw:
return None
# Under X / less than X / < X
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
if m:
hi = parse_inch_token(m.group(1))
if hi is not None:
return (0.0, hi)
# Over X / above X / > X / X+
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
m = re.match(r"^(.+?)\+\s*$", raw)
if m:
lo = parse_inch_token(m.group(1))
if lo is not None:
return (lo, float("inf"))
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
if len(parts) == 2:
lo = parse_inch_token(parts[0])
hi = parse_inch_token(parts[1])
if lo is not None and hi is not None and hi > lo:
return (lo, hi)
# Single value band treated as [v, v+epsilon) — rare
only = parse_inch_token(raw)
if only is not None:
return (only, only + 1e-9)
return None
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
s = (slug or "").strip().lower()
if not s:
return None
if s in {"xthin", "under", "thin"}:
return (0.0, 0.5)
m = _QTR_RANGE_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
hi = int(m.group(2)) * 0.25
if hi > lo:
return (lo, hi)
m = _QTR_MIN_RE.match(s)
if m:
lo = int(m.group(1)) * 0.25
return (lo, float("inf"))
return None
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
"""Bounds for one synced thickness term; name first, slug fallback."""
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
str(term.get("slug") or "")
)
def resolve_thickness_band_id(
thickness_in: float | int | None,
terms: Iterable[dict[str, Any]],
) -> Optional[int]:
"""Pick the synced thickness term id for thickness_in, or None if unknown.
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
"""
if thickness_in is None:
return None
try:
t = float(thickness_in)
except (TypeError, ValueError):
return None
if t < 0:
return None
scored: list[tuple[float, float, int]] = [] # (low, high, id)
for term in terms:
b = term_bounds(term)
if b is None:
continue
tid = term.get("id")
if tid is None:
continue
lo, hi = b
scored.append((lo, hi, int(tid)))
if not scored:
return None
# Prefer the tightest band that contains t under half-open [low, high).
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
if matches:
matches.sort() # tightest span first
return matches[0][3]
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
scored.sort(key=lambda x: x[0])
for lo, hi, tid in scored:
if t < hi:
return tid
return scored[-1][2]
def load_thickness_terms(conn) -> list[dict[str, Any]]:
"""Live Thickness attribute terms from the synced Woo cache."""
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
("thickness", "thickness"),
).fetchone()
if row is None:
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
row = conn.execute(
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
).fetchone()
if row is None:
return []
attr_id = row[0]
rows = conn.execute(
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
(attr_id,),
).fetchall()
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]