forked from Rockachopa/Timmy-time-dashboard
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
264 lines
7.8 KiB
Python
264 lines
7.8 KiB
Python
"""Conversation context management for Timmy.
|
|
|
|
Tracks conversation state, intent, and context to improve:
|
|
- Contextual understanding across multi-turn conversations
|
|
- Smarter tool usage decisions
|
|
- Natural reference to prior exchanges
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ConversationContext:
|
|
"""Tracks the current conversation state."""
|
|
|
|
user_name: str | None = None
|
|
current_topic: str | None = None
|
|
last_intent: str | None = None
|
|
turn_count: int = 0
|
|
started_at: datetime = field(default_factory=datetime.now)
|
|
|
|
def update_topic(self, topic: str) -> None:
|
|
"""Update the current conversation topic."""
|
|
self.current_topic = topic
|
|
self.turn_count += 1
|
|
|
|
def set_user_name(self, name: str) -> None:
|
|
"""Remember the user's name."""
|
|
self.user_name = name
|
|
logger.info("User name set to: %s", name)
|
|
|
|
def get_context_summary(self) -> str:
|
|
"""Generate a context summary for the prompt."""
|
|
parts = []
|
|
if self.user_name:
|
|
parts.append(f"User's name is {self.user_name}")
|
|
if self.current_topic:
|
|
parts.append(f"Current topic: {self.current_topic}")
|
|
if self.turn_count > 0:
|
|
parts.append(f"Conversation turn: {self.turn_count}")
|
|
return " | ".join(parts) if parts else ""
|
|
|
|
|
|
class ConversationManager:
|
|
"""Manages conversation context across sessions."""
|
|
|
|
def __init__(self) -> None:
|
|
self._contexts: dict[str, ConversationContext] = {}
|
|
|
|
def get_context(self, session_id: str) -> ConversationContext:
|
|
"""Get or create context for a session."""
|
|
if session_id not in self._contexts:
|
|
self._contexts[session_id] = ConversationContext()
|
|
return self._contexts[session_id]
|
|
|
|
def clear_context(self, session_id: str) -> None:
|
|
"""Clear context for a session."""
|
|
if session_id in self._contexts:
|
|
del self._contexts[session_id]
|
|
|
|
# Words that look like names but are actually verbs/UI states
|
|
_NAME_BLOCKLIST = frozenset(
|
|
{
|
|
"sending",
|
|
"loading",
|
|
"pending",
|
|
"processing",
|
|
"typing",
|
|
"working",
|
|
"going",
|
|
"trying",
|
|
"looking",
|
|
"getting",
|
|
"doing",
|
|
"waiting",
|
|
"running",
|
|
"checking",
|
|
"coming",
|
|
"leaving",
|
|
"thinking",
|
|
"reading",
|
|
"writing",
|
|
"watching",
|
|
"listening",
|
|
"playing",
|
|
"eating",
|
|
"sleeping",
|
|
"sitting",
|
|
"standing",
|
|
"walking",
|
|
"talking",
|
|
"asking",
|
|
"telling",
|
|
"feeling",
|
|
"hoping",
|
|
"wondering",
|
|
"glad",
|
|
"happy",
|
|
"sorry",
|
|
"sure",
|
|
"fine",
|
|
"good",
|
|
"great",
|
|
"okay",
|
|
"here",
|
|
"there",
|
|
"back",
|
|
"done",
|
|
"ready",
|
|
"busy",
|
|
"free",
|
|
"available",
|
|
"interested",
|
|
"confused",
|
|
"lost",
|
|
"stuck",
|
|
"curious",
|
|
"excited",
|
|
"tired",
|
|
"not",
|
|
"also",
|
|
"just",
|
|
"still",
|
|
"already",
|
|
"currently",
|
|
}
|
|
)
|
|
|
|
# Verb/adjective suffixes that never appear on real names
|
|
_NON_NAME_SUFFIXES = ("ing", "tion", "sion", "ness", "ment", "ful", "less", "ous", "ive", "ble")
|
|
|
|
def extract_user_name(self, message: str) -> str | None:
|
|
"""Try to extract user's name from message.
|
|
|
|
Requires the candidate word to be capitalized in the original
|
|
message (real names are written with a capital letter). Also
|
|
rejects words in the blocklist and common verb/adjective suffixes.
|
|
"""
|
|
message_lower = message.lower()
|
|
|
|
# Common patterns
|
|
patterns = [
|
|
"my name is ",
|
|
"i'm ",
|
|
"i am ",
|
|
"call me ",
|
|
]
|
|
|
|
for pattern in patterns:
|
|
if pattern in message_lower:
|
|
idx = message_lower.find(pattern) + len(pattern)
|
|
remainder = message[idx:].strip()
|
|
if not remainder:
|
|
continue
|
|
# Take first word as name (from original message for case info)
|
|
raw_name = remainder.split()[0].strip(".,!?;:")
|
|
if not raw_name or len(raw_name) < 2:
|
|
continue
|
|
# Require first letter to be uppercase in the original text
|
|
# (names are capitalized; "I am serving..." is not a name)
|
|
if not raw_name[0].isupper():
|
|
continue
|
|
# Reject common verbs, adjectives, and UI-state words
|
|
if raw_name.lower() in self._NAME_BLOCKLIST:
|
|
continue
|
|
# Reject words with verb/adjective suffixes
|
|
if any(raw_name.lower().endswith(s) for s in self._NON_NAME_SUFFIXES):
|
|
continue
|
|
return raw_name.capitalize()
|
|
|
|
return None
|
|
|
|
_TOOL_KEYWORDS = frozenset(
|
|
{
|
|
"search",
|
|
"look up",
|
|
"find",
|
|
"google",
|
|
"current price",
|
|
"latest",
|
|
"today's",
|
|
"news",
|
|
"weather",
|
|
"stock price",
|
|
"read file",
|
|
"write file",
|
|
"save",
|
|
"calculate",
|
|
"compute",
|
|
"run ",
|
|
"execute",
|
|
"shell",
|
|
"command",
|
|
"install",
|
|
}
|
|
)
|
|
|
|
_CHAT_ONLY_KEYWORDS = frozenset(
|
|
{
|
|
"hello",
|
|
"hi ",
|
|
"hey",
|
|
"how are you",
|
|
"what's up",
|
|
"your name",
|
|
"who are you",
|
|
"what are you",
|
|
"thanks",
|
|
"thank you",
|
|
"bye",
|
|
"goodbye",
|
|
"tell me about yourself",
|
|
"what can you do",
|
|
}
|
|
)
|
|
|
|
_SIMPLE_QUESTION_PREFIXES = ("what is", "who is", "how does", "why is", "when did", "where is")
|
|
_TIME_WORDS = ("today", "now", "current", "latest", "this week", "this month")
|
|
|
|
def _is_chat_only(self, message_lower: str) -> bool:
|
|
"""Return True if the message matches a chat-only pattern."""
|
|
return any(kw in message_lower for kw in self._CHAT_ONLY_KEYWORDS)
|
|
|
|
def _has_tool_keyword(self, message_lower: str) -> bool:
|
|
"""Return True if the message contains a tool-related keyword."""
|
|
return any(kw in message_lower for kw in self._TOOL_KEYWORDS)
|
|
|
|
def _is_simple_question(self, message_lower: str) -> bool | None:
|
|
"""Check if message is a simple question.
|
|
|
|
Returns True if it needs tools (real-time info), False if it
|
|
doesn't, or None if the message isn't a simple question.
|
|
"""
|
|
for prefix in self._SIMPLE_QUESTION_PREFIXES:
|
|
if message_lower.startswith(prefix):
|
|
return any(t in message_lower for t in self._TIME_WORDS)
|
|
return None
|
|
|
|
def should_use_tools(self, message: str, context: ConversationContext) -> bool:
|
|
"""Determine if this message likely requires tools.
|
|
|
|
Returns True if tools are likely needed, False for simple chat.
|
|
"""
|
|
message_lower = message.lower().strip()
|
|
|
|
if self._is_chat_only(message_lower):
|
|
return False
|
|
if self._has_tool_keyword(message_lower):
|
|
return True
|
|
|
|
simple = self._is_simple_question(message_lower)
|
|
if simple is not None:
|
|
return simple
|
|
|
|
return False
|
|
|
|
|
|
# Module-level singleton
|
|
conversation_manager = ConversationManager()
|