Trek Turkey commited on
Commit
021bdc2
·
1 Parent(s): d112466

feat(mimari): ANLAMA LLM'DE, HESAP KODDA — yapilandirilmis stok sorgusu

Browse files

Kullanicinin tezi dogrulandi: her Turkce cekim icin kural yazmak surdurulemez.
Stopword listesi 100 kelimeyi asti; 'elektrikli' kurali varken 'Elektrik
Bisiklet' bir harf farkiyla deldi, bot Verve 3'u yine elektrikli diye satti.

YENI AKIS:
musteri mesaji -> intent analyzer (gpt-4o-mini, ZATEN her mesajda calisiyor)
-> yapilandirilmis sorgu {urun, kategori, beden, renk, magaza, fiyat_max,
siralama, adet_sorusu, malzeme, onceki_urun}
-> lookup_stock_structured(): KOD deterministik calistirir (filtre/sayma/
siralama — olcum: sayma sorusunda Luna=19, Terra=19, kod=25 DOGRU)
-> ayni hukum/seal formatiyla doner.

- 'sarjli/akulu/pilli/elektrik' icin kural GEREKMEZ — model normalize eder.
- 'urun' alani SADECE mevcut mesajtan (onceki sohbetten Madone tasima
zehirlenmesi bitti); takip sorusu 'onceki_urun: true' ile ayri.
- Eski metin ayristirici FALLBACK olarak durur -> regresyon 65/65 yesil.
- Ek maliyet ~SIFIR: analyzer zaten calisiyordu, sema genisledi.
- /debug-intent endpoint'i: soru -> LLM anlama -> kod hesap zinciri testi.

Ilk denemede yakalanan bug da duzeltildi: siralama stok kontrolunden ONCE
ilk 5'i kesiyordu, en ucuz katalog kayitlari stoksuz cikinca bos donuyordu.

Files changed (3) hide show
  1. app.py +54 -7
  2. intent_analyzer.py +40 -2
  3. warehouse_lookup.py +226 -0
app.py CHANGED
@@ -1523,7 +1523,30 @@ def process_whatsapp_message_with_memory(user_message, phone_number):
1523
 
1524
  # Stok bilgisi ekle - detected_product kullan
1525
  # ONCELIK: XML sonucu (smart_warehouse_with_price.py) daha guvenilir
1526
- warehouse_info = get_warehouse_stock(stock_query_product)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1527
  # TAKIP SORUSU DESTEGI (11 Agu): "medium ne var" gibi urunsuz sorgular
1528
  # tek basina eslesmez; sohbet baglamindaki son urunle birlestirip dene.
1529
  # ("small elektrikli bisiklet" -> "medium ne var" => "medium elektrikli bisiklet")
@@ -1621,12 +1644,13 @@ def process_whatsapp_message_with_memory(user_message, phone_number):
1621
  # arama adinda 'karbon' gecen AKSESUARLARI buluyor ve kompakt stok
1622
  # listesi hic gonderilmiyordu -> bot 'stokta karbon bisiklet yok'
1623
  # dedi (11 Agu). Malzeme sorusunda o yolu ATLA, dogrudan listeye git.
1624
- _is_material_q = False
1625
- try:
1626
- from warehouse_lookup import _detect_material
1627
- _is_material_q = _detect_material(user_message) is not None
1628
- except Exception:
1629
- pass
 
1630
 
1631
  if should_query_stock and stock_query_product and not xml_has_valid_stock:
1632
  realtime_stock = None if _is_material_q else get_realtime_stock_parallel(stock_query_product)
@@ -2085,6 +2109,29 @@ async def debug_flow(q: str):
2085
  })
2086
 
2087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2088
  @app.get("/model-compare")
2089
  async def model_compare(q: str, models: str = "gpt-5.6-luna,gpt-5.6-terra"):
