This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/timmy/conversation.py
2026-03-20 16:31:34 -04:00

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()