import logging import re import time from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from database.view_manager import IdentifierType logger = logging.getLogger(__name__) ENTITY_TYPE_MAP = { IdentifierType.CREDIT_FILE_ID: "credit_account", IdentifierType.LEAD_ID: "lead", IdentifierType.PLATE_NUMBER: "vehicle", } _FOLLOWUP_PRONOUNS_RE = re.compile( r"\b(bu|bunu|bunun|buna|bunlar|bunlarin|o|onu|onun|ona|onlar|" r"su|sunu|sunun|ayni|diger|bahsettigim|bahsettigin|onceki|yukardaki|" r"it|its|this|that|the\s+account|the\s+loan|the\s+record|" r"his|her|their|the\s+customer|the\s+client)\b", re.IGNORECASE, ) _FOLLOWUP_PHRASES_RE = re.compile( r"(ne durumda|ne oldu|durumu ne|durumu nedir|sonucu ne|sonucu nedir|" r"onayi verildi mi|onaylandi mi|tamamlandi mi|" r"hala ayni mi|degisti mi|değişti mi|güncel durumu|guncel durumu|" r"son durum|mevcut durum|simdiki durum|şimdiki durum|" r"bilgisi ne|bilgisi nedir|bilgilerini goster|bilgilerini göster)", re.IGNORECASE, ) _DOCUMENT_INDICATOR_RE = re.compile( r"(nasil yapilir|nasıl yapılır|nedir acikla|ne demek|" r"surec|süreç|süreçleri|prosedur|prosedür|" r"adimlari|adımları|adimlari neler|" r"nasil.*hesaplanir|nasıl.*hesaplanır|nasil.*calisir|nasıl.*çalışır|" r"dokuman|doküman|belgede|belgeler)", re.IGNORECASE, ) _SHORT_QUERY_THRESHOLD = 60 @dataclass class EntityContext: entity_type: str identifier_type: IdentifierType identifier_value: str data_payload: Optional[Any] = None response_text: Optional[str] = None query_type: str = "database" turn_index: int = 0 timestamp: float = field(default_factory=time.time) view_name: Optional[str] = None sql_query: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return { "entity_type": self.entity_type, "identifier_type": self.identifier_type.value, "identifier_value": self.identifier_value, "query_type": self.query_type, "turn_index": self.turn_index, "timestamp": self.timestamp, "view_name": self.view_name, } @dataclass class FollowUpDetection: is_followup: bool confidence: float = 0.0 signals: List[str] = field(default_factory=list) resolved_identifier_type: Optional[IdentifierType] = None resolved_identifier_value: Optional[str] = None entity_context: Optional[EntityContext] = None needs_clarification: bool = False def to_dict(self) -> Dict[str, Any]: return { "is_followup": self.is_followup, "confidence": round(self.confidence, 3), "signals": self.signals, "resolved_identifier_type": self.resolved_identifier_type.value if self.resolved_identifier_type else None, "resolved_identifier_value": self.resolved_identifier_value, "needs_clarification": self.needs_clarification, } class SessionEntityStore: def __init__(self, expiry_turns: int = 10, expiry_seconds: float = 600.0, secondary_retention_turns: int = 5): self._primary: Optional[EntityContext] = None self._secondary: Optional[EntityContext] = None self._turn_index: int = 0 self._expiry_turns = expiry_turns self._expiry_seconds = expiry_seconds self._secondary_retention_turns = secondary_retention_turns self._has_had_context: bool = False self._last_aggregate: Optional[Dict[str, Any]] = None self._last_query_mode: Optional[str] = None @property def turn_index(self) -> int: return self._turn_index @property def primary(self) -> Optional[EntityContext]: if self._primary and self._is_expired(self._primary): logger.debug("primary entity context expired at turn %d", self._turn_index) self._primary = None return self._primary @property def secondary(self) -> Optional[EntityContext]: if self._secondary and self._is_expired(self._secondary, use_secondary=True): self._secondary = None return self._secondary def advance_turn(self) -> None: self._turn_index += 1 @property def last_query_mode(self) -> Optional[str]: return self._last_query_mode @last_query_mode.setter def last_query_mode(self, mode: str) -> None: self._last_query_mode = mode def has_active_context(self) -> bool: return self.primary is not None def update_entity( self, identifier_type: IdentifierType, identifier_value: str, data_payload: Optional[Any] = None, response_text: Optional[str] = None, query_type: str = "database", view_name: Optional[str] = None, sql_query: Optional[str] = None, ) -> None: entity_type = ENTITY_TYPE_MAP.get(identifier_type, "unknown") now = time.time() if self._primary and self._primary.identifier_type == identifier_type and self._primary.identifier_value == identifier_value: self._primary.data_payload = data_payload if data_payload is not None else self._primary.data_payload self._primary.response_text = response_text or self._primary.response_text self._primary.turn_index = self._turn_index self._primary.timestamp = now self._primary.view_name = view_name or self._primary.view_name self._primary.sql_query = sql_query or self._primary.sql_query logger.info("updated existing entity context %s=%s at turn %d", identifier_type.value, identifier_value, self._turn_index) return if self._primary and self._primary.identifier_type != identifier_type: self._secondary = self._primary logger.debug("moved primary to secondary: %s=%s", self._primary.identifier_type.value, self._primary.identifier_value) self._primary = EntityContext( entity_type=entity_type, identifier_type=identifier_type, identifier_value=identifier_value, data_payload=data_payload, response_text=response_text, query_type=query_type, turn_index=self._turn_index, timestamp=now, view_name=view_name, sql_query=sql_query, ) self._has_had_context = True logger.info("set primary entity context %s=%s at turn %d", identifier_type.value, identifier_value, self._turn_index) def update_turn_only(self) -> None: if self._primary: self._primary.turn_index = self._turn_index self._primary.timestamp = time.time() def update_aggregate(self, view_name: str, response_text: str, sql_query: Optional[str] = None) -> None: self._last_aggregate = { "view_name": view_name, "response_text": response_text, "sql_query": sql_query, "turn_index": self._turn_index, "timestamp": time.time(), } @property def last_aggregate(self) -> Optional[Dict[str, Any]]: if self._last_aggregate is None: return None turns_since = self._turn_index - self._last_aggregate["turn_index"] if turns_since > self._expiry_turns: self._last_aggregate = None return None return self._last_aggregate def detect_followup(self, query: str) -> FollowUpDetection: from database.view_manager import extract_identifier, has_view_specific_keywords _, id_val = extract_identifier(query) if id_val: return FollowUpDetection(is_followup=False, confidence=0.0, signals=["explicit_id_present"]) signals: List[str] = [] confidence = 0.0 has_pronouns = bool(_FOLLOWUP_PRONOUNS_RE.search(query)) if has_pronouns: signals.append("pronoun_reference") confidence += 0.35 has_view_kw = has_view_specific_keywords(query) if has_view_kw: signals.append("view_attribute_keywords") confidence += 0.40 has_phrases = bool(_FOLLOWUP_PHRASES_RE.search(query)) if has_phrases: signals.append("followup_phrase") confidence += 0.45 is_short = len(query.strip()) < _SHORT_QUERY_THRESHOLD if is_short: signals.append("short_query") confidence += 0.10 has_doc_signal = bool(_DOCUMENT_INDICATOR_RE.search(query)) if has_doc_signal: signals.append("document_indicator") confidence -= 0.30 signals.append("no_explicit_id") confidence += 0.05 has_strong_signal = has_pronouns or has_view_kw or has_phrases looks_like_followup = confidence >= 0.5 and has_strong_signal if not looks_like_followup: return FollowUpDetection(is_followup=False, confidence=max(confidence, 0.0), signals=signals) if not self.has_active_context(): if not self._has_had_context: return FollowUpDetection(is_followup=False, confidence=max(confidence, 0.0), signals=signals + ["no_prior_context"]) logger.info("followup detected but active context expired, requesting clarification") return FollowUpDetection( is_followup=True, confidence=min(confidence, 1.0), signals=signals + ["no_active_context"], needs_clarification=True, ) ctx = self._resolve_best_entity(query) logger.info( "followup resolved: %s=%s confidence=%.2f signals=%s", ctx.identifier_type.value, ctx.identifier_value, confidence, signals, ) return FollowUpDetection( is_followup=True, confidence=min(confidence, 1.0), signals=signals, resolved_identifier_type=ctx.identifier_type, resolved_identifier_value=ctx.identifier_value, entity_context=ctx, ) def _resolve_best_entity(self, query: str) -> EntityContext: if not self.secondary: return self._primary q_lower = query.lower() _VEHICLE_HINTS = {"plaka", "arac", "araç", "rehin", "lien", "marka", "model"} _LEAD_HINTS = {"lead", "basvuru", "başvuru", "musteri", "müşteri"} _CREDIT_HINTS = {"teklif", "dosya", "kredi", "statu", "statü", "odeme", "ödeme", "pesinat", "peşinat", "karar"} secondary = self._secondary TYPE_KEYWORDS = { IdentifierType.PLATE_NUMBER: _VEHICLE_HINTS, IdentifierType.LEAD_ID: _LEAD_HINTS, IdentifierType.CREDIT_FILE_ID: _CREDIT_HINTS, } secondary_hints = TYPE_KEYWORDS.get(secondary.identifier_type, set()) if any(h in q_lower for h in secondary_hints): primary_hints = TYPE_KEYWORDS.get(self._primary.identifier_type, set()) if not any(h in q_lower for h in primary_hints): logger.debug("followup resolved to secondary entity %s=%s", secondary.identifier_type.value, secondary.identifier_value) return secondary return self._primary def build_context_preamble(self) -> str: parts = [] if self.has_active_context(): ctx = self._primary parts.append(f"AKTIF VARLIK: {ctx.entity_type} (Tip: {ctx.identifier_type.value}, ID: {ctx.identifier_value})") if ctx.response_text: trimmed = ctx.response_text[:500] parts.append(f"ONCEKI YANIT: {trimmed}") agg = self.last_aggregate if agg: parts.append(f"SON TOPLU SORGU: {agg['view_name']}") if agg.get("response_text"): trimmed = agg["response_text"][:500] parts.append(f"TOPLU SORGU YANITI: {trimmed}") return "\n".join(parts) def clear(self) -> None: self._primary = None self._secondary = None self._turn_index = 0 self._has_had_context = False self._last_aggregate = None self._last_query_mode = None def _is_expired(self, ctx: EntityContext, use_secondary: bool = False) -> bool: turns_since = self._turn_index - ctx.turn_index max_turns = self._secondary_retention_turns if use_secondary else self._expiry_turns if turns_since > max_turns: return True if time.time() - ctx.timestamp > self._expiry_seconds: return True return False def to_dict(self) -> Dict[str, Any]: return { "turn_index": self._turn_index, "primary": self._primary.to_dict() if self._primary else None, "secondary": self._secondary.to_dict() if self._secondary else None, "expiry_turns": self._expiry_turns, "expiry_seconds": self._expiry_seconds, "last_aggregate": self._last_aggregate, } _store_instance: Optional[SessionEntityStore] = None def get_entity_store() -> SessionEntityStore: global _store_instance if _store_instance is None: from config import ENTITY_CONTEXT_EXPIRY_TURNS, ENTITY_CONTEXT_EXPIRY_SECONDS, ENTITY_CONTEXT_SECONDARY_RETENTION_TURNS _store_instance = SessionEntityStore( expiry_turns=ENTITY_CONTEXT_EXPIRY_TURNS, expiry_seconds=ENTITY_CONTEXT_EXPIRY_SECONDS, secondary_retention_turns=ENTITY_CONTEXT_SECONDARY_RETENTION_TURNS, ) return _store_instance def reset_entity_store() -> None: global _store_instance if _store_instance is not None: _store_instance.clear() _store_instance = None