forked from Rockachopa/Timmy-time-dashboard
162 lines
5.2 KiB
Python
162 lines
5.2 KiB
Python
"""Persistent chat session for Timmy.
|
|
|
|
Holds a singleton Agno Agent and a stable session_id so conversation
|
|
history persists across HTTP requests via Agno's SQLite storage.
|
|
|
|
This is the primary entry point for dashboard chat — instead of
|
|
creating a new agent per request, we reuse a single instance and
|
|
let Agno's session_id mechanism handle conversation continuity.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default session ID for the dashboard (stable across requests)
|
|
_DEFAULT_SESSION_ID = "dashboard"
|
|
|
|
# Module-level singleton agent (lazy-initialized, reused for all requests)
|
|
_agent = None
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response sanitization patterns
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Matches raw JSON tool calls: {"name": "python", "parameters": {...}}
|
|
_TOOL_CALL_JSON = re.compile(
|
|
r'\{\s*"name"\s*:\s*"[^"]+?"\s*,\s*"parameters"\s*:\s*\{.*?\}\s*\}',
|
|
re.DOTALL,
|
|
)
|
|
|
|
# Matches function-call-style text: memory_search(query="...") etc.
|
|
_FUNC_CALL_TEXT = re.compile(
|
|
r"\b(?:memory_search|web_search|shell|python|read_file|write_file|list_files|calculator)"
|
|
r"\s*\([^)]*\)",
|
|
)
|
|
|
|
# Matches chain-of-thought narration lines the model should keep internal
|
|
_COT_PATTERNS = [
|
|
re.compile(
|
|
r"^(?:Since |Using |Let me |I'll use |I will use |Here's a possible ).*$", re.MULTILINE
|
|
),
|
|
re.compile(r"^(?:I found a relevant |This context suggests ).*$", re.MULTILINE),
|
|
]
|
|
|
|
|
|
def _get_agent():
|
|
"""Lazy-initialize the singleton agent."""
|
|
global _agent
|
|
if _agent is None:
|
|
from timmy.agent import create_timmy
|
|
|
|
try:
|
|
_agent = create_timmy()
|
|
logger.info("Session: Timmy agent initialized (singleton)")
|
|
except Exception as exc:
|
|
logger.error("Session: Failed to create Timmy agent: %s", exc)
|
|
raise
|
|
return _agent
|
|
|
|
|
|
def chat(message: str, session_id: Optional[str] = None) -> str:
|
|
"""Send a message to Timmy and get a response.
|
|
|
|
Uses a persistent agent and session_id so Agno's SQLite history
|
|
provides multi-turn conversation context.
|
|
|
|
Args:
|
|
message: The user's message.
|
|
session_id: Optional session identifier (defaults to "dashboard").
|
|
|
|
Returns:
|
|
The agent's response text.
|
|
"""
|
|
sid = session_id or _DEFAULT_SESSION_ID
|
|
agent = _get_agent()
|
|
|
|
# Pre-processing: extract user facts
|
|
_extract_facts(message)
|
|
|
|
# Run with session_id so Agno retrieves history from SQLite
|
|
try:
|
|
run = agent.run(message, stream=False, session_id=sid)
|
|
response_text = run.content if hasattr(run, "content") else str(run)
|
|
except Exception as exc:
|
|
logger.error("Session: agent.run() failed: %s", exc)
|
|
return "I'm having trouble reaching my language model right now. Please try again shortly."
|
|
|
|
# Post-processing: clean up any leaked tool calls or chain-of-thought
|
|
response_text = _clean_response(response_text)
|
|
|
|
return response_text
|
|
|
|
|
|
def reset_session(session_id: Optional[str] = None) -> None:
|
|
"""Reset a session (clear conversation context).
|
|
|
|
This clears the ConversationManager state. Agno's SQLite history
|
|
is not cleared — that provides long-term continuity.
|
|
"""
|
|
sid = session_id or _DEFAULT_SESSION_ID
|
|
try:
|
|
from timmy.conversation import conversation_manager
|
|
|
|
conversation_manager.clear_context(sid)
|
|
except Exception as exc:
|
|
logger.debug("Session: context clear failed for %s: %s", sid, exc)
|
|
|
|
|
|
def _extract_facts(message: str) -> None:
|
|
"""Extract user facts from message and persist to memory system.
|
|
|
|
Ported from TimmyWithMemory._extract_and_store_facts().
|
|
Runs as a best-effort post-processor — failures are logged, not raised.
|
|
"""
|
|
try:
|
|
from timmy.conversation import conversation_manager
|
|
|
|
name = conversation_manager.extract_user_name(message)
|
|
if name:
|
|
try:
|
|
from timmy.memory_system import memory_system
|
|
|
|
memory_system.update_user_fact("Name", name)
|
|
logger.info("Session: Learned user name: %s", name)
|
|
except Exception as exc:
|
|
logger.debug("Session: fact persist failed: %s", exc)
|
|
except Exception as exc:
|
|
logger.debug("Session: Fact extraction skipped: %s", exc)
|
|
|
|
|
|
def _clean_response(text: str) -> str:
|
|
"""Remove hallucinated tool calls and chain-of-thought narration.
|
|
|
|
Small models sometimes output raw JSON tool calls or narrate their
|
|
internal reasoning instead of just answering. This strips those
|
|
artifacts from the response.
|
|
"""
|
|
if not text:
|
|
return text
|
|
|
|
# Convert literal \n escape sequences to actual newlines
|
|
# (models sometimes output these in tool-result text)
|
|
text = text.replace("\\n", "\n")
|
|
|
|
# Strip JSON tool call blocks
|
|
text = _TOOL_CALL_JSON.sub("", text)
|
|
|
|
# Strip function-call-style text
|
|
text = _FUNC_CALL_TEXT.sub("", text)
|
|
|
|
# Strip chain-of-thought narration lines
|
|
for pattern in _COT_PATTERNS:
|
|
text = pattern.sub("", text)
|
|
|
|
# Clean up leftover blank lines and whitespace
|
|
lines = [line for line in text.split("\n") if line.strip()]
|
|
text = "\n".join(lines)
|
|
|
|
return text.strip()
|