emreceniklioglu commited on
Commit ·
49102bb
0
Parent(s):
Initial commit: LangGraph multi-agent car finance chatbot
Browse files- .gitignore +30 -0
- README.md +365 -0
- chatbot/DIAGRAMS.md +149 -0
- chatbot/agents/__init__.py +0 -0
- chatbot/agents/crosssell_agent.py +86 -0
- chatbot/agents/faq_agent.py +134 -0
- chatbot/agents/intake_new.py +260 -0
- chatbot/agents/intake_used.py +229 -0
- chatbot/agents/reflection_agent.py +139 -0
- chatbot/agents/submission_agent.py +70 -0
- chatbot/agents/supervisor.py +196 -0
- chatbot/agents/validation_agent.py +126 -0
- chatbot/app.py +272 -0
- chatbot/config.py +58 -0
- chatbot/data/faq.md +180 -0
- chatbot/data/vehicle_catalog.json +150 -0
- chatbot/graph/__init__.py +0 -0
- chatbot/graph/edges.py +168 -0
- chatbot/graph/nodes.py +242 -0
- chatbot/graph/state.py +105 -0
- chatbot/graph/workflow.py +220 -0
- chatbot/guardrails/__init__.py +0 -0
- chatbot/guardrails/input_guard.py +93 -0
- chatbot/guardrails/output_guard.py +97 -0
- chatbot/llm/__init__.py +0 -0
- chatbot/llm/factory.py +141 -0
- chatbot/llm/json_utils.py +31 -0
- chatbot/llm/prompts.py +151 -0
- chatbot/memory/__init__.py +0 -0
- chatbot/memory/checkpointer.py +34 -0
- chatbot/memory/vector_store.py +173 -0
- chatbot/observability/__init__.py +0 -0
- chatbot/observability/audit_logger.py +207 -0
- chatbot/observability/dashboard.py +81 -0
- chatbot/observability/metrics.py +172 -0
- chatbot/observability/tracer.py +65 -0
- chatbot/requirements.txt +40 -0
- chatbot/setup.py +108 -0
- chatbot/tools/__init__.py +0 -0
- chatbot/tools/amount_tool.py +129 -0
- chatbot/tools/catalog_tool.py +69 -0
- chatbot/tools/db_tool.py +113 -0
- chatbot/tools/hgs_tool.py +53 -0
- chatbot/tools/rag_tool.py +44 -0
- 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:]
|