""" designed_spec.py — the DESIGNED piece's bill of materials (ported from sketch-to-quote src/lib/spec.js). The catalogue match (nearest gold SKU by YOUR CLIP embedding) is a factory-reality ANCHOR: it proves the build is manufacturable and gives a weight scale. But the quantities belong to the design that was drawn — from the best source available: 'design' — a vision pass counted/sized the stones, 'brief' — the customer's words carried a count, 'anchor' — nothing better, so the closest costed stack stands in. Note vs the source app: server-side there is no browser pixel-measurement of the ring opening, so stone sizes fall back to category-typical spans (labelled an estimate) rather than a measured scale. Ring-size weight scaling is fully ported. """ import re from sieve import SIEVE_TABLE, sieve_by_name, sieve_for_mm, sieve_for_ct from quote import normalize_lines, total_ct, total_pcs from market_ref import market_ct_band, typical_span from ringsize import size_weight_factor, band_share_for, is_ring, REF_SIZE, inner_diameter_mm from bom import base_sku WORD_NUM = {"one": 1, "single": 1, "a": 1, "an": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "fifteen": 15, "sixteen": 16, "twenty": 20} CENTRE_CT_CAP = 1.5 MAX_CT_PER_STONE = 3 def parse_brief(text, category): """Pull an explicit stone count out of the customer's words (conservative).""" t = " " + re.sub(r"[,.]", " ", str(text or "").lower()) + " " count = None centre = False num = r"(\d+|" + "|".join(WORD_NUM.keys()) + r")" m = re.search(num + r"\s*(?:tiny |small |round |brilliant |lab[- ]grown |white )*" r"(?:diamonds?|stones?|solitaires?|cz|gems?)", t) if m: count = int(m.group(1)) if m.group(1).isdigit() else WORD_NUM.get(m.group(1)) if re.search(r"\b(solitaire|single stone|one stone|centre stone|center stone)\b", t): centre = True if count is None: count = 1 if re.search(r"\b(pave|pavé|studded|encrusted|cluster|halo|eternity)\b", t) and count is None: count = None each = bool(re.search(r"\b(each|both|every)\s+(wing|side|end|petal|leaf|star|corner|link|ear)", t)) if each and count is not None: count *= 2 pair_words = bool(re.search(r"\b(pair|studs?|earrings?|jhumkas?|hoops?)\b", t)) is_pair = category == "Earrings" or pair_words return {"count": count, "centre": centre, "isPair": is_pair, "perPieceDoubled": each} def stone_mm_from_fraction(span_fraction, span_mm): f = float(span_fraction or 0) s = float(span_mm or 0) if not (f > 0) or not (s > 0): return None return round(min(0.9, f) * s, 2) def _dominant_sieve(lines): by = sorted(normalize_lines(lines), key=lambda l: -l["pcs"]) return by[0]["sieve"] if by else "+1.5-2" def _largest_sieve(lines): by = sorted(normalize_lines(lines), key=lambda l: -l["ctPerStone"]) return by[0]["sieve"] if by else "+9-9.5" def _line_for(sieve, pcs): s = sieve_by_name(sieve) or SIEVE_TABLE[2] pcs = max(0, round(pcs)) return {"sieve": s["sieve"], "mm": s["mm"], "ctPerStone": s["ct"], "pcs": pcs, "ct": round(max(0, pcs) * s["ct"], 3)} def _anchor_lines(bom): out = [] for d in (bom.get("diamonds") if bom else []) or []: pcs = float(d.get("pcs") or 0) cps = (float(d["ct"]) / pcs) if pcs > 0 else (float(d.get("ct") or 0) or 0.01) s = sieve_by_name(d.get("sieve")) or sieve_for_ct(cps) out.append({"sieve": s["sieve"], "mm": s["mm"], "ctPerStone": s["ct"], "pcs": pcs}) return normalize_lines(out) def _vision_lines(groups, multiplier, anchor_lines, scale, category): anchor_cts = [l["ctPerStone"] for l in anchor_lines if l["ctPerStone"] > 0] hi = max(anchor_cts) * 2 if anchor_cts else 0.05 lo = min(anchor_cts) * 0.5 if anchor_cts else 0.003 band = market_ct_band(category) if band: hi = min(hi, band["hi"]) lo = min(lo, band["lo"]) trust_size = bool(scale and scale.get("measured")) merged = {} for g in groups or []: pcs = round(float(g.get("count") or 0) * multiplier) if pcs <= 0: continue mm = stone_mm_from_fraction(g.get("span_fraction"), scale.get("spanMm") if scale else None) s = sieve_for_mm(mm) if mm and mm > 0 else sieve_for_ct( (band or {}).get("median") or SIEVE_TABLE[2]["ct"]) centre = bool(re.search(r"centre|center|solitaire", str(g.get("role") or ""), re.I)) if trust_size: if s["ct"] > MAX_CT_PER_STONE: s = sieve_for_ct(MAX_CT_PER_STONE) else: ceiling = max(hi, CENTRE_CT_CAP) if centre else hi if s["ct"] > ceiling: s = sieve_for_ct(ceiling) if s["ct"] < lo: s = sieve_for_ct(lo) merged[s["sieve"]] = merged.get(s["sieve"], 0) + pcs return normalize_lines([_line_for(sieve, pcs) for sieve, pcs in merged.items()]) def build_designed_spec(matches, boms, vision, brief, category, kt, color, scale=None, ring_size=None, anchor_sku=None): """matches: [{sku,title,similarity}]; boms: {base_sku: bom}.""" covered = [] for m in (matches or []): b = base_sku(m.get("sku")) bom = (boms or {}).get(b) if bom: covered.append({**m, "base": b, "bom": bom}) if not covered: return None # Default scale: no server-side pixel measurement, so category-typical span. if scale is None: scale = {"spanMm": typical_span(category), "measured": False, "source": f"typical {str(category).lower()} span"} pinned = next((m for m in covered if m["base"] == anchor_sku), None) if anchor_sku else None anchor = pinned or next((m for m in covered if m["bom"].get("diamonds")), None) or covered[0] a_lines = _anchor_lines(anchor["bom"]) with_gold = [m for m in covered if float(m["bom"].get("gold_gm") or 0) > 0] wsum = sum(m["similarity"] for m in with_gold) blend_gm = (sum(m["similarity"] * float(m["bom"]["gold_gm"]) for m in with_gold) / wsum if wsum else float(anchor["bom"].get("gold_gm") or 0) or None) prior_gm = (float(pinned["bom"]["gold_gm"]) if pinned and float(pinned["bom"].get("gold_gm") or 0) > 0 else blend_gm) if not prior_gm: return None parsed = parse_brief(brief, category) is_pair = vision.get("is_pair") if vision and "is_pair" in vision else parsed["isPair"] # ---- stone stack ---- lines = a_lines stone_source = "anchor" dense = False count_conf = str((vision or {}).get("count_confidence", "high")).lower() count_trusted = count_conf == "high" if vision and vision.get("stone_groups") and (count_trusted or parsed["count"] is None): mult = 2 if is_pair and (float(vision.get("pieces_visible") or 2) < 2) else 1 vl = _vision_lines(vision["stone_groups"], mult, a_lines, scale, category) if total_pcs(vl) > 0: lines = vl stone_source = "design" if not count_trusted: dense = True elif parsed["count"] is not None: want = parsed["count"] * (2 if is_pair and not parsed["perPieceDoubled"] else 1) band = market_ct_band(category) small = bool(re.search(r"\b(small|tiny|delicate|dainty|micro|petite)\b", str(brief or ""), re.I)) def accent(sieve): s = sieve_by_name(sieve) or SIEVE_TABLE[2] if band and small: s = sieve_for_ct(band["median"]) elif band and s["ct"] > band["hi"]: s = sieve_for_ct(band["hi"]) return s["sieve"] if parsed["centre"] and want > 1: lines = normalize_lines([_line_for(_largest_sieve(a_lines), 1), _line_for(accent(_dominant_sieve(a_lines)), want - 1)]) elif parsed["centre"]: lines = normalize_lines([_line_for(_largest_sieve(a_lines), want)]) else: lines = normalize_lines([_line_for(accent(_dominant_sieve(a_lines)), want)]) stone_source = "brief" # ---- gold weight ---- gold_gm = prior_gm weight_source = "neighbours" ratio = float((vision or {}).get("metal_ratio") or 0) if ratio > 0 and float(anchor["bom"].get("gold_gm") or 0) > 0: scaled = float(anchor["bom"]["gold_gm"]) * ratio blended = (scaled * prior_gm) ** 0.5 gold_gm = min(max(blended, prior_gm * 0.6), prior_gm * 1.8) weight_source = "design" # ---- ring size ---- size_info = None if is_ring(category) and ring_size: b_share = band_share_for({"lines": lines}) thickness_mm = 1.4 vm = (vision or {}).get("metal", {}) if float(vm.get("thickness_fraction") or 0) > 0 and scale.get("spanMm"): thickness_mm = min(3, max(0.6, vm["thickness_fraction"] * scale["spanMm"])) factor = size_weight_factor(ring_size, REF_SIZE, b_share, thickness_mm) gold_gm *= factor size_info = {"size": ring_size, "refSize": REF_SIZE, "factor": round(factor, 3), "innerMm": inner_diameter_mm(ring_size), "bandShare": b_share, "thicknessMm": round(thickness_mm, 1)} gold_gm = round(gold_gm, 2) notes = [] if stone_source == "anchor" and not vision: notes.append("Stone count taken from the closest costed design — say how many stones you want, or edit the stack.") if dense: notes.append("Dense stone work — the count is an estimate; check against the drawing.") if stone_source == "design" and scale and not scale.get("measured") and total_pcs(lines) > 0: notes.append(f"Stone sizes are scaled off a {scale.get('source')}, not a measurement" + (" — pick a ring size to measure them exactly" if is_ring(category) else "")) if (vision or {}).get("structural_defects"): notes.append("Design flagged: " + "; ".join(vision["structural_defects"])) return { "category": (vision or {}).get("category") or (anchor["bom"].get("category") if category == "Any" else category) or category, "units": 2 if is_pair else 1, "goldGm": gold_gm, "goldKt": kt, "goldColor": color, "lines": lines, "ctTotal": total_ct(lines), "pcsTotal": total_pcs(lines), "stoneSource": stone_source, "stoneEstimated": dense, "sizeInfo": size_info, "scale": scale, "weightSource": weight_source, "notes": notes, "anchor": { "sku": anchor["base"], "title": anchor.get("title"), "sim": anchor["similarity"], "goldGm": float(anchor["bom"].get("gold_gm") or 0) or None, "kt": anchor["bom"].get("gold_kt"), "color": anchor["bom"].get("gold_color"), "lines": a_lines, "ct": total_ct(a_lines), "pcs": total_pcs(a_lines), "pinned": bool(pinned), }, "candidates": [{ "sku": m["base"], "title": m.get("title"), "sim": m["similarity"], "goldGm": float(m["bom"].get("gold_gm") or 0) or None, "pcs": total_pcs(_anchor_lines(m["bom"])), "ct": total_ct(_anchor_lines(m["bom"])), } for m in covered], "coveredCount": len(covered), } SOURCE_LABEL = { "design": "from your design", "brief": "from your brief", "anchor": "from closest match", "neighbours": "averaged from lookalikes", "edited": "edited by you", }