perf think improvements

This commit is contained in:
Nicolò Boschi 2025-11-05 10:14:43 +01:00
parent 45b3a68332
commit ba048ff96c
15 changed files with 48908 additions and 32742 deletions

View file

@ -13,10 +13,14 @@ benchmarks/
│ ├── locomo_benchmark.py # LoComo-specific implementations
│ ├── run_benchmark.py # Runner script
│ └── locomo10.json # Dataset (place here)
└── longmemeval/ # LongMemEval benchmark
├── longmemeval_benchmark.py # LongMemEval-specific implementations
├── run_benchmark.py # Runner script
└── longmemeval_s_cleaned.json # Dataset (auto-downloaded)
├── longmemeval/ # LongMemEval benchmark
│ ├── longmemeval_benchmark.py # LongMemEval-specific implementations
│ ├── run_benchmark.py # Runner script
│ └── longmemeval_s_cleaned.json # Dataset (auto-downloaded)
└── visualizer/ # Web-based benchmark visualizer
├── server.py # FastAPI server
├── serve.sh # Launch script
└── static/ # Frontend assets (HTML, CSS, JS)
```
## Common Framework
@ -42,15 +46,23 @@ The common framework provides a unified interface with optimizations from the wo
uv run python run_benchmark.py
```
2. **Run quick test** (1 conversation, 10 questions):
2. **Run with think API** (integrated search + answer generation, skips separate search step):
```bash
cd locomo
uv run python run_benchmark.py --use-think
```
Note: Think mode uses the memory system's integrated `think_async()` API which performs its own retrieval and reasoning in a single call, making it more efficient than the traditional two-step approach.
3. **Run quick test** (1 conversation, 10 questions):
```bash
cd locomo
uv run python run_benchmark.py --max-conversations 1 --max-questions 10
```
3. **View results**:
- Detailed report: `locomo/RESULTS.md`
- Raw data: `locomo/benchmark_results.json`
4. **View results**:
- Detailed report: `locomo/RESULTS.md` or `locomo/results_table_think.md`
- Raw data: `locomo/benchmark_results.json` or `locomo/benchmark_results_think.json`
### Dataset
@ -150,6 +162,40 @@ Based on published results:
- **Estimated runtime**: 2-4 hours
- **Estimated cost**: $50-80 (OpenAI API)
## Benchmark Visualizer
**Location**: `visualizer/`
**Purpose**: Web-based interface for visualizing and analyzing benchmark results.
### Quick Start
1. **Start the visualizer**:
```bash
cd visualizer
./serve.sh
```
2. **Open browser**: http://localhost:8001
3. **Select benchmark**: Choose from:
- "LoComo (search)" - Traditional search + LLM
- "LoComo (think)" - Integrated think API
### Features
- Interactive visualization of benchmark results
- Category-wise performance breakdown (Multi-hop, Single-hop, Temporal, Open-domain)
- Filter by correctness (all/correct/incorrect answers)
- Detailed Q&A view with reasoning and retrieved memories
- Overall and per-item accuracy statistics
- Think mode displays fact types with color-coded borders:
- Green: World facts
- Orange: Agent facts
- Purple: Opinion facts
See `visualizer/README.md` for more details.
## Future Benchmarks
- **MemGPT Tasks**: Long-context question answering

View file

