emreceniklioglu commited on
Commit
49102bb
·
0 Parent(s):

Initial commit: LangGraph multi-agent car finance chatbot

Browse files
Files changed (45) hide show
  1. .gitignore +30 -0
  2. README.md +365 -0
  3. chatbot/DIAGRAMS.md +149 -0
  4. chatbot/agents/__init__.py +0 -0
  5. chatbot/agents/crosssell_agent.py +86 -0
  6. chatbot/agents/faq_agent.py +134 -0
  7. chatbot/agents/intake_new.py +260 -0
  8. chatbot/agents/intake_used.py +229 -0
  9. chatbot/agents/reflection_agent.py +139 -0
  10. chatbot/agents/submission_agent.py +70 -0
  11. chatbot/agents/supervisor.py +196 -0
  12. chatbot/agents/validation_agent.py +126 -0
  13. chatbot/app.py +272 -0
  14. chatbot/config.py +58 -0
  15. chatbot/data/faq.md +180 -0
  16. chatbot/data/vehicle_catalog.json +150 -0
  17. chatbot/graph/__init__.py +0 -0
  18. chatbot/graph/edges.py +168 -0
  19. chatbot/graph/nodes.py +242 -0
  20. chatbot/graph/state.py +105 -0
  21. chatbot/graph/workflow.py +220 -0
  22. chatbot/guardrails/__init__.py +0 -0
  23. chatbot/guardrails/input_guard.py +93 -0
  24. chatbot/guardrails/output_guard.py +97 -0
  25. chatbot/llm/__init__.py +0 -0
  26. chatbot/llm/factory.py +141 -0
  27. chatbot/llm/json_utils.py +31 -0
  28. chatbot/llm/prompts.py +151 -0
  29. chatbot/memory/__init__.py +0 -0
  30. chatbot/memory/checkpointer.py +34 -0
  31. chatbot/memory/vector_store.py +173 -0
  32. chatbot/observability/__init__.py +0 -0
  33. chatbot/observability/audit_logger.py +207 -0
  34. chatbot/observability/dashboard.py +81 -0
  35. chatbot/observability/metrics.py +172 -0
  36. chatbot/observability/tracer.py +65 -0
  37. chatbot/requirements.txt +40 -0
  38. chatbot/setup.py +108 -0
  39. chatbot/tools/__init__.py +0 -0
  40. chatbot/tools/amount_tool.py +129 -0
  41. chatbot/tools/catalog_tool.py +69 -0
  42. chatbot/tools/db_tool.py +113 -0
  43. chatbot/tools/hgs_tool.py +53 -0
  44. chatbot/tools/rag_tool.py +44 -0
  45. chatbot/tools/tckn_tool.py +62 -0
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+
9
+ # Ortam değişkenleri
10
+ .env
11
+ .env.*
12
+
13
+ # Veritabanı
14
+ *.db
15
+ *.sqlite3
16
+
17
+ # ChromaDB persist
18
+ chatbot/data/chroma/
19
+
20
+ # Log dosyaları
21
+ *.log
22
+ chatbot/app_out.log
23
+
24
+ # IDE
25
+ .vscode/
26
+ .idea/
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
README.md ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Araç Finansmanı Chatbot — Agentic Demo
2
+
3
+ Bankacılık mobil uygulaması için geliştirilmiş **LangGraph tabanlı çok-ajanlı (multi-agent)** araç finansmanı ön başvuru chatbotu. Müşteri, konuşma arayüzü üzerinden yeni veya ikinci el araç finansmanı ön başvurusunu tamamen otomatik olarak tamamlayabilir; süreç boyunca SSS sorabilir, verilerini düzeltebilir ve çapraz satış teklifini kabul edebilir.
4
+
5
+ ---
6
+
7
+ ## İçindekiler
8
+
9
+ 1. [Proje Kapsamı](#1-proje-kapsamı)
10
+ 2. [İş Kuralları](#2-iş-kuralları)
11
+ 3. [Agentic Workflow](#3-agentic-workflow)
12
+ 4. [Agent'lar](#4-agentlar)
13
+ 5. [Tool'lar (Araçlar)](#5-toollar-araçlar)
14
+ 6. [Güvenlik Katmanları](#6-güvenlik-katmanları)
15
+ 7. [Teknolojiler](#7-teknolojiler)
16
+ 8. [Proje Yapısı](#8-proje-yapısı)
17
+ 9. [İzleme Paneli](#9-i̇zleme-paneli)
18
+ 10. [Kurulum ve Çalıştırma](#10-kurulum-ve-çalıştırma)
19
+ 11. [Konfigürasyon](#11-konfigürasyon)
20
+ 12. [Diyagramlar](#12-diyagramlar)
21
+
22
+ ---
23
+
24
+ ## 1. Proje Kapsamı
25
+
26
+ | Kapsam İçi | Kapsam Dışı |
27
+ |---|---|
28
+ | Yeni araç finansman ön başvurusu | Nihai kredi onayı |
29
+ | 2. el araç finansman ön başvurusu | Faiz oranı garantisi |
30
+ | SSS / genel bilgi sorguları (RAG) | Ticari araç finansmanı |
31
+ | HGS çapraz satış teklifi | Bireysel kredi, konut kredisi vb. |
32
+ | Hedefli alan güncellemesi | Şube işlemleri |
33
+ | Başvuru numarası oluşturma | Gerçek kredi skorlama |
34
+
35
+ Sistem, müşteriye **adım adım rehberlik** eder. LLM her cevabı üretmez; deterministik iş kuralları önceliklidir ve LLM yalnızca niyet sınıflandırma, slot çıkarımı ile FAQ cevaplama için kullanılır.
36
+
37
+ ---
38
+
39
+ ## 2. İş Kuralları
40
+
41
+ ### 2.1 Yeni Araç Finansmanı
42
+
43
+ | Kural | Değer |
44
+ |---|---|
45
+ | Proforma fatura üst limiti | **7.000.000 TL** |
46
+ | Maksimum finansman oranı | Fatura tutarının **%60'ı** |
47
+ | Kefil zorunluluğu eşiği | Fatura **≥ 5.000.000 TL** ise kefil TCKN zorunlu |
48
+ | Araç tipi kısıtı | Yalnızca **binek** araç — ticari araçlar reddedilir |
49
+ | Limit aşımı davranışı | Sistem maksimum tutarı önerir, müşteriden yeni değer ister |
50
+
51
+ ### 2.2 İkinci El Araç Finansmanı
52
+
53
+ | Kural | Değer |
54
+ |---|---|
55
+ | Maksimum araç yaşı | **5 yıl** |
56
+ | Maksimum finansman oranı | Kasko değerinin **%40'ı** |
57
+ | Mutlak finansman üst limiti | **3.000.000 TL** |
58
+ | Uygulanan limit | `min(%40 × kasko, 3.000.000 TL)` |
59
+ | Satıcı TCKN | Opsiyonel (müşteri vermek istemeyebilir) |
60
+
61
+ ### 2.3 Genel Kurallar
62
+
63
+ - **TCKN format kontrolü** intake aşamasında regex ile yapılır: 11 hane, yalnızca rakam, sıfırla başlamaz. Mod-11 doğrulama fonksiyonu (`validate_tckn`) araç olarak kodda hazır fakat validation agent tarafından şu an çağrılmıyor.
64
+ - Tüm TCKN'ler veritabanına yazılmadan önce **maskelenir** (`123****8901` formatı).
65
+ - Müşteri onay ekranında her alanı **hedefli olarak değiştirebilir**; yalnızca değiştirilen alan ve ona bağımlı alanlar yeniden sorulur.
66
+ - Başvuru tamamlandıktan sonra UUID formatında **başvuru numarası** üretilir.
67
+ - HGS çapraz satış yalnızca **aktif HGS'i olmayan** müşterilere sunulur.
68
+
69
+ ---
70
+
71
+ ## 3. Agentic Workflow
72
+
73
+ Sistem **Supervisor + Specialist Agents** mimarisi üzerine inşa edilmiştir.
74
+
75
+ ```
76
+ Müşteri Mesajı
77
+
78
+
79
+ [Input Guard] ← PII maskeleme, injection tespiti
80
+
81
+
82
+ [Supervisor] ← Niyet sınıflandırma, yönlendirme
83
+
84
+ ┌────┴─────────────────────────────────────────┐
85
+ │ │ │ │ │
86
+ ▼ ▼ ▼ ▼ ▼
87
+ [Intake [Intake [FAQ/RAG [Submission [Cross-sell
88
+ Yeni] 2.El] Agent] Agent] Agent HGS]
89
+ │ │
90
+ └────┬──────┘
91
+
92
+ [Validation Agent] ← TCKN, katalog, iş kuralı kontrolleri
93
+
94
+
95
+ [Output Guard] ← PII redaction, finansal taahhüt filtresi
96
+
97
+
98
+ Müşteri Cevabı
99
+ ```
100
+
101
+ ### Paralel FAQ Akışı
102
+
103
+ Müşteri herhangi bir adımda SSS sorarsa:
104
+ 1. Supervisor `intent=faq` tespit eder
105
+ 2. Mevcut adım bilgisi state içindeki `faq_snapshot` alanına kaydedilir (`{"step": current_step}`)
106
+ 3. FAQ Agent soruyu yanıtlar
107
+ 4. Oturum önceki adımdan **devam eder** — müşteri yerini kaybetmez
108
+
109
+ ### Hedefli Güncelleme Akışı
110
+
111
+ Onay ekranında müşteri alan değiştirmek istediğinde:
112
+ 1. LLM hangi alanın değiştirileceğini çıkarır
113
+ 2. Yalnızca o alan yeniden sorulur
114
+ 3. Bağımlı alanlar bozulduysa otomatik uyarı verilir
115
+
116
+ ---
117
+
118
+ ## 4. Agent'lar
119
+
120
+ | Agent | Sorumluluk | Agentic Desen |
121
+ |---|---|---|
122
+ | **Supervisor Agent** | Niyet sınıflandırma, yönlendirme, planlama | Planner + Router |
123
+ | **Intake Agent (Yeni Araç)** | Fatura, model, finansman, kefil TCKN toplanması; araç kataloğu sorgusu (binek/ticari doğrulama) | Slot Filling + Tool Use |
124
+ | **Intake Agent (2. El)** | Kasko, yaş, finansman, satıcı TCKN toplanması | Slot Filling + Tool Use |
125
+ | **Validation Agent** | Finansman limit kontrolü, iş kuralı kontrolleri | Tool Use (deterministik) |
126
+ | **FAQ/RAG Agent** | SSS sorularını vektör DB'den yanıtlama | RAG + Tool Use |
127
+ | **Submission Agent** | Onaylanan başvuruyu SQLite'a kaydetme | Tool Use + Write |
128
+ | **Cross-sell Agent** | HGS durumu sorgulama ve kayıt | Tool Use (Scoped Access) |
129
+
130
+ Her agent **least-privilege** prensibine göre çalışır: yalnızca ihtiyaç duyduğu tool'lara erişimi vardır.
131
+
132
+ ---
133
+
134
+ ## 5. Tool'lar (Araçlar)
135
+
136
+ | Tool | Dosya | Açıklama |
137
+ |---|---|---|
138
+ | `validate_tckn` / `_mask` | `tools/tckn_tool.py` | Mod-11 doğrulama fonksiyonu (hazır, şu an çağrılmıyor) + TCKN maskeleme (`_mask` her yerde aktif) |
139
+ | `lookup_vehicle` | `tools/catalog_tool.py` | Marka/model → binek/ticari sınıflandırması (JSON katalog) |
140
+ | `retrieve_faq` | `tools/rag_tool.py` | ChromaDB'den semantic arama, chunk döndürme |
141
+ | `write_application` | `tools/db_tool.py` | Ön başvuruyu SQLite'a kayıt |
142
+ | `check_hgs_status` | `tools/hgs_tool.py` | Müşterinin aktif HGS'i var mı? (mock) |
143
+ | `register_hgs` | `tools/hgs_tool.py` | HGS başvurusu kayıt (mock) |
144
+
145
+ Tüm tool'lar `@tool` decorator ile LangChain'e kayıtlıdır. LLM hangi tool'u çağıracağına karar verir; iş mantığı tool içinde deterministik çalışır.
146
+
147
+ ---
148
+
149
+ ## 6. Güvenlik Katmanları
150
+
151
+ Bu projede uygulanan güvenlik katmanları:
152
+
153
+ | Katman | Bileşen | İşlev |
154
+ |---|---|---|
155
+ | **L4** | Input Guard (`guardrails/input_guard.py`) | PII maskeleme (TCKN, IBAN, kart), prompt injection tespiti, uzunluk limiti |
156
+ | **L5** | Orchestrator | Least-privilege tool erişimi, deterministik kural motoru |
157
+ | **L7** | Output Guard (`guardrails/output_guard.py`) | PII redaction, finansal taahhüt filtresi, FAQ kaynak zorunluluğu |
158
+
159
+ > L1 (TLS), L2 (IDP), L3 (API Gateway/WAF) bu projenin kapsam dışındaki altyapı gereksinimleridir.
160
+
161
+ Tüm guard ve tool olayları **SQLite Audit Log**'a yazılır (`audit_events` tablosu, append-only); TCKN gibi hassas veriler maskelenmiş haliyle kaydedilir.
162
+
163
+ ---
164
+
165
+ ## 7. Teknolojiler
166
+
167
+ ### Çerçeveler ve Kütüphaneler
168
+
169
+ | Teknoloji | Kullanım Amacı |
170
+ |---|---|
171
+ | **LangGraph** | Multi-agent state machine, checkpointing, graph orchestration |
172
+ | **LangChain** | Tool dekoratörü, LLM soyutlama katmanı, mesaj formatları |
173
+ | **Gradio** | Chatbot web arayüzü |
174
+ | **OpenTelemetry** | Tracing altyapısı kurulu (`observability/tracer.py`), şu an ConsoleSpanExporter ile terminale yazıyor; Jaeger/Grafana bağlantısı yok |
175
+
176
+ ### Yapay Zeka / LLM
177
+
178
+ | Teknoloji | Kullanım Amacı |
179
+ |---|---|
180
+ | **GPT-4o** (`ACTIVE_MODEL`) | Ana model — FAQ cevaplama, çok adımlı muhakeme |
181
+ | **GPT-4o-mini** (`ROUTER_MODEL`) | Hızlı model — niyet sınıflandırma, slot çıkarımı |
182
+ | **Claude (Anthropic)** | Opsiyonel alternatif model |
183
+ | **Gemini (Google)** | Opsiyonel alternatif model |
184
+ | **Jina Embeddings v3** | FAQ chunk embedding (API tabanlı) |
185
+
186
+ ### Depolama
187
+
188
+ | Teknoloji | Kullanım Amacı |
189
+ |---|---|
190
+ | **ChromaDB** | Vektör veritabanı — FAQ RAG için semantic arama |
191
+ | **SQLite** | Ön başvuru ve HGS kayıtları |
192
+ | **LangGraph MemorySaver** | Oturum başına konuşma durumu (short-term memory) |
193
+
194
+ ### Üretim Hedefi (Planlanan, Şu An Kodda Yok)
195
+
196
+ | Teknoloji | Kullanım Amacı |
197
+ |---|---|
198
+ | **vLLM** | LLM inference sunucusu (Llama 3.1 8B / 70B) |
199
+ | **BAAI/bge-m3** | On-prem embedding modeli |
200
+ | **Qdrant** | On-prem vektör veritabanı |
201
+ | **Redis** | Oturum store ve checkpointer |
202
+
203
+ ---
204
+
205
+ ## 8. Proje Yapısı
206
+
207
+ ```
208
+ CarFinanceAgent/
209
+ └── chatbot/
210
+ ├── app.py # Gradio arayüzü — giriş noktası
211
+ ├── config.py # Merkezi konfigürasyon, iş kuralları, API key'ler
212
+ ├── setup.py # Kurulum scripti (DB init, FAQ indeksleme)
213
+
214
+ ├── graph/
215
+ │ ├── state.py # ConversationState TypedDict
216
+ │ └── builder.py # LangGraph graph tanımı, edge'ler
217
+
218
+ ├── agents/
219
+ │ ├── supervisor.py # Niyet sınıflandırma, yönlendirme
220
+ │ ├── intake_new.py # Yeni araç slot filling
221
+ │ ├── intake_used.py # 2. el slot filling
222
+ │ ├── faq_agent.py # RAG tabanlı SSS yanıtlama
223
+ │ ├── validation_agent.py # TCKN, katalog, iş kuralı doğrulaması
224
+ │ ├── submission_agent.py # Başvuru kayıt
225
+ │ └── crosssell_agent.py # HGS çapraz satış
226
+
227
+ ├── tools/
228
+ │ ├── tckn_tool.py # TCKN mod-11 doğrulama
229
+ │ ├── catalog_tool.py # Araç kataloğu lookup
230
+ │ ├── rag_tool.py # ChromaDB retrieval
231
+ │ ├── db_tool.py # SQLite yazma/okuma
232
+ │ └── hgs_tool.py # HGS mock servisi
233
+
234
+ ├── guardrails/
235
+ │ ├── input_guard.py # L4: PII maskeleme, injection filtresi
236
+ │ └── output_guard.py # L7: PII redaction, finansal taahhüt filtresi
237
+
238
+ ├── memory/
239
+ │ ├── checkpointer.py # LangGraph MemorySaver konfigürasyonu
240
+ │ └── vector_store.py # ChromaDB bağlantısı, indexleme, arama
241
+
242
+ ├── llm/
243
+ │ ├── router.py # LLM seçimi (ACTIVE_MODEL / ROUTER_MODEL)
244
+ │ └── prompts.py # Tüm sistem prompt şablonları
245
+
246
+ ├── observability/
247
+ │ ├── tracer.py # OpenTelemetry tracing altyapısı (ConsoleSpanExporter)
248
+ │ ├── audit_logger.py # KVKK uyumlu SQLite audit log
249
+ │ ├── metrics.py # SQLite'tan metrik hesaplama (latency, dönüşüm oranı vb.)
250
+ │ └── dashboard.py # Gradio İzleme Paneli sekmesi
251
+
252
+ └── data/
253
+ ├── faq.md # SSS kaynak dokümanı (Markdown)
254
+ ├── vehicle_catalog.json # Araç marka/model → binek/ticari kataloğu
255
+ ├── chroma/ # ChromaDB persist dizini
256
+ └── applications.db # SQLite veritabanı
257
+ ```
258
+
259
+ ---
260
+
261
+ ## 9. İzleme Paneli
262
+
263
+ Uygulama, sohbet sekmesinin yanında **📊 İzleme Paneli** sekmesi içerir. Yenile butonuna basıldığında SQLite `audit_events` tablosundan anlık veri çeker; ek bir servis gerekmez.
264
+
265
+ ### Gösterilen Metrikler
266
+
267
+ | Metrik | Kaynak |
268
+ |---|---|
269
+ | Toplam başvuru sayısı | `applications` tablosu |
270
+ | HGS dönüşüm oranı (%) | `hgs_registrations / applications` |
271
+ | Guard tetiklenme sayısı | `audit_events` — `event_type=guard_event` |
272
+ | LLM latency p50 / p95 (ms) | Son 100 `llm_call` olayından hesaplanır |
273
+
274
+ ### Denetim Olay Tablosu
275
+
276
+ Son 30 audit olayı listelenir: oturum ID, saat, olay türü (`llm_call`, `tool_call`, `guard_event`, `slot_change`), agent adı ve özet.
277
+
278
+ ### LLM Çağrı Tablosu
279
+
280
+ Son 50 LLM çağrısı; model, latency, token sayısı, prompt ve response önizlemesiyle gösterilir. Kısmî oturum UUID'siyle filtrelenebilir.
281
+
282
+ > İlgili dosyalar: `observability/dashboard.py` (Gradio sekme), `observability/metrics.py` (SQLite sorgular), `observability/audit_logger.py` (kayıt).
283
+
284
+ ---
285
+
286
+ ## 10. Kurulum ve Çalıştırma
287
+
288
+ ### Gereksinimler
289
+
290
+ - Python 3.11+
291
+ - OpenAI API key (GPT-4o için)
292
+ - Jina AI API key (FAQ embedding için)
293
+
294
+ ### Adımlar
295
+
296
+ ```bash
297
+ # 1. Sanal ortam oluştur ve aktifleştir
298
+ cd chatbot
299
+ python -m venv .venv
300
+ .venv\Scripts\activate # Windows
301
+ # source .venv/bin/activate # Linux/Mac
302
+
303
+ # 2. Bağımlılıkları yükle
304
+ pip install -r requirements.txt
305
+
306
+ # 3. Ortam değişkenlerini ayarla
307
+ cp .env.example .env
308
+ # .env dosyasını düzenleyip API key'leri gir
309
+
310
+ # 4. İlk kurulumu çalıştır (DB init + FAQ indeksleme)
311
+ python setup.py
312
+
313
+ # 5. Uygulamayı başlat
314
+ python app.py
315
+ ```
316
+
317
+ Uygulama başladıktan sonra `http://localhost:7860` adresinde Gradio arayüzü açılır.
318
+
319
+ ---
320
+
321
+ ## 11. Konfigürasyon
322
+
323
+ `config.py` dosyası merkezi konfigürasyon noktasıdır. Model değiştirmek için yalnızca `.env` dosyasını güncellemek yeterlidir:
324
+
325
+ ```env
326
+ # .env
327
+ OPENAI_API_KEY=sk-...
328
+ ANTHROPIC_API_KEY=sk-ant-...
329
+ GOOGLE_API_KEY=AI...
330
+ JINA_API_KEY=jina_...
331
+
332
+ # Model seçimi
333
+ ACTIVE_MODEL=gpt-4o # veya: claude-opus-4-7, gemini-2.0-flash
334
+ ROUTER_MODEL=gpt-4o-mini # hızlı sınıflandırma modeli
335
+ ```
336
+
337
+ ### İş Kurallarını Değiştirme
338
+
339
+ Tüm eşikler `config.py`'de sabitler olarak tanımlıdır:
340
+
341
+ ```python
342
+ NEW_CAR_MAX_INVOICE = 7_000_000.0 # TL
343
+ NEW_CAR_FINANCE_RATIO = 0.60 # %60
344
+ NEW_CAR_GUARANTOR_THRESHOLD = 5_000_000.0
345
+ USED_CAR_MAX_AGE = 5 # yıl
346
+ USED_CAR_FINANCE_RATIO = 0.40 # %40
347
+ USED_CAR_MAX_FINANCE = 3_000_000.0 # TL
348
+ ```
349
+
350
+ ---
351
+
352
+ ## 12. Diyagramlar
353
+
354
+ Detaylı sistem mimarisi, use case akışları ve güvenlik katmanı diyagramları için [chatbot/DIAGRAMS.md](chatbot/DIAGRAMS.md) dosyasına bakın.
355
+
356
+ > **Not:** Diyagramlar Mermaid formatındadır. VS Code'da görüntülemek için **"Markdown Preview Mermaid Support"** uzantısını yükleyin, ardından `Ctrl+Shift+V` ile önizleyin. Alternatif olarak [mermaid.live](https://mermaid.live) sitesini kullanabilirsiniz.
357
+
358
+ ### Diyagram Listesi
359
+
360
+ | # | Diyagram | Açıklama |
361
+ |---|---|---|
362
+ | 1 | Agentic Tasarım | Agent'lar, tool'lar ve shared memory |
363
+ | 2 | Ana Akış (Use Case) | Yeni araç ve 2. el dalları, SSS entegrasyonu |
364
+ | 3 | Hedefli Güncelleme Akışı | Onay ekranında alan değiştirme mantığı |
365
+ | 4 | Paralel FAQ Akışı | Sequence diagram — snapshot / restore döngüsü |
chatbot/DIAGRAMS.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Araç Finansmanı Chatbot — Diyagramlar
2
+
3
+ ---
4
+
5
+ ## 1. Agentic Tasarım
6
+
7
+ ```mermaid
8
+ graph TD
9
+ USR[Müşteri Mesajı] --> SUP{Supervisor Agent<br/>Planner + Router}
10
+
11
+ SUP -->|niyet: yeni araç| INTAKE_NEW[Intake Agent<br/>Yeni Araç]
12
+ SUP -->|niyet: 2.el| INTAKE_USED[Intake Agent<br/>2. El]
13
+ SUP -->|niyet: SSS| FAQ[FAQ/RAG Agent]
14
+ SUP -->|kayıt zamanı| APP[Application Submission Agent]
15
+ SUP -->|çapraz satış| XS[Cross-sell Agent<br/>HGS]
16
+
17
+ INTAKE_NEW --> VAL[Validation Agent]
18
+ INTAKE_USED --> VAL
19
+ VAL -.tool.-> TOOLS[(Tool Registry<br/>TCKN, Katalog, Hesap)]
20
+
21
+ FAQ -.tool.-> VDB[(Vektör DB<br/>ChromaDB)]
22
+ APP -.tool.-> APPDB[(Ön Başvuru DB)]
23
+ XS -.tool.-> HGSAPI[HGS Servisi]
24
+
25
+ SUP <--> MEM[(Shared State / Memory<br/>slotlar, conversation history)]
26
+ INTAKE_NEW <--> MEM
27
+ INTAKE_USED <--> MEM
28
+ FAQ <--> MEM
29
+ APP <--> MEM
30
+ XS <--> MEM
31
+
32
+ SUP --> REF[Reflection / Self-check]
33
+ REF --> SUP
34
+ ```
35
+
36
+
37
+ ---
38
+
39
+ ## 2. Use Case Workflow — Ana Akış
40
+
41
+ ```mermaid
42
+ flowchart TD
43
+ Start([Müşteri chatbot'u açar]) --> S1[1- Karşılama ve amaç teyidi]
44
+ S1 --> S2{2- Niyet Sınıflandırma<br/>LLM}
45
+ S2 -->|Yeni Araç| A1
46
+ S2 -->|2. El| B1
47
+ S2 -->|SSS Sorusu| F1[FAQ/RAG Akışı]
48
+ S2 -->|Alakasız| OOS[Kapsam dışı - kibarca yönlendir]
49
+ F1 --> Resume[Önceki adıma dön]
50
+
51
+ subgraph NewCar["Dal A — Yeni Araç"]
52
+ A1[A1- Proforma fatura tutarı sor] --> A1V{≤ 7M?}
53
+ A1V -->|Hayır| AStop1[Üst limit aşıldı - başvuru kapatılır]
54
+ A1V -->|Evet| A2[A2- Araç modeli sor]
55
+ A2 --> A2V{Ticari mi?<br/>Katalog lookup}
56
+ A2V -->|Evet| AStop2[Ticari modele uygun değil]
57
+ A2V -->|Hayır| A3[A3- İstenen finansman sor]
58
+ A3 --> A3V{≤ %60 × fiyat?}
59
+ A3V -->|Hayır| A3F[Maks tutarı öner, tekrar sor]
60
+ A3F --> A3
61
+ A3V -->|Evet| A4{Fiyat ≥ 5M?}
62
+ A4 -->|Evet| A5[A5- Kefil TCKN sor]
63
+ A5 --> A5V{TCKN 11 hane format geçerli?}
64
+ A5V -->|Hayır| A5
65
+ A5V -->|Evet| Recap
66
+ A4 -->|Hayır| Recap
67
+ end
68
+
69
+ subgraph Used["Dal B — 2. El"]
70
+ B1[B1- Kasko değeri sor] --> B2[B2- Araç yaşı sor]
71
+ B2 --> B2V{Yaş ≤ 5?}
72
+ B2V -->|Hayır| BStop1[5 yaş üstü kabul edilmiyor]
73
+ B2V -->|Evet| B3[B3- İstenen finansman sor]
74
+ B3 --> B3V{"≤ min %40×kasko ve 3M?"}
75
+ B3V -->|Hayır| B3F[Maks tutarı öner, tekrar sor]
76
+ B3F --> B3
77
+ B3V -->|Evet| B4[B4- Satıcı TCKN sor - opsiyonel]
78
+ B4 --> Recap
79
+ end
80
+
81
+ Recap[7- Onay Ekranı: tüm alanları göster]
82
+ Recap --> Edit{Müşteri değiştirmek istiyor mu?}
83
+ Edit -->|Evet, alan adı belirt| GoTo[İlgili alanın sorusu tekrar sorulur ve doğrulanır]
84
+ GoTo -.yeni araç alanı güncelleme.-> A1
85
+ GoTo -.yeni araç alanı güncelleme.-> A2
86
+ GoTo -.yeni araç alanı güncelleme.-> A3
87
+ GoTo -.yeni araç alanı güncelleme.-> A5
88
+ GoTo -.ikinci el alanı güncelleme.-> B1
89
+ GoTo -.ikinci el alanı güncelleme.-> B2
90
+ GoTo -.ikinci el alanı güncelleme.-> B3
91
+ GoTo -.ikinci el alanı güncelleme.-> B4
92
+ Edit -->|Hayır, onayla| Submit[8- Ön başvuru DB'ye yaz]
93
+ Submit --> RefNo[Başvuru numarası dön]
94
+ RefNo --> Cross[9- HGS Çapraz Satış]
95
+ Cross --> CrossQ{HGS istiyor mu?}
96
+ CrossQ -->|Evet| HGS[HGS servisine kayıt]
97
+ CrossQ -->|Hayır| End
98
+ HGS --> End([Bitiş - özet ve teşekkür])
99
+ ```
100
+
101
+
102
+ ---
103
+
104
+ ## 3. Hedefli Güncelleme Akışı
105
+
106
+ ```mermaid
107
+ flowchart TD
108
+ Recap[Onay Ekranı] -->|"'X alanını değiştir'"| Detect[LLM: hedef alanı çıkar]
109
+ Detect --> AskOne[Yalnız hedef alanı sor]
110
+ AskOne --> V1{Alanın kendi kuralı OK?}
111
+ V1 -->|Hayır| AskOne
112
+ V1 -->|Evet| Cross{Çapraz kurallar<br/>hâlâ geçerli mi?}
113
+ Cross -->|Evet| Recap
114
+ Cross -->|Hayır - bağımlı alan bozuldu| Notify[Müşteriye bildir:<br/>'yeni X ile mevcut Y uymuyor']
115
+ Notify --> AskDep[Yalnız bağımlı alanı sor]
116
+ AskDep --> V2{Doğrula}
117
+ V2 -->|Hayır| AskDep
118
+ V2 -->|Evet| Recap
119
+ ```
120
+
121
+
122
+ ---
123
+
124
+ ## 4. Paralel SSS (FAQ) Akışı — Sequence
125
+
126
+ ```mermaid
127
+ sequenceDiagram
128
+ participant U as Müşteri
129
+ participant O as Orchestrator
130
+ participant R as Router LLM
131
+ participant F as FAQ Agent (RAG)
132
+ participant S as State (faq_snapshot)
133
+
134
+ Note over U,S: Müşteri Dal A - adım A2 (model sorusu) cevabını bekliyor
135
+ O->>U: "Hangi marka ve model?"
136
+ U->>O: "Vade seçenekleri nedir?"
137
+ O->>R: niyet sınıflandır
138
+ R-->>O: intent=faq
139
+ O->>S: faq_snapshot = {step: A2, awaited_slot: vehicle_model}
140
+ O->>F: query("vade seçenekleri")
141
+ F->>F: embed + top-k retrieve
142
+ F-->>O: cevap + kaynak chunk
143
+ O->>U: cevap + "Kaldığımız yerden - markayı sormuştum, devam edelim mi?"
144
+ U->>O: "BMW 320i"
145
+ O->>R: niyet sınıflandır
146
+ R-->>O: intent=slot_answer (vehicle_model)
147
+ O->>S: faq_snapshot temizle + slot kaydet
148
+ O->>U: (A3 - finansman tutarı sorusu)
149
+ ```
chatbot/agents/__init__.py ADDED
File without changes
chatbot/agents/crosssell_agent.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/crosssell_agent.py — HGS Çapraz Satış Agent'ı
3
+
4
+ AGENTIC PATTERN: Scoped Tool Access (Least-Privilege)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Bu agent YALNIZCA HGS servisine erişebilir.
7
+ Ön Başvuru DB'ye dokunamaz — bu least-privilege prensibin uygulaması.
8
+
9
+ İki adım:
10
+ 1. check_hgs_status → aktif HGS varsa adımı atla
11
+ 2. Teklif sun → onaylanırsa register_hgs çağır
12
+ """
13
+ from langchain_core.messages import AIMessage
14
+ from tools.hgs_tool import check_hgs_status, register_hgs
15
+ from graph.state import ConversationState
16
+ from observability.audit_logger import log_tool_call, log_slot_change
17
+
18
+ HGS_ACCEPT_WORDS = ["evet", "istiyorum", "tamam", "olur", "onaylıyorum"]
19
+ HGS_REJECT_WORDS = ["hayır", "istemiyorum", "geç", "atla", "vazgeçtim", "lazım değil", "hayir"]
20
+
21
+
22
+ def run_crosssell(state: ConversationState) -> dict:
23
+ """HGS çapraz satış node fonksiyonu."""
24
+
25
+ # Adım 1: Aktif HGS var mı?
26
+ hgs_status = check_hgs_status.invoke({"session_id": state["session_id"]})
27
+ log_tool_call(state["session_id"], "crosssell_agent", "check_hgs_status",
28
+ {}, hgs_status, True)
29
+
30
+ if hgs_status["has_active_hgs"]:
31
+ # HGS zaten var → adımı atla
32
+ return {
33
+ "current_step": "crosssell_skipped",
34
+ "hgs_consent": False,
35
+ "messages": state["messages"] + [
36
+ AIMessage(content="HGS'iniz zaten aktif. İyi sürüşler! 🚗\n\nBaşvurunuz için teşekkürler.")
37
+ ],
38
+ "flow_ended": True,
39
+ }
40
+
41
+ # Adım 2: Müşterinin son mesajı HGS teklifine cevap mı?
42
+ last_message = state["messages"][-1]
43
+ user_text = last_message.content if hasattr(last_message, "content") else ""
44
+
45
+ if hasattr(last_message, "type") and last_message.type == "ai":
46
+ # Teklifi zaten gönderdik, cevap bekliyoruz
47
+ return {"current_step": "crosssell_waiting"}
48
+
49
+ lower = user_text.lower()
50
+
51
+ if any(w in lower for w in HGS_REJECT_WORDS):
52
+ log_slot_change(state["session_id"], "hgs_consent", None, False)
53
+ return {
54
+ "current_step": "crosssell_declined",
55
+ "hgs_consent": False,
56
+ "flow_ended": True,
57
+ "messages": state["messages"] + [
58
+ AIMessage(content="Anlaşıldı. Başvurunuz için teşekkürler, iyi günler! 🙂")
59
+ ],
60
+ }
61
+
62
+ if any(w in lower for w in HGS_ACCEPT_WORDS):
63
+ # HGS kaydı yap
64
+ hgs_result = register_hgs.invoke({
65
+ "session_id": state["session_id"],
66
+ "application_id": state.get("application_id", ""),
67
+ })
68
+ log_tool_call(state["session_id"], "crosssell_agent", "register_hgs",
69
+ {"application_id": state.get("application_id")},
70
+ hgs_result, hgs_result["success"])
71
+ log_slot_change(state["session_id"], "hgs_consent", None, True)
72
+
73
+ if hgs_result["success"]:
74
+ msg = f"HGS'iniz başarıyla tanımlandı! (HGS No: {hgs_result['hgs_id']})\n\nTeşekkürler, iyi sürüşler! 🚗"
75
+ else:
76
+ msg = "HGS tanımlamasında bir sorun oluştu. Şubemizden destek alabilirsiniz."
77
+
78
+ return {
79
+ "current_step": "crosssell_complete",
80
+ "hgs_consent": True,
81
+ "flow_ended": True,
82
+ "messages": state["messages"] + [AIMessage(content=msg)],
83
+ }
84
+
85
+ # İlk kez geliyorsa teklif gösterildi mi? (submission_agent zaten HGS_PITCH'i gönderdi)
86
+ return {"current_step": "crosssell_waiting"}
chatbot/agents/faq_agent.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/faq_agent.py — FAQ / RAG Agent
3
+
4
+ AGENTIC PATTERN: RAG (Retrieval-Augmented Generation)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ RAG pattern'de model yeni bilgi üretmez; mevcut dokümanı
7
+ kullanarak soruyu cevaplar. Bu sayede:
8
+ - Halüsinasyon riski minimum (sadece getirilen chunk'tan cevap)
9
+ - Cevap kaynağı şeffaf (kaynak chunk gösterilir)
10
+ - Doküman güncellenince otomatik güncel cevap
11
+
12
+ Akış:
13
+ 1. retrieve_faq tool'u → ChromaDB'den top-k chunk
14
+ 2. Chunk'lar prompt'a context olarak eklenir
15
+ 3. Büyük model cevap üretir (sadece context'ten)
16
+ 4. Output Guard: kaynak chunk zorunlu
17
+ 5. State snapshot'tan kaldığı yere dönülür
18
+
19
+ SCOPED ACCESS: FAQ agent sadece VectorDB read yetkisine sahip.
20
+ Slot okuyamaz/yazamaz — başka müşterinin verisi sızmaz.
21
+ """
22
+ import time
23
+ from langchain_core.messages import AIMessage
24
+ from tools.rag_tool import retrieve_faq
25
+ from llm.factory import get_main_llm, get_llm
26
+ from llm.prompts import FAQ_SYSTEM
27
+ from graph.state import ConversationState
28
+ from guardrails.output_guard import check_output
29
+ from observability.audit_logger import log_llm_call, log_tool_call
30
+
31
+
32
+ def run_faq_agent(state: ConversationState) -> dict:
33
+ """
34
+ FAQ node fonksiyonu.
35
+
36
+ Müşterinin sorusunu RAG ile yanıtlar, ardından
37
+ kaldığı adıma devam etmesini önerir.
38
+ """
39
+ last_message = state["messages"][-1]
40
+ user_query = last_message.content if hasattr(last_message, "content") else str(last_message)
41
+
42
+ # ── Adım 1: Retrieval ─────────────────────────────────────────────────────
43
+ retrieval_result = retrieve_faq.invoke({"query": user_query})
44
+ chunks = retrieval_result["chunks"]
45
+ context = retrieval_result["context"]
46
+
47
+ log_tool_call(
48
+ session_id=state["session_id"],
49
+ agent="faq_agent",
50
+ tool_name="retrieve_faq",
51
+ args={"query": user_query[:100]},
52
+ result={"chunk_count": len(chunks)},
53
+ success=True,
54
+ )
55
+
56
+ # ── Adım 2: LLM ile cevap üret ────────────────────────────────────────────
57
+ llm = get_main_llm()
58
+ system_with_context = FAQ_SYSTEM.format(context=context)
59
+
60
+ faq_messages = [
61
+ {"role": "system", "content": system_with_context},
62
+ {"role": "user", "content": user_query},
63
+ ]
64
+
65
+ start = time.time()
66
+ response = llm.invoke(faq_messages)
67
+ # Gemini RECITATION/SAFETY filtresinde boş içerik dönebilir; farklı sampling
68
+ # ile bir kez daha dene (sıcaklık > 0 genelde filtreyi aşar).
69
+ if not (response.content or "").strip():
70
+ response = get_llm(size="large", temperature=0.5).invoke(faq_messages)
71
+ latency = int((time.time() - start) * 1000)
72
+
73
+ log_llm_call(
74
+ session_id=state["session_id"],
75
+ agent="faq_agent",
76
+ model=llm.model,
77
+ latency_ms=latency,
78
+ tokens_in=len(system_with_context.split()) + len(user_query.split()),
79
+ tokens_out=len(response.content.split()),
80
+ prompt=[
81
+ {"role": "system", "content": system_with_context},
82
+ {"role": "user", "content": user_query},
83
+ ],
84
+ response=response.content,
85
+ )
86
+
87
+ # ── Adım 3: Output Guard ──────────────────────────────────────────────────
88
+ guard_result = check_output(
89
+ text=response.content,
90
+ session_id=state["session_id"],
91
+ is_faq_response=True,
92
+ source_chunks=chunks,
93
+ )
94
+
95
+ if not guard_result["allowed"]:
96
+ answer = "Bu konuda şu anda yardımcı olamıyorum. Lütfen şubemizi arayın."
97
+ else:
98
+ answer = guard_result["cleaned_text"]
99
+
100
+ # ── Adım 4: Resume mesajı ekle ────────────────────────────────────────────
101
+ pending_question = _get_pending_question(state)
102
+ resume_hint = f"\n\n---\n*Kaldığımız yere dönelim: {pending_question}*" if pending_question else ""
103
+
104
+ full_answer = answer + resume_hint
105
+
106
+ return {
107
+ "current_step": "faq_answered",
108
+ "messages": state["messages"] + [AIMessage(content=full_answer)],
109
+ # Snapshot korunur; bir sonraki turda supervisor yeniden çalışır
110
+ }
111
+
112
+
113
+ # Bekleyen soru metinleri — slot bazlı
114
+ _PENDING_SLOT_QUESTIONS = {
115
+ "kasko_value": "Aracın güncel kasko değeri nedir? (TL olarak)",
116
+ "vehicle_age": "Aracın model yılı veya yaşı nedir? (örn: 2021 model veya 3 yaşında)",
117
+ "requested_amount_used": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
118
+ "seller_tckn": "Satıcının TC kimlik numarası nedir? (Bilmiyorsanız 'geç' yazabilirsiniz)",
119
+ "invoice_amount": "Aracın proforma fatura tutarı nedir? (TL olarak)",
120
+ "vehicle_model": "Hangi marka ve model aracı düşünüyorsunuz?",
121
+ "requested_amount_new": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
122
+ "guarantor_tckn": "Kefilinizin TC kimlik numarası nedir?",
123
+ }
124
+
125
+
126
+ def _get_pending_question(state: ConversationState) -> str | None:
127
+ """FAQ'ten sonra kullanıcının döneceği bekleyen soruyu üretir."""
128
+ awaited = state.get("awaited_slot")
129
+ if awaited and awaited in _PENDING_SLOT_QUESTIONS:
130
+ return _PENDING_SLOT_QUESTIONS[awaited]
131
+ # Henüz araç türü seçilmemişse baştaki soruya dön
132
+ if not state.get("vehicle_type"):
133
+ return "Yeni araç mı, 2. el araç mı düşünüyorsunuz?"
134
+ return None
chatbot/agents/intake_new.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/intake_new.py — Yeni Araç Slot Toplama Agent'ı
3
+
4
+ AGENTIC PATTERN: Slot Filling + LLM Entity Extraction
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Hangi slot'un eksik olduğunu state'e bakarak bulur,
7
+ o slot için soru sorar veya müşterinin cevabından değeri çıkarır.
8
+
9
+ LLM'in görevi SADECE:
10
+ - Sayı normalize (iki buçuk milyon → 2500000)
11
+ - Entity extract (araç modeli: "bmw üç serisi" → BMW 3 Serisi)
12
+ - TCKN ham metin çıkarımı
13
+
14
+ İş kuralı kararları (ticari mi, limit aşıldı mı) → validation_agent.
15
+ """
16
+ import time
17
+ from langchain_core.messages import AIMessage
18
+ from llm.factory import get_router_llm
19
+ from llm.json_utils import parse_llm_json
20
+ from llm.prompts import SLOT_EXTRACTION_SYSTEM, VEHICLE_MODEL_EXTRACTION_SYSTEM
21
+ from graph.state import ConversationState
22
+ from observability.audit_logger import log_llm_call, log_slot_change
23
+ from tools.tckn_tool import _mask
24
+
25
+
26
+ # Sıralı slot listesi (hangi alan önce sorulacak)
27
+ NEW_CAR_SLOTS_ORDER = [
28
+ "invoice_amount",
29
+ "vehicle_model",
30
+ "requested_amount_new",
31
+ "guarantor_tckn", # sadece kefil_required=True ise
32
+ ]
33
+
34
+ SLOT_QUESTIONS = {
35
+ "invoice_amount": "Aracın proforma fatura tutarı nedir? (TL olarak)",
36
+ "vehicle_model": "Hangi marka ve model aracı düşünüyorsunuz?",
37
+ "requested_amount_new": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
38
+ "guarantor_tckn": "Proforma tutarı 5.000.000 TL üzerinde olduğu için kefil göstermeniz gerekmektedir. Kefilinizin TC kimlik numarası nedir?",
39
+ }
40
+
41
+
42
+ def get_next_missing_slot(state: ConversationState) -> str | None:
43
+ """Sıradaki eksik slot'u döndürür; tümü doluysa None."""
44
+ for slot in NEW_CAR_SLOTS_ORDER:
45
+ if slot == "guarantor_tckn" and not state.get("kefil_required"):
46
+ continue
47
+ if not state.get(slot):
48
+ return slot
49
+ return None
50
+
51
+
52
+ def run_intake_new(state: ConversationState) -> dict:
53
+ """
54
+ Yeni araç intake node fonksiyonu.
55
+
56
+ İki mod:
57
+ A) Eksik slot var → soru sor
58
+ B) Son mesaj bir cevap → parse et, slot'a yaz, doğrula
59
+ """
60
+ last_message = state["messages"][-1]
61
+ user_text = last_message.content if hasattr(last_message, "content") else str(last_message)
62
+
63
+ # Eğer son mesaj AI ise (soru sorduk) → henüz cevap yok, bekle
64
+ if hasattr(last_message, "type") and last_message.type == "ai":
65
+ # Tüm slotlar zaten dolmuşsa intake_new_complete'i koru, recap akışı bozulmasın
66
+ if get_next_missing_slot(state) is None:
67
+ return {"current_step": "intake_new_complete", "awaited_slot": None}
68
+ return {"current_step": "intake_new_waiting"}
69
+
70
+ # Mevcut state'den ne beklediğimizi bul (awaited_slot alanından)
71
+ awaited_slot = state.get("awaited_slot")
72
+ import sys
73
+ print(f"[DEBUG intake_new] awaited_slot={awaited_slot} user_text={user_text!r} current_step={state.get('current_step')} invoice={state.get('invoice_amount')} vtype={state.get('vehicle_type')}", flush=True)
74
+ sys.stdout.flush()
75
+
76
+ # Parse fazından gelen güncellemeleri biriktir, ama erken return etme;
77
+ # parse başarılıysa aynı tur içinde sıradaki slotu da sormak istiyoruz.
78
+ parse_updates: dict = {}
79
+ if awaited_slot and awaited_slot in NEW_CAR_SLOTS_ORDER:
80
+ updated = _parse_slot(state, awaited_slot, user_text)
81
+ print(f"[DEBUG intake_new] parse result for slot '{awaited_slot}': {updated}", flush=True)
82
+ if updated:
83
+ parse_succeeded = awaited_slot in updated and updated[awaited_slot] is not None
84
+ if parse_succeeded:
85
+ updated["awaited_slot"] = None
86
+ updated["edit_target_slot"] = None # slot toplandı, edit hedefini temizle
87
+ # flow_ended set edildiyse (ör. fatura üst limit aşımı) hemen dur
88
+ if updated.get("flow_ended"):
89
+ return updated
90
+ parse_updates = updated
91
+ # Merge'i sıradaki-slot lookup'ı doğru görebilsin diye state üstüne uygula
92
+ state = {**state, **parse_updates}
93
+ else:
94
+ # Parse başarısız → tekrar sor (zaten updated içinde messages var)
95
+ return updated
96
+
97
+ # Bir sonraki eksik slot'u bul ve sor
98
+ next_slot = get_next_missing_slot(state)
99
+ print(f"[DEBUG intake_new] next missing slot: {next_slot}", flush=True)
100
+ if next_slot:
101
+ question = SLOT_QUESTIONS[next_slot]
102
+ return {
103
+ **parse_updates,
104
+ "current_step": f"asking_{next_slot}",
105
+ "awaited_slot": next_slot,
106
+ "messages": state["messages"] + [AIMessage(content=question)],
107
+ }
108
+
109
+ # Tüm slot'lar dolu → recap'e geç
110
+ print(f"[DEBUG intake_new] all slots filled, proceeding to recap", flush=True)
111
+ return {**parse_updates, "current_step": "intake_new_complete", "awaited_slot": None}
112
+
113
+
114
+ def _parse_slot(state: ConversationState, slot: str, user_text: str) -> dict | None:
115
+ """LLM ile slot değerini parse et."""
116
+ llm = get_router_llm()
117
+
118
+ if slot in ("invoice_amount", "requested_amount_new"):
119
+ return _parse_number_slot(state, slot, user_text, llm)
120
+ elif slot == "vehicle_model":
121
+ return _parse_vehicle_model(state, user_text, llm)
122
+ elif slot == "guarantor_tckn":
123
+ return _parse_tckn_slot(state, slot, user_text, llm)
124
+ return None
125
+
126
+
127
+ def _parse_number_slot(state, slot, user_text, llm) -> dict:
128
+ start = time.time()
129
+ response = llm.invoke([
130
+ {"role": "system", "content": SLOT_EXTRACTION_SYSTEM},
131
+ {"role": "user", "content": user_text},
132
+ ])
133
+ import sys
134
+ print(f"[DEBUG _parse_number_slot] slot={slot} user_text={user_text!r} llm_response={response.content!r}", flush=True)
135
+ sys.stdout.flush()
136
+ latency = int((time.time() - start) * 1000)
137
+ log_llm_call(
138
+ state["session_id"], "intake_new", llm.model, latency,
139
+ len(user_text.split()), len(response.content.split()),
140
+ prompt=[
141
+ {"role": "system", "content": SLOT_EXTRACTION_SYSTEM},
142
+ {"role": "user", "content": user_text},
143
+ ],
144
+ response=response.content,
145
+ )
146
+
147
+ parsed = parse_llm_json(response.content)
148
+ value = parsed.get("value") if parsed else None
149
+
150
+ if value is None:
151
+ question = SLOT_QUESTIONS.get(slot, "Lütfen rakamla yazın.")
152
+ return {
153
+ "current_step": f"asking_{slot}",
154
+ "awaited_slot": slot,
155
+ "messages": state["messages"] + [
156
+ AIMessage(content=f"Devam edelim — {question} (lütfen rakamla yazın, örn: 3000000 veya 3M TL)")
157
+ ],
158
+ }
159
+
160
+ old_val = state.get(slot)
161
+ log_slot_change(state["session_id"], slot, old_val, value)
162
+
163
+ # Fatura üst limit hızlı kontrol — kapatma, yeniden sor
164
+ if slot == "invoice_amount" and value > 7_000_000:
165
+ msg = (f"Proforma fatura tutarı maksimum 7.000.000 TL olabilir "
166
+ f"(girilen: {int(value):,} TL).\n"
167
+ "Aracın proforma fatura tutarı nedir? (TL olarak)")
168
+ return {
169
+ "current_step": "asking_invoice_amount",
170
+ "awaited_slot": "invoice_amount",
171
+ "messages": state["messages"] + [AIMessage(content=msg)],
172
+ }
173
+
174
+ return {slot: value, "current_step": f"{slot}_collected"}
175
+
176
+
177
+ def _parse_vehicle_model(state, user_text, llm) -> dict:
178
+ start = time.time()
179
+ response = llm.invoke([
180
+ {"role": "system", "content": VEHICLE_MODEL_EXTRACTION_SYSTEM},
181
+ {"role": "user", "content": user_text},
182
+ ])
183
+ latency = int((time.time() - start) * 1000)
184
+ log_llm_call(
185
+ state["session_id"], "intake_new", llm.model, latency,
186
+ len(user_text.split()), len(response.content.split()),
187
+ prompt=[
188
+ {"role": "system", "content": VEHICLE_MODEL_EXTRACTION_SYSTEM},
189
+ {"role": "user", "content": user_text},
190
+ ],
191
+ response=response.content,
192
+ )
193
+
194
+ parsed = parse_llm_json(response.content)
195
+ if parsed:
196
+ brand = parsed.get("brand", "")
197
+ model = parsed.get("model", "")
198
+ needs_clarification = parsed.get("needs_clarification", False)
199
+ else:
200
+ needs_clarification = True
201
+ brand = model = ""
202
+
203
+ if needs_clarification or not model:
204
+ return {
205
+ "current_step": "asking_vehicle_model",
206
+ "awaited_slot": "vehicle_model",
207
+ "messages": state["messages"] + [
208
+ AIMessage(content="Marka ve modeli tam olarak belirtebilir misiniz? (örn: Toyota Corolla, BMW 3 Serisi)")
209
+ ],
210
+ }
211
+
212
+ vehicle_model_str = f"{brand} {model}".strip()
213
+
214
+ # Ticari araç kontrolü — kataloğa bakarak hemen filtrele
215
+ from tools.catalog_tool import lookup_vehicle
216
+ catalog_result = lookup_vehicle.invoke({"brand": brand, "model": model})
217
+ if catalog_result["found"] and catalog_result["type"] == "ticari":
218
+ msg = (f"Maalesef {vehicle_model_str} ticari bir araçtır. "
219
+ "Bankamız yalnızca binek araçlara finansman sağlamaktadır. "
220
+ "Farklı bir araç modeli girebilirsiniz.")
221
+ return {
222
+ "current_step": "asking_vehicle_model",
223
+ "awaited_slot": "vehicle_model",
224
+ "messages": state["messages"] + [AIMessage(content=msg)],
225
+ }
226
+
227
+ log_slot_change(state["session_id"], "vehicle_model", state.get("vehicle_model"), vehicle_model_str)
228
+ return {"vehicle_model": vehicle_model_str, "current_step": "vehicle_model_collected"}
229
+
230
+
231
+ def _parse_tckn_slot(state, slot, user_text, llm) -> dict:
232
+ # Skip intent
233
+ skip_words = ["geç", "yok", "bilmiyorum", "pas", "atla", "istemiyorum"]
234
+ if any(w in user_text.lower() for w in skip_words):
235
+ if slot == "seller_tckn":
236
+ log_slot_change(state["session_id"], slot, None, "skipped")
237
+ return {slot: None, "current_step": f"{slot}_skipped"}
238
+
239
+ import re
240
+ # Ham TCKN veya input guard tarafından maskelenmiş (123****8910) her iki pattern'ı tanı
241
+ match = re.search(r"\b[1-9]\d{10}\b", user_text)
242
+ masked_match = re.search(r"\b([1-9]\d{2})\*{4}(\d{4})\b", user_text) if not match else None
243
+
244
+ if match:
245
+ tckn = match.group()
246
+ elif masked_match:
247
+ # Maskelenmiş geldi — ham hali yok, maskelenmiş hali sakla
248
+ tckn = masked_match.group()
249
+ else:
250
+ return {
251
+ "current_step": f"asking_{slot}",
252
+ "awaited_slot": slot,
253
+ "messages": state["messages"] + [
254
+ AIMessage(content="TCKN'yi 11 haneli olarak tam yazabilir misiniz?")
255
+ ],
256
+ }
257
+
258
+ masked = _mask(tckn) if match else tckn
259
+ log_slot_change(state["session_id"], slot, None, masked)
260
+ return {slot: tckn, "current_step": f"{slot}_collected"}
chatbot/agents/intake_used.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/intake_used.py — 2. El Araç Slot Toplama Agent'ı
3
+
4
+ AGENTIC PATTERN: Slot Filling (State Machine'e bağlı)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ intake_new.py ile aynı pattern; 2. el dalının slot'ları farklı.
7
+ Satıcı TCKN opsiyoneldir — müşteri "bilmiyorum" diyebilir.
8
+ """
9
+ import re
10
+ import time
11
+ from langchain_core.messages import AIMessage
12
+ from llm.factory import get_router_llm
13
+ from llm.json_utils import parse_llm_json
14
+ from llm.prompts import SLOT_EXTRACTION_SYSTEM
15
+ from graph.state import ConversationState
16
+ from observability.audit_logger import log_llm_call, log_slot_change
17
+ from tools.tckn_tool import _mask
18
+
19
+ USED_CAR_SLOTS_ORDER = ["kasko_value", "vehicle_age", "requested_amount_used", "seller_tckn"]
20
+
21
+ SLOT_QUESTIONS = {
22
+ "kasko_value": "Aracın güncel kasko değeri nedir? (TL olarak)",
23
+ "vehicle_age": "Aracın model yılı veya yaşı nedir? (örn: 2021 model veya 3 yaşında)",
24
+ "requested_amount_used": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
25
+ "seller_tckn": "Satıcının TC kimlik numarası nedir? (Bilmiyorsanız geçebilirsiniz)",
26
+ }
27
+
28
+
29
+ def get_next_missing_slot_used(state: ConversationState) -> str | None:
30
+ for slot in USED_CAR_SLOTS_ORDER:
31
+ val = state.get(slot)
32
+ # "" → skipped (geçildi), None → henüz sorulmadı
33
+ if val is None:
34
+ return slot
35
+ return None
36
+
37
+
38
+ def run_intake_used(state: ConversationState) -> dict:
39
+ last_message = state["messages"][-1]
40
+ user_text = last_message.content if hasattr(last_message, "content") else str(last_message)
41
+
42
+ if hasattr(last_message, "type") and last_message.type == "ai":
43
+ if get_next_missing_slot_used(state) is None:
44
+ return {"current_step": "intake_used_complete", "awaited_slot": None}
45
+ return {"current_step": "intake_used_waiting"}
46
+
47
+ awaited_slot = state.get("awaited_slot")
48
+
49
+ parse_updates: dict = {}
50
+ if awaited_slot and awaited_slot in USED_CAR_SLOTS_ORDER:
51
+ updated = _parse_slot_used(state, awaited_slot, user_text)
52
+ if updated:
53
+ parse_succeeded = (
54
+ (awaited_slot in updated and updated[awaited_slot] is not None)
55
+ or updated.get("current_step", "").endswith("_skipped")
56
+ )
57
+ if parse_succeeded:
58
+ updated["awaited_slot"] = None
59
+ updated["edit_target_slot"] = None # slot toplandı, edit hedefini temizle
60
+ if updated.get("flow_ended"):
61
+ return updated
62
+ parse_updates = updated
63
+ state = {**state, **parse_updates}
64
+ else:
65
+ # Parse başarısız → tekrar sor
66
+ return updated
67
+
68
+ next_slot = get_next_missing_slot_used(state)
69
+ if next_slot:
70
+ question = SLOT_QUESTIONS[next_slot]
71
+ return {
72
+ **parse_updates,
73
+ "current_step": f"asking_{next_slot}",
74
+ "awaited_slot": next_slot,
75
+ "messages": state["messages"] + [AIMessage(content=question)],
76
+ }
77
+
78
+ return {**parse_updates, "current_step": "intake_used_complete", "awaited_slot": None}
79
+
80
+
81
+ def _parse_slot_used(state, slot, user_text) -> dict | None:
82
+ llm = get_router_llm()
83
+
84
+ if slot == "kasko_value":
85
+ return _parse_number(state, slot, user_text, llm)
86
+
87
+ elif slot == "vehicle_age":
88
+ return _parse_age(state, user_text, llm)
89
+
90
+ elif slot == "requested_amount_used":
91
+ return _parse_number(state, slot, user_text, llm)
92
+
93
+ elif slot == "seller_tckn":
94
+ return _parse_optional_tckn(state, user_text)
95
+
96
+ return None
97
+
98
+
99
+ def _parse_number(state, slot, user_text, llm) -> dict:
100
+ start = time.time()
101
+ response = llm.invoke([
102
+ {"role": "system", "content": SLOT_EXTRACTION_SYSTEM},
103
+ {"role": "user", "content": user_text},
104
+ ])
105
+ latency = int((time.time() - start) * 1000)
106
+ log_llm_call(
107
+ state["session_id"], "intake_used", llm.model, latency,
108
+ len(user_text.split()), len(response.content.split()),
109
+ prompt=[
110
+ {"role": "system", "content": SLOT_EXTRACTION_SYSTEM},
111
+ {"role": "user", "content": user_text},
112
+ ],
113
+ response=response.content,
114
+ )
115
+ parsed = parse_llm_json(response.content)
116
+ value = parsed.get("value") if parsed else None
117
+
118
+ if value is None:
119
+ question = SLOT_QUESTIONS.get(slot, "Lütfen rakamla yazın.")
120
+ return {
121
+ "current_step": f"asking_{slot}",
122
+ "awaited_slot": slot,
123
+ "messages": state["messages"] + [
124
+ AIMessage(content=f"Devam edelim — {question} (lütfen rakamla yazın, örn: 500000 veya 500bin TL)")
125
+ ],
126
+ }
127
+ log_slot_change(state["session_id"], slot, state.get(slot), value)
128
+ return {slot: value, "current_step": f"{slot}_collected"}
129
+
130
+
131
+ def _parse_age(state, user_text, llm) -> dict:
132
+ """Model yılı veya yaş ifadesini parse eder."""
133
+ import datetime
134
+ current_year = datetime.date.today().year
135
+
136
+ # Önce direkt yıl ara: 2020, 2021 vb.
137
+ year_match = re.search(r"\b(20\d{2})\b", user_text)
138
+ if year_match:
139
+ age = current_year - int(year_match.group(1))
140
+ log_slot_change(state["session_id"], "vehicle_age", state.get("vehicle_age"), age)
141
+
142
+ if age < 0:
143
+ msg = (f"{year_match.group(1)} henüz geçmiş bir model yılı değil. "
144
+ "Lütfen geçerli bir model yılı giriniz (örn: 2021, 2022).")
145
+ return {
146
+ "current_step": "asking_vehicle_age",
147
+ "awaited_slot": "vehicle_age",
148
+ "messages": state["messages"] + [AIMessage(content=msg)],
149
+ }
150
+ if age > 5:
151
+ msg = (f"Araç yaşı {age} yıl olarak hesaplandı; maksimum kabul edilen 5 yıldır. "
152
+ "Lütfen 5 yıl veya daha küçük bir model yılı/yaş giriniz.")
153
+ return {
154
+ "current_step": "asking_vehicle_age",
155
+ "awaited_slot": "vehicle_age",
156
+ "messages": state["messages"] + [AIMessage(content=msg)],
157
+ }
158
+ return {"vehicle_age": age, "current_step": "vehicle_age_collected"}
159
+
160
+ # LLM ile parse et
161
+ start = time.time()
162
+ response = llm.invoke([
163
+ {"role": "system", "content": SLOT_EXTRACTION_SYSTEM},
164
+ {"role": "user", "content": f"Araç yaşı: {user_text}"},
165
+ ])
166
+ latency = int((time.time() - start) * 1000)
167
+ log_llm_call(
168
+ state["session_id"], "intake_used", llm.model, latency, 10, 10,
169
+ prompt=[
170
+ {"role": "system", "content": SLOT_EXTRACTION_SYSTEM},
171
+ {"role": "user", "content": f"Araç yaşı: {user_text}"},
172
+ ],
173
+ response=response.content,
174
+ )
175
+
176
+ parsed = parse_llm_json(response.content)
177
+ try:
178
+ age = int(parsed.get("value", 0)) if parsed else None
179
+ except Exception:
180
+ age = None
181
+
182
+ if age is None:
183
+ return {
184
+ "current_step": "asking_vehicle_age",
185
+ "awaited_slot": "vehicle_age",
186
+ "messages": state["messages"] + [
187
+ AIMessage(content="Yaşı anlayamadım. Model yılını (örn: 2021) veya kaç yaşında olduğunu yazar mısınız?")
188
+ ],
189
+ }
190
+
191
+ if age > 5:
192
+ msg = (f"Araç yaşı {age} yıl olarak alındı; maksimum kabul edilen 5 yıldır. "
193
+ "Lütfen 5 yıl veya daha küçük bir model yılı/yaş giriniz.")
194
+ return {
195
+ "current_step": "asking_vehicle_age",
196
+ "awaited_slot": "vehicle_age",
197
+ "messages": state["messages"] + [AIMessage(content=msg)],
198
+ }
199
+
200
+ log_slot_change(state["session_id"], "vehicle_age", state.get("vehicle_age"), age)
201
+ return {"vehicle_age": age, "current_step": "vehicle_age_collected"}
202
+
203
+
204
+ def _parse_optional_tckn(state, user_text) -> dict:
205
+ skip_words = ["geç", "yok", "bilmiyorum", "pas", "atla", "istemiyorum", "hayır"]
206
+ if any(w in user_text.lower() for w in skip_words):
207
+ return {"seller_tckn": "", "current_step": "seller_tckn_skipped"}
208
+
209
+ # Ham TCKN veya input guard tarafından maskelenmiş (789****2355) pattern
210
+ match = re.search(r"\b[1-9]\d{10}\b", user_text)
211
+ masked_match = re.search(r"\b[1-9]\d{2}\*{4}\d{4}\b", user_text) if not match else None
212
+
213
+ if match:
214
+ tckn = match.group()
215
+ masked = _mask(tckn)
216
+ elif masked_match:
217
+ tckn = masked_match.group()
218
+ masked = tckn # zaten maskeli
219
+ else:
220
+ return {
221
+ "current_step": "asking_seller_tckn",
222
+ "awaited_slot": "seller_tckn",
223
+ "messages": state["messages"] + [
224
+ AIMessage(content="TCKN 11 haneli olmalıdır. Bilmiyorsanız 'geç' yazabilirsiniz.")
225
+ ],
226
+ }
227
+
228
+ log_slot_change(state["session_id"], "seller_tckn", None, masked)
229
+ return {"seller_tckn": tckn, "current_step": "seller_tckn_collected"}
chatbot/agents/reflection_agent.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/reflection_agent.py — Self-Check / Reflection Agent
3
+
4
+ AGENTIC PATTERN: Reflection Pattern
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Reflection pattern: Agent kendi çıktısını veya mevcut state'i
7
+ "dışarıdan bakarak" değerlendirir; eksiklik veya tutarsızlık
8
+ varsa bunu işaretler ve ilgili adıma geri döner.
9
+
10
+ Bu agent, onay ekranı gösterilmeden önce çalışır:
11
+ - Tüm zorunlu slot'lar dolu mu?
12
+ - Kefil zorunluysa TCKN var mı?
13
+ - Çapraz kurallar hâlâ tutarlı mı? (örn: fatura değişince oran)
14
+
15
+ Neden Reflection önemli?
16
+ - Slot filling sırasında oluşabilecek edge case'leri yakalar
17
+ - LLM'e "güvenme, kontrol et" felsefesini uygular
18
+ - Başvuru DB'ye hatalı kayıt yazılmasını önler
19
+ """
20
+ import json
21
+ import time
22
+ from langchain_core.messages import AIMessage
23
+ from llm.factory import get_router_llm
24
+ from llm.prompts import REFLECTION_SYSTEM
25
+ from graph.state import ConversationState
26
+ from observability.audit_logger import log_llm_call
27
+ from config import NEW_CAR_GUARANTOR_THRESHOLD
28
+
29
+ # Ham slot adlarını müşteriye gösterilecek dostça etiketlere çevir
30
+ SLOT_LABELS = {
31
+ "invoice_amount": "Proforma fatura tutarı",
32
+ "vehicle_model": "Araç modeli",
33
+ "requested_amount_new": "İstenen finansman tutarı",
34
+ "guarantor_tckn": "Kefil TC kimlik numarası",
35
+ "kasko_value": "Kasko değeri",
36
+ "vehicle_age": "Araç yaşı",
37
+ "requested_amount_used": "İstenen finansman tutarı",
38
+ "seller_tckn": "Satıcı TC kimlik numarası",
39
+ }
40
+
41
+ # Geçersiz veya eksik slot için yeniden sorulacak sorular
42
+ SLOT_QUESTIONS = {
43
+ "invoice_amount": "Proforma fatura tutarı nedir? (TL olarak)",
44
+ "vehicle_model": "Aracın marka ve modelini belirtir misiniz? (örn: Toyota Corolla)",
45
+ "requested_amount_new": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
46
+ "guarantor_tckn": "Kefil TC kimlik numarası nedir?",
47
+ "kasko_value": "Aracın güncel kasko değeri nedir? (TL olarak)",
48
+ "vehicle_age": "Aracın model yılı veya yaşı nedir? (örn: 2021 model veya 3 yaşında)",
49
+ "requested_amount_used": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
50
+ "seller_tckn": "Satıcının TC kimlik numarası nedir? (Bilmiyorsanız 'geç' yazabilirsiniz)",
51
+ }
52
+
53
+
54
+ def run_reflection(state: ConversationState) -> dict:
55
+ """
56
+ Reflection node fonksiyonu.
57
+
58
+ Returns:
59
+ complete=True → recap'e geç
60
+ complete=False → eksik/geçersiz alana geri dön
61
+ """
62
+ vehicle_type = state.get("vehicle_type")
63
+
64
+ # Deterministik kontroller (LLM çağrısı yapmadan)
65
+ issues = [] # kural ihlali mesajları (dostça metin)
66
+ missing = [] # hiç doldurulmamış (None) slot'lar
67
+ reset_slots = [] # dolu ama geçersiz → temizlenip yeniden sorulacak slot'lar
68
+
69
+ if vehicle_type == "new":
70
+ if not state.get("invoice_amount"):
71
+ missing.append("invoice_amount")
72
+ if not state.get("vehicle_model"):
73
+ missing.append("vehicle_model")
74
+ if not state.get("requested_amount_new"):
75
+ missing.append("requested_amount_new")
76
+ # Kefil kontrolü
77
+ if state.get("kefil_required") and not state.get("guarantor_tckn"):
78
+ missing.append("guarantor_tckn")
79
+ # Çapraz kural: fatura değiştiyse oran hâlâ geçerli mi?
80
+ if state.get("invoice_amount") and state.get("requested_amount_new"):
81
+ max_allowed = state["invoice_amount"] * 0.60
82
+ if state["requested_amount_new"] > max_allowed:
83
+ issues.append(
84
+ f"Finansman tutarı ({_fmt(state['requested_amount_new'])} TL), "
85
+ f"proforma tutarının %60'ını ({_fmt(max_allowed)} TL) aşıyor."
86
+ )
87
+ reset_slots.append("requested_amount_new")
88
+
89
+ elif vehicle_type == "used":
90
+ if not state.get("kasko_value"):
91
+ missing.append("kasko_value")
92
+ if state.get("vehicle_age") is None:
93
+ missing.append("vehicle_age")
94
+ if not state.get("requested_amount_used"):
95
+ missing.append("requested_amount_used")
96
+ # Çapraz kural
97
+ if state.get("kasko_value") and state.get("requested_amount_used"):
98
+ max_allowed = min(state["kasko_value"] * 0.40, 3_000_000)
99
+ if state["requested_amount_used"] > max_allowed:
100
+ issues.append(
101
+ f"Finansman tutarı ({_fmt(state['requested_amount_used'])} TL) limiti aşıyor. "
102
+ f"Maksimum: {_fmt(max_allowed)} TL."
103
+ )
104
+ reset_slots.append("requested_amount_used")
105
+
106
+ if missing or issues:
107
+ parts = list(issues)
108
+ if missing:
109
+ labels = [SLOT_LABELS.get(m, m) for m in missing]
110
+ parts.append("Eksik bilgiler: " + ", ".join(labels))
111
+
112
+ # Önce geçersiz değeri düzelt, yoksa ilk eksik alanı tamamlat
113
+ target = reset_slots[0] if reset_slots else (missing[0] if missing else None)
114
+ if target:
115
+ question = SLOT_QUESTIONS.get(target, f"Lütfen {SLOT_LABELS.get(target, target).lower()} bilgisini girin.")
116
+ parts.append(question)
117
+
118
+ msg = "Başvuruyu kontrol ettim, düzeltmemiz gereken noktalar var:\n" + "\n".join(f"• {p}" for p in parts)
119
+
120
+ updates = {
121
+ "current_step": "reflection_issues_found",
122
+ "messages": state["messages"] + [AIMessage(content=msg)],
123
+ "errors": state["errors"] + issues,
124
+ "edit_target_slot": target,
125
+ }
126
+ # Geçersiz slot'ları temizle ki intake yeniden sorabilsin
127
+ for s in reset_slots:
128
+ updates[s] = None
129
+ # Yeniden sorulacak slot'u awaited olarak işaretle → kullanıcı cevabı parse edilir
130
+ if target:
131
+ updates["awaited_slot"] = target
132
+ return updates
133
+
134
+ # Her şey tamam
135
+ return {"current_step": "reflection_passed"}
136
+
137
+
138
+ def _fmt(amount: float) -> str:
139
+ return f"{int(amount):,}".replace(",", ".")
chatbot/agents/submission_agent.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/submission_agent.py — Başvuru Kayıt Agent'ı
3
+
4
+ AGENTIC PATTERN: Tool Use (write_application)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Müşteri tüm bilgileri onayladıktan sonra bu agent çalışır.
7
+ db_tool aracılığıyla SQLite'a yazar; LLM doğrudan DB'ye erişemez.
8
+
9
+ Adımlar:
10
+ 1. State'deki tüm slot'ları topla
11
+ 2. TCKN maskelenmiş haliyle gönder
12
+ 3. write_application tool'unu çağır
13
+ 4. UUID başvuru numarasını state'e yaz
14
+ 5. Müşteriye tebrik mesajı + HGS geçişine hazırlan
15
+ """
16
+ from langchain_core.messages import AIMessage
17
+ from tools.db_tool import write_application
18
+ from tools.tckn_tool import _mask
19
+ from graph.state import ConversationState
20
+ from observability.audit_logger import log_tool_call
21
+ from llm.prompts import HGS_PITCH
22
+
23
+
24
+ def run_submission(state: ConversationState) -> dict:
25
+ """Onaylanmış başvuruyu veritabanına yazar."""
26
+
27
+ vehicle_type = state.get("vehicle_type")
28
+
29
+ # TCKN'leri maskele — ham değer DB'ye girmez
30
+ guarantor_masked = _mask(state["guarantor_tckn"]) if state.get("guarantor_tckn") else None
31
+ seller_masked = _mask(state["seller_tckn"]) if state.get("seller_tckn") else None
32
+
33
+ result = write_application.invoke({
34
+ "session_id": state["session_id"],
35
+ "vehicle_type": vehicle_type,
36
+ "invoice_amount": state.get("invoice_amount"),
37
+ "vehicle_model": state.get("vehicle_model"),
38
+ "requested_amount_new": state.get("requested_amount_new"),
39
+ "guarantor_tckn_masked": guarantor_masked,
40
+ "kasko_value": state.get("kasko_value"),
41
+ "vehicle_age": state.get("vehicle_age"),
42
+ "requested_amount_used": state.get("requested_amount_used"),
43
+ "seller_tckn_masked": seller_masked,
44
+ })
45
+
46
+ log_tool_call(
47
+ session_id=state["session_id"],
48
+ agent="submission_agent",
49
+ tool_name="write_application",
50
+ args={"vehicle_type": vehicle_type},
51
+ result={"success": result["success"], "application_id": result.get("application_id")},
52
+ success=result["success"],
53
+ )
54
+
55
+ if not result["success"]:
56
+ error_msg = f"Başvuru kaydedilirken bir hata oluştu: {result['error']}. Lütfen tekrar deneyin."
57
+ return {
58
+ "current_step": "submission_failed",
59
+ "errors": state["errors"] + [error_msg],
60
+ "messages": state["messages"] + [AIMessage(content=error_msg)],
61
+ }
62
+
63
+ app_id = result["application_id"]
64
+ hgs_pitch = HGS_PITCH.format(application_id=app_id)
65
+
66
+ return {
67
+ "current_step": "submission_complete",
68
+ "application_id": app_id,
69
+ "messages": state["messages"] + [AIMessage(content=hgs_pitch)],
70
+ }
chatbot/agents/supervisor.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/supervisor.py — Supervisor / Router Agent
3
+
4
+ AGENTIC PATTERN: Supervisor Pattern
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Supervisor pattern'de bir üst agent, gelen isteği analiz eder
7
+ ve hangi alt-agent'ın görevi üstleneceğine karar verir.
8
+
9
+ Bu agent:
10
+ 1. Müşterinin mesajını alır
11
+ 2. Küçük (router) modeli kullanarak intent sınıflandırır
12
+ 3. Confidence < 0.7 ise netleştirme sorusu üretir
13
+ 4. Sonucu ConversationState'e yazar → LangGraph graph edge'i
14
+ bu sonuca göre bir sonraki node'u seçer
15
+
16
+ NEDEN Supervisor Pattern?
17
+ - Her alt-agent kendi sorumluluğuna odaklanır (single responsibility)
18
+ - Yeni bir dal (örn. "sigorta bilgisi") eklemek için sadece Supervisor
19
+ ve yeni agent yazılır; diğer agent'lara dokunulmaz
20
+ - Supervisor'ın kararı loglarda görülür → debug kolaylığı
21
+ """
22
+ import time
23
+ from langchain_core.messages import HumanMessage, AIMessage
24
+ from llm.factory import get_router_llm
25
+ from llm.json_utils import parse_llm_json
26
+ from llm.prompts import SUPERVISOR_SYSTEM, MSG_OUT_OF_SCOPE
27
+ from graph.state import ConversationState
28
+ from observability.audit_logger import log_llm_call
29
+
30
+
31
+ def run_supervisor(state: ConversationState) -> dict:
32
+ """
33
+ Supervisor node fonksiyonu.
34
+ LangGraph bu fonksiyonu çağırır; dönen dict state'i günceller.
35
+
36
+ Returns: state güncellemesi (sadece değişen alanlar)
37
+ """
38
+ last_message = state["messages"][-1]
39
+ user_text = last_message.content if hasattr(last_message, "content") else str(last_message)
40
+
41
+ # ── Mid-flow bypass ──────────────────────────────────────────────────────
42
+ # Zaten bir intake/recap/crosssell akışındaysak LLM sınıflandırması yapmadan
43
+ # mevcut adımı koru. Aksi halde "onaylıyorum", "2.500.000" gibi cevaplar
44
+ # yanlış intent (unclear / out_of_scope) olarak yorumlanır ve akış kopar.
45
+ # NOT: input_guard node'u current_step'i "input_guard_passed" olarak ezdiği
46
+ # için step kontrolüne güvenmiyoruz; vehicle_type + doldurulmuş slot'lara
47
+ # veya awaited_slot'a bakıyoruz.
48
+ vtype = state.get("vehicle_type")
49
+ has_new_progress = vtype == "new" and (
50
+ state.get("invoice_amount")
51
+ or state.get("vehicle_model")
52
+ or state.get("requested_amount_new")
53
+ )
54
+ has_used_progress = vtype == "used" and (
55
+ state.get("kasko_value")
56
+ or state.get("vehicle_age")
57
+ or state.get("requested_amount_used")
58
+ )
59
+ in_mid_flow = state.get("awaited_slot") or has_new_progress or has_used_progress
60
+
61
+ if in_mid_flow and vtype in ("new", "used"):
62
+ # Soru soruyorsa mid-flow'da bile FAQ'e yönlendir
63
+ if _looks_like_finance_question(user_text):
64
+ return {
65
+ "current_step": "supervisor_routed_faq",
66
+ "vehicle_type": vtype,
67
+ "_supervisor_intent": "faq",
68
+ }
69
+ intent = "new_car" if vtype == "new" else "used_car"
70
+ return {
71
+ "current_step": f"supervisor_routed_{intent}",
72
+ "vehicle_type": vtype,
73
+ "_supervisor_intent": intent,
74
+ }
75
+
76
+ # Deterministik kestirme: akış başında net araç türü ifadeleri LLM'e gitmeden yakalanır.
77
+ # (LLM router "yeni araç" gibi kısa ifadeleri bazen belirsiz sayıp clarifying'e düşürüyor.)
78
+ lower_text = user_text.lower().strip()
79
+ if not state.get("vehicle_type") and not _looks_like_finance_question(user_text):
80
+ is_new = any(w in lower_text for w in ["yeni", "sıfır", "sifir", "0 km", "0km", "sfr"])
81
+ is_used = any(w in lower_text for w in ["2.el", "2. el", "2el", "ikinci el", "kullanılmış", "kullanilmis"])
82
+ if is_new and not is_used:
83
+ return {"current_step": "supervisor_routed_new_car", "vehicle_type": "new", "_supervisor_intent": "new_car"}
84
+ if is_used and not is_new:
85
+ return {"current_step": "supervisor_routed_used_car", "vehicle_type": "used", "_supervisor_intent": "used_car"}
86
+
87
+ llm = get_router_llm()
88
+ start = time.time()
89
+
90
+ response = llm.invoke([
91
+ {"role": "system", "content": SUPERVISOR_SYSTEM},
92
+ {"role": "user", "content": user_text},
93
+ ])
94
+
95
+ latency = int((time.time() - start) * 1000)
96
+ log_llm_call(
97
+ session_id=state["session_id"],
98
+ agent="supervisor",
99
+ model=llm.model,
100
+ latency_ms=latency,
101
+ tokens_in=len(user_text.split()), # yaklaşık
102
+ tokens_out=len(response.content.split()),
103
+ prompt=[
104
+ {"role": "system", "content": SUPERVISOR_SYSTEM},
105
+ {"role": "user", "content": user_text},
106
+ ],
107
+ response=response.content,
108
+ )
109
+
110
+ # JSON parse (markdown code fence ve ek metni tolere eder)
111
+ parsed = parse_llm_json(response.content)
112
+ if parsed:
113
+ intent = parsed.get("intent", "out_of_scope")
114
+ confidence = parsed.get("confidence", 1.0)
115
+ clarification = parsed.get("clarification")
116
+ else:
117
+ intent = "out_of_scope"
118
+ confidence = 0.0
119
+ clarification = None
120
+
121
+ # Defansif heuristic — küçük router modeli finansman sorularını bazen yanlış
122
+ # ("out_of_scope", "unclear") ya da düşük confidence ile işaretliyor.
123
+ # Mesaj net bir araç finansmanı sorusuysa faq'e yönlendir ve clarifying'e
124
+ # düşmesini engelle (confidence'ı eşiğin üstüne çek).
125
+ if _looks_like_finance_question(user_text) and intent in ("out_of_scope", "unclear", "faq"):
126
+ intent = "faq"
127
+ confidence = max(confidence, 0.75)
128
+
129
+ # Netleştirme gerekiyorsa → mesaj ekle, intent belirsiz bırak
130
+ if intent == "unclear" or confidence < 0.7:
131
+ clarification_text = clarification or "İsteğinizi tam olarak anlamadım. Araç finansmanı mı yoksa başka bir konuda mı yardımcı olayım?"
132
+ return {
133
+ "current_step": "clarifying",
134
+ "messages": state["messages"] + [AIMessage(content=clarification_text)],
135
+ }
136
+
137
+ # Intent → vehicle_type mapping
138
+ vehicle_type = None
139
+ if intent == "new_car":
140
+ vehicle_type = "new"
141
+ elif intent == "used_car":
142
+ vehicle_type = "used"
143
+
144
+ # Araç türü belirsizse (new_car/used_car ama hangi tür belli değil) → sor
145
+ if intent in ("new_car", "used_car") and vehicle_type and not state.get("vehicle_type"):
146
+ # Mesajda açıkça "yeni"/"sıfır" veya "2.el"/"ikinci el" geçmiyorsa sor
147
+ lower_text = user_text.lower()
148
+ has_new = any(w in lower_text for w in ["yeni", "sıfır", "sfr", "0km", "sifir"])
149
+ has_used = any(w in lower_text for w in ["2.el", "ikinci el", "2el", "kullanılmış", "kullanilmis", "2. el"])
150
+ if not has_new and not has_used:
151
+ return {
152
+ "current_step": "clarifying",
153
+ "messages": state["messages"] + [AIMessage(content="Yeni araç mı, 2. el araç mı düşünüyorsunuz?")],
154
+ }
155
+
156
+ # Zaten devam eden bir intake adımındaysak current_step'e dokunma
157
+ # (intake_new/used hangi slot'u beklediğini current_step ile takip eder)
158
+ existing_step = state.get("current_step", "")
159
+ mid_intake = existing_step.startswith("asking_") or existing_step.endswith("_collected")
160
+ new_step = existing_step if mid_intake else f"supervisor_routed_{intent}"
161
+
162
+ return {
163
+ "current_step": new_step,
164
+ "vehicle_type": vehicle_type if vehicle_type else state.get("vehicle_type"),
165
+ # Kapsam dışı mesaj varsa hemen yanıtla
166
+ "messages": (
167
+ state["messages"] + [AIMessage(content=MSG_OUT_OF_SCOPE)]
168
+ if intent == "out_of_scope"
169
+ else state["messages"]
170
+ ),
171
+ "flow_ended": intent == "out_of_scope",
172
+ "_supervisor_intent": intent,
173
+ }
174
+
175
+
176
+ _FINANCE_KEYWORDS = (
177
+ "finansman", "kredi", "fatura", "kasko", "kefil", "vade", "faiz", "taksit",
178
+ "hgs", "araç", "arac", "model", "yaş", "yas", "tckn", "sigorta", "ödeme",
179
+ "odeme", "evrak", "belge", "başvuru", "basvuru", "limit", "proforma", "satıcı", "satici",
180
+ )
181
+ # "milyon", "tl", "tutar", "bin tl" kaldırıldı — saf sayı ifadelerinde false positive yaratıyor
182
+ _QUESTION_MARKERS = (
183
+ "?", " mi ", " mı ", " mu ", " mü ", "miyim", "mıyım", "muyum", "müyüm",
184
+ " mi?", " mı?", " mu?", " mü?",
185
+ "nedir", "ne kadar", "kaç", "nasıl", "ne olur", "var mı", "var mi",
186
+ )
187
+
188
+
189
+ def _looks_like_finance_question(text: str) -> bool:
190
+ """Mesaj araç finansmanı bağlamında bir soru gibi mi görünüyor?"""
191
+ if not text:
192
+ return False
193
+ lower = text.lower() + " " # sondaki boşluk: "...mu" gibi cümle sonu eklerini yakala
194
+ has_question = any(m in lower for m in _QUESTION_MARKERS)
195
+ has_finance = any(k in lower for k in _FINANCE_KEYWORDS)
196
+ return has_question and has_finance
chatbot/agents/validation_agent.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agents/validation_agent.py — Doğrulama Agent'ı (ReAct Pattern)
3
+
4
+ AGENTIC PATTERN: ReAct (Reason + Act)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ ReAct pattern: Düşün → Araç Çağır → Gözlemle → Tekrar Düşün...
7
+
8
+ Bu agent:
9
+ 1. Mevcut state'deki slot değerlerini alır
10
+ 2. İş kuralı tool'larını çağırarak doğrular
11
+ 3. Hata bulursa müşteriye açıklayıcı mesaj gönderir
12
+ 4. Tüm kurallar geçerse bir sonraki adıma onay verir
13
+
14
+ NEDEN ReAct?
15
+ - Validation birden fazla araç çağrısı gerektirebilir
16
+ (önce fatura kontrolü, sonra oran kontrolü, sonra kefil)
17
+ - Her araç sonucuna göre bir sonraki adımı dinamik seçer
18
+ - İş kuralları LLM'de değil, tool'larda → halüsinasyon riski 0
19
+
20
+ Araç erişimi: validate_new_car_amount, validate_used_car_amount,
21
+ validate_tckn, lookup_vehicle, calculate_max_finance_*
22
+ """
23
+ import time
24
+ from langchain_core.messages import AIMessage
25
+ from llm.factory import get_router_llm
26
+ from tools.tckn_tool import validate_tckn
27
+ from tools.catalog_tool import lookup_vehicle
28
+ from tools.amount_tool import validate_new_car_amount, validate_used_car_amount, calculate_max_finance_new, calculate_max_finance_used
29
+ from graph.state import ConversationState
30
+ from observability.audit_logger import log_tool_call
31
+
32
+
33
+ def validate_new_car_state(state: ConversationState) -> dict:
34
+ """
35
+ Yeni araç dalındaki tüm slot'ları sırayla doğrular.
36
+ İlk hata bulunduğunda müşteriye mesaj döner ve akış durur.
37
+ """
38
+ errors = []
39
+ messages_to_add = []
40
+
41
+ # Ticari araç kontrolü intake_new'da yapılıyor — validation'da tekrar kontrol gerekmez
42
+
43
+ # ── Finansman Tutarı Kontrolü ─────────────────────────────────────────────
44
+ if state.get("invoice_amount") and state.get("requested_amount_new"):
45
+ result = validate_new_car_amount.invoke({
46
+ "invoice_amount": state["invoice_amount"],
47
+ "requested_amount": state["requested_amount_new"],
48
+ })
49
+ log_tool_call(state["session_id"], "validation_agent", "validate_new_car_amount",
50
+ {"invoice": state["invoice_amount"], "requested": state["requested_amount_new"]},
51
+ result, result["valid"])
52
+
53
+ if not result["valid"]:
54
+ msg = (f"{result['error']}\n"
55
+ "Ne kadar finansman almak istiyorsunuz? (TL olarak)")
56
+ return {
57
+ "current_step": "validation_needs_retry",
58
+ "requested_amount_new": None,
59
+ "awaited_slot": "requested_amount_new",
60
+ "kefil_required": result.get("kefil_required", False),
61
+ "errors": state["errors"] + [result["error"]],
62
+ "messages": state["messages"] + [AIMessage(content=msg)],
63
+ "flow_ended": False,
64
+ }
65
+
66
+ # Kefil zorunluluğunu state'e yaz
67
+ if result.get("kefil_required"):
68
+ return {
69
+ "current_step": "validation_passed",
70
+ "kefil_required": True,
71
+ "errors": state["errors"],
72
+ "messages": state["messages"],
73
+ "flow_ended": False,
74
+ }
75
+
76
+ # ── Kefil TCKN Kontrolü ───────────────────────────────────────────────────
77
+ # TCKN mod-11 doğrulaması demo'da devre dışı — sadece 11 hane kontrolü intake'te yapılıyor
78
+
79
+ return {
80
+ "current_step": "validation_passed",
81
+ "errors": state["errors"],
82
+ "messages": state["messages"],
83
+ "flow_ended": False,
84
+ }
85
+
86
+
87
+ def validate_used_car_state(state: ConversationState) -> dict:
88
+ """2. El araç slot'larını doğrular."""
89
+ errors = []
90
+ messages_to_add = []
91
+
92
+ if state.get("kasko_value") and state.get("vehicle_age") is not None and state.get("requested_amount_used"):
93
+ result = validate_used_car_amount.invoke({
94
+ "kasko_value": state["kasko_value"],
95
+ "vehicle_age": state["vehicle_age"],
96
+ "requested_amount": state["requested_amount_used"],
97
+ })
98
+ log_tool_call(state["session_id"], "validation_agent", "validate_used_car_amount",
99
+ {"kasko": state["kasko_value"], "age": state["vehicle_age"],
100
+ "requested": state["requested_amount_used"]},
101
+ result, result["valid"])
102
+
103
+ if not result["valid"]:
104
+ msg = (f"{result['error']} Maksimum: {_fmt(result['max_allowed'])} TL.\n"
105
+ "Ne kadar finansman almak istiyorsunuz? (TL olarak)")
106
+ return {
107
+ "current_step": "validation_needs_retry",
108
+ "errors": state["errors"] + [result['error']],
109
+ "messages": state["messages"] + [AIMessage(content=msg)],
110
+ "requested_amount_used": None,
111
+ "awaited_slot": "requested_amount_used",
112
+ "flow_ended": False,
113
+ }
114
+
115
+ # Satıcı TCKN mod-11 doğrulaması demo'da devre dışı
116
+
117
+ return {
118
+ "current_step": "validation_passed",
119
+ "errors": state["errors"],
120
+ "messages": state["messages"],
121
+ "flow_ended": False,
122
+ }
123
+
124
+
125
+ def _fmt(amount: float) -> str:
126
+ return f"{int(amount):,}".replace(",", ".")
chatbot/app.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — Ana Uygulama Girişi (Gradio + FastAPI)
3
+
4
+ Gradio ChatInterface ile sohbet arayüzü sunar.
5
+ Üst kısımda model seçici dropdown ile GPT/Claude/Gemini arası geçiş.
6
+ Alt sekmede audit/metrik dashboard.
7
+
8
+ HuggingFace Spaces için: bu dosyayı root'ta tut, demo.launch() çağır.
9
+ """
10
+ import uuid
11
+ import os
12
+ import sys
13
+ import io
14
+
15
+ # Proje kök dizinini Python path'e ekle
16
+ sys.path.insert(0, os.path.dirname(__file__))
17
+
18
+ # Windows konsolunda emoji/unicode karakterlerin print hatası vermemesi için
19
+ if hasattr(sys.stdout, 'buffer'):
20
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace', line_buffering=True)
21
+
22
+ import gradio as gr
23
+ from langchain_core.messages import HumanMessage
24
+
25
+ from config import GRADIO_TITLE, GRADIO_SHARE
26
+ from graph.state import create_initial_state
27
+ from graph.workflow import get_compiled_graph, get_workflow_mermaid
28
+ from memory.checkpointer import get_thread_config
29
+ from memory.vector_store import index_faq
30
+ from tools.db_tool import init_db
31
+ from observability.dashboard import build_dashboard_tab
32
+
33
+ # ── Başlangıç: DB + FAQ İndeksleme ───────────────────────────────────────────
34
+ init_db()
35
+ try:
36
+ index_faq()
37
+ except Exception as e:
38
+ print(f"[Uyarı] FAQ indeksleme atlandı: {e}")
39
+
40
+ # Aktif oturumlar: session_id → ConversationState
41
+ _sessions: dict = {}
42
+
43
+
44
+ def chat(message: str, history: list, session_id: str, model_choice: str) -> str:
45
+ """
46
+ Gradio ChatInterface'in çağırdığı ana fonksiyon.
47
+
48
+ Args:
49
+ message: Kullanıcının yazdığı metin
50
+ history: Önceki mesajlar (Gradio formatı)
51
+ session_id: Oturum UUID (Gradio state'te saklanır)
52
+ model_choice: Kullanıcının seçtiği model
53
+
54
+ Returns:
55
+ Chatbot'un cevabı (string)
56
+ """
57
+ # Model seçimini config'e yansıt (runtime değişiklik)
58
+ _apply_model_choice(model_choice)
59
+
60
+ compiled = get_compiled_graph()
61
+ config = get_thread_config(session_id)
62
+
63
+ # Oturum state'i yoksa oluştur
64
+ if session_id not in _sessions:
65
+ _sessions[session_id] = create_initial_state(session_id)
66
+
67
+ state = _sessions[session_id]
68
+
69
+ # Kullanıcı mesajını state'e ekle
70
+ updated_messages = state["messages"] + [HumanMessage(content=message)]
71
+ state = {**state, "messages": updated_messages}
72
+ print(f"[DEBUG current state before invoke] {state}", flush=True)
73
+ import sys
74
+ print(f"[DEBUG chat] sid={session_id[:8]} msg={message!r} awaited={state.get('awaited_slot')} vtype={state.get('vehicle_type')} step={state.get('current_step')}", flush=True)
75
+ sys.stdout.flush()
76
+
77
+ # Graph'ı çalıştır
78
+ try:
79
+ result = compiled.invoke(state, config=config)
80
+ _sessions[session_id] = result
81
+ print(f"[DEBUG chat AFTER] awaited={result.get('awaited_slot')} vtype={result.get('vehicle_type')} step={result.get('current_step')} invoice={result.get('invoice_amount')}", flush=True)
82
+ sys.stdout.flush()
83
+
84
+ # Son AI mesajını bul
85
+ ai_messages = [m for m in result["messages"] if hasattr(m, "type") and m.type == "ai"]
86
+ if ai_messages:
87
+ return ai_messages[-1].content
88
+ return "İşleniyor..."
89
+ except Exception as e:
90
+ print(f"[ERROR chat] sid={session_id[:8]} {type(e).__name__}: {e}", flush=True)
91
+ from litellm.exceptions import ServiceUnavailableError, RateLimitError, Timeout
92
+ if isinstance(e, (ServiceUnavailableError, RateLimitError, Timeout)):
93
+ return ("Sistemimiz şu anda yoğun. Lütfen birkaç saniye sonra "
94
+ "mesajınızı tekrar gönderir misiniz? 🙏")
95
+ return "Beklenmeyen bir sorun oluştu. Lütfen tekrar deneyin."
96
+
97
+
98
+ def new_session() -> tuple[str, list]:
99
+ """Yeni oturum başlat — session_id üret, history temizle."""
100
+ session_id = str(uuid.uuid4())
101
+ welcome = "Merhaba! 👋 Araç finansmanı ön başvurusu için yardımcı olabilirim.\nYeni araç mı, 2. el araç mı düşünüyorsunuz?"
102
+ return session_id, [(None, welcome)]
103
+
104
+
105
+ def show_workflow() -> str:
106
+ """Workflow Mermaid diyagramını döndürür."""
107
+ try:
108
+ return f"```mermaid\n{get_workflow_mermaid()}\n```"
109
+ except Exception as e:
110
+ return f"Diyagram üretilirken hata: {e}"
111
+
112
+
113
+ def _apply_model_choice(choice: str):
114
+ """
115
+ Model seçimini runtime'da config modülüne uygular.
116
+ llm/factory.py config.ACTIVE_MODEL'i her çağrıda taze okuduğu için
117
+ bu değişiklik anında tüm agent'lara yansır — oturum sıfırlanmaz.
118
+ """
119
+ import config as cfg
120
+ model_map = {
121
+ "GPT-4o": ("gpt-4o", "gpt-4o-mini"),
122
+ "GPT-4o Mini": ("gpt-4o-mini", "gpt-4o-mini"),
123
+ "Claude Sonnet": ("claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
124
+ "Claude Haiku": ("claude-haiku-4-5-20251001", "claude-haiku-4-5-20251001"),
125
+ "Gemini Flash": ("gemini/gemini-2.5-flash", "gemini/gemini-2.5-flash-lite"),
126
+ "Gemini Flash Lite":("gemini/gemini-2.5-flash-lite", "gemini/gemini-2.5-flash-lite"),
127
+ }
128
+ if choice in model_map:
129
+ cfg.ACTIVE_MODEL, cfg.ROUTER_MODEL = model_map[choice]
130
+
131
+
132
+ # ── Gradio UI ─────────────────────────────────────────────────────────────────
133
+
134
+ MODEL_CHOICES = ["GPT-4o", "GPT-4o Mini", "Claude Sonnet", "Claude Haiku", "Gemini Flash", "Gemini Flash Lite"]
135
+
136
+ # Config'deki aktif modele göre dropdown başlangıç değerini belirle
137
+ _REVERSE_MODEL_MAP = {
138
+ "gpt-4o": "GPT-4o",
139
+ "gpt-4o-mini": "GPT-4o Mini",
140
+ "claude-sonnet-4-6": "Claude Sonnet",
141
+ "claude-haiku-4-5-20251001": "Claude Haiku",
142
+ "gemini/gemini-2.5-flash": "Gemini Flash",
143
+ "gemini/gemini-2.5-flash-lite": "Gemini Flash Lite",
144
+ "gemini/gemini-2.0-flash": "Gemini Flash",
145
+ "gemini/gemini-flash-latest": "Gemini Flash",
146
+ "gemini/gemini-2.0-flash-latest": "Gemini Flash",
147
+ "gemini/gemini-2.0-flash-lite": "Gemini Flash Lite",
148
+ }
149
+
150
+ import config as _init_cfg
151
+ _DEFAULT_MODEL_CHOICE = _REVERSE_MODEL_MAP.get(_init_cfg.ACTIVE_MODEL, "GPT-4o")
152
+
153
+ with gr.Blocks(title=GRADIO_TITLE) as demo:
154
+ gr.Markdown(f"# {GRADIO_TITLE}")
155
+ gr.Markdown("> Agentic AI öğrenme demosu — LangGraph + LiteLLM + ChromaDB/Jina RAG")
156
+
157
+ # Oturum state'i (tarayıcıda saklanır)
158
+ session_state = gr.State(value=str(uuid.uuid4()))
159
+
160
+ with gr.Tab("💬 Chatbot"):
161
+ with gr.Row():
162
+ model_dropdown = gr.Dropdown(
163
+ choices=MODEL_CHOICES,
164
+ value=_DEFAULT_MODEL_CHOICE,
165
+ label="🤖 Dil Modeli",
166
+ scale=2,
167
+ )
168
+ import config as _cfg
169
+ active_model_label = gr.Textbox(
170
+ value=f"{_cfg.ACTIVE_MODEL} | router: {_cfg.ROUTER_MODEL}",
171
+ label="Aktif Model",
172
+ interactive=False,
173
+ scale=3,
174
+ )
175
+ new_session_btn = gr.Button("🔄 Yeni Oturum", scale=1)
176
+
177
+ chatbot_ui = gr.Chatbot(
178
+ label="Arac Finansmani Chatbot",
179
+ height=500,
180
+ )
181
+
182
+ with gr.Row():
183
+ msg_input = gr.Textbox(
184
+ placeholder="Mesajınızı yazın... (örn: yeni araç finansmanı istiyorum)",
185
+ label="",
186
+ scale=5,
187
+ container=False,
188
+ )
189
+ send_btn = gr.Button("Gönder", scale=1, variant="primary")
190
+
191
+ # Model değişince etiketi güncelle (oturum SIFIRLANMAZ)
192
+ def on_model_change(choice):
193
+ _apply_model_choice(choice)
194
+ import config as cfg
195
+ return f"{cfg.ACTIVE_MODEL} | router: {cfg.ROUTER_MODEL}"
196
+
197
+ model_dropdown.change(
198
+ fn=on_model_change,
199
+ inputs=[model_dropdown],
200
+ outputs=[active_model_label],
201
+ )
202
+
203
+ # Mesaj gönderme
204
+ def respond(message, history, session_id, model):
205
+ if not message.strip():
206
+ return history, ""
207
+ response = chat(message, history, session_id, model)
208
+ history = history + [
209
+ {"role": "user", "content": message},
210
+ {"role": "assistant", "content": response},
211
+ ]
212
+ return history, ""
213
+
214
+ send_btn.click(
215
+ fn=respond,
216
+ inputs=[msg_input, chatbot_ui, session_state, model_dropdown],
217
+ outputs=[chatbot_ui, msg_input],
218
+ )
219
+ msg_input.submit(
220
+ fn=respond,
221
+ inputs=[msg_input, chatbot_ui, session_state, model_dropdown],
222
+ outputs=[chatbot_ui, msg_input],
223
+ )
224
+
225
+ # Yeni oturum
226
+ def start_new(old_sid):
227
+ new_sid = str(uuid.uuid4())
228
+ welcome = "Merhaba! 👋 Araç finansmanı ön başvurusu için yardımcı olabilirim.\nYeni araç mı, 2. el araç mı düşünüyorsunuz?"
229
+ return new_sid, [{"role": "assistant", "content": welcome}]
230
+
231
+ new_session_btn.click(
232
+ fn=start_new,
233
+ inputs=[session_state],
234
+ outputs=[session_state, chatbot_ui],
235
+ )
236
+
237
+ with gr.Tab("🗺️ Workflow Diyagramı"):
238
+ gr.Markdown("LangGraph state machine görselleştirmesi:")
239
+ diagram_output = gr.Markdown()
240
+ show_btn = gr.Button("Diyagramı Göster")
241
+ show_btn.click(fn=show_workflow, outputs=diagram_output)
242
+
243
+ # Audit/Metrik Dashboard sekmesi
244
+ build_dashboard_tab()
245
+
246
+ with gr.Tab("ℹ️ Agentic Pattern'ler"):
247
+ gr.Markdown("""
248
+ ## Bu Projede Kullanılan Agentic Pattern'ler
249
+
250
+ | Pattern | Dosya | Açıklama |
251
+ |---|---|---|
252
+ | **State Machine** | `graph/workflow.py` | LangGraph StateGraph — tüm akış |
253
+ | **Supervisor** | `agents/supervisor.py` | Intent sınıflandırma + dal yönlendirme |
254
+ | **Tool Use** | `tools/` | @tool ile iş kuralları kapsülleme |
255
+ | **ReAct** | `agents/validation_agent.py` | Reason + Act döngüsü |
256
+ | **RAG** | `agents/faq_agent.py` | Jina embed + ChromaDB retrieval |
257
+ | **Reflection** | `agents/reflection_agent.py` | Self-check onay öncesi |
258
+ | **Short-Term Memory** | `memory/checkpointer.py` | LangGraph MemorySaver |
259
+ | **Long-Term Memory** | `memory/vector_store.py` | ChromaDB persist |
260
+ | **Least-Privilege** | `agents/crosssell_agent.py` | Scoped tool access |
261
+ | **Input/Output Guard** | `guardrails/` | PII + injection filter |
262
+ | **Audit Log** | `observability/audit_logger.py` | KVKK/BDDK uyumlu log |
263
+ | **Streaming** | `app.py` | Gradio token streaming |
264
+ """)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ demo.launch(
269
+ server_name="0.0.0.0",
270
+ server_port=int(os.environ.get("GRADIO_SERVER_PORT", 7860)),
271
+ share=GRADIO_SHARE,
272
+ )
chatbot/config.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ config.py — Merkezi konfigürasyon dosyası.
3
+
4
+ Burası tüm çevresel değişkenleri ve model seçimini yönetir.
5
+ Model değiştirmek için sadece ACTIVE_MODEL ve ROUTER_MODEL'i güncelle;
6
+ başka hiçbir dosyaya dokunman gerekmez.
7
+ """
8
+ import os
9
+ from dotenv import load_dotenv
10
+
11
+ load_dotenv()
12
+
13
+ # ── LLM Model Seçimi ─────────────────────────────────────────────────────────
14
+ # Büyük model: FAQ cevaplama, çok adımlı muhakeme
15
+ # Küçük model: Intent sınıflandırma, slot çıkarımı, kısa onaylar
16
+ ACTIVE_MODEL: str = os.getenv("ACTIVE_MODEL", "gpt-4o")
17
+ ROUTER_MODEL: str = os.getenv("ROUTER_MODEL", "gpt-4o-mini")
18
+
19
+ # ── API Key'ler ───────────────────────────────────────────────────────────────
20
+ OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
21
+ ANTHROPIC_API_KEY: str = os.getenv("ANTHROPIC_API_KEY", "")
22
+ GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY", "")
23
+ JINA_API_KEY: str = os.getenv("JINA_API_KEY", "")
24
+
25
+ # ── Vektör DB ─────────────────────────────────────────────────────────────────
26
+ CHROMA_PERSIST_DIR: str = os.path.join(os.path.dirname(__file__), "data", "chroma")
27
+ FAQ_COLLECTION_NAME: str = "faq_chunks"
28
+
29
+ # ── SQLite ────────────────────────────────────────────────────────────────────
30
+ DB_PATH: str = os.path.join(os.path.dirname(__file__), "data", "applications.db")
31
+
32
+ # ── İş Kuralları ─────────────────────────────────────────────────────────────
33
+ # Yeni araç
34
+ NEW_CAR_MAX_INVOICE: float = 7_000_000.0 # TL — üst fatura limiti
35
+ NEW_CAR_FINANCE_RATIO: float = 0.60 # maks finansman / fatura
36
+ NEW_CAR_GUARANTOR_THRESHOLD: float = 5_000_000.0 # bu tutarın üstünde kefil zorunlu
37
+
38
+ # 2. el araç
39
+ USED_CAR_MAX_AGE: int = 5 # yıl
40
+ USED_CAR_FINANCE_RATIO: float = 0.40 # maks finansman / kasko
41
+ USED_CAR_MAX_FINANCE: float = 3_000_000.0 # TL — mutlak üst limit
42
+
43
+ # ── Embedding ─────────────────────────────────────────────────────────────────
44
+ JINA_EMBEDDING_MODEL: str = "jina-embeddings-v3"
45
+ JINA_API_URL: str = "https://api.jina.ai/v1/embeddings"
46
+ RAG_TOP_K: int = 3 # kaç chunk getirilecek
47
+
48
+ # ── Güvenlik ─────────────────────────────────────────────────────────────────
49
+ # Prompt injection tespit için anahtar kelime listesi
50
+ INJECTION_KEYWORDS: list[str] = [
51
+ "tüm talimatları unut", "ignore previous", "ignore all", "system:",
52
+ "disregard", "jailbreak", "pretend you are", "roleplay as",
53
+ "önceki talimatları sil", "artık farklı davran",
54
+ ]
55
+
56
+ # ── Gradio ───────────────────────────────────────────────────────────────────
57
+ GRADIO_TITLE: str = "Araç Finansmanı Chatbot — Agentic Demo"
58
+ GRADIO_SHARE: bool = False # HuggingFace Spaces'te True olur
chatbot/data/faq.md ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Araç Finansmanı Sıkça Sorulan Sorular
2
+
3
+ ## Genel Taşıt Finansmanı
4
+
5
+ ### Taşıt finansmanı nedir?
6
+ Taşıt finansmanı, banka aracılığıyla araç satın alımınız için sağlanan kredili ödeme çözümüdür. Belirlenen vade ve faiz oranı üzerinden aylık taksitlerle ödeme yaparsınız.
7
+
8
+ ### Taşıt finansmanına kimler başvurabilir?
9
+ Bankamızın mobil uygulamasına kayıtlı, 18 yaş üzeri, Türkiye Cumhuriyeti vatandaşı veya Türk vatandaşlığı bulunan yabancı uyruklu bireyler başvurabilir.
10
+
11
+ ### Başvuru ne kadar sürer?
12
+ Chatbot üzerinden ön başvuru birkaç dakika içinde tamamlanır. Ön başvuru onayı genellikle 1 iş günü içinde yapılır; nihai onay için gerekli belgelerin tamamlanması 2-3 iş günü sürebilir.
13
+
14
+ ### Finansman başvurusu için şubeye gitmem gerekir mi?
15
+ Ön başvuruyu tamamen mobil uygulama üzerinden yapabilirsiniz. Ancak nihai onay için bazı durumlarda şubeye gidilmesi gerekebilir.
16
+
17
+ ### Araç finansmanı ile normal kredi arasındaki fark nedir?
18
+ Araç finansmanı, satın alınan araç güvence (teminat) olarak gösterildiği için genellikle daha düşük faiz oranıyla kullandırılır. Araç üzerine rehin konulur; kredi tamamlanınca rehin kalkar.
19
+
20
+ ---
21
+
22
+ ## Yeni Araç Finansman Koşulları
23
+
24
+ ### Yeni araç finansmanında maksimum ne kadar kredi alabilirim?
25
+ Proforma fatura tutarının en fazla %60'ı kadar finansman kullanabilirsiniz. Örneğin 5.000.000 TL'lik bir araç için maksimum 3.000.000 TL finansman alabilirsiniz.
26
+
27
+ ### Yeni araç finansmanında fatura tutarı üst sınırı nedir?
28
+ Proforma fatura tutarı 7.000.000 TL'yi geçemez. Bu tutarın üzerindeki araçlar için şubemizi ziyaret etmenizi öneririz.
29
+
30
+ ### Fatura tutarım 5.000.000 TL üzerindeyse ne olur?
31
+ 5.000.000 TL ve üzeri fatura tutarlarında kefil göstermek zorunludur. Kefilinizin TC kimlik numarasını hazır bulundurmanız gerekmektedir.
32
+
33
+ ### Kefil olarak kim gösterilebilir?
34
+ Kefil; 18 yaş üzeri, düzenli geliri olan ve bankamız sisteminde negatif kaydı bulunmayan herhangi bir kişi olabilir. Kefil, başvuru sahibi ile aynı kişi olamaz.
35
+
36
+ ### Ticari araçlar için finansman yapılıyor mu?
37
+ Hayır, bankamız yeni araç finansmanında yalnızca binek araçlara finansman sağlamaktadır. Ticari araç finansmanı için işletme kredisi ürünlerimizi inceleyebilirsiniz.
38
+
39
+ ### Proforma fatura nedir?
40
+ Proforma fatura, araç bayisinin size önceden düzenlediği, kesin satış öncesi tahmini fiyatı gösteren belgedir. Gerçek fatura ile farklılık gösterebilir; nihai onay için gerçek fatura gerekir.
41
+
42
+ ### Yeni araçta vade seçenekleri nelerdir?
43
+ Yeni araç finansmanında 12, 24, 36, 48 ve 60 ay vade seçenekleri sunulmaktadır. Daha uzun vadeler için şube ile görüşmenizi öneririz.
44
+
45
+ ---
46
+
47
+ ## 2. El Araç Finansman Koşulları
48
+
49
+ ### 2. el araç finansmanı nasıl işler?
50
+ 2. el araç finansmanında aracın güncel kasko değeri ve yaşı esas alınır. Kasko değerinin %40'ı ile 3.000.000 TL arasındaki düşük tutar maksimum finansman olarak belirlenir.
51
+
52
+ ### 2. el araçta maksimum finansman tutarı nedir?
53
+ İki sınır vardır:
54
+ - Kasko değerinin %40'ı
55
+ - Mutlak üst limit: 3.000.000 TL
56
+ Bu ikisinden düşük olanı uygulanır.
57
+
58
+ ### 2. el araçta araç yaşı sınırı nedir?
59
+ Araç yaşı başvuru tarihi itibarıyla 5 yılı geçemez. 5 yıldan eski araçlara finansman sağlanmamaktadır.
60
+
61
+ ### 2. el araç alırken satıcı TCKN'si gerekli mi?
62
+ Satıcı TC kimlik numarası zorunlu değildir; isteğe bağlı olarak girebilirsiniz. Satıcı TCKN'si sağlandığında işlem güvenliğiniz artmaktadır.
63
+
64
+ ### 2. el araçta kasko değeri nasıl tespit edilir?
65
+ Kasko değerini sigorta şirketinizden veya bağımsız ekspertiz raporundan öğrenebilirsiniz. Başvuru sırasında tahmini değer girebilirsiniz; nihai onayda ekspertiz raporu istenebilir.
66
+
67
+ ### 2. el araçta vade seçenekleri nelerdir?
68
+ 2. el araç finansmanında 12, 24 ve 36 ay vade seçenekleri sunulmaktadır.
69
+
70
+ ### Yurt dışından araç alımında finansman yapılıyor mu?
71
+ Yurt dışından getirilen araçlara finansman sağlanmamaktadır. Yalnızca Türkiye'de kayıtlı araçlara hizmet verilmektedir.
72
+
73
+ ---
74
+
75
+ ## Kefil Gereksinimleri
76
+
77
+ ### Kefil her başvuruda zorunlu mu?
78
+ Hayır. Kefil yalnızca yeni araç başvurularında ve proforma fatura tutarı 5.000.000 TL veya üzerinde olduğunda zorunludur.
79
+
80
+ ### Kefilin geliri yeterli mi olmalı?
81
+ Evet, kefilin kredi notunun yeterli ve düzenli gelirinin bulunması gerekmektedir. Kefilin geliri, aylık taksit tutarının en az 3 katı olmalıdır.
82
+
83
+ ### Kefil olmak kefilim için risk yaratır mı?
84
+ Evet. Asıl borçlunun ödeme yapmaması durumunda kefil ödeme yükümlülüğünü üstlenir.
85
+
86
+ ### Kefil TCKN'sini nasıl girmeliyim?
87
+ 11 haneli TC kimlik numarasını rakamlarla yazmanız yeterlidir. Chatbot TCKN'nizi anında doğrulayacaktır.
88
+
89
+ ---
90
+
91
+ ## Başvuru Süreci ve Gerekli Belgeler
92
+
93
+ ### Ön başvuru için hangi belgeler gerekli?
94
+ Ön başvuru için herhangi bir belge yüklemeniz gerekmez; bilgileri chatbot üzerinden girmeniz yeterlidir. Nihai onay aşamasında:
95
+ - Nüfus cüzdanı
96
+ - Gelir belgesi (maaş bordrosu veya vergi levhası)
97
+ - Araç fatura veya tescil belgesi
98
+ istenebilir.
99
+
100
+ ### Başvurumun durumunu nasıl takip edebilirim?
101
+ Mobil uygulamanın "Başvurularım" bölümünden başvuru numaranızla takip edebilirsiniz.
102
+
103
+ ### Başvuru reddedilirse ne yapabilirim?
104
+ Red kararına itiraz için en yakın şubemizi ziyaret edebilir veya müşteri hizmetlerimizi arayabilirsiniz.
105
+
106
+ ### Birden fazla başvuru yapabilir miyim?
107
+ Aynı anda yalnızca bir aktif başvurunuz olabilir. Önceki başvurunuz sonuçlanmadan yeni başvuru açamazsınız.
108
+
109
+ ---
110
+
111
+ ## HGS Ürünü
112
+
113
+ ### HGS nedir?
114
+ HGS (Hızlı Geçiş Sistemi), köprü ve otoyol geçişlerinde kullanılan elektronik ödeme sistemidir. Araç camına yerleştirilen etiket sayesinde bariyer açılmadan geçiş yapabilirsiniz.
115
+
116
+ ### Bankamız üzerinden HGS nasıl tanımlatılır?
117
+ Araç finansmanı başvurusu tamamlandıktan sonra chatbot size HGS tanımlatma seçeneği sunar. Onaylamanız halinde hesabınıza otomatik olarak HGS tanımlanır.
118
+
119
+ ### HGS için ek ücret var mı?
120
+ HGS etiket ücreti ve ilk yükleme miktarı için şube veya müşteri hizmetlerimizden güncel bilgi alabilirsiniz.
121
+
122
+ ### Zaten HGS'm varsa ne olur?
123
+ Aktif HGS'iniz varsa chatbot otomatik olarak bu adımı atlayacaktır.
124
+
125
+ ### HGS bakiyesini nasıl yükleyebilirim?
126
+ Mobil uygulama, internet bankacılığı veya ATM üzerinden HGS bakiyenizi yükleyebilirsiniz.
127
+
128
+ ---
129
+
130
+ ## Ödeme Planı ve Vade Seçenekleri
131
+
132
+ ### Taksit tutarım nasıl hesaplanır?
133
+ Aylık taksit = (Finansman tutarı × Aylık faiz oranı) / (1 - (1 + Aylık faiz oranı)^(-Vade)) formülüyle hesaplanır. Kesin taksit tutarı için güncel faiz oranlarına göre hesaplama yapılır.
134
+
135
+ ### Erken ödeme yapabilir miyim?
136
+ Evet, erken kapatma mümkündür. Erken ödeme komisyonu olup olmadığını sözleşme aşamasında öğrenebilirsiniz.
137
+
138
+ ### Taksit ödeme tarihimi değiştirebilir miyim?
139
+ Ödeme günü değişikliği için şubenize başvurmanız gerekmektedir.
140
+
141
+ ### Taksitimi geç ödersem ne olur?
142
+ Gecikme faizi uygulanır ve kredi notunuz olumsuz etkilenebilir. Ödeme güçlüğü yaşarsanız önceden müşteri hizmetlerimizle iletişime geçmenizi öneririz.
143
+
144
+ ### Sabit faizli ve değişken faizli seçenek var mı?
145
+ Bankamız taşıt finansmanında sabit faiz oranı uygulamaktadır. Bu sayede ödeme planınız baştan bellidir.
146
+
147
+ ### Finansmanımı yeniden yapılandırabilir miyim?
148
+ Yapılandırma talepleri şube üzerinden değerlendirilmektedir. Koşullara bağlı olarak uygulanabilir.
149
+
150
+ ---
151
+
152
+ ## Sigorta ve Güvenceler
153
+
154
+ ### Araç zorunlu trafik sigortası gerekli mi?
155
+ Evet, finansman kullandırılan araçların zorunlu trafik sigortası (trafik sigortası) yaptırılması zorunludur.
156
+
157
+ ### Kasko sigortası zorunlu mu?
158
+ 2. el araç finansmanında kasko sigortası zorunludur. Yeni araç finansmanında önerilmekle birlikte zorunluluk koşula bağlıdır.
159
+
160
+ ### Araç üzerine rehin konulur mu?
161
+ Evet. Finansman süresi boyunca araç üzerine bankamız lehine rehin (trafik siciline şerh) konulur. Kredi kapatıldığında rehin otomatik olarak kaldırılır.
162
+
163
+ ---
164
+
165
+ ## Teknik ve Uygulama
166
+
167
+ ### Chatbot hangi saatlerde hizmet veriyor?
168
+ Chatbot 7/24 hizmet vermektedir. İnsan operatörler hafta içi 08:00-20:00 saatleri arasında ulaşılabilirdir.
169
+
170
+ ### Bilgilerimi yanlış girdim, düzeltebilir miyim?
171
+ Evet. Tüm bilgileri girdikten sonra onay ekranında istediğiniz alanı değiştirebilirsiniz.
172
+
173
+ ### Başvuruyu yarıda bırakırsam ne olur?
174
+ Bilgileriniz oturum süresince saklanır. Oturum kapandığında taslak başvurunuz silinir; tekrar başlamak gerekir.
175
+
176
+ ### Kişisel verilerim güvende mi?
177
+ Kişisel verileriniz KVKK kapsamında işlenmektedir. TC kimlik numaranız gibi hassas veriler sistemimizde maskelenmiş olarak saklanır ve banka dışına çıkmaz.
178
+
179
+ ### İnternet bağlantım kesilirse başvuruma ne olur?
180
+ Bağlantı kesildiğinde o ana kadar girilen bilgiler geçici olarak saklanır. Bağlantı yeniden kurulduğunda kaldığınız yerden devam edebilirsiniz.
chatbot/data/vehicle_catalog.json ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {"brand": "Toyota", "model": "Corolla", "type": "binek", "variants": ["1.5 Flame", "1.5 Dream", "1.8 Hybrid", "2.0 GR Sport"]},
3
+ {"brand": "Toyota", "model": "Yaris", "type": "binek", "variants": ["1.5 Flame", "1.5 Dream", "1.5 GR Sport"]},
4
+ {"brand": "Toyota", "model": "C-HR", "type": "binek", "variants": ["1.8 Hybrid", "2.0 GR Sport"]},
5
+ {"brand": "Toyota", "model": "RAV4", "type": "binek", "variants": ["2.5 Hybrid", "2.0 Adventure"]},
6
+ {"brand": "Toyota", "model": "Camry", "type": "binek", "variants": ["2.5 Hybrid", "2.5 Executive"]},
7
+ {"brand": "Toyota", "model": "Proace", "type": "ticari", "variants": ["L1H1", "L2H1", "Proace City"]},
8
+ {"brand": "Toyota", "model": "Hilux", "type": "ticari", "variants": ["4x4 Adventure", "4x4 Executive"]},
9
+
10
+ {"brand": "Honda", "model": "Civic", "type": "binek", "variants": ["1.5 Elegance", "1.5 Executive", "2.0 RS"]},
11
+ {"brand": "Honda", "model": "Jazz", "type": "binek", "variants": ["1.5 i-MMD Elegant", "1.5 i-MMD Executive"]},
12
+ {"brand": "Honda", "model": "HR-V", "type": "binek", "variants": ["1.5 i-MMD Comfort", "1.5 i-MMD Advance"]},
13
+ {"brand": "Honda", "model": "CR-V", "type": "binek", "variants": ["2.0 i-MMD Comfort", "2.0 i-MMD Executive"]},
14
+ {"brand": "Honda", "model": "e:NY1", "type": "binek", "variants": ["Standard"]},
15
+
16
+ {"brand": "Volkswagen", "model": "Golf", "type": "binek", "variants": ["1.0 TSI Life", "1.5 TSI Life", "1.5 TSI Style", "2.0 GTI"]},
17
+ {"brand": "Volkswagen", "model": "Polo", "type": "binek", "variants": ["1.0 TSI Comfortline", "1.0 TSI Highline"]},
18
+ {"brand": "Volkswagen", "model": "Passat", "type": "binek", "variants": ["1.5 TSI Life", "2.0 TDI Business", "2.0 TDI Elegance"]},
19
+ {"brand": "Volkswagen", "model": "Tiguan", "type": "binek", "variants": ["1.5 TSI Life", "2.0 TDI Elegance"]},
20
+ {"brand": "Volkswagen", "model": "T-Roc", "type": "binek", "variants": ["1.0 TSI Style", "1.5 TSI Sport"]},
21
+ {"brand": "Volkswagen", "model": "Arteon", "type": "binek", "variants": ["2.0 TSI Elegance", "2.0 TSI R-Line"]},
22
+ {"brand": "Volkswagen", "model": "Transporter", "type": "ticari", "variants": ["T6.1 Camlı Van", "T6.1 City Van", "Caravelle"]},
23
+ {"brand": "Volkswagen", "model": "Caddy", "type": "ticari", "variants": ["1.5 TSI Life", "2.0 TDI Life"]},
24
+ {"brand": "Volkswagen", "model": "Crafter", "type": "ticari", "variants": ["35 Uzun", "50 Uzun"]},
25
+
26
+ {"brand": "BMW", "model": "1 Serisi", "type": "binek", "variants": ["118i M Sport", "120i M Sport"]},
27
+ {"brand": "BMW", "model": "2 Serisi", "type": "binek", "variants": ["220i M Sport", "218d M Sport", "M240i xDrive"]},
28
+ {"brand": "BMW", "model": "3 Serisi", "type": "binek", "variants": ["318i", "320i", "330i", "330e", "M340i xDrive"]},
29
+ {"brand": "BMW", "model": "4 Serisi", "type": "binek", "variants": ["420i Coupe", "430i Coupe", "M440i Coupe"]},
30
+ {"brand": "BMW", "model": "5 Serisi", "type": "binek", "variants": ["520i", "530i xDrive", "530e", "M550i xDrive"]},
31
+ {"brand": "BMW", "model": "X1", "type": "binek", "variants": ["sDrive18i", "xDrive23i", "iX1 xDrive30"]},
32
+ {"brand": "BMW", "model": "X3", "type": "binek", "variants": ["xDrive20i", "xDrive30i", "M40i"]},
33
+ {"brand": "BMW", "model": "X5", "type": "binek", "variants": ["xDrive40i", "xDrive50e", "M60i"]},
34
+ {"brand": "BMW", "model": "iX", "type": "binek", "variants": ["xDrive40", "xDrive50", "M60"]},
35
+
36
+ {"brand": "Mercedes-Benz", "model": "A Serisi", "type": "binek", "variants": ["A 180 AMG Line", "A 200 AMG Line", "A 250 e AMG Line"]},
37
+ {"brand": "Mercedes-Benz", "model": "C Serisi", "type": "binek", "variants": ["C 180", "C 200 AMG Line", "C 300 AMG Line", "C 300 e AMG Line"]},
38
+ {"brand": "Mercedes-Benz", "model": "E Serisi", "type": "binek", "variants": ["E 200 Avantgarde", "E 220 d AMG Line", "E 450 AMG Line"]},
39
+ {"brand": "Mercedes-Benz", "model": "GLA", "type": "binek", "variants": ["GLA 180 AMG Line", "GLA 200 AMG Line"]},
40
+ {"brand": "Mercedes-Benz", "model": "GLC", "type": "binek", "variants": ["GLC 200 AMG Line", "GLC 300 e AMG Line"]},
41
+ {"brand": "Mercedes-Benz", "model": "EQA", "type": "binek", "variants": ["EQA 250", "EQA 350 4MATIC"]},
42
+ {"brand": "Mercedes-Benz", "model": "Sprinter", "type": "ticari", "variants": ["314 CDI L2H2", "316 CDI L3H2", "319 CDI L3H2"]},
43
+ {"brand": "Mercedes-Benz", "model": "Vito", "type": "ticari", "variants": ["114 CDI Uzun", "116 CDI Uzun", "119 CDI Extra Uzun"]},
44
+
45
+ {"brand": "Ford", "model": "Fiesta", "type": "binek", "variants": ["1.0 EcoBoost Titanium", "1.5 EcoBoost ST"]},
46
+ {"brand": "Ford", "model": "Focus", "type": "binek", "variants": ["1.0 EcoBoost Titanium", "1.5 EcoBoost ST-Line", "2.3 ST"]},
47
+ {"brand": "Ford", "model": "Mondeo", "type": "binek", "variants": ["2.0 EcoBlue Titanium"]},
48
+ {"brand": "Ford", "model": "Kuga", "type": "binek", "variants": ["1.5 EcoBlue ST-Line", "2.5 PHEV Titanium"]},
49
+ {"brand": "Ford", "model": "Puma", "type": "binek", "variants": ["1.0 EcoBoost mHEV ST-Line", "1.0 EcoBoost mHEV Titanium"]},
50
+ {"brand": "Ford", "model": "Mustang Mach-E", "type": "binek", "variants": ["Standard Range RWD", "Extended Range AWD"]},
51
+ {"brand": "Ford", "model": "Transit", "type": "ticari", "variants": ["Transit 350 L2H2", "Transit Custom 320", "Transit Connect"]},
52
+ {"brand": "Ford", "model": "Ranger", "type": "ticari", "variants": ["2.0 EcoBlue XLT", "3.0 V6 Raptor"]},
53
+ {"brand": "Ford", "model": "Transit Courier", "type": "ticari", "variants": ["1.0 EcoBoost Trend"]},
54
+
55
+ {"brand": "Fiat", "model": "Egea", "type": "binek", "variants": ["1.4 Fire Easy", "1.6 Multijet Easy Plus", "1.6 Multijet Lounge"]},
56
+ {"brand": "Fiat", "model": "Panda", "type": "binek", "variants": ["1.0 Hybrid Pop", "1.0 Hybrid Urban"]},
57
+ {"brand": "Fiat", "model": "500", "type": "binek", "variants": ["1.0 Hybrid Pop Star", "Electric 42 kWh"]},
58
+ {"brand": "Fiat", "model": "Tipo", "type": "binek", "variants": ["1.6 Multijet Urban", "1.6 Multijet Lounge"]},
59
+ {"brand": "Fiat", "model": "Doblo", "type": "ticari", "variants": ["1.3 Multijet Cargo", "1.6 Multijet Combi"]},
60
+ {"brand": "Fiat", "model": "Ducato", "type": "ticari", "variants": ["2.3 Multijet L2H2", "2.3 Multijet L3H2"]},
61
+
62
+ {"brand": "Renault", "model": "Clio", "type": "binek", "variants": ["1.0 TCe Joy", "1.0 TCe Touch", "1.6 E-Tech Hybrid"]},
63
+ {"brand": "Renault", "model": "Megane", "type": "binek", "variants": ["1.3 TCe Touch", "1.6 E-Tech PHEV RS Line", "E-Tech Electric 60 kWh"]},
64
+ {"brand": "Renault", "model": "Kadjar", "type": "binek", "variants": ["1.3 TCe Touch", "1.5 Blue dCi Intens"]},
65
+ {"brand": "Renault", "model": "Captur", "type": "binek", "variants": ["1.0 TCe Joy", "1.6 E-Tech PHEV RS Line"]},
66
+ {"brand": "Renault", "model": "Zoe", "type": "binek", "variants": ["R110", "R135"]},
67
+ {"brand": "Renault", "model": "Austral", "type": "binek", "variants": ["1.2 TCe Mild Hybrid Equilibre", "E-Tech 200 Esprit Alpine"]},
68
+ {"brand": "Renault", "model": "Trafic", "type": "ticari", "variants": ["2.0 dCi L1H1", "2.0 dCi L2H1"]},
69
+ {"brand": "Renault", "model": "Master", "type": "ticari", "variants": ["2.3 dCi L2H2", "2.3 dCi L3H2"]},
70
+
71
+ {"brand": "Hyundai", "model": "i10", "type": "binek", "variants": ["1.0 MPI Style", "1.2 MPI Elite"]},
72
+ {"brand": "Hyundai", "model": "i20", "type": "binek", "variants": ["1.0 T-GDI Style", "1.0 T-GDI Elite", "N Line 1.0 T-GDI"]},
73
+ {"brand": "Hyundai", "model": "i30", "type": "binek", "variants": ["1.0 T-GDI Style", "1.5 T-GDI N Line", "2.0 N"]},
74
+ {"brand": "Hyundai", "model": "Tucson", "type": "binek", "variants": ["1.6 T-GDI Style", "1.6 T-GDI Hybrid", "1.6 T-GDI PHEV"]},
75
+ {"brand": "Hyundai", "model": "Kona", "type": "binek", "variants": ["1.0 T-GDI Style", "Electric 65.4 kWh"]},
76
+ {"brand": "Hyundai", "model": "IONIQ 6", "type": "binek", "variants": ["RWD Standard Range", "AWD Long Range"]},
77
+ {"brand": "Hyundai", "model": "IONIQ 5", "type": "binek", "variants": ["RWD Standard", "AWD Boost"]},
78
+
79
+ {"brand": "Kia", "model": "Picanto", "type": "binek", "variants": ["1.2 MPI Cool", "1.2 MPI Comfort"]},
80
+ {"brand": "Kia", "model": "Rio", "type": "binek", "variants": ["1.2 MPI Cool", "1.4 MPI Style Plus"]},
81
+ {"brand": "Kia", "model": "Ceed", "type": "binek", "variants": ["1.0 T-GDI Cool", "1.5 T-GDI Style"]},
82
+ {"brand": "Kia", "model": "Sportage", "type": "binek", "variants": ["1.6 T-GDI Cool", "1.6 T-GDI Hybrid", "1.6 T-GDI PHEV"]},
83
+ {"brand": "Kia", "model": "Niro", "type": "binek", "variants": ["Hybrid 1.6 GDI", "PHEV 1.6 GDI", "EV 64.8 kWh"]},
84
+ {"brand": "Kia", "model": "EV6", "type": "binek", "variants": ["RWD Standard", "AWD Long Range GT-Line"]},
85
+
86
+ {"brand": "Dacia", "model": "Sandero", "type": "binek", "variants": ["1.0 SCe Essential", "1.0 TCe Comfort", "1.0 TCe Stepway"]},
87
+ {"brand": "Dacia", "model": "Duster", "type": "binek", "variants": ["1.0 TCe Essential 4x2", "1.3 TCe Prestige 4x2"]},
88
+ {"brand": "Dacia", "model": "Logan", "type": "binek", "variants": ["1.0 SCe Essential", "1.0 TCe Expression"]},
89
+ {"brand": "Dacia", "model": "Spring", "type": "binek", "variants": ["Comfort", "Extreme"]},
90
+ {"brand": "Dacia", "model": "Jogger", "type": "binek", "variants": ["1.0 TCe Expression 7+1", "1.6 Hybrid Expression"]},
91
+
92
+ {"brand": "Peugeot", "model": "208", "type": "binek", "variants": ["1.2 PureTech Active Pack", "1.2 PureTech Allure", "e-208 EV 50 kWh"]},
93
+ {"brand": "Peugeot", "model": "308", "type": "binek", "variants": ["1.2 PureTech Active Pack", "1.6 Hybrid Allure", "1.6 PHEV GT"]},
94
+ {"brand": "Peugeot", "model": "2008", "type": "binek", "variants": ["1.2 PureTech Active Pack", "e-2008 EV 50 kWh"]},
95
+ {"brand": "Peugeot", "model": "3008", "type": "binek", "variants": ["1.2 PureTech Allure", "1.6 PHEV GT", "E-3008 Long Range"]},
96
+ {"brand": "Peugeot", "model": "Partner", "type": "ticari", "variants": ["1.5 BlueHDi 100 Standard", "e-Partner EV 50 kWh"]},
97
+ {"brand": "Peugeot", "model": "Expert", "type": "ticari", "variants": ["1.5 BlueHDi 120 L1", "2.0 BlueHDi 145 L2"]},
98
+ {"brand": "Peugeot", "model": "Boxer", "type": "ticari", "variants": ["2.2 BlueHDi L2H2", "2.2 BlueHDi L3H2"]},
99
+
100
+ {"brand": "Citroen", "model": "C3", "type": "binek", "variants": ["1.2 PureTech Feel", "1.2 PureTech Shine"]},
101
+ {"brand": "Citroen", "model": "C4", "type": "binek", "variants": ["1.2 PureTech Feel Pack", "e-C4 EV 50 kWh"]},
102
+ {"brand": "Citroen", "model": "C5 X", "type": "binek", "variants": ["1.2 PureTech Shine", "1.6 PHEV Shine Pack"]},
103
+ {"brand": "Citroen", "model": "Berlingo", "type": "ticari", "variants": ["1.5 BlueHDi 100 Standard", "e-Berlingo EV 50 kWh"]},
104
+
105
+ {"brand": "Skoda", "model": "Fabia", "type": "binek", "variants": ["1.0 MPI Ambition", "1.0 TSI Style"]},
106
+ {"brand": "Skoda", "model": "Octavia", "type": "binek", "variants": ["1.0 TSI Ambition", "1.5 TSI Style", "2.0 TDI Sportline"]},
107
+ {"brand": "Skoda", "model": "Superb", "type": "binek", "variants": ["2.0 TDI Style", "2.0 TSI Sportline"]},
108
+ {"brand": "Skoda", "model": "Kamiq", "type": "binek", "variants": ["1.0 TSI Ambition", "1.5 TSI Style"]},
109
+ {"brand": "Skoda", "model": "Karoq", "type": "binek", "variants": ["1.5 TSI Ambition", "2.0 TDI Style"]},
110
+ {"brand": "Skoda", "model": "Kodiaq", "type": "binek", "variants": ["2.0 TDI Style", "2.0 TSI Sportline 4x4"]},
111
+
112
+ {"brand": "Audi", "model": "A1", "type": "binek", "variants": ["25 TFSI Advanced", "30 TFSI S Line"]},
113
+ {"brand": "Audi", "model": "A3", "type": "binek", "variants": ["30 TFSI Advanced", "35 TFSI S Line", "40 TFSI e S Line"]},
114
+ {"brand": "Audi", "model": "A4", "type": "binek", "variants": ["35 TFSI Advanced", "35 TDI Advanced", "40 TFSI S Line"]},
115
+ {"brand": "Audi", "model": "A6", "type": "binek", "variants": ["40 TDI Advanced", "55 TFSI e S Line"]},
116
+ {"brand": "Audi", "model": "Q2", "type": "binek", "variants": ["30 TFSI Advanced", "35 TFSI S Line"]},
117
+ {"brand": "Audi", "model": "Q3", "type": "binek", "variants": ["35 TFSI Advanced", "35 TDI S Line"]},
118
+ {"brand": "Audi", "model": "Q5", "type": "binek", "variants": ["40 TDI S Line", "55 TFSI e S Line quattro"]},
119
+ {"brand": "Audi", "model": "e-tron", "type": "binek", "variants": ["55 quattro Advanced", "S quattro"]},
120
+
121
+ {"brand": "Opel", "model": "Corsa", "type": "binek", "variants": ["1.2 Edition", "1.2 GS Line", "Corsa-e 50 kWh"]},
122
+ {"brand": "Opel", "model": "Astra", "type": "binek", "variants": ["1.2 T GS Line", "1.6 PHEV GS Line"]},
123
+ {"brand": "Opel", "model": "Mokka", "type": "binek", "variants": ["1.2 T Edition", "Mokka-e 50 kWh"]},
124
+ {"brand": "Opel", "model": "Crossland", "type": "binek", "variants": ["1.2 T Edition", "1.5 D GS Line"]},
125
+ {"brand": "Opel", "model": "Vivaro", "type": "ticari", "variants": ["2.0 CDTi L1H1", "Vivaro-e 75 kWh L2"]},
126
+ {"brand": "Opel", "model": "Combo", "type": "ticari", "variants": ["1.5 CDTi L1 Standard", "Combo-e EV 50 kWh"]},
127
+
128
+ {"brand": "Volvo", "model": "XC40", "type": "binek", "variants": ["B3 FWD Core", "Recharge Twin AWD"]},
129
+ {"brand": "Volvo", "model": "XC60", "type": "binek", "variants": ["B4 FWD Core", "T8 PHEV AWD Plus"]},
130
+ {"brand": "Volvo", "model": "S60", "type": "binek", "variants": ["B4 FWD Core", "T8 PHEV AWD Plus"]},
131
+ {"brand": "Volvo", "model": "V60", "type": "binek", "variants": ["B4 FWD Core", "T8 PHEV AWD Plus"]},
132
+
133
+ {"brand": "Seat", "model": "Ibiza", "type": "binek", "variants": ["1.0 TSI Reference", "1.0 TSI FR"]},
134
+ {"brand": "Seat", "model": "Leon", "type": "binek", "variants": ["1.0 TSI Reference", "1.5 TSI FR", "1.4 e-Hybrid FR"]},
135
+ {"brand": "Seat", "model": "Ateca", "type": "binek", "variants": ["1.0 TSI Reference", "1.5 TSI FR"]},
136
+
137
+ {"brand": "Cupra", "model": "Born", "type": "binek", "variants": ["150 kW V1", "170 kW V2"]},
138
+ {"brand": "Cupra", "model": "Formentor", "type": "binek", "variants": ["1.5 TSI 150 HP", "2.0 TSI 4Drive 310 HP"]},
139
+
140
+ {"brand": "Tesla", "model": "Model 3", "type": "binek", "variants": ["Standard Range RWD", "Long Range AWD", "Performance AWD"]},
141
+ {"brand": "Tesla", "model": "Model Y", "type": "binek", "variants": ["Long Range AWD", "Performance AWD"]},
142
+ {"brand": "Tesla", "model": "Model S", "type": "binek", "variants": ["Dual Motor AWD", "Plaid"]},
143
+
144
+ {"brand": "Isuzu", "model": "D-Max", "type": "ticari", "variants": ["2.5 LS 4x4", "3.0 LS 4x4"]},
145
+ {"brand": "Isuzu", "model": "N-Series", "type": "ticari", "variants": ["N35", "N50", "N75"]},
146
+
147
+ {"brand": "Iveco", "model": "Daily", "type": "ticari", "variants": ["35S14 L3H2", "35S18 L4H2", "70C18"]},
148
+
149
+ {"brand": "MAN", "model": "TGE", "type": "ticari", "variants": ["3.180 L3H2 FWD", "5.180 L4H3 4WD"]}
150
+ ]
chatbot/graph/__init__.py ADDED
File without changes
chatbot/graph/edges.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ graph/edges.py — Koşullu Kenar Fonksiyonları
3
+
4
+ AGENTIC PATTERN: State Machine — Conditional Transitions
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LangGraph'ta conditional_edge, bir node'dan çıkan "karar noktalarını"
7
+ temsil eder. Her edge fonksiyonu state'i okur ve bir sonraki
8
+ node'un adını string olarak döndürür.
9
+
10
+ Bu dosya SADECE yönlendirme kararlarını içerir — iş mantığı yok.
11
+ İş mantığı: agent dosyalarında (intake, validation vb.)
12
+ """
13
+ from graph.state import ConversationState
14
+ from observability.audit_logger import log_routing
15
+
16
+
17
+ def route_after_supervisor(state: ConversationState) -> str:
18
+ """
19
+ Supervisor'ın intent kararına göre hangi dala gidileceğini belirler.
20
+
21
+ new_car → intake_new node'una
22
+ used_car → intake_used node'una
23
+ faq → faq_agent node'una
24
+ out_of_scope / clarifying → END (akış kapanır veya tekrar bekler)
25
+ """
26
+ step = state.get("current_step", "")
27
+ decision, reason = _decide_after_supervisor(state, step)
28
+
29
+ try:
30
+ sid = state.get("session_id")
31
+ if sid:
32
+ log_routing(sid, "supervisor", decision, reason)
33
+ except Exception:
34
+ pass # routing log hatası akışı bozmasın
35
+
36
+ return decision
37
+
38
+
39
+ def _decide_after_supervisor(state: ConversationState, step: str) -> tuple[str, str]:
40
+ if state.get("flow_ended"):
41
+ return "END", "flow_ended"
42
+
43
+ # Başvuru zaten oluşturuldu → HGS adımındayız; recap/submission tekrarlanmamalı
44
+ if state.get("application_id"):
45
+ return "crosssell", "application already submitted, resume HGS step"
46
+
47
+ if step == "clarifying":
48
+ return "wait_for_input", "supervisor asked for clarification"
49
+
50
+ intent = step.replace("supervisor_routed_", "")
51
+
52
+ if intent == "faq":
53
+ return "faq_agent", "faq intent"
54
+
55
+ if intent == "new_car" or state.get("vehicle_type") == "new":
56
+ # Bekleyen bir slot varsa önce kullanıcının cevabını parse et (veri kaybını önler)
57
+ if state.get("awaited_slot"):
58
+ return "intake_new", "parse awaited slot first"
59
+ if _all_new_slots_filled(state):
60
+ return "reflection", "new_car: all slots filled"
61
+ return "intake_new", f"new_car intent (step={step})"
62
+
63
+ if intent == "used_car" or state.get("vehicle_type") == "used":
64
+ if state.get("awaited_slot"):
65
+ return "intake_used", "parse awaited slot first"
66
+ if _all_used_slots_filled(state):
67
+ return "reflection", "used_car: all slots filled"
68
+ return "intake_used", f"used_car intent (step={step})"
69
+
70
+ return "END", f"no matching route (intent={intent!r}, step={step!r})"
71
+
72
+
73
+ def route_after_intake_new(state: ConversationState) -> str:
74
+ """Yeni araç intake'i bitince nereye gideceğini belirler."""
75
+ if state.get("flow_ended"):
76
+ return "END"
77
+ if state.get("current_step") == "intake_new_complete":
78
+ return "validation_new"
79
+ # Slot dolmadı → bir sonraki turda supervisor yeniden yönlendirir
80
+ return "wait_for_input"
81
+
82
+
83
+ def route_after_intake_used(state: ConversationState) -> str:
84
+ """2. El intake'i bitince nereye gideceğini belirler."""
85
+ if state.get("flow_ended"):
86
+ return "END"
87
+ if state.get("current_step") == "intake_used_complete":
88
+ return "validation_used"
89
+ return "wait_for_input"
90
+
91
+
92
+ def route_after_validation(state: ConversationState) -> str:
93
+ """Validation sonucuna göre yönlendir."""
94
+ if state.get("flow_ended"):
95
+ return "END"
96
+ step = state.get("current_step", "")
97
+ if step == "validation_passed":
98
+ return "reflection"
99
+ if step == "validation_needs_retry":
100
+ # Hangi dal?
101
+ return "intake_new" if state.get("vehicle_type") == "new" else "intake_used"
102
+ return "END"
103
+
104
+
105
+ def route_after_reflection(state: ConversationState) -> str:
106
+ """Reflection tamamsa recap'e, sorun varsa ilgili intake'e dön."""
107
+ if state.get("flow_ended"):
108
+ return "END"
109
+ step = state.get("current_step", "")
110
+ if step == "reflection_passed":
111
+ return "recap"
112
+ # Eksik slot varsa o intake'e geri dön
113
+ return "intake_new" if state.get("vehicle_type") == "new" else "intake_used"
114
+
115
+
116
+ def route_after_recap(state: ConversationState) -> str:
117
+ """Onay ekranı kararına göre yönlendir."""
118
+ if state.get("recap_confirmed"):
119
+ return "submission"
120
+ if state.get("edit_target_slot"):
121
+ # Hangi dal?
122
+ return "intake_new" if state.get("vehicle_type") == "new" else "intake_used"
123
+ # Cevap bekliyoruz
124
+ return "wait_for_input"
125
+
126
+
127
+ def route_after_submission(state: ConversationState) -> str:
128
+ if state.get("flow_ended") or state.get("current_step") == "submission_failed":
129
+ return "END"
130
+ return "crosssell"
131
+
132
+
133
+ def route_after_crosssell(state: ConversationState) -> str:
134
+ if state.get("flow_ended"):
135
+ return "END"
136
+ if state.get("current_step") == "crosssell_waiting":
137
+ return "wait_for_input"
138
+ return "END"
139
+
140
+
141
+ def route_after_faq(state: ConversationState) -> str:
142
+ """FAQ cevabından sonra turu bitir — kullanıcıdan yeni mesaj beklenir.
143
+
144
+ NOT: supervisor'a geri DÖNMEMELİ. Aksi halde supervisor, FAQ agent'ın
145
+ ürettiği AI cevabını yeni bir kullanıcı mesajı sanıp yeniden sınıflandırır
146
+ ve bir döngüye girer (faq → supervisor → faq → ... → clarifying).
147
+ Mid-flow resume bir sonraki turda supervisor'ın mid-flow bypass'ı ile olur.
148
+ """
149
+ return "wait_for_input"
150
+
151
+
152
+ # ── Yardımcı Fonksiyonlar ─────────────────────────────────────────────────────
153
+
154
+ def _all_new_slots_filled(state: ConversationState) -> bool:
155
+ base = all([state.get("invoice_amount"), state.get("vehicle_model"), state.get("requested_amount_new")])
156
+ if not base:
157
+ return False
158
+ if state.get("kefil_required") and not state.get("guarantor_tckn"):
159
+ return False
160
+ return True
161
+
162
+
163
+ def _all_used_slots_filled(state: ConversationState) -> bool:
164
+ return (
165
+ bool(state.get("kasko_value"))
166
+ and state.get("vehicle_age") is not None
167
+ and bool(state.get("requested_amount_used"))
168
+ )
chatbot/graph/nodes.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ graph/nodes.py — LangGraph Node Fonksiyonları
3
+
4
+ AGENTIC PATTERN: State Machine — Nodes
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Her node, state'i alır, bir işlem yapar ve güncellenmiş
7
+ state parçasını döndürür. LangGraph bu parçaları mevcut
8
+ state ile birleştirerek yeni state oluşturur.
9
+
10
+ Not: Bu dosya agent modüllerini çağıran ince bir sarmalayıcıdır.
11
+ İş mantığı agent dosyalarında; bağlantı mantığı burada.
12
+ """
13
+ import time
14
+ from langchain_core.messages import AIMessage, HumanMessage
15
+ from graph.state import ConversationState
16
+ from guardrails.input_guard import check_input, get_block_message
17
+ from guardrails.output_guard import check_output
18
+ from agents.supervisor import run_supervisor
19
+ from agents.intake_new import run_intake_new
20
+ from agents.intake_used import run_intake_used
21
+ from agents.faq_agent import run_faq_agent
22
+ from agents.validation_agent import validate_new_car_state, validate_used_car_state
23
+ from agents.reflection_agent import run_reflection
24
+ from agents.submission_agent import run_submission
25
+ from agents.crosssell_agent import run_crosssell
26
+ from observability.audit_logger import log_guard_event
27
+
28
+
29
+ def input_guard_node(state: ConversationState) -> dict:
30
+ """
31
+ L4 — Input Guard Node.
32
+ Her turda ilk çalışan node; güvenlik geçmezse akış durur.
33
+ """
34
+ last_message = state["messages"][-1]
35
+ user_text = last_message.content if hasattr(last_message, "content") else ""
36
+
37
+ guard_result = check_input(user_text, state["session_id"])
38
+
39
+ if not guard_result["allowed"]:
40
+ block_msg = get_block_message()
41
+ # flow_ended set etme — sadece bu mesajı engelle, session'ı öldürme;
42
+ # bir sonraki normal mesajda kaldığı yerden devam edilebilsin.
43
+ return {
44
+ "current_step": state.get("current_step", "input_blocked"),
45
+ "messages": state["messages"] + [AIMessage(content=block_msg)],
46
+ }
47
+
48
+ # PII maskelenmişse mesajı güncelle
49
+ if guard_result["pii_detected"]:
50
+ updated_messages = state["messages"][:-1] + [
51
+ HumanMessage(content=guard_result["sanitized_text"])
52
+ ]
53
+ return {"messages": updated_messages, "current_step": "input_guard_passed"}
54
+
55
+ return {"current_step": "input_guard_passed"}
56
+
57
+
58
+ def supervisor_node(state: ConversationState) -> dict:
59
+ """Supervisor / Router Agent."""
60
+ return run_supervisor(state)
61
+
62
+
63
+ def intake_new_node(state: ConversationState) -> dict:
64
+ """Yeni Araç Slot Toplama."""
65
+ return run_intake_new(state)
66
+
67
+
68
+ def intake_used_node(state: ConversationState) -> dict:
69
+ """2. El Araç Slot Toplama."""
70
+ return run_intake_used(state)
71
+
72
+
73
+ def faq_agent_node(state: ConversationState) -> dict:
74
+ """FAQ / RAG Agent."""
75
+ # FAQ moduna girerken snapshot kaydet
76
+ if not state.get("faq_snapshot"):
77
+ state = {**state, "faq_snapshot": {"step": state.get("current_step")}}
78
+ return run_faq_agent(state)
79
+
80
+
81
+ def validation_new_node(state: ConversationState) -> dict:
82
+ """Yeni Araç Doğrulama."""
83
+ return validate_new_car_state(state)
84
+
85
+
86
+ def validation_used_node(state: ConversationState) -> dict:
87
+ """2. El Araç Doğrulama."""
88
+ return validate_used_car_state(state)
89
+
90
+
91
+ def reflection_node(state: ConversationState) -> dict:
92
+ """Self-Check / Reflection."""
93
+ return run_reflection(state)
94
+
95
+
96
+ _EDIT_SLOT_KEYWORDS_USED = {
97
+ "kasko_value": ["kasko"],
98
+ "vehicle_age": ["yaş", "model yılı", "yıl"],
99
+ "requested_amount_used":["finansman", "tutar", "kredi", "miktar"],
100
+ "seller_tckn": ["satıcı tckn", "satıcı kimlik", "satıcının tc"],
101
+ }
102
+ _EDIT_SLOT_KEYWORDS_NEW = {
103
+ "invoice_amount": ["fatura", "proforma"],
104
+ "vehicle_model": ["model", "marka"],
105
+ "requested_amount_new": ["finansman", "tutar", "kredi", "miktar"],
106
+ "guarantor_tckn": ["kefil", "garantör"],
107
+ }
108
+ _RECAP_SLOT_QUESTIONS = {
109
+ "kasko_value": "Aracın güncel kasko değeri nedir? (TL olarak)",
110
+ "vehicle_age": "Aracın model yılı veya yaşı nedir? (örn: 2021 model veya 3 yaşında)",
111
+ "requested_amount_used":"Ne kadar finansman almak istiyorsunuz? (TL olarak)",
112
+ "seller_tckn": "Satıcının TC kimlik numarası nedir? (Bilmiyorsanız 'geç' yazabilirsiniz)",
113
+ "invoice_amount": "Aracın proforma fatura tutarı nedir? (TL olarak)",
114
+ "vehicle_model": "Hangi marka ve model aracı düşünüyorsunuz?",
115
+ "requested_amount_new": "Ne kadar finansman almak istiyorsunuz? (TL olarak)",
116
+ "guarantor_tckn": "Kefilinizin TC kimlik numarası nedir?",
117
+ }
118
+
119
+
120
+ def _detect_edit_slot(lower: str, vehicle_type: str) -> str | None:
121
+ keywords = _EDIT_SLOT_KEYWORDS_USED if vehicle_type == "used" else _EDIT_SLOT_KEYWORDS_NEW
122
+ for slot, words in keywords.items():
123
+ if any(w in lower for w in words):
124
+ return slot
125
+ return None
126
+
127
+
128
+ def recap_node(state: ConversationState) -> dict:
129
+ """
130
+ Onay Ekranı Node.
131
+ Tüm slot'ları müşteriye gösterir; onay veya düzenleme bekler.
132
+ """
133
+ last_message = state["messages"][-1]
134
+ user_text = last_message.content if hasattr(last_message, "content") else ""
135
+
136
+ # Eğer son mesaj kullanıcıdan geldiyse → onay / düzenleme parse et
137
+ if hasattr(last_message, "type") and last_message.type != "ai":
138
+ lower = user_text.lower().strip()
139
+ # Onay yalnızca tam "onaylıyorum" ile verilir — "evet/tamam" gibi ifadeler yetmez
140
+ if lower == "onaylıyorum":
141
+ return {"recap_confirmed": True, "current_step": "recap_confirmed"}
142
+
143
+ # Araç türü değişikliği → tüm slotları sıfırla, baştan başla
144
+ vehicle_type_words = ["araç türü", "arac turu", "araç tipi", "yeni araç", "2.el", "2. el", "ikinci el", "sıfır araç"]
145
+ if any(w in lower for w in vehicle_type_words):
146
+ return {
147
+ "current_step": "start",
148
+ "vehicle_type": None,
149
+ "invoice_amount": None, "vehicle_model": None, "requested_amount_new": None,
150
+ "guarantor_tckn": None, "kefil_required": False,
151
+ "kasko_value": None, "vehicle_age": None, "requested_amount_used": None,
152
+ "seller_tckn": None,
153
+ "awaited_slot": None, "edit_target_slot": None, "recap_confirmed": False,
154
+ "errors": [],
155
+ "messages": state["messages"] + [AIMessage(content="Yeni araç mı, 2. el araç mı düşünüyorsunuz?")],
156
+ }
157
+
158
+ # Hangi slot güncellenmek isteniyor?
159
+ target_slot = _detect_edit_slot(lower, state.get("vehicle_type"))
160
+ if target_slot:
161
+ question = _RECAP_SLOT_QUESTIONS.get(target_slot, "Yeni değeri giriniz.")
162
+ return {
163
+ "current_step": "recap_edit_requested",
164
+ "recap_confirmed": False,
165
+ "edit_target_slot": target_slot,
166
+ "awaited_slot": target_slot,
167
+ target_slot: None,
168
+ "messages": state["messages"] + [AIMessage(content=question)],
169
+ }
170
+
171
+ # Düzenleme niyeti var ama hangi alan belli değil → sor
172
+ edit_words = ["değiştir", "düzelt", "güncelle", "yanlış", "hata", "değiştirmek", "güncellemek"]
173
+ if any(w in lower for w in edit_words):
174
+ msg = "Hangi bilgiyi güncellemek istersiniz? (örn: kasko değeri, finansman tutarı, araç yaşı)"
175
+ return {
176
+ "current_step": "recap_shown",
177
+ "messages": state["messages"] + [AIMessage(content=msg)],
178
+ }
179
+
180
+ # İlk kez Recap (veya son mesaj AI ise) → özet göster
181
+ summary = _build_recap_message(state)
182
+ return {
183
+ "current_step": "recap_shown",
184
+ "messages": state["messages"] + [AIMessage(content=summary)],
185
+ }
186
+
187
+
188
+ def submission_node(state: ConversationState) -> dict:
189
+ """Başvuru Kayıt Agent."""
190
+ return run_submission(state)
191
+
192
+
193
+ def crosssell_node(state: ConversationState) -> dict:
194
+ """HGS Çapraz Satış."""
195
+ return run_crosssell(state)
196
+
197
+
198
+ def wait_node(state: ConversationState) -> dict:
199
+ """
200
+ Pasif bekleme node'u — kullanıcı cevabı bekliyor.
201
+ current_step'e DOKUNMAZ: önceki node'un set ettiği "asking_xxx" değeri
202
+ korunmalı ki bir sonraki turda intake hangi slot'u beklediğini bilsin.
203
+ """
204
+ return {}
205
+
206
+
207
+ # ── Yardımcı: Özet Mesajı ─────────────────────────────────────────────────────
208
+
209
+ def _build_recap_message(state: ConversationState) -> str:
210
+ lines = ["📋 **Başvuru Özeti** — Bilgilerinizi kontrol edin:\n"]
211
+
212
+ if state.get("vehicle_type") == "new":
213
+ lines.append(f"• Araç Türü: Yeni Araç")
214
+ if state.get("invoice_amount"):
215
+ lines.append(f"• Proforma Fatura: {_fmt(state['invoice_amount'])} TL")
216
+ if state.get("vehicle_model"):
217
+ lines.append(f"• Araç Modeli: {state['vehicle_model']}")
218
+ if state.get("requested_amount_new"):
219
+ lines.append(f"• İstenen Finansman: {_fmt(state['requested_amount_new'])} TL")
220
+ if state.get("guarantor_tckn"):
221
+ from tools.tckn_tool import _mask
222
+ lines.append(f"• Kefil TCKN: {_mask(state['guarantor_tckn'])}")
223
+ else:
224
+ lines.append(f"• Araç Türü: 2. El Araç")
225
+ if state.get("kasko_value"):
226
+ lines.append(f"• Kasko Değeri: {_fmt(state['kasko_value'])} TL")
227
+ if state.get("vehicle_age"):
228
+ lines.append(f"• Araç Yaşı: {state['vehicle_age']} yıl")
229
+ if state.get("requested_amount_used"):
230
+ lines.append(f"• İstenen Finansman: {_fmt(state['requested_amount_used'])} TL")
231
+ if state.get("seller_tckn"):
232
+ from tools.tckn_tool import _mask
233
+ lines.append(f"• Satıcı TCKN: {_mask(state['seller_tckn'])}")
234
+
235
+ lines.append("\n✅ Bilgiler doğruysa **'onaylıyorum'** yazın.")
236
+ lines.append("✏️ Değişiklik için hangi alanı düzeltmek istediğinizi belirtin.")
237
+
238
+ return "\n".join(lines)
239
+
240
+
241
+ def _fmt(amount: float) -> str:
242
+ return f"{int(amount):,}".replace(",", ".")
chatbot/graph/state.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ graph/state.py — Merkezi Konuşma Durumu (State Machine Pattern)
3
+
4
+ AGENTIC PATTERN: State Machine
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LangGraph'ta her şey bir "state" etrafında döner. Bu TypedDict:
7
+ - Konuşmanın hangi adımında olduğumuzu (current_step)
8
+ - Hangi slot'ların dolu olduğunu (invoice_amount, vehicle_model vb.)
9
+ - Konuşma geçmişini (messages)
10
+ - Hata ve meta bilgilerini
11
+ tüm agent'lar arasında paylaşılan tek gerçek kaynaktır.
12
+
13
+ Her node bu state'i okur, değiştirir ve geri yazar.
14
+ LangGraph, iki çağrı arasında state'i otomatik saklar (MemorySaver ile).
15
+ """
16
+ from __future__ import annotations
17
+ from typing import Literal, Optional
18
+ from typing_extensions import TypedDict
19
+ from langchain_core.messages import BaseMessage
20
+
21
+
22
+ class ConversationState(TypedDict):
23
+ """
24
+ Bir oturumun tüm bilgisini taşıyan merkezi veri yapısı.
25
+
26
+ LangGraph bu TypedDict'i her node çağrısında günceller ve
27
+ bir sonraki node'a aktarır. Siz node fonksiyonlarında
28
+ sadece değişen alanları döndürmeniz yeterlidir.
29
+ """
30
+
31
+ # ── Oturum Kimliği ────────────────────────────────────────────────────────
32
+ session_id: str # her konuşma için benzersiz UUID
33
+ current_step: str # hangi graph node'undayız (debug için)
34
+
35
+ # ── Araç Türü ─────────────────────────────────────────────────────────────
36
+ vehicle_type: Optional[Literal["new", "used"]] # None = henüz belirlenmedi
37
+
38
+ # ── Yeni Araç Slot'ları (Dal A) ───────────────────────────────────────────
39
+ invoice_amount: Optional[float] # Proforma fatura tutarı (TL)
40
+ vehicle_model: Optional[str] # Normalize edilmiş marka + model
41
+ vehicle_model_type: Optional[Literal["binek", "ticari"]] # katalog sonucu
42
+ requested_amount_new: Optional[float] # İstenen finansman tutarı
43
+ guarantor_tckn: Optional[str] # Maskelenmiş kefil TCKN (***7890***)
44
+ kefil_required: bool # fatura >= 5M ise True
45
+
46
+ # ── 2. El Araç Slot'ları (Dal B) ─────────────────────────────────────────
47
+ kasko_value: Optional[float] # Güncel kasko değeri (TL)
48
+ vehicle_age: Optional[int] # Araç yaşı (yıl)
49
+ requested_amount_used: Optional[float] # İstenen finansman tutarı
50
+ seller_tckn: Optional[str] # Satıcı TCKN (opsiyonel, maskelenmiş)
51
+
52
+ # ── Konuşma Geçmişi ───────────────────────────────────────────────────────
53
+ messages: list[BaseMessage] # tüm kullanıcı + asistan mesajları
54
+
55
+ # ── Meta / Akış Kontrolü ──────────────────────────────────────────────────
56
+ application_id: Optional[str] # Başvuru tamamlandığında UUID
57
+ hgs_consent: Optional[bool] # HGS çapraz satış kabulü
58
+ errors: list[str] # biriken hata mesajları
59
+ flow_ended: bool # akış bitti mi (red, tamamlanma)
60
+
61
+ # ── FAQ Snapshot ──────────────────────────────────────────────────────────
62
+ # FAQ moduna girerken mevcut adım buraya kaydedilir;
63
+ # FAQ tamamlanınca geri dönülür. Slot'lar SİLİNMEZ.
64
+ faq_snapshot: Optional[dict] # {"step": "A2", "pending_question": "..."}
65
+
66
+ # ── Onay Ekranı ───────────────────────────────────────────────────────────
67
+ recap_confirmed: bool # müşteri tüm bilgileri onayladı mı
68
+ edit_target_slot: Optional[str] # düzenlemek istenen alan adı
69
+
70
+ # ── Beklenen Slot (intake için kalıcı) ────────────────────────────────────
71
+ # current_step her node'da değişiyor, bu yüzden hangi slot'un cevabını
72
+ # beklediğimizi ayrı bir alanda tutuyoruz. intake_new/used buraya yazar
73
+ # ve bir sonraki turda buradan okur.
74
+ awaited_slot: Optional[str]
75
+
76
+
77
+ def create_initial_state(session_id: str) -> ConversationState:
78
+ """
79
+ Yeni bir oturum için boş başlangıç state'i döndürür.
80
+ Tüm slot'lar None, tüm listeler boş, bool'lar False.
81
+ """
82
+ return ConversationState(
83
+ session_id=session_id,
84
+ current_step="start",
85
+ vehicle_type=None,
86
+ invoice_amount=None,
87
+ vehicle_model=None,
88
+ vehicle_model_type=None,
89
+ requested_amount_new=None,
90
+ guarantor_tckn=None,
91
+ kefil_required=False,
92
+ kasko_value=None,
93
+ vehicle_age=None,
94
+ requested_amount_used=None,
95
+ seller_tckn=None,
96
+ messages=[],
97
+ application_id=None,
98
+ hgs_consent=None,
99
+ errors=[],
100
+ flow_ended=False,
101
+ faq_snapshot=None,
102
+ recap_confirmed=False,
103
+ edit_target_slot=None,
104
+ awaited_slot=None,
105
+ )
chatbot/graph/workflow.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ graph/workflow.py — LangGraph StateGraph Tanımı
3
+
4
+ AGENTIC PATTERN: State Machine (LangGraph)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LangGraph'ta bir "graph" şunlardan oluşur:
7
+ - StateGraph: state type + node'lar + edge'ler
8
+ - Node: state alan, state parçası döndüren fonksiyon
9
+ - Edge: iki node arasındaki bağlantı
10
+ - Conditional Edge: state'e göre dinamik yönlendirme
11
+
12
+ compile() çağrısı graph'ı çalıştırılabilir hale getirir.
13
+ MemorySaver ile her thread (oturum) kendi checkpoint'ını korur.
14
+
15
+ Görsel üretmek için:
16
+ from graph.workflow import create_graph
17
+ g = create_graph()
18
+ print(g.get_graph().draw_mermaid())
19
+ """
20
+ from langgraph.graph import StateGraph, END
21
+ from langgraph.checkpoint.memory import MemorySaver
22
+
23
+ from graph.state import ConversationState
24
+ from graph.nodes import (
25
+ input_guard_node,
26
+ supervisor_node,
27
+ intake_new_node,
28
+ intake_used_node,
29
+ faq_agent_node,
30
+ validation_new_node,
31
+ validation_used_node,
32
+ reflection_node,
33
+ recap_node,
34
+ submission_node,
35
+ crosssell_node,
36
+ wait_node,
37
+ )
38
+ from graph.edges import (
39
+ route_after_supervisor,
40
+ route_after_intake_new,
41
+ route_after_intake_used,
42
+ route_after_validation,
43
+ route_after_reflection,
44
+ route_after_recap,
45
+ route_after_submission,
46
+ route_after_crosssell,
47
+ route_after_faq,
48
+ )
49
+
50
+ # Singleton compiled graph
51
+ _compiled_graph = None
52
+
53
+
54
+ def create_graph():
55
+ """
56
+ LangGraph StateGraph'ı oluşturur ve compile eder.
57
+
58
+ Graf Yapısı:
59
+ input_guard → supervisor → [intake_new | intake_used | faq_agent]
60
+ ↓ ↓
61
+ validation_new validation_used
62
+ ↓ ↓
63
+ reflection
64
+
65
+ recap
66
+
67
+ submission
68
+
69
+ crosssell
70
+
71
+ END
72
+ """
73
+ graph = StateGraph(ConversationState)
74
+
75
+ # ── Node'ları Ekle ─────────────────────────────────────────────────────────
76
+ graph.add_node("input_guard", input_guard_node)
77
+ graph.add_node("supervisor", supervisor_node)
78
+ graph.add_node("intake_new", intake_new_node)
79
+ graph.add_node("intake_used", intake_used_node)
80
+ graph.add_node("faq_agent", faq_agent_node)
81
+ graph.add_node("validation_new", validation_new_node)
82
+ graph.add_node("validation_used", validation_used_node)
83
+ graph.add_node("reflection", reflection_node)
84
+ graph.add_node("recap", recap_node)
85
+ graph.add_node("submission", submission_node)
86
+ graph.add_node("crosssell", crosssell_node)
87
+ graph.add_node("wait_for_input", wait_node)
88
+
89
+ # ── Başlangıç Node'u ───────────────────────────────────────────────────────
90
+ # Her kullanıcı mesajı input_guard'dan girer
91
+ graph.set_entry_point("input_guard")
92
+
93
+ # ── Sabit Edge'ler (her zaman aynı yönlendirme) ───────────────────────────
94
+ graph.add_edge("input_guard", "supervisor")
95
+
96
+ # ── Koşullu Edge'ler (state'e göre dinamik yönlendirme) ──────────────────
97
+ # Supervisor kararı
98
+ # route_after_supervisor(state) fonksiyonu state'e bakar ve bir string döner.
99
+ # LangGraph bu string'i aşağıdaki dictionary'den lookup'tan geçirip ilgili node'a atlar.
100
+ graph.add_conditional_edges(
101
+ "supervisor",
102
+ route_after_supervisor,
103
+ {
104
+ "intake_new": "intake_new",
105
+ "intake_used": "intake_used",
106
+ "faq_agent": "faq_agent",
107
+ "reflection": "reflection",
108
+ "crosssell": "crosssell",
109
+ "wait_for_input": "wait_for_input",
110
+ "END": END,
111
+ },
112
+ )
113
+
114
+ # Yeni araç intake
115
+ graph.add_conditional_edges(
116
+ "intake_new",
117
+ route_after_intake_new,
118
+ {
119
+ "validation_new": "validation_new",
120
+ "wait_for_input": "wait_for_input",
121
+ "END": END,
122
+ },
123
+ )
124
+
125
+ # 2. El intake
126
+ graph.add_conditional_edges(
127
+ "intake_used",
128
+ route_after_intake_used,
129
+ {
130
+ "validation_used": "validation_used",
131
+ "wait_for_input": "wait_for_input",
132
+ "END": END,
133
+ },
134
+ )
135
+
136
+ # Validation
137
+ graph.add_conditional_edges(
138
+ "validation_new",
139
+ route_after_validation,
140
+ {"reflection": "reflection", "intake_new": "intake_new", "END": END},
141
+ )
142
+ graph.add_conditional_edges(
143
+ "validation_used",
144
+ route_after_validation,
145
+ {"reflection": "reflection", "intake_used": "intake_used", "END": END},
146
+ )
147
+
148
+ # Reflection
149
+ graph.add_conditional_edges(
150
+ "reflection",
151
+ route_after_reflection,
152
+ {"recap": "recap", "intake_new": "intake_new", "intake_used": "intake_used", "END": END},
153
+ )
154
+
155
+ # Recap → Submission
156
+ graph.add_conditional_edges(
157
+ "recap",
158
+ route_after_recap,
159
+ {
160
+ "submission": "submission",
161
+ "intake_new": "intake_new",
162
+ "intake_used": "intake_used",
163
+ "wait_for_input": "wait_for_input",
164
+ "END": END,
165
+ },
166
+ )
167
+
168
+ # Submission → CrossSell
169
+ graph.add_conditional_edges(
170
+ "submission",
171
+ route_after_submission,
172
+ {"crosssell": "crosssell", "END": END},
173
+ )
174
+
175
+ # CrossSell → END
176
+ graph.add_conditional_edges(
177
+ "crosssell",
178
+ route_after_crosssell,
179
+ {"wait_for_input": "wait_for_input", "END": END},
180
+ )
181
+
182
+ # FAQ cevabından kaldığı yere dön
183
+ graph.add_conditional_edges(
184
+ "faq_agent",
185
+ route_after_faq,
186
+ {
187
+ "supervisor": "supervisor",
188
+ "intake_new": "intake_new",
189
+ "intake_used": "intake_used",
190
+ "recap": "recap",
191
+ "wait_for_input": "wait_for_input",
192
+ },
193
+ )
194
+
195
+ # Bekleme node'u her zaman durur (kullanıcı girdisi bekliyor)
196
+ graph.add_edge("wait_for_input", END)
197
+
198
+ return graph
199
+
200
+
201
+ def get_compiled_graph():
202
+ """
203
+ MemorySaver ile compile edilmiş graph'ı döndürür (singleton).
204
+
205
+ MemorySaver: Her thread_id için ayrı checkpoint tutar.
206
+ thread_id = session_id → her kullanıcı oturumu bağımsız.
207
+ """
208
+ global _compiled_graph
209
+ if _compiled_graph is None:
210
+ checkpointer = MemorySaver()
211
+ graph = create_graph()
212
+ _compiled_graph = graph.compile(checkpointer=checkpointer)
213
+ return _compiled_graph
214
+
215
+
216
+ def get_workflow_mermaid() -> str:
217
+ """Graf yapısını Mermaid diyagramı olarak döndürür (görsel için)."""
218
+ graph = create_graph()
219
+ compiled = graph.compile()
220
+ return compiled.get_graph().draw_mermaid()
chatbot/guardrails/__init__.py ADDED
File without changes
chatbot/guardrails/input_guard.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ guardrails/input_guard.py — Giriş Güvenlik Katmanı
3
+
4
+ REPORT Karşılığı: L4 — Input Guard
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Müşteri mesajı Orchestrator'a ulaşmadan önce şu dört kontrol:
7
+
8
+ 1. PII Maskeleme: TCKN, IBAN, kart no gibi değerleri maskele
9
+ (müşteri güvenlik amacıyla paylaşmış olabilir — güvenli saklama)
10
+ 2. Prompt Injection Tespiti: "tüm talimatları unut" gibi ifadeler
11
+ 3. Kapsam Dışı Ön Filtre: açıkça alakasız kısa mesajlar
12
+ 4. Uzunluk Kontrolü: aşırı uzun mesajlar sınırlandırılır
13
+
14
+ Kural bazlı (deterministik) çalışır — ek LLM çağrısı yapmaz.
15
+ Hız kritik: her kullanıcı turunda çalışır.
16
+ """
17
+ import re
18
+ from config import INJECTION_KEYWORDS
19
+ from observability.audit_logger import log_guard_event
20
+
21
+
22
+ # ── Regex Kalıpları ───────────────────────────────────────────────────────────
23
+ _TCKN_RE = re.compile(r"\b[1-9]\d{10}\b")
24
+ _IBAN_RE = re.compile(r"\bTR\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{2}\b", re.IGNORECASE)
25
+ _CARD_RE = re.compile(r"\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b")
26
+
27
+ MAX_MESSAGE_LENGTH = 1000 # karakter
28
+
29
+
30
+ def check_input(message: str, session_id: str) -> dict:
31
+ """
32
+ Giriş mesajını güvenlik kontrollerinden geçirir.
33
+
34
+ Returns:
35
+ {
36
+ "allowed": bool,
37
+ "sanitized_text": str, # PII maskelenmiş metin
38
+ "block_reason": str | None, # neden engellendi
39
+ "pii_detected": bool,
40
+ "injection_detected": bool,
41
+ }
42
+ """
43
+ result = {
44
+ "allowed": True,
45
+ "sanitized_text": message,
46
+ "block_reason": None,
47
+ "pii_detected": False,
48
+ "injection_detected": False,
49
+ }
50
+
51
+ # 1. Uzunluk kontrolü
52
+ if len(message) > MAX_MESSAGE_LENGTH:
53
+ result["allowed"] = False
54
+ result["block_reason"] = f"Mesaj çok uzun ({len(message)} karakter, limit {MAX_MESSAGE_LENGTH})."
55
+ log_guard_event(session_id, "input_guard", True, "message_too_long")
56
+ return result
57
+
58
+ # 2. PII Maskeleme (engelleme değil — sadece maskele)
59
+ sanitized = message
60
+ if _TCKN_RE.search(sanitized):
61
+ sanitized = _TCKN_RE.sub(lambda m: m.group()[:3] + "****" + m.group()[7:], sanitized)
62
+ result["pii_detected"] = True
63
+
64
+ if _IBAN_RE.search(sanitized):
65
+ sanitized = _IBAN_RE.sub("[IBAN GİZLENDİ]", sanitized)
66
+ result["pii_detected"] = True
67
+
68
+ if _CARD_RE.search(sanitized):
69
+ sanitized = _CARD_RE.sub("[KART NO GİZLENDİ]", sanitized)
70
+ result["pii_detected"] = True
71
+
72
+ if result["pii_detected"]:
73
+ log_guard_event(session_id, "input_guard", True, "pii_detected", sanitized)
74
+
75
+ result["sanitized_text"] = sanitized
76
+
77
+ # 3. Prompt Injection Tespiti
78
+ lower = message.lower()
79
+ for keyword in INJECTION_KEYWORDS:
80
+ if keyword.lower() in lower:
81
+ result["allowed"] = False
82
+ result["injection_detected"] = True
83
+ result["block_reason"] = "Güvenlik politikası: bu ifadeyle devam edilemiyor."
84
+ log_guard_event(session_id, "input_guard", True, f"injection_keyword:{keyword}", sanitized[:100])
85
+ return result
86
+
87
+ log_guard_event(session_id, "input_guard", False, "passed")
88
+ return result
89
+
90
+
91
+ def get_block_message() -> str:
92
+ """Engellenen mesaj için standart yanıt."""
93
+ return "Üzgünüm, bu mesajla devam edemiyorum. Lütfen araç finansmanı konusunda sorunuzu yeniden yazın."
chatbot/guardrails/output_guard.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ guardrails/output_guard.py — Çıkış Güvenlik Katmanı
3
+
4
+ REPORT Karşılığı: L7 — Output Guard
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LLM çıktısı müşteriye iletilmeden önce üç kontrol:
7
+
8
+ 1. PII Redaction: Çıktıda kalan hassas veri parçaları temizlenir
9
+ 2. Finansal Taahhüt Filtresi: Faiz oranı garantisi, kesin onay vb.
10
+ ifadeler engellenir → yasal ve itibar riski önlenir
11
+ 3. FAQ Kaynak Zorunluluğu: FAQ cevabı referans chunk olmadan geçemez
12
+
13
+ Basit kural listesi tabanlı — ek LLM çağrısı yapmaz.
14
+ """
15
+ import re
16
+ from observability.audit_logger import log_guard_event
17
+
18
+ # Yasak finansal taahhüt ifadeleri (Türkçe + İngilizce)
19
+ FINANCIAL_COMMITMENT_PATTERNS = [
20
+ r"%\s*0[,.]?[05]?\s*(faiz|interest)",
21
+ r"kesinlikle onaylan",
22
+ r"garantili kredi",
23
+ r"anında onay",
24
+ r"100\s*%\s*onay",
25
+ r"faiz\s*almıyoruz",
26
+ r"faizsiz",
27
+ r"guaranteed approval",
28
+ r"interest free",
29
+ ]
30
+
31
+ _TCKN_RE = re.compile(r"\b[1-9]\d{10}\b")
32
+ _IBAN_RE = re.compile(r"\bTR\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{2}\b", re.IGNORECASE)
33
+
34
+
35
+ def check_output(text: str, session_id: str, is_faq_response: bool = False, source_chunks: list = None) -> dict:
36
+ """
37
+ LLM çıktısını güvenlik kontrollerinden geçirir.
38
+
39
+ Args:
40
+ text: LLM'in ürettiği metin
41
+ session_id: Audit log için
42
+ is_faq_response: FAQ cevabıysa kaynak chunk kontrolü yapılır
43
+ source_chunks: RAG'dan gelen kaynak chunk'lar
44
+
45
+ Returns:
46
+ {
47
+ "allowed": bool,
48
+ "cleaned_text": str,
49
+ "block_reason": str | None,
50
+ "redacted_pii": bool,
51
+ "financial_block": bool,
52
+ }
53
+ """
54
+ result = {
55
+ "allowed": True,
56
+ "cleaned_text": text,
57
+ "block_reason": None,
58
+ "redacted_pii": False,
59
+ "financial_block": False,
60
+ }
61
+
62
+ # 1. PII Redaction
63
+ cleaned = text
64
+ if _TCKN_RE.search(cleaned):
65
+ cleaned = _TCKN_RE.sub("[KİŞİSEL VERİ GİZLENDİ]", cleaned)
66
+ result["redacted_pii"] = True
67
+ log_guard_event(session_id, "output_guard", True, "pii_in_output")
68
+
69
+ if _IBAN_RE.search(cleaned):
70
+ cleaned = _IBAN_RE.sub("[IBAN GİZLENDİ]", cleaned)
71
+ result["redacted_pii"] = True
72
+
73
+ result["cleaned_text"] = cleaned
74
+
75
+ # 2. Finansal Taahhüt Filtresi
76
+ lower = text.lower()
77
+ for pattern in FINANCIAL_COMMITMENT_PATTERNS:
78
+ if re.search(pattern, lower):
79
+ result["allowed"] = False
80
+ result["financial_block"] = True
81
+ result["block_reason"] = "Finansal taahhüt ifadesi tespit edildi."
82
+ log_guard_event(session_id, "output_guard", True, f"financial_commitment:{pattern[:30]}")
83
+ return result
84
+
85
+ # 3. FAQ kaynak zorunluluğu
86
+ if is_faq_response and not source_chunks:
87
+ result["allowed"] = False
88
+ result["block_reason"] = "FAQ cevabı kaynak chunk olmadan iletilemez."
89
+ log_guard_event(session_id, "output_guard", True, "faq_no_source")
90
+ return result
91
+
92
+ log_guard_event(session_id, "output_guard", False, "passed")
93
+ return result
94
+
95
+
96
+ def get_financial_block_message() -> str:
97
+ return "Bu konuda kesin bilgi veremiyorum. Güncel faiz oranları ve koşullar için lütfen şubemizi veya müşteri hizmetlerimizi arayın."
chatbot/llm/__init__.py ADDED
File without changes
chatbot/llm/factory.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ llm/factory.py — LLM Model Switcher (LiteLLM Wrapper)
3
+
4
+ AGENTIC PATTERN: Unified LLM Interface
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LiteLLM, OpenAI / Anthropic / Google API'lerini TEK bir interface
7
+ uzerinden kullanmana izin verir.
8
+
9
+ Model degistirmek icin config.py'deki ACTIVE_MODEL'i guncelle:
10
+ "gpt-4o" -> OpenAI GPT-4o
11
+ "claude-sonnet-4-6" -> Anthropic Claude Sonnet
12
+ "gemini/gemini-flash-latest" -> Google Gemini Flash
13
+
14
+ Iki katmanli mimari (REPORT Q6):
15
+ - ROUTER (kucuk/hizli): intent siniflandirma, slot cikarimi
16
+ - MAIN (buyuk/guclu): FAQ cevaplama, karmasik muhakeme
17
+ """
18
+ import time
19
+ import litellm
20
+ from litellm.exceptions import (
21
+ ServiceUnavailableError,
22
+ RateLimitError,
23
+ Timeout,
24
+ InternalServerError,
25
+ APIConnectionError,
26
+ )
27
+ import config
28
+
29
+ # LiteLLM verbose loglamasini kapat
30
+ litellm.set_verbose = False
31
+
32
+ # Gecici (transient) hatalarda yeniden dene
33
+ _RETRYABLE_ERRORS = (
34
+ ServiceUnavailableError, # 503 - model yogun
35
+ RateLimitError, # 429
36
+ Timeout,
37
+ InternalServerError, # 500
38
+ APIConnectionError,
39
+ )
40
+ _MAX_RETRIES = 3
41
+ _BASE_DELAY = 1.0 # saniye
42
+
43
+
44
+ class LLMResponse:
45
+ """LLM cevabini tasimak icin minimal wrapper."""
46
+ def __init__(self, content: str):
47
+ self.content = content
48
+
49
+
50
+ class LiteLLMWrapper:
51
+ """
52
+ litellm.completion() uzerinde LangChain benzeri invoke() arayuzu.
53
+ Tum agent'lar llm.invoke(messages) -> response.content seklinde kullanir.
54
+ """
55
+
56
+ def __init__(self, model: str, temperature: float = 0.0):
57
+ self.model = model
58
+ self.temperature = temperature
59
+
60
+ def invoke(self, messages: list) -> LLMResponse:
61
+ """
62
+ messages: dict listesi [{"role": "system", "content": "..."}]
63
+ veya LangChain BaseMessage listesi
64
+ """
65
+ formatted = _format_messages(messages)
66
+ last_error = None
67
+ for attempt in range(_MAX_RETRIES):
68
+ try:
69
+ response = litellm.completion(
70
+ model=self.model,
71
+ messages=formatted,
72
+ temperature=self.temperature,
73
+ )
74
+ choice = response.choices[0]
75
+ content = choice.message.content
76
+ # Gemini bazen güvenlik/RECITATION filtresinde None içerik döndürür.
77
+ # Çağrı noktaları .split() vb. çağırdığı için None'ı boş string'e çevir.
78
+ if content is None:
79
+ finish = getattr(choice, "finish_reason", "unknown")
80
+ print(f"[WARN llm] {self.model} boş içerik döndü (finish_reason={finish})", flush=True)
81
+ content = ""
82
+ return LLMResponse(content=content)
83
+ except _RETRYABLE_ERRORS as e:
84
+ last_error = e
85
+ if attempt < _MAX_RETRIES - 1:
86
+ time.sleep(_BASE_DELAY * (2 ** attempt)) # 1s, 2s, 4s
87
+ raise last_error
88
+
89
+ def bind_tools(self, tools):
90
+ """Tool binding destegi (validation_agent icin)."""
91
+ return BoundLLM(self, tools)
92
+
93
+
94
+ class BoundLLM:
95
+ """bind_tools() sonrasi tool-calling destekli wrapper."""
96
+
97
+ def __init__(self, llm: LiteLLMWrapper, tools: list):
98
+ self.llm = llm
99
+ self.model = llm.model
100
+ self.tools = tools
101
+
102
+ def invoke(self, messages: list) -> LLMResponse:
103
+ # Basit fallback: tool'lar olmadan normal invoke
104
+ return self.llm.invoke(messages)
105
+
106
+
107
+ def _format_messages(messages: list) -> list[dict]:
108
+ """LangChain mesaj nesnelerini ya da dict'leri litellm formatina donustur."""
109
+ result = []
110
+ for m in messages:
111
+ if isinstance(m, dict):
112
+ result.append(m)
113
+ elif hasattr(m, "type"):
114
+ # LangChain BaseMessage: HumanMessage, AIMessage, SystemMessage
115
+ role_map = {"human": "user", "ai": "assistant", "system": "system"}
116
+ role = role_map.get(m.type, "user")
117
+ result.append({"role": role, "content": m.content})
118
+ else:
119
+ result.append({"role": "user", "content": str(m)})
120
+ return result
121
+
122
+
123
+ def get_llm(size: str = "large", temperature: float = 0.0) -> LiteLLMWrapper:
124
+ """
125
+ size="large" -> Ana model (ACTIVE_MODEL)
126
+ size="small" -> Router model (ROUTER_MODEL)
127
+
128
+ config modulu direkt okunur -> UI'dan model degisikliginde aninda yansir.
129
+ """
130
+ model_name = config.ACTIVE_MODEL if size == "large" else config.ROUTER_MODEL
131
+ return LiteLLMWrapper(model=model_name, temperature=temperature)
132
+
133
+
134
+ def get_router_llm() -> LiteLLMWrapper:
135
+ """Kisayol: intent ve slot gorevleri icin kucuk hizli model."""
136
+ return get_llm(size="small")
137
+
138
+
139
+ def get_main_llm() -> LiteLLMWrapper:
140
+ """Kisayol: FAQ ve karmasik gorevler icin buyuk guclu model."""
141
+ return get_llm(size="large")
chatbot/llm/json_utils.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM JSON cevaplarını güvenli parse etme yardımcıları.
2
+
3
+ LLM'ler bazen ```json ... ``` markdown code fence ile sarar veya
4
+ JSON öncesi/sonrası açıklama metni ekler. Bu helper bu varyasyonları temizler.
5
+ """
6
+ import json
7
+ import re
8
+
9
+ _FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.IGNORECASE | re.MULTILINE)
10
+
11
+
12
+ def parse_llm_json(text: str) -> dict | None:
13
+ """LLM cevabından JSON dict çıkar; başarısızsa None döner."""
14
+ if not text:
15
+ return None
16
+ s = text.strip()
17
+ # Markdown code fence'i kaldır
18
+ s = _FENCE_RE.sub("", s).strip()
19
+ # Direkt dene
20
+ try:
21
+ return json.loads(s)
22
+ except Exception:
23
+ pass
24
+ # İlk { ... } bloğunu yakalamayı dene
25
+ match = re.search(r"\{[\s\S]*\}", s)
26
+ if match:
27
+ try:
28
+ return json.loads(match.group())
29
+ except Exception:
30
+ return None
31
+ return None
chatbot/llm/prompts.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ llm/prompts.py — Tüm Sistem Prompt'ları
3
+
4
+ Her agent'ın kendi sistem prompt'u vardır. Bu tasarım:
5
+ 1. Her agent'ı bağımsız test etmeyi kolaylaştırır.
6
+ 2. Prompt mühendisliğini merkezi bir yerde tutar.
7
+ 3. Farklı modeller için ince ayar yapılabilir.
8
+
9
+ KURAL: LLM'e hiçbir zaman "şu tutarı onayla" veya "kuralı uygula"
10
+ denmez. LLM sadece ÇIKARIM yapar (sayı parse, intent anlama).
11
+ Kurallar validation agent'ında KOD olarak çalışır.
12
+ """
13
+
14
+ # ── Supervisor / Router ───────────────────────────────────────────────────────
15
+ SUPERVISOR_SYSTEM = """Sen bir araç finansmanı chatbot'unun yönlendirici bileşenisin.
16
+ Müşterinin mesajını analiz edip aşağıdaki 4 kategoriden birini JSON olarak döndür.
17
+
18
+ Kategoriler:
19
+ - "new_car" : Yeni araç finansmanı başvurusu YAPMAK / BAŞLATMAK istiyor
20
+ (örn: "yeni araç almak istiyorum", "sıfır araç finansmanı")
21
+ - "used_car" : 2. el araç finansmanı başvurusu YAPMAK istiyor
22
+ (örn: "ikinci el için başvurmak istiyorum")
23
+ - "faq" : Araç finansmanı, kefil, vade, faiz, fatura limiti, HGS, kasko,
24
+ belgeler, ödeme, sigorta, kredi/finansman tutarı veya limiti hakkında BİLGİ / SORU soruyor
25
+ (örn: "fatura tutarım 5M üzerindeyse ne olur?",
26
+ "kefil zorunlu mu?", "kaç yaşa kadar 2. el alınır?",
27
+ "HGS ücretli mi?", "vade seçenekleri neler?",
28
+ "yeni araç finansmanı olarak 5 milyondan fazla alabilir miyim?",
29
+ "ne kadar kredi çekebilirim?", "maksimum tutar nedir?")
30
+ - "out_of_scope": Araç finansmanıyla TAMAMEN alakasız konu
31
+ (örn: hava durumu, başka banka ürünleri, kişisel sohbet)
32
+
33
+ ÖNEMLİ KURALLAR:
34
+ 1. Mesaj soru içeriyorsa ("?", "ne olur", "nedir", "nasıl", "kaç", "mı/mi/mu/mü")
35
+ ve konu araç finansmanı ile ilgiliyse → MUTLAKA "faq" döndür.
36
+ 2. "Yeni araç" veya "2. el" geçmesi tek başına new_car/used_car anlamına gelmez.
37
+ Niyet "başvuru yapma" mı yoksa "bilgi alma" mı ona bak.
38
+ 3. Araç finansmanı kapsamındaki her bilgi sorusu "faq"tır — "out_of_scope" sadece
39
+ tamamen alakasız konular içindir.
40
+
41
+ Çıktı formatı (sadece JSON, başka metin yok):
42
+ {"intent": "<kategori>", "confidence": <0.0-1.0>}
43
+
44
+ Eğer gerçekten belirsizse (örn. "merhaba", "yardım"):
45
+ {"intent": "unclear", "confidence": 0.4, "clarification": "Yeni mi 2. el mi araç düşünüyorsunuz, yoksa bir bilgi mi almak istersiniz?"}
46
+ """
47
+
48
+ # ── Slot Çıkarımı (Sayı Parse) ────────────────────────────────────────────────
49
+ SLOT_EXTRACTION_SYSTEM = """Müşterinin mesajından sayısal değerleri parse etmeni istiyorum.
50
+ Türkçe sayı ifadelerini rakama çevir:
51
+
52
+ Örnekler:
53
+ - "iki buçuk milyon" → 2500000
54
+ - "2.5M TL" → 2500000
55
+ - "beş yüz bin" → 500000
56
+ - "1,200,000" → 1200000
57
+
58
+ Araç yaşı örnekleri:
59
+ - "2020 model" → mevcut yıldan çıkar (yaş = 2026 - 2020 = 6)
60
+ - "5 yaşında" → 5
61
+ - "Kasım 2021 model" → 2026 - 2021 = 5
62
+
63
+ TCKN çıkarımı:
64
+ - "TC'si 12345678901 olan" → "12345678901"
65
+ - "geçmek istiyorum / yok / bilmiyorum" → null
66
+
67
+ Çıktı formatı (sadece JSON):
68
+ {"value": <sayı veya null>, "original": "<müşterinin yazdığı>"}
69
+ """
70
+
71
+ # ── Araç Modeli Çıkarımı ─────────────────────────────────────────────────────
72
+ VEHICLE_MODEL_EXTRACTION_SYSTEM = """Müşterinin mesajından araç markası ve modelini çıkar.
73
+ Kısaltmaları ve yazım hatalarını normalize et.
74
+
75
+ Örnekler:
76
+ - "bmw 3 serisi" → {"brand": "BMW", "model": "3 Serisi"}
77
+ - "volkswagen golf" → {"brand": "Volkswagen", "model": "Golf"}
78
+ - "reno megane" → {"brand": "Renault", "model": "Megane"}
79
+ - "toyota" → {"brand": "Toyota", "model": null, "needs_clarification": true}
80
+
81
+ Çıktı (sadece JSON):
82
+ {"brand": "<marka>", "model": "<model veya null>", "needs_clarification": <bool>}
83
+ """
84
+
85
+ # ── FAQ Agent ─────────────────────────────────────────────────────────────────
86
+ FAQ_SYSTEM = """Sen bir banka araç finansmanı uzmanısın. Yalnızca sana verilen
87
+ referans metinlerden (SSS dokümanı) cevap üret. Referansta olmayan bilgiyi
88
+ kesinlikle ekleme veya uydurma.
89
+
90
+ Kurallar:
91
+ 1. Her cevabın sonuna kaynağını belirt: [Kaynak: <chunk başlığı>]
92
+ 2. Faiz oranı, kesin onay veya garanti ifadesi kullanma.
93
+ 3. Yanıtın kısa ve net olsun (max 3 paragraf).
94
+ 4. Bilgi yoksa "Bu konuda elimde bilgi yok, şubeyi arayabilirsiniz" de.
95
+
96
+ Referans metinler:
97
+ {context}
98
+ """
99
+
100
+ # ── Onay Ekranı Düzenleme ─────────────────────���───────────────────────────────
101
+ EDIT_DETECTION_SYSTEM = """Müşteri onay ekranında bir alanı değiştirmek istiyor.
102
+ Hangi alan değiştirilmek isteniyor?
103
+
104
+ Geçerli alan adları:
105
+ - invoice_amount (proforma fatura tutarı)
106
+ - vehicle_model (araç modeli)
107
+ - requested_amount_new (istenen finansman - yeni araç)
108
+ - guarantor_tckn (kefil TCKN)
109
+ - kasko_value (kasko değeri)
110
+ - vehicle_age (araç yaşı)
111
+ - requested_amount_used (istenen finansman - 2. el)
112
+ - seller_tckn (satıcı TCKN)
113
+
114
+ Çıktı (sadece JSON):
115
+ {"target_slot": "<alan adı veya null>", "new_value_hint": "<varsa yeni değer ipucu>"}
116
+ """
117
+
118
+ # ── Reflection / Self-check ───────────────────────────────────────────────────
119
+ REFLECTION_SYSTEM = """Araç finansmanı başvurusu için toplanan bilgileri kontrol ediyorsun.
120
+ Araç türüne göre eksik veya hatalı alan var mı tespit et.
121
+
122
+ Yeni araç için zorunlu alanlar: invoice_amount, vehicle_model, requested_amount_new
123
+ Yeni araçta kefil: invoice_amount >= 5000000 ise guarantor_tckn zorunlu
124
+
125
+ 2. El araç için zorunlu alanlar: kasko_value, vehicle_age, requested_amount_used
126
+
127
+ Mevcut state:
128
+ {state_summary}
129
+
130
+ Çıktı (sadece JSON):
131
+ {{"complete": <bool>, "missing_fields": [<alan adları>], "issues": [<açıklama>]}}
132
+ """
133
+
134
+ # ── HGS Çapraz Satış ─────────────────────────────────────────────────────────
135
+ HGS_PITCH = """Başvurunuz başarıyla oluşturuldu! 🎉
136
+
137
+ Başvuru Numaranız: {application_id}
138
+
139
+ ─────────────────────────────────────
140
+ 💳 HGS (Hızlı Geçiş Sistemi) Teklifi
141
+ ─────────────────────────────────────
142
+ Yeni aracınız için HGS tanımlatmak ister misiniz?
143
+ Bankamız üzerinden anında aktif edebilirsiniz.
144
+
145
+ Devam etmek istiyor musunuz?"""
146
+
147
+ # ── Genel Hata Mesajları ──────────────────────────────────────────────────────
148
+ MSG_WELCOME = "Merhaba! Araç finansmanı ön başvurusu için yardımcı olabilirim. Yeni araç mı, 2. el araç mı düşünüyorsunuz?"
149
+ MSG_OUT_OF_SCOPE = "Üzgünüm, bu konuda yardımcı olamıyorum. Araç finansmanı veya ilgili sorularınız için buradayım."
150
+ MSG_OPERATOR_REDIRECT = "Bu konuyu daha iyi değerlendirmek için sizi müşteri hizmetlerimize yönlendiriyorum. 0850 XXX XX XX numaralı hattı arayabilirsiniz."
151
+ MSG_FLOW_ENDED_LIMIT = "Maalesef bu başvuru kriterleri karşılamadığı için devam edemiyoruz. Farklı bir seçenek için şubemizi ziyaret edebilirsiniz."
chatbot/memory/__init__.py ADDED
File without changes
chatbot/memory/checkpointer.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ memory/checkpointer.py — LangGraph MemorySaver Konfigürasyonu
3
+
4
+ AGENTIC PATTERN: Short-Term Memory (Checkpointing)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LangGraph MemorySaver, her oturum (thread_id) için state'i
7
+ in-memory olarak saklar. Kullanıcı yeni mesaj gönderdiğinde
8
+ graph, önceki state'ten devam eder — slot'lar kaybolmaz.
9
+
10
+ thread_id = session_id: Her kullanıcı oturumu tamamen bağımsız.
11
+ İki oturumun state'i birbirine karışamaz.
12
+
13
+ Üretimde: SqliteSaver veya RedisSaver kullanılır.
14
+ Öğrenme için: MemorySaver yeterli.
15
+ """
16
+ from langgraph.checkpoint.memory import MemorySaver
17
+
18
+ # Uygulama genelinde tek bir checkpointer instance
19
+ _checkpointer = MemorySaver()
20
+
21
+
22
+ def get_checkpointer() -> MemorySaver:
23
+ """Global checkpointer instance'ını döndürür."""
24
+ return _checkpointer
25
+
26
+
27
+ def get_thread_config(session_id: str) -> dict:
28
+ """
29
+ LangGraph invoke/stream için gerekli config dict'ini döndürür.
30
+
31
+ Kullanım:
32
+ graph.invoke(state, config=get_thread_config(session_id))
33
+ """
34
+ return {"configurable": {"thread_id": session_id}}
chatbot/memory/vector_store.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ memory/vector_store.py — Jina Embedding + ChromaDB RAG Altyapısı
3
+
4
+ AGENTIC PATTERN: Long-Term Memory (RAG)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Uzun vadeli hafıza = SSS dokümanının vektörleştirilmiş hali.
7
+ Uygulama her başladığında FAQ dokümanı chunk'lara bölünür,
8
+ Jina API ile embed edilir ve ChromaDB'ye kaydedilir.
9
+
10
+ ChromaDB, veritabanını diske persist eder (data/chroma/).
11
+ Uygulama yeniden başlatılınca aynı vektörler tekrar kullanılır
12
+ (re-indeksleme yapılmaz).
13
+
14
+ Jina AI free tier: https://jina.ai/embeddings/
15
+ - Model: jina-embeddings-v3
16
+ - 1M token/ay ücretsiz
17
+ - 8192 token context window
18
+ - Türkçe dahil çok dilli destek
19
+ """
20
+ from __future__ import annotations
21
+ import os
22
+ import re
23
+ import httpx
24
+ import chromadb
25
+ from config import (
26
+ CHROMA_PERSIST_DIR,
27
+ FAQ_COLLECTION_NAME,
28
+ JINA_API_KEY,
29
+ JINA_API_URL,
30
+ JINA_EMBEDDING_MODEL,
31
+ RAG_TOP_K,
32
+ )
33
+
34
+ FAQ_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "faq.md")
35
+
36
+ # ChromaDB istemcisi (singleton)
37
+ _client: chromadb.PersistentClient | None = None
38
+ _collection = None
39
+
40
+
41
+ def _get_client():
42
+ global _client, _collection
43
+ if _client is None:
44
+ os.makedirs(CHROMA_PERSIST_DIR, exist_ok=True)
45
+ _client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIR)
46
+ _collection = _client.get_or_create_collection(FAQ_COLLECTION_NAME)
47
+ return _client, _collection
48
+
49
+
50
+ def embed_texts(texts: list[str]) -> list[list[float]]:
51
+ """
52
+ Jina AI API ile metinleri vektörleştirir.
53
+
54
+ Jina'nın 'retrieval.passage' görevi embedding üretim içindir.
55
+ Sorgu zamanında 'retrieval.query' kullanılır (asimetrik retrieval).
56
+ """
57
+ if not JINA_API_KEY:
58
+ raise ValueError("JINA_API_KEY tanımlı değil. .env dosyasını kontrol edin.")
59
+
60
+ response = httpx.post(
61
+ JINA_API_URL,
62
+ headers={"Authorization": f"Bearer {JINA_API_KEY}", "Content-Type": "application/json"},
63
+ json={"model": JINA_EMBEDDING_MODEL, "task": "retrieval.passage", "input": texts},
64
+ timeout=30.0,
65
+ )
66
+ response.raise_for_status()
67
+ return [item["embedding"] for item in response.json()["data"]]
68
+
69
+
70
+ def embed_query(query: str) -> list[float]:
71
+ """
72
+ Sorgu metnini vektörleştirir (passage'dan farklı görev).
73
+ Asimetrik retrieval: soru ve cevap farklı embedding uzayında.
74
+ """
75
+ response = httpx.post(
76
+ JINA_API_URL,
77
+ headers={"Authorization": f"Bearer {JINA_API_KEY}", "Content-Type": "application/json"},
78
+ json={"model": JINA_EMBEDDING_MODEL, "task": "retrieval.query", "input": [query]},
79
+ timeout=30.0,
80
+ )
81
+ response.raise_for_status()
82
+ return response.json()["data"][0]["embedding"]
83
+
84
+
85
+ def _chunk_faq(faq_text: str) -> list[dict]:
86
+ """
87
+ FAQ dokümanını başlık + içerik çiftlerine böler.
88
+ ### başlığı bir chunk sınırıdır.
89
+
90
+ Returns:
91
+ [{"id": str, "text": str, "title": str}, ...]
92
+ """
93
+ chunks = []
94
+ sections = re.split(r"\n### ", faq_text)
95
+
96
+ for i, section in enumerate(sections):
97
+ if not section.strip():
98
+ continue
99
+ lines = section.strip().split("\n", 1)
100
+ title = lines[0].strip("# ").strip()
101
+ content = lines[1].strip() if len(lines) > 1 else ""
102
+ if content:
103
+ chunks.append({
104
+ "id": f"faq_{i:03d}",
105
+ "text": f"Soru: {title}\nCevap: {content}",
106
+ "title": title,
107
+ })
108
+ return chunks
109
+
110
+
111
+ def index_faq(force_reindex: bool = False):
112
+ """
113
+ FAQ dokümanını okuyup ChromaDB'ye indeksler.
114
+ force_reindex=False → zaten indekslenmişse atla.
115
+ """
116
+ _, collection = _get_client()
117
+
118
+ # Zaten indekslenmişse atla
119
+ if not force_reindex and collection.count() > 0:
120
+ print(f"[VectorStore] {collection.count()} chunk zaten indeksli, atlanıyor.")
121
+ return
122
+
123
+ with open(FAQ_PATH, encoding="utf-8") as f:
124
+ faq_text = f.read()
125
+
126
+ chunks = _chunk_faq(faq_text)
127
+ print(f"[VectorStore] {len(chunks)} chunk indeksleniyor...")
128
+
129
+ # Jina API limitleri için batch halinde embed et
130
+ batch_size = 20
131
+ for i in range(0, len(chunks), batch_size):
132
+ batch = chunks[i: i + batch_size]
133
+ texts = [c["text"] for c in batch]
134
+ embeddings = embed_texts(texts)
135
+
136
+ collection.upsert(
137
+ ids=[c["id"] for c in batch],
138
+ documents=[c["text"] for c in batch],
139
+ embeddings=embeddings,
140
+ metadatas=[{"title": c["title"]} for c in batch],
141
+ )
142
+
143
+ print(f"[VectorStore] İndeksleme tamamlandı. Toplam: {collection.count()} chunk")
144
+
145
+
146
+ def search_faq(query: str, top_k: int = RAG_TOP_K) -> list[dict]:
147
+ """
148
+ Sorguya en alakalı FAQ chunk'larını döndürür.
149
+
150
+ Returns:
151
+ [{"text": str, "title": str, "distance": float}, ...]
152
+ """
153
+ _, collection = _get_client()
154
+
155
+ if collection.count() == 0:
156
+ return []
157
+
158
+ query_embedding = embed_query(query)
159
+ results = collection.query(
160
+ query_embeddings=[query_embedding],
161
+ n_results=min(top_k, collection.count()),
162
+ include=["documents", "metadatas", "distances"],
163
+ )
164
+
165
+ chunks = []
166
+ for doc, meta, dist in zip(
167
+ results["documents"][0],
168
+ results["metadatas"][0],
169
+ results["distances"][0],
170
+ ):
171
+ chunks.append({"text": doc, "title": meta["title"], "distance": dist})
172
+
173
+ return chunks
chatbot/observability/__init__.py ADDED
File without changes
chatbot/observability/audit_logger.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ observability/audit_logger.py — Değiştirilemez Denetim Kaydı
3
+
4
+ REPORT Karşılığı: "Audit Log + Trace" (Gözlem & Denetim)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Her LLM çağrısı, tool çağrısı, guard olayı ve slot değişimi
7
+ değiştirilemez (APPEND-ONLY) SQLite tablosuna kaydedilir.
8
+
9
+ KVKK ve BDDK uyumu için:
10
+ - TCKN, kart no gibi hassas veriler maskelenmiş haliyle yazılır
11
+ - Ham kişisel veri loga girmez
12
+ - Audit kayıtları silinemez (soft-delete bile yok)
13
+
14
+ Event tipleri:
15
+ llm_call : LLM'e giden/dönen her çağrı
16
+ tool_call : @tool fonksiyonu çağrıları
17
+ guard_event : Input/output guard tetiklenmeleri
18
+ slot_change : Konuşma state'inde slot değişimleri
19
+ """
20
+ import json
21
+ import sqlite3
22
+ import uuid
23
+ from datetime import datetime, timezone
24
+ from config import DB_PATH
25
+
26
+
27
+ def log_event(
28
+ session_id: str,
29
+ event_type: str, # llm_call | tool_call | guard_event | slot_change
30
+ agent: str, # hangi agent oluşturdu
31
+ details: dict, # olay detayları (model, latency, token vb.)
32
+ masked_input: str = "", # maskelenmiş kullanıcı girdisi
33
+ ) -> str:
34
+ """
35
+ Tek bir denetim olayı kaydeder.
36
+
37
+ Returns:
38
+ event_id: Oluşturulan kayıt UUID'si
39
+ """
40
+ event_id = str(uuid.uuid4())
41
+ timestamp = datetime.now(timezone.utc).isoformat()
42
+
43
+ conn = sqlite3.connect(DB_PATH)
44
+ conn.execute(
45
+ """INSERT INTO audit_events (id, session_id, timestamp, event_type, agent, details, masked_input)
46
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
47
+ (event_id, session_id, timestamp, event_type, agent, json.dumps(details, ensure_ascii=False), masked_input),
48
+ )
49
+ conn.commit()
50
+ conn.close()
51
+ return event_id
52
+
53
+
54
+ def log_llm_call(
55
+ session_id: str,
56
+ agent: str,
57
+ model: str,
58
+ latency_ms: int,
59
+ tokens_in: int,
60
+ tokens_out: int,
61
+ prompt=None,
62
+ response=None,
63
+ max_chars: int = 4000,
64
+ ):
65
+ """LLM çağrısı için kısayol.
66
+
67
+ prompt: str | list[dict] | None — LLM'e giden mesaj(lar). list verilirse
68
+ role/content'e indirgenip JSON-serialize edilir.
69
+ response: str | None — LLM'in ürettiği ham içerik.
70
+ max_chars: prompt/response için karakter üst limiti (DB'yi şişirmemek için).
71
+ """
72
+ details = {
73
+ "model": model,
74
+ "latency_ms": latency_ms,
75
+ "tokens_in": tokens_in,
76
+ "tokens_out": tokens_out,
77
+ }
78
+
79
+ if prompt is not None:
80
+ details["prompt"] = _trim(_normalize_prompt(prompt), max_chars)
81
+ if response is not None:
82
+ details["response"] = _trim(str(response), max_chars)
83
+
84
+ return log_event(
85
+ session_id=session_id,
86
+ event_type="llm_call",
87
+ agent=agent,
88
+ details=details,
89
+ )
90
+
91
+
92
+ def _normalize_prompt(prompt) -> str:
93
+ """Prompt'u loglanabilir stringe çevir; system mesajlarını ele (gürültü)."""
94
+ if isinstance(prompt, str):
95
+ return prompt
96
+ if isinstance(prompt, list):
97
+ parts = []
98
+ for m in prompt:
99
+ if isinstance(m, dict):
100
+ role = m.get("role", "?")
101
+ if role == "system":
102
+ continue # system prompt'u görüntülemeden gizle
103
+ content = m.get("content", "")
104
+ parts.append(f"[{role}] {content}")
105
+ else:
106
+ parts.append(str(m))
107
+ return "\n".join(parts)
108
+ return str(prompt)
109
+
110
+
111
+ def _trim(text: str, limit: int) -> str:
112
+ if len(text) <= limit:
113
+ return text
114
+ return text[:limit] + f"... <truncated {len(text) - limit} chars>"
115
+
116
+
117
+ def log_tool_call(session_id: str, agent: str, tool_name: str, args: dict, result: dict, success: bool):
118
+ """Tool çağrısı için kısayol."""
119
+ return log_event(
120
+ session_id=session_id,
121
+ event_type="tool_call",
122
+ agent=agent,
123
+ details={"tool": tool_name, "args": _sanitize(args), "result": _sanitize(result), "success": success},
124
+ )
125
+
126
+
127
+ def log_guard_event(session_id: str, guard_type: str, triggered: bool, reason: str, masked_text: str = ""):
128
+ """Guard tetiklenmesi için kısayol."""
129
+ return log_event(
130
+ session_id=session_id,
131
+ event_type="guard_event",
132
+ agent=guard_type,
133
+ details={"triggered": triggered, "reason": reason},
134
+ masked_input=masked_text,
135
+ )
136
+
137
+
138
+ def log_routing(session_id: str, from_node: str, to_node: str, reason: str = ""):
139
+ """Edge fonksiyonunun verdiği yönlendirme kararını kaydet."""
140
+ return log_event(
141
+ session_id=session_id,
142
+ event_type="routing",
143
+ agent=from_node,
144
+ details={"from": from_node, "to": to_node, "reason": reason},
145
+ )
146
+
147
+
148
+ def log_slot_change(session_id: str, slot_name: str, old_value, new_value):
149
+ """Slot değişimi için kısayol — hassas alanları maskele."""
150
+ sensitive = {"guarantor_tckn", "seller_tckn"}
151
+ if slot_name in sensitive:
152
+ old_value = _mask_tckn(str(old_value)) if old_value else None
153
+ new_value = _mask_tckn(str(new_value)) if new_value else None
154
+
155
+ return log_event(
156
+ session_id=session_id,
157
+ event_type="slot_change",
158
+ agent="orchestrator",
159
+ details={"slot": slot_name, "old": old_value, "new": new_value},
160
+ )
161
+
162
+
163
+ def get_session_events(session_id: str) -> list[dict]:
164
+ """Bir oturumun tüm audit olaylarını döndürür."""
165
+ conn = sqlite3.connect(DB_PATH)
166
+ rows = conn.execute(
167
+ "SELECT id, timestamp, event_type, agent, details, masked_input FROM audit_events WHERE session_id=? ORDER BY timestamp",
168
+ (session_id,),
169
+ ).fetchall()
170
+ conn.close()
171
+
172
+ return [
173
+ {
174
+ "id": r[0], "timestamp": r[1], "event_type": r[2],
175
+ "agent": r[3], "details": json.loads(r[4]), "masked_input": r[5],
176
+ }
177
+ for r in rows
178
+ ]
179
+
180
+
181
+ def get_recent_events(limit: int = 50) -> list[dict]:
182
+ """Son N olayı döndürür (dashboard için)."""
183
+ conn = sqlite3.connect(DB_PATH)
184
+ rows = conn.execute(
185
+ "SELECT id, session_id, timestamp, event_type, agent, details FROM audit_events ORDER BY timestamp DESC LIMIT ?",
186
+ (limit,),
187
+ ).fetchall()
188
+ conn.close()
189
+ return [{"id": r[0], "session": r[1][:8], "time": r[2][11:19], "type": r[3], "agent": r[4], "details": r[5]} for r in rows]
190
+
191
+
192
+ def _sanitize(data: dict) -> dict:
193
+ """Dict içindeki hassas alanları maskele."""
194
+ sensitive_keys = {"tckn", "tc", "card", "iban", "password"}
195
+ result = {}
196
+ for k, v in data.items():
197
+ if any(s in k.lower() for s in sensitive_keys) and isinstance(v, str):
198
+ result[k] = _mask_tckn(v)
199
+ else:
200
+ result[k] = v
201
+ return result
202
+
203
+
204
+ def _mask_tckn(value: str) -> str:
205
+ if len(value) >= 7:
206
+ return value[:3] + "****" + value[7:]
207
+ return "***"
chatbot/observability/dashboard.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ observability/dashboard.py — Gradio Audit/Metrik Dashboard Sekmesi
3
+
4
+ REPORT Karşılığı: Metric/Alert dashboard görselleştirmesi.
5
+ Gradio'nun Tab bileşeni ile ana chat'in yanında ikinci bir sekme olarak gösterilir.
6
+ """
7
+ import gradio as gr
8
+ from observability.metrics import get_summary_metrics, get_audit_table, get_llm_calls
9
+
10
+
11
+ def build_dashboard_tab():
12
+ """
13
+ Gradio Tab bileşeni döndürür — app.py'deki Blocks içine eklenir.
14
+ """
15
+ with gr.Tab("📊 İzleme Paneli"):
16
+ gr.Markdown("## Gerçek Zamanlı Metrikler")
17
+
18
+ with gr.Row():
19
+ total_apps = gr.Number(label="Toplam Başvuru", interactive=False)
20
+ hgs_rate = gr.Number(label="HGS Dönüşüm %", interactive=False)
21
+ guard_count = gr.Number(label="Guard Tetiklenme", interactive=False)
22
+ p50 = gr.Number(label="LLM Latency p50 (ms)", interactive=False)
23
+ p95 = gr.Number(label="LLM Latency p95 (ms)", interactive=False)
24
+
25
+ refresh_btn = gr.Button("🔄 Yenile")
26
+ audit_table = gr.Dataframe(
27
+ headers=["Oturum", "Saat", "Tür", "Agent", "Özet", "Prompt", "Response"],
28
+ label="Son Denetim Olayları",
29
+ interactive=False,
30
+ wrap=True,
31
+ )
32
+
33
+ gr.Markdown("## 🤖 LLM Çağrıları (prompt + response)")
34
+ llm_session_filter = gr.Textbox(
35
+ label="Oturum filtresi (boş = tümü, kısmî UUID kabul)",
36
+ placeholder="örn: 3f9a1b2c",
37
+ )
38
+ llm_table = gr.Dataframe(
39
+ headers=["Oturum", "Saat", "Agent", "Model", "Latency (ms)", "Tok In", "Tok Out", "Prompt", "Response"],
40
+ label="Son LLM Çağrıları",
41
+ interactive=False,
42
+ wrap=True,
43
+ )
44
+
45
+ def refresh_metrics(sid_filter: str):
46
+ m = get_summary_metrics()
47
+ rows = get_audit_table(30)
48
+ table_data = [
49
+ [r["session"], r["time"], r["type"], r["agent"], r["summary"],
50
+ r.get("prompt", ""), r.get("response", "")]
51
+ for r in rows
52
+ ]
53
+
54
+ sid = sid_filter.strip() or None
55
+ llm_rows = get_llm_calls(50, session_id=None)
56
+ if sid:
57
+ llm_rows = [r for r in llm_rows if sid in r["session"]]
58
+ llm_data = [
59
+ [r["session"], r["time"], r["agent"], r["model"],
60
+ r["latency_ms"], r["tokens_in"], r["tokens_out"],
61
+ r["prompt"], r["response"]]
62
+ for r in llm_rows
63
+ ]
64
+
65
+ return (
66
+ m["total_applications"],
67
+ m["hgs_conversion_rate"],
68
+ m["guard_triggers"],
69
+ m["llm_latency_p50_ms"],
70
+ m["llm_latency_p95_ms"],
71
+ table_data,
72
+ llm_data,
73
+ )
74
+
75
+ refresh_btn.click(
76
+ fn=refresh_metrics,
77
+ inputs=[llm_session_filter],
78
+ outputs=[total_apps, hgs_rate, guard_count, p50, p95, audit_table, llm_table],
79
+ )
80
+
81
+ return refresh_btn
chatbot/observability/metrics.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ observability/metrics.py — İş ve Teknik Metrikler
3
+
4
+ REPORT Karşılığı: "Metric / Alert" (Gözlem & Denetim)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Audit log tablosundan gerçek zamanlı metrik hesaplamaları yapar.
7
+ Dashboard bu modülü sorguladığında güncel sayılar döner.
8
+
9
+ Teknik metrikler:
10
+ - LLM latency (p50, p95 hesaplaması)
11
+ - Token kullanımı (model bazında)
12
+
13
+ İş metrikleri:
14
+ - Başvuru tamamlama oranı
15
+ - Adım bazlı terk (en çok hangi adımda bırakılıyor)
16
+ - Guard tetiklenme sayısı (injection girişimi vb.)
17
+ - HGS dönüşüm oranı
18
+ - FAQ kullanım sıklığı
19
+ """
20
+ import json
21
+ import sqlite3
22
+ from datetime import datetime, timezone
23
+ from config import DB_PATH
24
+
25
+
26
+ def _fmt_local_time(ts: str) -> str:
27
+ """ISO UTC timestamp'i yerel saate çevirip HH:MM:SS döndürür."""
28
+ if not ts:
29
+ return ""
30
+ try:
31
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
32
+ if dt.tzinfo is None:
33
+ dt = dt.replace(tzinfo=timezone.utc)
34
+ return dt.astimezone().strftime("%H:%M:%S")
35
+ except Exception:
36
+ return ts[11:19] if len(ts) >= 19 else ts
37
+
38
+
39
+ def get_summary_metrics() -> dict:
40
+ """Dashboard için özet metrik paketi."""
41
+ conn = sqlite3.connect(DB_PATH)
42
+
43
+ # Toplam başvuru sayısı
44
+ total_apps = conn.execute("SELECT COUNT(*) FROM applications").fetchone()[0]
45
+
46
+ # HGS kayıt sayısı
47
+ hgs_count = conn.execute("SELECT COUNT(*) FROM hgs_registrations").fetchone()[0]
48
+
49
+ # Guard tetiklenme sayısı
50
+ guard_triggers = conn.execute(
51
+ "SELECT COUNT(*) FROM audit_events WHERE event_type='guard_event' AND details LIKE '%\"triggered\": true%'"
52
+ ).fetchone()[0]
53
+
54
+ # Son 100 LLM çağrısından latency hesapla
55
+ latencies = conn.execute(
56
+ "SELECT details FROM audit_events WHERE event_type='llm_call' ORDER BY timestamp DESC LIMIT 100"
57
+ ).fetchall()
58
+
59
+ lat_values = []
60
+ total_tokens_in = 0
61
+ total_tokens_out = 0
62
+ for (det,) in latencies:
63
+ d = json.loads(det)
64
+ if "latency_ms" in d:
65
+ lat_values.append(d["latency_ms"])
66
+ total_tokens_in += d.get("tokens_in", 0)
67
+ total_tokens_out += d.get("tokens_out", 0)
68
+
69
+ lat_values.sort()
70
+ p50 = lat_values[len(lat_values) // 2] if lat_values else 0
71
+ p95 = lat_values[int(len(lat_values) * 0.95)] if lat_values else 0
72
+
73
+ # Araç türü dağılımı
74
+ type_dist = conn.execute(
75
+ "SELECT vehicle_type, COUNT(*) FROM applications GROUP BY vehicle_type"
76
+ ).fetchall()
77
+
78
+ conn.close()
79
+
80
+ return {
81
+ "total_applications": total_apps,
82
+ "hgs_registrations": hgs_count,
83
+ "hgs_conversion_rate": round(hgs_count / total_apps * 100, 1) if total_apps > 0 else 0,
84
+ "guard_triggers": guard_triggers,
85
+ "llm_latency_p50_ms": p50,
86
+ "llm_latency_p95_ms": p95,
87
+ "total_tokens_in": total_tokens_in,
88
+ "total_tokens_out": total_tokens_out,
89
+ "vehicle_type_distribution": {r[0]: r[1] for r in type_dist},
90
+ }
91
+
92
+
93
+ def get_audit_table(limit: int = 30) -> list[dict]:
94
+ """Dashboard audit log tablosu için son olaylar."""
95
+ conn = sqlite3.connect(DB_PATH)
96
+ rows = conn.execute(
97
+ """SELECT session_id, timestamp, event_type, agent, details
98
+ FROM audit_events ORDER BY timestamp DESC LIMIT ?""",
99
+ (limit,),
100
+ ).fetchall()
101
+ conn.close()
102
+
103
+ result = []
104
+ for r in rows:
105
+ details = json.loads(r[4])
106
+ # Latency veya triggered gibi özet bilgi çıkar
107
+ summary = ""
108
+ if "latency_ms" in details:
109
+ summary = f"{details['latency_ms']}ms"
110
+ elif "triggered" in details:
111
+ summary = "✓ Tetiklendi" if details["triggered"] else "- Geçti"
112
+ elif "slot" in details:
113
+ summary = f"{details['slot']}: {details.get('new')}"
114
+ elif "from" in details and "to" in details:
115
+ summary = f"{details['from']} → {details['to']}"
116
+ if details.get("reason"):
117
+ summary += f" ({details['reason']})"
118
+
119
+ prompt_preview = _preview(details.get("prompt"))
120
+ response_preview = _preview(details.get("response"))
121
+
122
+ result.append({
123
+ "session": r[0][:8] + "...",
124
+ "time": _fmt_local_time(r[1]),
125
+ "type": r[2],
126
+ "agent": r[3],
127
+ "summary": summary,
128
+ "prompt": prompt_preview,
129
+ "response": response_preview,
130
+ })
131
+ return result
132
+
133
+
134
+ def _preview(text, limit: int = 200) -> str:
135
+ if text is None:
136
+ return ""
137
+ s = str(text).replace("\n", " ⏎ ")
138
+ return s if len(s) <= limit else s[:limit] + "…"
139
+
140
+
141
+ def get_llm_calls(limit: int = 50, session_id: str | None = None) -> list[dict]:
142
+ """LLM çağrılarını prompt+response ile birlikte döndür (izleme için)."""
143
+ conn = sqlite3.connect(DB_PATH)
144
+ if session_id:
145
+ rows = conn.execute(
146
+ """SELECT session_id, timestamp, agent, details FROM audit_events
147
+ WHERE event_type='llm_call' AND session_id=? ORDER BY timestamp DESC LIMIT ?""",
148
+ (session_id, limit),
149
+ ).fetchall()
150
+ else:
151
+ rows = conn.execute(
152
+ """SELECT session_id, timestamp, agent, details FROM audit_events
153
+ WHERE event_type='llm_call' ORDER BY timestamp DESC LIMIT ?""",
154
+ (limit,),
155
+ ).fetchall()
156
+ conn.close()
157
+
158
+ out = []
159
+ for sid, ts, agent, det in rows:
160
+ d = json.loads(det)
161
+ out.append({
162
+ "session": sid[:8] + "...",
163
+ "time": _fmt_local_time(ts),
164
+ "agent": agent,
165
+ "model": d.get("model", ""),
166
+ "latency_ms": d.get("latency_ms", 0),
167
+ "tokens_in": d.get("tokens_in", 0),
168
+ "tokens_out": d.get("tokens_out", 0),
169
+ "prompt": d.get("prompt", ""),
170
+ "response": d.get("response", ""),
171
+ })
172
+ return out
chatbot/observability/tracer.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ observability/tracer.py — OpenTelemetry Distributed Tracing
3
+
4
+ REPORT Karşılığı: "Audit Log + Trace" (OpenTelemetry ile distributed tracing)
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Her konuşma turu bir TRACE; her agent çağrısı bir SPAN olarak kaydedilir.
7
+
8
+ Span hiyerarşisi:
9
+ conversation_turn (root span)
10
+ ├── input_guard
11
+ ├── supervisor
12
+ │ └── llm_call
13
+ ├── intake_agent / faq_agent / validation_agent
14
+ │ ├── llm_call
15
+ │ └── tool_call
16
+ └── output_guard
17
+
18
+ Geliştirmede: ConsoleSpanExporter ile terminale yazdırılır.
19
+ Üretimde: OTLP exporter ile Jaeger/Grafana Tempo'ya gönderilir.
20
+ """
21
+ from contextlib import contextmanager
22
+ from opentelemetry import trace
23
+ from opentelemetry.sdk.trace import TracerProvider
24
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
25
+ from opentelemetry.sdk.resources import Resource
26
+
27
+ # Uygulama kimliği
28
+ resource = Resource.create({"service.name": "arac-finansman-chatbot", "service.version": "1.0.0"})
29
+
30
+ # Provider kurulumu (uygulama başında bir kez yapılır)
31
+ _provider = TracerProvider(resource=resource)
32
+ _provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
33
+ trace.set_tracer_provider(_provider)
34
+
35
+ tracer = trace.get_tracer("chatbot.tracer")
36
+
37
+
38
+ @contextmanager
39
+ def trace_span(name: str, attributes: dict = None):
40
+ """
41
+ Context manager olarak span başlatır.
42
+
43
+ Kullanım:
44
+ with trace_span("validation_agent", {"session_id": "abc"}):
45
+ # bu blok içindeki işlemler span içinde yer alır
46
+ ...
47
+ """
48
+ with tracer.start_as_current_span(name) as span:
49
+ if attributes:
50
+ for k, v in attributes.items():
51
+ span.set_attribute(k, str(v))
52
+ try:
53
+ yield span
54
+ except Exception as e:
55
+ span.record_exception(e)
56
+ span.set_status(trace.StatusCode.ERROR, str(e))
57
+ raise
58
+
59
+
60
+ def get_current_trace_id() -> str:
61
+ """Aktif trace ID'sini döndürür (log korelasyonu için)."""
62
+ ctx = trace.get_current_span().get_span_context()
63
+ if ctx.is_valid:
64
+ return format(ctx.trace_id, "032x")
65
+ return "no-trace"
chatbot/requirements.txt ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bu proje Python 3.10+ gerektirir.
2
+ # Sanal ortam olusturmak icin:
3
+ # python3.11 -m venv .venv
4
+ # .venv\Scripts\activate
5
+ # pip install -r requirements.txt
6
+
7
+ # Agentic Framework (Python 3.10+ zorunlu)
8
+ langgraph>=0.2.0
9
+ langchain>=0.3.0
10
+ langchain-community>=0.3.0
11
+
12
+ # LLM API (GPT / Claude / Gemini hepsini tek interfaceyle)
13
+ litellm>=1.40.0
14
+ langchain-openai>=0.2.0
15
+
16
+ # Backend
17
+ fastapi>=0.115.0
18
+ uvicorn[standard]>=0.30.0
19
+
20
+ # UI (HuggingFace Spaces uyumlu)
21
+ gradio>=4.44.0
22
+
23
+ # Vector DB (Docker gerektirmez)
24
+ chromadb>=0.5.0
25
+
26
+ # Embedding (Jina AI - httpx ile direkt cagri)
27
+ httpx>=0.27.0
28
+
29
+ # Veri dogrulama
30
+ pydantic>=2.0.0
31
+
32
+ # Ortam degiskenleri
33
+ python-dotenv>=1.0.0
34
+
35
+ # Gozlem & Denetim
36
+ opentelemetry-sdk>=1.25.0
37
+ opentelemetry-exporter-otlp>=1.25.0
38
+
39
+ # Yardimci
40
+ python-dateutil>=2.9.0
chatbot/setup.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ setup.py — Kurulum ve İlk Çalıştırma Scripti
3
+
4
+ Çalıştır:
5
+ python setup.py
6
+
7
+ Bu script:
8
+ 1. Gerekli klasörleri oluşturur
9
+ 2. .env dosyasını kontrol eder
10
+ 3. SQLite tabloları oluşturur
11
+ 4. FAQ dokümanını ChromaDB'ye indeksler
12
+ 5. Temel yapıyı doğrular
13
+ """
14
+ import os
15
+ import sys
16
+
17
+ # Proje kök dizini
18
+ BASE_DIR = os.path.dirname(__file__)
19
+ sys.path.insert(0, BASE_DIR)
20
+
21
+
22
+ def check_env():
23
+ """API key'lerin tanımlı olduğunu kontrol et."""
24
+ from dotenv import load_dotenv
25
+ load_dotenv()
26
+
27
+ env_path = os.path.join(BASE_DIR, ".env")
28
+ if not os.path.exists(env_path):
29
+ example_path = os.path.join(BASE_DIR, ".env.example")
30
+ print(f"[UYARI] .env dosyası bulunamadı.")
31
+ print(f" .env.example dosyasını kopyalayın: cp {example_path} {env_path}")
32
+ print(f" Ardından API key'lerinizi doldurun.\n")
33
+ else:
34
+ print("[✓] .env dosyası bulundu.")
35
+
36
+ import config as cfg
37
+ missing = []
38
+ if not cfg.OPENAI_API_KEY and "gpt" in cfg.ACTIVE_MODEL:
39
+ missing.append("OPENAI_API_KEY")
40
+ if not cfg.ANTHROPIC_API_KEY and "claude" in cfg.ACTIVE_MODEL:
41
+ missing.append("ANTHROPIC_API_KEY")
42
+ if not cfg.GOOGLE_API_KEY and "gemini" in cfg.ACTIVE_MODEL:
43
+ missing.append("GOOGLE_API_KEY")
44
+ if not cfg.JINA_API_KEY:
45
+ missing.append("JINA_API_KEY (FAQ RAG için gerekli)")
46
+
47
+ if missing:
48
+ print(f"[UYARI] Eksik API key'ler: {', '.join(missing)}")
49
+ else:
50
+ print(f"[✓] API key'ler tanımlı. Aktif model: {cfg.ACTIVE_MODEL}")
51
+
52
+
53
+ def setup_db():
54
+ """SQLite tabloları oluştur."""
55
+ from tools.db_tool import init_db
56
+ init_db()
57
+ print("[✓] SQLite tabloları hazır.")
58
+
59
+
60
+ def index_faq():
61
+ """FAQ dokümanını ChromaDB'ye indeksle."""
62
+ from config import JINA_API_KEY
63
+ if not JINA_API_KEY:
64
+ print("[UYARI] JINA_API_KEY eksik — FAQ indeksleme atlandı.")
65
+ return
66
+
67
+ from memory.vector_store import index_faq as _index
68
+ try:
69
+ _index()
70
+ print("[✓] FAQ dokümanı indekslendi.")
71
+ except Exception as e:
72
+ print(f"[HATA] FAQ indeksleme başarısız: {e}")
73
+
74
+
75
+ def verify_catalog():
76
+ """Araç kataloğunun yüklendiğini doğrula."""
77
+ import json
78
+ catalog_path = os.path.join(BASE_DIR, "data", "vehicle_catalog.json")
79
+ with open(catalog_path, encoding="utf-8") as f:
80
+ catalog = json.load(f)
81
+ print(f"[✓] Araç kataloğu: {len(catalog)} marka/model grubu yüklendi.")
82
+
83
+
84
+ def main():
85
+ print("=" * 50)
86
+ print("Araç Finansmanı Chatbot — Kurulum")
87
+ print("=" * 50 + "\n")
88
+
89
+ # Data klasörünü oluştur
90
+ os.makedirs(os.path.join(BASE_DIR, "data"), exist_ok=True)
91
+ os.makedirs(os.path.join(BASE_DIR, "data", "chroma"), exist_ok=True)
92
+
93
+ check_env()
94
+ setup_db()
95
+ verify_catalog()
96
+ index_faq()
97
+
98
+ print("\n" + "=" * 50)
99
+ print("Kurulum tamamlandı!")
100
+ print("\nUygulamayı başlatmak için:")
101
+ print(" python app.py")
102
+ print("\nVeya doğrudan Gradio ile:")
103
+ print(" gradio app.py")
104
+ print("=" * 50)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
chatbot/tools/__init__.py ADDED
File without changes
chatbot/tools/amount_tool.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/amount_tool.py — Finansman Tutarı Hesap ve Doğrulama
3
+
4
+ AGENTIC PATTERN: Tool Use — Deterministik İş Kuralları
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Tüm finansman kuralları (üst limitler, oranlar) burada KOD olarak
7
+ çalışır. LLM bu kuralları ne hesaplar ne de doğrular — sadece
8
+ hangi tool'u çağıracağını seçer.
9
+
10
+ Bu sayede:
11
+ - Kural hataları test edilebilir (birim test)
12
+ - Kural değişikliği için tek bir yer
13
+ - LLM halüsinasyonu → iş kararlarına etki edemez
14
+ """
15
+ from langchain_core.tools import tool
16
+ from config import (
17
+ NEW_CAR_MAX_INVOICE,
18
+ NEW_CAR_FINANCE_RATIO,
19
+ NEW_CAR_GUARANTOR_THRESHOLD,
20
+ USED_CAR_MAX_AGE,
21
+ USED_CAR_FINANCE_RATIO,
22
+ USED_CAR_MAX_FINANCE,
23
+ )
24
+
25
+
26
+ @tool
27
+ def validate_new_car_amount(invoice_amount: float, requested_amount: float) -> dict:
28
+ """
29
+ Yeni araç finansman tutarını iş kurallarına göre doğrular.
30
+
31
+ Kurallar:
32
+ - Fatura tutarı ≤ 7.000.000 TL
33
+ - Finansman ≤ %60 × fatura tutarı
34
+
35
+ Returns:
36
+ {"valid": bool, "max_allowed": float, "error": str | None,
37
+ "kefil_required": bool}
38
+ """
39
+ # Kural 1: Fatura üst limiti
40
+ if invoice_amount > NEW_CAR_MAX_INVOICE:
41
+ return {
42
+ "valid": False,
43
+ "max_allowed": NEW_CAR_MAX_INVOICE,
44
+ "error": f"Proforma fatura tutarı {_fmt(NEW_CAR_MAX_INVOICE)} TL üst limitini aşıyor.",
45
+ "kefil_required": False,
46
+ }
47
+
48
+ # Kural 2: Finansman oranı
49
+ max_finance = invoice_amount * NEW_CAR_FINANCE_RATIO
50
+ if requested_amount > max_finance:
51
+ return {
52
+ "valid": False,
53
+ "max_allowed": max_finance,
54
+ "error": (
55
+ f"İstenen finansman ({_fmt(requested_amount)} TL), "
56
+ f"fatura tutarının %{int(NEW_CAR_FINANCE_RATIO*100)}'ini aşıyor. "
57
+ f"Maksimum: {_fmt(max_finance)} TL"
58
+ ),
59
+ "kefil_required": invoice_amount >= NEW_CAR_GUARANTOR_THRESHOLD,
60
+ }
61
+
62
+ return {
63
+ "valid": True,
64
+ "max_allowed": max_finance,
65
+ "error": None,
66
+ "kefil_required": invoice_amount >= NEW_CAR_GUARANTOR_THRESHOLD,
67
+ }
68
+
69
+
70
+ @tool
71
+ def validate_used_car_amount(kasko_value: float, vehicle_age: int, requested_amount: float) -> dict:
72
+ """
73
+ 2. El araç finansman tutarını iş kurallarına göre doğrular.
74
+
75
+ Kurallar:
76
+ - Araç yaşı ≤ 5 yıl
77
+ - Finansman ≤ min(%40 × kasko_value, 3.000.000 TL)
78
+
79
+ Returns:
80
+ {"valid": bool, "max_allowed": float, "error": str | None}
81
+ """
82
+ # Kural 1: Yaş kontrolü
83
+ if vehicle_age > USED_CAR_MAX_AGE:
84
+ return {
85
+ "valid": False,
86
+ "max_allowed": 0,
87
+ "error": f"Araç yaşı {vehicle_age} yıl, maksimum kabul edilen {USED_CAR_MAX_AGE} yıldır.",
88
+ }
89
+
90
+ # Kural 2: Finansman oranı (min ile sınırla)
91
+ ratio_limit = kasko_value * USED_CAR_FINANCE_RATIO
92
+ max_finance = min(ratio_limit, USED_CAR_MAX_FINANCE)
93
+
94
+ if requested_amount > max_finance:
95
+ return {
96
+ "valid": False,
97
+ "max_allowed": max_finance,
98
+ "error": (
99
+ f"İstenen finansman ({_fmt(requested_amount)} TL) limiti aşıyor. "
100
+ f"Kasko oranı limiti: {_fmt(ratio_limit)} TL, "
101
+ f"mutlak üst limit: {_fmt(USED_CAR_MAX_FINANCE)} TL."
102
+ ),
103
+ }
104
+
105
+ return {"valid": True, "max_allowed": max_finance, "error": None}
106
+
107
+
108
+ @tool
109
+ def calculate_max_finance_new(invoice_amount: float) -> dict:
110
+ """Yeni araç için alınabilecek maksimum finansmanı hesaplar."""
111
+ max_f = invoice_amount * NEW_CAR_FINANCE_RATIO
112
+ return {
113
+ "max_finance": max_f,
114
+ "ratio": NEW_CAR_FINANCE_RATIO,
115
+ "kefil_required": invoice_amount >= NEW_CAR_GUARANTOR_THRESHOLD,
116
+ }
117
+
118
+
119
+ @tool
120
+ def calculate_max_finance_used(kasko_value: float) -> dict:
121
+ """2. El araç için alınabilecek maksimum finansmanı hesaplar."""
122
+ ratio_limit = kasko_value * USED_CAR_FINANCE_RATIO
123
+ max_f = min(ratio_limit, USED_CAR_MAX_FINANCE)
124
+ return {"max_finance": max_f, "ratio": USED_CAR_FINANCE_RATIO, "absolute_cap": USED_CAR_MAX_FINANCE}
125
+
126
+
127
+ def _fmt(amount: float) -> str:
128
+ """Sayıyı Türkçe binlik ayırıcıyla formatlar: 3000000 → '3.000.000'"""
129
+ return f"{int(amount):,}".replace(",", ".")
chatbot/tools/catalog_tool.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/catalog_tool.py — Araç Modeli Kataloğu
3
+
4
+ AGENTIC PATTERN: Tool Use
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Araç modeli → binek/ticari sınıflandırması bu tool ile yapılır.
7
+ LLM modeli çıkarır, katalog nihai kararı verir.
8
+ O(1) lookup: JSON yüklenip dict'e çevrilir, uygulama başında bir kez.
9
+ """
10
+ import json
11
+ import os
12
+ from functools import lru_cache
13
+ from langchain_core.tools import tool
14
+
15
+ CATALOG_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "vehicle_catalog.json")
16
+
17
+
18
+ @lru_cache(maxsize=1)
19
+ def _load_catalog() -> dict:
20
+ """
21
+ Kataloğu bir kez yükle, in-memory cache'de tut.
22
+ lru_cache(maxsize=1) → uygulama hayatı boyunca sadece bir kez okur.
23
+ """
24
+ with open(CATALOG_PATH, encoding="utf-8") as f:
25
+ entries = json.load(f)
26
+
27
+ # Arama kolaylığı için düzleştirilmiş dict: "bmw_3 serisi" → {type, brand, model}
28
+ catalog: dict = {}
29
+ for entry in entries:
30
+ key = f"{entry['brand'].lower()}_{entry['model'].lower()}"
31
+ catalog[key] = entry
32
+ # Variant'ları da ekle
33
+ for variant in entry.get("variants", []):
34
+ vkey = f"{entry['brand'].lower()}_{variant.lower()}"
35
+ catalog[vkey] = {**entry, "model": variant}
36
+ return catalog
37
+
38
+
39
+ @tool
40
+ def lookup_vehicle(brand: str, model: str) -> dict:
41
+ """
42
+ Araç marka ve modelinin binek/ticari sınıfını katalogdan döndürür.
43
+
44
+ Args:
45
+ brand: Araç markası (örn. "BMW", "Ford")
46
+ model: Araç modeli (örn. "3 Serisi", "Transit")
47
+
48
+ Returns:
49
+ {"found": bool, "type": "binek"|"ticari"|None, "brand": str, "model": str}
50
+ """
51
+ catalog = _load_catalog()
52
+ key = f"{brand.lower()}_{model.lower()}"
53
+
54
+ if key in catalog:
55
+ entry = catalog[key]
56
+ return {"found": True, "type": entry["type"], "brand": entry["brand"], "model": entry["model"]}
57
+
58
+ # Fuzzy: sadece marka eşleşmesi
59
+ brand_matches = [v for k, v in catalog.items() if k.startswith(brand.lower() + "_")]
60
+ if brand_matches:
61
+ return {
62
+ "found": False,
63
+ "type": None,
64
+ "brand": brand,
65
+ "model": model,
66
+ "suggestions": [f"{m['brand']} {m['model']}" for m in brand_matches[:5]],
67
+ }
68
+
69
+ return {"found": False, "type": None, "brand": brand, "model": model, "suggestions": []}
chatbot/tools/db_tool.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/db_tool.py — SQLite Başvuru Yazma Aracı
3
+
4
+ AGENTIC PATTERN: Tool Use — Least-Privilege
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ LLM doğrudan veritabanına erişemez. Orchestrator bu tool'u çağırır.
7
+ TCKN ve hassas veriler maskelenmiş haliyle yazılır.
8
+ Sadece submission_agent bu tool'a erişebilir (least-privilege).
9
+ """
10
+ import sqlite3
11
+ import uuid
12
+ from datetime import datetime
13
+ from langchain_core.tools import tool
14
+ from config import DB_PATH
15
+
16
+
17
+ def init_db():
18
+ """Uygulama başlarken tabloları oluştur (yoksa)."""
19
+ conn = sqlite3.connect(DB_PATH)
20
+ conn.execute("""
21
+ CREATE TABLE IF NOT EXISTS applications (
22
+ id TEXT PRIMARY KEY,
23
+ session_id TEXT,
24
+ vehicle_type TEXT,
25
+ created_at TEXT,
26
+ -- Yeni araç alanları
27
+ invoice_amount REAL,
28
+ vehicle_model TEXT,
29
+ requested_amount_new REAL,
30
+ guarantor_tckn_masked TEXT,
31
+ -- 2. el alanları
32
+ kasko_value REAL,
33
+ vehicle_age INTEGER,
34
+ requested_amount_used REAL,
35
+ seller_tckn_masked TEXT,
36
+ -- Meta
37
+ status TEXT DEFAULT 'pending'
38
+ )
39
+ """)
40
+ conn.execute("""
41
+ CREATE TABLE IF NOT EXISTS audit_events (
42
+ id TEXT PRIMARY KEY,
43
+ session_id TEXT,
44
+ timestamp TEXT,
45
+ event_type TEXT,
46
+ agent TEXT,
47
+ details TEXT,
48
+ masked_input TEXT
49
+ )
50
+ """)
51
+ conn.execute("""
52
+ CREATE TABLE IF NOT EXISTS hgs_registrations (
53
+ id TEXT PRIMARY KEY,
54
+ application_id TEXT,
55
+ session_id TEXT,
56
+ created_at TEXT,
57
+ status TEXT DEFAULT 'active'
58
+ )
59
+ """)
60
+ conn.commit()
61
+ conn.close()
62
+
63
+
64
+ @tool
65
+ def write_application(
66
+ session_id: str,
67
+ vehicle_type: str,
68
+ invoice_amount: float | None = None,
69
+ vehicle_model: str | None = None,
70
+ requested_amount_new: float | None = None,
71
+ guarantor_tckn_masked: str | None = None,
72
+ kasko_value: float | None = None,
73
+ vehicle_age: int | None = None,
74
+ requested_amount_used: float | None = None,
75
+ seller_tckn_masked: str | None = None,
76
+ ) -> dict:
77
+ """
78
+ Onaylanmış başvuruyu veritabanına yazar.
79
+
80
+ Returns:
81
+ {"success": bool, "application_id": str, "error": str | None}
82
+ """
83
+ app_id = str(uuid.uuid4())[:8].upper() # Kısa, okunabilir başvuru no
84
+
85
+ try:
86
+ conn = sqlite3.connect(DB_PATH)
87
+ conn.execute(
88
+ """INSERT INTO applications VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
89
+ (
90
+ app_id, session_id, vehicle_type,
91
+ datetime.utcnow().isoformat(),
92
+ invoice_amount, vehicle_model, requested_amount_new, guarantor_tckn_masked,
93
+ kasko_value, vehicle_age, requested_amount_used, seller_tckn_masked,
94
+ "pending",
95
+ ),
96
+ )
97
+ conn.commit()
98
+ conn.close()
99
+ return {"success": True, "application_id": app_id, "error": None}
100
+ except Exception as e:
101
+ return {"success": False, "application_id": None, "error": str(e)}
102
+
103
+
104
+ @tool
105
+ def get_application_history(session_id: str) -> dict:
106
+ """Bir oturumun geçmiş başvurularını listeler."""
107
+ conn = sqlite3.connect(DB_PATH)
108
+ rows = conn.execute(
109
+ "SELECT id, vehicle_type, created_at, status FROM applications WHERE session_id=? ORDER BY created_at DESC",
110
+ (session_id,),
111
+ ).fetchall()
112
+ conn.close()
113
+ return {"applications": [{"id": r[0], "type": r[1], "date": r[2], "status": r[3]} for r in rows]}
chatbot/tools/hgs_tool.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/hgs_tool.py — HGS Mock Servisi
3
+
4
+ AGENTIC PATTERN: Tool Use — Scoped Access
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ Çapraz satış agent'ı YALNIZCA bu tool'a erişebilir.
7
+ Ön Başvuru DB'ye dokunamaz (least-privilege prensibi).
8
+
9
+ Gerçek entegrasyonda: Karayolları HGS API çağrısı yapılır.
10
+ Burada: in-memory mock (öğrenme amaçlı).
11
+ """
12
+ import uuid
13
+ import sqlite3
14
+ from datetime import datetime
15
+ from langchain_core.tools import tool
16
+ from config import DB_PATH
17
+
18
+
19
+ @tool
20
+ def check_hgs_status(session_id: str) -> dict:
21
+ """
22
+ Müşterinin aktif HGS kaydı var mı kontrol eder.
23
+ Aktif HGS varsa çapraz satış adımı gösterilmez.
24
+
25
+ Returns:
26
+ {"has_active_hgs": bool}
27
+ """
28
+ # Mock: session_id'e göre rastgele sonuç (gerçekte CRM'e sorgu)
29
+ # Basitlik için: id'nin son hanesi çifts → HGS var
30
+ has_hgs = (ord(session_id[-1]) % 2 == 0)
31
+ return {"has_active_hgs": has_hgs}
32
+
33
+
34
+ @tool
35
+ def register_hgs(session_id: str, application_id: str) -> dict:
36
+ """
37
+ HGS başvurusunu kaydeder.
38
+
39
+ Returns:
40
+ {"success": bool, "hgs_id": str, "error": str | None}
41
+ """
42
+ hgs_id = "HGS-" + str(uuid.uuid4())[:6].upper()
43
+ try:
44
+ conn = sqlite3.connect(DB_PATH)
45
+ conn.execute(
46
+ "INSERT INTO hgs_registrations VALUES (?,?,?,?,?)",
47
+ (hgs_id, application_id, session_id, datetime.utcnow().isoformat(), "active"),
48
+ )
49
+ conn.commit()
50
+ conn.close()
51
+ return {"success": True, "hgs_id": hgs_id, "error": None}
52
+ except Exception as e:
53
+ return {"success": False, "hgs_id": None, "error": str(e)}
chatbot/tools/rag_tool.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/rag_tool.py — RAG Retrieval Aracı
3
+
4
+ AGENTIC PATTERN: Tool Use + Long-Term Memory
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6
+ FAQ agent bu tool'u çağırarak ChromaDB'den ilgili chunk'ları getirir.
7
+ Tool; embed et → ara → döndür işlemini kapsüller.
8
+ FAQ agent'ı sadece READ yetkisine sahiptir; slot okuyamaz/yazamaz.
9
+ """
10
+ from langchain_core.tools import tool
11
+ from memory.vector_store import search_faq
12
+ from config import RAG_TOP_K
13
+
14
+
15
+ @tool
16
+ def retrieve_faq(query: str, top_k: int = RAG_TOP_K) -> dict:
17
+ """
18
+ Kullanıcı sorusuna en alakalı SSS içeriklerini getirir.
19
+
20
+ Args:
21
+ query: Kullanıcının sorusu
22
+ top_k: Kaç chunk getirileceği (varsayılan config'den)
23
+
24
+ Returns:
25
+ {"chunks": [{"text": str, "title": str, "distance": float}],
26
+ "context": str} # LLM'e doğrudan verilecek birleşik metin
27
+ """
28
+ chunks = search_faq(query, top_k=top_k)
29
+
30
+ if not chunks:
31
+ return {
32
+ "chunks": [],
33
+ "context": "Bu konuda SSS dokümanında bilgi bulunamadı.",
34
+ }
35
+
36
+ # LLM'e verilecek context metnini oluştur
37
+ context_parts = []
38
+ for i, chunk in enumerate(chunks, 1):
39
+ context_parts.append(f"[{i}] {chunk['title']}\n{chunk['text']}")
40
+
41
+ return {
42
+ "chunks": chunks,
43
+ "context": "\n\n".join(context_parts),
44
+ }
chatbot/tools/tckn_tool.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools/tckn_tool.py — TCKN Doğrulama Aracı
3
+
4
+ AGENTIC PATTERN: Tool Use
5
+ ━━━━━━━━━━━━━━━━━━━━━━━━
6
+ @tool decorator ile LangChain/LangGraph'a kayıt edilir.
7
+ Agent, "bu TCKN geçerli mi?" sorusunu cevaplamak için
8
+ bu tool'u çağırır. LLM kendi başına checksum hesaplamaz;
9
+ sadece hangi tool'u çağıracağına karar verir.
10
+
11
+ Algoritma: Türk TC Kimlik No mod-11 doğrulama standardı
12
+ - 11 haneli, ilk hane 0 olamaz
13
+ - 10. hane: (d1+d3+d5+d7+d9)*7 - (d2+d4+d6+d8) mod 10
14
+ - 11. hane: (d1+d2+...+d10) mod 10
15
+
16
+ API çağrısı YOK — tamamen deterministik, senkron.
17
+ """
18
+ from langchain_core.tools import tool
19
+
20
+
21
+ @tool
22
+ def validate_tckn(tckn: str) -> dict:
23
+ """
24
+ Türk TC Kimlik Numarasını mod-11 algoritmasıyla doğrular.
25
+
26
+ Args:
27
+ tckn: Doğrulanacak TCKN (11 hane, string)
28
+
29
+ Returns:
30
+ {"valid": bool, "error": str | None, "masked": str}
31
+ """
32
+ # Boşluk ve tire temizle
33
+ tckn = tckn.strip().replace(" ", "").replace("-", "")
34
+
35
+ # Format kontrolü
36
+ if not tckn.isdigit():
37
+ return {"valid": False, "error": "TCKN yalnızca rakamlardan oluşmalı.", "masked": ""}
38
+ if len(tckn) != 11:
39
+ return {"valid": False, "error": f"TCKN 11 hane olmalı, {len(tckn)} hane girildi.", "masked": ""}
40
+ if tckn[0] == "0":
41
+ return {"valid": False, "error": "TCKN 0 ile başlayamaz.", "masked": ""}
42
+
43
+ digits = [int(c) for c in tckn]
44
+
45
+ # 10. hane kontrolü
46
+ check10 = ((sum(digits[i] for i in range(0, 9, 2)) * 7) - sum(digits[i] for i in range(1, 8, 2))) % 10
47
+ if digits[9] != check10:
48
+ return {"valid": False, "error": "TCKN doğrulama hatası (10. hane).", "masked": _mask(tckn)}
49
+
50
+ # 11. hane kontrolü
51
+ check11 = sum(digits[:10]) % 10
52
+ if digits[10] != check11:
53
+ return {"valid": False, "error": "TCKN doğrulama hatası (11. hane).", "masked": _mask(tckn)}
54
+
55
+ return {"valid": True, "error": None, "masked": _mask(tckn)}
56
+
57
+
58
+ def _mask(tckn: str) -> str:
59
+ """TCKN'nin ortasını gizler: 123****8901 formatında döner."""
60
+ if len(tckn) != 11:
61
+ return "***"
62
+ return tckn[:3] + "****" + tckn[7:]