SamiKoen commited on
Commit
0b52cfc
·
1 Parent(s): 2fb7273

Mirror V2 content into V1 (snapshot for continued development)

Browse files
.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: indigo
5
- colorTo: purple
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # BF-Realtime
12
 
13
- Trek Bisiklet sesli satis asistani OpenAI Realtime API ile canli ses sohbet.
14
 
15
- ## Mimari
16
 
17
- - **Backend:** FastAPI + WebSocket (relay)
18
- - **Model:** `gpt-realtime` (OpenAI Realtime API)
19
- - **Tool calling:** Stok ve fiyat sorgusu icin `get_warehouse_stock_smart_with_price`
20
- - **Frontend:** Web Audio API (PCM16, 24kHz)
 
 
 
 
 
 
21
 
22
- ## Environment Variables
23
 
24
- `OPENAI_API_KEY` Space Settings -> Variables and secrets'tan ayarlanmali.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- BF-Realtime - Trek Bisiklet sesli satis asistani
3
- FastAPI + WebSocket relay -> OpenAI Realtime API
4
- """
5
- import os
6
- import json
7
  import asyncio
8
  import logging
9
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
10
- from fastapi.responses import FileResponse
 
11
  from fastapi.staticfiles import StaticFiles
12
- import websockets
13
 
14
- # Local mantik
15
- from smart_warehouse_with_price import get_warehouse_stock_smart_with_price
16
- from prompts import get_active_prompt_content_only
 
 
 
 
 
 
 
 
 
 
17
 
18
  logging.basicConfig(level=logging.INFO)
19
  logger = logging.getLogger(__name__)
20
 
21
- OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
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 _startup_warmup():
31
- """XML cache + parse index + stok XML'i thread'de baslat — event loop'u bloklama."""
32
- async def loop():
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('warehouse xml refresh hatasi')
44
- await asyncio.sleep(3600) # 1 saatte bir tazele (cache 24h, ama erken refresh)
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
- @app.get("/debug-warehouse")
63
- async def debug_warehouse():
64
- """get_cached_warehouse_xml() durumunu kontrol et — Cloudflare/Trek PHP debug icin."""
65
- import re
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
- """Trek PHP, ideasoft sayfalari ve diger 5 program icin merkezi XML endpoint.
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<!-- HF Space: no data -->\n<Products></Products>',
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", # 5 dakika downstream cache
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 fastapi.responses import JSONResponse
139
- from smart_warehouse_with_price import _bh_get, BIZIMHESAP_BASE
140
- data, status = _bh_cached("products", lambda: _bh_get(f"{BIZIMHESAP_BASE}/products"))
 
 
 
 
 
141
  if data is None:
142
- return JSONResponse({"resultCode": 0, "errorText": "BizimHesap fetch failed", "data": None}, status_code=502)
 
 
 
143
  return JSONResponse(
144
  data,
145
- headers={"Access-Control-Allow-Origin": "*", "X-Cache": status, "Cache-Control": "public, max-age=300"},
 
 
 
 
146
  )
147
 
148
 
149
  @app.get("/bh/warehouses")
150
  async def bh_warehouses():
151
- from fastapi.responses import JSONResponse
152
- from smart_warehouse_with_price import _bh_get, BIZIMHESAP_BASE
153
- data, status = _bh_cached("warehouses", lambda: _bh_get(f"{BIZIMHESAP_BASE}/warehouses"))
 
 
 
 
 
154
  if data is None:
155
- return JSONResponse({"resultCode": 0, "errorText": "BizimHesap fetch failed", "data": None}, status_code=502)
 
 
 
156
  return JSONResponse(
157
  data,
158
- headers={"Access-Control-Allow-Origin": "*", "X-Cache": status, "Cache-Control": "public, max-age=300"},
 
 
 
 
159
  )
160
 
161
 
162
  @app.get("/bh/inventory/{wid}")
163
  async def bh_inventory(wid: str):
164
- from fastapi.responses import JSONResponse
165
- from smart_warehouse_with_price import _bh_get, BIZIMHESAP_BASE
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
- entry["data"],
183
- headers={"Access-Control-Allow-Origin": "*", "X-Cache": "stale", "Cache-Control": "public, max-age=60"},
184
  )
185
- return JSONResponse({"resultCode": 0, "errorText": "BizimHesap fetch failed", "data": None}, status_code=502)
186
-
187
-
188
- @app.get("/debug-search")
189
- async def debug_search(q: str):
190
- """Bir kullanici sorgusunu (q) get_warehouse_stock_smart_with_price'a ver, sonucu don."""
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
- except websockets.exceptions.ConnectionClosed:
826
- logger.info("OpenAI WebSocket kapandi")
827
- except Exception as e:
828
- logger.error(f"openai_to_client error: {e}")
 
829
 
830
- await asyncio.gather(client_to_openai(), openai_to_client())
831
 
832
- except Exception as e:
833
- logger.exception("Realtime relay hatasi")
834
- try:
835
- await client_ws.send_text(json.dumps({
836
- "type": "error",
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>=0.115.0
2
- uvicorn[standard]>=0.32.0
3
- websockets>=13.0
4
- python-multipart>=0.0.9
5
- requests>=2.32.0
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 - 2 hours (reduced from 12 hours for more accurate results)
14
- CACHE_DURATION = 86400 # 24 saat — Cloudflare 1015 ban riskine karsi uzun tut
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
- """Get Trek XML with 12-hour caching"""
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
- url = 'https://www.trekbisiklet.com.tr/output/2688003925'
32
- response = requests.get(url, verify=False, timeout=10)
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
- except Exception as e:
 
 
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
- """Warehouse XML 2 saatlik cache.
449
- Once BizimHesap B2B API'sine direkt baglan; basarisizsa Trek PHP fallback."""
450
- current_time = time.time()
451
-
452
- if cache['warehouse_xml']['data'] and (current_time - cache['warehouse_xml']['time'] < CACHE_DURATION):
453
- return cache['warehouse_xml']['data']
454
-
455
- # 1. Tercih: BizimHesap'a direkt
456
- xml_text = _build_warehouse_xml()
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
- # NOT: Asistan zaten 'stok ara' fonksiyonunu cagiriyor, ek soru-filtre
524
- # gereksiz ve cok agresif (her '?' icin dusuyordu). Sadece live_support
525
- # ve non_product_words yeterli.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=45
724
  )
725
 
726
  if response.status_code == 200:
727
  result = response.json()
728
  indices_str = result['choices'][0]['message']['content'].strip()
729
- logger.info(f"[search] GPT indices: {indices_str!r}")
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.upper() in wh['name'].upper():
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
- # Sadece magaza isimleri (adet gizli)
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
- result.append(f" {v['variant']} {wh_str}")
884
- else:
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