Spaces:
Sleeping
Sleeping
| """ | |
| 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", | |
| } | |