fleet-memory/memora/operations/think_operations.py
2025-11-05 15:35:07 +01:00

288 lines
10 KiB
Python

"""
Think operations for formulating answers based on agent and world facts.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Dict, List, Any
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class ThinkOperationsMixin:
"""Mixin class for think operations."""
async def think_async(
self,
agent_id: str,
query: str,
thinking_budget: int = 50,
top_k: int = 10,
) -> Dict[str, Any]:
"""
Think and formulate an answer using agent identity, world facts, and opinions.
This method:
1. Retrieves agent facts (agent's identity and past actions)
2. Retrieves world facts (general knowledge)
3. Retrieves existing opinions (agent's formed perspectives)
4. Uses LLM to formulate an answer
5. Extracts and stores any new opinions formed during thinking
6. Returns plain text answer and the facts used
Args:
agent_id: Agent identifier
query: Question to answer
thinking_budget: Number of memory units to explore
top_k: Maximum facts to retrieve
Returns:
Dict with:
- text: Plain text answer (no markdown)
- based_on: Dict with 'world', 'agent', and 'opinion' fact lists
- new_opinions: List of newly formed opinions
"""
# Use cached LLM config
if self._llm_config is None:
raise ValueError("Memory LLM API key not set. Set MEMORY_LLM_API_KEY environment variable.")
# Steps 1-3: Run all three searches in parallel
(agent_results, _), (world_results, _), (opinion_results, _) = await asyncio.gather(
# Get agent facts (identity)
self.search_async(
agent_id=agent_id,
query=query,
thinking_budget=thinking_budget,
top_k=top_k,
enable_trace=False,
fact_type='agent'
),
# Get world facts
self.search_async(
agent_id=agent_id,
query=query,
thinking_budget=thinking_budget,
top_k=top_k,
enable_trace=False,
fact_type='world'
),
# Get existing opinions
self.search_async(
agent_id=agent_id,
query=query,
thinking_budget=thinking_budget,
top_k=top_k,
enable_trace=False,
fact_type='opinion'
)
)
# Step 4: Format facts for LLM with full details as JSON
import json
def format_facts(facts):
if not facts:
return "[]"
formatted = []
for fact in facts:
fact_obj = {
"text": fact['text']
}
# Add context if available
if fact.get('context'):
fact_obj["context"] = fact['context']
# Add event_date if available
if fact.get('event_date'):
from datetime import datetime
event_date = fact['event_date']
if isinstance(event_date, str):
fact_obj["event_date"] = event_date
elif isinstance(event_date, datetime):
fact_obj["event_date"] = event_date.strftime('%Y-%m-%d %H:%M:%S')
# Add score if available
if fact.get('score') is not None:
fact_obj["score"] = fact['score']
formatted.append(fact_obj)
return json.dumps(formatted, indent=2)
agent_facts_text = format_facts(agent_results)
world_facts_text = format_facts(world_results)
opinion_facts_text = format_facts(opinion_results)
# Step 5: Call Groq to formulate answer
prompt = f"""You are an AI assistant answering a question based on retrieved facts provided in JSON format.
AGENT IDENTITY (what the agent has done):
{agent_facts_text}
WORLD FACTS (general knowledge):
{world_facts_text}
YOUR EXISTING OPINIONS (perspectives you've formed):
{opinion_facts_text}
QUESTION: {query}
The facts above are provided as JSON arrays. Each fact may include:
- text: The fact content
- context: Additional context information
- event_date: When the fact occurred
- score: Relevance score
Provide a helpful, accurate answer based on the facts above. Be consistent with your existing opinions. If the facts don't contain enough information to answer the question, say so clearly. Do not use markdown formatting - respond in plain text only.
If you form any new opinions while thinking about this question, state them clearly in your answer."""
answer_text = await self._llm_config.call(
messages=[
{"role": "system", "content": "You are a helpful AI assistant. Always respond in plain text without markdown formatting. You can form and express opinions based on facts."},
{"role": "user", "content": prompt}
],
scope="memory_think",
temperature=0.7,
max_tokens=1000
)
answer_text = answer_text.strip()
# Step 6: Extract and store new opinions asynchronously (fire and forget)
await self._task_backend.submit_task({
'type': 'form_opinion',
'agent_id': agent_id,
'answer_text': answer_text,
'query': query
})
# Step 7: Return response with facts split by type (don't wait for opinions)
return {
"text": answer_text,
"based_on": {
"world": world_results,
"agent": agent_results,
"opinion": opinion_results
},
"new_opinions": [] # Opinions are being extracted asynchronously
}
async def _extract_and_store_opinions_async(
self,
agent_id: str,
answer_text: str,
query: str
):
"""
Background task to extract and store opinions from think response.
This runs asynchronously and does not block the think response.
Args:
agent_id: Agent identifier
answer_text: The generated answer text
query: The original query
"""
try:
# Extract opinions from the answer
new_opinions = await self._extract_opinions_from_text(text=answer_text, query=query)
# Store new opinions
if new_opinions:
current_time = datetime.now(timezone.utc)
for opinion_dict in new_opinions:
await self.put_async(
agent_id=agent_id,
content=opinion_dict["text"],
context=f"formed during thinking about: {query}",
event_date=current_time,
fact_type_override='opinion',
confidence_score=opinion_dict["confidence"]
)
logger.debug(f"[THINK] Extracted and stored {len(new_opinions)} new opinions")
except Exception as e:
logger.warning(f"[THINK] Failed to extract/store opinions: {str(e)}")
async def _extract_opinions_from_text(
self,
text: str,
query: str
) -> List[Dict[str, Any]]:
"""
Extract opinions with reasons and confidence from text using LLM.
Args:
text: Text to extract opinions from
query: The original query that prompted this response
Returns:
List of dicts with keys: 'text' (opinion with reasons), 'confidence' (score 0-1)
"""
class Opinion(BaseModel):
"""An opinion formed by the agent."""
opinion: str = Field(description="The opinion or perspective formed")
reasons: str = Field(description="The reasons supporting this opinion")
confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
class OpinionExtractionResponse(BaseModel):
"""Response containing extracted opinions."""
opinions: List[Opinion] = Field(
default_factory=list,
description="List of opinions formed with their supporting reasons and confidence scores"
)
extraction_prompt = f"""Extract any NEW opinions or perspectives that were formed while answering the following question.
ORIGINAL QUESTION:
{query}
ANSWER PROVIDED:
{text}
An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts. It represents a formed perspective or belief.
IMPORTANT: Do NOT extract statements like:
- "I don't have enough information"
- "The facts don't contain information about X"
- "I cannot answer because..."
- Simple acknowledgments or meta-statements about the query itself
ONLY extract actual opinions, judgments, or perspectives about substantive topics.
For each opinion found, provide:
1. The opinion itself (what the agent believes or concludes)
2. The reasons or facts that support it
3. A confidence score (0.0 to 1.0) indicating how confident the agent is in this opinion
If no genuine opinions are expressed (e.g., the response just says "I don't know"), return an empty list."""
try:
result = await self._llm_config.call(
messages=[
{"role": "system", "content": "You extract opinions and perspectives from text."},
{"role": "user", "content": extraction_prompt}
],
response_format=OpinionExtractionResponse,
scope="memory_extract_opinion"
)
# Format opinions with reasons included in the text and confidence score
formatted_opinions = []
for op in result.opinions:
# Combine opinion and reasons into a single statement
opinion_with_reasons = f"{op.opinion} (Reasons: {op.reasons})"
formatted_opinions.append({
"text": opinion_with_reasons,
"confidence": op.confidence
})
return formatted_opinions
except Exception as e:
logger.warning(f"Failed to extract opinions: {str(e)}")
return []