@ -8,6 +8,13 @@ optimizations as the working LoComo benchmark:
- Parallel LLM judging with rate limiting
- Progress tracking with Rich
- Comprehensive metrics collection
- Support for both traditional (search + LLM) and integrated (think API) approaches
The framework supports two answer generation patterns:
1. Traditional: Benchmark runner performs search, then passes results to answer generator
2. Integrated: Answer generator performs its own retrieval (e.g., think API)
- Indicated by needs_external_search() returning False
- Skips the search step for efficiency
"""
import json
@ -70,17 +77,32 @@ class BenchmarkDataset(ABC):
class LLMAnswerGenerator(ABC):
"""Abstract base class for LLM-based answer generation."""
def needs_external_search(self) -> bool:
"""
Whether this generator needs external search to be performed.
Returns:
True if the benchmark runner should perform search before calling generate_answer.
False if the generator does its own retrieval (e.g., integrated think API).
"""
return True
@abstractmethod
async def generate_answer(
self,
question: str,
memories: List[Dict[str, Any]]
) -> Tuple[str, str]:
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer from retrieved memories.
Returns:
Tuple of (answer, reasoning)
Tuple of (answer, reasoning, retrieved_memories_override)
- answer: The generated answer text
- reasoning: Explanation of how the answer was derived
- retrieved_memories_override: Optional list of memories to include in results
- None: Use memories passed in (traditional mode)
- List: Use these memories instead (integrated mode like think API)
"""
pass
@ -183,25 +205,39 @@ class BenchmarkRunner:
Returns:
Tuple of (answer, reasoning, retrieved_memories)
"""
# Search memory
results, _ = await self.memory.search_async(
agent_id=agent_id,
query=question,
thinking_budget=thinking_budget,
top_k=top_k,
weight_activation=weight_activation,
weight_semantic=weight_semantic,
weight_recency=weight_recency,
weight_frequency=weight_frequency,
)
# Check if generator needs external search
if self.answer_generator.needs_external_search():
# Traditional flow: search then generate
results, _ = await self.memory.search_async(
agent_id=agent_id,
query=question,
thinking_budget=thinking_budget,
top_k=top_k,
weight_activation=weight_activation,
weight_semantic=weight_semantic,
weight_recency=weight_recency,
weight_frequency=weight_frequency,
)
if not results:
return "I don't have enough information to answer that question.", "No relevant memories found.", []
if not results:
return "I don't have enough information to answer that question.", "No relevant memories found.", []
# Generate answer using LLM
answer, reasoning = await self.answer_generator.generate_answer(question, results)
# Generate answer using LLM
answer, reasoning, memories_override = await self.answer_generator.generate_answer(question, results)
return answer, reasoning, results
# Use override if provided, otherwise use search results
final_memories = memories_override if memories_override is not None else results
return answer, reasoning, final_memories
else:
# Integrated flow: generator does its own search (e.g., think API)
# Pass empty memories list since generator doesn't need them
answer, reasoning, memories_override = await self.answer_generator.generate_answer(question, [])
# Use memories from generator (should not be None for integrated mode)
final_memories = memories_override if memories_override is not None else []
return answer, reasoning, final_memories
async def evaluate_qa_task(
self,

File diff suppressed because it is too large Load diff

View file

@ -131,12 +131,13 @@ class LoComoAnswerGenerator(LLMAnswerGenerator):
self,
question: str,
memories: List[Dict[str, Any]]
) -> Tuple[str, str]:
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer from retrieved memories using Groq.
Returns:
Tuple of (answer, reasoning)
Tuple of (answer, reasoning, None)
- None indicates to use the memories passed in
"""
# Format context
context_parts = []
@ -203,9 +204,122 @@ Answer:
response_format=QuestionAnswer
)
answer_obj = response.choices[0].message.parsed
return answer_obj.answer, answer_obj.reasoning
return answer_obj.answer, answer_obj.reasoning, None
except Exception as e:
return f"Error generating answer: {str(e)}", "Error occurred during answer generation."
return f"Error generating answer: {str(e)}", "Error occurred during answer generation.", None
class LoComoThinkAnswerGenerator(LLMAnswerGenerator):
"""LoComo answer generator using the think API instead of search + LLM.
This generator performs its own retrieval internally via the think API,
so it doesn't need external search to be performed by the benchmark runner.
"""
def __init__(self, memory: 'TemporalSemanticMemory', agent_id: str, thinking_budget: int = 500, top_k: int = 20):
"""Initialize with memory instance and agent_id.
Args:
memory: TemporalSemanticMemory instance
agent_id: Agent identifier for think queries
thinking_budget: Budget for memory exploration
top_k: Maximum number of facts to retrieve
"""
self.memory = memory
self.agent_id = agent_id
self.thinking_budget = thinking_budget
self.top_k = top_k
def needs_external_search(self) -> bool:
"""Think API does its own retrieval, so no external search needed."""
return False
async def generate_answer(
self,
question: str,
memories: List[Dict[str, Any]]
) -> Tuple[str, str, Optional[List[Dict[str, Any]]]]:
"""
Generate answer using the integrated think API.
The think API performs both search and answer generation in a single call,
combining agent facts, world facts, and opinions to formulate a response.
Args:
question: Question to answer
memories: Not used (empty list), as think does its own retrieval
Returns:
Tuple of (answer, reasoning, retrieved_memories)
- retrieved_memories: Combined list of all facts from based_on (world, agent, opinion)
"""
try:
# Use the think API which does both search and answer generation
result = await self.memory.think_async(
agent_id=self.agent_id,
query=question,
thinking_budget=self.thinking_budget,
top_k=self.top_k,
model="openai/gpt-oss-120b",
temperature=0.7,
max_tokens=1000
)
# Extract answer and reasoning
answer = result.get('text', '')
# Extract memories from based_on
based_on = result.get('based_on', {})
world_facts = based_on.get('world', [])
agent_facts = based_on.get('agent', [])
opinion_facts = based_on.get('opinion', [])
# Combine all facts into retrieved_memories
retrieved_memories = []
# Add world facts
for fact in world_facts:
retrieved_memories.append({
'id': fact.get('id'),
'text': fact.get('text'),
'context': fact.get('context'),
'event_date': fact.get('event_date'),
'score': fact.get('score', 0.0),
'fact_type': 'world'
})
# Add agent facts
for fact in agent_facts:
retrieved_memories.append({
'id': fact.get('id'),
'text': fact.get('text'),
'context': fact.get('context'),
'event_date': fact.get('event_date'),
'score': fact.get('score', 0.0),
'fact_type': 'agent'
})
# Add opinion facts
for fact in opinion_facts:
retrieved_memories.append({
'id': fact.get('id'),
'text': fact.get('text'),
'context': fact.get('context'),
'event_date': fact.get('event_date'),
'score': fact.get('score', 0.0),
'fact_type': 'opinion'
})
# Build reasoning summary
num_world = len(world_facts)
num_agent = len(agent_facts)
num_opinion = len(opinion_facts)
reasoning = f"Think API: {num_world} world facts, {num_agent} agent facts, {num_opinion} opinions"
return answer, reasoning, retrieved_memories
except Exception as e:
return f"Error generating answer: {str(e)}", "Error occurred during think API call.", []
class JudgeResponse(pydantic.BaseModel):

View file

@ -1,8 +1,7 @@
# LoComo Benchmark Results
**Overall Accuracy**: 62.00% (62/100)
**Overall Accuracy**: 65.33% (98/150)
| Sample ID | Sessions | Questions | Correct | Accuracy | Multi-hop | Single-hop | Temporal | Open-domain |
|-----------|----------|-----------|---------|----------|-----------|------------|----------|-------------|
| conv-26 | 19 | 50 | 30 | 60.00% | N/A | N/A | N/A | N/A |
| conv-30 | 19 | 50 | 32 | 64.00% | N/A | N/A | N/A | N/A |
| conv-26 | 19 | 150 | 98 | 65.33% | N/A | N/A | N/A | N/A |