2090
  """LUNA vs TERRA — ayni soru, ayni baglam, farkli model.
 
1523
 
1524
  # Stok bilgisi ekle - detected_product kullan
1525
  # ONCELIK: XML sonucu (smart_warehouse_with_price.py) daha guvenilir
1526
+ # YAPILANDIRILMIS SORGU — ANLAMA LLM'DE, HESAP KODDA (11 Agu gece).
1527
+ # Intent analyzer serbest Turkce'yi yapiya cevirir ({kategori:
1528
+ # elektrikli, siralama: ucuz...}), kod deterministik calistirir.
1529
+ # 'Elektrik/sarjli/akulu' icin kural GEREKMEZ — model Turkce'yi bilir.
1530
+ # Eski metin ayristirici FALLBACK olarak durur.
1531
+ warehouse_info = None
1532
+ _struct = intent_analysis.get("stok_sorgusu") if isinstance(intent_analysis, dict) else None
1533
+ if isinstance(_struct, dict) and any(v for v in _struct.values()):
1534
+ if _struct.get("onceki_urun") and not _struct.get("urun"):
1535
+ _prev_p = get_conversation_context(phone_number).get("current_product")
1536
+ if _prev_p:
1537
+ _struct["urun"] = _prev_p
1538
+ try:
1539
+ from warehouse_lookup import lookup_stock_structured
1540
+ warehouse_info = lookup_stock_structured(_struct)
1541
+ if warehouse_info:
1542
+ logger.info(f"🎯 [wh_struct] HIT: {_struct}")
1543
+ else:
1544
+ logger.info(f"[wh_struct] None, metin yoluna dusuluyor: {_struct}")
1545
+ except Exception as e:
1546
+ logger.warning(f"[wh_struct] hata, metin yolu devrede: {e}")
1547
+
1548
+ if not warehouse_info:
1549
+ warehouse_info = get_warehouse_stock(stock_query_product)
1550
  # TAKIP SORUSU DESTEGI (11 Agu): "medium ne var" gibi urunsuz sorgular
1551
  # tek basina eslesmez; sohbet baglamindaki son urunle birlestirip dene.
1552
  # ("small elektrikli bisiklet" -> "medium ne var" => "medium elektrikli bisiklet")
 
1644
  # arama adinda 'karbon' gecen AKSESUARLARI buluyor ve kompakt stok
1645
  # listesi hic gonderilmiyordu -> bot 'stokta karbon bisiklet yok'
1646
  # dedi (11 Agu). Malzeme sorusunda o yolu ATLA, dogrudan listeye git.
1647
+ _is_material_q = bool(isinstance(_struct, dict) and _struct.get("malzeme"))
1648
+ if not _is_material_q:
1649
+ try:
1650
+ from warehouse_lookup import _detect_material
1651
+ _is_material_q = _detect_material(user_message) is not None
1652
+ except Exception:
1653
+ pass
1654
 
1655
  if should_query_stock and stock_query_product and not xml_has_valid_stock:
1656
  realtime_stock = None if _is_material_q else get_realtime_stock_parallel(stock_query_product)
 
2109
  })
2110
 
2111
 
2112
+ @app.get("/debug-intent")
2113
+ async def debug_intent(q: str):
2114
+ """UCTAN UCA yapilandirilmis sorgu testi:
2115
+ soru -> intent analyzer (LLM anlama) -> lookup_stock_structured (kod hesap).
2116
+ 'Elektrik Bisiklet' gibi kural-disi ifadelerin cozuldugunu dogrulamak icin."""
2117
+ import asyncio as _a
2118
+ from intent_analyzer import analyze_customer_intent
2119
+ from warehouse_lookup import lookup_stock_structured
2120
+
2121
+ analysis = await _a.to_thread(analyze_customer_intent, q, None)
2122
+ struct = analysis.get("stok_sorgusu") if isinstance(analysis, dict) else None
2123
+ result = None
2124
+ if isinstance(struct, dict) and any(v for v in struct.values()):
2125
+ result = await _a.to_thread(lookup_stock_structured, struct)
2126
+ return JSONResponse({
2127
+ "soru": q,
2128
+ "1_llm_anlama": struct,
2129
+ "2_kod_hesap": {"hit": result is not None,
2130
+ "hukum": (result[0][:160] if result else None),
2131
+ "satirlar": (result[1:12] if result else None)},
2132
+ })
2133
+
2134
+
2135
  @app.get("/model-compare")
2136
  async def model_compare(q: str, models: str = "gpt-5.6-luna,gpt-5.6-terra"):
2137
  """LUNA vs TERRA — ayni soru, ayni baglam, farkli model.
intent_analyzer.py CHANGED
@@ -88,8 +88,46 @@ JSON FORMATI:
88
  "customer_mood": "positive",
89
  "suggested_action": "notify_store", // notify_store, answer_directly, both, none
90
  "notification_required": true,
