File size: 11,664 Bytes
73d02ec
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
"""
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",
}