360 lines
14 KiB
Python
360 lines
14 KiB
Python
"""
|
|
Think operation utilities for formulating answers based on agent and world facts.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from ..response_models import DispositionTraits, MemoryFact
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Opinion(BaseModel):
|
|
"""An opinion formed by the bank."""
|
|
|
|
opinion: str = Field(description="The opinion or perspective with reasoning included")
|
|
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"
|
|
)
|
|
|
|
|
|
def describe_trait_level(value: int) -> str:
|
|
"""Convert trait value (1-5) to descriptive text."""
|
|
levels = {1: "very low", 2: "low", 3: "moderate", 4: "high", 5: "very high"}
|
|
return levels.get(value, "moderate")
|
|
|
|
|
|
def build_disposition_description(disposition: DispositionTraits) -> str:
|
|
"""Build a disposition description string from disposition traits."""
|
|
skepticism_desc = {
|
|
1: "You are very trusting and tend to take information at face value.",
|
|
2: "You tend to trust information but may question obvious inconsistencies.",
|
|
3: "You have a balanced approach to information, neither too trusting nor too skeptical.",
|
|
4: "You are somewhat skeptical and often question the reliability of information.",
|
|
5: "You are highly skeptical and critically examine all information for accuracy and hidden motives.",
|
|
}
|
|
|
|
literalism_desc = {
|
|
1: "You interpret information very flexibly, reading between the lines and inferring intent.",
|
|
2: "You tend to consider context and implied meaning alongside literal statements.",
|
|
3: "You balance literal interpretation with contextual understanding.",
|
|
4: "You prefer to interpret information more literally and precisely.",
|
|
5: "You interpret information very literally and focus on exact wording and commitments.",
|
|
}
|
|
|
|
empathy_desc = {
|
|
1: "You focus primarily on facts and data, setting aside emotional context.",
|
|
2: "You consider facts first but acknowledge emotional factors exist.",
|
|
3: "You balance factual analysis with emotional understanding.",
|
|
4: "You give significant weight to emotional context and human factors.",
|
|
5: "You strongly consider the emotional state and circumstances of others when forming memories.",
|
|
}
|
|
|
|
return f"""Your disposition traits:
|
|
- Skepticism ({describe_trait_level(disposition.skepticism)}): {skepticism_desc.get(disposition.skepticism, skepticism_desc[3])}
|
|
- Literalism ({describe_trait_level(disposition.literalism)}): {literalism_desc.get(disposition.literalism, literalism_desc[3])}
|
|
- Empathy ({describe_trait_level(disposition.empathy)}): {empathy_desc.get(disposition.empathy, empathy_desc[3])}"""
|
|
|
|
|
|
def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
|
|
"""Format facts as JSON for LLM prompt."""
|
|
import json
|
|
|
|
if not facts:
|
|
return "[]"
|
|
formatted = []
|
|
for fact in facts:
|
|
fact_obj = {"text": fact.text}
|
|
|
|
# Add context if available
|
|
if fact.context:
|
|
fact_obj["context"] = fact.context
|
|
|
|
# Add occurred_start if available (when the fact occurred)
|
|
if fact.occurred_start:
|
|
occurred_start = fact.occurred_start
|
|
if isinstance(occurred_start, str):
|
|
fact_obj["occurred_start"] = occurred_start
|
|
elif isinstance(occurred_start, datetime):
|
|
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
formatted.append(fact_obj)
|
|
|
|
return json.dumps(formatted, indent=2)
|
|
|
|
|
|
def build_think_prompt(
|
|
agent_facts_text: str,
|
|
world_facts_text: str,
|
|
opinion_facts_text: str,
|
|
query: str,
|
|
name: str,
|
|
disposition: DispositionTraits,
|
|
background: str,
|
|
context: str | None = None,
|
|
) -> str:
|
|
"""Build the think prompt for the LLM."""
|
|
disposition_desc = build_disposition_description(disposition)
|
|
|
|
name_section = f"""
|
|
|
|
Your name: {name}
|
|
"""
|
|
|
|
background_section = ""
|
|
if background:
|
|
background_section = f"""
|
|
|
|
Your background:
|
|
{background}
|
|
"""
|
|
|
|
context_section = ""
|
|
if context:
|
|
context_section = f"""
|
|
ADDITIONAL CONTEXT:
|
|
{context}
|
|
|
|
"""
|
|
|
|
return f"""Here's what I know and have experienced:
|
|
|
|
MY IDENTITY & EXPERIENCES:
|
|
{agent_facts_text}
|
|
|
|
WHAT I KNOW ABOUT THE WORLD:
|
|
{world_facts_text}
|
|
|
|
MY EXISTING OPINIONS & BELIEFS:
|
|
{opinion_facts_text}
|
|
|
|
{context_section}{name_section}{disposition_desc}{background_section}
|
|
|
|
QUESTION: {query}
|
|
|
|
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
|
|
|
|
|
|
def get_system_message(disposition: DispositionTraits) -> str:
|
|
"""Get the system message for the think LLM call."""
|
|
# Build disposition-specific instructions based on trait values
|
|
instructions = []
|
|
|
|
# Skepticism influences how much to question/doubt information
|
|
if disposition.skepticism >= 4:
|
|
instructions.append("Be skeptical of claims and look for potential issues or inconsistencies.")
|
|
elif disposition.skepticism <= 2:
|
|
instructions.append("Trust the information provided and take statements at face value.")
|
|
|
|
# Literalism influences interpretation style
|
|
if disposition.literalism >= 4:
|
|
instructions.append("Interpret information literally and focus on exact commitments and wording.")
|
|
elif disposition.literalism <= 2:
|
|
instructions.append("Read between the lines and consider implied meaning and context.")
|
|
|
|
# Empathy influences consideration of emotional factors
|
|
if disposition.empathy >= 4:
|
|
instructions.append("Consider the emotional state and circumstances behind the information.")
|
|
elif disposition.empathy <= 2:
|
|
instructions.append("Focus on facts and outcomes rather than emotional context.")
|
|
|
|
disposition_instruction = (
|
|
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
|
|
)
|
|
|
|
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
|
|
|
|
|
|
async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]:
|
|
"""
|
|
Extract opinions with reasons and confidence from text using LLM.
|
|
|
|
Args:
|
|
llm_config: LLM configuration to use
|
|
text: Text to extract opinions from
|
|
query: The original query that prompted this response
|
|
|
|
Returns:
|
|
List of Opinion objects with text and confidence
|
|
"""
|
|
extraction_prompt = f"""Extract any NEW opinions or perspectives from the answer below and rewrite them in FIRST-PERSON as if YOU are stating the opinion directly.
|
|
|
|
ORIGINAL QUESTION:
|
|
{query}
|
|
|
|
ANSWER PROVIDED:
|
|
{text}
|
|
|
|
Your task: Find opinions in the answer and rewrite them AS IF YOU ARE THE ONE SAYING THEM.
|
|
|
|
An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts.
|
|
|
|
IMPORTANT: Do NOT extract statements like:
|
|
- "I don't have enough information"
|
|
- "The facts don't contain information about X"
|
|
- "I cannot answer because..."
|
|
|
|
ONLY extract actual opinions about substantive topics.
|
|
|
|
CRITICAL FORMAT REQUIREMENTS:
|
|
1. **ALWAYS start with first-person phrases**: "I think...", "I believe...", "In my view...", "I've come to believe...", "Previously I thought... but now..."
|
|
2. **NEVER use third-person**: Do NOT say "The speaker thinks..." or "They believe..." - always use "I"
|
|
3. Include the reasoning naturally within the statement
|
|
4. Provide a confidence score (0.0 to 1.0)
|
|
|
|
CORRECT Examples (✓ FIRST-PERSON):
|
|
- "I think Alice is more reliable because she consistently delivers on time and writes clean code"
|
|
- "Previously I thought all engineers were equal, but now I feel that experience and track record really matter"
|
|
- "I believe reliability is best measured by consistent output over time"
|
|
- "I've come to believe that track records are more important than potential"
|
|
|
|
WRONG Examples (✗ THIRD-PERSON - DO NOT USE):
|
|
- "The speaker thinks Alice is more reliable"
|
|
- "They believe reliability matters"
|
|
- "It is believed that Alice is better"
|
|
|
|
If no genuine opinions are expressed (e.g., the response just says "I don't know"), return an empty list."""
|
|
|
|
try:
|
|
result = await llm_config.call(
|
|
messages=[
|
|
{
|
|
"role": "system",
|
|
"content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.",
|
|
},
|
|
{"role": "user", "content": extraction_prompt},
|
|
],
|
|
response_format=OpinionExtractionResponse,
|
|
scope="memory_extract_opinion",
|
|
)
|
|
|
|
# Format opinions with confidence score and convert to first-person
|
|
formatted_opinions = []
|
|
for op in result.opinions:
|
|
# Convert third-person to first-person if needed
|
|
opinion_text = op.opinion
|
|
|
|
# Replace common third-person patterns with first-person
|
|
def singularize_verb(verb):
|
|
if verb.endswith("es"):
|
|
return verb[:-1] # believes -> believe
|
|
elif verb.endswith("s"):
|
|
return verb[:-1] # thinks -> think
|
|
return verb
|
|
|
|
# Pattern: "The speaker/user [verb]..." -> "I [verb]..."
|
|
match = re.match(
|
|
r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$",
|
|
opinion_text,
|
|
re.IGNORECASE,
|
|
)
|
|
if match:
|
|
verb = singularize_verb(match.group(2))
|
|
that_part = match.group(3) or "" # Keep " that" if present
|
|
rest = match.group(4)
|
|
opinion_text = f"I {verb}{that_part}{rest}"
|
|
|
|
# If still doesn't start with first-person, prepend "I believe that "
|
|
first_person_starters = [
|
|
"I think",
|
|
"I believe",
|
|
"I feel",
|
|
"In my view",
|
|
"I've come to believe",
|
|
"Previously I",
|
|
]
|
|
if not any(opinion_text.startswith(starter) for starter in first_person_starters):
|
|
opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
|
|
|
|
formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence))
|
|
|
|
return formatted_opinions
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to extract opinions: {str(e)}")
|
|
return []
|
|
|
|
|
|
async def reflect(
|
|
llm_config,
|
|
query: str,
|
|
experience_facts: list[str] = None,
|
|
world_facts: list[str] = None,
|
|
opinion_facts: list[str] = None,
|
|
name: str = "Assistant",
|
|
disposition: DispositionTraits = None,
|
|
background: str = "",
|
|
context: str = None,
|
|
) -> str:
|
|
"""
|
|
Standalone reflect function for generating answers based on facts.
|
|
|
|
This is a static version of the reflect operation that can be called
|
|
without a MemoryEngine instance, useful for testing.
|
|
|
|
Args:
|
|
llm_config: LLM provider instance
|
|
query: Question to answer
|
|
experience_facts: List of experience/agent fact strings
|
|
world_facts: List of world fact strings
|
|
opinion_facts: List of opinion fact strings
|
|
name: Name of the agent/persona
|
|
disposition: Disposition traits (defaults to neutral)
|
|
background: Background information
|
|
context: Additional context for the prompt
|
|
|
|
Returns:
|
|
Generated answer text
|
|
"""
|
|
# Default disposition if not provided
|
|
if disposition is None:
|
|
disposition = DispositionTraits(skepticism=3, literalism=3, empathy=3)
|
|
|
|
# Convert string lists to MemoryFact format for formatting
|
|
def to_memory_facts(facts: list[str], fact_type: str) -> list[MemoryFact]:
|
|
if not facts:
|
|
return []
|
|
return [MemoryFact(id=f"test-{i}", text=f, fact_type=fact_type) for i, f in enumerate(facts)]
|
|
|
|
agent_results = to_memory_facts(experience_facts or [], "experience")
|
|
world_results = to_memory_facts(world_facts or [], "world")
|
|
opinion_results = to_memory_facts(opinion_facts or [], "opinion")
|
|
|
|
# Format facts for prompt
|
|
agent_facts_text = format_facts_for_prompt(agent_results)
|
|
world_facts_text = format_facts_for_prompt(world_results)
|
|
opinion_facts_text = format_facts_for_prompt(opinion_results)
|
|
|
|
# Build prompt
|
|
prompt = build_think_prompt(
|
|
agent_facts_text=agent_facts_text,
|
|
world_facts_text=world_facts_text,
|
|
opinion_facts_text=opinion_facts_text,
|
|
query=query,
|
|
name=name,
|
|
disposition=disposition,
|
|
background=background,
|
|
context=context,
|
|
)
|
|
|
|
system_message = get_system_message(disposition)
|
|
|
|
# Call LLM
|
|
answer_text = await llm_config.call(
|
|
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
|
|
scope="memory_think",
|
|
temperature=0.9,
|
|
max_completion_tokens=1000,
|
|
)
|
|
|
|
return answer_text.strip()
|