Spaces:
Sleeping
Sleeping
| 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 | |
| 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, | |
| } | |
| 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 | |
| def turn_index(self) -> int: | |
| return self._turn_index | |
| 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 | |
| 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 | |
| def last_query_mode(self) -> Optional[str]: | |
| return self._last_query_mode | |
| 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(), | |
| } | |
| 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 | |