91
- "notification_reason": "Müşteri FX 2'yi ayırtmak istiyor"
92
- }"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  # Bağlam varsa ekle
95
  context_info = ""
 
88
  "customer_mood": "positive",
89
  "suggested_action": "notify_store", // notify_store, answer_directly, both, none
90
  "notification_required": true,
91
+ "notification_reason": "Müşteri FX 2'yi ayırtmak istiyor",
92
+ "stok_sorgusu": { ... } // Aşağıya bak — stok/fiyat sorusuysa DOLDUR, değilse null
93
+ }
94
+
95
+ STOK_SORGUSU (EN ÖNEMLİ ALAN — stok/fiyat/model sorularında doldur):
96
+ Müşterinin serbest cümlesini şu YAPIYA çevir. Sistem bu yapıyı deterministik
97
+ çalıştırır; senin görevin Türkçe'yi ANLAYIP normalize etmek:
98
+ {
99
+ "urun": "marlin 4", // SADECE BU MESAJTA geçen model adı. Önceki
100
+ // konuşmadaki ürünü BURAYA YAZMA! Kategori
101
+ // sorusunda ("elektrikli bisikletler") null bırak.
102
+ "onceki_urun": false, // "bunun başka rengi", "medium'u var mı" gibi
103
+ // önceki ürüne atıf varsa true
104
+ "kategori": null, // elektrikli|sehir|dag|yol|gravel|cocuk|giyim|kask|lastik|pompa
105
+ // NORMALIZE ET: "elektrik/şarjlı/akülü/pilli/e-bike" → elektrikli
106
+ // "MTB/dağ/arazi" → dag, "hibrit/city" → sehir
107
+ "beden": null, // XXS|XS|S|M|ML|L|XL|XXL veya 47-62 kadro ölçüsü
108
+ // "small→S, medium→M, large→L, küçük→S, orta→M"
109
+ "renk": null, // "su yeşili", "mat siyah", "kırmızı"...
110
+ "magaza": null, // caddebostan|alsancak|bahcekoy
111
+ // "Kadıköy→caddebostan, İzmir→alsancak, Sarıyer→bahcekoy"
112
+ "fiyat_max": null, // "100 bin altında/bütçem 150 bin" → 100000/150000
113
+ "fiyat_min": null, // "300 bin üstü" → 300000
114
+ "siralama": null, // "en ucuz/uygun/ekonomik" → ucuz, "en pahalı/iyi" → pahali
115
+ "adet_sorusu": false, // "kaç tane/kaç adet var" → true
116
+ "malzeme": null // "karbon" | "aluminyum" (kadro malzemesi sorusuysa)
117
+ }
118
+
119
+ STOK_SORGUSU ÖRNEKLERİ:
120
+ - "Elektrik Bisiklet modellerini soruyorum" → {"kategori": "elektrikli"}
121
+ - "şarjlı bisiklet var mı" → {"kategori": "elektrikli"}
122
+ - "En ucuz elektrikli bisiklet modeli hangisi" → {"kategori": "elektrikli", "siralama": "ucuz"}
123
+ - "su yeşili xs marlin hangi mağazada" → {"urun": "marlin", "beden": "XS", "renk": "su yeşili"}
124
+ - "caddebostanda kaç tane marlin var" → {"urun": "marlin", "magaza": "caddebostan", "adet_sorusu": true}
125
+ - "100 bin altında dağ bisikleti" → {"kategori": "dag", "fiyat_max": 100000}
126
+ - "oğlum için 80 bin bütçeyle mtb arıyoruz" → {"kategori": "dag", "fiyat_max": 80000}
127
+ - "madone kaç para" → {"urun": "madone"}
128
+ - "bunun mavisi var mı" → {"onceki_urun": true, "renk": "mavi"}
129
+ - "karbonda hangi modeller var" → {"malzeme": "karbon"}
130
+ - "merhaba nasılsınız" → stok_sorgusu: null"""
131
 
132
  # Bağlam varsa ekle
133
  context_info = ""
warehouse_lookup.py CHANGED
@@ -578,6 +578,232 @@ def _detect_material(query: str) -> str | None:
578
  return None
579
 
580
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  def _seal(lines: list[str]) -> list[str]:
582
  """Hukum listesini KAPAT: modele 'listede olmayani ekleme' de.
583
 
 
578
  return None
579
 
580
 
