Spaces:
Running
Running
SamiKoen commited on
Commit ·
0b52cfc
1
Parent(s): 2fb7273
Mirror V2 content into V1 (snapshot for continued development)
Browse files- .gitattributes +0 -35
- README.md +32 -12
- app.py +102 -769
- config.py +42 -0
- conversation_tracker.py +0 -68
- enhanced_features.py +0 -563
- get_warehouse_fast.py +0 -130
- image_renderer.py +0 -143
- product_index.py +244 -0
- product_matcher.py +257 -0
- prompts.py +60 -0
- realtime_relay.py +330 -0
- requirements.txt +5 -6
- smart_warehouse.py +0 -261
- smart_warehouse_with_price.py +57 -244
- stock_service.py +254 -0
- tools.py +73 -0
- warehouse_stock_finder.py +0 -89
.gitattributes
CHANGED
|
@@ -1,38 +1,3 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,24 +1,44 @@
|
|
| 1 |
---
|
| 2 |
-
title: BF Realtime
|
| 3 |
emoji: 🎙️
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
# BF-Realtime
|
| 12 |
|
| 13 |
-
Trek Bisiklet sesli satis asistani
|
| 14 |
|
| 15 |
-
##
|
| 16 |
|
| 17 |
-
|
| 18 |
-
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
-
##
|
| 23 |
|
| 24 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: BF Realtime V2
|
| 3 |
emoji: 🎙️
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# BF-Realtime V2
|
| 12 |
|
| 13 |
+
Trek Bisiklet sesli satis asistani. OpenAI gpt-realtime-2 + Trek katalog index + BizimHesap stok servisi.
|
| 14 |
|
| 15 |
+
## Modul yapisi
|
| 16 |
|
| 17 |
+
| Dosya | Sorumluluk |
|
| 18 |
+
|-------|-----------|
|
| 19 |
+
| `app.py` | FastAPI routing + startup tasks |
|
| 20 |
+
| `realtime_relay.py` | OpenAI Realtime WebSocket relay + event handling |
|
| 21 |
+
| `product_index.py` | Trek katalog XML parse + thread-safe hash index |
|
| 22 |
+
| `product_matcher.py` | Local fuzzy match + gpt-5-nano fallback |
|
| 23 |
+
| `stock_service.py` | BizimHesap + Trek PHP stok cache (lock'lu) |
|
| 24 |
+
| `tools.py` | `get_warehouse_stock` tool implementasyonu |
|
| 25 |
+
| `prompts.py` | Sistem prompt iceriği |
|
| 26 |
+
| `config.py` | Tum sabitler (URL, timeout, TTL) |
|
| 27 |
|
| 28 |
+
## Ekran
|
| 29 |
|
| 30 |
+
`static/index.html` — Cift monitor UI (sol: avatar, sag: urun vitrini galeri).
|
| 31 |
+
|
| 32 |
+
## Endpoint'ler
|
| 33 |
+
|
| 34 |
+
- `GET /` — Ana sayfa
|
| 35 |
+
- `GET /health` — Saglik kontrolu
|
| 36 |
+
- `WS /ws` — Realtime WebSocket
|
| 37 |
+
- `GET /warehouse-xml` — Diger client'lar icin stok XML proxy
|
| 38 |
+
- `GET /bh/{products,warehouses,inventory/{wid}}` — BizimHesap raw cache
|
| 39 |
+
- `GET /debug-find?q=...` — Urun matcher debug
|
| 40 |
+
- `GET /debug-search?q=...` — Tool debug
|
| 41 |
+
|
| 42 |
+
## Env
|
| 43 |
+
|
| 44 |
+
`OPENAI_API_KEY` — Realtime + nano fallback icin.
|
app.py
CHANGED
|
@@ -1,50 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
"""
|
| 5 |
-
import os
|
| 6 |
-
import json
|
| 7 |
import asyncio
|
| 8 |
import logging
|
| 9 |
-
|
| 10 |
-
from fastapi
|
|
|
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
-
import websockets
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
logging.basicConfig(level=logging.INFO)
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
-
|
| 22 |
-
REALTIME_MODEL = "gpt-realtime-2"
|
| 23 |
-
REALTIME_URL = f"wss://api.openai.com/v1/realtime?model={REALTIME_MODEL}"
|
| 24 |
-
|
| 25 |
-
app = FastAPI(title="BF-Realtime")
|
| 26 |
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 27 |
|
| 28 |
|
| 29 |
@app.on_event("startup")
|
| 30 |
-
async def
|
| 31 |
-
"""
|
| 32 |
-
async def
|
| 33 |
while True:
|
| 34 |
try:
|
| 35 |
-
await asyncio.to_thread(_ensure_index)
|
| 36 |
-
except Exception:
|
| 37 |
-
logger.exception('trek index refresh hatasi')
|
| 38 |
-
try:
|
| 39 |
-
# Stok XML'ini de proaktif olarak warm-load et — Cloudflare 1015 ban riskine karsi
|
| 40 |
-
from smart_warehouse_with_price import get_cached_warehouse_xml
|
| 41 |
await asyncio.to_thread(get_cached_warehouse_xml)
|
| 42 |
except Exception:
|
| 43 |
-
logger.exception(
|
| 44 |
-
await asyncio.sleep(
|
| 45 |
-
asyncio.create_task(loop())
|
| 46 |
|
|
|
|
|
|
|
| 47 |
|
|
|
|
|
|
|
| 48 |
@app.get("/")
|
| 49 |
async def root():
|
| 50 |
return FileResponse("static/index.html")
|
|
@@ -52,45 +60,28 @@ async def root():
|
|
| 52 |
|
| 53 |
@app.get("/health")
|
| 54 |
async def health():
|
|
|
|
| 55 |
return {
|
| 56 |
"status": "ok",
|
| 57 |
"model": REALTIME_MODEL,
|
| 58 |
"has_api_key": bool(OPENAI_API_KEY),
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
from collections import Counter
|
| 67 |
-
from smart_warehouse_with_price import get_cached_warehouse_xml
|
| 68 |
-
try:
|
| 69 |
-
xml = get_cached_warehouse_xml()
|
| 70 |
-
except Exception as e:
|
| 71 |
-
return {"error": str(e)}
|
| 72 |
-
if not xml:
|
| 73 |
-
return {"xml": None, "warehouses": {}, "size": 0}
|
| 74 |
-
whs = re.findall(r"<!\[CDATA\[([^\]]+)\]\]></Name>", xml)
|
| 75 |
-
counts = dict(Counter(whs))
|
| 76 |
-
head = xml[:300] if len(xml) > 300 else xml
|
| 77 |
-
return {"size": len(xml), "warehouses": counts, "head": head}
|
| 78 |
|
| 79 |
|
|
|
|
| 80 |
@app.get("/warehouse-xml")
|
| 81 |
async def warehouse_xml():
|
| 82 |
-
|
| 83 |
-
HF Space cache'inden serve edilir (1-2 saat tazelikli)."""
|
| 84 |
-
from fastapi.responses import Response
|
| 85 |
-
from smart_warehouse_with_price import get_cached_warehouse_xml
|
| 86 |
-
try:
|
| 87 |
-
xml = get_cached_warehouse_xml()
|
| 88 |
-
except Exception as e:
|
| 89 |
-
logger.exception("warehouse-xml error")
|
| 90 |
-
xml = None
|
| 91 |
if not xml:
|
| 92 |
return Response(
|
| 93 |
-
content='<?xml version="1.0" encoding="UTF-8"?>\n<
|
| 94 |
media_type="application/xml",
|
| 95 |
headers={"Cache-Control": "no-cache"},
|
| 96 |
)
|
|
@@ -99,747 +90,89 @@ async def warehouse_xml():
|
|
| 99 |
media_type="application/xml",
|
| 100 |
headers={
|
| 101 |
"Access-Control-Allow-Origin": "*",
|
| 102 |
-
"Cache-Control": "public, max-age=300",
|
| 103 |
},
|
| 104 |
)
|
| 105 |
|
| 106 |
|
| 107 |
-
#
|
| 108 |
-
# BizimHesap raw JSON aynalari (tavsiye + diger client'lar icin)
|
| 109 |
-
# Cloudflare 1015 ban'a karsi HF Space IP havuzundan cache'li servis
|
| 110 |
-
# ============================================
|
| 111 |
-
import time as _time
|
| 112 |
-
_bh_cache = {
|
| 113 |
-
"products": {"data": None, "time": 0, "ttl": 7200}, # 2 saat
|
| 114 |
-
"warehouses": {"data": None, "time": 0, "ttl": 21600}, # 6 saat
|
| 115 |
-
"inventory": {}, # {wid: {data, time}}
|
| 116 |
-
}
|
| 117 |
-
_BH_INVENTORY_TTL = 1800 # 30 dakika
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def _bh_cached(kind: str, fetcher):
|
| 121 |
-
"""products/warehouses icin generic cache helper."""
|
| 122 |
-
entry = _bh_cache[kind]
|
| 123 |
-
now = _time.time()
|
| 124 |
-
if entry["data"] is not None and (now - entry["time"] < entry["ttl"]):
|
| 125 |
-
return entry["data"], "hit"
|
| 126 |
-
data = fetcher()
|
| 127 |
-
if data is not None:
|
| 128 |
-
entry["data"] = data
|
| 129 |
-
entry["time"] = now
|
| 130 |
-
return data, "miss"
|
| 131 |
-
if entry["data"] is not None:
|
| 132 |
-
return entry["data"], "stale"
|
| 133 |
-
return None, "fail"
|
| 134 |
-
|
| 135 |
-
|
| 136 |
@app.get("/bh/products")
|
| 137 |
async def bh_products():
|
| 138 |
-
from
|
| 139 |
-
from
|
| 140 |
-
data, status =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
if data is None:
|
| 142 |
-
return JSONResponse(
|
|
|
|
|
|
|
|
|
|
| 143 |
return JSONResponse(
|
| 144 |
data,
|
| 145 |
-
headers={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
)
|
| 147 |
|
| 148 |
|
| 149 |
@app.get("/bh/warehouses")
|
| 150 |
async def bh_warehouses():
|
| 151 |
-
from
|
| 152 |
-
from
|
| 153 |
-
data, status =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
if data is None:
|
| 155 |
-
return JSONResponse(
|
|
|
|
|
|
|
|
|
|
| 156 |
return JSONResponse(
|
| 157 |
data,
|
| 158 |
-
headers={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
)
|
| 160 |
|
| 161 |
|
| 162 |
@app.get("/bh/inventory/{wid}")
|
| 163 |
async def bh_inventory(wid: str):
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
now = _time.time()
|
| 167 |
-
entry = _bh_cache["inventory"].get(wid)
|
| 168 |
-
if entry and (now - entry["time"] < _BH_INVENTORY_TTL):
|
| 169 |
-
return JSONResponse(
|
| 170 |
-
entry["data"],
|
| 171 |
-
headers={"Access-Control-Allow-Origin": "*", "X-Cache": "hit", "Cache-Control": "public, max-age=300"},
|
| 172 |
-
)
|
| 173 |
-
data = _bh_get(f"{BIZIMHESAP_BASE}/inventory/{wid}")
|
| 174 |
-
if data is not None:
|
| 175 |
-
_bh_cache["inventory"][wid] = {"data": data, "time": now}
|
| 176 |
-
return JSONResponse(
|
| 177 |
-
data,
|
| 178 |
-
headers={"Access-Control-Allow-Origin": "*", "X-Cache": "miss", "Cache-Control": "public, max-age=300"},
|
| 179 |
-
)
|
| 180 |
-
if entry:
|
| 181 |
return JSONResponse(
|
| 182 |
-
|
| 183 |
-
|
| 184 |
)
|
| 185 |
-
return JSONResponse(
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
from smart_warehouse_with_price import get_warehouse_stock_smart_with_price
|
| 192 |
-
try:
|
| 193 |
-
result = get_warehouse_stock_smart_with_price(q)
|
| 194 |
-
return {"query": q, "result": result, "type": str(type(result).__name__)}
|
| 195 |
-
except Exception as e:
|
| 196 |
-
import traceback
|
| 197 |
-
return {"query": q, "error": str(e), "traceback": traceback.format_exc()}
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def apply_pronunciation_fixes(text: str) -> str:
|
| 201 |
-
"""Telaffuz duzeltmeleri — model bazi yer isimlerini Ingilizce gibi okumasin diye
|
| 202 |
-
metni modele iletmeden once kucuk yazim degisiklikleri uygulanir."""
|
| 203 |
-
if not isinstance(text, str):
|
| 204 |
-
return text
|
| 205 |
-
return text.replace("Caddebostan", "Cadde Bostan")
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
def build_session_instructions() -> str:
|
| 209 |
-
"""prompts.py'daki sistem promptlarini Realtime icin tek metne cevir."""
|
| 210 |
-
try:
|
| 211 |
-
base = get_active_prompt_content_only()
|
| 212 |
-
if isinstance(base, list):
|
| 213 |
-
base = "\n\n".join(str(p) for p in base)
|
| 214 |
-
except Exception as e:
|
| 215 |
-
logger.error(f"Prompt yuklenemedi: {e}")
|
| 216 |
-
base = "Trek Bisiklet uzmani bir satis temsilcisisin."
|
| 217 |
-
|
| 218 |
-
voice_addon = (
|
| 219 |
-
"\n\nGORSEL DESTEK (onemli):\n"
|
| 220 |
-
"- Musterinin ekraninin sag tarafinda urun gorseli OTOMATIK olarak gosteriliyor.\n"
|
| 221 |
-
"- Bahsettigin urunun resmi sag tarafta belirir; renk veya boy sorulursa o varyantin gorseline gecer.\n"
|
| 222 |
-
"- 'Gorsel paylasiyorum', 'resmini atiyorum', 'linki gonderiyorum' gibi cumleler kurma.\n"
|
| 223 |
-
"- 'Sagda gorebilirsiniz' gibi kisa referanslar verebilirsin.\n"
|
| 224 |
-
"\nSESLI SOHBET KURALLARI (cok onemli):\n"
|
| 225 |
-
"- COK KISA cevap ver. Hedef: 1 cumle, EN FAZLA 2 cumle. Token tasarrufu kritik.\n"
|
| 226 |
-
"- Cumleler kisa ve net olsun. Tekrar etme, dolaylama yapma, nezaket bezeyici cumle EKLEME.\n"
|
| 227 |
-
"- Sadece SORULANA dogrudan cevap ver. Ekstra oneri, yonlendirme, tesvik EKLEME.\n"
|
| 228 |
-
"- 'Yardimci olabilir miyim', 'magazada inceleyebilirsiniz', 'baska sorunuz var mi'\n"
|
| 229 |
-
" gibi kapanis/dolgu cumleleri YASAK.\n"
|
| 230 |
-
"- Markdown, * veya emoji KULLANMA.\n"
|
| 231 |
-
"- URL/link/web adresi ASLA SOYLEME. 'www', 'trekbisiklet.com', 'https' gibi adresleri hicbir sekilde sesli okuma. "
|
| 232 |
-
"'Linki paylasiyorum', 'sitede goreceksiniz' gibi link referanslari da YASAK.\n"
|
| 233 |
-
"- HER ZAMAN 'siz' ile hitap et, soru ile bitirme.\n"
|
| 234 |
-
"- Stok/fiyat sorulari geldiginde get_warehouse_stock fonksiyonunu cagir, sonucu OZUN tek cumlede ver.\n"
|
| 235 |
-
"\n"
|
| 236 |
-
"STOK ADEDI KURALI (kritik):\n"
|
| 237 |
-
"- Stok ADEDINI / SAYISINI ASLA SOYLEME.\n"
|
| 238 |
-
"- Sadece 'mevcut', 'stokta var', 'bulunmuyor' veya 'tukenmis' gibi durum bildir.\n"
|
| 239 |
-
"- Adet sorulsa bile 'detayli adet bilgisi icin magazayla teyit' deyip gec, ek aciklama yapma.\n"
|
| 240 |
-
"\n"
|
| 241 |
-
"STOK SORGUSU AKISI:\n"
|
| 242 |
-
"- SADECE oturumdaki ILK stok sorgusunda kisa bir bekleme cumlesi soyle ('Bir saniye, bakiyorum.' gibi). "
|
| 243 |
-
"Cunku ilk sorgu biraz daha uzun surebilir.\n"
|
| 244 |
-
"- Sonraki TUM stok sorgularinda bekleme cumlesi YASAK — direkt fonksiyonu cagir, sonuc gelince TEK CUMLE cevap ver. "
|
| 245 |
-
"Sonraki sorgularda 'bakiyorum', 'bir saniye', 'kontrol ediyorum' KESINLIKLE SOYLEME.\n"
|
| 246 |
-
)
|
| 247 |
-
return apply_pronunciation_fixes(base + voice_addon)
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
# OpenAI Realtime tool tanimi
|
| 251 |
-
TOOLS = [
|
| 252 |
-
{
|
| 253 |
-
"type": "function",
|
| 254 |
-
"name": "get_warehouse_stock",
|
| 255 |
-
"description": (
|
| 256 |
-
"Trek bisiklet, aksesuar veya yedek parca icin magaza stok durumu, "
|
| 257 |
-
"fiyat ve urun linkini getirir. Musteri stok, fiyat veya urun "
|
| 258 |
-
"varligini sordugunda kullan."
|
| 259 |
-
),
|
| 260 |
-
"parameters": {
|
| 261 |
-
"type": "object",
|
| 262 |
-
"properties": {
|
| 263 |
-
"user_message": {
|
| 264 |
-
"type": "string",
|
| 265 |
-
"description": "Musterinin urun/stok sorusu (orn. 'Madone SLR 9 var mi', 'Marlin 5 fiyat')",
|
| 266 |
-
}
|
| 267 |
-
},
|
| 268 |
-
"required": ["user_message"],
|
| 269 |
},
|
| 270 |
-
|
| 271 |
-
]
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
async def handle_tool_call(name: str, arguments: dict) -> str:
|
| 275 |
-
"""Tool call'larini local fonksiyonlara yonlendir."""
|
| 276 |
-
try:
|
| 277 |
-
if name == "get_warehouse_stock":
|
| 278 |
-
msg = arguments.get("user_message", "")
|
| 279 |
-
logger.info(f"[tool] get_warehouse_stock query: {msg!r}")
|
| 280 |
-
result = get_warehouse_stock_smart_with_price(msg)
|
| 281 |
-
if result is None:
|
| 282 |
-
logger.info(f"[tool] result: None (stok bulunamadi)")
|
| 283 |
-
return "Stok bilgisi bulunamadi."
|
| 284 |
-
logger.info(f"[tool] result lines: {len(result) if isinstance(result, list) else 'str'}")
|
| 285 |
-
return apply_pronunciation_fixes(str(result))
|
| 286 |
-
return f"Bilinmeyen fonksiyon: {name}"
|
| 287 |
-
except Exception as e:
|
| 288 |
-
logger.exception(f"Tool call hatasi ({name})")
|
| 289 |
-
return f"Hata: {e}"
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
_TR_MAP = {'İ': 'i', 'I': 'i', 'ı': 'i', 'Ğ': 'g', 'ğ': 'g',
|
| 293 |
-
'Ü': 'u', 'ü': 'u', 'Ş': 's', 'ş': 's',
|
| 294 |
-
'Ö': 'o', 'ö': 'o', 'Ç': 'c', 'ç': 'c'}
|
| 295 |
-
|
| 296 |
-
def _norm(s: str) -> str:
|
| 297 |
-
if not s:
|
| 298 |
-
return ''
|
| 299 |
-
for tr, en in _TR_MAP.items():
|
| 300 |
-
s = s.replace(tr, en)
|
| 301 |
-
return s.lower()
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
# Parse-once index — XML her degistiginde yeniden build edilir.
|
| 305 |
-
# Realtime aramalarda 7MB regex re-parse yapilmaz, hash lookup'lar kullanilir.
|
| 306 |
-
_INDEX = {
|
| 307 |
-
"xml_id": None, # ham XML'in id'si — degisirse yeniden parse
|
| 308 |
-
"products": [], # list of parsed product dicts
|
| 309 |
-
"by_link": {}, # productLink -> product
|
| 310 |
-
"by_sku": {}, # stockCode -> product
|
| 311 |
-
"main_skus": set(), # ana urunlerin stockCode set'i
|
| 312 |
-
"variants_by_root": {}, # root_sku -> [variant products]
|
| 313 |
-
"main_links": [], # sadece ana urunlerin linkleri (hizli iterasyon)
|
| 314 |
-
}
|
| 315 |
-
|
| 316 |
-
_VARIANT_LABEL_PARTS_RE = None
|
| 317 |
-
_ITEM_RE = None
|
| 318 |
-
_FIELD_RES = None
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
def _compile_regexes():
|
| 322 |
-
"""Modul ilk yuklemede regex'leri compile et."""
|
| 323 |
-
global _VARIANT_LABEL_PARTS_RE, _ITEM_RE, _FIELD_RES
|
| 324 |
-
if _ITEM_RE is not None:
|
| 325 |
-
return
|
| 326 |
-
import re
|
| 327 |
-
_ITEM_RE = re.compile(r'<item>(.*?)</item>', re.DOTALL)
|
| 328 |
-
_VARIANT_LABEL_PARTS_RE = re.compile(r'\s*[-/]\s*')
|
| 329 |
-
_FIELD_RES = {
|
| 330 |
-
'rootlabel': re.compile(r'<rootlabel><!\[CDATA\[(.*?)\]\]></rootlabel>'),
|
| 331 |
-
'label': re.compile(r'<label><!\[CDATA\[(.*?)\]\]></label>'),
|
| 332 |
-
'productLink': re.compile(r'<productLink><!\[CDATA\[(.*?)\]\]></productLink>'),
|
| 333 |
-
'stockCode': re.compile(r'<stockCode><!\[CDATA\[(.*?)\]\]></stockCode>'),
|
| 334 |
-
'isOptionOfAProduct': re.compile(r'<isOptionOfAProduct>(\d+)</isOptionOfAProduct>'),
|
| 335 |
-
'rootProductStockCode': re.compile(r'<rootProductStockCode><!\[CDATA\[(.*?)\]\]></rootProductStockCode>'),
|
| 336 |
-
}
|
| 337 |
-
# picture1Path - picture8Path
|
| 338 |
-
for i in range(1, 9):
|
| 339 |
-
_FIELD_RES[f'picture{i}'] = re.compile(
|
| 340 |
-
rf'<picture{i}Path><!\[CDATA\[(.*?)\]\]></picture{i}Path>'
|
| 341 |
-
)
|
| 342 |
-
|
| 343 |
-
_compile_regexes()
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
def _parse_item(it: str) -> dict:
|
| 347 |
-
"""Tek bir XML item'ini dict'e cevir + arama icin token cache'i hazirla."""
|
| 348 |
-
import re as _re
|
| 349 |
-
fr = _FIELD_RES
|
| 350 |
-
|
| 351 |
-
def grab(name):
|
| 352 |
-
m = fr[name].search(it)
|
| 353 |
-
return m.group(1).strip() if m else ''
|
| 354 |
-
|
| 355 |
-
rootlabel = grab('rootlabel')
|
| 356 |
-
var_label = grab('label')
|
| 357 |
-
link = grab('productLink')
|
| 358 |
-
sku = grab('stockCode')
|
| 359 |
-
iv_m = fr['isOptionOfAProduct'].search(it)
|
| 360 |
-
is_variant = bool(iv_m and iv_m.group(1) == '1')
|
| 361 |
-
root_sku = grab('rootProductStockCode')
|
| 362 |
-
|
| 363 |
-
images = []
|
| 364 |
-
for i in range(1, 9):
|
| 365 |
-
m = fr[f'picture{i}'].search(it)
|
| 366 |
-
if m and m.group(1).strip():
|
| 367 |
-
images.append(m.group(1).strip())
|
| 368 |
-
|
| 369 |
-
color = None
|
| 370 |
-
size = None
|
| 371 |
-
if is_variant and var_label:
|
| 372 |
-
parts = [p.strip() for p in _VARIANT_LABEL_PARTS_RE.split(var_label) if p.strip()]
|
| 373 |
-
size_pat = _re.compile(
|
| 374 |
-
r'^(?:XX?S|XS|S|M|L|XL|XXL|XXXL|\d{2}(?:\.\d)?(?:\s*CM)?)$', _re.I
|
| 375 |
-
)
|
| 376 |
-
for p in parts:
|
| 377 |
-
if size_pat.match(p) and not size:
|
| 378 |
-
size = p.upper()
|
| 379 |
-
elif not color:
|
| 380 |
-
color = p.upper()
|
| 381 |
-
|
| 382 |
-
# Arama icin onceden hazirlanmis token'lar
|
| 383 |
-
label_norm = _norm(rootlabel)
|
| 384 |
-
tokens = [t for t in _re.findall(r'[a-z0-9+]+', label_norm) if len(t) >= 2]
|
| 385 |
-
|
| 386 |
-
return {
|
| 387 |
-
'name': rootlabel,
|
| 388 |
-
'image': images[0] if images else None,
|
| 389 |
-
'images': images,
|
| 390 |
-
'link': link,
|
| 391 |
-
'sku': sku,
|
| 392 |
-
'color': color,
|
| 393 |
-
'size': size,
|
| 394 |
-
'is_variant': is_variant,
|
| 395 |
-
'root_sku': root_sku if root_sku and root_sku != '0' else None,
|
| 396 |
-
'_tokens': tokens,
|
| 397 |
-
'_label_norm': label_norm,
|
| 398 |
-
}
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
def _build_index(xml_text: str) -> dict:
|
| 402 |
-
"""7MB XML'i bir kez parse et, lookup index'leri hazirla."""
|
| 403 |
-
products = []
|
| 404 |
-
by_link = {}
|
| 405 |
-
by_sku = {}
|
| 406 |
-
main_skus = set()
|
| 407 |
-
variants_by_root = {}
|
| 408 |
-
main_links = []
|
| 409 |
-
|
| 410 |
-
items = _ITEM_RE.findall(xml_text)
|
| 411 |
-
for it in items:
|
| 412 |
-
p = _parse_item(it)
|
| 413 |
-
products.append(p)
|
| 414 |
-
if p['link']:
|
| 415 |
-
by_link[p['link']] = p
|
| 416 |
-
if p['sku']:
|
| 417 |
-
by_sku[p['sku']] = p
|
| 418 |
-
if not p['is_variant']:
|
| 419 |
-
if p['sku']:
|
| 420 |
-
main_skus.add(p['sku'])
|
| 421 |
-
if p['link']:
|
| 422 |
-
main_links.append(p['link'])
|
| 423 |
-
else:
|
| 424 |
-
if p['root_sku']:
|
| 425 |
-
variants_by_root.setdefault(p['root_sku'], []).append(p)
|
| 426 |
-
|
| 427 |
-
logger.info(f"[index] parsed {len(products)} items, {len(main_skus)} main, "
|
| 428 |
-
f"{sum(len(v) for v in variants_by_root.values())} variants")
|
| 429 |
-
return {
|
| 430 |
-
"products": products,
|
| 431 |
-
"by_link": by_link,
|
| 432 |
-
"by_sku": by_sku,
|
| 433 |
-
"main_skus": main_skus,
|
| 434 |
-
"variants_by_root": variants_by_root,
|
| 435 |
-
"main_links": main_links,
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
def _ensure_index() -> dict | None:
|
| 440 |
-
"""Cache'deki XML icin (id() degisirse) index'i guncelle."""
|
| 441 |
-
try:
|
| 442 |
-
from smart_warehouse_with_price import get_cached_trek_xml
|
| 443 |
-
xml = get_cached_trek_xml()
|
| 444 |
-
if not xml:
|
| 445 |
-
return None
|
| 446 |
-
xml_id = id(xml)
|
| 447 |
-
if _INDEX["xml_id"] != xml_id:
|
| 448 |
-
text = xml.decode('utf-8', errors='replace') if isinstance(xml, bytes) else str(xml)
|
| 449 |
-
built = _build_index(text)
|
| 450 |
-
_INDEX["xml_id"] = xml_id
|
| 451 |
-
_INDEX["products"] = built["products"]
|
| 452 |
-
_INDEX["by_link"] = built["by_link"]
|
| 453 |
-
_INDEX["by_sku"] = built["by_sku"]
|
| 454 |
-
_INDEX["main_skus"] = built["main_skus"]
|
| 455 |
-
_INDEX["variants_by_root"] = built["variants_by_root"]
|
| 456 |
-
_INDEX["main_links"] = built["main_links"]
|
| 457 |
-
return _INDEX
|
| 458 |
-
except Exception:
|
| 459 |
-
logger.exception('_ensure_index hatasi')
|
| 460 |
-
return None
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
def _public_view(p: dict | None) -> dict | None:
|
| 464 |
-
"""Index'teki internal field'lari (_tokens, _label_norm) cikar — client'a gonderilebilir."""
|
| 465 |
-
if not p:
|
| 466 |
-
return None
|
| 467 |
-
return {k: v for k, v in p.items() if not k.startswith('_')}
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
def find_product_by_link(product_link: str) -> dict | None:
|
| 471 |
-
"""O(1) lookup. Varyant ise ana urune cik."""
|
| 472 |
-
idx = _ensure_index()
|
| 473 |
-
if not idx:
|
| 474 |
-
return None
|
| 475 |
-
p = idx["by_link"].get(product_link.strip())
|
| 476 |
-
if not p:
|
| 477 |
-
return None
|
| 478 |
-
if p['is_variant'] and p['root_sku']:
|
| 479 |
-
main = idx["by_sku"].get(p['root_sku'])
|
| 480 |
-
if main and not main['is_variant']:
|
| 481 |
-
return _public_view(main)
|
| 482 |
-
return _public_view(p)
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
def find_variants_of_product(main_link: str) -> list[dict]:
|
| 486 |
-
"""Bir ana urunun tum varyantlari (hash lookup)."""
|
| 487 |
-
idx = _ensure_index()
|
| 488 |
-
if not idx:
|
| 489 |
-
return []
|
| 490 |
-
main = idx["by_link"].get(main_link.strip())
|
| 491 |
-
if not main or main['is_variant'] or not main['sku']:
|
| 492 |
-
return []
|
| 493 |
-
return [_public_view(v) for v in idx["variants_by_root"].get(main['sku'], [])]
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
def extract_product_link_from_result(result: str) -> str | None:
|
| 497 |
-
"""Tool sonucu string'inden ilk productLink URL'sini cikar."""
|
| 498 |
-
import re
|
| 499 |
-
if not result:
|
| 500 |
-
return None
|
| 501 |
-
m = re.search(r'https?://(?:www\.)?trekbisiklet\.com\.tr/urun/[^\s)\]\'"]+', result)
|
| 502 |
-
return m.group(0) if m else None
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
# Renk eşanlam haritası (TR söylüyorlar, XML'de TR/EN karışık olabilir)
|
| 506 |
-
_COLOR_SYNONYMS = {
|
| 507 |
-
'siyah': ['siyah', 'black', 'noir', 'negro'],
|
| 508 |
-
'beyaz': ['beyaz', 'white', 'blanco'],
|
| 509 |
-
'mavi': ['mavi', 'blue', 'azul', 'navy', 'lacivert'],
|
| 510 |
-
'kirmizi': ['kirmizi', 'kırmızı', 'red', 'rojo'],
|
| 511 |
-
'yesil': ['yesil', 'yeşil', 'green', 'verde'],
|
| 512 |
-
'sari': ['sari', 'sarı', 'yellow', 'amarillo'],
|
| 513 |
-
'turuncu': ['turuncu', 'orange', 'naranja'],
|
| 514 |
-
'mor': ['mor', 'purple'],
|
| 515 |
-
'pembe': ['pembe', 'pink', 'rosa'],
|
| 516 |
-
'gri': ['gri', 'grey', 'gray', 'gris'],
|
| 517 |
-
'kahverengi': ['kahverengi', 'brown', 'marron'],
|
| 518 |
-
'altin': ['altin', 'altın', 'gold'],
|
| 519 |
-
'gumus': ['gumus', 'gümüş', 'silver'],
|
| 520 |
-
'bordo': ['bordo', 'burgundy'],
|
| 521 |
-
}
|
| 522 |
-
|
| 523 |
-
def _detect_colors_in_text(text_norm: str) -> set[str]:
|
| 524 |
-
"""Metinde geçen renkleri normalized form'da dondur."""
|
| 525 |
-
found = set()
|
| 526 |
-
for key, syns in _COLOR_SYNONYMS.items():
|
| 527 |
-
for s in syns:
|
| 528 |
-
if _norm(s) in text_norm:
|
| 529 |
-
found.add(key)
|
| 530 |
-
break
|
| 531 |
-
return found
|
| 532 |
-
|
| 533 |
-
def find_main_product_in_text(text: str) -> dict | None:
|
| 534 |
-
"""Metinde gecen ANA urunu bul. ZORUNLU tokenlar:
|
| 535 |
-
- 4+ karakter kelimeler (marlin, domane, emonda)
|
| 536 |
-
- 1-2 basamakli sayilar (model numaralari: 4, 5, 6)
|
| 537 |
-
Birden fazla urun gecerse, METINDE EN SON gecen kazanir
|
| 538 |
-
(asistan konustukca son bahsedilen urune gecisin)."""
|
| 539 |
-
try:
|
| 540 |
-
idx = _ensure_index()
|
| 541 |
-
if not idx:
|
| 542 |
-
return None
|
| 543 |
-
text_norm = _norm(text)
|
| 544 |
-
|
| 545 |
-
best = None
|
| 546 |
-
best_last_pos = -1
|
| 547 |
-
for p in idx["products"]:
|
| 548 |
-
if p['is_variant']:
|
| 549 |
-
continue
|
| 550 |
-
tokens = p.get('_tokens') or []
|
| 551 |
-
mandatory = [
|
| 552 |
-
t for t in tokens
|
| 553 |
-
if len(t) >= 4 or (t.isdigit() and 1 <= len(t) <= 2)
|
| 554 |
-
]
|
| 555 |
-
if not mandatory:
|
| 556 |
-
continue
|
| 557 |
-
# Tum mandatory tokenlar metinde olmali
|
| 558 |
-
positions = []
|
| 559 |
-
ok = True
|
| 560 |
-
for t in mandatory:
|
| 561 |
-
pos = text_norm.rfind(t)
|
| 562 |
-
if pos < 0:
|
| 563 |
-
ok = False
|
| 564 |
-
break
|
| 565 |
-
positions.append(pos)
|
| 566 |
-
if not ok:
|
| 567 |
-
continue
|
| 568 |
-
last_pos = max(positions)
|
| 569 |
-
if last_pos > best_last_pos:
|
| 570 |
-
best_last_pos = last_pos
|
| 571 |
-
best = p
|
| 572 |
-
return _public_view(best)
|
| 573 |
-
except Exception:
|
| 574 |
-
logger.exception('find_main_product_in_text hatasi')
|
| 575 |
-
return None
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
def find_color_variant(main_link: str, text: str) -> dict | None:
|
| 579 |
-
"""Verilen ana urunun, metinde gecen renge ait varyantini dondur.
|
| 580 |
-
DONUS: variant'in resmi/galeri'si + ANA URUN'un ismi ve linki (boy bilgisi yok).
|
| 581 |
-
Metinde renk yoksa None doner."""
|
| 582 |
-
try:
|
| 583 |
-
idx = _ensure_index()
|
| 584 |
-
if not idx:
|
| 585 |
-
return None
|
| 586 |
-
text_colors = _detect_colors_in_text(_norm(text))
|
| 587 |
-
if not text_colors:
|
| 588 |
-
return None
|
| 589 |
-
main = idx["by_link"].get(main_link.strip())
|
| 590 |
-
if not main or main['is_variant'] or not main['sku']:
|
| 591 |
-
return None
|
| 592 |
-
variants = idx["variants_by_root"].get(main['sku'], [])
|
| 593 |
-
for v in variants:
|
| 594 |
-
v_color = (v.get('color') or '').lower()
|
| 595 |
-
if not v_color:
|
| 596 |
-
continue
|
| 597 |
-
for key, syns in _COLOR_SYNONYMS.items():
|
| 598 |
-
if key in text_colors and any(_norm(s) in _norm(v_color) for s in syns):
|
| 599 |
-
# Resim varyanttan, isim/link ana urunden — boy ve renk ismi ayiklanir
|
| 600 |
-
return {
|
| 601 |
-
'name': main['name'],
|
| 602 |
-
'image': v.get('image'),
|
| 603 |
-
'images': v.get('images') or [],
|
| 604 |
-
'link': main['link'],
|
| 605 |
-
'sku': main['sku'],
|
| 606 |
-
'color': (v.get('color') or '').upper(),
|
| 607 |
-
'size': None,
|
| 608 |
-
'is_variant': False,
|
| 609 |
-
}
|
| 610 |
-
return None
|
| 611 |
-
except Exception:
|
| 612 |
-
logger.exception('find_color_variant hatasi')
|
| 613 |
-
return None
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
@app.websocket("/ws")
|
| 617 |
-
async def realtime_relay(client_ws: WebSocket):
|
| 618 |
-
"""Browser <-> OpenAI Realtime API arasinda WebSocket relay."""
|
| 619 |
-
await client_ws.accept()
|
| 620 |
-
|
| 621 |
-
if not OPENAI_API_KEY:
|
| 622 |
-
await client_ws.send_text(json.dumps({
|
| 623 |
-
"type": "error",
|
| 624 |
-
"error": {"message": "OPENAI_API_KEY tanimli degil."}
|
| 625 |
-
}))
|
| 626 |
-
await client_ws.close()
|
| 627 |
-
return
|
| 628 |
-
|
| 629 |
-
headers = {
|
| 630 |
-
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
| 631 |
-
}
|
| 632 |
-
|
| 633 |
-
try:
|
| 634 |
-
async with websockets.connect(REALTIME_URL, additional_headers=headers) as openai_ws:
|
| 635 |
-
logger.info("OpenAI Realtime baglantisi kuruldu")
|
| 636 |
-
|
| 637 |
-
# Initial session config (GA API format)
|
| 638 |
-
await openai_ws.send(json.dumps({
|
| 639 |
-
"type": "session.update",
|
| 640 |
-
"session": {
|
| 641 |
-
"type": "realtime",
|
| 642 |
-
"model": REALTIME_MODEL,
|
| 643 |
-
"instructions": build_session_instructions(),
|
| 644 |
-
"output_modalities": ["audio"],
|
| 645 |
-
"audio": {
|
| 646 |
-
"input": {
|
| 647 |
-
"format": {"type": "audio/pcm", "rate": 24000},
|
| 648 |
-
"transcription": {"model": "whisper-1"},
|
| 649 |
-
"turn_detection": {
|
| 650 |
-
"type": "server_vad",
|
| 651 |
-
"threshold": 0.5,
|
| 652 |
-
"prefix_padding_ms": 300,
|
| 653 |
-
"silence_duration_ms": 700,
|
| 654 |
-
"interrupt_response": False,
|
| 655 |
-
"create_response": True,
|
| 656 |
-
},
|
| 657 |
-
},
|
| 658 |
-
"output": {
|
| 659 |
-
"format": {"type": "audio/pcm", "rate": 24000},
|
| 660 |
-
},
|
| 661 |
-
},
|
| 662 |
-
"tools": TOOLS,
|
| 663 |
-
"tool_choice": "auto",
|
| 664 |
-
}
|
| 665 |
-
}))
|
| 666 |
-
|
| 667 |
-
async def client_to_openai():
|
| 668 |
-
try:
|
| 669 |
-
while True:
|
| 670 |
-
msg = await client_ws.receive_text()
|
| 671 |
-
await openai_ws.send(msg)
|
| 672 |
-
except WebSocketDisconnect:
|
| 673 |
-
logger.info("Client disconnected")
|
| 674 |
-
except Exception as e:
|
| 675 |
-
logger.error(f"client_to_openai error: {e}")
|
| 676 |
-
|
| 677 |
-
# Asistan transkripti per-response birikir, response.done'da urun aramasi yapilir
|
| 678 |
-
# current_main_link: en son gosterilen ana urun (varyant aramasi icin scope)
|
| 679 |
-
transcript_buf = {
|
| 680 |
-
"text": "",
|
| 681 |
-
"tool_link": None,
|
| 682 |
-
"current_main_link": None,
|
| 683 |
-
"last_shown_in_response": None,
|
| 684 |
-
"last_check_len": 0,
|
| 685 |
-
}
|
| 686 |
-
|
| 687 |
-
async def openai_to_client():
|
| 688 |
-
try:
|
| 689 |
-
async for raw in openai_ws:
|
| 690 |
-
try:
|
| 691 |
-
data = json.loads(raw)
|
| 692 |
-
except Exception:
|
| 693 |
-
await client_ws.send_text(raw)
|
| 694 |
-
continue
|
| 695 |
-
|
| 696 |
-
evt_type = data.get("type", "")
|
| 697 |
-
|
| 698 |
-
# Debug: onemli event'leri logla
|
| 699 |
-
if evt_type in ("session.created", "session.updated"):
|
| 700 |
-
logger.info(f"[Realtime] {evt_type}")
|
| 701 |
-
elif evt_type == "error":
|
| 702 |
-
logger.error(f"[Realtime] ERROR: {json.dumps(data)[:500]}")
|
| 703 |
-
elif evt_type == "response.done":
|
| 704 |
-
status = data.get("response", {}).get("status")
|
| 705 |
-
details = data.get("response", {}).get("status_details")
|
| 706 |
-
logger.info(f"[Realtime] response.done status={status} details={details}")
|
| 707 |
-
elif evt_type in ("input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped", "input_audio_buffer.committed", "response.created"):
|
| 708 |
-
logger.info(f"[Realtime] {evt_type}")
|
| 709 |
-
|
| 710 |
-
# Yeni response basladiginda transkript ve live state sifirla
|
| 711 |
-
# tool_link KULLANICI YENI TURNE GECINCE sifirlanir (asagida)
|
| 712 |
-
if evt_type == "response.created":
|
| 713 |
-
transcript_buf["text"] = ""
|
| 714 |
-
transcript_buf["last_shown_in_response"] = None
|
| 715 |
-
transcript_buf["last_check_len"] = 0
|
| 716 |
-
|
| 717 |
-
# Kullanici yeni soru sormaya basladi — yeni turn
|
| 718 |
-
if evt_type == "input_audio_buffer.speech_started":
|
| 719 |
-
transcript_buf["tool_link"] = None
|
| 720 |
-
|
| 721 |
-
# Asistan ses transkripti — fragmanlari biriktir + LIVE urun tespiti
|
| 722 |
-
if evt_type in ("response.audio_transcript.delta",
|
| 723 |
-
"response.output_audio_transcript.delta",
|
| 724 |
-
"response.output_text.delta"):
|
| 725 |
-
d = data.get("delta", "")
|
| 726 |
-
if isinstance(d, str):
|
| 727 |
-
transcript_buf["text"] += d
|
| 728 |
-
# Her ~80 char'da bir tara (perf icin throttle)
|
| 729 |
-
if len(transcript_buf["text"]) - transcript_buf["last_check_len"] >= 80:
|
| 730 |
-
transcript_buf["last_check_len"] = len(transcript_buf["text"])
|
| 731 |
-
live_p = find_main_product_in_text(transcript_buf["text"])
|
| 732 |
-
if live_p and live_p.get("image"):
|
| 733 |
-
live_link = live_p.get("link")
|
| 734 |
-
if live_link != transcript_buf["last_shown_in_response"]:
|
| 735 |
-
transcript_buf["last_shown_in_response"] = live_link
|
| 736 |
-
transcript_buf["current_main_link"] = live_link
|
| 737 |
-
logger.info(f"[product/live] {live_p['name']}")
|
| 738 |
-
try:
|
| 739 |
-
await client_ws.send_text(json.dumps({
|
| 740 |
-
"type": "product.show",
|
| 741 |
-
"product": live_p,
|
| 742 |
-
}))
|
| 743 |
-
except Exception:
|
| 744 |
-
pass
|
| 745 |
-
|
| 746 |
-
# response.done — sadece RENK OVERRIDE icin transkript kullanilir.
|
| 747 |
-
# Yeni ana urun gosterimi tamamen TOOL CAGRISINA birakilir (GPT'ye guven).
|
| 748 |
-
# Boylece "Marlin 4'e donme" tahmin bug'i tamamen ortadan kalkar.
|
| 749 |
-
if evt_type == "response.done":
|
| 750 |
-
text = transcript_buf["text"]
|
| 751 |
-
active_main = transcript_buf["current_main_link"]
|
| 752 |
-
if text and active_main:
|
| 753 |
-
color_v = find_color_variant(active_main, text)
|
| 754 |
-
if color_v and color_v.get("image"):
|
| 755 |
-
logger.info(f"[product/color-override] {color_v['name']}")
|
| 756 |
-
try:
|
| 757 |
-
await client_ws.send_text(json.dumps({
|
| 758 |
-
"type": "product.show",
|
| 759 |
-
"product": color_v,
|
| 760 |
-
}))
|
| 761 |
-
except Exception:
|
| 762 |
-
pass
|
| 763 |
-
|
| 764 |
-
# Tool call yakala
|
| 765 |
-
if evt_type == "response.function_call_arguments.done":
|
| 766 |
-
call_id = data.get("call_id")
|
| 767 |
-
fn_name = data.get("name")
|
| 768 |
-
try:
|
| 769 |
-
args = json.loads(data.get("arguments", "{}"))
|
| 770 |
-
except Exception:
|
| 771 |
-
args = {}
|
| 772 |
-
|
| 773 |
-
logger.info(f"Tool call: {fn_name}({args})")
|
| 774 |
-
result = await handle_tool_call(fn_name, args)
|
| 775 |
-
|
| 776 |
-
# Urun resmi bul ve client'a gonder (split-screen display icin)
|
| 777 |
-
# Tool result'taki gercek productLink'i kullan — garanti dogru urun
|
| 778 |
-
if fn_name == "get_warehouse_stock":
|
| 779 |
-
product = None
|
| 780 |
-
product_link = extract_product_link_from_result(result)
|
| 781 |
-
if product_link:
|
| 782 |
-
product = find_product_by_link(product_link)
|
| 783 |
-
# Stok XML duser veya tool urun bulamazsa — Trek katalog index'inden
|
| 784 |
-
# kullanici sorgusuna gore arama yap, en azindan resmi gosterelim
|
| 785 |
-
if not product or not product.get("image"):
|
| 786 |
-
query = args.get("user_message", "")
|
| 787 |
-
fallback = find_main_product_in_text(query)
|
| 788 |
-
if fallback and fallback.get("image"):
|
| 789 |
-
product = fallback
|
| 790 |
-
if product and product.get("image"):
|
| 791 |
-
logger.info(f"[product/tool] {product['name']} ({product.get('link')})")
|
| 792 |
-
transcript_buf["tool_link"] = product.get("link") or product_link
|
| 793 |
-
if product.get("link"):
|
| 794 |
-
transcript_buf["current_main_link"] = product["link"]
|
| 795 |
-
transcript_buf["last_shown_in_response"] = product["link"]
|
| 796 |
-
try:
|
| 797 |
-
await client_ws.send_text(json.dumps({
|
| 798 |
-
"type": "product.show",
|
| 799 |
-
"product": product,
|
| 800 |
-
}))
|
| 801 |
-
except Exception:
|
| 802 |
-
pass
|
| 803 |
-
|
| 804 |
-
# Modelin URL'i sesli okumamasi icin tool sonucundan linkleri temizle
|
| 805 |
-
import re as _re
|
| 806 |
-
result = _re.sub(r'(?im)^\s*(?:Link|URL|Url|🔗.*?):.*$', '', result)
|
| 807 |
-
result = _re.sub(r'https?://\S+', '', result)
|
| 808 |
-
result = _re.sub(r'\n{3,}', '\n\n', result).strip()
|
| 809 |
-
|
| 810 |
-
# Tool sonucunu OpenAI'ye geri gonder
|
| 811 |
-
await openai_ws.send(json.dumps({
|
| 812 |
-
"type": "conversation.item.create",
|
| 813 |
-
"item": {
|
| 814 |
-
"type": "function_call_output",
|
| 815 |
-
"call_id": call_id,
|
| 816 |
-
"output": result,
|
| 817 |
-
}
|
| 818 |
-
}))
|
| 819 |
-
# Yeni response istegi
|
| 820 |
-
await openai_ws.send(json.dumps({"type": "response.create"}))
|
| 821 |
|
| 822 |
-
# Tum mesajlari client'a forward et
|
| 823 |
-
await client_ws.send_text(raw)
|
| 824 |
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
|
|
|
| 829 |
|
| 830 |
-
await asyncio.gather(client_to_openai(), openai_to_client())
|
| 831 |
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
"error": {"message": str(e)}
|
| 838 |
-
}))
|
| 839 |
-
except Exception:
|
| 840 |
-
pass
|
| 841 |
-
finally:
|
| 842 |
-
try:
|
| 843 |
-
await client_ws.close()
|
| 844 |
-
except Exception:
|
| 845 |
-
pass
|
|
|
|
| 1 |
+
"""BF-Realtime V2 — FastAPI entry point.
|
| 2 |
+
|
| 3 |
+
Sadece routing ve startup logic. Tum is logic'i ayri modullerde:
|
| 4 |
+
- product_index: Trek katalog XML parse + hash index
|
| 5 |
+
- product_matcher: fuzzy ana urun + renk varyanti eslestirme
|
| 6 |
+
- stock_service: BizimHesap + Trek PHP stok cache
|
| 7 |
+
- tools: get_warehouse_stock implementasyonu
|
| 8 |
+
- realtime_relay: OpenAI Realtime WS proxy
|
| 9 |
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
|
|
|
|
|
|
|
|
|
| 12 |
import asyncio
|
| 13 |
import logging
|
| 14 |
+
|
| 15 |
+
from fastapi import FastAPI, WebSocket
|
| 16 |
+
from fastapi.responses import FileResponse, JSONResponse, Response
|
| 17 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 18 |
|
| 19 |
+
from config import (
|
| 20 |
+
OPENAI_API_KEY,
|
| 21 |
+
REALTIME_MODEL,
|
| 22 |
+
REFRESH_INTERVAL,
|
| 23 |
+
)
|
| 24 |
+
from product_index import background_refresh_loop, get_index
|
| 25 |
+
from product_matcher import find_main_product_in_text
|
| 26 |
+
from realtime_relay import realtime_relay
|
| 27 |
+
from stock_service import (
|
| 28 |
+
cached_bh,
|
| 29 |
+
cached_bh_inventory,
|
| 30 |
+
get_cached_warehouse_xml,
|
| 31 |
+
)
|
| 32 |
|
| 33 |
logging.basicConfig(level=logging.INFO)
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
+
app = FastAPI(title="BF-Realtime V2")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
app.mount("/static", StaticFiles(directory="static"), name="static")
|
| 38 |
|
| 39 |
|
| 40 |
@app.on_event("startup")
|
| 41 |
+
async def _startup():
|
| 42 |
+
"""Background refresh tasks — index + warehouse XML her saatte tazelenir."""
|
| 43 |
+
async def warehouse_loop():
|
| 44 |
while True:
|
| 45 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
await asyncio.to_thread(get_cached_warehouse_xml)
|
| 47 |
except Exception:
|
| 48 |
+
logger.exception("warehouse refresh hatasi")
|
| 49 |
+
await asyncio.sleep(REFRESH_INTERVAL)
|
|
|
|
| 50 |
|
| 51 |
+
asyncio.create_task(background_refresh_loop(REFRESH_INTERVAL))
|
| 52 |
+
asyncio.create_task(warehouse_loop())
|
| 53 |
|
| 54 |
+
|
| 55 |
+
# ---------- Frontend ----------
|
| 56 |
@app.get("/")
|
| 57 |
async def root():
|
| 58 |
return FileResponse("static/index.html")
|
|
|
|
| 60 |
|
| 61 |
@app.get("/health")
|
| 62 |
async def health():
|
| 63 |
+
idx = get_index()
|
| 64 |
return {
|
| 65 |
"status": "ok",
|
| 66 |
"model": REALTIME_MODEL,
|
| 67 |
"has_api_key": bool(OPENAI_API_KEY),
|
| 68 |
+
"index_main_count": idx.main_count,
|
| 69 |
}
|
| 70 |
|
| 71 |
|
| 72 |
+
# ---------- Realtime WebSocket ----------
|
| 73 |
+
@app.websocket("/ws")
|
| 74 |
+
async def ws_endpoint(client_ws: WebSocket):
|
| 75 |
+
await realtime_relay(client_ws)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
|
| 78 |
+
# ---------- Public proxy: warehouse XML (digerservisler kullaniyor) ----------
|
| 79 |
@app.get("/warehouse-xml")
|
| 80 |
async def warehouse_xml():
|
| 81 |
+
xml = await asyncio.to_thread(get_cached_warehouse_xml)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if not xml:
|
| 83 |
return Response(
|
| 84 |
+
content='<?xml version="1.0" encoding="UTF-8"?>\n<Products></Products>',
|
| 85 |
media_type="application/xml",
|
| 86 |
headers={"Cache-Control": "no-cache"},
|
| 87 |
)
|
|
|
|
| 90 |
media_type="application/xml",
|
| 91 |
headers={
|
| 92 |
"Access-Control-Allow-Origin": "*",
|
| 93 |
+
"Cache-Control": "public, max-age=300",
|
| 94 |
},
|
| 95 |
)
|
| 96 |
|
| 97 |
|
| 98 |
+
# ---------- Public proxy: BizimHesap raw (tavsiye/sold/diger client'lar) ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
@app.get("/bh/products")
|
| 100 |
async def bh_products():
|
| 101 |
+
from stock_service import bh_get
|
| 102 |
+
from config import BIZIMHESAP_BASE, CACHE_TTL_BH_PRODUCTS
|
| 103 |
+
data, status = await asyncio.to_thread(
|
| 104 |
+
cached_bh,
|
| 105 |
+
"products",
|
| 106 |
+
lambda: bh_get(f"{BIZIMHESAP_BASE}/products"),
|
| 107 |
+
CACHE_TTL_BH_PRODUCTS,
|
| 108 |
+
)
|
| 109 |
if data is None:
|
| 110 |
+
return JSONResponse(
|
| 111 |
+
{"resultCode": 0, "errorText": "BizimHesap fetch failed", "data": None},
|
| 112 |
+
status_code=502,
|
| 113 |
+
)
|
| 114 |
return JSONResponse(
|
| 115 |
data,
|
| 116 |
+
headers={
|
| 117 |
+
"Access-Control-Allow-Origin": "*",
|
| 118 |
+
"X-Cache": status,
|
| 119 |
+
"Cache-Control": "public, max-age=300",
|
| 120 |
+
},
|
| 121 |
)
|
| 122 |
|
| 123 |
|
| 124 |
@app.get("/bh/warehouses")
|
| 125 |
async def bh_warehouses():
|
| 126 |
+
from stock_service import bh_get
|
| 127 |
+
from config import BIZIMHESAP_BASE, CACHE_TTL_BH_WAREHOUSES
|
| 128 |
+
data, status = await asyncio.to_thread(
|
| 129 |
+
cached_bh,
|
| 130 |
+
"warehouses",
|
| 131 |
+
lambda: bh_get(f"{BIZIMHESAP_BASE}/warehouses"),
|
| 132 |
+
CACHE_TTL_BH_WAREHOUSES,
|
| 133 |
+
)
|
| 134 |
if data is None:
|
| 135 |
+
return JSONResponse(
|
| 136 |
+
{"resultCode": 0, "errorText": "BizimHesap fetch failed", "data": None},
|
| 137 |
+
status_code=502,
|
| 138 |
+
)
|
| 139 |
return JSONResponse(
|
| 140 |
data,
|
| 141 |
+
headers={
|
| 142 |
+
"Access-Control-Allow-Origin": "*",
|
| 143 |
+
"X-Cache": status,
|
| 144 |
+
"Cache-Control": "public, max-age=300",
|
| 145 |
+
},
|
| 146 |
)
|
| 147 |
|
| 148 |
|
| 149 |
@app.get("/bh/inventory/{wid}")
|
| 150 |
async def bh_inventory(wid: str):
|
| 151 |
+
data, status = await asyncio.to_thread(cached_bh_inventory, wid)
|
| 152 |
+
if data is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
return JSONResponse(
|
| 154 |
+
{"resultCode": 0, "errorText": "BizimHesap fetch failed", "data": None},
|
| 155 |
+
status_code=502,
|
| 156 |
)
|
| 157 |
+
return JSONResponse(
|
| 158 |
+
data,
|
| 159 |
+
headers={
|
| 160 |
+
"Access-Control-Allow-Origin": "*",
|
| 161 |
+
"X-Cache": status,
|
| 162 |
+
"Cache-Control": "public, max-age=300",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
},
|
| 164 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
|
|
|
|
|
|
| 166 |
|
| 167 |
+
# ---------- Debug ----------
|
| 168 |
+
@app.get("/debug-find")
|
| 169 |
+
async def debug_find(q: str):
|
| 170 |
+
p = find_main_product_in_text(q)
|
| 171 |
+
return {"query": q, "matched": (p.get("name") if p else None), "result": p}
|
| 172 |
|
|
|
|
| 173 |
|
| 174 |
+
@app.get("/debug-search")
|
| 175 |
+
async def debug_search(q: str):
|
| 176 |
+
from smart_warehouse_with_price import get_warehouse_stock_smart_with_price
|
| 177 |
+
result = await asyncio.to_thread(get_warehouse_stock_smart_with_price, q)
|
| 178 |
+
return {"query": q, "result": result}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BF-Realtime V2 — merkezi konfigurasyon."""
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
# OpenAI
|
| 5 |
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 6 |
+
REALTIME_MODEL = "gpt-realtime-2"
|
| 7 |
+
REALTIME_URL = f"wss://api.openai.com/v1/realtime?model={REALTIME_MODEL}"
|
| 8 |
+
|
| 9 |
+
# Tool icindeki fuzzy matching modeli (primary)
|
| 10 |
+
MATCHER_MODEL = "gpt-5.5"
|
| 11 |
+
MATCHER_TIMEOUT = 12
|
| 12 |
+
|
| 13 |
+
# Fallback model (matcher fail ederse)
|
| 14 |
+
NANO_MODEL = "gpt-5-nano"
|
| 15 |
+
NANO_TIMEOUT = 6
|
| 16 |
+
|
| 17 |
+
# Trek katalog XML (urun resmi, link, isim, varyant bilgisi)
|
| 18 |
+
TREK_XML_URL = "https://www.trekbisiklet.com.tr/output/2688003925"
|
| 19 |
+
TREK_XML_TIMEOUT = 8
|
| 20 |
+
|
| 21 |
+
# Stok kaynaklari
|
| 22 |
+
BIZIMHESAP_TOKEN = "6F4BAF303FA240608A39653824B6C495"
|
| 23 |
+
BIZIMHESAP_BASE = "https://bizimhesap.com/api/b2b"
|
| 24 |
+
BIZIMHESAP_TIMEOUT = 12
|
| 25 |
+
BIZIMHESAP_HEADERS = {"token": BIZIMHESAP_TOKEN, "Accept": "application/json"}
|
| 26 |
+
|
| 27 |
+
TREK_PHP_URL = "https://video.trek-turkey.com/bizimhesap-warehouse-xml-b2b-api-v2.php"
|
| 28 |
+
TREK_PHP_TIMEOUT = 30 # PHP icinde 15dk cache — proxy'nin upstream cevabi 30s'ye kadar surebilir
|
| 29 |
+
|
| 30 |
+
# Cache TTL'leri (saniye)
|
| 31 |
+
CACHE_TTL_TREK_XML = 86400 # 24 saat
|
| 32 |
+
CACHE_TTL_WAREHOUSE = 86400 # 24 saat
|
| 33 |
+
CACHE_TTL_BH_PRODUCTS = 7200 # 2 saat
|
| 34 |
+
CACHE_TTL_BH_WAREHOUSES = 21600 # 6 saat
|
| 35 |
+
CACHE_TTL_BH_INVENTORY = 1800 # 30 dakika
|
| 36 |
+
CACHE_TTL_SEARCH = 3600 # 1 saat
|
| 37 |
+
|
| 38 |
+
# Background refresh interval
|
| 39 |
+
REFRESH_INTERVAL = 3600 # 1 saat
|
| 40 |
+
|
| 41 |
+
# 2'den fazla kritik magaza bossa stok cache'ini guncelleme
|
| 42 |
+
CRITICAL_WAREHOUSES = {"BAHCEKOY", "CADDEBOSTAN", "ALSANCAK"}
|
conversation_tracker.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
| 1 |
-
"""Conversation tracking for BF chatbot"""
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import os
|
| 5 |
-
from datetime import datetime
|
| 6 |
-
from typing import List, Dict, Any
|
| 7 |
-
|
| 8 |
-
# Konuşma geçmişini saklamak için
|
| 9 |
-
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 10 |
-
CONVERSATIONS_FILE = os.path.join(BASE_DIR, "conversations.json")
|
| 11 |
-
PUBLIC_FILE = os.path.join(BASE_DIR, "public", "conversations.json")
|
| 12 |
-
MAX_CONVERSATIONS = 100 # Son 100 konuşmayı sakla
|
| 13 |
-
|
| 14 |
-
def load_conversations():
|
| 15 |
-
"""Load conversation history from file"""
|
| 16 |
-
if os.path.exists(CONVERSATIONS_FILE):
|
| 17 |
-
try:
|
| 18 |
-
with open(CONVERSATIONS_FILE, 'r', encoding='utf-8') as f:
|
| 19 |
-
return json.load(f)
|
| 20 |
-
except:
|
| 21 |
-
return []
|
| 22 |
-
return []
|
| 23 |
-
|
| 24 |
-
def save_conversations(conversations):
|
| 25 |
-
"""Save conversations to file"""
|
| 26 |
-
try:
|
| 27 |
-
# Keep only last MAX_CONVERSATIONS
|
| 28 |
-
if len(conversations) > MAX_CONVERSATIONS:
|
| 29 |
-
conversations = conversations[-MAX_CONVERSATIONS:]
|
| 30 |
-
|
| 31 |
-
# Try to save to file
|
| 32 |
-
with open(CONVERSATIONS_FILE, 'w', encoding='utf-8') as f:
|
| 33 |
-
json.dump(conversations, f, ensure_ascii=False, indent=2)
|
| 34 |
-
print(f"✅ Saved {len(conversations)} conversations to {CONVERSATIONS_FILE}")
|
| 35 |
-
|
| 36 |
-
# Also save to a backup location that Gradio can access
|
| 37 |
-
try:
|
| 38 |
-
# Create a directory if it doesn't exist
|
| 39 |
-
os.makedirs(os.path.dirname(PUBLIC_FILE), exist_ok=True)
|
| 40 |
-
with open(PUBLIC_FILE, 'w', encoding='utf-8') as f:
|
| 41 |
-
json.dump(conversations, f, ensure_ascii=False, indent=2)
|
| 42 |
-
print(f"✅ Also saved to {PUBLIC_FILE}")
|
| 43 |
-
except:
|
| 44 |
-
pass
|
| 45 |
-
|
| 46 |
-
except Exception as e:
|
| 47 |
-
print(f"❌ Error saving conversations: {e}")
|
| 48 |
-
# If file write fails, at least keep in memory
|
| 49 |
-
global _memory_conversations
|
| 50 |
-
_memory_conversations = conversations
|
| 51 |
-
|
| 52 |
-
# Keep a memory backup
|
| 53 |
-
_memory_conversations = []
|
| 54 |
-
|
| 55 |
-
def add_conversation(user_message, bot_response):
|
| 56 |
-
"""Add a new conversation to history"""
|
| 57 |
-
conversations = load_conversations()
|
| 58 |
-
|
| 59 |
-
conversation = {
|
| 60 |
-
"timestamp": datetime.now().isoformat(),
|
| 61 |
-
"user": user_message,
|
| 62 |
-
"bot": bot_response
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
conversations.append(conversation)
|
| 66 |
-
save_conversations(conversations)
|
| 67 |
-
|
| 68 |
-
return conversation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
enhanced_features.py
DELETED
|
@@ -1,563 +0,0 @@
|
|
| 1 |
-
# -*- coding: utf-8 -*-
|
| 2 |
-
"""
|
| 3 |
-
Trek Chatbot Enhanced Features
|
| 4 |
-
1. Görsel AI Entegrasyonu
|
| 5 |
-
2. Kişiselleştirilmiş Öneri Motoru
|
| 6 |
-
3. Gelişmiş Ürün Karşılaştırma
|
| 7 |
-
"""
|
| 8 |
-
|
| 9 |
-
import os
|
| 10 |
-
import json
|
| 11 |
-
import base64
|
| 12 |
-
import requests
|
| 13 |
-
from datetime import datetime
|
| 14 |
-
import pandas as pd
|
| 15 |
-
from PIL import Image
|
| 16 |
-
import random
|
| 17 |
-
|
| 18 |
-
# Kullanıcı profili dosyası
|
| 19 |
-
USER_PROFILES_FILE = "user_profiles.json"
|
| 20 |
-
|
| 21 |
-
class UserProfileManager:
|
| 22 |
-
"""Kullanıcı profili yönetimi"""
|
| 23 |
-
|
| 24 |
-
def __init__(self):
|
| 25 |
-
self.profiles = self.load_profiles()
|
| 26 |
-
|
| 27 |
-
def load_profiles(self):
|
| 28 |
-
"""Kullanıcı profillerini yükle"""
|
| 29 |
-
if os.path.exists(USER_PROFILES_FILE):
|
| 30 |
-
with open(USER_PROFILES_FILE, 'r', encoding='utf-8') as f:
|
| 31 |
-
return json.load(f)
|
| 32 |
-
return {}
|
| 33 |
-
|
| 34 |
-
def save_profiles(self):
|
| 35 |
-
"""Kullanıcı profillerini kaydet"""
|
| 36 |
-
with open(USER_PROFILES_FILE, 'w', encoding='utf-8') as f:
|
| 37 |
-
json.dump(self.profiles, f, ensure_ascii=False, indent=2)
|
| 38 |
-
|
| 39 |
-
def get_or_create_profile(self, user_id="default_user"):
|
| 40 |
-
"""Kullanıcı profili al veya oluştur"""
|
| 41 |
-
if user_id not in self.profiles:
|
| 42 |
-
self.profiles[user_id] = {
|
| 43 |
-
"created_at": datetime.now().isoformat(),
|
| 44 |
-
"preferences": {
|
| 45 |
-
"budget_range": None,
|
| 46 |
-
"bike_category": None,
|
| 47 |
-
"size": None,
|
| 48 |
-
"usage_purpose": None
|
| 49 |
-
},
|
| 50 |
-
"interaction_history": [],
|
| 51 |
-
"favorite_products": [],
|
| 52 |
-
"viewed_products": []
|
| 53 |
-
}
|
| 54 |
-
return self.profiles[user_id]
|
| 55 |
-
|
| 56 |
-
def update_user_preference(self, user_id, key, value):
|
| 57 |
-
"""Kullanıcı tercihini güncelle"""
|
| 58 |
-
profile = self.get_or_create_profile(user_id)
|
| 59 |
-
profile["preferences"][key] = value
|
| 60 |
-
self.save_profiles()
|
| 61 |
-
|
| 62 |
-
def add_interaction(self, user_id, interaction_type, data):
|
| 63 |
-
"""Kullanıcı etkileşimi ekle"""
|
| 64 |
-
profile = self.get_or_create_profile(user_id)
|
| 65 |
-
interaction = {
|
| 66 |
-
"timestamp": datetime.now().isoformat(),
|
| 67 |
-
"type": interaction_type,
|
| 68 |
-
"data": data
|
| 69 |
-
}
|
| 70 |
-
profile["interaction_history"].append(interaction)
|
| 71 |
-
# Son 50 etkileşimi tut
|
| 72 |
-
profile["interaction_history"] = profile["interaction_history"][-50:]
|
| 73 |
-
|
| 74 |
-
# Otomatik tercih çıkarımı
|
| 75 |
-
self._extract_preferences_from_interaction(user_id, interaction_type, data)
|
| 76 |
-
|
| 77 |
-
self.save_profiles()
|
| 78 |
-
|
| 79 |
-
def _extract_preferences_from_interaction(self, user_id, interaction_type, data):
|
| 80 |
-
"""Etkileşimden otomatik tercih çıkarımı"""
|
| 81 |
-
profile = self.get_or_create_profile(user_id)
|
| 82 |
-
|
| 83 |
-
if interaction_type == "chat_message":
|
| 84 |
-
user_message = data.get("user_message", "").lower()
|
| 85 |
-
|
| 86 |
-
# Bütçe tespiti
|
| 87 |
-
import re
|
| 88 |
-
if "bütçe" in user_message or "budget" in user_message:
|
| 89 |
-
numbers = re.findall(r'\d+', user_message)
|
| 90 |
-
if len(numbers) >= 1:
|
| 91 |
-
try:
|
| 92 |
-
budget_value = int(numbers[0]) * 1000 # K TL formatı için
|
| 93 |
-
if budget_value > 10000: # Makul bütçe aralığı
|
| 94 |
-
current_budget = profile["preferences"].get("budget_range")
|
| 95 |
-
if not current_budget:
|
| 96 |
-
# Bütçe aralığını tahmin et
|
| 97 |
-
budget_max = budget_value * 1.5
|
| 98 |
-
profile["preferences"]["budget_range"] = [budget_value, budget_max]
|
| 99 |
-
except ValueError:
|
| 100 |
-
pass
|
| 101 |
-
|
| 102 |
-
# Bisiklet kategorisi tespiti
|
| 103 |
-
bike_categories = {
|
| 104 |
-
"dağ": "dağ bisikleti",
|
| 105 |
-
"mountain": "dağ bisikleti",
|
| 106 |
-
"mtb": "dağ bisikleti",
|
| 107 |
-
"yol": "yol bisikleti",
|
| 108 |
-
"road": "yol bisikleti",
|
| 109 |
-
"şehir": "şehir bisikleti",
|
| 110 |
-
"city": "şehir bisikleti",
|
| 111 |
-
"urban": "şehir bisikleti",
|
| 112 |
-
"elektrikli": "elektrikli bisiklet",
|
| 113 |
-
"electric": "elektrikli bisiklet",
|
| 114 |
-
"e-bike": "elektrikli bisiklet",
|
| 115 |
-
"gravel": "gravel bisiklet"
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
for keyword, category in bike_categories.items():
|
| 119 |
-
if keyword in user_message:
|
| 120 |
-
profile["preferences"]["bike_category"] = category
|
| 121 |
-
break
|
| 122 |
-
|
| 123 |
-
# Kullanım amacı tespiti
|
| 124 |
-
usage_purposes = {
|
| 125 |
-
"işe": "günlük ulaşım",
|
| 126 |
-
"work": "günlük ulaşım",
|
| 127 |
-
"spor": "spor ve egzersiz",
|
| 128 |
-
"sport": "spor ve egzersiz",
|
| 129 |
-
"egzersiz": "spor ve egzersiz",
|
| 130 |
-
"fitness": "spor ve egzersiz",
|
| 131 |
-
"tur": "tur ve gezi",
|
| 132 |
-
"tour": "tur ve gezi",
|
| 133 |
-
"gezi": "tur ve gezi",
|
| 134 |
-
"yarış": "yarış ve performans",
|
| 135 |
-
"race": "yarış ve performans",
|
| 136 |
-
"performance": "yarış ve performans"
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
for keyword, purpose in usage_purposes.items():
|
| 140 |
-
if keyword in user_message:
|
| 141 |
-
profile["preferences"]["usage_purpose"] = purpose
|
| 142 |
-
break
|
| 143 |
-
|
| 144 |
-
class VisualAI:
|
| 145 |
-
"""Görsel AI işlemleri"""
|
| 146 |
-
|
| 147 |
-
def __init__(self, openai_api_key):
|
| 148 |
-
self.api_key = openai_api_key
|
| 149 |
-
|
| 150 |
-
def analyze_bike_image(self, image_path):
|
| 151 |
-
"""Bisiklet görselini analiz et"""
|
| 152 |
-
if not self.api_key:
|
| 153 |
-
# Yerel görsel analiz (demo amaçlı)
|
| 154 |
-
return self.local_image_analysis(image_path)
|
| 155 |
-
|
| 156 |
-
return self.openai_image_analysis(image_path)
|
| 157 |
-
|
| 158 |
-
def local_image_analysis(self, image_path):
|
| 159 |
-
"""Yerel görsel analiz (demo)"""
|
| 160 |
-
try:
|
| 161 |
-
# Görseli yükle ve temel bilgileri al
|
| 162 |
-
img = Image.open(image_path)
|
| 163 |
-
width, height = img.size
|
| 164 |
-
|
| 165 |
-
# Basit analiz mantığı (demo amaçlı)
|
| 166 |
-
bike_types = ["Yol Bisikleti", "Dağ Bisikleti", "Şehir Bisikleti", "Elektrikli Bisiklet"]
|
| 167 |
-
trek_models = ["Madone", "Émonda", "Domane", "Marlin", "Fuel EX", "FX", "Powerfly"]
|
| 168 |
-
colors = ["Siyah", "Beyaz", "Kırmızı", "Mavi", "Gri", "Yeşil"]
|
| 169 |
-
|
| 170 |
-
# Rastgele ama mantıklı analiz
|
| 171 |
-
detected_type = random.choice(bike_types)
|
| 172 |
-
detected_model = random.choice(trek_models)
|
| 173 |
-
detected_color = random.choice(colors)
|
| 174 |
-
|
| 175 |
-
return f"""🖼️ **Görsel Analiz Sonucu**
|
| 176 |
-
|
| 177 |
-
📊 **Görsel Bilgileri:**
|
| 178 |
-
• Boyut: {width}x{height} piksel
|
| 179 |
-
• Format: {img.format if img.format else 'Bilinmiyor'}
|
| 180 |
-
|
| 181 |
-
🚲 **Bisiklet Analizi:**
|
| 182 |
-
• **Tip:** {detected_type}
|
| 183 |
-
• **Tahmini Model:** Trek {detected_model}
|
| 184 |
-
• **Renk:** {detected_color}
|
| 185 |
-
|
| 186 |
-
🔍 **Tespit Edilen Özellikler:**
|
| 187 |
-
• Karbon kadro yapısı görünüyor
|
| 188 |
-
• Profesyonel seviye ekipman
|
| 189 |
-
• Aerodinamik tasarım elementleri
|
| 190 |
-
|
| 191 |
-
💡 **Önerilerim:**
|
| 192 |
-
Bu bisiklet {detected_type.lower()} kategorisinde. Eğer {detected_model} modeli ilginizi çekiyorsa,
|
| 193 |
-
stoklarımızda bu seriyle ilgili güncel modelleri gösterebilirim.
|
| 194 |
-
|
| 195 |
-
*Not: Bu yerel analiz sistemidir. Daha detaylı analiz için Vision API entegrasyonu önerilir.*"""
|
| 196 |
-
|
| 197 |
-
except Exception as e:
|
| 198 |
-
return f"🖼️ Görsel analiz hatası: {str(e)}"
|
| 199 |
-
|
| 200 |
-
def openai_image_analysis(self, image_path):
|
| 201 |
-
"""OpenAI Vision API ile analiz"""
|
| 202 |
-
|
| 203 |
-
try:
|
| 204 |
-
# Görseli base64'e çevir
|
| 205 |
-
with open(image_path, "rb") as image_file:
|
| 206 |
-
base64_image = base64.b64encode(image_file.read()).decode('utf-8')
|
| 207 |
-
|
| 208 |
-
headers = {
|
| 209 |
-
"Content-Type": "application/json",
|
| 210 |
-
"Authorization": f"Bearer {self.api_key}"
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
payload = {
|
| 214 |
-
"model": "gpt-4-vision-preview",
|
| 215 |
-
"messages": [
|
| 216 |
-
{
|
| 217 |
-
"role": "user",
|
| 218 |
-
"content": [
|
| 219 |
-
{
|
| 220 |
-
"type": "text",
|
| 221 |
-
"text": "Bu bisiklet görselini analiz et. Hangi tip bisiklet? Marka, model tahmininde bulun. Trek bisikletleri hakkında uzmanısın."
|
| 222 |
-
},
|
| 223 |
-
{
|
| 224 |
-
"type": "image_url",
|
| 225 |
-
"image_url": {
|
| 226 |
-
"url": f"data:image/jpeg;base64,{base64_image}"
|
| 227 |
-
}
|
| 228 |
-
}
|
| 229 |
-
]
|
| 230 |
-
}
|
| 231 |
-
],
|
| 232 |
-
"max_tokens": 300
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
response = requests.post("https://api.openai.com/v1/chat/completions",
|
| 236 |
-
headers=headers, json=payload)
|
| 237 |
-
|
| 238 |
-
if response.status_code == 200:
|
| 239 |
-
result = response.json()
|
| 240 |
-
return result["choices"][0]["message"]["content"]
|
| 241 |
-
else:
|
| 242 |
-
return f"Görsel analiz hatası: {response.status_code}"
|
| 243 |
-
|
| 244 |
-
except Exception as e:
|
| 245 |
-
return f"Görsel analiz hatası: {str(e)}"
|
| 246 |
-
|
| 247 |
-
class ProductComparison:
|
| 248 |
-
"""Ürün karşılaştırma sistemi"""
|
| 249 |
-
|
| 250 |
-
def __init__(self, products_data):
|
| 251 |
-
self.products = products_data
|
| 252 |
-
|
| 253 |
-
def round_price(self, price_str):
|
| 254 |
-
"""Fiyatı yuvarlama formülüne göre yuvarla"""
|
| 255 |
-
try:
|
| 256 |
-
price_float = float(price_str)
|
| 257 |
-
# Fiyat 200000 üzerindeyse en yakın 5000'lik basamağa yuvarla
|
| 258 |
-
if price_float > 200000:
|
| 259 |
-
return str(round(price_float / 5000) * 5000)
|
| 260 |
-
# Fiyat 30000 üzerindeyse en yakın 1000'lik basamağa yuvarla
|
| 261 |
-
elif price_float > 30000:
|
| 262 |
-
return str(round(price_float / 1000) * 1000)
|
| 263 |
-
# Fiyat 10000 üzerindeyse en yakın 100'lük basamağa yuvarla
|
| 264 |
-
elif price_float > 10000:
|
| 265 |
-
return str(round(price_float / 100) * 100)
|
| 266 |
-
# Diğer durumlarda en yakın 10'luk basamağa yuvarla
|
| 267 |
-
else:
|
| 268 |
-
return str(round(price_float / 10) * 10)
|
| 269 |
-
except (ValueError, TypeError):
|
| 270 |
-
return price_str
|
| 271 |
-
|
| 272 |
-
def find_products_by_name(self, product_names):
|
| 273 |
-
"""İsimlere göre ürünleri bul"""
|
| 274 |
-
found_products = []
|
| 275 |
-
for name in product_names:
|
| 276 |
-
for product in self.products:
|
| 277 |
-
if name.lower() in product[2].lower(): # product[2] = full_name
|
| 278 |
-
found_products.append(product)
|
| 279 |
-
break
|
| 280 |
-
return found_products
|
| 281 |
-
|
| 282 |
-
def create_comparison_table(self, product_names):
|
| 283 |
-
"""Karşılaştırma tablosu oluştur"""
|
| 284 |
-
products = self.find_products_by_name(product_names)
|
| 285 |
-
|
| 286 |
-
if len(products) < 2:
|
| 287 |
-
return "Karşılaştırma için en az 2 ürün gerekli."
|
| 288 |
-
|
| 289 |
-
# Tablo verilerini hazırla
|
| 290 |
-
comparison_data = []
|
| 291 |
-
for product in products:
|
| 292 |
-
name, item_info, full_name = product
|
| 293 |
-
|
| 294 |
-
# Ürün bilgilerini parse et
|
| 295 |
-
stock_status = item_info[0] if len(item_info) > 0 else "Bilgi yok"
|
| 296 |
-
price_raw = item_info[1] if len(item_info) > 1 and item_info[1] else "Fiyat yok"
|
| 297 |
-
product_link = item_info[2] if len(item_info) > 2 else ""
|
| 298 |
-
image_url = item_info[6] if len(item_info) > 6 and item_info[6] else ""
|
| 299 |
-
|
| 300 |
-
# Fiyatı yuvarlama formülüne göre yuvarla
|
| 301 |
-
if price_raw != "Fiyat yok":
|
| 302 |
-
price = self.round_price(price_raw)
|
| 303 |
-
price_display = f"{price} TL"
|
| 304 |
-
else:
|
| 305 |
-
price_display = price_raw
|
| 306 |
-
|
| 307 |
-
comparison_data.append({
|
| 308 |
-
"Ürün": full_name,
|
| 309 |
-
"Stok": stock_status,
|
| 310 |
-
"Fiyat": price_display,
|
| 311 |
-
"Link": product_link,
|
| 312 |
-
"Resim": image_url if image_url else "Resim yok"
|
| 313 |
-
})
|
| 314 |
-
|
| 315 |
-
# DataFrame oluştur
|
| 316 |
-
df = pd.DataFrame(comparison_data)
|
| 317 |
-
|
| 318 |
-
# Markdown tablosu olarak döndür
|
| 319 |
-
return df.to_markdown(index=False)
|
| 320 |
-
|
| 321 |
-
def get_similar_products(self, product_name, category_filter=None):
|
| 322 |
-
"""Benzer ürünleri bul"""
|
| 323 |
-
similar_products = []
|
| 324 |
-
base_name = product_name.lower().split()[0] # İlk kelimeyi al
|
| 325 |
-
|
| 326 |
-
for product in self.products:
|
| 327 |
-
product_full_name = product[2].lower()
|
| 328 |
-
if base_name in product_full_name and product_name.lower() != product_full_name:
|
| 329 |
-
if category_filter:
|
| 330 |
-
if category_filter.lower() in product_full_name:
|
| 331 |
-
similar_products.append(product)
|
| 332 |
-
else:
|
| 333 |
-
similar_products.append(product)
|
| 334 |
-
|
| 335 |
-
return similar_products[:5] # İlk 5 benzer ürün
|
| 336 |
-
|
| 337 |
-
class PersonalizedRecommendations:
|
| 338 |
-
"""Kişiselleştirilmiş öneriler"""
|
| 339 |
-
|
| 340 |
-
def __init__(self, profile_manager, products_data):
|
| 341 |
-
self.profile_manager = profile_manager
|
| 342 |
-
self.products = products_data
|
| 343 |
-
|
| 344 |
-
def get_budget_recommendations(self, user_id, budget_min, budget_max):
|
| 345 |
-
"""Bütçeye uygun öneriler"""
|
| 346 |
-
suitable_products = []
|
| 347 |
-
|
| 348 |
-
for product in self.products:
|
| 349 |
-
if product[1][0] == "stokta" and product[1][1]: # Stokta ve fiyatı var
|
| 350 |
-
try:
|
| 351 |
-
price = float(product[1][1])
|
| 352 |
-
if budget_min <= price <= budget_max:
|
| 353 |
-
suitable_products.append(product)
|
| 354 |
-
except (ValueError, TypeError):
|
| 355 |
-
continue
|
| 356 |
-
|
| 357 |
-
# Kullanıcı tercihlerini kaydet
|
| 358 |
-
self.profile_manager.update_user_preference(user_id, "budget_range", [budget_min, budget_max])
|
| 359 |
-
|
| 360 |
-
return suitable_products[:5] # İlk 5 öneri
|
| 361 |
-
|
| 362 |
-
def get_personalized_suggestions(self, user_id):
|
| 363 |
-
"""Geçmiş davranışlara göre öneriler"""
|
| 364 |
-
profile = self.profile_manager.get_or_create_profile(user_id)
|
| 365 |
-
preferences = profile["preferences"]
|
| 366 |
-
|
| 367 |
-
suggestions = []
|
| 368 |
-
|
| 369 |
-
# Bütçe tercihi varsa ona göre filtrele
|
| 370 |
-
if preferences.get("budget_range"):
|
| 371 |
-
budget_min, budget_max = preferences["budget_range"]
|
| 372 |
-
suggestions.extend(self.get_budget_recommendations(user_id, budget_min, budget_max))
|
| 373 |
-
|
| 374 |
-
# Kategori tercihi varsa ona göre filtrele
|
| 375 |
-
if preferences.get("bike_category"):
|
| 376 |
-
category = preferences["bike_category"]
|
| 377 |
-
for product in self.products:
|
| 378 |
-
if category.lower() in product[2].lower() and product[1][0] == "stokta":
|
| 379 |
-
suggestions.append(product)
|
| 380 |
-
|
| 381 |
-
return list(set(suggestions))[:3] # Duplicate'ları kaldır ve ilk 3'ü al
|
| 382 |
-
|
| 383 |
-
# Global instance'lar
|
| 384 |
-
profile_manager = UserProfileManager()
|
| 385 |
-
visual_ai = None # OpenAI key ile başlatılacak
|
| 386 |
-
product_comparison = None # Products data ile başlatılacak
|
| 387 |
-
personalized_recommendations = None # Profile manager ve products ile başlatılacak
|
| 388 |
-
|
| 389 |
-
def initialize_enhanced_features(openai_api_key, products_data):
|
| 390 |
-
"""Enhanced özellikleri başlat"""
|
| 391 |
-
global visual_ai, product_comparison, personalized_recommendations
|
| 392 |
-
|
| 393 |
-
visual_ai = VisualAI(openai_api_key)
|
| 394 |
-
product_comparison = ProductComparison(products_data)
|
| 395 |
-
personalized_recommendations = PersonalizedRecommendations(profile_manager, products_data)
|
| 396 |
-
|
| 397 |
-
def process_image_message(image_path, user_message):
|
| 398 |
-
"""Görsel mesajını işle"""
|
| 399 |
-
if visual_ai and image_path:
|
| 400 |
-
image_analysis = visual_ai.analyze_bike_image(image_path)
|
| 401 |
-
return f"Görsel Analiz: {image_analysis}\n\nSoru: {user_message}"
|
| 402 |
-
return user_message
|
| 403 |
-
|
| 404 |
-
def handle_comparison_request(user_message):
|
| 405 |
-
"""Karşılaştırma talebini işle"""
|
| 406 |
-
try:
|
| 407 |
-
if "karşılaştır" in user_message.lower() or "compare" in user_message.lower():
|
| 408 |
-
# Ürün isimlerini çıkarmaya çalış
|
| 409 |
-
words = user_message.lower().split()
|
| 410 |
-
potential_products = []
|
| 411 |
-
|
| 412 |
-
# Bilinen model isimlerini ara
|
| 413 |
-
known_models = ["émonda", "madone", "domane", "marlin", "fuel", "powerfly", "fx"]
|
| 414 |
-
for word in words:
|
| 415 |
-
for model in known_models:
|
| 416 |
-
if model in word:
|
| 417 |
-
potential_products.append(model)
|
| 418 |
-
|
| 419 |
-
if len(potential_products) >= 2 and product_comparison:
|
| 420 |
-
comparison_table = product_comparison.create_comparison_table(potential_products)
|
| 421 |
-
return f"Ürün Karşılaştırması:\n\n{comparison_table}"
|
| 422 |
-
|
| 423 |
-
return None
|
| 424 |
-
except Exception as e:
|
| 425 |
-
print(f"Comparison error: {e}")
|
| 426 |
-
return None
|
| 427 |
-
|
| 428 |
-
def get_user_chat_context(user_id, limit=5):
|
| 429 |
-
"""Son sohbet geçmişini kontekst için al"""
|
| 430 |
-
try:
|
| 431 |
-
profile = profile_manager.get_or_create_profile(user_id)
|
| 432 |
-
interactions = profile.get("interaction_history", [])
|
| 433 |
-
|
| 434 |
-
# Son chat mesajlarını al
|
| 435 |
-
chat_messages = []
|
| 436 |
-
for interaction in reversed(interactions):
|
| 437 |
-
if interaction['type'] == 'chat_message' and len(chat_messages) < limit:
|
| 438 |
-
data = interaction['data']
|
| 439 |
-
chat_messages.append({
|
| 440 |
-
"user": data.get('user_message', ''),
|
| 441 |
-
"assistant": data.get('bot_response', ''),
|
| 442 |
-
"timestamp": data.get('timestamp', '')
|
| 443 |
-
})
|
| 444 |
-
|
| 445 |
-
return list(reversed(chat_messages)) # Kronolojik sıra
|
| 446 |
-
|
| 447 |
-
except Exception as e:
|
| 448 |
-
print(f"Chat context error: {e}")
|
| 449 |
-
return []
|
| 450 |
-
|
| 451 |
-
def get_user_profile_summary(user_id):
|
| 452 |
-
"""Kullanıcı profil özetini döndür"""
|
| 453 |
-
try:
|
| 454 |
-
profile = profile_manager.get_or_create_profile(user_id)
|
| 455 |
-
preferences = profile.get("preferences", {})
|
| 456 |
-
|
| 457 |
-
if not any(preferences.values()):
|
| 458 |
-
return "Henüz tercihleriniz kaydedilmemiş. Bisiklet arayışınız hakkında konuşarak size daha iyi öneriler verebilirim."
|
| 459 |
-
|
| 460 |
-
summary = "🔄 **Kaydedilen Tercihleriniz:**\n\n"
|
| 461 |
-
|
| 462 |
-
if preferences.get("bike_category"):
|
| 463 |
-
summary += f"🚲 **Bisiklet Kategorisi:** {preferences['bike_category']}\n"
|
| 464 |
-
|
| 465 |
-
if preferences.get("budget_range"):
|
| 466 |
-
budget_min, budget_max = preferences['budget_range']
|
| 467 |
-
summary += f"💰 **Bütçe Aralığı:** {budget_min:,.0f} - {budget_max:,.0f} TL\n"
|
| 468 |
-
|
| 469 |
-
if preferences.get("usage_purpose"):
|
| 470 |
-
summary += f"🎯 **Kullanım Amacı:** {preferences['usage_purpose']}\n"
|
| 471 |
-
|
| 472 |
-
if preferences.get("size"):
|
| 473 |
-
summary += f"📏 **Boyut:** {preferences['size']}\n"
|
| 474 |
-
|
| 475 |
-
# Son etkileşimler
|
| 476 |
-
interactions = profile.get("interaction_history", [])
|
| 477 |
-
if interactions:
|
| 478 |
-
recent_chats = [i for i in interactions[-5:] if i['type'] == 'chat_message']
|
| 479 |
-
if recent_chats:
|
| 480 |
-
summary += f"\n📝 **Son {len(recent_chats)} Sohbet:**\n"
|
| 481 |
-
for chat in recent_chats:
|
| 482 |
-
timestamp = chat['data'].get('timestamp', 'Bilinmiyor')
|
| 483 |
-
summary += f"• {timestamp}: Sohbet\n"
|
| 484 |
-
|
| 485 |
-
summary += "\n*Bu tercihler sohbetlerimizden otomatik olarak çıkarıldı.*"
|
| 486 |
-
return summary
|
| 487 |
-
|
| 488 |
-
except Exception as e:
|
| 489 |
-
print(f"Profile summary error: {e}")
|
| 490 |
-
return "Profil bilgilerine şu anda erişilemiyor."
|
| 491 |
-
|
| 492 |
-
def get_user_recommendations(user_id, user_message):
|
| 493 |
-
"""Kullanıcıya özel öneriler al"""
|
| 494 |
-
try:
|
| 495 |
-
# Kullanıcı etkileşimini kaydet (tercih çıkarımı için)
|
| 496 |
-
profile_manager.add_interaction(user_id, "recommendation_query", {
|
| 497 |
-
"message": user_message,
|
| 498 |
-
"timestamp": datetime.now().isoformat()
|
| 499 |
-
})
|
| 500 |
-
|
| 501 |
-
# Bütçe sorgusu varsa
|
| 502 |
-
if "bütçe" in user_message.lower() or "budget" in user_message.lower():
|
| 503 |
-
# Rakamları çıkarmaya çalış
|
| 504 |
-
import re
|
| 505 |
-
numbers = re.findall(r'\d+', user_message)
|
| 506 |
-
if len(numbers) >= 2 and personalized_recommendations:
|
| 507 |
-
budget_min = int(numbers[0]) * 1000 # K TL formatı için
|
| 508 |
-
budget_max = int(numbers[1]) * 1000
|
| 509 |
-
recommendations = personalized_recommendations.get_budget_recommendations(
|
| 510 |
-
user_id, budget_min, budget_max
|
| 511 |
-
)
|
| 512 |
-
|
| 513 |
-
if recommendations:
|
| 514 |
-
rec_text = "Bütçenize uygun öneriler:\n\n"
|
| 515 |
-
for product in recommendations[:3]:
|
| 516 |
-
rec_text += f"• {product[2]} - {product[1][1]} TL\n"
|
| 517 |
-
return rec_text
|
| 518 |
-
elif len(numbers) >= 1 and personalized_recommendations:
|
| 519 |
-
# Tek sayı varsa aralık oluştur
|
| 520 |
-
budget_center = int(numbers[0]) * 1000
|
| 521 |
-
budget_min = int(budget_center * 0.8)
|
| 522 |
-
budget_max = int(budget_center * 1.2)
|
| 523 |
-
recommendations = personalized_recommendations.get_budget_recommendations(
|
| 524 |
-
user_id, budget_min, budget_max
|
| 525 |
-
)
|
| 526 |
-
|
| 527 |
-
if recommendations:
|
| 528 |
-
rec_text = f"{numbers[0]}K TL bütçenize uygun öneriler:\n\n"
|
| 529 |
-
for product in recommendations[:3]:
|
| 530 |
-
rec_text += f"• {product[2]} - {product[1][1]} TL\n"
|
| 531 |
-
return rec_text
|
| 532 |
-
|
| 533 |
-
# Kullanıcı profili tercihleri varsa öneri ver
|
| 534 |
-
if personalized_recommendations:
|
| 535 |
-
profile = profile_manager.get_or_create_profile(user_id)
|
| 536 |
-
preferences = profile.get("preferences", {})
|
| 537 |
-
|
| 538 |
-
# Eğer kullanıcının kaydedilmiş tercihleri varsa
|
| 539 |
-
if any(preferences.values()):
|
| 540 |
-
suggestions = personalized_recommendations.get_personalized_suggestions(user_id)
|
| 541 |
-
if suggestions:
|
| 542 |
-
sug_text = "Tercihlerinize göre önerilerimiz:\n\n"
|
| 543 |
-
for product in suggestions[:3]:
|
| 544 |
-
sug_text += f"• {product[2]} - {product[1][1]} TL\n"
|
| 545 |
-
|
| 546 |
-
# Tercih özetini ekle
|
| 547 |
-
pref_summary = []
|
| 548 |
-
if preferences.get("bike_category"):
|
| 549 |
-
pref_summary.append(f"Kategori: {preferences['bike_category']}")
|
| 550 |
-
if preferences.get("budget_range"):
|
| 551 |
-
pref_summary.append(f"Bütçe: {preferences['budget_range'][0]:,.0f}-{preferences['budget_range'][1]:,.0f} TL")
|
| 552 |
-
if preferences.get("usage_purpose"):
|
| 553 |
-
pref_summary.append(f"Kullanım: {preferences['usage_purpose']}")
|
| 554 |
-
|
| 555 |
-
if pref_summary:
|
| 556 |
-
sug_text += f"\n*Kaydedilen tercihleriniz: {', '.join(pref_summary)}*"
|
| 557 |
-
|
| 558 |
-
return sug_text
|
| 559 |
-
|
| 560 |
-
return None
|
| 561 |
-
except Exception as e:
|
| 562 |
-
print(f"Recommendations error: {e}")
|
| 563 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
get_warehouse_fast.py
DELETED
|
@@ -1,130 +0,0 @@
|
|
| 1 |
-
"""Ultra fast warehouse stock getter using regex"""
|
| 2 |
-
|
| 3 |
-
def get_warehouse_stock(product_name):
|
| 4 |
-
"""Super fast warehouse stock finder using regex instead of XML parsing"""
|
| 5 |
-
try:
|
| 6 |
-
import re
|
| 7 |
-
import requests
|
| 8 |
-
|
| 9 |
-
# Fetch XML
|
| 10 |
-
warehouse_url = 'https://video.trek-turkey.com/bizimhesap-warehouse-xml-b2b-api-v2.php'
|
| 11 |
-
response = requests.get(warehouse_url, verify=False, timeout=7)
|
| 12 |
-
|
| 13 |
-
if response.status_code != 200:
|
| 14 |
-
return None
|
| 15 |
-
|
| 16 |
-
xml_text = response.text
|
| 17 |
-
|
| 18 |
-
# Turkish normalization
|
| 19 |
-
turkish_map = {'ı': 'i', 'ğ': 'g', 'ü': 'u', 'ş': 's', 'ö': 'o', 'ç': 'c', 'İ': 'i', 'I': 'i'}
|
| 20 |
-
|
| 21 |
-
def normalize_turkish(text):
|
| 22 |
-
text = text.lower()
|
| 23 |
-
for tr_char, en_char in turkish_map.items():
|
| 24 |
-
text = text.replace(tr_char, en_char)
|
| 25 |
-
return text
|
| 26 |
-
|
| 27 |
-
# Normalize search
|
| 28 |
-
search_name = normalize_turkish(product_name.strip())
|
| 29 |
-
search_name = search_name.replace('(2026)', '').replace('(2025)', '').strip()
|
| 30 |
-
search_words = search_name.split()
|
| 31 |
-
|
| 32 |
-
# Separate size from product words
|
| 33 |
-
size_words = ['s', 'm', 'l', 'xl', 'xs', 'xxl', 'ml']
|
| 34 |
-
size_indicators = ['beden', 'size', 'boy']
|
| 35 |
-
|
| 36 |
-
variant_filter = [w for w in search_words if w in size_words]
|
| 37 |
-
product_words = [w for w in search_words if w not in size_words and w not in size_indicators]
|
| 38 |
-
|
| 39 |
-
print(f"DEBUG - Looking for: {' '.join(product_words)}")
|
| 40 |
-
print(f"DEBUG - Size filter: {variant_filter}")
|
| 41 |
-
|
| 42 |
-
# Build exact pattern for common products
|
| 43 |
-
search_pattern = ' '.join(product_words).upper()
|
| 44 |
-
|
| 45 |
-
# Handle specific products
|
| 46 |
-
if 'madone' in product_words and 'sl' in product_words and '6' in product_words and 'gen' in product_words and '8' in product_words:
|
| 47 |
-
search_pattern = 'MADONE SL 6 GEN 8'
|
| 48 |
-
elif 'madone' in product_words and 'slr' in product_words and '7' in product_words:
|
| 49 |
-
search_pattern = 'MADONE SLR 7 GEN 8'
|
| 50 |
-
else:
|
| 51 |
-
# Generic pattern
|
| 52 |
-
search_pattern = search_pattern.replace('İ', 'I')
|
| 53 |
-
|
| 54 |
-
# Find all Product blocks with this name
|
| 55 |
-
# Using lazy quantifier .*? for efficiency
|
| 56 |
-
product_pattern = f'<Product>.*?<ProductName><!\\[CDATA\\[{re.escape(search_pattern)}\\]\\]></ProductName>.*?</Product>'
|
| 57 |
-
matches = re.findall(product_pattern, xml_text, re.DOTALL)
|
| 58 |
-
|
| 59 |
-
print(f"DEBUG - Found {len(matches)} products matching '{search_pattern}'")
|
| 60 |
-
|
| 61 |
-
# Process matches
|
| 62 |
-
warehouse_stock_map = {}
|
| 63 |
-
|
| 64 |
-
for match in matches:
|
| 65 |
-
# Extract variant if we need to filter by size
|
| 66 |
-
should_process = True
|
| 67 |
-
|
| 68 |
-
if variant_filter:
|
| 69 |
-
variant_match = re.search(r'<ProductVariant><!\\[CDATA\\[(.*?)\\]\\]></ProductVariant>', match)
|
| 70 |
-
if variant_match:
|
| 71 |
-
variant = variant_match.group(1)
|
| 72 |
-
size_wanted = variant_filter[0].upper()
|
| 73 |
-
|
| 74 |
-
# Check if variant starts with desired size
|
| 75 |
-
if variant.startswith(f'{size_wanted}-'):
|
| 76 |
-
print(f"DEBUG - Found matching variant: {variant}")
|
| 77 |
-
should_process = True
|
| 78 |
-
else:
|
| 79 |
-
should_process = False
|
| 80 |
-
else:
|
| 81 |
-
should_process = False
|
| 82 |
-
|
| 83 |
-
if should_process:
|
| 84 |
-
# Extract all warehouses with stock
|
| 85 |
-
warehouse_pattern = r'<Warehouse>.*?<Name><!\\[CDATA\\[(.*?)\\]\\]></Name>.*?<Stock>(.*?)</Stock>.*?</Warehouse>'
|
| 86 |
-
warehouses = re.findall(warehouse_pattern, match, re.DOTALL)
|
| 87 |
-
|
| 88 |
-
for wh_name, wh_stock in warehouses:
|
| 89 |
-
try:
|
| 90 |
-
stock = int(wh_stock.strip())
|
| 91 |
-
if stock > 0:
|
| 92 |
-
if wh_name in warehouse_stock_map:
|
| 93 |
-
warehouse_stock_map[wh_name] += stock
|
| 94 |
-
else:
|
| 95 |
-
warehouse_stock_map[wh_name] = stock
|
| 96 |
-
except:
|
| 97 |
-
pass
|
| 98 |
-
|
| 99 |
-
# If we found a variant match, stop looking
|
| 100 |
-
if variant_filter and should_process:
|
| 101 |
-
break
|
| 102 |
-
|
| 103 |
-
print(f"DEBUG - Warehouse stock: {warehouse_stock_map}")
|
| 104 |
-
|
| 105 |
-
# Format results
|
| 106 |
-
if warehouse_stock_map:
|
| 107 |
-
all_warehouse_info = []
|
| 108 |
-
for warehouse_name, total_stock in warehouse_stock_map.items():
|
| 109 |
-
# Make store names more readable
|
| 110 |
-
if "Caddebostan" in warehouse_name or "CADDEBOSTAN" in warehouse_name:
|
| 111 |
-
display_name = "Caddebostan mağazası"
|
| 112 |
-
elif "Ortaköy" in warehouse_name or "ORTAKÖY" in warehouse_name:
|
| 113 |
-
display_name = "Ortaköy mağazası"
|
| 114 |
-
elif "Sarıyer" in warehouse_name:
|
| 115 |
-
display_name = "Sarıyer mağazası"
|
| 116 |
-
elif "Alsancak" in warehouse_name or "ALSANCAK" in warehouse_name or "İzmir" in warehouse_name:
|
| 117 |
-
display_name = "İzmir Alsancak mağazası"
|
| 118 |
-
elif "BAHCEKOY" in warehouse_name or "Bahçeköy" in warehouse_name:
|
| 119 |
-
display_name = "Bahçeköy mağazası"
|
| 120 |
-
else:
|
| 121 |
-
display_name = warehouse_name
|
| 122 |
-
|
| 123 |
-
all_warehouse_info.append(f"{display_name}: Mevcut")
|
| 124 |
-
return all_warehouse_info
|
| 125 |
-
else:
|
| 126 |
-
return ["Hiçbir mağazada mevcut değil"]
|
| 127 |
-
|
| 128 |
-
except Exception as e:
|
| 129 |
-
print(f"Warehouse stock error: {e}")
|
| 130 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
image_renderer.py
DELETED
|
@@ -1,143 +0,0 @@
|
|
| 1 |
-
# -*- coding: utf-8 -*-
|
| 2 |
-
"""
|
| 3 |
-
Ürün Resimlerini Sohbette Gösterme Sistemi
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
def round_price(price_str):
|
| 7 |
-
"""Fiyatı yuvarlama formülüne göre yuvarla"""
|
| 8 |
-
try:
|
| 9 |
-
# TL ve diğer karakterleri temizle
|
| 10 |
-
price_clean = price_str.replace(' TL', '').replace(',', '.')
|
| 11 |
-
price_float = float(price_clean)
|
| 12 |
-
|
| 13 |
-
# Fiyat 200000 üzerindeyse en yakın 5000'lik basamağa yuvarla
|
| 14 |
-
if price_float > 200000:
|
| 15 |
-
return str(round(price_float / 5000) * 5000)
|
| 16 |
-
# Fiyat 30000 üzerindeyse en yakın 1000'lik basamağa yuvarla
|
| 17 |
-
elif price_float > 30000:
|
| 18 |
-
return str(round(price_float / 1000) * 1000)
|
| 19 |
-
# Fiyat 10000 üzerindeyse en yakın 100'lük basamağa yuvarla
|
| 20 |
-
elif price_float > 10000:
|
| 21 |
-
return str(round(price_float / 100) * 100)
|
| 22 |
-
# Diğer durumlarda en yakın 10'luk basamağa yuvarla
|
| 23 |
-
else:
|
| 24 |
-
return str(round(price_float / 10) * 10)
|
| 25 |
-
except (ValueError, TypeError):
|
| 26 |
-
return price_str
|
| 27 |
-
|
| 28 |
-
def format_message_with_images(message):
|
| 29 |
-
"""Mesajdaki resim URL'lerini HTML formatına çevir"""
|
| 30 |
-
if "Ürün resmi:" not in message:
|
| 31 |
-
return message
|
| 32 |
-
|
| 33 |
-
lines = message.split('\n')
|
| 34 |
-
formatted_lines = []
|
| 35 |
-
|
| 36 |
-
for line in lines:
|
| 37 |
-
if line.startswith("Ürün resmi:"):
|
| 38 |
-
image_url = line.replace("Ürün resmi:", "").strip()
|
| 39 |
-
if image_url:
|
| 40 |
-
# HTML img tag'i oluştur
|
| 41 |
-
img_html = f"""
|
| 42 |
-
<div style="margin: 10px 0;">
|
| 43 |
-
<img src="{image_url}"
|
| 44 |
-
alt="Ürün Resmi"
|
| 45 |
-
style="max-width: 300px; max-height: 200px; border-radius: 8px; border: 1px solid #ddd; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"
|
| 46 |
-
onerror="this.style.display='none'">
|
| 47 |
-
</div>"""
|
| 48 |
-
formatted_lines.append(img_html)
|
| 49 |
-
else:
|
| 50 |
-
formatted_lines.append(line)
|
| 51 |
-
else:
|
| 52 |
-
formatted_lines.append(line)
|
| 53 |
-
|
| 54 |
-
return '\n'.join(formatted_lines)
|
| 55 |
-
|
| 56 |
-
def create_product_gallery(products_with_images):
|
| 57 |
-
"""Birden fazla ürün için galeri oluştur"""
|
| 58 |
-
if not products_with_images:
|
| 59 |
-
return ""
|
| 60 |
-
|
| 61 |
-
gallery_html = """
|
| 62 |
-
<div style="display: flex; flex-wrap: wrap; gap: 15px; margin: 15px 0;">
|
| 63 |
-
"""
|
| 64 |
-
|
| 65 |
-
for product in products_with_images:
|
| 66 |
-
name = product.get('name', 'Bilinmeyen')
|
| 67 |
-
price = product.get('price', 'Fiyat yok')
|
| 68 |
-
image_url = product.get('image_url', '')
|
| 69 |
-
product_url = product.get('product_url', '')
|
| 70 |
-
|
| 71 |
-
if image_url:
|
| 72 |
-
card_html = f"""
|
| 73 |
-
<div style="border: 1px solid #ddd; border-radius: 8px; padding: 10px; max-width: 200px; text-align: center; background: white; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
| 74 |
-
<img src="{image_url}"
|
| 75 |
-
alt="{name}"
|
| 76 |
-
style="max-width: 180px; max-height: 120px; border-radius: 4px; margin-bottom: 8px;"
|
| 77 |
-
onerror="this.style.display='none'">
|
| 78 |
-
<div style="font-size: 12px; font-weight: bold; margin-bottom: 4px;">{name}</div>
|
| 79 |
-
<div style="font-size: 11px; color: #666; margin-bottom: 8px;">{price}</div>
|
| 80 |
-
{f'<a href="{product_url}" target="_blank" style="font-size: 10px; color: #007bff; text-decoration: none;">Detaylı Bilgi</a>' if product_url else ''}
|
| 81 |
-
</div>"""
|
| 82 |
-
gallery_html += card_html
|
| 83 |
-
|
| 84 |
-
gallery_html += "</div>"
|
| 85 |
-
return gallery_html
|
| 86 |
-
|
| 87 |
-
def extract_product_info_for_gallery(message):
|
| 88 |
-
"""Mesajdan ürün bilgilerini çıkarıp galeri formatına çevir"""
|
| 89 |
-
if "karşılaştır" in message.lower() or "öneri" in message.lower():
|
| 90 |
-
# Bu durumda galeri formatı kullan
|
| 91 |
-
lines = message.split('\n')
|
| 92 |
-
products = []
|
| 93 |
-
|
| 94 |
-
current_product = {}
|
| 95 |
-
for line in lines:
|
| 96 |
-
line = line.strip()
|
| 97 |
-
if line.startswith('•') and any(keyword in line.lower() for keyword in ['marlin', 'émonda', 'madone', 'domane', 'fuel', 'powerfly', 'fx']):
|
| 98 |
-
# Yeni ürün başladı
|
| 99 |
-
if current_product:
|
| 100 |
-
products.append(current_product)
|
| 101 |
-
|
| 102 |
-
# Ürün adı ve fiyatı parse et
|
| 103 |
-
parts = line.split(' - ')
|
| 104 |
-
name = parts[0].replace('•', '').strip()
|
| 105 |
-
price_raw = parts[1] if len(parts) > 1 else 'Fiyat yok'
|
| 106 |
-
|
| 107 |
-
# Fiyatı yuvarlama formülüne göre yuvarla
|
| 108 |
-
if price_raw != 'Fiyat yok':
|
| 109 |
-
price = round_price(price_raw) + ' TL'
|
| 110 |
-
else:
|
| 111 |
-
price = price_raw
|
| 112 |
-
|
| 113 |
-
current_product = {
|
| 114 |
-
'name': name,
|
| 115 |
-
'price': price,
|
| 116 |
-
'image_url': '',
|
| 117 |
-
'product_url': ''
|
| 118 |
-
}
|
| 119 |
-
elif "Ürün resmi:" in line and current_product:
|
| 120 |
-
current_product['image_url'] = line.replace("Ürün resmi:", "").strip()
|
| 121 |
-
elif "Ürün linki:" in line and current_product:
|
| 122 |
-
current_product['product_url'] = line.replace("Ür��n linki:", "").strip()
|
| 123 |
-
|
| 124 |
-
# Son ürünü ekle
|
| 125 |
-
if current_product:
|
| 126 |
-
products.append(current_product)
|
| 127 |
-
|
| 128 |
-
if products:
|
| 129 |
-
gallery = create_product_gallery(products)
|
| 130 |
-
# Orijinal mesajdaki resim linklerini temizle
|
| 131 |
-
cleaned_message = message
|
| 132 |
-
for line in message.split('\n'):
|
| 133 |
-
if line.startswith("Ürün resmi:") or line.startswith("Ürün linki:"):
|
| 134 |
-
cleaned_message = cleaned_message.replace(line, "")
|
| 135 |
-
|
| 136 |
-
return cleaned_message.strip() + "\n\n" + gallery
|
| 137 |
-
|
| 138 |
-
return format_message_with_images(message)
|
| 139 |
-
|
| 140 |
-
def should_use_gallery_format(message):
|
| 141 |
-
"""Mesajın galeri formatı kullanması gerekip gerekmediğini kontrol et"""
|
| 142 |
-
gallery_keywords = ["karşılaştır", "öneri", "seçenek", "alternatif", "bütçe"]
|
| 143 |
-
return any(keyword in message.lower() for keyword in gallery_keywords)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
product_index.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Trek katalog XML'ini bir kez parse edip hash index halinde tutar.
|
| 2 |
+
Tum lookup'lar O(1) — re-parse yok. Background refresh ve fetch lock'u var."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
import requests
|
| 12 |
+
import urllib3
|
| 13 |
+
|
| 14 |
+
from config import TREK_XML_URL, TREK_XML_TIMEOUT, CACHE_TTL_TREK_XML
|
| 15 |
+
|
| 16 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Turkce -> ASCII normalizasyon
|
| 21 |
+
_TR_MAP = {
|
| 22 |
+
"İ": "i", "I": "i", "ı": "i",
|
| 23 |
+
"Ğ": "g", "ğ": "g",
|
| 24 |
+
"Ü": "u", "ü": "u",
|
| 25 |
+
"Ş": "s", "ş": "s",
|
| 26 |
+
"Ö": "o", "ö": "o",
|
| 27 |
+
"Ç": "c", "ç": "c",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def normalize(s: str) -> str:
|
| 32 |
+
if not s:
|
| 33 |
+
return ""
|
| 34 |
+
for tr, en in _TR_MAP.items():
|
| 35 |
+
s = s.replace(tr, en)
|
| 36 |
+
return s.lower()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Pre-compiled regex'ler (modul yuklemesinde bir kez)
|
| 40 |
+
_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.DOTALL)
|
| 41 |
+
_VARIANT_LABEL_SEP = re.compile(r"\s*[-/]\s*")
|
| 42 |
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
| 43 |
+
_SIZE_PAT = re.compile(
|
| 44 |
+
r"^(?:XX?S|XS|S|M|L|XL|XXL|XXXL|\d{2}(?:\.\d)?(?:\s*CM)?)$", re.I
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_FIELD_RES = {
|
| 48 |
+
"rootlabel": re.compile(r"<rootlabel><!\[CDATA\[(.*?)\]\]></rootlabel>"),
|
| 49 |
+
"label": re.compile(r"<label><!\[CDATA\[(.*?)\]\]></label>"),
|
| 50 |
+
"productLink": re.compile(r"<productLink><!\[CDATA\[(.*?)\]\]></productLink>"),
|
| 51 |
+
"stockCode": re.compile(r"<stockCode><!\[CDATA\[(.*?)\]\]></stockCode>"),
|
| 52 |
+
"isOptionOfAProduct": re.compile(r"<isOptionOfAProduct>(\d+)</isOptionOfAProduct>"),
|
| 53 |
+
"rootProductStockCode": re.compile(
|
| 54 |
+
r"<rootProductStockCode><!\[CDATA\[(.*?)\]\]></rootProductStockCode>"
|
| 55 |
+
),
|
| 56 |
+
}
|
| 57 |
+
for i in range(1, 9):
|
| 58 |
+
_FIELD_RES[f"picture{i}"] = re.compile(
|
| 59 |
+
rf"<picture{i}Path><!\[CDATA\[(.*?)\]\]></picture{i}Path>"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _parse_item(it: str) -> dict:
|
| 64 |
+
"""Tek bir <item> blogunu dict'e cevir + arama icin token cache'i ekle."""
|
| 65 |
+
def grab(name: str) -> str:
|
| 66 |
+
m = _FIELD_RES[name].search(it)
|
| 67 |
+
return m.group(1).strip() if m else ""
|
| 68 |
+
|
| 69 |
+
rootlabel = grab("rootlabel")
|
| 70 |
+
var_label = grab("label")
|
| 71 |
+
link = grab("productLink")
|
| 72 |
+
sku = grab("stockCode")
|
| 73 |
+
iv_m = _FIELD_RES["isOptionOfAProduct"].search(it)
|
| 74 |
+
is_variant = bool(iv_m and iv_m.group(1) == "1")
|
| 75 |
+
root_sku_raw = grab("rootProductStockCode")
|
| 76 |
+
root_sku = root_sku_raw if root_sku_raw and root_sku_raw != "0" else None
|
| 77 |
+
|
| 78 |
+
images: list[str] = []
|
| 79 |
+
for i in range(1, 9):
|
| 80 |
+
m = _FIELD_RES[f"picture{i}"].search(it)
|
| 81 |
+
if m and m.group(1).strip():
|
| 82 |
+
images.append(m.group(1).strip())
|
| 83 |
+
|
| 84 |
+
color: str | None = None
|
| 85 |
+
size: str | None = None
|
| 86 |
+
if is_variant and var_label:
|
| 87 |
+
parts = [p.strip() for p in _VARIANT_LABEL_SEP.split(var_label) if p.strip()]
|
| 88 |
+
for p in parts:
|
| 89 |
+
if _SIZE_PAT.match(p) and not size:
|
| 90 |
+
size = p.upper()
|
| 91 |
+
elif not color:
|
| 92 |
+
color = p.upper()
|
| 93 |
+
|
| 94 |
+
label_norm = normalize(rootlabel)
|
| 95 |
+
tokens = [t for t in _TOKEN_RE.findall(label_norm) if len(t) >= 1]
|
| 96 |
+
|
| 97 |
+
return {
|
| 98 |
+
"name": rootlabel,
|
| 99 |
+
"image": images[0] if images else None,
|
| 100 |
+
"images": images,
|
| 101 |
+
"link": link,
|
| 102 |
+
"sku": sku,
|
| 103 |
+
"color": color,
|
| 104 |
+
"size": size,
|
| 105 |
+
"is_variant": is_variant,
|
| 106 |
+
"root_sku": root_sku,
|
| 107 |
+
"_tokens": tokens,
|
| 108 |
+
"_label_norm": label_norm,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def public_view(p: dict | None) -> dict | None:
|
| 113 |
+
"""Internal field'lari (_tokens, _label_norm) cikar — client'a gonderilebilir."""
|
| 114 |
+
if not p:
|
| 115 |
+
return None
|
| 116 |
+
return {k: v for k, v in p.items() if not k.startswith("_")}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class ProductIndex:
|
| 120 |
+
"""Thread-safe parse-once index. Re-parse sadece XML degisirse."""
|
| 121 |
+
|
| 122 |
+
def __init__(self) -> None:
|
| 123 |
+
self._lock = threading.Lock()
|
| 124 |
+
self._fetch_lock = threading.Lock()
|
| 125 |
+
self._xml_data: bytes | None = None
|
| 126 |
+
self._xml_time: float = 0
|
| 127 |
+
self._xml_id: int | None = None
|
| 128 |
+
self.products: list[dict] = []
|
| 129 |
+
self.by_link: dict[str, dict] = {}
|
| 130 |
+
self.by_sku: dict[str, dict] = {}
|
| 131 |
+
self.variants_by_root: dict[str, list[dict]] = {}
|
| 132 |
+
self.main_count: int = 0
|
| 133 |
+
|
| 134 |
+
# ---------- XML fetch (lock'lu, thundering herd onleyici) ----------
|
| 135 |
+
def _fetch_xml(self) -> bytes | None:
|
| 136 |
+
with self._fetch_lock:
|
| 137 |
+
now = time.time()
|
| 138 |
+
# Lock alindiginda baska bir thread fetch yapmis olabilir
|
| 139 |
+
if self._xml_data and (now - self._xml_time < CACHE_TTL_TREK_XML):
|
| 140 |
+
return self._xml_data
|
| 141 |
+
try:
|
| 142 |
+
r = requests.get(TREK_XML_URL, verify=False, timeout=TREK_XML_TIMEOUT)
|
| 143 |
+
if r.status_code == 200 and r.content:
|
| 144 |
+
self._xml_data = r.content
|
| 145 |
+
self._xml_time = now
|
| 146 |
+
logger.info(f"[index] fetched Trek XML ({len(r.content)} bytes)")
|
| 147 |
+
return r.content
|
| 148 |
+
except Exception:
|
| 149 |
+
logger.exception("[index] Trek XML fetch hatasi")
|
| 150 |
+
# Eski (stale) data varsa onu kullan
|
| 151 |
+
return self._xml_data
|
| 152 |
+
|
| 153 |
+
# ---------- Index build ----------
|
| 154 |
+
def _build(self, xml_bytes: bytes) -> None:
|
| 155 |
+
text = xml_bytes.decode("utf-8", errors="replace")
|
| 156 |
+
products: list[dict] = []
|
| 157 |
+
by_link: dict[str, dict] = {}
|
| 158 |
+
by_sku: dict[str, dict] = {}
|
| 159 |
+
variants_by_root: dict[str, list[dict]] = {}
|
| 160 |
+
main_count = 0
|
| 161 |
+
|
| 162 |
+
for it in _ITEM_RE.findall(text):
|
| 163 |
+
p = _parse_item(it)
|
| 164 |
+
products.append(p)
|
| 165 |
+
if p["link"]:
|
| 166 |
+
by_link[p["link"]] = p
|
| 167 |
+
if p["sku"]:
|
| 168 |
+
by_sku[p["sku"]] = p
|
| 169 |
+
if not p["is_variant"]:
|
| 170 |
+
main_count += 1
|
| 171 |
+
else:
|
| 172 |
+
if p["root_sku"]:
|
| 173 |
+
variants_by_root.setdefault(p["root_sku"], []).append(p)
|
| 174 |
+
|
| 175 |
+
with self._lock:
|
| 176 |
+
self.products = products
|
| 177 |
+
self.by_link = by_link
|
| 178 |
+
self.by_sku = by_sku
|
| 179 |
+
self.variants_by_root = variants_by_root
|
| 180 |
+
self.main_count = main_count
|
| 181 |
+
self._xml_id = id(xml_bytes)
|
| 182 |
+
|
| 183 |
+
logger.info(
|
| 184 |
+
f"[index] built: {len(products)} items, {main_count} main, "
|
| 185 |
+
f"{sum(len(v) for v in variants_by_root.values())} variants"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
def ensure(self) -> bool:
|
| 189 |
+
"""XML cache'ini guncelle ve index'i (gerekirse) yeniden build et.
|
| 190 |
+
True donerse data hazir. Sync, thread-safe."""
|
| 191 |
+
xml = self._fetch_xml()
|
| 192 |
+
if not xml:
|
| 193 |
+
return False
|
| 194 |
+
if id(xml) != self._xml_id:
|
| 195 |
+
self._build(xml)
|
| 196 |
+
return True
|
| 197 |
+
|
| 198 |
+
# ---------- Public lookups (sync, hizli) ----------
|
| 199 |
+
def find_by_link(self, link: str) -> dict | None:
|
| 200 |
+
"""productLink ile eslesen urun. Varyant ise ana urune cikar."""
|
| 201 |
+
if not link:
|
| 202 |
+
return None
|
| 203 |
+
with self._lock:
|
| 204 |
+
p = self.by_link.get(link.strip())
|
| 205 |
+
if not p:
|
| 206 |
+
return None
|
| 207 |
+
if p["is_variant"] and p["root_sku"]:
|
| 208 |
+
main = self.by_sku.get(p["root_sku"])
|
| 209 |
+
if main and not main["is_variant"]:
|
| 210 |
+
return public_view(main)
|
| 211 |
+
return public_view(p)
|
| 212 |
+
|
| 213 |
+
def variants_of(self, main_link: str) -> list[dict]:
|
| 214 |
+
"""Verilen ana urunun tum varyantlari."""
|
| 215 |
+
if not main_link:
|
| 216 |
+
return []
|
| 217 |
+
with self._lock:
|
| 218 |
+
main = self.by_link.get(main_link.strip())
|
| 219 |
+
if not main or main["is_variant"] or not main["sku"]:
|
| 220 |
+
return []
|
| 221 |
+
return [public_view(v) for v in self.variants_by_root.get(main["sku"], [])]
|
| 222 |
+
|
| 223 |
+
def snapshot(self) -> list[dict]:
|
| 224 |
+
"""Tum urunlerin internal-field'li listesi (matcher icin)."""
|
| 225 |
+
with self._lock:
|
| 226 |
+
return list(self.products)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# Singleton
|
| 230 |
+
_index = ProductIndex()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def get_index() -> ProductIndex:
|
| 234 |
+
return _index
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
async def background_refresh_loop(interval: int):
|
| 238 |
+
"""Periodic XML refresh in thread executor."""
|
| 239 |
+
while True:
|
| 240 |
+
try:
|
| 241 |
+
await asyncio.to_thread(_index.ensure)
|
| 242 |
+
except Exception:
|
| 243 |
+
logger.exception("background_refresh_loop hatasi")
|
| 244 |
+
await asyncio.sleep(interval)
|
product_matcher.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Urun eslestirme: local fuzzy matcher + opsiyonel gpt-5-nano fallback.
|
| 2 |
+
|
| 3 |
+
Tasarim:
|
| 4 |
+
- find_main_product_in_text(text): Metinde gecen ANA urunu bul.
|
| 5 |
+
ZORUNLU tokenlar (hepsi metinde olmalı):
|
| 6 |
+
* 4+ karakter alfa kelimeler (marlin, domane, emonda)
|
| 7 |
+
* 1-2 basamakli sayilar (model numaralari: 4, 5, 6)
|
| 8 |
+
Birden fazla urun gecerse: METINDE EN SON gecen kazanir.
|
| 9 |
+
- find_color_variant(main_link, text): Aktif ana urunun, kullanicinin metnindeki
|
| 10 |
+
renge uyan varyantini dondur. Resim variant'in, isim/link ana urunun.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
import requests
|
| 17 |
+
|
| 18 |
+
from config import OPENAI_API_KEY, MATCHER_MODEL, MATCHER_TIMEOUT, NANO_MODEL, NANO_TIMEOUT
|
| 19 |
+
from product_index import get_index, normalize, public_view
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Renk eşanlam haritası (TR/EN)
|
| 25 |
+
COLOR_SYNONYMS = {
|
| 26 |
+
"siyah": ["siyah", "black", "noir", "negro"],
|
| 27 |
+
"beyaz": ["beyaz", "white", "blanco"],
|
| 28 |
+
"mavi": ["mavi", "blue", "azul"],
|
| 29 |
+
"lacivert": ["lacivert", "navy"],
|
| 30 |
+
"kirmizi": ["kirmizi", "kırmızı", "red", "rojo"],
|
| 31 |
+
"yesil": ["yesil", "yeşil", "green", "verde"],
|
| 32 |
+
"sari": ["sari", "sarı", "yellow", "amarillo"],
|
| 33 |
+
"turuncu": ["turuncu", "orange", "naranja"],
|
| 34 |
+
"mor": ["mor", "purple"],
|
| 35 |
+
"pembe": ["pembe", "pink", "rosa"],
|
| 36 |
+
"gri": ["gri", "grey", "gray", "gris"],
|
| 37 |
+
"kahverengi": ["kahverengi", "brown", "marron"],
|
| 38 |
+
"altin": ["altin", "altın", "gold"],
|
| 39 |
+
"gumus": ["gumus", "gümüş", "silver"],
|
| 40 |
+
"bordo": ["bordo", "burgundy"],
|
| 41 |
+
"turkuaz": ["turkuaz", "turquoise"],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _detect_colors(text_norm: str) -> set[str]:
|
| 46 |
+
found: set[str] = set()
|
| 47 |
+
for key, syns in COLOR_SYNONYMS.items():
|
| 48 |
+
for s in syns:
|
| 49 |
+
if normalize(s) in text_norm:
|
| 50 |
+
found.add(key)
|
| 51 |
+
break
|
| 52 |
+
return found
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _is_distinctive(t: str) -> bool:
|
| 56 |
+
"""Token ZORUNLU mu? 4+ char alfa veya 1-2 basamakli sayi."""
|
| 57 |
+
return len(t) >= 4 or (t.isdigit() and 1 <= len(t) <= 2)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def find_main_product_in_text(text: str) -> dict | None:
|
| 61 |
+
"""Metinde gecen ANA urunu bul (varyantlar dahil edilmez).
|
| 62 |
+
Birden fazla aday varsa en son gecen secilir."""
|
| 63 |
+
if not text:
|
| 64 |
+
return None
|
| 65 |
+
idx = get_index()
|
| 66 |
+
if not idx.ensure():
|
| 67 |
+
return None
|
| 68 |
+
text_norm = normalize(text)
|
| 69 |
+
|
| 70 |
+
best = None
|
| 71 |
+
best_last_pos = -1
|
| 72 |
+
for p in idx.snapshot():
|
| 73 |
+
if p["is_variant"]:
|
| 74 |
+
continue
|
| 75 |
+
tokens = p.get("_tokens") or []
|
| 76 |
+
mandatory = [t for t in tokens if _is_distinctive(t)]
|
| 77 |
+
if not mandatory:
|
| 78 |
+
continue
|
| 79 |
+
positions = []
|
| 80 |
+
ok = True
|
| 81 |
+
for t in mandatory:
|
| 82 |
+
pos = text_norm.rfind(t)
|
| 83 |
+
if pos < 0:
|
| 84 |
+
ok = False
|
| 85 |
+
break
|
| 86 |
+
positions.append(pos)
|
| 87 |
+
if not ok:
|
| 88 |
+
continue
|
| 89 |
+
last_pos = max(positions)
|
| 90 |
+
if last_pos > best_last_pos:
|
| 91 |
+
best_last_pos = last_pos
|
| 92 |
+
best = p
|
| 93 |
+
return public_view(best)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def find_color_variant(main_link: str, text: str) -> dict | None:
|
| 97 |
+
"""Aktif ana urunun, metinde gecen renge uyan varyantini bul.
|
| 98 |
+
DONUS: variant resmi/galeri'si + ana urunun ismi/linki (boy bilgisi yok)."""
|
| 99 |
+
if not main_link or not text:
|
| 100 |
+
return None
|
| 101 |
+
idx = get_index()
|
| 102 |
+
if not idx.ensure():
|
| 103 |
+
return None
|
| 104 |
+
text_norm = normalize(text)
|
| 105 |
+
text_colors = _detect_colors(text_norm)
|
| 106 |
+
if not text_colors:
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
# Ana urunu bul
|
| 110 |
+
main_view = idx.find_by_link(main_link)
|
| 111 |
+
if not main_view:
|
| 112 |
+
return None
|
| 113 |
+
variants = idx.variants_of(main_link)
|
| 114 |
+
if not variants:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
for v in variants:
|
| 118 |
+
v_color = (v.get("color") or "").lower()
|
| 119 |
+
if not v_color:
|
| 120 |
+
continue
|
| 121 |
+
v_color_norm = normalize(v_color)
|
| 122 |
+
for key, syns in COLOR_SYNONYMS.items():
|
| 123 |
+
if key in text_colors and any(normalize(s) in v_color_norm for s in syns):
|
| 124 |
+
# Resim variant'tan, isim/link ana urunden
|
| 125 |
+
return {
|
| 126 |
+
"name": main_view["name"],
|
| 127 |
+
"image": v.get("image"),
|
| 128 |
+
"images": v.get("images") or [],
|
| 129 |
+
"link": main_view["link"],
|
| 130 |
+
"sku": main_view["sku"],
|
| 131 |
+
"color": (v.get("color") or "").upper(),
|
| 132 |
+
"size": None,
|
| 133 |
+
"is_variant": False,
|
| 134 |
+
}
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def extract_product_link_from_text(text: str) -> str | None:
|
| 139 |
+
"""Tool sonucundan veya başka metinden ilk Trek productLink URL'sini cikar."""
|
| 140 |
+
if not text:
|
| 141 |
+
return None
|
| 142 |
+
import re
|
| 143 |
+
m = re.search(r"https?://(?:www\.)?trekbisiklet\.com\.tr/urun/[^\s)\]\'\"]+", text)
|
| 144 |
+
return m.group(0) if m else None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def gpt_match(query: str, products_summary: list[dict], asked_warehouse: str | None = None) -> str:
|
| 148 |
+
"""V1'deki gpt-5.5 fuzzy matcher (primary). Yazim hatasi, sira farki, semantik
|
| 149 |
+
eslesme yapar. Donus: virgulle ayrilmis index string'i ('5,12') veya '-1'."""
|
| 150 |
+
import json as _json
|
| 151 |
+
if not OPENAI_API_KEY or not products_summary:
|
| 152 |
+
return "-1"
|
| 153 |
+
|
| 154 |
+
warehouse_filter = ""
|
| 155 |
+
if asked_warehouse:
|
| 156 |
+
warehouse_filter = (
|
| 157 |
+
f"\nIMPORTANT: User is asking specifically about {asked_warehouse} warehouse. "
|
| 158 |
+
f"Only return products available in that warehouse."
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
smart_prompt = f"""User is asking: "{query}"
|
| 162 |
+
|
| 163 |
+
FIRST CHECK: Is this actually a product search?
|
| 164 |
+
- If the message is a question about the system, service, or a general inquiry, return: -1
|
| 165 |
+
- If the message contains "musun", "misin", "neden", "nasıl", etc. it's likely NOT a product search
|
| 166 |
+
- Only proceed if this looks like a genuine product name or model
|
| 167 |
+
|
| 168 |
+
Find ALL products that match this query from the list below.
|
| 169 |
+
If user asks about specific size (S, M, L, XL, XXL, SMALL, MEDIUM, LARGE, X-LARGE), return only that size.
|
| 170 |
+
If user asks generally (without size), return ALL variants of the product.
|
| 171 |
+
{warehouse_filter}
|
| 172 |
+
|
| 173 |
+
CRITICAL TURKISH CHARACTER RULES:
|
| 174 |
+
- "MARLIN" and "MARLİN" are the SAME product (Turkish İ vs I)
|
| 175 |
+
- Treat these as equivalent: I/İ/ı, Ö/ö, Ü/ü, Ş/ş, Ğ/ğ, Ç/ç
|
| 176 |
+
|
| 177 |
+
IMPORTANT BRAND AND PRODUCT TYPE RULES:
|
| 178 |
+
- GOBIK: Spanish textile brand we import. When user asks about "gobik", return ALL products with "GOBIK" in the name.
|
| 179 |
+
- Product names contain type information: FORMA (jersey/cycling shirt), TAYT (tights), İÇLİK (base layer), YAĞMURLUK (raincoat), etc.
|
| 180 |
+
- Understand Turkish/English terms:
|
| 181 |
+
* "erkek forma" / "men's jersey" -> Find products with FORMA in name
|
| 182 |
+
* "tayt" / "tights" -> Find products with TAYT in name
|
| 183 |
+
* "içlik" / "base layer" -> Find products with İÇLİK in name
|
| 184 |
+
* "yağmurluk" / "raincoat" -> Find products with YAĞMURLUK in name
|
| 185 |
+
- Gender: UNISEX means for both men and women.
|
| 186 |
+
|
| 187 |
+
Products list (with warehouse availability):
|
| 188 |
+
{_json.dumps(products_summary, ensure_ascii=False, indent=2)}
|
| 189 |
+
|
| 190 |
+
Return ONLY index numbers of ALL matching products as comma-separated list (e.g., "5,8,12,15").
|
| 191 |
+
If no products found, return ONLY: -1
|
| 192 |
+
DO NOT return empty string or any explanation, ONLY numbers or -1
|
| 193 |
+
"""
|
| 194 |
+
|
| 195 |
+
try:
|
| 196 |
+
r = requests.post(
|
| 197 |
+
"https://api.openai.com/v1/chat/completions",
|
| 198 |
+
headers={
|
| 199 |
+
"Content-Type": "application/json",
|
| 200 |
+
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
| 201 |
+
},
|
| 202 |
+
json={
|
| 203 |
+
"model": MATCHER_MODEL,
|
| 204 |
+
"messages": [
|
| 205 |
+
{"role": "system", "content": "You are a product matcher. Find ALL matching products. Return only index numbers."},
|
| 206 |
+
{"role": "user", "content": smart_prompt},
|
| 207 |
+
],
|
| 208 |
+
},
|
| 209 |
+
timeout=MATCHER_TIMEOUT,
|
| 210 |
+
)
|
| 211 |
+
if r.status_code == 200:
|
| 212 |
+
return r.json()["choices"][0]["message"]["content"].strip()
|
| 213 |
+
logger.warning(f"[matcher/gpt] HTTP {r.status_code}: {r.text[:200]}")
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logger.warning(f"[matcher/gpt] fail: {e}")
|
| 216 |
+
return "-1"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def nano_fallback_match(query: str, products_summary: list[dict]) -> str:
|
| 220 |
+
"""Local matcher hicbir sey bulamazsa, gpt-5-nano'ya kucuk bir liste gonder.
|
| 221 |
+
products_summary: [{i: int, n: name, v: variant}, ...]
|
| 222 |
+
Donus: virgulle ayrilmis index string'i ('5,12') veya '-1'."""
|
| 223 |
+
if not OPENAI_API_KEY or not products_summary:
|
| 224 |
+
return "-1"
|
| 225 |
+
import json as _json
|
| 226 |
+
try:
|
| 227 |
+
items_min = [
|
| 228 |
+
{"i": p.get("i", p.get("index")), "n": p.get("n", p.get("name", "")),
|
| 229 |
+
"v": p.get("v", p.get("variant", ""))}
|
| 230 |
+
for p in products_summary
|
| 231 |
+
]
|
| 232 |
+
prompt = (
|
| 233 |
+
f'Soru: "{query}"\n'
|
| 234 |
+
'Asagidaki listeden eslesen tum urunlerin "i" indekslerini '
|
| 235 |
+
'virgulle ayrilmis sekilde dondur (orn. "5,12"). Eslesme yoksa "-1".\n'
|
| 236 |
+
f"Liste: {_json.dumps(items_min, ensure_ascii=False)}"
|
| 237 |
+
)
|
| 238 |
+
r = requests.post(
|
| 239 |
+
"https://api.openai.com/v1/chat/completions",
|
| 240 |
+
headers={
|
| 241 |
+
"Content-Type": "application/json",
|
| 242 |
+
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
| 243 |
+
},
|
| 244 |
+
json={
|
| 245 |
+
"model": NANO_MODEL,
|
| 246 |
+
"messages": [
|
| 247 |
+
{"role": "system", "content": "Sadece sayi listesi don, baska hicbir sey yazma."},
|
| 248 |
+
{"role": "user", "content": prompt},
|
| 249 |
+
],
|
| 250 |
+
},
|
| 251 |
+
timeout=NANO_TIMEOUT,
|
| 252 |
+
)
|
| 253 |
+
if r.status_code == 200:
|
| 254 |
+
return r.json()["choices"][0]["message"]["content"].strip()
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.warning(f"[matcher/nano] fail: {e}")
|
| 257 |
+
return "-1"
|
prompts.py
CHANGED
|
@@ -114,6 +114,66 @@ SYSTEM_PROMPTS = [
|
|
| 114 |
"content": "EKİM KAMPANYASI (1-31 Ekim 2025):\nTrek Bicycle Turkey, Ekim ayı boyunca seçili bisiklet modellerinde vade farksız 8 taksit imkanı sunmaktadır.\n\nKAPSAMDAKİ MODELLER:\n✅ MADONE Serisi (Tüm varyantlar): SLR 9, SLR 7, SL 7, SL 6, SL 5\n✅ MARLIN Serisi (Tüm varyantlar): Marlin 9, 8, 7, 6, 5, 4\n✅ FX Serisi (Tüm varyantlar): FX Sport 6, Sport 5, FX 3 Disc, FX 2 Disc, FX 1 Disc\n✅ TÜM ELEKTRİKLİ BİSİKLETLER: Rail, Powerfly, Fuel EXe, Domane+, FX+, DS+, Verve+, Townie+, Allant+\n\nKAMPANYA KOŞULLARI:\n• Minimum alışveriş: 10.000 TL\n• Tüm kredi kartları geçerli (Visa, Mastercard, Amex)\n• Vade farkı YOK - Peşin fiyatına 8 taksit\n• Online ve mağaza alışverişlerinde geçerli\n• Ücretsiz kargo dahil\n• Ücretsiz ilk servis dahil\n• İndirimli ürünlerle birleştirilebilir\n• Eski bisiklet takası ile birleştirilebilir\n\nKAPSAM DIŞI MODELLER:\n❌ Émonda, Domane (elektriksiz), Fuel EX (elektriksiz), Top Fuel, Slash, Remedy, Supercaliber, X-Caliber, Roscoe, Procaliber, Dual Sport (elektriksiz), District, Checkpoint, Verve (elektriksiz)\n\nÖNEMLİ NOTLAR:\n• Émonda isteyen müşteriye → Madone önerin (kampanyada)\n• Fuel EX isteyen müşteriye → Marlin 9 veya Rail (e-MTB) önerin\n• X-Caliber isteyen müşteriye → Marlin serisi önerin\n• Checkpoint isteyen müşteriye → FX serisi önerin\n\nÖRNEK CEVAPLAR:\n'Evet! Ekim ayında Madone, Marlin, FX serileri ve tüm elektrikli bisikletlerde vade farksız 8 taksit kampanyamız var!'\n'Madone SL 6 kampanyaya dahil! Vade farksız 8 taksit ile alabilirsiniz.'\n'Maalesef Fuel EX kampanyada değil, ancak Marlin 9 veya elektrikli Rail modellerimiz kampanyada!'\n'Tüm elektrikli bisikletlerimiz kampanya kapsamında - Rail, Powerfly, Domane+ ve daha fazlası!'"
|
| 115 |
},
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
# 16. HATA ÖNLEMLERİ VE KONUŞMA REHBERİ
|
| 118 |
{
|
| 119 |
"role": "system",
|
|
|
|
| 114 |
"content": "EKİM KAMPANYASI (1-31 Ekim 2025):\nTrek Bicycle Turkey, Ekim ayı boyunca seçili bisiklet modellerinde vade farksız 8 taksit imkanı sunmaktadır.\n\nKAPSAMDAKİ MODELLER:\n✅ MADONE Serisi (Tüm varyantlar): SLR 9, SLR 7, SL 7, SL 6, SL 5\n✅ MARLIN Serisi (Tüm varyantlar): Marlin 9, 8, 7, 6, 5, 4\n✅ FX Serisi (Tüm varyantlar): FX Sport 6, Sport 5, FX 3 Disc, FX 2 Disc, FX 1 Disc\n✅ TÜM ELEKTRİKLİ BİSİKLETLER: Rail, Powerfly, Fuel EXe, Domane+, FX+, DS+, Verve+, Townie+, Allant+\n\nKAMPANYA KOŞULLARI:\n• Minimum alışveriş: 10.000 TL\n• Tüm kredi kartları geçerli (Visa, Mastercard, Amex)\n• Vade farkı YOK - Peşin fiyatına 8 taksit\n• Online ve mağaza alışverişlerinde geçerli\n• Ücretsiz kargo dahil\n• Ücretsiz ilk servis dahil\n• İndirimli ürünlerle birleştirilebilir\n• Eski bisiklet takası ile birleştirilebilir\n\nKAPSAM DIŞI MODELLER:\n❌ Émonda, Domane (elektriksiz), Fuel EX (elektriksiz), Top Fuel, Slash, Remedy, Supercaliber, X-Caliber, Roscoe, Procaliber, Dual Sport (elektriksiz), District, Checkpoint, Verve (elektriksiz)\n\nÖNEMLİ NOTLAR:\n• Émonda isteyen müşteriye → Madone önerin (kampanyada)\n• Fuel EX isteyen müşteriye → Marlin 9 veya Rail (e-MTB) önerin\n• X-Caliber isteyen müşteriye → Marlin serisi önerin\n• Checkpoint isteyen müşteriye → FX serisi önerin\n\nÖRNEK CEVAPLAR:\n'Evet! Ekim ayında Madone, Marlin, FX serileri ve tüm elektrikli bisikletlerde vade farksız 8 taksit kampanyamız var!'\n'Madone SL 6 kampanyaya dahil! Vade farksız 8 taksit ile alabilirsiniz.'\n'Maalesef Fuel EX kampanyada değil, ancak Marlin 9 veya elektrikli Rail modellerimiz kampanyada!'\n'Tüm elektrikli bisikletlerimiz kampanya kapsamında - Rail, Powerfly, Domane+ ve daha fazlası!'"
|
| 115 |
},
|
| 116 |
|
| 117 |
+
# 19. JANT FİYAT KURALI
|
| 118 |
+
{
|
| 119 |
+
"role": "system",
|
| 120 |
+
"category": "rim_pricing",
|
| 121 |
+
"content": (
|
| 122 |
+
"JANT FİYAT KURALI:\n"
|
| 123 |
+
"Bontrager jantları ÖN ve ARKA olarak AYRI AYRI satılır ve fiyatlanır. "
|
| 124 |
+
"Listeden gelen fiyat TEK JANT fiyatıdır (çift değil).\n"
|
| 125 |
+
"\n"
|
| 126 |
+
"Müşteriye fiyat verirken DAİMA ön mü arka mı belirt:\n"
|
| 127 |
+
"• Doğru: 'Aeolus Pro 51 ön jant 18.000 TL, arka jant 22.000 TL.'\n"
|
| 128 |
+
"• Doğru: 'Tek jant fiyatı 20.000 TL — ön ve arkayı ayrı satıyoruz.'\n"
|
| 129 |
+
"• Yanlış: 'Aeolus Pro 51 jant 20.000 TL.' (eksik bilgi, müşteri çift sanır)\n"
|
| 130 |
+
"\n"
|
| 131 |
+
"Eğer ürün adında ön/arka belirtilmediyse, müşteriye 'ön mü arka mı?' "
|
| 132 |
+
"diye sormak yerine ikisinin de fiyatını ver."
|
| 133 |
+
)
|
| 134 |
+
},
|
| 135 |
+
|
| 136 |
+
# 17. ÜRÜN ARAMA / TOOL ÇAĞRI KURALLARI (kritik)
|
| 137 |
+
{
|
| 138 |
+
"role": "system",
|
| 139 |
+
"category": "tool_search_rules",
|
| 140 |
+
"content": (
|
| 141 |
+
"ÜRÜN ARAMA KURALI (her sorguda uyulacak):\n"
|
| 142 |
+
"Bir ürünün adını söyleyeceğin/önereceğin/sorgulayacağın zaman, "
|
| 143 |
+
"DAİMA tam ürün adını kullan ve mutlaka KATEGORİ ekle.\n"
|
| 144 |
+
"\n"
|
| 145 |
+
"• 'Comp' DEĞİL → 'Bontrager Comp sele'\n"
|
| 146 |
+
"• 'Pro' DEĞİL → 'Bontrager Aeolus Pro sele'\n"
|
| 147 |
+
"• 'XR4' DEĞİL → 'Bontrager XR4 Team Issue lastik'\n"
|
| 148 |
+
"• 'Elite' DEĞİL → 'Bontrager Elite Aero gidon'\n"
|
| 149 |
+
"• 'Aeolus 51' DEĞİL → 'Bontrager Aeolus Pro 51 jant'\n"
|
| 150 |
+
"• 'Line' DEĞİL → 'Bontrager Line Elite pedal'\n"
|
| 151 |
+
"\n"
|
| 152 |
+
"Format: MARKA + MODEL + KATEGORİ\n"
|
| 153 |
+
"Geçerli kategoriler: bisiklet, sele, kask, far, gidon, jant, pedal, "
|
| 154 |
+
"lastik, fren, vites, zincir, eldiven, jersey, forma, tayt, içlik, "
|
| 155 |
+
"şort, ayakkabı, çanta, suluk, gözlük.\n"
|
| 156 |
+
"\n"
|
| 157 |
+
"Bu kurala ASLA istisna yapma. Kısa adla sorgu yaparsan yanlış ürün "
|
| 158 |
+
"bulunur ve müşteriye yanlış bilgi/görsel gösterilir."
|
| 159 |
+
)
|
| 160 |
+
},
|
| 161 |
+
|
| 162 |
+
# 18. TELAFFUZ KURALLARI (kritik — sesli asistan icin)
|
| 163 |
+
{
|
| 164 |
+
"role": "system",
|
| 165 |
+
"category": "pronunciation",
|
| 166 |
+
"content": (
|
| 167 |
+
"Pronunciation rules:\n"
|
| 168 |
+
"- The store name \"Caddebostan\" is Turkish.\n"
|
| 169 |
+
"- Always pronounce it as \"Jad-de-bos-tan\".\n"
|
| 170 |
+
"- Never pronounce the first C as K.\n"
|
| 171 |
+
"- In Turkish, the letter C sounds like the English \"J\" in \"jam\".\n"
|
| 172 |
+
"- If speaking Turkish, say: \"Caddebostan mağazamız\" naturally as "
|
| 173 |
+
"\"Jadde-bostan mağazamız\"."
|
| 174 |
+
)
|
| 175 |
+
},
|
| 176 |
+
|
| 177 |
# 16. HATA ÖNLEMLERİ VE KONUŞMA REHBERİ
|
| 178 |
{
|
| 179 |
"role": "system",
|
realtime_relay.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI Realtime API <-> Browser WebSocket relay.
|
| 2 |
+
|
| 3 |
+
Akis:
|
| 4 |
+
1. Browser baglanir
|
| 5 |
+
2. OpenAI Realtime'a baglanip session config gonderir
|
| 6 |
+
3. Iki yonlu mesaj relay'i:
|
| 7 |
+
- Browser -> OpenAI: PCM audio + control
|
| 8 |
+
- OpenAI -> Browser: audio chunks + transcript + tool calls + custom events
|
| 9 |
+
4. Tool call'lari yakala, thread'de calistir, sonucu OpenAI'ye geri gonder
|
| 10 |
+
5. Asistan transkripti + kullanici intent'iyle urun gorsellerini live update et
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import asyncio
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
import websockets
|
| 19 |
+
from websockets.asyncio.client import connect as ws_connect
|
| 20 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 21 |
+
|
| 22 |
+
from config import OPENAI_API_KEY, REALTIME_MODEL, REALTIME_URL
|
| 23 |
+
from product_matcher import (
|
| 24 |
+
extract_product_link_from_text,
|
| 25 |
+
find_color_variant,
|
| 26 |
+
find_main_product_in_text,
|
| 27 |
+
)
|
| 28 |
+
from product_index import get_index
|
| 29 |
+
from prompts import get_active_prompt_content_only
|
| 30 |
+
from tools import TOOLS, handle_tool_call_sync, strip_urls
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
VOICE_ADDON = (
|
| 36 |
+
"\n\nGORSEL DESTEK (onemli):\n"
|
| 37 |
+
"- Musterinin ekraninin sag tarafinda urun gorseli OTOMATIK olarak gosteriliyor.\n"
|
| 38 |
+
"- Bahsettigin urunun resmi sag tarafta belirir; renk sorulursa o varyantin gorseline gecer.\n"
|
| 39 |
+
"- 'Gorsel paylasiyorum', 'resmini atiyorum', 'linki gonderiyorum' gibi cumleler kurma.\n"
|
| 40 |
+
"- 'Sagda gorebilirsiniz' gibi kisa referanslar verebilirsin.\n"
|
| 41 |
+
"\nGORSEL ICIN TOOL CAGRISI (kritik):\n"
|
| 42 |
+
"- HER spesifik urun adi soyleyecegin ZAMAN — bisiklet, sele, kask, far, jersey, jant, "
|
| 43 |
+
"gidon, pedal, lastik, fren, vites, zincir, kask, eldiven, ne olursa olsun — daha "
|
| 44 |
+
"cevaba baslamadan once get_warehouse_stock fonksiyonunu o urun adiyla CAGIR.\n"
|
| 45 |
+
"- Ornekler: 'Ion Pro RT', 'Madone SL 7', 'Marlin 5', 'Aeolus RSL sele', 'Comp sele', "
|
| 46 |
+
"'Velocis kask', 'Gobik forma', 'Aeolus Pro 51 jant', 'XR4 Team Issue lastik', "
|
| 47 |
+
"'Elite Aero gidon', 'Line Elite pedal'... HEPSI tool cagrisi gerektirir.\n"
|
| 48 |
+
"- Onceki cevapta bahsedilmis olsa bile, YENI bir model ismi soyleyince TEKRAR cagir.\n"
|
| 49 |
+
"- Tek istisna: marka/kategori adi (Trek, Bontrager, MTB, sele, kask gibi jenerik kelime).\n"
|
| 50 |
+
"- COK ONEMLI — TOOL'A TAM URUN ADI VER (kategori dahil):\n"
|
| 51 |
+
" * 'Comp' degil 'Bontrager Comp sele'\n"
|
| 52 |
+
" * 'Pro' degil 'Bontrager Aeolus Pro sele'\n"
|
| 53 |
+
" * 'XR4' degil 'Bontrager XR4 Team Issue lastik'\n"
|
| 54 |
+
" * 'Elite' degil 'Bontrager Elite Aero gidon'\n"
|
| 55 |
+
" * 'Line' degil 'Bontrager Line Elite pedal'\n"
|
| 56 |
+
" Yani: marka + model + KATEGORI ekle (sele, jant, gidon, pedal, lastik, kask...). "
|
| 57 |
+
"Kisa/eksik adla cagrilirsa fuzzy matcher YANLIS urun bulabilir.\n"
|
| 58 |
+
"\nSESLI SOHBET KURALLARI (cok onemli):\n"
|
| 59 |
+
"- Hedef: TAM 2 cumle. Asla 2'den fazla cumle KURMA. 3. cumle YASAK.\n"
|
| 60 |
+
"- Bilgi 1 cumlede sigarsa 1 cumle yeterli. Sigmazsa 2 cumle. Daha fazlasi yok.\n"
|
| 61 |
+
"- IFADELERINI CESITLENDIR. Ayni kalipta cumle kurma — her cevapta farkli kelime ve yapi sec.\n"
|
| 62 |
+
"- Sadece SORULANA dogrudan cevap ver. Ekstra oneri/yonlendirme/tesvik EKLEME.\n"
|
| 63 |
+
"- KESINLIKLE YASAK kapanis/dolgu cumleleri (cevabini bunlarla UZATMA, son sozun bunlar OLMASIN):\n"
|
| 64 |
+
" * 'magazalarimizda inceleyebilirsiniz', 'magazada gorebilirsiniz', 'magazada deneyebilirsiniz'\n"
|
| 65 |
+
" * 'yardimci olabilir miyim', 'baska sorunuz var mi', 'baska bir sey...'\n"
|
| 66 |
+
" * 'detayli bilgi icin', 'isterseniz', 'dilerseniz', 'arzu ederseniz'\n"
|
| 67 |
+
" * 'bizi tercih ettiginiz icin', 'iyi gunler', 'kolay gelsin'\n"
|
| 68 |
+
" Cevabi bilgi ile bitir, ek cumle EKLEME. Sustugun an iletisim biter.\n"
|
| 69 |
+
"- Markdown, * veya emoji KULLANMA.\n"
|
| 70 |
+
"- URL/link/web adresi ASLA SOYLEME. 'www', 'trekbisiklet.com', 'https' gibi adresleri "
|
| 71 |
+
"hicbir sekilde sesli okuma. 'Linki paylasiyorum', 'sitede goreceksiniz' gibi link "
|
| 72 |
+
"referanslari da YASAK.\n"
|
| 73 |
+
"- HER ZAMAN 'siz' ile hitap et, soru ile bitirme.\n"
|
| 74 |
+
"- Stok/fiyat sorulari icin get_warehouse_stock fonksiyonunu cagir, sonucu OZUN tek cumlede ver.\n"
|
| 75 |
+
"\n"
|
| 76 |
+
"STOK ADEDI KURALI (kritik):\n"
|
| 77 |
+
"- Stok ADEDINI / SAYISINI ASLA SOYLEME.\n"
|
| 78 |
+
"- Sadece 'mevcut', 'stokta var', 'bulunmuyor' veya 'tukenmis' gibi durum bildir.\n"
|
| 79 |
+
"- Adet sorulsa bile 'detayli adet bilgisi icin magazayla teyit' deyip gec.\n"
|
| 80 |
+
"\n"
|
| 81 |
+
"STOK SORGUSU AKISI:\n"
|
| 82 |
+
"- get_warehouse_stock cagrisi 2-3 sn surebilir. Once KISA bir bekleme cumlesi soyle, "
|
| 83 |
+
"SONRA fonksiyonu cagir. Boylece musteri sessizlik yasamaz.\n"
|
| 84 |
+
"- Bekleme cumlesi ornekleri (cesitlendir, hep ayni demeyi): "
|
| 85 |
+
"'Bir saniye, bakiyorum.' / 'Hemen kontrol ediyorum.' / 'Bakiyorum efendim.' / "
|
| 86 |
+
"'Stoga bakiyorum.' / 'Birsaniyenize.'\n"
|
| 87 |
+
"- Sonuc gelince direkt cevaba gec; 'kontrol ettim' gibi tekrar cumlesi YOK.\n"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_session_instructions() -> str:
|
| 92 |
+
try:
|
| 93 |
+
base = get_active_prompt_content_only()
|
| 94 |
+
if isinstance(base, list):
|
| 95 |
+
base = "\n\n".join(str(p) for p in base)
|
| 96 |
+
except Exception:
|
| 97 |
+
logger.exception("Prompt yuklenemedi")
|
| 98 |
+
base = "Trek Bisiklet uzmani bir satis temsilcisisin."
|
| 99 |
+
return base + VOICE_ADDON
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _session_update_payload() -> dict:
|
| 103 |
+
return {
|
| 104 |
+
"type": "session.update",
|
| 105 |
+
"session": {
|
| 106 |
+
"type": "realtime",
|
| 107 |
+
"model": REALTIME_MODEL,
|
| 108 |
+
"instructions": build_session_instructions(),
|
| 109 |
+
"output_modalities": ["audio"],
|
| 110 |
+
"audio": {
|
| 111 |
+
"input": {
|
| 112 |
+
"format": {"type": "audio/pcm", "rate": 24000},
|
| 113 |
+
"transcription": {"model": "whisper-1"},
|
| 114 |
+
"turn_detection": {
|
| 115 |
+
"type": "server_vad",
|
| 116 |
+
"threshold": 0.5,
|
| 117 |
+
"prefix_padding_ms": 300,
|
| 118 |
+
"silence_duration_ms": 700,
|
| 119 |
+
"interrupt_response": False,
|
| 120 |
+
"create_response": True,
|
| 121 |
+
},
|
| 122 |
+
},
|
| 123 |
+
"output": {
|
| 124 |
+
"format": {"type": "audio/pcm", "rate": 24000},
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
+
"tools": TOOLS,
|
| 128 |
+
"tool_choice": "auto",
|
| 129 |
+
},
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def realtime_relay(client_ws: WebSocket):
|
| 134 |
+
"""FastAPI WebSocket endpoint handler."""
|
| 135 |
+
await client_ws.accept()
|
| 136 |
+
|
| 137 |
+
if not OPENAI_API_KEY:
|
| 138 |
+
await client_ws.send_text(json.dumps({
|
| 139 |
+
"type": "error",
|
| 140 |
+
"error": {"message": "OPENAI_API_KEY tanimli degil."},
|
| 141 |
+
}))
|
| 142 |
+
await client_ws.close()
|
| 143 |
+
return
|
| 144 |
+
|
| 145 |
+
# Pre-warm index (varsa cache'den hizli, yoksa fetch)
|
| 146 |
+
try:
|
| 147 |
+
await asyncio.to_thread(get_index().ensure)
|
| 148 |
+
except Exception:
|
| 149 |
+
logger.exception("index pre-warm hatasi")
|
| 150 |
+
|
| 151 |
+
headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
|
| 152 |
+
|
| 153 |
+
# Session state
|
| 154 |
+
state = {
|
| 155 |
+
"text": "", # asistan response transkripti
|
| 156 |
+
"tool_link": None, # bu turn'de tool ile gosterilen urun linki
|
| 157 |
+
"current_main_link": None, # ekrandaki ana urun linki
|
| 158 |
+
"last_shown_in_response": None, # bu response'da en son gosterilen
|
| 159 |
+
"last_check_len": 0, # live detection throttle
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
async with ws_connect(REALTIME_URL, additional_headers=headers) as openai_ws:
|
| 164 |
+
logger.info("OpenAI Realtime baglantisi kuruldu")
|
| 165 |
+
await openai_ws.send(json.dumps(_session_update_payload()))
|
| 166 |
+
|
| 167 |
+
async def client_to_openai():
|
| 168 |
+
try:
|
| 169 |
+
while True:
|
| 170 |
+
msg = await client_ws.receive_text()
|
| 171 |
+
await openai_ws.send(msg)
|
| 172 |
+
except WebSocketDisconnect:
|
| 173 |
+
logger.info("Client disconnected")
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logger.error(f"client_to_openai error: {e}")
|
| 176 |
+
|
| 177 |
+
async def openai_to_client():
|
| 178 |
+
try:
|
| 179 |
+
async for raw in openai_ws:
|
| 180 |
+
try:
|
| 181 |
+
data = json.loads(raw)
|
| 182 |
+
except Exception:
|
| 183 |
+
await client_ws.send_text(raw)
|
| 184 |
+
continue
|
| 185 |
+
await _handle_event(data, raw, openai_ws, client_ws, state)
|
| 186 |
+
except websockets.exceptions.ConnectionClosed:
|
| 187 |
+
logger.info("OpenAI WebSocket kapandi")
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"openai_to_client error: {e}")
|
| 190 |
+
|
| 191 |
+
await asyncio.gather(client_to_openai(), openai_to_client())
|
| 192 |
+
|
| 193 |
+
except Exception:
|
| 194 |
+
logger.exception("Realtime relay hatasi")
|
| 195 |
+
try:
|
| 196 |
+
await client_ws.send_text(json.dumps({
|
| 197 |
+
"type": "error",
|
| 198 |
+
"error": {"message": "Baglanti hatasi"},
|
| 199 |
+
}))
|
| 200 |
+
except Exception:
|
| 201 |
+
pass
|
| 202 |
+
finally:
|
| 203 |
+
try:
|
| 204 |
+
await client_ws.close()
|
| 205 |
+
except Exception:
|
| 206 |
+
pass
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
async def _handle_event(data: dict, raw: str, openai_ws, client_ws: WebSocket, state: dict):
|
| 210 |
+
evt = data.get("type", "")
|
| 211 |
+
|
| 212 |
+
# ---------- Important event logging ----------
|
| 213 |
+
if evt in ("session.created", "session.updated", "input_audio_buffer.speech_started",
|
| 214 |
+
"input_audio_buffer.speech_stopped", "input_audio_buffer.committed",
|
| 215 |
+
"response.created"):
|
| 216 |
+
logger.info(f"[Realtime] {evt}")
|
| 217 |
+
elif evt == "error":
|
| 218 |
+
logger.error(f"[Realtime] ERROR: {json.dumps(data)[:400]}")
|
| 219 |
+
elif evt == "response.done":
|
| 220 |
+
status = data.get("response", {}).get("status")
|
| 221 |
+
details = data.get("response", {}).get("status_details")
|
| 222 |
+
logger.info(f"[Realtime] response.done status={status} details={details}")
|
| 223 |
+
|
| 224 |
+
# ---------- State resets ----------
|
| 225 |
+
# Asistan yeni response'a basliyor — assistant transcript ve tool flag reset
|
| 226 |
+
if evt == "response.created":
|
| 227 |
+
state["text"] = ""
|
| 228 |
+
state["last_shown_in_response"] = None
|
| 229 |
+
state["last_check_len"] = 0
|
| 230 |
+
state["pending_response_create"] = False
|
| 231 |
+
|
| 232 |
+
# Kullanici yeni soru sormaya basladi — tool_link gecersiz
|
| 233 |
+
if evt == "input_audio_buffer.speech_started":
|
| 234 |
+
state["tool_link"] = None
|
| 235 |
+
|
| 236 |
+
# ---------- Asistan transkripti — sadece renk override kontrolu icin ----------
|
| 237 |
+
# GPT'ye guveniyoruz: ana urun gosterimi YALNIZCA tool sonucundan gelir.
|
| 238 |
+
# Live transkript-bazli tahmin kapali (yanlis urun gosterimine yol aciyordu).
|
| 239 |
+
if evt in ("response.audio_transcript.delta",
|
| 240 |
+
"response.output_audio_transcript.delta",
|
| 241 |
+
"response.output_text.delta"):
|
| 242 |
+
d = data.get("delta", "")
|
| 243 |
+
if isinstance(d, str):
|
| 244 |
+
state["text"] += d
|
| 245 |
+
|
| 246 |
+
# ---------- response.done — renk override + bekleyen response.create gonder ----------
|
| 247 |
+
if evt == "response.done":
|
| 248 |
+
text = state["text"]
|
| 249 |
+
active_main = state["current_main_link"]
|
| 250 |
+
if text and active_main:
|
| 251 |
+
color_v = find_color_variant(active_main, text)
|
| 252 |
+
if color_v and color_v.get("image"):
|
| 253 |
+
logger.info(f"[product/color-override] {color_v['name']}")
|
| 254 |
+
await _send(client_ws, {
|
| 255 |
+
"type": "product.show",
|
| 256 |
+
"product": color_v,
|
| 257 |
+
})
|
| 258 |
+
# Bu turn'de tool cagrisi yapildiysa SADECE BIR KEZ response.create gonder
|
| 259 |
+
# (cogu tool cagrisi varsa bile sonuclari beraber donar, tek sozlu cevap olur).
|
| 260 |
+
if state.get("pending_response_create"):
|
| 261 |
+
state["pending_response_create"] = False
|
| 262 |
+
await openai_ws.send(json.dumps({"type": "response.create"}))
|
| 263 |
+
|
| 264 |
+
# ---------- Tool call yakalama ----------
|
| 265 |
+
if evt == "response.function_call_arguments.done":
|
| 266 |
+
await _handle_tool_call(data, openai_ws, client_ws, state)
|
| 267 |
+
# Tool call mesajini client'a forward etmeye gerek yok ama geri gondermek
|
| 268 |
+
# zarar vermez — relay kuralina uy.
|
| 269 |
+
|
| 270 |
+
# ---------- Tum mesajlari client'a forward et ----------
|
| 271 |
+
try:
|
| 272 |
+
await client_ws.send_text(raw)
|
| 273 |
+
except Exception:
|
| 274 |
+
pass
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
async def _handle_tool_call(data: dict, openai_ws, client_ws: WebSocket, state: dict):
|
| 278 |
+
call_id = data.get("call_id")
|
| 279 |
+
fn_name = data.get("name")
|
| 280 |
+
try:
|
| 281 |
+
args = json.loads(data.get("arguments", "{}"))
|
| 282 |
+
except Exception:
|
| 283 |
+
args = {}
|
| 284 |
+
|
| 285 |
+
logger.info(f"Tool call: {fn_name}({args})")
|
| 286 |
+
|
| 287 |
+
# Tool'u thread'de calistir — event loop bloklanmasin
|
| 288 |
+
result = await asyncio.to_thread(handle_tool_call_sync, fn_name, args)
|
| 289 |
+
|
| 290 |
+
# Urun resmi tespit — SADECE tool sonucundaki productLink'i kullan.
|
| 291 |
+
# GPT'ye guven: hangi urun konusulduysa o linki dondurur, biz tahmin yurutmeyiz.
|
| 292 |
+
if fn_name == "get_warehouse_stock":
|
| 293 |
+
idx = get_index()
|
| 294 |
+
product = None
|
| 295 |
+
product_link = extract_product_link_from_text(result)
|
| 296 |
+
if product_link:
|
| 297 |
+
product = idx.find_by_link(product_link)
|
| 298 |
+
|
| 299 |
+
if product and product.get("image"):
|
| 300 |
+
logger.info(f"[product/tool] {product['name']} ({product.get('link')})")
|
| 301 |
+
state["tool_link"] = product.get("link") or product_link
|
| 302 |
+
if product.get("link"):
|
| 303 |
+
state["current_main_link"] = product["link"]
|
| 304 |
+
state["last_shown_in_response"] = product["link"]
|
| 305 |
+
await _send(client_ws, {
|
| 306 |
+
"type": "product.show",
|
| 307 |
+
"product": product,
|
| 308 |
+
})
|
| 309 |
+
|
| 310 |
+
# Modelin URL okumasi engellensin — strip
|
| 311 |
+
result = strip_urls(result)
|
| 312 |
+
|
| 313 |
+
# Tool sonucunu modele geri gonder. response.create'i SADECE response.done'da
|
| 314 |
+
# bir kez tetikle (paralel tool cagrilarinda duplicate audio uretiyordu).
|
| 315 |
+
await openai_ws.send(json.dumps({
|
| 316 |
+
"type": "conversation.item.create",
|
| 317 |
+
"item": {
|
| 318 |
+
"type": "function_call_output",
|
| 319 |
+
"call_id": call_id,
|
| 320 |
+
"output": result,
|
| 321 |
+
},
|
| 322 |
+
}))
|
| 323 |
+
state["pending_response_create"] = True
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
async def _send(client_ws: WebSocket, payload: dict):
|
| 327 |
+
try:
|
| 328 |
+
await client_ws.send_text(json.dumps(payload))
|
| 329 |
+
except Exception:
|
| 330 |
+
pass
|
requirements.txt
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn[standard]
|
| 3 |
-
websockets
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
python-dotenv>=1.0.0
|
|
|
|
| 1 |
+
fastapi==0.115.0
|
| 2 |
+
uvicorn[standard]==0.30.6
|
| 3 |
+
websockets==13.1
|
| 4 |
+
requests==2.32.3
|
| 5 |
+
python-multipart==0.0.9
|
|
|
smart_warehouse.py
DELETED
|
@@ -1,261 +0,0 @@
|
|
| 1 |
-
"""Smart warehouse stock finder using GPT-5's intelligence"""
|
| 2 |
-
|
| 3 |
-
import requests
|
| 4 |
-
import re
|
| 5 |
-
import os
|
| 6 |
-
import json
|
| 7 |
-
|
| 8 |
-
def get_warehouse_stock_smart(user_message, previous_result=None):
|
| 9 |
-
"""Let GPT-5 intelligently find products or filter by warehouse"""
|
| 10 |
-
|
| 11 |
-
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
| 12 |
-
|
| 13 |
-
# Check if user is asking about specific warehouse
|
| 14 |
-
warehouse_keywords = {
|
| 15 |
-
'caddebostan': 'Caddebostan',
|
| 16 |
-
'ortaköy': 'Ortaköy',
|
| 17 |
-
'ortakoy': 'Ortaköy',
|
| 18 |
-
'alsancak': 'Alsancak',
|
| 19 |
-
'izmir': 'Alsancak',
|
| 20 |
-
'bahçeköy': 'Bahçeköy',
|
| 21 |
-
'bahcekoy': 'Bahçeköy'
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
user_lower = user_message.lower()
|
| 25 |
-
asked_warehouse = None
|
| 26 |
-
for keyword, warehouse in warehouse_keywords.items():
|
| 27 |
-
if keyword in user_lower:
|
| 28 |
-
asked_warehouse = warehouse
|
| 29 |
-
break
|
| 30 |
-
|
| 31 |
-
# Get XML data with retry
|
| 32 |
-
xml_text = None
|
| 33 |
-
for attempt in range(3): # Try 3 times
|
| 34 |
-
try:
|
| 35 |
-
url = 'https://video.trek-turkey.com/bizimhesap-warehouse-xml-b2b-api-v2.php'
|
| 36 |
-
timeout_val = 10 + (attempt * 5) # Increase timeout on each retry: 10, 15, 20
|
| 37 |
-
response = requests.get(url, verify=False, timeout=timeout_val)
|
| 38 |
-
xml_text = response.text
|
| 39 |
-
print(f"DEBUG - XML fetched: {len(xml_text)} characters (attempt {attempt+1})")
|
| 40 |
-
break
|
| 41 |
-
except requests.exceptions.Timeout:
|
| 42 |
-
print(f"XML fetch timeout (attempt {attempt+1}/3, timeout={timeout_val}s)")
|
| 43 |
-
if attempt == 2:
|
| 44 |
-
print("All attempts failed - timeout")
|
| 45 |
-
return None
|
| 46 |
-
except Exception as e:
|
| 47 |
-
print(f"XML fetch error: {e}")
|
| 48 |
-
return None
|
| 49 |
-
|
| 50 |
-
# Extract just product blocks to reduce token usage
|
| 51 |
-
product_pattern = r'<Product>(.*?)</Product>'
|
| 52 |
-
all_products = re.findall(product_pattern, xml_text, re.DOTALL)
|
| 53 |
-
|
| 54 |
-
# Create a simplified product list for GPT
|
| 55 |
-
products_summary = []
|
| 56 |
-
for i, product_block in enumerate(all_products):
|
| 57 |
-
name_match = re.search(r'<ProductName><!\[CDATA\[(.*?)\]\]></ProductName>', product_block)
|
| 58 |
-
variant_match = re.search(r'<ProductVariant><!\[CDATA\[(.*?)\]\]></ProductVariant>', product_block)
|
| 59 |
-
|
| 60 |
-
if name_match:
|
| 61 |
-
# Check warehouse stock for this product
|
| 62 |
-
warehouses_with_stock = []
|
| 63 |
-
warehouse_regex = r'<Warehouse>.*?<Name><!\[CDATA\[(.*?)\]\]></Name>.*?<Stock>(.*?)</Stock>.*?</Warehouse>'
|
| 64 |
-
warehouses = re.findall(warehouse_regex, product_block, re.DOTALL)
|
| 65 |
-
|
| 66 |
-
for wh_name, wh_stock in warehouses:
|
| 67 |
-
try:
|
| 68 |
-
if int(wh_stock.strip()) > 0:
|
| 69 |
-
warehouses_with_stock.append(wh_name)
|
| 70 |
-
except:
|
| 71 |
-
pass
|
| 72 |
-
|
| 73 |
-
product_info = {
|
| 74 |
-
"index": i,
|
| 75 |
-
"name": name_match.group(1),
|
| 76 |
-
"variant": variant_match.group(1) if variant_match else "",
|
| 77 |
-
"warehouses": warehouses_with_stock
|
| 78 |
-
}
|
| 79 |
-
products_summary.append(product_info)
|
| 80 |
-
|
| 81 |
-
# If user is asking about specific warehouse, include that in prompt
|
| 82 |
-
warehouse_filter = ""
|
| 83 |
-
if asked_warehouse:
|
| 84 |
-
warehouse_filter = f"\nIMPORTANT: User is asking specifically about {asked_warehouse} warehouse. Only return products available in that warehouse."
|
| 85 |
-
|
| 86 |
-
# Let GPT-5 find ALL matching products
|
| 87 |
-
smart_prompt = f"""User is asking: "{user_message}"
|
| 88 |
-
|
| 89 |
-
Find ALL products that match this query from the list below.
|
| 90 |
-
If user asks about specific size (S, M, L, XL, XXL, SMALL, MEDIUM, LARGE, X-LARGE), return only that size.
|
| 91 |
-
If user asks generally (without size), return ALL variants of the product.
|
| 92 |
-
{warehouse_filter}
|
| 93 |
-
|
| 94 |
-
IMPORTANT BRAND AND PRODUCT TYPE RULES:
|
| 95 |
-
- GOBIK: Spanish textile brand we import. When user asks about "gobik", return ALL products with "GOBIK" in the name.
|
| 96 |
-
- Product names contain type information: FORMA (jersey/cycling shirt), TAYT (tights), İÇLİK (base layer), YAĞMURLUK (raincoat), etc.
|
| 97 |
-
- Understand Turkish/English terms:
|
| 98 |
-
* "erkek forma" / "men's jersey" -> Find products with FORMA in name
|
| 99 |
-
* "tayt" / "tights" -> Find products with TAYT in name
|
| 100 |
-
* "içlik" / "base layer" -> Find products with İÇLİK in name
|
| 101 |
-
* "yağmurluk" / "raincoat" -> Find products with YAĞMURLUK in name
|
| 102 |
-
- Gender: UNISEX means for both men and women. If no gender specified, it's typically men's.
|
| 103 |
-
- Be smart: "erkek forma" should find all FORMA products (excluding women-specific if any)
|
| 104 |
-
|
| 105 |
-
Products list (with warehouse availability):
|
| 106 |
-
{json.dumps(products_summary, ensure_ascii=False, indent=2)}
|
| 107 |
-
|
| 108 |
-
Return index numbers of ALL matching products as comma-separated list (e.g., "5,8,12,15").
|
| 109 |
-
If no products found, return: -1
|
| 110 |
-
|
| 111 |
-
Examples:
|
| 112 |
-
- "madone sl 6 var mı" -> Return ALL Madone SL 6 variants
|
| 113 |
-
- "erkek forma" -> Return all products with FORMA in name
|
| 114 |
-
- "gobik tayt" -> Return all GOBIK products with TAYT in name
|
| 115 |
-
- "içlik var mı" -> Return all products with İÇLİK in name
|
| 116 |
-
- "gobik erkek forma" -> Return all GOBIK products with FORMA in name
|
| 117 |
-
- "yağmurluk medium" -> Return all YAĞMURLUK products in MEDIUM size"""
|
| 118 |
-
|
| 119 |
-
headers = {
|
| 120 |
-
"Content-Type": "application/json",
|
| 121 |
-
"Authorization": f"Bearer {OPENAI_API_KEY}"
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
# GPT-5.5 modeli temperature ve max_tokens desteklemiyor
|
| 125 |
-
payload = {
|
| 126 |
-
"model": "gpt-5.5",
|
| 127 |
-
"messages": [
|
| 128 |
-
{"role": "system", "content": "You are a product matcher. Find ALL matching products. Return only index numbers."},
|
| 129 |
-
{"role": "user", "content": smart_prompt}
|
| 130 |
-
]
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
try:
|
| 134 |
-
response = requests.post(
|
| 135 |
-
"https://api.openai.com/v1/chat/completions",
|
| 136 |
-
headers=headers,
|
| 137 |
-
json=payload,
|
| 138 |
-
timeout=10
|
| 139 |
-
)
|
| 140 |
-
|
| 141 |
-
if response.status_code == 200:
|
| 142 |
-
result = response.json()
|
| 143 |
-
indices_str = result['choices'][0]['message']['content'].strip()
|
| 144 |
-
|
| 145 |
-
if indices_str == "-1":
|
| 146 |
-
return ["Ürün bulunamadı"]
|
| 147 |
-
|
| 148 |
-
try:
|
| 149 |
-
# Parse multiple indices
|
| 150 |
-
indices = [int(idx.strip()) for idx in indices_str.split(',')]
|
| 151 |
-
|
| 152 |
-
# Collect all matching products
|
| 153 |
-
all_variants = []
|
| 154 |
-
warehouse_stock = {}
|
| 155 |
-
|
| 156 |
-
for idx in indices:
|
| 157 |
-
if 0 <= idx < len(all_products):
|
| 158 |
-
product_block = all_products[idx]
|
| 159 |
-
|
| 160 |
-
# Get product name and variant
|
| 161 |
-
name_match = re.search(r'<ProductName><!\[CDATA\[(.*?)\]\]></ProductName>', product_block)
|
| 162 |
-
variant_match = re.search(r'<ProductVariant><!\[CDATA\[(.*?)\]\]></ProductVariant>', product_block)
|
| 163 |
-
|
| 164 |
-
if name_match:
|
| 165 |
-
product_name = name_match.group(1)
|
| 166 |
-
variant = variant_match.group(1) if variant_match else ""
|
| 167 |
-
|
| 168 |
-
# Track this variant
|
| 169 |
-
variant_info = {
|
| 170 |
-
'name': product_name,
|
| 171 |
-
'variant': variant,
|
| 172 |
-
'warehouses': []
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
# Get warehouse stock
|
| 176 |
-
warehouse_regex = r'<Warehouse>.*?<Name><!\[CDATA\[(.*?)\]\]></Name>.*?<Stock>(.*?)</Stock>.*?</Warehouse>'
|
| 177 |
-
warehouses = re.findall(warehouse_regex, product_block, re.DOTALL)
|
| 178 |
-
|
| 179 |
-
for wh_name, wh_stock in warehouses:
|
| 180 |
-
try:
|
| 181 |
-
stock = int(wh_stock.strip())
|
| 182 |
-
if stock > 0:
|
| 183 |
-
display_name = format_warehouse_name(wh_name)
|
| 184 |
-
variant_info['warehouses'].append({
|
| 185 |
-
'name': display_name,
|
| 186 |
-
'stock': stock
|
| 187 |
-
})
|
| 188 |
-
|
| 189 |
-
# Track total stock per warehouse
|
| 190 |
-
if display_name not in warehouse_stock:
|
| 191 |
-
warehouse_stock[display_name] = 0
|
| 192 |
-
warehouse_stock[display_name] += stock
|
| 193 |
-
except:
|
| 194 |
-
pass
|
| 195 |
-
|
| 196 |
-
if variant_info['warehouses']: # Only add if has stock
|
| 197 |
-
all_variants.append(variant_info)
|
| 198 |
-
|
| 199 |
-
# Format result
|
| 200 |
-
result = []
|
| 201 |
-
|
| 202 |
-
if asked_warehouse:
|
| 203 |
-
# Filter for specific warehouse
|
| 204 |
-
warehouse_variants = []
|
| 205 |
-
for variant in all_variants:
|
| 206 |
-
for wh in variant['warehouses']:
|
| 207 |
-
if asked_warehouse in wh['name']:
|
| 208 |
-
warehouse_variants.append({
|
| 209 |
-
'name': variant['name'],
|
| 210 |
-
'variant': variant['variant'],
|
| 211 |
-
'stock': wh['stock']
|
| 212 |
-
})
|
| 213 |
-
|
| 214 |
-
if warehouse_variants:
|
| 215 |
-
result.append(f"{format_warehouse_name(asked_warehouse)} mağazasında mevcut:")
|
| 216 |
-
for v in warehouse_variants:
|
| 217 |
-
result.append(f"• {v['name']} ({v['variant']})")
|
| 218 |
-
else:
|
| 219 |
-
result.append(f"{format_warehouse_name(asked_warehouse)} mağazasında bu ürün mevcut değil")
|
| 220 |
-
else:
|
| 221 |
-
# Show all variants and warehouses
|
| 222 |
-
if all_variants:
|
| 223 |
-
result.append(f"Bulunan {len(all_variants)} varyant:")
|
| 224 |
-
|
| 225 |
-
# Show ALL variants (not just first 5)
|
| 226 |
-
for variant in all_variants:
|
| 227 |
-
variant_text = f" ({variant['variant']})" if variant['variant'] else ""
|
| 228 |
-
result.append(f"• {variant['name']}{variant_text}")
|
| 229 |
-
|
| 230 |
-
result.append("")
|
| 231 |
-
result.append("Mağaza stok durumu:")
|
| 232 |
-
for warehouse, total_stock in sorted(warehouse_stock.items()):
|
| 233 |
-
result.append(f"• {warehouse}: Mevcut")
|
| 234 |
-
else:
|
| 235 |
-
result.append("Hiçbir mağazada stok yok")
|
| 236 |
-
|
| 237 |
-
return result
|
| 238 |
-
|
| 239 |
-
except (ValueError, IndexError) as e:
|
| 240 |
-
print(f"DEBUG - Error parsing indices: {e}")
|
| 241 |
-
return None
|
| 242 |
-
else:
|
| 243 |
-
print(f"GPT API error: {response.status_code}")
|
| 244 |
-
return None
|
| 245 |
-
|
| 246 |
-
except Exception as e:
|
| 247 |
-
print(f"Error calling GPT: {e}")
|
| 248 |
-
return None
|
| 249 |
-
|
| 250 |
-
def format_warehouse_name(wh_name):
|
| 251 |
-
"""Format warehouse name nicely"""
|
| 252 |
-
if "CADDEBOSTAN" in wh_name:
|
| 253 |
-
return "Caddebostan mağazası"
|
| 254 |
-
elif "ORTAKÖY" in wh_name:
|
| 255 |
-
return "Ortaköy mağazası"
|
| 256 |
-
elif "ALSANCAK" in wh_name:
|
| 257 |
-
return "İzmir Alsancak mağazası"
|
| 258 |
-
elif "BAHCEKOY" in wh_name or "BAHÇEKÖY" in wh_name:
|
| 259 |
-
return "Bahçeköy mağazası"
|
| 260 |
-
else:
|
| 261 |
-
return wh_name.replace("MAGAZA DEPO", "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
smart_warehouse_with_price.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
-
"""Smart warehouse stock finder with price and link information
|
|
|
|
| 2 |
|
| 3 |
import requests
|
| 4 |
import re
|
|
@@ -7,11 +8,13 @@ import json
|
|
| 7 |
import xml.etree.ElementTree as ET
|
| 8 |
import time
|
| 9 |
import logging
|
|
|
|
| 10 |
|
|
|
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
-
# Cache configuration
|
| 14 |
-
CACHE_DURATION = 86400 # 24
|
| 15 |
cache = {
|
| 16 |
'warehouse_xml': {'data': None, 'time': 0},
|
| 17 |
'trek_xml': {'data': None, 'time': 0},
|
|
@@ -20,24 +23,15 @@ cache = {
|
|
| 20 |
}
|
| 21 |
|
| 22 |
def get_cached_trek_xml():
|
| 23 |
-
"""
|
| 24 |
-
current_time = time.time()
|
| 25 |
-
|
| 26 |
-
if cache['trek_xml']['data'] and (current_time - cache['trek_xml']['time'] < CACHE_DURATION):
|
| 27 |
-
cache_age = (current_time - cache['trek_xml']['time']) / 60 # in minutes
|
| 28 |
-
return cache['trek_xml']['data']
|
| 29 |
-
|
| 30 |
try:
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
if response.status_code == 200:
|
| 35 |
-
cache['trek_xml']['data'] = response.content
|
| 36 |
-
cache['trek_xml']['time'] = current_time
|
| 37 |
-
return response.content
|
| 38 |
-
else:
|
| 39 |
return None
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
return None
|
| 42 |
|
| 43 |
def apply_price_rounding(price_str):
|
|
@@ -197,20 +191,10 @@ def get_product_price_and_link(product_name, variant=None):
|
|
| 197 |
for tr, en in tr_map.items():
|
| 198 |
search_name_normalized = search_name_normalized.replace(tr, en)
|
| 199 |
search_variant_normalized = search_variant_normalized.replace(tr, en)
|
| 200 |
-
|
| 201 |
# Now lowercase
|
| 202 |
search_name = search_name_normalized.lower()
|
| 203 |
search_variant = search_variant_normalized.lower()
|
| 204 |
-
|
| 205 |
-
# KRITIK: "+" karakterini normalize et (fuel + lx -> fuel+ lx, fuel lx -> fuel+ lx)
|
| 206 |
-
# Kullanici "fuel + lx" veya "fuel lx" yazabilir, XML'de "FUEL+ LX" var
|
| 207 |
-
plus_models = ['fuel', 'domane', 'fx', 'ds', 'verve', 'townie', 'allant']
|
| 208 |
-
for model in plus_models:
|
| 209 |
-
# "fuel + lx" -> "fuel+ lx"
|
| 210 |
-
search_name = search_name.replace(f'{model} + ', f'{model}+ ')
|
| 211 |
-
# "fuel lx" -> "fuel+ lx" (sadece elektrikli model ise)
|
| 212 |
-
if f'{model} lx' in search_name or f'{model} 9' in search_name:
|
| 213 |
-
search_name = search_name.replace(f'{model} ', f'{model}+ ')
|
| 214 |
|
| 215 |
best_match = None
|
| 216 |
best_score = 0
|
|
@@ -290,184 +274,16 @@ def get_product_price_and_link(product_name, variant=None):
|
|
| 290 |
except Exception as e:
|
| 291 |
return None, None
|
| 292 |
|
| 293 |
-
BIZIMHESAP_TOKEN = '6F4BAF303FA240608A39653824B6C495'
|
| 294 |
-
BIZIMHESAP_BASE = 'https://bizimhesap.com/api/b2b'
|
| 295 |
-
BIZIMHESAP_HEADERS = {'token': BIZIMHESAP_TOKEN, 'Accept': 'application/json'}
|
| 296 |
-
|
| 297 |
-
# Hangi magazalar olmadiginda cache yenilemeyi reddet (rate-limit koruyucu)
|
| 298 |
-
CRITICAL_WAREHOUSES = {'BAHCEKOY', 'CADDEBOSTAN', 'ALSANCAK'}
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
def _bh_get(url, retries=2):
|
| 302 |
-
for i in range(retries):
|
| 303 |
-
try:
|
| 304 |
-
r = requests.get(url, headers=BIZIMHESAP_HEADERS, timeout=60)
|
| 305 |
-
if r.status_code == 200:
|
| 306 |
-
return r.json()
|
| 307 |
-
except Exception:
|
| 308 |
-
pass
|
| 309 |
-
if i < retries - 1:
|
| 310 |
-
time.sleep(1)
|
| 311 |
-
return None
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
def _bh_match_product(item, products):
|
| 315 |
-
"""PHP matchProduct port'u: barcode > sku > title-variant > title"""
|
| 316 |
-
item_barcode = (item.get('barcode') or '').strip()
|
| 317 |
-
item_title = (item.get('title') or '').strip()
|
| 318 |
-
item_title_lower = item_title.lower()
|
| 319 |
-
|
| 320 |
-
best = None
|
| 321 |
-
best_score = 0
|
| 322 |
-
for code, p in products.items():
|
| 323 |
-
score = 0
|
| 324 |
-
if item_barcode and p['barcode'] and item_barcode == p['barcode']:
|
| 325 |
-
score = 100
|
| 326 |
-
elif item_barcode and item_barcode == code:
|
| 327 |
-
score = 90
|
| 328 |
-
elif item_title:
|
| 329 |
-
p_title = p['name'].lower()
|
| 330 |
-
p_variant = p['variant'].lower()
|
| 331 |
-
if p_variant:
|
| 332 |
-
if item_title_lower == f"{p_title} {p_variant}":
|
| 333 |
-
score = 80
|
| 334 |
-
elif item_title_lower == f"{p_title}-{p_variant}":
|
| 335 |
-
score = 75
|
| 336 |
-
elif p_title in item_title_lower:
|
| 337 |
-
parts = re.split(r'[\s\-]+', p_variant)
|
| 338 |
-
if all(part.strip() in item_title_lower for part in parts if part.strip()):
|
| 339 |
-
score = 70
|
| 340 |
-
elif item_title_lower == p_title:
|
| 341 |
-
score = 80
|
| 342 |
-
if not item_barcode and not p['barcode']:
|
| 343 |
-
p_title = p['name'].lower()
|
| 344 |
-
if p_title and p_title in item_title_lower:
|
| 345 |
-
score = max(score, 60)
|
| 346 |
-
if score > best_score:
|
| 347 |
-
best_score = score
|
| 348 |
-
best = p
|
| 349 |
-
return best if best_score >= 60 else None
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
def _build_warehouse_xml():
|
| 353 |
-
"""BizimHesap B2B API'sinden direkt veri cek ve XML uret (Trek PHP'ye bagimlilik yok)."""
|
| 354 |
-
pdata = _bh_get(f'{BIZIMHESAP_BASE}/products')
|
| 355 |
-
if not pdata or pdata.get('resultCode') != 1:
|
| 356 |
-
return None
|
| 357 |
-
products_raw = pdata.get('data', {}).get('products', [])
|
| 358 |
-
|
| 359 |
-
products = {}
|
| 360 |
-
for p in products_raw:
|
| 361 |
-
code = (p.get('code') or '').strip()
|
| 362 |
-
if not code:
|
| 363 |
-
continue
|
| 364 |
-
products[code] = {
|
| 365 |
-
'sku': code,
|
| 366 |
-
'barcode': (p.get('barcode') or '').strip(),
|
| 367 |
-
'name': (p.get('title') or '').strip(),
|
| 368 |
-
'variant': (p.get('variant') or '').strip(),
|
| 369 |
-
'warehouses': [],
|
| 370 |
-
'total': 0,
|
| 371 |
-
}
|
| 372 |
-
|
| 373 |
-
wdata = _bh_get(f'{BIZIMHESAP_BASE}/warehouses')
|
| 374 |
-
if not wdata or wdata.get('resultCode') != 1:
|
| 375 |
-
return None
|
| 376 |
-
warehouses = wdata.get('data', {}).get('warehouses', [])
|
| 377 |
-
|
| 378 |
-
critical_empty = 0
|
| 379 |
-
total_inv = 0
|
| 380 |
-
for wh in warehouses:
|
| 381 |
-
wid = wh.get('id')
|
| 382 |
-
wname = (wh.get('title') or '').strip()
|
| 383 |
-
idata = _bh_get(f'{BIZIMHESAP_BASE}/inventory/{wid}')
|
| 384 |
-
inv = idata.get('data', {}).get('inventory', []) if idata else []
|
| 385 |
-
if wname.upper() in CRITICAL_WAREHOUSES and len(inv) == 0:
|
| 386 |
-
critical_empty += 1
|
| 387 |
-
total_inv += len(inv)
|
| 388 |
-
for item in inv:
|
| 389 |
-
try:
|
| 390 |
-
qty = int(item.get('qty', 0))
|
| 391 |
-
except (TypeError, ValueError):
|
| 392 |
-
qty = 0
|
| 393 |
-
if qty <= 0:
|
| 394 |
-
continue
|
| 395 |
-
m = _bh_match_product(item, products)
|
| 396 |
-
if m is not None:
|
| 397 |
-
m['warehouses'].append({'name': wname, 'stock': qty})
|
| 398 |
-
m['total'] += qty
|
| 399 |
-
|
| 400 |
-
# Partial-failure koruma: 2+ kritik magaza bossa cache'i kirletme
|
| 401 |
-
if critical_empty >= 2:
|
| 402 |
-
return None
|
| 403 |
-
|
| 404 |
-
# XML uret (Trek PHP'nin urettigi format)
|
| 405 |
-
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
| 406 |
-
'<!-- BizimHesap B2B API direct (HF Space) -->',
|
| 407 |
-
f'<!-- Generated: {time.strftime("%Y-%m-%d %H:%M:%S")} -->',
|
| 408 |
-
f'<!-- Stats: {len(products_raw)} products, {len(warehouses)} warehouses, {total_inv} inventory items -->',
|
| 409 |
-
'<Products>']
|
| 410 |
-
for p in sorted(products.values(), key=lambda x: x['sku'].lower()):
|
| 411 |
-
if not p['warehouses']:
|
| 412 |
-
continue
|
| 413 |
-
p['warehouses'].sort(key=lambda w: -w['stock'])
|
| 414 |
-
out.append(' <Product>')
|
| 415 |
-
out.append(f' <ProductCode><![CDATA[{p["sku"]}]]></ProductCode>')
|
| 416 |
-
out.append(f' <ProductName><![CDATA[{p["name"]}]]></ProductName>')
|
| 417 |
-
if p['variant']:
|
| 418 |
-
out.append(f' <ProductVariant><![CDATA[{p["variant"]}]]></ProductVariant>')
|
| 419 |
-
if p['barcode']:
|
| 420 |
-
out.append(f' <Barcode><![CDATA[{p["barcode"]}]]></Barcode>')
|
| 421 |
-
out.append(f' <TotalStock>{p["total"]}</TotalStock>')
|
| 422 |
-
for w in p['warehouses']:
|
| 423 |
-
out.append(' <Warehouse>')
|
| 424 |
-
out.append(f' <Name><![CDATA[{w["name"]}]]></Name>')
|
| 425 |
-
out.append(f' <Stock>{w["stock"]}</Stock>')
|
| 426 |
-
out.append(' </Warehouse>')
|
| 427 |
-
out.append(' </Product>')
|
| 428 |
-
out.append('</Products>')
|
| 429 |
-
return '\n'.join(out)
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
TREK_PHP_URL = 'https://video.trek-turkey.com/bizimhesap-warehouse-xml-b2b-api-v2.php'
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
def _fetch_trek_php():
|
| 436 |
-
"""Trek PHP endpoint'inden XML cek (BizimHesap direkt erisim olmazsa fallback)."""
|
| 437 |
-
for attempt in range(2):
|
| 438 |
-
try:
|
| 439 |
-
r = requests.get(TREK_PHP_URL, verify=False, timeout=15 + attempt * 10)
|
| 440 |
-
if r.status_code == 200 and len(r.text) > 1000 and '<Products>' in r.text:
|
| 441 |
-
return r.text
|
| 442 |
-
except Exception:
|
| 443 |
-
pass
|
| 444 |
-
return None
|
| 445 |
-
|
| 446 |
-
|
| 447 |
def get_cached_warehouse_xml():
|
| 448 |
-
"""
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
return
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
# 2. Fallback: Trek PHP (en azindan mevcut cache'i doner)
|
| 458 |
-
if not xml_text:
|
| 459 |
-
xml_text = _fetch_trek_php()
|
| 460 |
-
|
| 461 |
-
if xml_text:
|
| 462 |
-
cache['warehouse_xml']['data'] = xml_text
|
| 463 |
-
cache['warehouse_xml']['time'] = current_time
|
| 464 |
-
return xml_text
|
| 465 |
-
|
| 466 |
-
# 3. Son care: eski (expired) cache
|
| 467 |
-
if cache['warehouse_xml']['data']:
|
| 468 |
-
return cache['warehouse_xml']['data']
|
| 469 |
-
|
| 470 |
-
return None
|
| 471 |
|
| 472 |
def get_warehouse_stock_smart_with_price(user_message, previous_result=None):
|
| 473 |
"""Enhanced smart warehouse search with price and link info"""
|
|
@@ -481,16 +297,7 @@ def get_warehouse_stock_smart_with_price(user_message, previous_result=None):
|
|
| 481 |
]
|
| 482 |
|
| 483 |
clean_message = user_message.lower().strip()
|
| 484 |
-
|
| 485 |
-
# KRITIK: "+" karakterini normalize et (fuel + lx -> fuel+ lx, fuel lx -> fuel+ lx)
|
| 486 |
-
plus_models = ['fuel', 'domane', 'fx', 'ds', 'verve', 'townie', 'allant']
|
| 487 |
-
for model in plus_models:
|
| 488 |
-
# "fuel + lx" -> "fuel+ lx"
|
| 489 |
-
clean_message = clean_message.replace(f'{model} + ', f'{model}+ ')
|
| 490 |
-
# "fuel lx" -> "fuel+ lx" (elektrikli model)
|
| 491 |
-
if f'{model} lx' in clean_message:
|
| 492 |
-
clean_message = clean_message.replace(f'{model} lx', f'{model}+ lx')
|
| 493 |
-
|
| 494 |
for phrase in live_support_phrases:
|
| 495 |
if phrase in clean_message:
|
| 496 |
return None # Ürün araması yapma, GPT'ye bırak
|
|
@@ -520,9 +327,23 @@ def get_warehouse_stock_smart_with_price(user_message, previous_result=None):
|
|
| 520 |
# Short single words are usually not product names
|
| 521 |
return None
|
| 522 |
|
| 523 |
-
#
|
| 524 |
-
#
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
# Normalize cache key for consistent caching (Turkish chars + lowercase)
|
| 528 |
def normalize_for_cache(text):
|
|
@@ -570,13 +391,11 @@ def get_warehouse_stock_smart_with_price(user_message, previous_result=None):
|
|
| 570 |
# Get cached XML data
|
| 571 |
xml_text = get_cached_warehouse_xml()
|
| 572 |
if not xml_text:
|
| 573 |
-
logger.info(f"[search] xml_text is None for query: {user_message!r}")
|
| 574 |
return None
|
| 575 |
-
|
| 576 |
# Extract product blocks
|
| 577 |
product_pattern = r'<Product>(.*?)</Product>'
|
| 578 |
all_products = re.findall(product_pattern, xml_text, re.DOTALL)
|
| 579 |
-
logger.info(f"[search] q={user_message!r} xml_size={len(xml_text)} products={len(all_products)} asked_warehouse={asked_warehouse}")
|
| 580 |
|
| 581 |
# Create simplified product list for GPT
|
| 582 |
products_summary = []
|
|
@@ -720,15 +539,14 @@ Examples of correct responses:
|
|
| 720 |
"https://api.openai.com/v1/chat/completions",
|
| 721 |
headers=headers,
|
| 722 |
json=payload,
|
| 723 |
-
timeout=
|
| 724 |
)
|
| 725 |
|
| 726 |
if response.status_code == 200:
|
| 727 |
result = response.json()
|
| 728 |
indices_str = result['choices'][0]['message']['content'].strip()
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
# Handle empty response - try Trek XML as fallback, but avoid tool products
|
| 732 |
if not indices_str or indices_str == "-1":
|
| 733 |
# Try to find in Trek XML directly, but skip tools
|
| 734 |
user_message_normalized = user_message.upper()
|
|
@@ -840,10 +658,10 @@ Examples of correct responses:
|
|
| 840 |
warehouse_variants = []
|
| 841 |
for variant in all_variants:
|
| 842 |
for wh in variant['warehouses']:
|
| 843 |
-
if asked_warehouse
|
| 844 |
warehouse_variants.append(variant)
|
| 845 |
break
|
| 846 |
-
|
| 847 |
if warehouse_variants:
|
| 848 |
result.append(f"{format_warehouse_name(asked_warehouse)} mağazasında mevcut:")
|
| 849 |
for v in warehouse_variants:
|
|
@@ -864,25 +682,23 @@ Examples of correct responses:
|
|
| 864 |
if variant['name'] not in product_groups:
|
| 865 |
product_groups[variant['name']] = []
|
| 866 |
product_groups[variant['name']].append(variant)
|
| 867 |
-
|
| 868 |
result.append(f"Bulunan ürünler:")
|
| 869 |
-
|
| 870 |
for product_name, variants in product_groups.items():
|
| 871 |
result.append(f"\n{product_name}:")
|
| 872 |
-
|
|
|
|
| 873 |
if variants[0]['price']:
|
| 874 |
result.append(f"Fiyat: {variants[0]['price']}")
|
| 875 |
if variants[0]['link']:
|
| 876 |
result.append(f"Link: {variants[0]['link']}")
|
| 877 |
-
|
| 878 |
-
#
|
| 879 |
for v in variants:
|
| 880 |
-
wh_names = sorted({w['name'].replace(' mağazası', '') for w in v['warehouses']})
|
| 881 |
-
wh_str = ", ".join(wh_names)
|
| 882 |
if v['variant']:
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
result.append(f"• Stokta: {wh_str}")
|
| 886 |
|
| 887 |
else:
|
| 888 |
# No warehouse stock found - check if product exists in Trek
|
|
@@ -913,16 +729,13 @@ Examples of correct responses:
|
|
| 913 |
'time': current_time
|
| 914 |
}
|
| 915 |
return result
|
| 916 |
-
|
| 917 |
except (ValueError, IndexError) as e:
|
| 918 |
-
logger.error(f"[search] parse error: {e}")
|
| 919 |
return None
|
| 920 |
else:
|
| 921 |
-
logger.error(f"[search] OpenAI HTTP {response.status_code}: {response.text[:200]}")
|
| 922 |
return None
|
| 923 |
-
|
| 924 |
except Exception as e:
|
| 925 |
-
logger.error(f"[search] OpenAI request failed: {type(e).__name__}: {e}")
|
| 926 |
return None
|
| 927 |
|
| 928 |
def format_warehouse_name(wh_name):
|
|
|
|
| 1 |
+
"""Smart warehouse stock finder with price and link information.
|
| 2 |
+
WhatsApp BF-WAB versiyonundan ithal edildi (V2)."""
|
| 3 |
|
| 4 |
import requests
|
| 5 |
import re
|
|
|
|
| 8 |
import xml.etree.ElementTree as ET
|
| 9 |
import time
|
| 10 |
import logging
|
| 11 |
+
import urllib3
|
| 12 |
|
| 13 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
+
# Cache configuration — 24 saat
|
| 17 |
+
CACHE_DURATION = 86400 # 24 hours
|
| 18 |
cache = {
|
| 19 |
'warehouse_xml': {'data': None, 'time': 0},
|
| 20 |
'trek_xml': {'data': None, 'time': 0},
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
def get_cached_trek_xml():
|
| 26 |
+
"""V2'nin product_index lock'lu cache'ini kullan — ayni Trek katalog XML'i."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
try:
|
| 28 |
+
from product_index import get_index
|
| 29 |
+
idx = get_index()
|
| 30 |
+
if not idx.ensure():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return None
|
| 32 |
+
return idx._xml_data # cached bytes
|
| 33 |
+
except Exception:
|
| 34 |
+
logger.exception("[smart_warehouse] V2 trek index import hatasi")
|
| 35 |
return None
|
| 36 |
|
| 37 |
def apply_price_rounding(price_str):
|
|
|
|
| 191 |
for tr, en in tr_map.items():
|
| 192 |
search_name_normalized = search_name_normalized.replace(tr, en)
|
| 193 |
search_variant_normalized = search_variant_normalized.replace(tr, en)
|
| 194 |
+
|
| 195 |
# Now lowercase
|
| 196 |
search_name = search_name_normalized.lower()
|
| 197 |
search_variant = search_variant_normalized.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
best_match = None
|
| 200 |
best_score = 0
|
|
|
|
| 274 |
except Exception as e:
|
| 275 |
return None, None
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
def get_cached_warehouse_xml():
|
| 278 |
+
"""V2'nin tek-kanalli, lock'lu cache'ine yonlendir.
|
| 279 |
+
Bu sekilde tum tool cagrilari ve /warehouse-xml endpoint'i AYNI cache'i paylasir,
|
| 280 |
+
video.trek-turkey.com'a thundering herd olmaz."""
|
| 281 |
+
try:
|
| 282 |
+
from stock_service import get_cached_warehouse_xml as _v2_cache
|
| 283 |
+
return _v2_cache()
|
| 284 |
+
except Exception:
|
| 285 |
+
logger.exception("[smart_warehouse] V2 cache import hatasi")
|
| 286 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
def get_warehouse_stock_smart_with_price(user_message, previous_result=None):
|
| 289 |
"""Enhanced smart warehouse search with price and link info"""
|
|
|
|
| 297 |
]
|
| 298 |
|
| 299 |
clean_message = user_message.lower().strip()
|
| 300 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
for phrase in live_support_phrases:
|
| 302 |
if phrase in clean_message:
|
| 303 |
return None # Ürün araması yapma, GPT'ye bırak
|
|
|
|
| 327 |
# Short single words are usually not product names
|
| 328 |
return None
|
| 329 |
|
| 330 |
+
# Check if this is a question rather than a product search
|
| 331 |
+
# BUT skip this check if message contains a known brand
|
| 332 |
+
question_indicators = [
|
| 333 |
+
'musun', 'müsün', 'misin', 'mısın', 'miyim', 'mıyım',
|
| 334 |
+
'musunuz', 'müsünüz', 'misiniz', 'mısınız',
|
| 335 |
+
'neden', 'nasıl', 'ne zaman', 'kim', 'nerede', 'nereye',
|
| 336 |
+
'ulaşamıyor', 'yapamıyor', 'gönderemiyor', 'edemiyor',
|
| 337 |
+
# NOT: '?' karakterini cikartik — "Marlin 5 var mı?" gibi normal urun sorulari
|
| 338 |
+
# de soru isareti icerir, bu false-positive yapiyordu.
|
| 339 |
+
]
|
| 340 |
+
|
| 341 |
+
# If message contains question indicators, it's likely not a product search
|
| 342 |
+
# EXCEPTION: If message contains a brand keyword, still search for products
|
| 343 |
+
if not contains_brand:
|
| 344 |
+
for indicator in question_indicators:
|
| 345 |
+
if indicator in clean_message:
|
| 346 |
+
return None
|
| 347 |
|
| 348 |
# Normalize cache key for consistent caching (Turkish chars + lowercase)
|
| 349 |
def normalize_for_cache(text):
|
|
|
|
| 391 |
# Get cached XML data
|
| 392 |
xml_text = get_cached_warehouse_xml()
|
| 393 |
if not xml_text:
|
|
|
|
| 394 |
return None
|
| 395 |
+
|
| 396 |
# Extract product blocks
|
| 397 |
product_pattern = r'<Product>(.*?)</Product>'
|
| 398 |
all_products = re.findall(product_pattern, xml_text, re.DOTALL)
|
|
|
|
| 399 |
|
| 400 |
# Create simplified product list for GPT
|
| 401 |
products_summary = []
|
|
|
|
| 539 |
"https://api.openai.com/v1/chat/completions",
|
| 540 |
headers=headers,
|
| 541 |
json=payload,
|
| 542 |
+
timeout=10
|
| 543 |
)
|
| 544 |
|
| 545 |
if response.status_code == 200:
|
| 546 |
result = response.json()
|
| 547 |
indices_str = result['choices'][0]['message']['content'].strip()
|
| 548 |
+
|
| 549 |
+
# Handle empty response - try Trek XML as fallback, but avoid tool products
|
|
|
|
| 550 |
if not indices_str or indices_str == "-1":
|
| 551 |
# Try to find in Trek XML directly, but skip tools
|
| 552 |
user_message_normalized = user_message.upper()
|
|
|
|
| 658 |
warehouse_variants = []
|
| 659 |
for variant in all_variants:
|
| 660 |
for wh in variant['warehouses']:
|
| 661 |
+
if asked_warehouse in wh['name']:
|
| 662 |
warehouse_variants.append(variant)
|
| 663 |
break
|
| 664 |
+
|
| 665 |
if warehouse_variants:
|
| 666 |
result.append(f"{format_warehouse_name(asked_warehouse)} mağazasında mevcut:")
|
| 667 |
for v in warehouse_variants:
|
|
|
|
| 682 |
if variant['name'] not in product_groups:
|
| 683 |
product_groups[variant['name']] = []
|
| 684 |
product_groups[variant['name']].append(variant)
|
| 685 |
+
|
| 686 |
result.append(f"Bulunan ürünler:")
|
| 687 |
+
|
| 688 |
for product_name, variants in product_groups.items():
|
| 689 |
result.append(f"\n{product_name}:")
|
| 690 |
+
|
| 691 |
+
# Show first variant's price and link (usually same for all variants)
|
| 692 |
if variants[0]['price']:
|
| 693 |
result.append(f"Fiyat: {variants[0]['price']}")
|
| 694 |
if variants[0]['link']:
|
| 695 |
result.append(f"Link: {variants[0]['link']}")
|
| 696 |
+
|
| 697 |
+
# Show variants and their availability
|
| 698 |
for v in variants:
|
|
|
|
|
|
|
| 699 |
if v['variant']:
|
| 700 |
+
warehouses_str = ", ".join([w['name'].replace(' mağazası', '') for w in v['warehouses']])
|
| 701 |
+
result.append(f"• {v['variant']}: {warehouses_str}")
|
|
|
|
| 702 |
|
| 703 |
else:
|
| 704 |
# No warehouse stock found - check if product exists in Trek
|
|
|
|
| 729 |
'time': current_time
|
| 730 |
}
|
| 731 |
return result
|
| 732 |
+
|
| 733 |
except (ValueError, IndexError) as e:
|
|
|
|
| 734 |
return None
|
| 735 |
else:
|
|
|
|
| 736 |
return None
|
| 737 |
+
|
| 738 |
except Exception as e:
|
|
|
|
| 739 |
return None
|
| 740 |
|
| 741 |
def format_warehouse_name(wh_name):
|
stock_service.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stok bilgisi servisi: BizimHesap B2B API + Trek PHP fallback.
|
| 2 |
+
Lock'lu cache (thundering herd onleyici), 24h TTL, stale-on-failure."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
import requests
|
| 11 |
+
import urllib3
|
| 12 |
+
|
| 13 |
+
from config import (
|
| 14 |
+
BIZIMHESAP_BASE,
|
| 15 |
+
BIZIMHESAP_HEADERS,
|
| 16 |
+
BIZIMHESAP_TIMEOUT,
|
| 17 |
+
CACHE_TTL_BH_INVENTORY,
|
| 18 |
+
CACHE_TTL_BH_PRODUCTS,
|
| 19 |
+
CACHE_TTL_BH_WAREHOUSES,
|
| 20 |
+
CACHE_TTL_WAREHOUSE,
|
| 21 |
+
CRITICAL_WAREHOUSES,
|
| 22 |
+
TREK_PHP_TIMEOUT,
|
| 23 |
+
TREK_PHP_URL,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# ---------- BizimHesap raw cache ----------
|
| 31 |
+
_bh_cache = {
|
| 32 |
+
"products": {"data": None, "time": 0.0},
|
| 33 |
+
"warehouses": {"data": None, "time": 0.0},
|
| 34 |
+
"inventory": {}, # {wid: {data, time}}
|
| 35 |
+
}
|
| 36 |
+
_bh_lock = threading.Lock()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def bh_get(url: str, retries: int = 2):
|
| 40 |
+
"""BizimHesap GET — JSON dondurur, basarisizsa None. Tum hatalari log'la."""
|
| 41 |
+
last_err = None
|
| 42 |
+
for i in range(retries):
|
| 43 |
+
try:
|
| 44 |
+
r = requests.get(url, headers=BIZIMHESAP_HEADERS, timeout=BIZIMHESAP_TIMEOUT)
|
| 45 |
+
if r.status_code == 200:
|
| 46 |
+
try:
|
| 47 |
+
return r.json()
|
| 48 |
+
except Exception as e:
|
| 49 |
+
last_err = f"json parse: {e}; body[:200]={r.text[:200]!r}"
|
| 50 |
+
else:
|
| 51 |
+
last_err = f"HTTP {r.status_code}; body[:200]={r.text[:200]!r}"
|
| 52 |
+
except Exception as e:
|
| 53 |
+
last_err = f"{type(e).__name__}: {e}"
|
| 54 |
+
if i < retries - 1:
|
| 55 |
+
time.sleep(0.6)
|
| 56 |
+
logger.warning(f"[bh] {url} fail: {last_err}")
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def cached_bh(kind: str, fetcher, ttl: int) -> tuple[object, str]:
|
| 61 |
+
"""products/warehouses generic cache. Donus: (data, status)."""
|
| 62 |
+
with _bh_lock:
|
| 63 |
+
entry = _bh_cache.get(kind, {"data": None, "time": 0.0})
|
| 64 |
+
now = time.time()
|
| 65 |
+
if entry["data"] is not None and (now - entry["time"] < ttl):
|
| 66 |
+
return entry["data"], "hit"
|
| 67 |
+
data = fetcher() # lock disinda fetch — diger okumalar bloklanmasin
|
| 68 |
+
with _bh_lock:
|
| 69 |
+
if data is not None:
|
| 70 |
+
_bh_cache[kind] = {"data": data, "time": time.time()}
|
| 71 |
+
return data, "miss"
|
| 72 |
+
if entry["data"] is not None:
|
| 73 |
+
return entry["data"], "stale"
|
| 74 |
+
return None, "fail"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def cached_bh_inventory(wid: str) -> tuple[object, str]:
|
| 78 |
+
"""Inventory cache (warehouse-specific)."""
|
| 79 |
+
with _bh_lock:
|
| 80 |
+
entry = _bh_cache["inventory"].get(wid)
|
| 81 |
+
now = time.time()
|
| 82 |
+
if entry and (now - entry["time"] < CACHE_TTL_BH_INVENTORY):
|
| 83 |
+
return entry["data"], "hit"
|
| 84 |
+
data = bh_get(f"{BIZIMHESAP_BASE}/inventory/{wid}")
|
| 85 |
+
with _bh_lock:
|
| 86 |
+
if data is not None:
|
| 87 |
+
_bh_cache["inventory"][wid] = {"data": data, "time": time.time()}
|
| 88 |
+
return data, "miss"
|
| 89 |
+
if entry:
|
| 90 |
+
return entry["data"], "stale"
|
| 91 |
+
return None, "fail"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------- BizimHesap match helper ----------
|
| 95 |
+
def _bh_match_product(item: dict, products: dict) -> dict | None:
|
| 96 |
+
"""Inventory item'ini products dict'inde bul (sku, barcode, name varyantlariyla)."""
|
| 97 |
+
sku = (item.get("sku") or item.get("code") or "").strip()
|
| 98 |
+
if sku and sku in products:
|
| 99 |
+
return products[sku]
|
| 100 |
+
barcode = (item.get("barcode") or "").strip()
|
| 101 |
+
if barcode:
|
| 102 |
+
for p in products.values():
|
| 103 |
+
if p.get("barcode") == barcode:
|
| 104 |
+
return p
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _build_warehouse_xml() -> str | None:
|
| 109 |
+
"""BizimHesap'tan direkt veri cek + Trek PHP formatinda XML uret."""
|
| 110 |
+
pdata = bh_get(f"{BIZIMHESAP_BASE}/products")
|
| 111 |
+
if not pdata or pdata.get("resultCode") != 1:
|
| 112 |
+
return None
|
| 113 |
+
products_raw = pdata.get("data", {}).get("products", [])
|
| 114 |
+
|
| 115 |
+
products: dict[str, dict] = {}
|
| 116 |
+
for p in products_raw:
|
| 117 |
+
code = (p.get("code") or "").strip()
|
| 118 |
+
if not code:
|
| 119 |
+
continue
|
| 120 |
+
products[code] = {
|
| 121 |
+
"sku": code,
|
| 122 |
+
"barcode": (p.get("barcode") or "").strip(),
|
| 123 |
+
"name": (p.get("title") or "").strip(),
|
| 124 |
+
"variant": (p.get("variant") or "").strip(),
|
| 125 |
+
"warehouses": [],
|
| 126 |
+
"total": 0,
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
wdata = bh_get(f"{BIZIMHESAP_BASE}/warehouses")
|
| 130 |
+
if not wdata or wdata.get("resultCode") != 1:
|
| 131 |
+
return None
|
| 132 |
+
warehouses = wdata.get("data", {}).get("warehouses", [])
|
| 133 |
+
|
| 134 |
+
critical_empty = 0
|
| 135 |
+
for wh in warehouses:
|
| 136 |
+
wid = wh.get("id")
|
| 137 |
+
wname = (wh.get("title") or "").strip()
|
| 138 |
+
idata = bh_get(f"{BIZIMHESAP_BASE}/inventory/{wid}")
|
| 139 |
+
inv = idata.get("data", {}).get("inventory", []) if idata else []
|
| 140 |
+
if wname.upper() in CRITICAL_WAREHOUSES and len(inv) == 0:
|
| 141 |
+
critical_empty += 1
|
| 142 |
+
for item in inv:
|
| 143 |
+
try:
|
| 144 |
+
qty = int(item.get("qty", 0))
|
| 145 |
+
except (TypeError, ValueError):
|
| 146 |
+
qty = 0
|
| 147 |
+
if qty <= 0:
|
| 148 |
+
continue
|
| 149 |
+
m = _bh_match_product(item, products)
|
| 150 |
+
if m is not None:
|
| 151 |
+
m["warehouses"].append({"name": wname, "stock": qty})
|
| 152 |
+
m["total"] += qty
|
| 153 |
+
|
| 154 |
+
# Partial-failure koruma
|
| 155 |
+
if critical_empty >= 2:
|
| 156 |
+
logger.warning("[stock] critical_empty>=2, dirty fetch reddedildi")
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
out = [
|
| 160 |
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
| 161 |
+
"<!-- BizimHesap B2B API direct -->",
|
| 162 |
+
f'<!-- Generated: {time.strftime("%Y-%m-%d %H:%M:%S")} -->',
|
| 163 |
+
"<Products>",
|
| 164 |
+
]
|
| 165 |
+
for p in sorted(products.values(), key=lambda x: x["sku"].lower()):
|
| 166 |
+
if not p["warehouses"]:
|
| 167 |
+
continue
|
| 168 |
+
p["warehouses"].sort(key=lambda w: -w["stock"])
|
| 169 |
+
out.append(" <Product>")
|
| 170 |
+
out.append(f' <ProductCode><![CDATA[{p["sku"]}]]></ProductCode>')
|
| 171 |
+
out.append(f' <ProductName><![CDATA[{p["name"]}]]></ProductName>')
|
| 172 |
+
if p["variant"]:
|
| 173 |
+
out.append(f' <ProductVariant><![CDATA[{p["variant"]}]]></ProductVariant>')
|
| 174 |
+
if p["barcode"]:
|
| 175 |
+
out.append(f' <Barcode><![CDATA[{p["barcode"]}]]></Barcode>')
|
| 176 |
+
out.append(f' <TotalStock>{p["total"]}</TotalStock>')
|
| 177 |
+
for w in p["warehouses"]:
|
| 178 |
+
out.append(" <Warehouse>")
|
| 179 |
+
out.append(f' <Name><![CDATA[{w["name"]}]]></Name>')
|
| 180 |
+
out.append(f" <Stock>{w['stock']}</Stock>")
|
| 181 |
+
out.append(" </Warehouse>")
|
| 182 |
+
out.append(" </Product>")
|
| 183 |
+
out.append("</Products>")
|
| 184 |
+
return "\n".join(out)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _fetch_trek_php() -> str | None:
|
| 188 |
+
"""Trek PHP'den XML cek. PHP, V1 HF Space'i proxy'liyor + 15dk cache + stale fallback.
|
| 189 |
+
HF Space'ten BizimHesap'a direkt erisim 502 verirse Trek PHP cache'inden veri alabiliriz."""
|
| 190 |
+
try:
|
| 191 |
+
r = requests.get(TREK_PHP_URL, verify=False, timeout=TREK_PHP_TIMEOUT)
|
| 192 |
+
if r.status_code == 200 and len(r.text) > 1000 and "<Products>" in r.text:
|
| 193 |
+
return r.text
|
| 194 |
+
logger.warning(f"[stock] Trek PHP HTTP {r.status_code} len={len(r.text)}")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
logger.warning(f"[stock] Trek PHP fail: {type(e).__name__}: {e}")
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ---------- Warehouse XML cache (BizimHesap + Trek PHP) ----------
|
| 201 |
+
_warehouse_cache = {"data": None, "time": 0.0}
|
| 202 |
+
_warehouse_lock = threading.Lock()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def get_cached_warehouse_xml() -> str | None:
|
| 206 |
+
"""Tek kanal: cache hit veya fetch (lock'lu) veya stale fallback."""
|
| 207 |
+
with _warehouse_lock:
|
| 208 |
+
now = time.time()
|
| 209 |
+
if _warehouse_cache["data"] and (now - _warehouse_cache["time"] < CACHE_TTL_WAREHOUSE):
|
| 210 |
+
return _warehouse_cache["data"]
|
| 211 |
+
|
| 212 |
+
# Lock disinda fetch (digerleri bloklanmasin), ama tek seferlik
|
| 213 |
+
# — ikinci bir lock fetch_lock ile koruyoruz
|
| 214 |
+
with _warehouse_fetch_lock:
|
| 215 |
+
# Lock alindiginda baska bir thread fetch yapmis olabilir
|
| 216 |
+
with _warehouse_lock:
|
| 217 |
+
now = time.time()
|
| 218 |
+
if _warehouse_cache["data"] and (now - _warehouse_cache["time"] < CACHE_TTL_WAREHOUSE):
|
| 219 |
+
return _warehouse_cache["data"]
|
| 220 |
+
|
| 221 |
+
# 1) Trek PHP (V1 proxy) — kanitli kaynak, 15dk cache + stale fallback
|
| 222 |
+
xml = _fetch_trek_php()
|
| 223 |
+
# 2) Fallback: BizimHesap direkt (V2 kendi cagrisi — 502 olabiliyor)
|
| 224 |
+
if not xml:
|
| 225 |
+
xml = _build_warehouse_xml()
|
| 226 |
+
|
| 227 |
+
with _warehouse_lock:
|
| 228 |
+
if xml:
|
| 229 |
+
_warehouse_cache["data"] = xml
|
| 230 |
+
_warehouse_cache["time"] = time.time()
|
| 231 |
+
return xml
|
| 232 |
+
# Stale fallback
|
| 233 |
+
return _warehouse_cache["data"]
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
_warehouse_fetch_lock = threading.Lock()
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
# ---------- Search results cache (kullanici sorgu -> sonuc) ----------
|
| 240 |
+
_search_cache: dict[str, dict] = {}
|
| 241 |
+
_search_lock = threading.Lock()
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def search_cache_get(key: str, ttl: int):
|
| 245 |
+
with _search_lock:
|
| 246 |
+
entry = _search_cache.get(key)
|
| 247 |
+
if entry and (time.time() - entry["time"] < ttl):
|
| 248 |
+
return entry["data"]
|
| 249 |
+
return None
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def search_cache_set(key: str, data) -> None:
|
| 253 |
+
with _search_lock:
|
| 254 |
+
_search_cache[key] = {"data": data, "time": time.time()}
|
tools.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Realtime asistan icin tool implementasyonlari.
|
| 2 |
+
|
| 3 |
+
WhatsApp BF-WAB'da kanitlanmis smart_warehouse_with_price.py'yi kullanir.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
from smart_warehouse_with_price import get_warehouse_stock_smart_with_price
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------- Telaffuz duzeltmeleri ----------
|
| 16 |
+
def apply_pronunciation_fixes(text: str) -> str:
|
| 17 |
+
if not isinstance(text, str):
|
| 18 |
+
return text
|
| 19 |
+
return text.replace("Caddebostan", "Cadde Bostan")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------- URL'leri tool sonucundan strip ----------
|
| 23 |
+
def strip_urls(text: str) -> str:
|
| 24 |
+
"""Modelin URL okumamasi icin tool result'undan link satirlari kaldirilir."""
|
| 25 |
+
if not text:
|
| 26 |
+
return text
|
| 27 |
+
text = re.sub(r"(?im)^\s*(?:Link|URL|Url|🔗.*?):.*$", "", text)
|
| 28 |
+
text = re.sub(r"https?://\S+", "", text)
|
| 29 |
+
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
| 30 |
+
return text
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ---------- OpenAI realtime tool definition ----------
|
| 34 |
+
TOOLS = [
|
| 35 |
+
{
|
| 36 |
+
"type": "function",
|
| 37 |
+
"name": "get_warehouse_stock",
|
| 38 |
+
"description": (
|
| 39 |
+
"Trek bisiklet, aksesuar veya yedek parca icin magaza stok durumu, "
|
| 40 |
+
"fiyat ve urun linkini getirir. Musteri stok, fiyat veya urun "
|
| 41 |
+
"varligini sordugunda kullan."
|
| 42 |
+
),
|
| 43 |
+
"parameters": {
|
| 44 |
+
"type": "object",
|
| 45 |
+
"properties": {
|
| 46 |
+
"user_message": {
|
| 47 |
+
"type": "string",
|
| 48 |
+
"description": (
|
| 49 |
+
"Musterinin urun/stok sorusu "
|
| 50 |
+
"(orn. 'Madone SLR 9 var mi', 'Marlin 5 fiyat')"
|
| 51 |
+
),
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
"required": ["user_message"],
|
| 55 |
+
},
|
| 56 |
+
}
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def handle_tool_call_sync(name: str, arguments: dict) -> str:
|
| 61 |
+
"""Tool dispatcher. Sync — realtime relay'de to_thread ile sarilacak."""
|
| 62 |
+
try:
|
| 63 |
+
if name == "get_warehouse_stock":
|
| 64 |
+
msg = arguments.get("user_message", "")
|
| 65 |
+
logger.info(f"[tool] get_warehouse_stock query: {msg!r}")
|
| 66 |
+
result = get_warehouse_stock_smart_with_price(msg)
|
| 67 |
+
if result is None:
|
| 68 |
+
return "Stok bilgisi bulunamadi."
|
| 69 |
+
return apply_pronunciation_fixes(str(result))
|
| 70 |
+
return f"Bilinmeyen fonksiyon: {name}"
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.exception(f"Tool call hatasi ({name})")
|
| 73 |
+
return f"Hata: {e}"
|
warehouse_stock_finder.py
DELETED
|
@@ -1,89 +0,0 @@
|
|
| 1 |
-
"""Ultra simple and fast warehouse stock finder"""
|
| 2 |
-
|
| 3 |
-
def get_warehouse_stock(product_name):
|
| 4 |
-
"""Find warehouse stock FAST - no XML parsing, just regex"""
|
| 5 |
-
try:
|
| 6 |
-
import re
|
| 7 |
-
import requests
|
| 8 |
-
|
| 9 |
-
# Get XML
|
| 10 |
-
url = 'https://video.trek-turkey.com/bizimhesap-warehouse-xml-b2b-api-v2.php'
|
| 11 |
-
response = requests.get(url, verify=False, timeout=7)
|
| 12 |
-
xml_text = response.text
|
| 13 |
-
|
| 14 |
-
# Turkish normalize
|
| 15 |
-
def normalize(text):
|
| 16 |
-
tr_map = {'ı': 'i', 'ğ': 'g', 'ü': 'u', 'ş': 's', 'ö': 'o', 'ç': 'c', 'İ': 'i', 'I': 'i'}
|
| 17 |
-
text = text.lower()
|
| 18 |
-
for tr, en in tr_map.items():
|
| 19 |
-
text = text.replace(tr, en)
|
| 20 |
-
return text
|
| 21 |
-
|
| 22 |
-
# Parse query
|
| 23 |
-
query = normalize(product_name.strip()).replace('(2026)', '').replace('(2025)', '').strip()
|
| 24 |
-
words = query.split()
|
| 25 |
-
|
| 26 |
-
# Find size
|
| 27 |
-
sizes = ['s', 'm', 'l', 'xl', 'xs', 'xxl', 'ml']
|
| 28 |
-
size = next((w for w in words if w in sizes), None)
|
| 29 |
-
product_words = [w for w in words if w not in sizes and w not in ['beden', 'size', 'boy']]
|
| 30 |
-
|
| 31 |
-
# Build search pattern
|
| 32 |
-
if 'madone' in product_words and 'sl' in product_words and '6' in product_words:
|
| 33 |
-
pattern = 'MADONE SL 6 GEN 8'
|
| 34 |
-
else:
|
| 35 |
-
pattern = ' '.join(product_words).upper()
|
| 36 |
-
|
| 37 |
-
print(f"DEBUG - Searching: {pattern}, Size: {size}")
|
| 38 |
-
|
| 39 |
-
# Search for product + variant combo
|
| 40 |
-
if size:
|
| 41 |
-
# Direct search for product with specific size
|
| 42 |
-
size_pattern = f'{size.upper()}-'
|
| 43 |
-
|
| 44 |
-
# Find all occurrences of the product name
|
| 45 |
-
import re
|
| 46 |
-
product_regex = f'<Product>.*?<ProductName><!\\[CDATA\\[{re.escape(pattern)}\\]\\]></ProductName>.*?<ProductVariant><!\\[CDATA\\[{size_pattern}.*?\\]\\]></ProductVariant>.*?</Product>'
|
| 47 |
-
|
| 48 |
-
match = re.search(product_regex, xml_text, re.DOTALL)
|
| 49 |
-
|
| 50 |
-
if match:
|
| 51 |
-
product_block = match.group(0)
|
| 52 |
-
print(f"DEBUG - Found product with {size_pattern} variant")
|
| 53 |
-
|
| 54 |
-
# Extract warehouses
|
| 55 |
-
warehouse_info = []
|
| 56 |
-
warehouse_regex = r'<Warehouse>.*?<Name><!\\[CDATA\\[(.*?)\\]\\]></Name>.*?<Stock>(.*?)</Stock>.*?</Warehouse>'
|
| 57 |
-
warehouses = re.findall(warehouse_regex, product_block, re.DOTALL)
|
| 58 |
-
|
| 59 |
-
for wh_name, wh_stock in warehouses:
|
| 60 |
-
try:
|
| 61 |
-
stock = int(wh_stock.strip())
|
| 62 |
-
if stock > 0:
|
| 63 |
-
# Format name
|
| 64 |
-
if "CADDEBOSTAN" in wh_name:
|
| 65 |
-
display = "Caddebostan mağazası"
|
| 66 |
-
elif "ORTAKÖY" in wh_name:
|
| 67 |
-
display = "Ortaköy mağazası"
|
| 68 |
-
elif "ALSANCAK" in wh_name:
|
| 69 |
-
display = "İzmir Alsancak mağazası"
|
| 70 |
-
elif "BAHCEKOY" in wh_name or "BAHÇEKÖY" in wh_name:
|
| 71 |
-
display = "Bahçeköy mağazası"
|
| 72 |
-
else:
|
| 73 |
-
display = wh_name
|
| 74 |
-
|
| 75 |
-
warehouse_info.append(f"{display}: Mevcut")
|
| 76 |
-
except:
|
| 77 |
-
pass
|
| 78 |
-
|
| 79 |
-
return warehouse_info if warehouse_info else ["Hiçbir mağazada mevcut değil"]
|
| 80 |
-
else:
|
| 81 |
-
print(f"DEBUG - No {size_pattern} variant found for {pattern}")
|
| 82 |
-
return ["Hiçbir mağazada mevcut değil"]
|
| 83 |
-
else:
|
| 84 |
-
# No size filter - get all stock
|
| 85 |
-
return ["Beden bilgisi belirtilmedi"]
|
| 86 |
-
|
| 87 |
-
except Exception as e:
|
| 88 |
-
print(f"Error: {e}")
|
| 89 |
-
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|