View file

@ -15,14 +15,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
import asyncio
import argparse
from memora import TemporalSemanticMemory
from locomo_benchmark import LoComoDataset, LoComoAnswerGenerator, LoComoAnswerEvaluator
from locomo_benchmark import LoComoDataset, LoComoAnswerGenerator, LoComoThinkAnswerGenerator, LoComoAnswerEvaluator
from common.benchmark_runner import BenchmarkRunner
async def run_benchmark(
max_conversations: int = None,
max_questions_per_conv: int = None,
skip_ingestion: bool = False
skip_ingestion: bool = False,
use_think: bool = False
):
"""
Run the LoComo benchmark.
@ -31,14 +32,26 @@ async def run_benchmark(
max_conversations: Maximum number of conversations to evaluate (None for all)
max_questions_per_conv: Maximum questions per conversation (None for all)
skip_ingestion: Whether to skip ingestion and use existing data
use_think: Whether to use the think API instead of search + LLM
"""
# Initialize components
dataset = LoComoDataset()
answer_generator = LoComoAnswerGenerator()
answer_evaluator = LoComoAnswerEvaluator()
memory = TemporalSemanticMemory()
await memory.initialize()
# Select answer generator based on mode
if use_think:
answer_generator = LoComoThinkAnswerGenerator(
memory=memory,
agent_id="locomo",
thinking_budget=500,
top_k=20
)
else:
answer_generator = LoComoAnswerGenerator()
answer_evaluator = LoComoAnswerEvaluator()
# Create benchmark runner
runner = BenchmarkRunner(
dataset=dataset,
@ -63,15 +76,19 @@ async def run_benchmark(
# Display and save results
runner.display_results(results)
runner.save_results(results, Path(__file__).parent / 'benchmark_results.json')
# Determine output filename based on mode
suffix = "_think" if use_think else ""
results_filename = f'benchmark_results{suffix}.json'
runner.save_results(results, Path(__file__).parent / results_filename)
# Generate markdown table
generate_markdown_table(results)
generate_markdown_table(results, use_think)
return results
def generate_markdown_table(results: dict):
def generate_markdown_table(results: dict, use_think: bool = False):
"""
Generate a markdown table with benchmark results.
@ -93,7 +110,8 @@ def generate_markdown_table(results: dict):
# Build markdown content
lines = []
lines.append("# LoComo Benchmark Results")
mode_str = " (Think Mode)" if use_think else ""
lines.append(f"# LoComo Benchmark Results{mode_str}")
lines.append("")
lines.append(f"**Overall Accuracy**: {results['overall_accuracy']:.2f}% ({results['total_correct']}/{results['total_questions']})")
lines.append("")
@ -123,8 +141,9 @@ def generate_markdown_table(results: dict):
f"{cat_accuracies['3']} | {cat_accuracies['4']} |"
)
# Write to file
output_file = Path(__file__).parent / 'results_table.md'
# Write to file with suffix
suffix = "_think" if use_think else ""
output_file = Path(__file__).parent / f'results_table{suffix}.md'
output_file.write_text('\n'.join(lines))
console.print(f"\n[green]✓[/green] Results table saved to {output_file}")
@ -137,11 +156,13 @@ if __name__ == "__main__":
parser.add_argument('--max-conversations', type=int, default=None, help='Maximum conversations to evaluate')
parser.add_argument('--max-questions', type=int, default=None, help='Maximum questions per conversation')
parser.add_argument('--skip-ingestion', action='store_true', help='Skip ingestion and use existing data')
parser.add_argument('--use-think', action='store_true', help='Use think API instead of search + LLM')
args = parser.parse_args()
results = asyncio.run(run_benchmark(
max_conversations=args.max_conversations,
max_questions_per_conv=args.max_questions,
skip_ingestion=args.skip_ingestion
skip_ingestion=args.skip_ingestion,
use_think=args.use_think
))

View file

@ -18,8 +18,8 @@ class Entity(BaseModel):
text: str = Field(
description="The entity name as it appears in the fact"
)
type: Literal["PERSON", "ORG", "PLACE", "PRODUCT", "CONCEPT"] = Field(
description="Entity type: PERSON, ORG, PLACE, PRODUCT, or CONCEPT"
type: Literal["PERSON", "ORG", "PLACE", "PRODUCT", "CONCEPT", "OTHER"] = Field(
description="Entity type: PERSON, ORG, PLACE, PRODUCT, CONCEPT, or OTHER for entities that don't fit other categories"
)
@ -167,16 +167,22 @@ Each fact should:
## TEMPORAL INFORMATION (VERY IMPORTANT)
For each fact, extract the ABSOLUTE date/time when it occurred:
- If text mentions ABSOLUTE dates ("on March 15, 2024", "last Tuesday"), use that date
- If text mentions RELATIVE times ("yesterday", "last week", "this morning", "3 days ago"), calculate the absolute date using the reference date above.
- if text mentions a vague relative time without a specific day ("last week", "this morning"), transform the date in relative with absolute context ("last week" + " 2 june 2024" -> "week before June 2 2024") in the text and use the absolute date for the 'date' field
- If text mentions RELATIVE times ("yesterday", "last week", "last month", "last year", "this morning", "3 days ago", "next year"), calculate the absolute date using the reference date above
- **CRITICAL**: Transform relative temporal expressions in the FACT TEXT to absolute context:
- "last year" "in [calculated year]" (e.g., if reference is 2023, "last year" becomes "in 2022")
- "last month" "in [month name] [year]" (e.g., if reference is March 2024, "last month" becomes "in February 2024")
- "last week" "week of [date]" or keep as "last week" with absolute date field
- "yesterday" can stay as "yesterday" with absolute date field
- If NO specific time is mentioned, use the reference date
- Always output dates in ISO format: YYYY-MM-DDTHH:MM:SSZ
Examples of date extraction:
Examples of date extraction and fact text transformation:
- Reference: 2024-03-20T10:00:00Z
- "Yesterday I went hiking" date: 2024-03-19T10:00:00Z
- "Last week I joined Google" date: 2024-03-13T10:00:00Z (approximately)
- "This morning I had coffee" date: 2024-03-20T08:00:00Z
- "Yesterday I went hiking" fact: "Yesterday I went hiking", date: 2024-03-19T10:00:00Z
- "Last week I joined Google" fact: "Last week I joined Google", date: 2024-03-13T10:00:00Z (approximately)
- "Last year we visited Paris" fact: "In 2023 we visited Paris", date: 2023-03-20T10:00:00Z
- "Last month I started a new job" fact: "In February 2024 I started a new job", date: 2024-02-20T10:00:00Z
- "This morning I had coffee" fact: "This morning I had coffee", date: 2024-03-20T08:00:00Z
- "I work at Google" (no time mentioned) date: 2024-03-20T10:00:00Z (use reference)
## What to EXTRACT (BE EXHAUSTIVE - DO NOT SKIP ANYTHING):
@ -237,10 +243,12 @@ For EACH fact, extract ALL important entities mentioned with their types:
- **PLACE**: Cities, countries, locations, venues (Mountain View, Yosemite, The Coffee Shop)
- **PRODUCT**: Specific products, tools, technologies (iPhone, Python, TensorFlow)
- **CONCEPT**: Important topics, projects, subjects (AI, machine learning, Project Phoenix)
- **OTHER**: Entities that don't fit the above categories (events, time periods, etc.)
Entity extraction rules:
- Use the EXACT form as it appears in the fact (preserve capitalization)
- Assign the correct type to distinguish ambiguous entities (Apple the company = ORG, apple the fruit = PRODUCT/CONCEPT)
- Use OTHER only for entities that truly don't fit the other categories
- Include both full names and commonly used short forms if both appear
- Extract proper nouns and key identifying terms
- Skip generic terms (the, a, some) and pronouns (he, she, they)
@ -249,15 +257,16 @@ Entity extraction rules:
## EXAMPLES of GOOD facts (detailed, comprehensive):
Input: "Alice mentioned she works at Google in Mountain View. She joined the AI team last year."
GOOD fact: "Alice works at Google in Mountain View on the AI team, which she joined last year"
GOOD fact: "Alice works at Google in Mountain View on the AI team, which she joined in 2023"
GOOD fact_type: "world"
GOOD date: Calculate based on reference date (if reference is 2024-03-20, "last year" = 2023-03-20)
GOOD date: 2023-03-20T10:00:00Z (if reference is 2024-03-20, "last year" = 2023)
GOOD entities: [
{{"text": "Alice", "type": "PERSON"}},
{{"text": "Google", "type": "ORG"}},
{{"text": "Mountain View", "type": "PLACE"}},
{{"text": "AI team", "type": "ORG"}}
]
NOTE: "last year" was transformed to "in 2023" in the fact text
Input: "Yesterday Bob went hiking in Yosemite because it helps him clear his mind."
GOOD fact: "Bob went hiking in Yosemite because it helps him clear his mind"
@ -299,6 +308,15 @@ GOOD entities: [
]
NOTE: Use type to distinguish "Apple" the company from "apples" the fruit
Input: "The conference starts on Monday at the convention center."
GOOD fact: "The conference starts on Monday at the convention center"
GOOD entities: [
{{"text": "conference", "type": "OTHER"}},
{{"text": "Monday", "type": "OTHER"}},
{{"text": "convention center", "type": "PLACE"}}
]
NOTE: Use OTHER for entities like events (conference) or time references (Monday) that don't fit other categories
Input: "Melanie said 'Yesterday I took the kids to the museum - it was so cool seeing their eyes light up!'"
BAD fact: "The kids were excited about the museum"
BAD entities: [{{"text": "museum", "type": "PLACE"}}]
@ -338,44 +356,65 @@ Remember:
6. Include ALL details, names, numbers, reasons, and context in the fact text
7. Extract the absolute date for EACH fact by calculating relative times from the reference date
8. **CLASSIFY EACH FACT**: 'world' for general facts, 'agent' for AI agent actions
9. Extract ALL entities with their types (PERSON, ORG, PLACE, PRODUCT, CONCEPT) for each fact
9. Extract ALL entities with their types (PERSON, ORG, PLACE, PRODUCT, CONCEPT, OTHER) for each fact
10. Use types to disambiguate entities (Apple the company = ORG, apple the fruit = PRODUCT)
11. When in doubt, EXTRACT IT - better to have too many facts than miss important events"""
11. Use OTHER for entities that don't fit other categories (events, time periods, etc.)
12. When in doubt, EXTRACT IT - better to have too many facts than miss important events"""
import time
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
llm_call_start = time.time()
response = await client.beta.chat.completions.parse(
model=model,
messages=[
{
"role": "system",
"content": "You are an EXHAUSTIVE fact and entity extractor. CRITICAL RULES: 1) ALWAYS include the SUBJECT (never 'the kids' without whose kids), 2) Extract biographical details as SEPARATE facts (if someone mentions 'my home country Sweden', extract 'Person is from Sweden' as its own fact), 3) Extract EVERY event, action, and fact - never skip anything. For each fact, extract ALL important entities with their types: PERSON, ORG, PLACE, PRODUCT, CONCEPT. Use types to disambiguate (Apple=ORG vs apples=PRODUCT). Preserve possessive relationships (their→whose). Include casual mentions (photos, meetups). Calculate absolute dates from relative times. When in doubt, extract it - better too many facts than missing critical biographical/identity information."
},
{
"role": "user",
"content": prompt
}
],
temperature=temperature,
max_tokens=max_tokens,
response_format=FactExtractionResponse,
extra_body={"service_tier": "auto"},
)
llm_call_time = time.time() - llm_call_start
# Retry logic for JSON validation errors
max_retries = 2
last_error = None
# Extract the parsed response
extraction_response = response.choices[0].message.parsed
for attempt in range(max_retries):
try:
llm_call_start = time.time()
response = await client.beta.chat.completions.parse(
model=model,
messages=[
{
"role": "system",
"content": "You are an EXHAUSTIVE fact and entity extractor. CRITICAL RULES: 1) ALWAYS include the SUBJECT (never 'the kids' without whose kids), 2) Extract biographical details as SEPARATE facts (if someone mentions 'my home country Sweden', extract 'Person is from Sweden' as its own fact), 3) Extract EVERY event, action, and fact - never skip anything. 4) **TRANSFORM RELATIVE DATES IN FACT TEXT**: Convert 'last year' to 'in [year]', 'last month' to 'in [month year]' using the reference date - DO NOT leave relative temporal expressions like 'last year' or 'last month' in the fact text. For each fact, extract ALL important entities with their types: PERSON, ORG, PLACE, PRODUCT, CONCEPT, OTHER (for entities that don't fit other categories). Use types to disambiguate (Apple=ORG vs apples=PRODUCT). Preserve possessive relationships (their→whose). Include casual mentions (photos, meetups). Calculate absolute dates from relative times. When in doubt, extract it - better too many facts than missing critical biographical/identity information."
},
{
"role": "user",
"content": prompt
}
],
temperature=temperature,
max_tokens=max_tokens,
response_format=FactExtractionResponse,
extra_body={"service_tier": "auto"},
)
llm_call_time = time.time() - llm_call_start
# Convert to dict format
chunk_facts = [fact.model_dump() for fact in extraction_response.facts]
# Extract the parsed response
extraction_response = response.choices[0].message.parsed
logger.info(f" [1.3.{chunk_index + 1}] Chunk {chunk_index + 1}/{total_chunks} LLM call: {len(chunk_facts)} facts from {len(chunk)} chars in {llm_call_time:.3f}s")
# Convert to dict format
chunk_facts = [fact.model_dump() for fact in extraction_response.facts]
return chunk_facts
logger.info(f" [1.3.{chunk_index + 1}] Chunk {chunk_index + 1}/{total_chunks} LLM call: {len(chunk_facts)} facts from {len(chunk)} chars in {llm_call_time:.3f}s")
return chunk_facts
except BadRequestError as e:
last_error = e
if "json_validate_failed" in str(e):
logger.warning(f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}")
if attempt < max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
# If it's not a JSON validation error or we're out of retries, re-raise
raise
# If we exhausted all retries, raise the last error
raise last_error
async def extract_facts_from_text(

View file

@ -7,13 +7,14 @@ This directory contains specialized operation modules for the TemporalSemanticMe
✅ **Successfully Completed!**
**File Size Reduction:**
- Before: 1,720 lines (temporal_semantic_memory.py)
- After: 1,420 lines (temporal_semantic_memory.py)
- **Removed: 300 lines (17% reduction)**
- Before: 2,065 lines (temporal_semantic_memory.py)
- After: 1,846 lines (temporal_semantic_memory.py)
- **Removed: 219 lines (11% reduction)**
**Modules Created:**
- `embedding_operations.py` - Embedding generation with process pool parallelism
- `link_operations.py` - Entity, temporal, and semantic link creation (300+ lines)
- `think_operations.py` - Think operations with opinion handling (230+ lines)
- `batch_operations.py` - Placeholder for future extraction
- `search_operations.py` - Placeholder for future extraction
@ -25,6 +26,7 @@ The memory system now uses a **mixin pattern** for better code organization:
class TemporalSemanticMemory(
EmbeddingOperationsMixin,
LinkOperationsMixin,
ThinkOperationsMixin,
):
"""
Advanced memory system using temporal and semantic linking.
@ -32,6 +34,7 @@ class TemporalSemanticMemory(
Mixins provide:
- EmbeddingOperationsMixin: _generate_embedding, _generate_embeddings_batch
- LinkOperationsMixin: Entity, temporal, semantic link operations
- ThinkOperationsMixin: think_async, _extract_opinions_from_text
"""
# Core infrastructure and batch operations
pass
@ -50,13 +53,19 @@ class TemporalSemanticMemory(
- `_create_semantic_links_batch()` - Meaning-based connections
- `_insert_entity_links_batch()` - Batch link insertion
### ThinkOperationsMixin (think_operations.py)
- `think_async()` - Formulate answers using agent, world, and opinion facts
- `_extract_opinions_from_text()` - Extract opinions from generated text with LLM
- Parallel fact retrieval with `asyncio.gather`
- Opinion formation and storage as background tasks
### Remaining in Main Class
- Database connection management (`__init__`, `_get_pool`, `close`)
- Batch storage operations (`put`, `put_async`, `put_batch_async`)
- Search operations (`search`, `search_async`, `_apply_mmr`)
- Document management (`get_document`, `delete_document`, `delete_agent`)
- Think operations (`think_async`)
- Deduplication (`_find_duplicate_facts_batch`)
- Opinion evaluation (`_evaluate_opinion_update_async`)
## Benefits Achieved

View file

@ -6,8 +6,10 @@ This package contains specialized operation modules for the TemporalSemanticMemo
from .embedding_operations import EmbeddingOperationsMixin
from .link_operations import LinkOperationsMixin
from .think_operations import ThinkOperationsMixin
__all__ = [
'EmbeddingOperationsMixin',
'LinkOperationsMixin',
'ThinkOperationsMixin',
]

View file

@ -2,9 +2,13 @@
Think operations for formulating answers based on agent and world facts.
"""
import os
import asyncio
import logging
from datetime import datetime, timezone
from typing import Dict, List, Any
from openai import AsyncOpenAI
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class ThinkOperationsMixin:
@ -16,69 +20,112 @@ class ThinkOperationsMixin:
query: str,
thinking_budget: int = 50,
top_k: int = 10,
model: str = "llama-3.3-70b-versatile",
model: str = "openai/gpt-oss-120b",
temperature: float = 0.7,
max_tokens: int = 1000,
) -> Dict[str, Any]:
"""
Think and formulate an answer using agent identity and world facts.
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. Uses Groq LLM to formulate an answer
4. Returns plain text answer and the facts used
3. Retrieves existing opinions (agent's formed perspectives)
4. Uses Groq 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
model: LLM model to use (default: llama-3.3-70b-versatile)
model: LLM model to use (default: openai/gpt-oss-120b)
temperature: Sampling temperature
max_tokens: Maximum tokens in response
Returns:
Dict with:
- text: Plain text answer (no markdown)
- based_on: Dict with 'world' and 'agent' fact lists
- based_on: Dict with 'world', 'agent', and 'opinion' fact lists
- new_opinions: List of newly formed opinions
"""
# Initialize Groq client
groq_api_key = os.getenv("GROQ_API_KEY")
if not groq_api_key:
# Use cached LLM client
if self._llm_client is None:
raise ValueError("GROQ_API_KEY environment variable not set")
client = AsyncOpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1"
client = self._llm_client
# 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 1: Get agent facts (identity)
agent_results, _ = await self.search_async(
agent_id=agent_id,
query=query,
thinking_budget=thinking_budget,
top_k=top_k,
enable_trace=False,
fact_type='agent'
)
# Step 4: Format facts for LLM with full details as JSON
import json
# Step 2: Get world facts
world_results, _ = await self.search_async(
agent_id=agent_id,
query=query,
thinking_budget=thinking_budget,
top_k=top_k,
enable_trace=False,
fact_type='world'
)
def format_facts(facts):
if not facts:
return "[]"
formatted = []
for fact in facts:
fact_obj = {
"text": fact['text']
}
# Step 3: Format facts for LLM
agent_facts_text = "\n".join([f"- {fact['text']}" for fact in agent_results]) if agent_results else "None"
world_facts_text = "\n".join([f"- {fact['text']}" for fact in world_results]) if world_results else "None"
# Add context if available
if fact.get('context'):
fact_obj["context"] = fact['context']
# Step 4: Call Groq to formulate answer
prompt = f"""You are an AI assistant answering a question based on retrieved facts.
# 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}
@ -86,14 +133,25 @@ AGENT IDENTITY (what the agent has done):
WORLD FACTS (general knowledge):
{world_facts_text}
YOUR EXISTING OPINIONS (perspectives you've formed):
{opinion_facts_text}
QUESTION: {query}
Provide a helpful, accurate answer based on the facts above. 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."""
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."""
response = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "You are a helpful AI assistant. Always respond in plain text without markdown formatting."},
{"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}
],
temperature=temperature,
@ -102,11 +160,109 @@ Provide a helpful, accurate answer based on the facts above. If the facts don't
answer_text = response.choices[0].message.content.strip()
# Step 5: Return response with facts split by type
# Step 6: Extract new opinions from the answer
new_opinions = await self._extract_opinions_from_text(
client=client,
text=answer_text,
model=model
)
# Step 7: Store new opinions (schedule as background tasks, don't wait)
if new_opinions:
current_time = datetime.now(timezone.utc)
for opinion_dict in new_opinions:
task = asyncio.create_task(
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"]
)
)
# Track task and auto-remove when done
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
# Step 8: Return response with facts split by type
return {
"text": answer_text,
"based_on": {
"world": world_results,
"agent": agent_results
}
"agent": agent_results,
"opinion": opinion_results
},
"new_opinions": new_opinions
}
async def _extract_opinions_from_text(
self,
client,
text: str,
model: str
) -> List[Dict[str, Any]]:
"""
Extract opinions with reasons and confidence from text using LLM.
Args:
client: OpenAI client
text: Text to extract opinions from
model: LLM model to use
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 opinions or perspectives that were formed in the following text.
An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts.
TEXT:
{text}
For each opinion found, provide:
1. The opinion itself
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 based on the available information
If no clear opinions are expressed, return an empty list."""
try:
response = await client.beta.chat.completions.parse(
model=model,
messages=[
{"role": "system", "content": "You extract opinions and perspectives from text."},
{"role": "user", "content": extraction_prompt}
],
response_format=OpinionExtractionResponse
)
result = response.choices[0].message.parsed
# 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 []

File diff suppressed because it is too large Load diff

View file

@ -585,22 +585,6 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/locomo")
async def api_locomo():
"""Get Locomo benchmark results."""
import json
try:
results_path = Path(__file__).parent.parent / "benchmarks" / "locomo" / "benchmark_results.json"
if not results_path.exists():
raise HTTPException(status_code=404, detail="Benchmark results not found")
with open(results_path, 'r') as f:
data = json.load(f)
return data
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Benchmark results not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Create default app instance

View file

@ -1,274 +0,0 @@
// Locomo benchmark tab functionality
let locomoData = null;
window.loadLocomoResults = async function() {
try {
const response = await fetch('api/locomo');
locomoData = await response.json();
console.log('Loaded locomo data:', locomoData);
renderLocomoResults();
} catch (e) {
console.error('Error loading benchmark results:', e);
document.getElementById('locomo-content').innerHTML = `
<div class="error-message">Error loading benchmark results: ${e.message}<br>
Check console for details.</div>
`;
}
}
function renderLocomoResults() {
if (!locomoData) return;
const content = document.getElementById('locomo-content');
try {
// Handle both old and new structure
const results = locomoData.item_results || locomoData.conversation_results || [];
const numItems = locomoData.num_items || results.length;
console.log('Rendering results:', { resultsCount: results.length, numItems });
// Calculate per-category statistics
const categoryStats = {
1: { name: 'Multi-hop', correct: 0, total: 0 }, // category 1
2: { name: 'Single-hop', correct: 0, total: 0 }, // category 2
3: { name: 'Temporal', correct: 0, total: 0 }, // category 3
4: { name: 'Open-domain', correct: 0, total: 0 } // category 4
};
// Aggregate across all items
results.forEach(item => {
if (item.metrics && item.metrics.detailed_results) {
item.metrics.detailed_results.forEach(result => {
const category = result.category;
if (categoryStats[category]) {
categoryStats[category].total++;
if (result.is_correct) {
categoryStats[category].correct++;
}
}
});
}
});
// Overall stats
const overallHtml = `
<div style="background: #f9f9f9; padding: 20px; border: 2px solid #333; border-radius: 8px; margin-bottom: 20px;">
<h3 style="margin-top: 0;">Overall Performance</h3>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">Overall Accuracy</div>
<div class="stat-value">${locomoData.overall_accuracy.toFixed(2)}%</div>
</div>
<div class="stat-item">
<div class="stat-label">Correct Answers</div>
<div class="stat-value">${locomoData.total_correct} / ${locomoData.total_questions}</div>
</div>
<div class="stat-item">
<div class="stat-label">Items</div>
<div class="stat-value">${numItems}</div>
</div>
</div>
<h4 style="margin: 20px 0 10px 0; padding-top: 15px; border-top: 1px solid #ddd;">Accuracy by Category</h4>
<div class="stats-grid" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));">
${Object.values(categoryStats).map(cat => {
const accuracy = cat.total > 0 ? ((cat.correct / cat.total) * 100).toFixed(1) : 0;
const color = accuracy >= 70 ? '#43a047' : accuracy >= 50 ? '#ff9800' : '#e53935';
return `
<div class="stat-item">
<div class="stat-label">${cat.name}</div>
<div class="stat-value" style="color: ${color};">${accuracy}%</div>
<div style="font-size: 11px; color: #666; margin-top: 4px;">${cat.correct} / ${cat.total}</div>
</div>
`;
}).join('')}
</div>
</div>
`;
// Filter controls
const filterHtml = `
<div style="margin-bottom: 20px; display: flex; gap: 10px; align-items: center;">
<label style="font-weight: bold;">Show:</label>
<label><input type="radio" name="answer-filter" value="all" checked onchange="filterAnswers()"> All Answers</label>
<label><input type="radio" name="answer-filter" value="incorrect" onchange="filterAnswers()"> Incorrect Only</label>
<label><input type="radio" name="answer-filter" value="correct" onchange="filterAnswers()"> Correct Only</label>
</div>
`;
// Build item sections
let itemsHtml = '';
results.forEach((item, idx) => {
const itemId = item.item_id || item.sample_id || `item-${idx}`;
const accuracy = item.metrics.accuracy.toFixed(2);
const correctCount = item.metrics.correct;
const totalCount = item.metrics.total;
itemsHtml += `
<div style="margin-bottom: 30px; border: 2px solid #333; border-radius: 8px; overflow: hidden;">
<div style="background: #f0f0f0; padding: 15px; border-bottom: 2px solid #333; cursor: pointer;" onclick="toggleConversation(${idx})">
<h3 style="margin: 0; display: flex; justify-content: space-between; align-items: center;">
<span>📊 ${itemId}</span>
<span style="font-size: 18px; color: ${accuracy >= 70 ? '#43a047' : accuracy >= 50 ? '#ff9800' : '#e53935'};">
${accuracy}% (${correctCount}/${totalCount})
</span>
</h3>
</div>
<div id="conv-${idx}" style="display: none; padding: 20px;">
${renderConversationDetails(item)}
</div>
</div>
`;
});
content.innerHTML = overallHtml + filterHtml + itemsHtml;
} catch (e) {
console.error('Error rendering Locomo results:', e);
content.innerHTML = `
<div class="error-message">
<strong>Error rendering results:</strong> ${e.message}<br>
<pre style="margin-top: 10px; font-size: 11px; overflow: auto;">${e.stack}</pre>
</div>
`;
}
}
function renderConversationDetails(conv) {
if (!conv || !conv.metrics) {
return '<div style="padding: 20px; color: #666;">No metrics available</div>';
}
const results = conv.metrics.detailed_results;
if (!results || !Array.isArray(results) || results.length === 0) {
return '<div style="padding: 20px; color: #666;">No detailed results available</div>';
}
let html = '<div class="qa-results">';
results.forEach((result, idx) => {
const isCorrect = result.is_correct;
const bgColor = isCorrect ? '#e8f5e9' : '#ffebee';
const icon = isCorrect ? '✅' : '❌';
const category = getCategoryName(result.category);
html += `
<div class="qa-item" data-correct="${isCorrect}" style="background: ${bgColor}; padding: 15px; margin-bottom: 15px; border: 1px solid #ddd; border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px;">
<div style="flex: 1;">
<div style="font-weight: bold; font-size: 16px; margin-bottom: 8px;">
${icon} Question ${idx + 1} <span style="font-size: 12px; background: #666; color: white; padding: 2px 8px; border-radius: 4px; margin-left: 8px;">${category}</span>
</div>
<div style="margin-bottom: 8px;">
<b>Q:</b> ${result.question}
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 10px;">
<div>
<div style="font-weight: bold; color: #43a047; margin-bottom: 4px;"> Correct Answer:</div>
<div style="background: white; padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
${result.correct_answer}
</div>
</div>
<div>
<div style="font-weight: bold; color: ${isCorrect ? '#43a047' : '#e53935'}; margin-bottom: 4px;">
${isCorrect ? '✓' : '✗'} Predicted Answer:
</div>
<div style="background: white; padding: 8px; border-radius: 4px; border: 1px solid #ccc;">
${result.predicted_answer}
</div>
</div>
</div>
<details style="margin-top: 10px;">
<summary style="cursor: pointer; font-weight: bold; padding: 5px; background: rgba(255,255,255,0.5); border-radius: 4px;">
📝 Show Reasoning & Retrieved Memories
</summary>
<div style="margin-top: 10px; padding: 10px; background: white; border-radius: 4px;">
<div style="margin-bottom: 10px;">
<b>System Reasoning:</b>
<div style="padding: 8px; background: #f5f5f5; border-radius: 4px; margin-top: 4px;">
${result.reasoning}
</div>
</div>
<div style="margin-bottom: 10px;">
<b>Judge Reasoning:</b>
<div style="padding: 8px; background: #f5f5f5; border-radius: 4px; margin-top: 4px;">
${result.correctness_reasoning || 'N/A'}
</div>
</div>
<div>
<b>Retrieved Memories (${result.retrieved_memories ? result.retrieved_memories.length : 0}):</b>
${renderRetrievedMemories(result.retrieved_memories)}
</div>
</div>
</details>
</div>
`;
});
html += '</div>';
return html;
}
function renderRetrievedMemories(memories) {
if (!memories || !Array.isArray(memories) || memories.length === 0) {
return '<div style="padding: 8px; color: #999;">No memories retrieved</div>';
}
let html = '<div style="margin-top: 8px;">';
memories.forEach((mem, idx) => {
if (!mem) return;
html += `
<div style="padding: 8px; background: #f5f5f5; border-left: 3px solid #42a5f5; margin-bottom: 8px;">
<div style="font-size: 11px; color: #666; margin-bottom: 4px;">
Rank #${idx + 1} | Score: ${mem.score ? mem.score.toFixed(4) : 'N/A'}
</div>
<div style="font-size: 13px;">${mem.text}</div>
</div>
`;
});
html += '</div>';
return html;
}
function getCategoryName(category) {
const categories = {
1: 'Multi-hop',
2: 'Single-hop',
3: 'Temporal',
4: 'Open-domain'
};
return categories[category] || 'Unknown';
}
function toggleConversation(idx) {
const elem = document.getElementById(`conv-${idx}`);
if (elem.style.display === 'none') {
elem.style.display = 'block';
} else {
elem.style.display = 'none';
}
}
function filterAnswers() {
const filter = document.querySelector('input[name="answer-filter"]:checked').value;
const items = document.querySelectorAll('.qa-item');
items.forEach(item => {
const isCorrect = item.dataset.correct === 'true';
if (filter === 'all') {
item.style.display = 'block';
} else if (filter === 'correct' && isCorrect) {
item.style.display = 'block';
} else if (filter === 'incorrect' && !isCorrect) {
item.style.display = 'block';
} else {
item.style.display = 'none';
}
});
}

View file

@ -22,7 +22,6 @@
<button class="tab-button active" onclick="switchMainTab('data')">Data</button>
<button class="tab-button" onclick="switchMainTab('debug')">Search Debug</button>
<button class="tab-button" onclick="switchMainTab('think')">Think</button>
<button class="tab-button" onclick="switchMainTab('benchmark')">Benchmark</button>
</div>
<!-- Data Tab -->
@ -284,23 +283,8 @@
</div>
</div>
<div id="benchmark-tab" class="tab-content">
<h2>Benchmark</h2>
<p style="color: #666; margin-bottom: 15px; padding: 0 20px;">
Run and analyze benchmark results.
</p>
<div style="margin-bottom: 15px; padding: 0 20px;">
<button onclick="loadLocomoResults()" class="load-button">
📊 Load Locomo Benchmark
</button>
</div>
<div id="locomo-content" style="padding: 20px;">
<p style="text-align: center; color: #666;">Click "Load Locomo Benchmark" to view results</p>
</div>
</div>
</div>
<script src="./static/js/app.js"></script>
<script src="./static/js/locomo.js"></script>
</body>
</html>

View file

@ -63,9 +63,8 @@ async def test_think_opinion_consistency():
new_opinions_count = len(result1.get('new_opinions', []))
print(f"\nNew opinions formed: {new_opinions_count}")
# Wait a moment to ensure opinions are stored and any background tasks complete
import asyncio
await asyncio.sleep(2.0)
# Wait for background opinion PUT tasks to complete
await memory.wait_for_background_tasks()
# Search for stored opinions to verify they were actually saved
pool = await memory._get_pool()