581
+ # ---------- YAPILANDIRILMIS SORGU (11 Agu gece mimari degisikligi) ----------
582
+ # ANLAMA LLM'DE, HESAP KODDA. Eski yol: kod, Turkce'yi stopword/regex'le
583
+ # ayristirmaya calisiyordu — her cekim icin kural gerekiyordu ('elektrikli'
584
+ # kurali vardi, 'Elektrik Bisiklet' bir harf farkiyla deldi; kullanici hakli:
585
+ # binlerce kural yazilamaz). Yeni yol: intent analyzer (LLM, zaten her
586
+ # mesajda calisiyor) serbest metni SU YAPIYA cevirir, kod calistirir:
587
+ # {urun, kategori, beden, renk, magaza, fiyat_max, fiyat_min,
588
+ # siralama, adet_sorusu, malzeme, onceki_urun}
589
+ # Boylece 'sarjli bisiklet', 'akulu', 'pilli' icin kural GEREKMEZ — model
590
+ # Turkce'yi zaten biliyor. Sayma/filtreleme/siralama kodda kalir (olcum:
591
+ # Luna ve Terra 19 dedi, kod 25 — dogru).
592
+
593
+ _STRUCT_CATEGORY_MAP = {
594
+ "elektrikli": "ELEKTRİKLİ", "sehir": "ŞEHİR/HİBRİT", "hibrit": "ŞEHİR/HİBRİT",
595
+ "dag": "DAĞ", "yol": "YOL", "gravel": "GRAVEL", "cocuk": "ÇOCUK",
596
+ "giyim": "GİYİM", "lastik": "LASTİK", "kask": "KASK", "pompa": "POMPA",
597
+ }
598
+
599
+
600
+ def _rule_for_label(label: str):
601
+ for lb, _qw, cat_pat, name_pat in _CATEGORY_RULES:
602
+ if lb == label:
603
+ return cat_pat, name_pat
604
+ return None, None
605
+
606
+
607
+ def lookup_stock_structured(p: dict) -> list[str] | None:
608
+ """Intent analyzer'in cikardigi yapilandirilmis sorguyu calistirir.
609
+ Donus: lookup_stock_for_query ile ayni format (hukumlu satir listesi).
610
+ None = bu sorgu stokla cozulemez -> cagiran taraf eski yola/listeye duser."""
611
+ if not isinstance(p, dict):
612
+ return None
613
+
614
+ urun = (p.get("urun") or "").strip()
615
+ kategori_raw = normalize((p.get("kategori") or "").strip())
616
+ beden = (p.get("beden") or "").strip().upper() or None
617
+ renk = normalize((p.get("renk") or "").strip()) or None
618
+ magaza = (p.get("magaza") or "").strip().upper() or None
619
+ if magaza:
620
+ magaza = normalize(magaza).upper().replace("İ", "I")
621
+ magaza = {"CADDEBOSTAN": "CADDEBOSTAN", "ALSANCAK": "ALSANCAK",
622
+ "BAHCEKOY": "BAHCEKOY"}.get(magaza, None)
623
+ try:
624
+ fiyat_max = float(p["fiyat_max"]) if p.get("fiyat_max") else None
625
+ fiyat_min = float(p["fiyat_min"]) if p.get("fiyat_min") else None
626
+ except (TypeError, ValueError):
627
+ fiyat_max = fiyat_min = None
628
+ siralama = (p.get("siralama") or "").strip().lower() or None
629
+ adet_sorusu = bool(p.get("adet_sorusu"))
630
+ malzeme = (p.get("malzeme") or "").strip().lower() or None
631
+
632
+ # Hicbir kriter yoksa yapacak is yok
633
+ if not any([urun, kategori_raw, beden, renk, fiyat_max, fiyat_min,
634
+ siralama, adet_sorusu]):
635
+ return None
636
+
637
+ # Malzeme sorusu -> None: app.py kompakt liste + malzeme talimati gonderir
638
+ # (A/B olcumu: siniflandirmayi LLM listeden koddan IYI yapiyor)
639
+ if malzeme and not any([beden, renk, fiyat_max, adet_sorusu]):
640
+ logger.info("[wh_struct] malzeme sorusu -> kompakt listeye birakildi")
641
+ return None
642
+
643
+ # Rakip marka koruma
644
+ if urun and set(re.findall(r"[a-z0-9]+", normalize(urun))) & _COMPETITOR_TOKENS:
645
+ logger.info(f"[wh_struct] rakip marka: {urun!r}")
646
+ return None
647
+
648
+ idx = get_index()
649
+ sku_map = _get_sku_map()
650
+ if not sku_map or not idx.ensure():
651
+ return None
652
+
653
+ # --- AILE SECIMI ---
654
+ label = _STRUCT_CATEGORY_MAP.get(kategori_raw) if kategori_raw else None
655
+ cat_pat, name_pat = _rule_for_label(label) if label else (None, None)
656
+ urun_tokens = [t for t in re.findall(r"[a-z0-9]+", normalize(urun))
657
+ if t not in ("bisiklet", "bisikleti", "bike", "model", "modeli")] if urun else []
658
+ bike_search = bool(label and label not in ("GİYİM", "LASTİK", "KASK", "POMPA")) or \
659
+ any(t in _BIKE_FAMILY_TOKENS for t in urun_tokens)
660
+
661
+ family: list[dict] = []
662
+ for prod in idx.snapshot():
663
+ if prod["is_variant"]:
664
+ continue
665
+ nn = prod.get("_label_norm", "")
666
+ if not nn:
667
+ continue
668
+ if urun_tokens:
669
+ words = set(re.findall(r"[a-z0-9]+", nn))
670
+ if not all(t in words for t in urun_tokens):
671
+ continue
672
+ if label:
673
+ cn = prod.get("_category_norm", "")
674
+ if cn:
675
+ if cat_pat and not cat_pat.search(cn):
676
+ continue
677
+ elif name_pat and not name_pat.search(nn):
678
+ continue
679
+ if bike_search and any(t in nn for t in _ACCESSORY_TOKENS):
680
+ continue
681
+ family.append(prod)
682
+
683
+ # Kriter var ama urun/kategori yok (sadece renk/beden/fiyat/siralama):
684
+ # tum bisiklet havuzunda ara
685
+ if not family and not urun_tokens and not label:
686
+ family = _all_bikes()
687
+
688
+ if not family:
689
+ if label or urun_tokens:
690
+ hedef = urun or (label or "")
691
+ return _seal([f"[STOK HÜKMÜ — KESİN VERİ] '{hedef}' için stokta ürün YOK ❌. "
692
+ f"Model/fiyat UYDURMA, müşteriyi mağazaya yönlendir."])
693
+ return None
694
+
695
+ cat_suffix = f" ({label})" if label else ""
696
+
697
+ # --- ADET SAYMA ---
698
+ if adet_sorusu:
699
+ toplam = 0
700
+ dagilim: dict[str, int] = {}
701
+ satirlar: list[str] = []
702
+ for m in family:
703
+ link = m.get("link") or ""
704
+ for v in (idx.variants_of(link) if link else [m]):
705
+ sku = (v.get("sku") or "").strip()
706
+ if not sku:
707
+ continue
708
+ for wh, qty in sku_map.get(sku, []):
709
+ if magaza and wh.upper() != magaza:
710
+ continue
711
+ if beden and not _variant_matches_size(v, beden):
712
+ continue
713
+ if renk and renk not in normalize(_format_variant_label(v)):
714
+ continue
715
+ toplam += qty
716
+ dagilim[wh] = dagilim.get(wh, 0) + qty
717
+ satirlar.append(f"• {m['name']} {_format_variant_label(v)} — {wh}: {qty} adet")
718
+ if toplam:
719
+ nerede = f"{magaza} mağazasında" if magaza else "tüm mağazalarda"
720
+ dag = ", ".join(f"{k}: {v}" for k, v in sorted(dagilim.items(), key=lambda x: -x[1]))
721
+ out = [f"[ADET HÜKMÜ — KESİN VERİ] {nerede} TOPLAM {toplam} ADET var. "
722
+ f"Dağılım: {dag}. Bu sayı sistem hesabı — KENDİN SAYMA:"]
723
+ out.extend(satirlar[:30])
724
+ return _seal(out)
725
+ return _seal([f"[ADET HÜKMÜ — KESİN VERİ] {'bu kriterlerle' if not magaza else magaza + ' mağazasında'} "
726
+ f"stok YOK (0 adet). Sayı UYDURMA."])
727
+
728
+ # --- FIYAT FILTRESI ---
729
+ if fiyat_min is not None or fiyat_max is not None:
730
+ family = [m for m in family if m.get("_category_norm") or not label]
731
+ family = [m for m in family if _price_in_range(m.get("price"), fiyat_min, fiyat_max)]
732
+ family.sort(key=lambda m: m.get("price") or 0)
733
+
734
+ # --- SIRALAMA (en ucuz / en pahali) ---
735
+ # STOKLU olanlar arasindan sec: fiyat sirali katalog listesinin basi
736
+ # stoksuz eski modellerle dolu olabiliyor (ilk denemede 'en ucuz
737
+ # elektrikli' bos donmustu — en ucuz 5 kayit depoda yoktu).
738
+ if siralama in ("ucuz", "pahali", "cheap", "expensive"):
739
+ pool = [m for m in family if m.get("price")]
740
+ if label:
741
+ cat_ok = [m for m in pool if m.get("_category_norm")]
742
+ if cat_ok:
743
+ pool = cat_ok # kategori dogrulanmis olanlar (aksesuar sizmasin)
744
+ ucuz = siralama in ("ucuz", "cheap")
745
+ pool.sort(key=lambda m: m["price"], reverse=not ucuz)
746
+ picked: list[list[str]] = []
747
+ for m in pool:
748
+ lines, found, _ = _stock_lines_for_main(
749
+ m, sku_map, idx, size=beden, name_suffix=cat_suffix, color=renk)
750
+ if found:
751
+ picked.append(lines)
752
+ if len(picked) >= 3:
753
+ break
754
+ if picked:
755
+ out = [f"[STOK HÜKMÜ — KESİN VERİ] Stoktaki EN "
756
+ f"{'UYGUN FİYATLI' if ucuz else 'ÜST SEGMENT'} {kind if (kind := (label + ' ') if label else '') else ''}"
757
+ f"modeller ({'ucuzdan' if ucuz else 'pahalıdan'} sıralı). "
758
+ f"Fiyat hesabı YAPMA, sırayı DEĞİŞTİRME:"]
759
+ for s in picked:
760
+ out.extend(s)
761
+ return _seal(out)
762
+ return _seal([f"[STOK HÜKMÜ — KESİN VERİ] Bu kriterlerle stokta fiyatlı ürün "
763
+ f"bulunamadı ❌. Fiyat/model UYDURMA, mağazaya yönlendir."])
764
+
765
+ # --- SONUC LISTESI ---
766
+ sections: list[list[str]] = []
767
+ all_sizes: set[str] = set()
768
+ for m in family:
769
+ lines, found, sizes = _stock_lines_for_main(
770
+ m, sku_map, idx, size=beden, name_suffix=cat_suffix, color=renk)
771
+ all_sizes |= sizes
772
+ if found:
773
+ sections.append(lines)
774
+ if len(sections) >= 8:
775
+ break
776
+
777
+ kind = f"{label} " if label else ""
778
+ if sections:
779
+ parts = []
780
+ if beden:
781
+ parts.append(f"{beden} beden")
782
+ if renk:
783
+ parts.append(f"'{renk.upper()}' renk")
784
+ if fiyat_max:
785
+ parts.append(f"{int(fiyat_max):,} TL altı".replace(",", "."))
786
+ kriter = " + ".join(parts) if parts else "aranan kriterlerde"
787
+ out = [f"[STOK HÜKMÜ — KESİN VERİ] {kriter} {kind}ürün STOKTA VAR ✅. "
788
+ f"Aşağıdakileri eksiksiz aktar, 'stok yok' YAZMA:"]
789
+ for s in sections:
790
+ out.extend(s)
791
+ return _seal(out)
792
+
793
+ # Aile var ama filtre bos birakti -> kesin YOK + alternatifler
794
+ if beden or renk or fiyat_max:
795
+ sizes_str = ", ".join(sorted(all_sizes)) if all_sizes else "-"
796
+ out = [f"[STOK HÜKMÜ — KESİN VERİ] Bu kriterlerle STOKTA YOK ❌. "
797
+ f"Stokta olan bedenler: {sizes_str}. Alternatif sun, UYDURMA:"]
798
+ for m in family[:5]:
799
+ lines, found, _ = _stock_lines_for_main(m, sku_map, idx, name_suffix=cat_suffix)
800
+ if found:
801
+ out.extend(lines)
802
+ return _seal(out)
803
+
804
+ return None
805
+
806
+
807
  def _seal(lines: list[str]) -> list[str]:
808
  """Hukum listesini KAPAT: modele 'listede olmayani ekleme' de.
809