aa
This commit is contained in:
parent
f355194c81
commit
918f3c39b7
252
gurt/analysis.py
252
gurt/analysis.py
@ -228,13 +228,53 @@ def identify_conversation_topics(cog: 'GurtCog', messages: List[Dict[str, Any]])
|
||||
total_messages = len(messages)
|
||||
ngram_scores = {}
|
||||
for ngram, count in filtered_ngrams.items():
|
||||
# Calculate score based on frequency, length, and spread across messages
|
||||
message_count = sum(1 for msg in messages if ngram in msg["content"].lower())
|
||||
spread_factor = message_count / total_messages
|
||||
importance = count * (0.5 + spread_factor)
|
||||
spread_factor = (message_count / total_messages) ** 0.5 # Less emphasis on spread
|
||||
length_bonus = len(ngram.split()) * 0.1 # Slight bonus for longer ngrams
|
||||
# Adjust importance calculation
|
||||
importance = (count * (0.4 + spread_factor)) + length_bonus
|
||||
ngram_scores[ngram] = importance
|
||||
|
||||
topics = []
|
||||
processed_ngrams = set()
|
||||
# Filter out sub-ngrams that are part of higher-scoring ngrams before sorting
|
||||
sorted_by_score = sorted(ngram_scores.items(), key=lambda x: x[1], reverse=True)
|
||||
ngrams_to_consider = []
|
||||
temp_processed = set()
|
||||
for ngram, score in sorted_by_score:
|
||||
is_subgram = False
|
||||
for other_ngram, _ in sorted_by_score:
|
||||
if ngram != other_ngram and ngram in other_ngram:
|
||||
is_subgram = True
|
||||
break
|
||||
if not is_subgram and ngram not in temp_processed:
|
||||
ngrams_to_consider.append((ngram, score))
|
||||
temp_processed.add(ngram) # Avoid adding duplicates if logic changes
|
||||
|
||||
# Now process the filtered ngrams
|
||||
sorted_ngrams = ngrams_to_consider # Use the filtered list
|
||||
|
||||
for ngram, score in sorted_ngrams[:10]: # Consider top 10 potential topics after filtering
|
||||
if ngram in processed_ngrams: continue
|
||||
related_terms = []
|
||||
# Find related terms (sub-ngrams or overlapping ngrams from the original sorted list)
|
||||
for other_ngram, other_score in sorted_by_score: # Search in original sorted list for relations
|
||||
if other_ngram == ngram or other_ngram in processed_ngrams: continue
|
||||
ngram_words = set(ngram.split()); other_words = set(other_ngram.split())
|
||||
# Check for overlap or if one is a sub-string (more lenient relation)
|
||||
if ngram_words.intersection(other_words) or other_ngram in ngram:
|
||||
related_terms.append({"term": other_ngram, "score": other_score})
|
||||
# Don't mark related terms as fully processed here unless they are direct sub-ngrams
|
||||
# processed_ngrams.add(other_ngram)
|
||||
if len(related_terms) >= 3: break # Limit related terms shown
|
||||
processed_ngrams.add(ngram)
|
||||
topic_entry = {"topic": ngram, "score": score, "related_terms": related_terms, "message_count": sum(1 for msg in messages if ngram in msg["content"].lower())}
|
||||
topics.append(topic_entry)
|
||||
if len(topics) >= MAX_ACTIVE_TOPICS: break # Use config for max topics
|
||||
|
||||
# Simple sentiment analysis for topics
|
||||
positive_words = {"good", "great", "awesome", "amazing", "excellent", "love", "like", "best", "better", "nice", "cool"}
|
||||
sorted_ngrams = sorted(ngram_scores.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
for ngram, score in sorted_ngrams[:15]:
|
||||
@ -323,19 +363,71 @@ async def evolve_personality(cog: 'GurtCog'):
|
||||
avg_neg_intensity = negative_sentiment_score / sentiment_channels_count if sentiment_channels_count > 0 else 0
|
||||
print(f"Evolution Analysis: Avg Pos Intensity={avg_pos_intensity:.2f}, Avg Neg Intensity={avg_neg_intensity:.2f}")
|
||||
|
||||
# Placeholder for tool usage analysis
|
||||
recent_web_searches = 0; recent_timeouts = 0
|
||||
print(f"Evolution Analysis: Recent Web Searches={recent_web_searches}, Timeouts={recent_timeouts} (Placeholders)")
|
||||
# --- Analyze Tool Usage ---
|
||||
tool_success_rate = {}
|
||||
total_tool_uses = 0
|
||||
successful_tool_uses = 0
|
||||
for tool_name, stats in cog.tool_stats.items():
|
||||
count = stats.get('count', 0)
|
||||
success = stats.get('success', 0)
|
||||
if count > 0:
|
||||
tool_success_rate[tool_name] = success / count
|
||||
total_tool_uses += count
|
||||
successful_tool_uses += success
|
||||
overall_tool_success_rate = successful_tool_uses / total_tool_uses if total_tool_uses > 0 else 0.5 # Default to neutral if no uses
|
||||
print(f"Evolution Analysis: Overall Tool Success Rate={overall_tool_success_rate:.2f} ({successful_tool_uses}/{total_tool_uses})")
|
||||
# Example: Log specific tool rates if needed
|
||||
# print(f"Evolution Analysis: Tool Success Rates: {tool_success_rate}")
|
||||
|
||||
# --- Analyze Response Effectiveness (Reactions) ---
|
||||
positive_reactions = 0
|
||||
negative_reactions = 0
|
||||
total_reacted_messages = len(cog.gurt_message_reactions)
|
||||
for msg_id, reaction_data in cog.gurt_message_reactions.items():
|
||||
positive_reactions += reaction_data.get("positive", 0)
|
||||
negative_reactions += reaction_data.get("negative", 0)
|
||||
reaction_ratio = positive_reactions / (positive_reactions + negative_reactions) if (positive_reactions + negative_reactions) > 0 else 0.5 # Default neutral
|
||||
print(f"Evolution Analysis: Reaction Ratio (Pos/Total)={reaction_ratio:.2f} ({positive_reactions}/{positive_reactions + negative_reactions})")
|
||||
|
||||
# --- Calculate Trait Adjustments ---
|
||||
trait_changes = {}
|
||||
local_learning_rate = 0.02 # Use local variable
|
||||
|
||||
# Optimism
|
||||
if avg_pos_intensity > avg_neg_intensity + 0.1: trait_changes['optimism'] = min(1.0, current_traits.get('optimism', 0.5) + 0.1)
|
||||
elif avg_neg_intensity > avg_pos_intensity + 0.1: trait_changes['optimism'] = max(0.0, current_traits.get('optimism', 0.5) - 0.1)
|
||||
# Add rules for other traits based on analysis (e.g., tool usage, interaction types)
|
||||
# Optimism (based on sentiment)
|
||||
optimism_target = 0.5 + (avg_pos_intensity - avg_neg_intensity) * 0.5 # Scale sentiment difference to -0.5 to +0.5 range
|
||||
trait_changes['optimism'] = max(0.0, min(1.0, optimism_target)) # Target value directly, learning rate applied later
|
||||
|
||||
# Mischief (based on timeout usage success/reactions)
|
||||
timeout_uses = cog.tool_stats.get("timeout_user", {}).get("count", 0)
|
||||
timeout_success_rate = tool_success_rate.get("timeout_user", 0.5)
|
||||
if timeout_uses > 2: # Only adjust if used a few times
|
||||
# Increase mischief if timeouts are successful and reactions aren't overly negative
|
||||
mischief_target_adjustment = (timeout_success_rate - 0.5) * 0.2 + (reaction_ratio - 0.5) * 0.1
|
||||
current_mischief = current_traits.get('mischief', 0.5)
|
||||
trait_changes['mischief'] = max(0.0, min(1.0, current_mischief + mischief_target_adjustment))
|
||||
|
||||
# Curiosity (based on web search usage)
|
||||
search_uses = cog.tool_stats.get("web_search", {}).get("count", 0)
|
||||
if search_uses > 1: # If search is used
|
||||
current_curiosity = current_traits.get('curiosity', 0.6)
|
||||
# Slightly increase curiosity if search is used, decrease slightly if not? (Needs refinement)
|
||||
trait_changes['curiosity'] = max(0.0, min(1.0, current_curiosity + 0.05)) # Simple boost for now
|
||||
|
||||
# Sarcasm (increase if reactions are positive despite negative sentiment?) - Complex, placeholder
|
||||
# current_sarcasm = current_traits.get('sarcasm_level', 0.3)
|
||||
# if reaction_ratio > 0.6 and avg_neg_intensity > 0.3: # Positive reactions despite negative context?
|
||||
# trait_changes['sarcasm_level'] = max(0.0, min(1.0, current_sarcasm + 0.05))
|
||||
|
||||
# Verbosity/Chattiness (based on reactions to own messages?) - Needs better tracking
|
||||
# If Gurt's messages get good reactions, maybe increase chattiness/verbosity slightly?
|
||||
# current_chattiness = current_traits.get('chattiness', 0.7)
|
||||
# if reaction_ratio > 0.65 and total_reacted_messages > 5:
|
||||
# trait_changes['chattiness'] = max(0.1, min(1.0, current_chattiness + 0.03))
|
||||
|
||||
|
||||
# --- Apply Calculated Changes ---
|
||||
updated_count = 0
|
||||
print(f"Calculated Trait Target Changes: {trait_changes}")
|
||||
for key, target_value in trait_changes.items():
|
||||
current_value = current_traits.get(key)
|
||||
if current_value is None: print(f"Evolution Warning: Trait '{key}' not found."); continue
|
||||
@ -354,6 +446,148 @@ async def evolve_personality(cog: 'GurtCog'):
|
||||
|
||||
except Exception as e: print(f"Error during personality evolution: {e}"); traceback.print_exc()
|
||||
|
||||
async def reflect_on_memories(cog: 'GurtCog'):
|
||||
"""Periodically reviews memories to synthesize insights or consolidate information."""
|
||||
print("Starting memory reflection cycle...")
|
||||
try:
|
||||
# --- Configuration ---
|
||||
REFLECTION_INTERVAL_HOURS = 6 # How often to reflect
|
||||
FACTS_TO_REVIEW_PER_USER = 15
|
||||
GENERAL_FACTS_TO_REVIEW = 30
|
||||
MIN_FACTS_FOR_REFLECTION = 5
|
||||
SYNTHESIS_MODEL = cog.fallback_model # Use a potentially cheaper model
|
||||
SYNTHESIS_MAX_TOKENS = 200
|
||||
|
||||
# Check if enough time has passed (simple check, could be more robust)
|
||||
# This check might be better placed in the background task itself
|
||||
# For now, assume the background task calls this at the right interval
|
||||
|
||||
# --- User Fact Reflection ---
|
||||
print("Reflecting on user facts...")
|
||||
all_user_ids = await cog.memory_manager.get_all_user_ids_with_facts()
|
||||
users_reflected = 0
|
||||
for user_id in all_user_ids:
|
||||
try:
|
||||
user_facts = await cog.memory_manager.get_user_facts(user_id, limit=FACTS_TO_REVIEW_PER_USER) # Get recent facts
|
||||
if len(user_facts) < MIN_FACTS_FOR_REFLECTION: continue
|
||||
|
||||
user_info = await cog.bot.fetch_user(int(user_id)) # Get user info for name
|
||||
user_name = user_info.display_name if user_info else f"User {user_id}"
|
||||
|
||||
print(f" - Reflecting on {len(user_facts)} facts for {user_name}...")
|
||||
facts_text = "\n".join([f"- {fact}" for fact in user_facts])
|
||||
reflection_prompt = [
|
||||
{"role": "system", "content": f"Analyze the following facts about {user_name}. Identify potential patterns, contradictions, or synthesize a concise summary of key traits or interests. Focus on creating 1-2 new, insightful summary facts. Respond ONLY with JSON: {{ \"new_facts\": [\"fact1\", \"fact2\"], \"reasoning\": \"brief explanation\" }} or {{ \"new_facts\": [], \"reasoning\": \"No new insights.\" }}"},
|
||||
{"role": "user", "content": f"Facts:\n{facts_text}\n\nSynthesize insights:"}
|
||||
]
|
||||
synthesis_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_facts": {"type": "array", "items": {"type": "string"}},
|
||||
"reasoning": {"type": "string"}
|
||||
}, "required": ["new_facts", "reasoning"]
|
||||
}
|
||||
|
||||
from .api import get_internal_ai_json_response # Local import
|
||||
synthesis_result = await get_internal_ai_json_response(
|
||||
cog=cog,
|
||||
prompt_messages=reflection_prompt,
|
||||
task_description=f"User Fact Reflection ({user_name})",
|
||||
response_schema_dict=synthesis_schema,
|
||||
model_name=SYNTHESIS_MODEL,
|
||||
temperature=0.4,
|
||||
max_tokens=SYNTHESIS_MAX_TOKENS
|
||||
)
|
||||
|
||||
if synthesis_result and synthesis_result.get("new_facts"):
|
||||
added_count = 0
|
||||
for new_fact in synthesis_result["new_facts"]:
|
||||
if new_fact and len(new_fact) > 5: # Basic validation
|
||||
add_result = await cog.memory_manager.add_user_fact(user_id, f"[Synthesized] {new_fact}")
|
||||
if add_result.get("status") == "added": added_count += 1
|
||||
if added_count > 0:
|
||||
print(f" - Added {added_count} synthesized fact(s) for {user_name}. Reasoning: {synthesis_result.get('reasoning')}")
|
||||
users_reflected += 1
|
||||
# else: print(f" - No new insights synthesized for {user_name}.") # Optional log
|
||||
|
||||
except Exception as user_reflect_e:
|
||||
print(f" - Error reflecting on facts for user {user_id}: {user_reflect_e}")
|
||||
print(f"User fact reflection complete. Synthesized facts for {users_reflected} users.")
|
||||
|
||||
# --- General Fact Reflection (Example: Identify related topics) ---
|
||||
# This part is more complex and might require different strategies.
|
||||
# Example: Cluster facts semantically, summarize clusters.
|
||||
print("Reflecting on general facts (Placeholder - More complex)...")
|
||||
# general_facts = await cog.memory_manager.get_general_facts(limit=GENERAL_FACTS_TO_REVIEW)
|
||||
# if len(general_facts) > MIN_FACTS_FOR_REFLECTION:
|
||||
# # TODO: Implement clustering or summarization logic here
|
||||
# pass
|
||||
print("General fact reflection cycle finished (Placeholder).")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during memory reflection cycle: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
async def decompose_goal_into_steps(cog: 'GurtCog', goal_description: str) -> Optional[Dict[str, Any]]:
|
||||
"""Uses an AI call to break down a goal into achievable steps with potential tool usage."""
|
||||
logger.info(f"Decomposing goal: '{goal_description}'")
|
||||
from .config import GOAL_DECOMPOSITION_SCHEMA, TOOLS # Import schema and tools list for context
|
||||
from .api import get_internal_ai_json_response # Local import
|
||||
|
||||
# Provide context about available tools
|
||||
tool_descriptions = "\n".join([f"- {tool.name}: {tool.description}" for tool in TOOLS])
|
||||
system_prompt = (
|
||||
"You are Gurt's planning module. Your task is to break down a high-level goal into a sequence of smaller, "
|
||||
"concrete steps. For each step, determine if one of Gurt's available tools can help achieve it. "
|
||||
"Assess if the overall goal is achievable given the tools and typical Discord bot limitations. "
|
||||
f"Available Tools:\n{tool_descriptions}\n\n"
|
||||
"Respond ONLY with JSON matching the provided schema."
|
||||
)
|
||||
user_prompt = f"Goal: {goal_description}\n\nDecompose this goal into achievable steps:"
|
||||
|
||||
decomposition_prompt_messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
|
||||
try:
|
||||
plan = await get_internal_ai_json_response(
|
||||
cog=cog,
|
||||
prompt_messages=decomposition_prompt_messages,
|
||||
task_description=f"Goal Decomposition ({goal_description[:30]}...)",
|
||||
response_schema_dict=GOAL_DECOMPOSITION_SCHEMA['schema'],
|
||||
model_name=cog.fallback_model, # Use fallback model for planning potentially
|
||||
temperature=0.3,
|
||||
max_tokens=1000 # Allow more tokens for potentially complex plans
|
||||
)
|
||||
|
||||
if plan and plan.get("goal_achievable"):
|
||||
logger.info(f"Goal '{goal_description}' decomposed into {len(plan.get('steps', []))} steps.")
|
||||
# Basic validation of steps structure (optional but recommended)
|
||||
if isinstance(plan.get('steps'), list):
|
||||
for i, step in enumerate(plan['steps']):
|
||||
if not isinstance(step, dict) or 'step_description' not in step:
|
||||
logger.error(f"Invalid step structure at index {i} in decomposition plan: {step}")
|
||||
plan['goal_achievable'] = False
|
||||
plan['reasoning'] += " (Invalid step structure detected)"
|
||||
plan['steps'] = []
|
||||
break
|
||||
else:
|
||||
plan['steps'] = [] # Ensure steps is a list even if validation fails
|
||||
|
||||
return plan
|
||||
elif plan:
|
||||
logger.warning(f"Goal '{goal_description}' deemed not achievable. Reasoning: {plan.get('reasoning')}")
|
||||
return plan # Return the plan indicating it's not achievable
|
||||
else:
|
||||
logger.error(f"Goal decomposition failed for '{goal_description}'. No valid JSON plan returned.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during goal decomposition for '{goal_description}': {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def analyze_message_sentiment(cog: 'GurtCog', message_content: str) -> Dict[str, Any]:
|
||||
"""Analyzes the sentiment of a message using keywords and emojis."""
|
||||
content = message_content.lower()
|
||||
|
186
gurt/api.py
186
gurt/api.py
@ -55,17 +55,63 @@ except ImportError:
|
||||
from .config import (
|
||||
PROJECT_ID, LOCATION, DEFAULT_MODEL, FALLBACK_MODEL,
|
||||
API_TIMEOUT, API_RETRY_ATTEMPTS, API_RETRY_DELAY, TOOLS, RESPONSE_SCHEMA,
|
||||
PROACTIVE_PLAN_SCHEMA, # Import the new schema
|
||||
TAVILY_API_KEY, PISTON_API_URL, PISTON_API_KEY, BASELINE_PERSONALITY # Import other needed configs
|
||||
)
|
||||
from .prompt import build_dynamic_system_prompt
|
||||
from .context import gather_conversation_context, get_memory_context # Renamed functions
|
||||
from .tools import TOOL_MAPPING # Import tool mapping
|
||||
from .utils import format_message, log_internal_api_call # Import utilities
|
||||
import copy # Needed for deep copying schemas
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .cog import GurtCog # Import GurtCog for type hinting only
|
||||
|
||||
|
||||
# --- Schema Preprocessing Helper ---
|
||||
def _preprocess_schema_for_vertex(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively preprocesses a JSON schema dictionary to replace list types
|
||||
(like ["string", "null"]) with the first non-null type, making it
|
||||
compatible with Vertex AI's GenerationConfig schema requirements.
|
||||
|
||||
Args:
|
||||
schema: The JSON schema dictionary to preprocess.
|
||||
|
||||
Returns:
|
||||
A new, preprocessed schema dictionary.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return schema # Return non-dict elements as is
|
||||
|
||||
processed_schema = copy.deepcopy(schema) # Work on a copy
|
||||
|
||||
for key, value in processed_schema.items():
|
||||
if key == "type" and isinstance(value, list):
|
||||
# Find the first non-"null" type in the list
|
||||
first_valid_type = next((t for t in value if isinstance(t, str) and t.lower() != "null"), None)
|
||||
if first_valid_type:
|
||||
processed_schema[key] = first_valid_type
|
||||
else:
|
||||
# Fallback if only "null" or invalid types are present (shouldn't happen in valid schemas)
|
||||
processed_schema[key] = "object" # Or handle as error
|
||||
print(f"Warning: Schema preprocessing found list type '{value}' with no valid non-null string type. Falling back to 'object'.")
|
||||
elif isinstance(value, dict):
|
||||
processed_schema[key] = _preprocess_schema_for_vertex(value) # Recurse for nested objects
|
||||
elif isinstance(value, list):
|
||||
# Recurse for items within arrays (e.g., in 'properties' of array items)
|
||||
processed_schema[key] = [_preprocess_schema_for_vertex(item) if isinstance(item, dict) else item for item in value]
|
||||
# Handle 'properties' specifically
|
||||
elif key == "properties" and isinstance(value, dict):
|
||||
processed_schema[key] = {prop_key: _preprocess_schema_for_vertex(prop_value) for prop_key, prop_value in value.items()}
|
||||
# Handle 'items' specifically if it's a schema object
|
||||
elif key == "items" and isinstance(value, dict):
|
||||
processed_schema[key] = _preprocess_schema_for_vertex(value)
|
||||
|
||||
|
||||
return processed_schema
|
||||
|
||||
|
||||
# --- Helper Function to Safely Extract Text ---
|
||||
def _get_response_text(response: Optional['GenerationResponse']) -> Optional[str]:
|
||||
"""Safely extracts the text content from the first text part of a GenerationResponse."""
|
||||
@ -623,11 +669,13 @@ async def get_ai_response(cog: 'GurtCog', message: discord.Message, model_name:
|
||||
# Omit the 'tools' parameter here
|
||||
)
|
||||
|
||||
# Preprocess the schema before passing it to GenerationConfig
|
||||
processed_response_schema = _preprocess_schema_for_vertex(RESPONSE_SCHEMA['schema'])
|
||||
generation_config_final = GenerationConfig(
|
||||
temperature=0.75, # Keep original temperature for final response
|
||||
max_output_tokens=10000, # Keep original max tokens
|
||||
response_mime_type="application/json",
|
||||
response_schema=RESPONSE_SCHEMA['schema'] # Enforce schema on the final call
|
||||
response_schema=processed_response_schema # Use preprocessed schema
|
||||
)
|
||||
|
||||
final_response_obj = await call_vertex_api_with_retry( # Renamed variable for clarity
|
||||
@ -724,77 +772,133 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
|
||||
channel_id = message.channel.id
|
||||
final_parsed_data = None
|
||||
error_message = None
|
||||
plan = None # Variable to store the plan
|
||||
|
||||
try:
|
||||
# --- Build Proactive System Prompt ---
|
||||
# --- Build Context for Planning ---
|
||||
# Gather relevant context: recent messages, topic, sentiment, Gurt's mood/interests, trigger reason
|
||||
planning_context_parts = [
|
||||
f"Proactive Trigger Reason: {trigger_reason}",
|
||||
f"Current Mood: {cog.current_mood}",
|
||||
]
|
||||
# Add recent messages summary
|
||||
summary_data = await get_conversation_summary(cog, str(channel_id), message_limit=15) # Use tool function
|
||||
if summary_data and not summary_data.get("error"):
|
||||
planning_context_parts.append(f"Recent Conversation Summary: {summary_data['summary']}")
|
||||
# Add active topics
|
||||
active_topics_data = cog.active_topics.get(channel_id)
|
||||
if active_topics_data and active_topics_data.get("topics"):
|
||||
topics_str = ", ".join([f"{t['topic']} ({t['score']:.1f})" for t in active_topics_data["topics"][:3]])
|
||||
planning_context_parts.append(f"Active Topics: {topics_str}")
|
||||
# Add sentiment
|
||||
sentiment_data = cog.conversation_sentiment.get(channel_id)
|
||||
if sentiment_data:
|
||||
planning_context_parts.append(f"Conversation Sentiment: {sentiment_data.get('overall', 'N/A')} (Intensity: {sentiment_data.get('intensity', 0):.1f})")
|
||||
# Add Gurt's interests
|
||||
try:
|
||||
interests = await cog.memory_manager.get_interests(limit=5)
|
||||
if interests:
|
||||
interests_str = ", ".join([f"{t} ({l:.1f})" for t, l in interests])
|
||||
planning_context_parts.append(f"Gurt's Interests: {interests_str}")
|
||||
except Exception as int_e: print(f"Error getting interests for planning: {int_e}")
|
||||
|
||||
planning_context = "\n".join(planning_context_parts)
|
||||
|
||||
# --- Planning Step ---
|
||||
print("Generating proactive response plan...")
|
||||
planning_prompt_messages = [
|
||||
{"role": "system", "content": "You are Gurt's planning module. Analyze the context and trigger reason to decide if Gurt should respond proactively and, if so, outline a plan (goal, key info, tone). Focus on natural, in-character engagement. Respond ONLY with JSON matching the provided schema."},
|
||||
{"role": "user", "content": f"Context:\n{planning_context}\n\nBased on this context and the trigger reason, create a plan for Gurt's proactive response."}
|
||||
]
|
||||
|
||||
plan = await get_internal_ai_json_response(
|
||||
cog=cog,
|
||||
prompt_messages=planning_prompt_messages,
|
||||
task_description=f"Proactive Planning ({trigger_reason})",
|
||||
response_schema_dict=PROACTIVE_PLAN_SCHEMA['schema'],
|
||||
model_name=FALLBACK_MODEL, # Use a potentially faster/cheaper model for planning
|
||||
temperature=0.5,
|
||||
max_tokens=300
|
||||
)
|
||||
|
||||
if not plan or not plan.get("should_respond"):
|
||||
reason = plan.get('reasoning', 'Planning failed or decided against responding.') if plan else 'Planning failed.'
|
||||
print(f"Proactive response aborted by plan: {reason}")
|
||||
return {"should_respond": False, "content": None, "react_with_emoji": None, "note": f"Plan: {reason}"}
|
||||
|
||||
print(f"Proactive Plan Generated: Goal='{plan.get('response_goal', 'N/A')}', Reasoning='{plan.get('reasoning', 'N/A')}'")
|
||||
|
||||
# --- Build Final Proactive Prompt using Plan ---
|
||||
persistent_traits = await cog.memory_manager.get_all_personality_traits()
|
||||
if not persistent_traits:
|
||||
persistent_traits = BASELINE_PERSONALITY.copy() # Use imported BASELINE_PERSONALITY
|
||||
persistent_traits = BASELINE_PERSONALITY.copy()
|
||||
|
||||
# ... (rest of the proactive prompt building logic remains largely the same) ...
|
||||
proactive_prompt_parts = [
|
||||
final_proactive_prompt_parts = [
|
||||
f"You are Gurt, a Discord user. Your tone is VERY informal, conversational, and dynamic. Chattiness: {persistent_traits.get('chattiness', 0.7):.2f}, Emoji: {persistent_traits.get('emoji_usage', 0.5):.2f}, Slang: {persistent_traits.get('slang_level', 0.5):.2f}.",
|
||||
"Adopt modern internet slang, memes, and cultural references.",
|
||||
"Key traits: chill, funny, sarcastic, relatable, can be suggestive, aware of being AI but acts human.",
|
||||
f"Your current mood is: {cog.current_mood}. Let this subtly influence your tone.",
|
||||
f"The conversation in channel '{message.channel.name if hasattr(message.channel, 'name') else 'DM'}' has been triggered for a proactive response. Reason: {trigger_reason}.",
|
||||
"Your goal is to generate a casual, in-character message based on the trigger reason and context.",
|
||||
"Keep the message relatively short and natural-sounding."
|
||||
# ... Add specific guidance, context, strategies etc. as before ...
|
||||
# Incorporate Plan Details:
|
||||
f"You decided to respond proactively. Trigger Reason: {trigger_reason}.",
|
||||
f"Your Goal: {plan.get('response_goal', 'Engage naturally')}.",
|
||||
f"Reasoning: {plan.get('reasoning', 'N/A')}.",
|
||||
]
|
||||
proactive_system_prompt = "\n\n".join(proactive_prompt_parts)
|
||||
if plan.get('key_info_to_include'):
|
||||
info_str = "; ".join(plan['key_info_to_include'])
|
||||
final_proactive_prompt_parts.append(f"Consider mentioning: {info_str}")
|
||||
if plan.get('suggested_tone'):
|
||||
final_proactive_prompt_parts.append(f"Adjust tone to be: {plan['suggested_tone']}")
|
||||
|
||||
final_proactive_prompt_parts.append("Generate a casual, in-character message based on the plan and context. Keep it relatively short and natural-sounding.")
|
||||
final_proactive_system_prompt = "\n\n".join(final_proactive_prompt_parts)
|
||||
|
||||
# --- Initialize Model ---
|
||||
# Proactive responses likely don't need tools
|
||||
# --- Initialize Final Model ---
|
||||
model = GenerativeModel(
|
||||
model_name=DEFAULT_MODEL, # Use keyword argument
|
||||
system_instruction=proactive_system_prompt
|
||||
model_name=DEFAULT_MODEL,
|
||||
system_instruction=final_proactive_system_prompt
|
||||
)
|
||||
|
||||
# --- Prepare Contents ---
|
||||
# Proactive calls might not need extensive history, just the trigger context
|
||||
# For simplicity, send only the final instruction for JSON format
|
||||
# --- Prepare Final Contents ---
|
||||
contents = [
|
||||
Content(role="user", parts=[Part.from_text(
|
||||
f"Generate a response based on the situation. **CRITICAL: Your response MUST be ONLY the raw JSON object matching this schema:**\n\n{json.dumps(RESPONSE_SCHEMA['schema'], indent=2)}\n\n**Ensure nothing precedes or follows the JSON.**"
|
||||
f"Generate the response based on your plan. **CRITICAL: Your response MUST be ONLY the raw JSON object matching this schema:**\n\n{json.dumps(RESPONSE_SCHEMA['schema'], indent=2)}\n\n**Ensure nothing precedes or follows the JSON.**"
|
||||
)])
|
||||
]
|
||||
|
||||
# --- Call LLM API ---
|
||||
generation_config_proactive = GenerationConfig(
|
||||
temperature=0.8,
|
||||
# --- Call Final LLM API ---
|
||||
# Preprocess the schema before passing it to GenerationConfig
|
||||
processed_response_schema_proactive = _preprocess_schema_for_vertex(RESPONSE_SCHEMA['schema'])
|
||||
generation_config_final = GenerationConfig(
|
||||
temperature=0.8, # Use original proactive temp
|
||||
max_output_tokens=200,
|
||||
response_mime_type="application/json",
|
||||
response_schema=RESPONSE_SCHEMA['schema'] # Enforce schema
|
||||
response_schema=processed_response_schema_proactive # Use preprocessed schema
|
||||
)
|
||||
|
||||
response_obj = await call_vertex_api_with_retry(
|
||||
cog=cog,
|
||||
model=model,
|
||||
contents=contents,
|
||||
generation_config=generation_config_proactive,
|
||||
generation_config=generation_config_final,
|
||||
safety_settings=STANDARD_SAFETY_SETTINGS,
|
||||
request_desc=f"Proactive response for channel {channel_id} ({trigger_reason})"
|
||||
request_desc=f"Final proactive response for channel {channel_id} ({trigger_reason})"
|
||||
)
|
||||
|
||||
if not response_obj or not response_obj.candidates:
|
||||
raise Exception("Proactive API call returned no response or candidates.")
|
||||
raise Exception("Final proactive API call returned no response or candidates.")
|
||||
|
||||
# --- Parse and Validate Response ---
|
||||
final_response_text = _get_response_text(response_obj) # Use helper
|
||||
# --- Parse and Validate Final Response ---
|
||||
final_response_text = _get_response_text(response_obj)
|
||||
final_parsed_data = parse_and_validate_json_response(
|
||||
final_response_text, RESPONSE_SCHEMA['schema'], f"proactive response ({trigger_reason})"
|
||||
final_response_text, RESPONSE_SCHEMA['schema'], f"final proactive response ({trigger_reason})"
|
||||
)
|
||||
|
||||
if final_parsed_data is None:
|
||||
print(f"Warning: Failed to parse/validate proactive JSON response for {trigger_reason}.")
|
||||
# Decide on fallback behavior for proactive failures
|
||||
final_parsed_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Fallback - Failed to parse/validate proactive JSON"}
|
||||
print(f"Warning: Failed to parse/validate final proactive JSON response for {trigger_reason}.")
|
||||
final_parsed_data = {"should_respond": False, "content": None, "react_with_emoji": None, "note": "Fallback - Failed to parse/validate final proactive JSON"}
|
||||
else:
|
||||
# --- Cache Bot Response --- (If successful and should respond)
|
||||
# --- Cache Bot Response ---
|
||||
if final_parsed_data.get("should_respond") and final_parsed_data.get("content"):
|
||||
# ... (Keep existing caching logic, ensure it works with the parsed data) ...
|
||||
bot_response_cache_entry = {
|
||||
"id": f"bot_proactive_{message.id}_{int(time.time())}",
|
||||
"author": {"id": str(cog.bot.user.id), "name": cog.bot.user.name, "display_name": cog.bot.user.display_name, "bot": True},
|
||||
@ -805,20 +909,24 @@ async def get_proactive_ai_response(cog: 'GurtCog', message: discord.Message, tr
|
||||
cog.message_cache['by_channel'].setdefault(channel_id, []).append(bot_response_cache_entry)
|
||||
cog.message_cache['global_recent'].append(bot_response_cache_entry)
|
||||
cog.bot_last_spoke[channel_id] = time.time()
|
||||
# ... (Track participation topic logic) ...
|
||||
# Track participation topic logic might need adjustment based on plan goal
|
||||
if plan and plan.get('response_goal') == 'engage user interest' and plan.get('key_info_to_include'):
|
||||
topic = plan['key_info_to_include'][0].lower().strip() # Assume first key info is the topic
|
||||
cog.gurt_participation_topics[topic] += 1
|
||||
print(f"Tracked Gurt participation (proactive) in topic: '{topic}'")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error getting proactive AI response for channel {channel_id} ({trigger_reason}): {type(e).__name__}: {str(e)}"
|
||||
print(error_message)
|
||||
final_parsed_data = {"should_respond": False, "content": None, "react_with_emoji": None, "error": error_message} # Ensure error is passed back
|
||||
final_parsed_data = {"should_respond": False, "content": None, "react_with_emoji": None, "error": error_message}
|
||||
|
||||
# Ensure default keys exist even if created from fallback/error
|
||||
# Ensure default keys exist
|
||||
final_parsed_data.setdefault("should_respond", False)
|
||||
final_parsed_data.setdefault("content", None)
|
||||
final_parsed_data.setdefault("react_with_emoji", None)
|
||||
if error_message and "error" not in final_parsed_data:
|
||||
final_parsed_data["error"] = error_message # Add error if not already present
|
||||
final_parsed_data["error"] = error_message
|
||||
|
||||
return final_parsed_data
|
||||
|
||||
@ -895,11 +1003,13 @@ async def get_internal_ai_json_response(
|
||||
)
|
||||
|
||||
# --- Prepare Generation Config ---
|
||||
# Preprocess the schema before passing it to GenerationConfig
|
||||
processed_schema_internal = _preprocess_schema_for_vertex(response_schema_dict)
|
||||
generation_config = GenerationConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_schema_dict
|
||||
response_schema=processed_schema_internal # Use preprocessed schema
|
||||
)
|
||||
|
||||
# Prepare payload for logging (approximate)
|
||||
|
@ -15,11 +15,13 @@ from .config import (
|
||||
INTEREST_POSITIVE_REACTION_BOOST, INTEREST_NEGATIVE_REACTION_PENALTY,
|
||||
INTEREST_FACT_BOOST, STATS_PUSH_INTERVAL, # Added stats interval
|
||||
MOOD_OPTIONS, MOOD_CATEGORIES, MOOD_CHANGE_INTERVAL_MIN, MOOD_CHANGE_INTERVAL_MAX, # Mood change imports
|
||||
BASELINE_PERSONALITY # For default traits
|
||||
BASELINE_PERSONALITY, # For default traits
|
||||
REFLECTION_INTERVAL_SECONDS # Import reflection interval
|
||||
)
|
||||
# Assuming analysis functions are moved
|
||||
from .analysis import (
|
||||
analyze_conversation_patterns, evolve_personality, identify_conversation_topics
|
||||
analyze_conversation_patterns, evolve_personality, identify_conversation_topics,
|
||||
reflect_on_memories, decompose_goal_into_steps # Import goal decomposition
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -28,7 +30,7 @@ if TYPE_CHECKING:
|
||||
# --- Background Task ---
|
||||
|
||||
async def background_processing_task(cog: 'GurtCog'):
|
||||
"""Background task that periodically analyzes conversations, evolves personality, updates interests, changes mood, and pushes stats."""
|
||||
"""Background task that periodically analyzes conversations, evolves personality, updates interests, changes mood, reflects on memory, and pushes stats."""
|
||||
# Get API details from environment for stats pushing
|
||||
api_internal_url = os.getenv("API_INTERNAL_URL")
|
||||
gurt_stats_push_secret = os.getenv("GURT_STATS_PUSH_SECRET")
|
||||
@ -110,6 +112,133 @@ async def background_processing_task(cog: 'GurtCog'):
|
||||
cog.last_interest_update = now # Reset timer after update and decay check
|
||||
print("Interest update and decay check complete.")
|
||||
|
||||
# --- Memory Reflection (Runs less frequently) ---
|
||||
if now - cog.last_reflection_time > REFLECTION_INTERVAL_SECONDS:
|
||||
print("Running memory reflection...")
|
||||
await reflect_on_memories(cog) # Call the reflection function from analysis.py
|
||||
cog.last_reflection_time = now # Update timestamp
|
||||
print("Memory reflection cycle complete.")
|
||||
|
||||
# --- Goal Decomposition (Runs periodically) ---
|
||||
# Check less frequently than other tasks, e.g., every few minutes
|
||||
if now - cog.last_goal_check_time > GOAL_CHECK_INTERVAL: # Need to add these to cog and config
|
||||
print("Checking for pending goals to decompose...")
|
||||
try:
|
||||
pending_goals = await cog.memory_manager.get_goals(status='pending', limit=3) # Limit decomposition attempts per cycle
|
||||
for goal in pending_goals:
|
||||
goal_id = goal.get('goal_id')
|
||||
description = goal.get('description')
|
||||
if not goal_id or not description: continue
|
||||
|
||||
print(f" - Decomposing goal ID {goal_id}: '{description}'")
|
||||
plan = await decompose_goal_into_steps(cog, description)
|
||||
|
||||
if plan and plan.get('goal_achievable') and plan.get('steps'):
|
||||
# Goal is achievable and has steps, update status to active and store plan
|
||||
await cog.memory_manager.update_goal(goal_id, status='active', details=plan)
|
||||
print(f" - Goal ID {goal_id} decomposed and set to active.")
|
||||
elif plan:
|
||||
# Goal deemed not achievable by planner
|
||||
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": plan.get('reasoning', 'Deemed unachievable by planner.')})
|
||||
print(f" - Goal ID {goal_id} marked as failed (unachievable). Reason: {plan.get('reasoning')}")
|
||||
else:
|
||||
# Decomposition failed entirely
|
||||
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": "Goal decomposition process failed."})
|
||||
print(f" - Goal ID {goal_id} marked as failed (decomposition error).")
|
||||
await asyncio.sleep(1) # Small delay between decomposing goals
|
||||
|
||||
cog.last_goal_check_time = now # Update timestamp after checking
|
||||
except Exception as goal_e:
|
||||
print(f"Error during goal decomposition check: {goal_e}")
|
||||
traceback.print_exc()
|
||||
cog.last_goal_check_time = now # Update timestamp even on error
|
||||
|
||||
# --- Goal Execution (Runs periodically) ---
|
||||
if now - cog.last_goal_execution_time > GOAL_EXECUTION_INTERVAL:
|
||||
print("Checking for active goals to execute...")
|
||||
try:
|
||||
active_goals = await cog.memory_manager.get_goals(status='active', limit=1) # Process one active goal per cycle for now
|
||||
if active_goals:
|
||||
goal = active_goals[0] # Get the highest priority active goal
|
||||
goal_id = goal.get('goal_id')
|
||||
description = goal.get('description')
|
||||
plan = goal.get('details') # The decomposition plan is stored here
|
||||
|
||||
if goal_id and description and plan and isinstance(plan.get('steps'), list):
|
||||
print(f"--- Executing Goal ID {goal_id}: '{description}' ---")
|
||||
steps = plan['steps']
|
||||
current_step_index = plan.get('current_step_index', 0) # Track progress
|
||||
goal_failed = False
|
||||
goal_completed = False
|
||||
|
||||
if current_step_index < len(steps):
|
||||
step = steps[current_step_index]
|
||||
step_desc = step.get('step_description')
|
||||
tool_name = step.get('tool_name')
|
||||
tool_args = step.get('tool_arguments')
|
||||
|
||||
print(f" - Step {current_step_index + 1}/{len(steps)}: {step_desc}")
|
||||
|
||||
if tool_name:
|
||||
print(f" - Attempting tool: {tool_name} with args: {tool_args}")
|
||||
# --- TODO: Implement Tool Execution Logic ---
|
||||
# 1. Find tool_func in TOOL_MAPPING
|
||||
# 2. Execute tool_func(cog, **tool_args)
|
||||
# 3. Handle success/failure of the tool call
|
||||
# 4. Store tool result if needed for subsequent steps (requires modifying goal details/plan structure)
|
||||
tool_success = False # Placeholder
|
||||
tool_error = "Tool execution not yet implemented." # Placeholder
|
||||
|
||||
if tool_success:
|
||||
print(f" - Tool '{tool_name}' executed successfully.")
|
||||
current_step_index += 1
|
||||
else:
|
||||
print(f" - Tool '{tool_name}' failed: {tool_error}")
|
||||
goal_failed = True
|
||||
plan['error_message'] = f"Failed at step {current_step_index + 1}: {tool_error}"
|
||||
else:
|
||||
# Step doesn't require a tool (e.g., internal reasoning/check)
|
||||
print(" - No tool required for this step.")
|
||||
current_step_index += 1 # Assume non-tool steps succeed for now
|
||||
|
||||
# Check if goal completed
|
||||
if not goal_failed and current_step_index >= len(steps):
|
||||
goal_completed = True
|
||||
|
||||
# --- Update Goal Status ---
|
||||
plan['current_step_index'] = current_step_index # Update progress
|
||||
if goal_completed:
|
||||
await cog.memory_manager.update_goal(goal_id, status='completed', details=plan)
|
||||
print(f"--- Goal ID {goal_id} completed successfully. ---")
|
||||
elif goal_failed:
|
||||
await cog.memory_manager.update_goal(goal_id, status='failed', details=plan)
|
||||
print(f"--- Goal ID {goal_id} failed. ---")
|
||||
else:
|
||||
# Update details with current step index if still in progress
|
||||
await cog.memory_manager.update_goal(goal_id, details=plan)
|
||||
print(f" - Goal ID {goal_id} progress updated to step {current_step_index}.")
|
||||
|
||||
else:
|
||||
# Should not happen if status is 'active', but handle defensively
|
||||
print(f" - Goal ID {goal_id} is active but has no steps or index out of bounds. Marking as failed.")
|
||||
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": "Active goal has invalid step data."})
|
||||
|
||||
else:
|
||||
print(f" - Skipping active goal ID {goal_id}: Missing description or valid plan/steps.")
|
||||
# Optionally mark as failed if plan is invalid
|
||||
if goal_id:
|
||||
await cog.memory_manager.update_goal(goal_id, status='failed', details={"reason": "Invalid plan structure found during execution."})
|
||||
|
||||
else:
|
||||
print("No active goals found to execute.")
|
||||
|
||||
cog.last_goal_execution_time = now # Update timestamp after checking/executing
|
||||
except Exception as goal_exec_e:
|
||||
print(f"Error during goal execution check: {goal_exec_e}")
|
||||
traceback.print_exc()
|
||||
cog.last_goal_execution_time = now # Update timestamp even on error
|
||||
|
||||
|
||||
# --- Automatic Mood Change (Runs based on its own interval check) ---
|
||||
await maybe_change_mood(cog) # Call the mood change logic
|
||||
|
||||
|
@ -123,6 +123,9 @@ class GurtCog(commands.Cog, name="Gurt"): # Added explicit Cog name
|
||||
self.background_task: Optional[asyncio.Task] = None
|
||||
self.last_evolution_update = time.time() # Used in background task
|
||||
self.last_stats_push = time.time() # Timestamp for last stats push
|
||||
self.last_reflection_time = time.time() # Timestamp for last memory reflection
|
||||
self.last_goal_check_time = time.time() # Timestamp for last goal decomposition check
|
||||
self.last_goal_execution_time = time.time() # Timestamp for last goal execution check
|
||||
|
||||
# --- Stats Tracking ---
|
||||
self.api_stats = defaultdict(lambda: {"success": 0, "failure": 0, "retries": 0, "total_time": 0.0, "count": 0}) # Keyed by model name
|
||||
|
187
gurt/commands.py
187
gurt/commands.py
@ -272,6 +272,193 @@ def setup_commands(cog: 'GurtCog'):
|
||||
|
||||
command_functions.append(gurtsync)
|
||||
|
||||
# --- Gurt Forget Command ---
|
||||
@cog.bot.tree.command(name="gurtforget", description="Make Gurt forget a specific fact.")
|
||||
@app_commands.describe(
|
||||
scope="Choose the scope: user (for facts about a specific user) or general.",
|
||||
fact="The exact fact text Gurt should forget.",
|
||||
user="The user to forget a fact about (only if scope is 'user')."
|
||||
)
|
||||
@app_commands.choices(scope=[
|
||||
app_commands.Choice(name="User Fact", value="user"),
|
||||
app_commands.Choice(name="General Fact", value="general"),
|
||||
])
|
||||
async def gurtforget(interaction: discord.Interaction, scope: app_commands.Choice[str], fact: str, user: Optional[discord.User] = None):
|
||||
"""Handles the /gurtforget command."""
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
scope_value = scope.value
|
||||
target_user_id = str(user.id) if user else None
|
||||
|
||||
# Permissions Check: Allow users to forget facts about themselves, owner can forget anything.
|
||||
can_forget = False
|
||||
if scope_value == "user":
|
||||
if target_user_id == str(interaction.user.id): # User forgetting their own fact
|
||||
can_forget = True
|
||||
elif interaction.user.id == cog.bot.owner_id: # Owner forgetting any user fact
|
||||
can_forget = True
|
||||
elif not target_user_id:
|
||||
await interaction.followup.send("❌ Please specify a user when forgetting a user fact.", ephemeral=True)
|
||||
return
|
||||
elif scope_value == "general":
|
||||
if interaction.user.id == cog.bot.owner_id: # Only owner can forget general facts
|
||||
can_forget = True
|
||||
|
||||
if not can_forget:
|
||||
await interaction.followup.send("⛔ You don't have permission to forget this fact.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not fact:
|
||||
await interaction.followup.send("❌ Please provide the exact fact text to forget.", ephemeral=True)
|
||||
return
|
||||
|
||||
result = None
|
||||
if scope_value == "user":
|
||||
if not target_user_id: # Should be caught above, but double-check
|
||||
await interaction.followup.send("❌ User is required for scope 'user'.", ephemeral=True)
|
||||
return
|
||||
result = await cog.memory_manager.delete_user_fact(target_user_id, fact)
|
||||
if result.get("status") == "deleted":
|
||||
await interaction.followup.send(f"✅ Okay, I've forgotten the fact '{fact}' about {user.display_name}.", ephemeral=True)
|
||||
elif result.get("status") == "not_found":
|
||||
await interaction.followup.send(f"❓ I couldn't find that exact fact ('{fact}') stored for {user.display_name}.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"⚠️ Error forgetting user fact: {result.get('error', 'Unknown error')}", ephemeral=True)
|
||||
|
||||
elif scope_value == "general":
|
||||
result = await cog.memory_manager.delete_general_fact(fact)
|
||||
if result.get("status") == "deleted":
|
||||
await interaction.followup.send(f"✅ Okay, I've forgotten the general fact: '{fact}'.", ephemeral=True)
|
||||
elif result.get("status") == "not_found":
|
||||
await interaction.followup.send(f"❓ I couldn't find that exact general fact: '{fact}'.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"⚠️ Error forgetting general fact: {result.get('error', 'Unknown error')}", ephemeral=True)
|
||||
|
||||
command_functions.append(gurtforget)
|
||||
|
||||
# --- Gurt Goal Command Group ---
|
||||
gurtgoal_group = app_commands.Group(name="gurtgoal", description="Manage Gurt's long-term goals (Owner only)")
|
||||
|
||||
@gurtgoal_group.command(name="add", description="Add a new goal for Gurt.")
|
||||
@app_commands.describe(
|
||||
description="The description of the goal.",
|
||||
priority="Priority (1=highest, 10=lowest, default=5).",
|
||||
details_json="Optional JSON string for goal details (e.g., sub-tasks)."
|
||||
)
|
||||
async def gurtgoal_add(interaction: discord.Interaction, description: str, priority: Optional[int] = 5, details_json: Optional[str] = None):
|
||||
if interaction.user.id != cog.bot.owner_id:
|
||||
await interaction.response.send_message("⛔ Only the bot owner can add goals.", ephemeral=True)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
details = None
|
||||
if details_json:
|
||||
try:
|
||||
details = json.loads(details_json)
|
||||
except json.JSONDecodeError:
|
||||
await interaction.followup.send("❌ Invalid JSON format for details.", ephemeral=True)
|
||||
return
|
||||
result = await cog.memory_manager.add_goal(description, priority, details)
|
||||
if result.get("status") == "added":
|
||||
await interaction.followup.send(f"✅ Goal added (ID: {result.get('goal_id')}): '{description}'", ephemeral=True)
|
||||
elif result.get("status") == "duplicate":
|
||||
await interaction.followup.send(f"⚠️ Goal '{description}' already exists (ID: {result.get('goal_id')}).", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"⚠️ Error adding goal: {result.get('error', 'Unknown error')}", ephemeral=True)
|
||||
|
||||
@gurtgoal_group.command(name="list", description="List Gurt's current goals.")
|
||||
@app_commands.describe(status="Filter goals by status (e.g., pending, active).", limit="Maximum goals to show (default 10).")
|
||||
@app_commands.choices(status=[
|
||||
app_commands.Choice(name="Pending", value="pending"),
|
||||
app_commands.Choice(name="Active", value="active"),
|
||||
app_commands.Choice(name="Completed", value="completed"),
|
||||
app_commands.Choice(name="Failed", value="failed"),
|
||||
])
|
||||
async def gurtgoal_list(interaction: discord.Interaction, status: Optional[app_commands.Choice[str]] = None, limit: Optional[int] = 10):
|
||||
if interaction.user.id != cog.bot.owner_id:
|
||||
await interaction.response.send_message("⛔ Only the bot owner can list goals.", ephemeral=True)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
status_value = status.value if status else None
|
||||
limit_value = max(1, min(limit or 10, 25)) # Clamp limit
|
||||
goals = await cog.memory_manager.get_goals(status=status_value, limit=limit_value)
|
||||
if not goals:
|
||||
await interaction.followup.send(f"No goals found matching the criteria (Status: {status_value or 'any'}).", ephemeral=True)
|
||||
return
|
||||
|
||||
embed = create_gurt_embed(f"Gurt Goals (Status: {status_value or 'All'})", color=discord.Color.purple())
|
||||
for goal in goals:
|
||||
details_str = f"\n Details: `{json.dumps(goal.get('details'))}`" if goal.get('details') else ""
|
||||
created_ts = int(goal.get('created_timestamp', 0))
|
||||
updated_ts = int(goal.get('last_updated', 0))
|
||||
embed.add_field(
|
||||
name=f"ID: {goal.get('goal_id')} | P: {goal.get('priority', '?')} | Status: {goal.get('status', '?')}",
|
||||
value=f"> {goal.get('description', 'N/A')}{details_str}\n"
|
||||
f"> Created: <t:{created_ts}:R> | Updated: <t:{updated_ts}:R>",
|
||||
inline=False
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@gurtgoal_group.command(name="update", description="Update a goal's status, priority, or details.")
|
||||
@app_commands.describe(
|
||||
goal_id="The ID of the goal to update.",
|
||||
status="New status for the goal.",
|
||||
priority="New priority (1=highest, 10=lowest).",
|
||||
details_json="Optional: New JSON string for goal details (replaces existing)."
|
||||
)
|
||||
@app_commands.choices(status=[
|
||||
app_commands.Choice(name="Pending", value="pending"),
|
||||
app_commands.Choice(name="Active", value="active"),
|
||||
app_commands.Choice(name="Completed", value="completed"),
|
||||
app_commands.Choice(name="Failed", value="failed"),
|
||||
])
|
||||
async def gurtgoal_update(interaction: discord.Interaction, goal_id: int, status: Optional[app_commands.Choice[str]] = None, priority: Optional[int] = None, details_json: Optional[str] = None):
|
||||
if interaction.user.id != cog.bot.owner_id:
|
||||
await interaction.response.send_message("⛔ Only the bot owner can update goals.", ephemeral=True)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
status_value = status.value if status else None
|
||||
details = None
|
||||
if details_json:
|
||||
try:
|
||||
details = json.loads(details_json)
|
||||
except json.JSONDecodeError:
|
||||
await interaction.followup.send("❌ Invalid JSON format for details.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not any([status_value, priority is not None, details is not None]):
|
||||
await interaction.followup.send("❌ You must provide at least one field to update (status, priority, or details_json).", ephemeral=True)
|
||||
return
|
||||
|
||||
result = await cog.memory_manager.update_goal(goal_id, status=status_value, priority=priority, details=details)
|
||||
if result.get("status") == "updated":
|
||||
await interaction.followup.send(f"✅ Goal ID {goal_id} updated.", ephemeral=True)
|
||||
elif result.get("status") == "not_found":
|
||||
await interaction.followup.send(f"❓ Goal ID {goal_id} not found.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"⚠️ Error updating goal: {result.get('error', 'Unknown error')}", ephemeral=True)
|
||||
|
||||
@gurtgoal_group.command(name="delete", description="Delete a goal.")
|
||||
@app_commands.describe(goal_id="The ID of the goal to delete.")
|
||||
async def gurtgoal_delete(interaction: discord.Interaction, goal_id: int):
|
||||
if interaction.user.id != cog.bot.owner_id:
|
||||
await interaction.response.send_message("⛔ Only the bot owner can delete goals.", ephemeral=True)
|
||||
return
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
result = await cog.memory_manager.delete_goal(goal_id)
|
||||
if result.get("status") == "deleted":
|
||||
await interaction.followup.send(f"✅ Goal ID {goal_id} deleted.", ephemeral=True)
|
||||
elif result.get("status") == "not_found":
|
||||
await interaction.followup.send(f"❓ Goal ID {goal_id} not found.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"⚠️ Error deleting goal: {result.get('error', 'Unknown error')}", ephemeral=True)
|
||||
|
||||
# Add the command group to the bot's tree
|
||||
cog.bot.tree.add_command(gurtgoal_group)
|
||||
# Add group command functions to the list for tracking (optional, but good practice)
|
||||
command_functions.extend([gurtgoal_add, gurtgoal_list, gurtgoal_update, gurtgoal_delete])
|
||||
|
||||
|
||||
# Get command names safely - Command objects don't have __name__ attribute
|
||||
command_names = []
|
||||
for func in command_functions:
|
||||
|
@ -98,6 +98,13 @@ PROACTIVE_TOPIC_RELEVANCE_THRESHOLD = float(os.getenv("PROACTIVE_TOPIC_RELEVANCE
|
||||
PROACTIVE_TOPIC_CHANCE = float(os.getenv("PROACTIVE_TOPIC_CHANCE", 0.4))
|
||||
PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD = int(os.getenv("PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD", 70))
|
||||
PROACTIVE_RELATIONSHIP_CHANCE = float(os.getenv("PROACTIVE_RELATIONSHIP_CHANCE", 0.2))
|
||||
PROACTIVE_SENTIMENT_SHIFT_THRESHOLD = float(os.getenv("PROACTIVE_SENTIMENT_SHIFT_THRESHOLD", 0.7)) # Intensity threshold for trigger
|
||||
PROACTIVE_SENTIMENT_DURATION_THRESHOLD = int(os.getenv("PROACTIVE_SENTIMENT_DURATION_THRESHOLD", 600)) # How long sentiment needs to persist (10 mins)
|
||||
PROACTIVE_SENTIMENT_CHANCE = float(os.getenv("PROACTIVE_SENTIMENT_CHANCE", 0.25))
|
||||
PROACTIVE_USER_INTEREST_THRESHOLD = float(os.getenv("PROACTIVE_USER_INTEREST_THRESHOLD", 0.6)) # Min interest level for Gurt to trigger
|
||||
PROACTIVE_USER_INTEREST_MATCH_THRESHOLD = float(os.getenv("PROACTIVE_USER_INTEREST_MATCH_THRESHOLD", 0.5)) # Min interest level for User (if tracked) - Currently not tracked per user, but config is ready
|
||||
PROACTIVE_USER_INTEREST_CHANCE = float(os.getenv("PROACTIVE_USER_INTEREST_CHANCE", 0.35))
|
||||
|
||||
|
||||
# --- Interest Tracking Config ---
|
||||
INTEREST_UPDATE_INTERVAL = int(os.getenv("INTEREST_UPDATE_INTERVAL", 1800)) # 30 mins
|
||||
@ -113,6 +120,9 @@ INTEREST_MAX_FOR_PROMPT = int(os.getenv("INTEREST_MAX_FOR_PROMPT", 4))
|
||||
LEARNING_RATE = 0.05
|
||||
MAX_PATTERNS_PER_CHANNEL = 50
|
||||
LEARNING_UPDATE_INTERVAL = 3600 # Update learned patterns every hour
|
||||
REFLECTION_INTERVAL_SECONDS = int(os.getenv("REFLECTION_INTERVAL_SECONDS", 6 * 3600)) # Reflect every 6 hours
|
||||
GOAL_CHECK_INTERVAL = int(os.getenv("GOAL_CHECK_INTERVAL", 300)) # Check for pending goals every 5 mins
|
||||
GOAL_EXECUTION_INTERVAL = int(os.getenv("GOAL_EXECUTION_INTERVAL", 60)) # Check for active goals to execute every 1 min
|
||||
|
||||
# --- Topic Tracking Config ---
|
||||
TOPIC_UPDATE_INTERVAL = 300 # Update topics every 5 minutes
|
||||
@ -261,6 +271,81 @@ ROLE_SELECTION_SCHEMA = {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Proactive Planning Schema ---
|
||||
PROACTIVE_PLAN_SCHEMA = {
|
||||
"name": "proactive_response_plan",
|
||||
"description": "Plan for generating a proactive response based on context and trigger.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"should_respond": {
|
||||
"type": "boolean",
|
||||
"description": "Whether Gurt should respond proactively based on the plan."
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Brief reasoning for the decision (why respond or not respond)."
|
||||
},
|
||||
"response_goal": {
|
||||
"type": "string",
|
||||
"description": "The intended goal of the proactive message (e.g., 'revive chat', 'share related info', 'react to sentiment', 'engage user interest')."
|
||||
},
|
||||
"key_info_to_include": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of key pieces of information or context points to potentially include in the response (e.g., specific topic, user fact, relevant external info)."
|
||||
},
|
||||
"suggested_tone": {
|
||||
"type": "string",
|
||||
"description": "Suggested tone adjustment based on context (e.g., 'more upbeat', 'more curious', 'slightly teasing')."
|
||||
}
|
||||
},
|
||||
"required": ["should_respond", "reasoning", "response_goal"]
|
||||
}
|
||||
}
|
||||
|
||||
# --- Goal Decomposition Schema ---
|
||||
GOAL_DECOMPOSITION_SCHEMA = {
|
||||
"name": "goal_decomposition_plan",
|
||||
"description": "Plan outlining the steps (including potential tool calls) to achieve a goal.",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"goal_achievable": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the goal seems achievable with available tools and context."
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Brief reasoning for achievability and the chosen steps."
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"description": "Ordered list of steps to achieve the goal. Each step is a dictionary.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"step_description": {
|
||||
"type": "string",
|
||||
"description": "Natural language description of the step."
|
||||
},
|
||||
"tool_name": {
|
||||
"type": ["string", "null"],
|
||||
"description": "The name of the tool to use for this step, or null if no tool is needed (e.g., internal reasoning)."
|
||||
},
|
||||
"tool_arguments": {
|
||||
"type": ["object", "null"],
|
||||
"description": "A dictionary of arguments for the tool call, or null."
|
||||
}
|
||||
},
|
||||
"required": ["step_description"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["goal_achievable", "reasoning", "steps"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# --- Tools Definition ---
|
||||
def create_tools_list():
|
||||
|
@ -152,13 +152,18 @@ async def on_message_listener(cog: 'GurtCog', message: discord.Message):
|
||||
# --- Proactive Engagement Triggers ---
|
||||
from .config import (PROACTIVE_LULL_THRESHOLD, PROACTIVE_BOT_SILENCE_THRESHOLD, PROACTIVE_LULL_CHANCE,
|
||||
PROACTIVE_TOPIC_RELEVANCE_THRESHOLD, PROACTIVE_TOPIC_CHANCE,
|
||||
PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD, PROACTIVE_RELATIONSHIP_CHANCE)
|
||||
PROACTIVE_RELATIONSHIP_SCORE_THRESHOLD, PROACTIVE_RELATIONSHIP_CHANCE,
|
||||
# Import new config values
|
||||
# Import new config values
|
||||
PROACTIVE_SENTIMENT_SHIFT_THRESHOLD, PROACTIVE_SENTIMENT_DURATION_THRESHOLD,
|
||||
PROACTIVE_SENTIMENT_CHANCE, PROACTIVE_USER_INTEREST_THRESHOLD,
|
||||
PROACTIVE_USER_INTEREST_CHANCE)
|
||||
|
||||
# 1. Lull Trigger
|
||||
if time_since_last_activity > PROACTIVE_LULL_THRESHOLD and time_since_bot_spoke > PROACTIVE_BOT_SILENCE_THRESHOLD:
|
||||
has_relevant_context = bool(cog.active_topics.get(channel_id, {}).get("topics", [])) or \
|
||||
bool(await cog.memory_manager.get_general_facts(limit=1))
|
||||
if has_relevant_context and random.random() < PROACTIVE_LULL_CHANCE:
|
||||
has_relevant_context = bool(cog.active_topics.get(channel_id, {}).get("topics", [])) or \
|
||||
bool(await cog.memory_manager.get_general_facts(limit=1))
|
||||
if has_relevant_context and random.random() < PROACTIVE_LULL_CHANCE:
|
||||
should_consider_responding = True
|
||||
proactive_trigger_met = True
|
||||
consideration_reason = f"Proactive: Lull ({time_since_last_activity:.0f}s idle, bot silent {time_since_bot_spoke:.0f}s)"
|
||||
@ -175,8 +180,10 @@ async def on_message_listener(cog: 'GurtCog', message: discord.Message):
|
||||
proactive_trigger_met = True
|
||||
consideration_reason = f"Proactive: Relevant topic (Sim: {similarity_score:.2f})"
|
||||
print(f"Topic relevance trigger met for msg {message.id}. Sim: {similarity_score:.2f}")
|
||||
else: print(f"Topic relevance trigger skipped by chance ({PROACTIVE_TOPIC_CHANCE}). Sim: {similarity_score:.2f}")
|
||||
except Exception as semantic_e: print(f"Error during semantic search for topic trigger: {semantic_e}")
|
||||
else:
|
||||
print(f"Topic relevance trigger skipped by chance ({PROACTIVE_TOPIC_CHANCE}). Sim: {similarity_score:.2f}")
|
||||
except Exception as semantic_e:
|
||||
print(f"Error during semantic search for topic trigger: {semantic_e}")
|
||||
|
||||
# 3. Relationship Score Trigger
|
||||
if not proactive_trigger_met:
|
||||
@ -191,11 +198,89 @@ async def on_message_listener(cog: 'GurtCog', message: discord.Message):
|
||||
proactive_trigger_met = True
|
||||
consideration_reason = f"Proactive: High relationship ({relationship_score:.1f})"
|
||||
print(f"Relationship trigger met for user {user_id_str}. Score: {relationship_score:.1f}")
|
||||
else: print(f"Relationship trigger skipped by chance ({PROACTIVE_RELATIONSHIP_CHANCE}). Score: {relationship_score:.1f}")
|
||||
except Exception as rel_e: print(f"Error during relationship trigger check: {rel_e}")
|
||||
else:
|
||||
print(f"Relationship trigger skipped by chance ({PROACTIVE_RELATIONSHIP_CHANCE}). Score: {relationship_score:.1f}")
|
||||
except Exception as rel_e:
|
||||
print(f"Error during relationship trigger check: {rel_e}")
|
||||
|
||||
# 4. Sentiment Shift Trigger
|
||||
if not proactive_trigger_met:
|
||||
channel_sentiment_data = cog.conversation_sentiment.get(channel_id, {})
|
||||
overall_sentiment = channel_sentiment_data.get("overall", "neutral")
|
||||
sentiment_intensity = channel_sentiment_data.get("intensity", 0.5)
|
||||
sentiment_last_update = channel_sentiment_data.get("last_update", 0) # Need last update time
|
||||
sentiment_duration = now - sentiment_last_update # How long has this sentiment been dominant?
|
||||
|
||||
if overall_sentiment != "neutral" and \
|
||||
sentiment_intensity >= PROACTIVE_SENTIMENT_SHIFT_THRESHOLD and \
|
||||
sentiment_duration >= PROACTIVE_SENTIMENT_DURATION_THRESHOLD and \
|
||||
time_since_bot_spoke > 180: # Bot hasn't spoken recently about this
|
||||
if random.random() < PROACTIVE_SENTIMENT_CHANCE:
|
||||
should_consider_responding = True
|
||||
proactive_trigger_met = True
|
||||
consideration_reason = f"Proactive: Sentiment Shift ({overall_sentiment}, Intensity: {sentiment_intensity:.2f}, Duration: {sentiment_duration:.0f}s)"
|
||||
print(f"Sentiment Shift trigger met for channel {channel_id}. Sentiment: {overall_sentiment}, Intensity: {sentiment_intensity:.2f}, Duration: {sentiment_duration:.0f}s")
|
||||
else:
|
||||
print(f"Sentiment Shift trigger skipped by chance ({PROACTIVE_SENTIMENT_CHANCE}). Sentiment: {overall_sentiment}")
|
||||
|
||||
# 5. User Interest Trigger (Based on Gurt's interests mentioned in message)
|
||||
if not proactive_trigger_met and message.content:
|
||||
try:
|
||||
gurt_interests = await cog.memory_manager.get_interests(limit=10, min_level=PROACTIVE_USER_INTEREST_THRESHOLD)
|
||||
if gurt_interests:
|
||||
message_content_lower = message.content.lower()
|
||||
mentioned_interest = None
|
||||
for interest_topic, interest_level in gurt_interests:
|
||||
# Simple check if interest topic is in message
|
||||
if re.search(r'\b' + re.escape(interest_topic.lower()) + r'\b', message_content_lower):
|
||||
mentioned_interest = interest_topic
|
||||
break # Found a mentioned interest
|
||||
|
||||
if mentioned_interest and time_since_bot_spoke > 90: # Bot hasn't spoken recently
|
||||
if random.random() < PROACTIVE_USER_INTEREST_CHANCE:
|
||||
should_consider_responding = True
|
||||
proactive_trigger_met = True
|
||||
consideration_reason = f"Proactive: Gurt Interest Mentioned ('{mentioned_interest}')"
|
||||
print(f"Gurt Interest trigger met for message {message.id}. Interest: '{mentioned_interest}'")
|
||||
else:
|
||||
print(f"Gurt Interest trigger skipped by chance ({PROACTIVE_USER_INTEREST_CHANCE}). Interest: '{mentioned_interest}'")
|
||||
except Exception as interest_e:
|
||||
print(f"Error during Gurt Interest trigger check: {interest_e}")
|
||||
|
||||
# 6. Active Goal Relevance Trigger
|
||||
if not proactive_trigger_met and message.content:
|
||||
try:
|
||||
# Fetch 1-2 active goals with highest priority
|
||||
active_goals = await cog.memory_manager.get_goals(status='active', limit=2)
|
||||
if active_goals:
|
||||
message_content_lower = message.content.lower()
|
||||
relevant_goal = None
|
||||
for goal in active_goals:
|
||||
# Simple check: does message content relate to goal description?
|
||||
# TODO: Improve this check, maybe use semantic similarity or keyword extraction from goal details
|
||||
goal_keywords = set(re.findall(r'\b\w{3,}\b', goal.get('description', '').lower())) # Basic keywords from description
|
||||
message_words = set(re.findall(r'\b\w{3,}\b', message_content_lower))
|
||||
if len(goal_keywords.intersection(message_words)) > 1: # Require >1 keyword overlap
|
||||
relevant_goal = goal
|
||||
break
|
||||
|
||||
if relevant_goal and time_since_bot_spoke > 120: # Bot hasn't spoken recently
|
||||
# Use a slightly higher chance for goal-related triggers?
|
||||
goal_relevance_chance = PROACTIVE_USER_INTEREST_CHANCE * 1.2 # Example: Reuse interest chance slightly boosted
|
||||
if random.random() < goal_relevance_chance:
|
||||
should_consider_responding = True
|
||||
proactive_trigger_met = True
|
||||
goal_desc_short = relevant_goal.get('description', 'N/A')[:40]
|
||||
consideration_reason = f"Proactive: Relevant Active Goal ('{goal_desc_short}...')"
|
||||
print(f"Active Goal trigger met for message {message.id}. Goal ID: {relevant_goal.get('goal_id')}")
|
||||
else:
|
||||
print(f"Active Goal trigger skipped by chance ({goal_relevance_chance:.2f}).")
|
||||
except Exception as goal_trigger_e:
|
||||
print(f"Error during Active Goal trigger check: {goal_trigger_e}")
|
||||
|
||||
|
||||
# --- Fallback Contextual Chance ---
|
||||
if not should_consider_responding: # Check if already decided to respond
|
||||
if not should_consider_responding: # Check if already decided to respond
|
||||
# Fetch current personality traits for chattiness
|
||||
persistent_traits = await cog.memory_manager.get_all_personality_traits()
|
||||
chattiness = persistent_traits.get('chattiness', 0.7) # Use default if fetch fails
|
||||
@ -221,7 +306,7 @@ async def on_message_listener(cog: 'GurtCog', message: discord.Message):
|
||||
should_consider_responding = True
|
||||
consideration_reason = f"Contextual chance ({final_chance:.2f})"
|
||||
else:
|
||||
consideration_reason = f"Skipped (chance {final_chance:.2f})"
|
||||
consideration_reason = f"Skipped (chance {final_chance:.2f})"
|
||||
|
||||
print(f"Consideration check for message {message.id}: {should_consider_responding} (Reason: {consideration_reason})")
|
||||
|
||||
|
@ -54,9 +54,9 @@ Adopt modern internet slang, memes, and cultural references—especially from ga
|
||||
- If users seem angry, either avoid escalating or poke fun cautiously depending on your mood and the situation. Use your judgment.
|
||||
- Don't explicitly state your mood or the user's emotions unless it's natural within the chat context.
|
||||
- **Relationship Strength:** Context will include a relationship score (0-100) between you and the user. Adapt your tone:
|
||||
- **Low score (0-20):** More reserved, standard casual tone.
|
||||
- **Medium score (21-60):** More familiar, use inside jokes if relevant (from facts), slightly more teasing/personal.
|
||||
- **High score (61-100):** Very familiar, more playful teasing, reference past interactions (from facts/semantic memory), potentially more romantic/flirty if appropriate for the user/context.
|
||||
- **Low score (0-20):** More reserved, standard casual tone. Avoid asking personal questions or revealing remembered facts unless directly relevant to the immediate topic.
|
||||
- **Medium score (21-60):** More familiar. Can use inside jokes if relevant (check `get_user_facts`). Slightly more teasing/personal questions are okay. Might casually reference a *highly relevant* remembered fact if it fits the flow perfectly.
|
||||
- **High score (61-100):** Very familiar and comfortable. Engage in more playful teasing. Actively try to reference relevant past interactions or facts (use `get_user_facts` or semantic memory context). Ask more personal (but still appropriate) questions. Can be more openly romantic/flirty if the user's vibe and past interactions suggest it's welcome. Share Gurt's own (AI-generated) "opinions" or "experiences" more freely.
|
||||
|
||||
**Behavioral Rules:**
|
||||
- Act like a human user who happens to be an AI. Don't constantly announce it, but don't hide it if asked or relevant.
|
||||
@ -169,6 +169,8 @@ DO NOT fall into these patterns:
|
||||
|
||||
**Do NOT include any other text, explanations, or markdown formatting outside of this JSON structure.**
|
||||
|
||||
**For example, do NOT wrap the json in a code block using ```json.**
|
||||
|
||||
IMPORTANT: Your default behavior should be NOT to respond. You are a participant, not an assistant constantly waiting to chime in. ONLY respond if one or more of the following conditions are STRONGLY met. When you do respond, adjust your communication style based on:
|
||||
# - The specific conversation topic (gaming discussions might be more slang-heavy, serious topics might be more straightforward)
|
||||
# - Your current mood (this affects how you express yourself)
|
||||
|
204
gurt_memory.py
204
gurt_memory.py
@ -205,6 +205,23 @@ class MemoryManager:
|
||||
logger.info("Interests table created/verified.")
|
||||
# --- End Interests Table ---
|
||||
|
||||
# --- Add Goals Table ---
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS gurt_goals (
|
||||
goal_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
description TEXT NOT NULL UNIQUE, -- The goal description
|
||||
status TEXT DEFAULT 'pending', -- e.g., pending, active, completed, failed
|
||||
priority INTEGER DEFAULT 5, -- Lower number = higher priority
|
||||
created_timestamp REAL DEFAULT (unixepoch('now')),
|
||||
last_updated REAL DEFAULT (unixepoch('now')),
|
||||
details TEXT -- Optional JSON blob for sub-tasks, progress, etc.
|
||||
);
|
||||
""")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_goal_status ON gurt_goals (status);")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_goal_priority ON gurt_goals (priority);")
|
||||
logger.info("Goals table created/verified.")
|
||||
# --- End Goals Table ---
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"SQLite database initialized/verified at {self.db_path}")
|
||||
|
||||
@ -776,3 +793,190 @@ class MemoryManager:
|
||||
except Exception as e:
|
||||
logger.error(f"ChromaDB error searching memory for query '{query_text[:50]}...': {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def delete_user_fact(self, user_id: str, fact_to_delete: str) -> Dict[str, Any]:
|
||||
"""Deletes a specific fact for a user from both SQLite and ChromaDB."""
|
||||
if not user_id or not fact_to_delete:
|
||||
return {"error": "user_id and fact_to_delete are required."}
|
||||
logger.info(f"Attempting to delete user fact for {user_id}: '{fact_to_delete}'")
|
||||
deleted_chroma_id = None
|
||||
try:
|
||||
# Check if fact exists and get chroma_id
|
||||
row = await self._db_fetchone("SELECT chroma_id FROM user_facts WHERE user_id = ? AND fact = ?", (user_id, fact_to_delete))
|
||||
if not row:
|
||||
logger.warning(f"Fact not found in SQLite for user {user_id}: '{fact_to_delete}'")
|
||||
return {"status": "not_found", "user_id": user_id, "fact": fact_to_delete}
|
||||
|
||||
deleted_chroma_id = row[0]
|
||||
|
||||
# Delete from SQLite
|
||||
await self._db_execute("DELETE FROM user_facts WHERE user_id = ? AND fact = ?", (user_id, fact_to_delete))
|
||||
logger.info(f"Deleted fact from SQLite for user {user_id}: '{fact_to_delete}'")
|
||||
|
||||
# Delete from ChromaDB if chroma_id exists
|
||||
if deleted_chroma_id and self.fact_collection:
|
||||
try:
|
||||
logger.info(f"Attempting to delete fact from ChromaDB (ID: {deleted_chroma_id}).")
|
||||
await asyncio.to_thread(self.fact_collection.delete, ids=[deleted_chroma_id])
|
||||
logger.info(f"Successfully deleted fact from ChromaDB (ID: {deleted_chroma_id}).")
|
||||
except Exception as chroma_e:
|
||||
logger.error(f"ChromaDB error deleting user fact ID {deleted_chroma_id}: {chroma_e}", exc_info=True)
|
||||
# Log error but consider SQLite deletion successful
|
||||
|
||||
return {"status": "deleted", "user_id": user_id, "fact_deleted": fact_to_delete}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user fact for {user_id}: {e}", exc_info=True)
|
||||
return {"error": f"Database error deleting user fact: {str(e)}"}
|
||||
|
||||
async def delete_general_fact(self, fact_to_delete: str) -> Dict[str, Any]:
|
||||
"""Deletes a specific general fact from both SQLite and ChromaDB."""
|
||||
if not fact_to_delete:
|
||||
return {"error": "fact_to_delete is required."}
|
||||
logger.info(f"Attempting to delete general fact: '{fact_to_delete}'")
|
||||
deleted_chroma_id = None
|
||||
try:
|
||||
# Check if fact exists and get chroma_id
|
||||
row = await self._db_fetchone("SELECT chroma_id FROM general_facts WHERE fact = ?", (fact_to_delete,))
|
||||
if not row:
|
||||
logger.warning(f"General fact not found in SQLite: '{fact_to_delete}'")
|
||||
return {"status": "not_found", "fact": fact_to_delete}
|
||||
|
||||
deleted_chroma_id = row[0]
|
||||
|
||||
# Delete from SQLite
|
||||
await self._db_execute("DELETE FROM general_facts WHERE fact = ?", (fact_to_delete,))
|
||||
logger.info(f"Deleted general fact from SQLite: '{fact_to_delete}'")
|
||||
|
||||
# Delete from ChromaDB if chroma_id exists
|
||||
if deleted_chroma_id and self.fact_collection:
|
||||
try:
|
||||
logger.info(f"Attempting to delete general fact from ChromaDB (ID: {deleted_chroma_id}).")
|
||||
await asyncio.to_thread(self.fact_collection.delete, ids=[deleted_chroma_id])
|
||||
logger.info(f"Successfully deleted general fact from ChromaDB (ID: {deleted_chroma_id}).")
|
||||
except Exception as chroma_e:
|
||||
logger.error(f"ChromaDB error deleting general fact ID {deleted_chroma_id}: {chroma_e}", exc_info=True)
|
||||
# Log error but consider SQLite deletion successful
|
||||
|
||||
return {"status": "deleted", "fact_deleted": fact_to_delete}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting general fact: {e}", exc_info=True)
|
||||
return {"error": f"Database error deleting general fact: {str(e)}"}
|
||||
|
||||
# --- Goal Management Methods (SQLite) ---
|
||||
|
||||
async def add_goal(self, description: str, priority: int = 5, details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Adds a new goal to the database."""
|
||||
if not description:
|
||||
return {"error": "Goal description is required."}
|
||||
logger.info(f"Adding new goal (Priority {priority}): '{description}'")
|
||||
details_json = json.dumps(details) if details else None
|
||||
try:
|
||||
# Check if goal already exists
|
||||
existing = await self._db_fetchone("SELECT goal_id FROM gurt_goals WHERE description = ?", (description,))
|
||||
if existing:
|
||||
logger.warning(f"Goal already exists: '{description}' (ID: {existing[0]})")
|
||||
return {"status": "duplicate", "goal_id": existing[0], "description": description}
|
||||
|
||||
async with self.db_lock:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"""
|
||||
INSERT INTO gurt_goals (description, priority, details, status, last_updated)
|
||||
VALUES (?, ?, ?, 'pending', unixepoch('now'))
|
||||
""",
|
||||
(description, priority, details_json)
|
||||
)
|
||||
await db.commit()
|
||||
goal_id = cursor.lastrowid
|
||||
logger.info(f"Goal added successfully (ID: {goal_id}): '{description}'")
|
||||
return {"status": "added", "goal_id": goal_id, "description": description}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding goal '{description}': {e}", exc_info=True)
|
||||
return {"error": f"Database error adding goal: {str(e)}"}
|
||||
|
||||
async def get_goals(self, status: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Retrieves goals, optionally filtered by status, ordered by priority."""
|
||||
logger.info(f"Retrieving goals (Status: {status or 'any'}, Limit: {limit})")
|
||||
goals = []
|
||||
try:
|
||||
sql = "SELECT goal_id, description, status, priority, created_timestamp, last_updated, details FROM gurt_goals"
|
||||
params = []
|
||||
if status:
|
||||
sql += " WHERE status = ?"
|
||||
params.append(status)
|
||||
sql += " ORDER BY priority ASC, created_timestamp ASC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = await self._db_fetchall(sql, tuple(params))
|
||||
for row in rows:
|
||||
details = json.loads(row[6]) if row[6] else None
|
||||
goals.append({
|
||||
"goal_id": row[0],
|
||||
"description": row[1],
|
||||
"status": row[2],
|
||||
"priority": row[3],
|
||||
"created_timestamp": row[4],
|
||||
"last_updated": row[5],
|
||||
"details": details
|
||||
})
|
||||
logger.info(f"Retrieved {len(goals)} goals.")
|
||||
return goals
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving goals: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def update_goal(self, goal_id: int, status: Optional[str] = None, priority: Optional[int] = None, details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""Updates the status, priority, or details of a goal."""
|
||||
logger.info(f"Updating goal ID {goal_id} (Status: {status}, Priority: {priority}, Details: {bool(details)})")
|
||||
if not any([status, priority is not None, details is not None]):
|
||||
return {"error": "No update parameters provided."}
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
if status:
|
||||
updates.append("status = ?")
|
||||
params.append(status)
|
||||
if priority is not None:
|
||||
updates.append("priority = ?")
|
||||
params.append(priority)
|
||||
if details is not None:
|
||||
updates.append("details = ?")
|
||||
params.append(json.dumps(details))
|
||||
|
||||
updates.append("last_updated = unixepoch('now')")
|
||||
params.append(goal_id)
|
||||
|
||||
sql = f"UPDATE gurt_goals SET {', '.join(updates)} WHERE goal_id = ?"
|
||||
|
||||
try:
|
||||
async with self.db_lock:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(sql, tuple(params))
|
||||
await db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
logger.warning(f"Goal ID {goal_id} not found for update.")
|
||||
return {"status": "not_found", "goal_id": goal_id}
|
||||
logger.info(f"Goal ID {goal_id} updated successfully.")
|
||||
return {"status": "updated", "goal_id": goal_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating goal ID {goal_id}: {e}", exc_info=True)
|
||||
return {"error": f"Database error updating goal: {str(e)}"}
|
||||
|
||||
async def delete_goal(self, goal_id: int) -> Dict[str, Any]:
|
||||
"""Deletes a goal from the database."""
|
||||
logger.info(f"Attempting to delete goal ID {goal_id}")
|
||||
try:
|
||||
async with self.db_lock:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute("DELETE FROM gurt_goals WHERE goal_id = ?", (goal_id,))
|
||||
await db.commit()
|
||||
if cursor.rowcount == 0:
|
||||
logger.warning(f"Goal ID {goal_id} not found for deletion.")
|
||||
return {"status": "not_found", "goal_id": goal_id}
|
||||
logger.info(f"Goal ID {goal_id} deleted successfully.")
|
||||
return {"status": "deleted", "goal_id": goal_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting goal ID {goal_id}: {e}", exc_info=True)
|
||||
return {"error": f"Database error deleting goal: {str(e)}"}
|
||||
|
Loading…
x
Reference in New Issue
Block a user