CarFinanceAgent / chatbot /agents /supervisor.py
emreceniklioglu
Initial commit: LangGraph multi-agent car finance chatbot
49102bb
Raw
History Blame Contribute Delete
8.86 kB
"""
agents/supervisor.py — Supervisor / Router Agent
AGENTIC PATTERN: Supervisor Pattern
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Supervisor pattern'de bir üst agent, gelen isteği analiz eder
ve hangi alt-agent'ın görevi üstleneceğine karar verir.
Bu agent:
1. Müşterinin mesajını alır
2. Küçük (router) modeli kullanarak intent sınıflandırır
3. Confidence < 0.7 ise netleştirme sorusu üretir
4. Sonucu ConversationState'e yazar → LangGraph graph edge'i
bu sonuca göre bir sonraki node'u seçer
NEDEN Supervisor Pattern?
- Her alt-agent kendi sorumluluğuna odaklanır (single responsibility)
- Yeni bir dal (örn. "sigorta bilgisi") eklemek için sadece Supervisor
ve yeni agent yazılır; diğer agent'lara dokunulmaz
- Supervisor'ın kararı loglarda görülür → debug kolaylığı
"""
import time
from langchain_core.messages import HumanMessage, AIMessage
from llm.factory import get_router_llm
from llm.json_utils import parse_llm_json
from llm.prompts import SUPERVISOR_SYSTEM, MSG_OUT_OF_SCOPE
from graph.state import ConversationState
from observability.audit_logger import log_llm_call
def run_supervisor(state: ConversationState) -> dict:
"""
Supervisor node fonksiyonu.
LangGraph bu fonksiyonu çağırır; dönen dict state'i günceller.
Returns: state güncellemesi (sadece değişen alanlar)
"""
last_message = state["messages"][-1]
user_text = last_message.content if hasattr(last_message, "content") else str(last_message)
# ── Mid-flow bypass ──────────────────────────────────────────────────────
# Zaten bir intake/recap/crosssell akışındaysak LLM sınıflandırması yapmadan
# mevcut adımı koru. Aksi halde "onaylıyorum", "2.500.000" gibi cevaplar
# yanlış intent (unclear / out_of_scope) olarak yorumlanır ve akış kopar.
# NOT: input_guard node'u current_step'i "input_guard_passed" olarak ezdiği
# için step kontrolüne güvenmiyoruz; vehicle_type + doldurulmuş slot'lara
# veya awaited_slot'a bakıyoruz.
vtype = state.get("vehicle_type")
has_new_progress = vtype == "new" and (
state.get("invoice_amount")
or state.get("vehicle_model")
or state.get("requested_amount_new")
)
has_used_progress = vtype == "used" and (
state.get("kasko_value")
or state.get("vehicle_age")
or state.get("requested_amount_used")
)
in_mid_flow = state.get("awaited_slot") or has_new_progress or has_used_progress
if in_mid_flow and vtype in ("new", "used"):
# Soru soruyorsa mid-flow'da bile FAQ'e yönlendir
if _looks_like_finance_question(user_text):
return {
"current_step": "supervisor_routed_faq",
"vehicle_type": vtype,
"_supervisor_intent": "faq",
}
intent = "new_car" if vtype == "new" else "used_car"
return {
"current_step": f"supervisor_routed_{intent}",
"vehicle_type": vtype,
"_supervisor_intent": intent,
}
# Deterministik kestirme: akış başında net araç türü ifadeleri LLM'e gitmeden yakalanır.
# (LLM router "yeni araç" gibi kısa ifadeleri bazen belirsiz sayıp clarifying'e düşürüyor.)
lower_text = user_text.lower().strip()
if not state.get("vehicle_type") and not _looks_like_finance_question(user_text):
is_new = any(w in lower_text for w in ["yeni", "sıfır", "sifir", "0 km", "0km", "sfr"])
is_used = any(w in lower_text for w in ["2.el", "2. el", "2el", "ikinci el", "kullanılmış", "kullanilmis"])
if is_new and not is_used:
return {"current_step": "supervisor_routed_new_car", "vehicle_type": "new", "_supervisor_intent": "new_car"}
if is_used and not is_new:
return {"current_step": "supervisor_routed_used_car", "vehicle_type": "used", "_supervisor_intent": "used_car"}
llm = get_router_llm()
start = time.time()
response = llm.invoke([
{"role": "system", "content": SUPERVISOR_SYSTEM},
{"role": "user", "content": user_text},
])
latency = int((time.time() - start) * 1000)
log_llm_call(
session_id=state["session_id"],
agent="supervisor",
model=llm.model,
latency_ms=latency,
tokens_in=len(user_text.split()), # yaklaşık
tokens_out=len(response.content.split()),
prompt=[
{"role": "system", "content": SUPERVISOR_SYSTEM},
{"role": "user", "content": user_text},
],
response=response.content,
)
# JSON parse (markdown code fence ve ek metni tolere eder)
parsed = parse_llm_json(response.content)
if parsed:
intent = parsed.get("intent", "out_of_scope")
confidence = parsed.get("confidence", 1.0)
clarification = parsed.get("clarification")
else:
intent = "out_of_scope"
confidence = 0.0
clarification = None
# Defansif heuristic — küçük router modeli finansman sorularını bazen yanlış
# ("out_of_scope", "unclear") ya da düşük confidence ile işaretliyor.
# Mesaj net bir araç finansmanı sorusuysa faq'e yönlendir ve clarifying'e
# düşmesini engelle (confidence'ı eşiğin üstüne çek).
if _looks_like_finance_question(user_text) and intent in ("out_of_scope", "unclear", "faq"):
intent = "faq"
confidence = max(confidence, 0.75)
# Netleştirme gerekiyorsa → mesaj ekle, intent belirsiz bırak
if intent == "unclear" or confidence < 0.7:
clarification_text = clarification or "İsteğinizi tam olarak anlamadım. Araç finansmanı mı yoksa başka bir konuda mı yardımcı olayım?"
return {
"current_step": "clarifying",
"messages": state["messages"] + [AIMessage(content=clarification_text)],
}
# Intent → vehicle_type mapping
vehicle_type = None
if intent == "new_car":
vehicle_type = "new"
elif intent == "used_car":
vehicle_type = "used"
# Araç türü belirsizse (new_car/used_car ama hangi tür belli değil) → sor
if intent in ("new_car", "used_car") and vehicle_type and not state.get("vehicle_type"):
# Mesajda açıkça "yeni"/"sıfır" veya "2.el"/"ikinci el" geçmiyorsa sor
lower_text = user_text.lower()
has_new = any(w in lower_text for w in ["yeni", "sıfır", "sfr", "0km", "sifir"])
has_used = any(w in lower_text for w in ["2.el", "ikinci el", "2el", "kullanılmış", "kullanilmis", "2. el"])
if not has_new and not has_used:
return {
"current_step": "clarifying",
"messages": state["messages"] + [AIMessage(content="Yeni araç mı, 2. el araç mı düşünüyorsunuz?")],
}
# Zaten devam eden bir intake adımındaysak current_step'e dokunma
# (intake_new/used hangi slot'u beklediğini current_step ile takip eder)
existing_step = state.get("current_step", "")
mid_intake = existing_step.startswith("asking_") or existing_step.endswith("_collected")
new_step = existing_step if mid_intake else f"supervisor_routed_{intent}"
return {
"current_step": new_step,
"vehicle_type": vehicle_type if vehicle_type else state.get("vehicle_type"),
# Kapsam dışı mesaj varsa hemen yanıtla
"messages": (
state["messages"] + [AIMessage(content=MSG_OUT_OF_SCOPE)]
if intent == "out_of_scope"
else state["messages"]
),
"flow_ended": intent == "out_of_scope",
"_supervisor_intent": intent,
}
_FINANCE_KEYWORDS = (
"finansman", "kredi", "fatura", "kasko", "kefil", "vade", "faiz", "taksit",
"hgs", "araç", "arac", "model", "yaş", "yas", "tckn", "sigorta", "ödeme",
"odeme", "evrak", "belge", "başvuru", "basvuru", "limit", "proforma", "satıcı", "satici",
)
# "milyon", "tl", "tutar", "bin tl" kaldırıldı — saf sayı ifadelerinde false positive yaratıyor
_QUESTION_MARKERS = (
"?", " mi ", " mı ", " mu ", " mü ", "miyim", "mıyım", "muyum", "müyüm",
" mi?", " mı?", " mu?", " mü?",
"nedir", "ne kadar", "kaç", "nasıl", "ne olur", "var mı", "var mi",
)
def _looks_like_finance_question(text: str) -> bool:
"""Mesaj araç finansmanı bağlamında bir soru gibi mi görünüyor?"""
if not text:
return False
lower = text.lower() + " " # sondaki boşluk: "...mu" gibi cümle sonu eklerini yakala
has_question = any(m in lower for m in _QUESTION_MARKERS)
has_finance = any(k in lower for k in _FINANCE_KEYWORDS)
return has_question and has_finance