temporal offset and others

This commit is contained in:
Nicolò Boschi 2025-11-14 18:38:57 +01:00
parent 3e618cb001
commit e15cbb4a44
15 changed files with 772 additions and 390 deletions

View file

@ -480,7 +480,7 @@ for each candidate memory unit:
- Temporal awareness through formatted date context
- Significantly improves precision on multi-hop and temporal queries
**Pluggable Design**: Abstract `Reranker` interface allows future API-based rerankers (e.g., Cohere Rerank, Jina Reranker)
**Implementation**: Uses cross-encoder neural reranking with ms-marco-MiniLM-L-6-v2 model for all queries
### 3.4 Token Budget Filtering

View file

@ -22,10 +22,10 @@ The system provides:
**Retrieval Pipeline**:
```
Query → [Semantic + Keyword + Graph + Temporal] → RRF Merge → Reranker → MMR → Results
Query → [Semantic + Keyword + Graph + Temporal] → RRF Merge → Cross-Encoder Reranking → MMR → Results
```
- 4-way parallel retrieval for high recall
- Neural reranking (optional) for precision
- Neural cross-encoder reranking for precision
- MMR diversification to avoid redundancy
**Key Features**:
@ -242,19 +242,6 @@ curl -X POST http://localhost:8080/api/search \
"query": "What does Alice do?",
"thinking_budget": 100,
"top_k": 10,
"reranker": "heuristic",
"trace": false
}'
# Optional: Use cross-encoder reranker for better accuracy
curl -X POST http://localhost:8080/api/search \
-H "Content-Type: application/json" \
-d '{
"agent_id": "alice_agent",
"query": "What does Alice do?",
"thinking_budget": 100,
"top_k": 10,
"reranker": "cross-encoder",
"trace": false
}'
```

View file

@ -39,6 +39,8 @@ pub struct Fact {
pub context: Option<String>,
#[serde(default)]
pub event_date: Option<String>,
#[serde(default)]
pub document_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@ -78,6 +80,7 @@ pub struct BatchMemoryRequest {
pub struct BatchMemoryResponse {
pub success: bool,
pub stored_count: Option<i32>,
pub items_count: Option<i32>,
pub error: Option<String>,
pub job_id: Option<String>,
}
@ -419,6 +422,57 @@ impl ApiClient {
Ok(result)
}
pub fn update_agent_name(
&self,
agent_id: &str,
name: &str,
verbose: bool,
) -> Result<AgentProfile> {
#[derive(Serialize)]
struct UpdateNameRequest {
name: String,
}
let url = format!("{}/api/v1/agents/{}", self.base_url, agent_id);
let request = UpdateNameRequest {
name: name.to_string(),
};
if verbose {
eprintln!("Request URL: {}", url);
eprintln!("Request body:\n{}", serde_json::to_string_pretty(&request).unwrap_or_default());
}
let response = self
.client
.put(&url)
.json(&request)
.timeout(Duration::from_secs(30))
.send()?;
let status = response.status();
if verbose {
eprintln!("Response status: {}", status);
}
if !status.is_success() {
let error_body = response.text().unwrap_or_default();
if verbose {
eprintln!("Error response body:\n{}", error_body);
}
anyhow::bail!("API returned error status {}: {}", status, error_body);
}
let response_text = response.text()?;
if verbose {
eprintln!("Response body:\n{}", response_text);
}
let result: AgentProfile = serde_json::from_str(&response_text)
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
Ok(result)
}
pub fn update_personality(
&self,
agent_id: &str,
@ -786,4 +840,44 @@ impl ApiClient {
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
Ok(result)
}
pub fn clear_memories(&self, agent_id: &str, fact_type: Option<&str>, verbose: bool) -> Result<DeleteResponse> {
let mut url = format!("{}/api/v1/agents/{}/memories", self.base_url, agent_id);
if let Some(ft) = fact_type {
url.push_str(&format!("?fact_type={}", ft));
}
if verbose {
eprintln!("Request URL: {}", url);
}
let response = self
.client
.delete(&url)
.timeout(Duration::from_secs(60))
.send()?;
let status = response.status();
if verbose {
eprintln!("Response status: {}", status);
}
if !status.is_success() {
let error_body = response.text().unwrap_or_default();
if verbose {
eprintln!("Error response body:\n{}", error_body);
}
anyhow::bail!("API returned error status {}: {}", status, error_body);
}
let response_text = response.text()?;
if verbose {
eprintln!("Response body:\n{}", response_text);
}
let result: DeleteResponse = serde_json::from_str(&response_text)
.with_context(|| format!("Failed to parse API response. Response was: {}", response_text))?;
Ok(result)
}
}

View file

@ -83,38 +83,17 @@ enum AgentCommands {
agent_id: String,
},
/// Update agent personality traits
SetPersonality {
/// Set agent name
Name {
/// Agent ID
agent_id: String,
/// Openness to experience (0.0-1.0)
#[arg(long)]
openness: f32,
/// Conscientiousness (0.0-1.0)
#[arg(long)]
conscientiousness: f32,
/// Extraversion (0.0-1.0)
#[arg(long)]
extraversion: f32,
/// Agreeableness (0.0-1.0)
#[arg(long)]
agreeableness: f32,
/// Neuroticism (0.0-1.0)
#[arg(long)]
neuroticism: f32,
/// Bias strength (0.0-1.0)
#[arg(long)]
bias_strength: f32,
/// Agent name
name: String,
},
/// Set or merge agent background
SetBackground {
Background {
/// Agent ID
agent_id: String,
@ -221,6 +200,20 @@ enum MemoryCommands {
/// Memory unit ID
unit_id: String,
},
/// Clear all memories for an agent
Clear {
/// Agent ID
agent_id: String,
/// Fact type to clear (world, agent, opinion). If not specified, clears all types.
#[arg(short = 't', long, value_parser = ["world", "agent", "opinion"])]
fact_type: Option<String>,
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
@ -358,21 +351,7 @@ fn run() -> Result<()> {
match response {
Ok(profile) => {
if output_format == OutputFormat::Pretty {
ui::print_info(&format!("Profile for agent '{}'", agent_id));
let personality = &profile.personality; {
println!("\n Personality Traits:");
println!(" Openness: {:.2}", personality.openness);
println!(" Conscientiousness: {:.2}", personality.conscientiousness);
println!(" Extraversion: {:.2}", personality.extraversion);
println!(" Agreeableness: {:.2}", personality.agreeableness);
println!(" Neuroticism: {:.2}", personality.neuroticism);
println!(" Bias Strength: {:.2}", personality.bias_strength);
}
if !profile.background.is_empty() {
println!("\n Background:\n{}", profile.background);
}
ui::print_profile(&profile);
} else {
output::print_output(&profile, output_format)?;
}
@ -488,30 +467,19 @@ fn run() -> Result<()> {
}
}
AgentCommands::SetPersonality {
AgentCommands::Name {
agent_id,
openness,
conscientiousness,
extraversion,
agreeableness,
neuroticism,
bias_strength,
name,
} => {
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner("Updating personality..."))
Some(ui::create_spinner("Updating agent name..."))
} else {
None
};
let response = client.update_personality(
let response = client.update_agent_name(
&agent_id,
openness,
conscientiousness,
extraversion,
agreeableness,
neuroticism,
bias_strength,
&name,
verbose,
);
@ -522,15 +490,7 @@ fn run() -> Result<()> {
match response {
Ok(profile) => {
if output_format == OutputFormat::Pretty {
ui::print_success("Personality updated successfully");
let p = &profile.personality; {
println!(" Openness: {:.2}", p.openness);
println!(" Conscientiousness: {:.2}", p.conscientiousness);
println!(" Extraversion: {:.2}", p.extraversion);
println!(" Agreeableness: {:.2}", p.agreeableness);
println!(" Neuroticism: {:.2}", p.neuroticism);
println!(" Bias Strength: {:.2}", p.bias_strength);
}
ui::print_success(&format!("Agent name updated to '{}'", profile.name));
} else {
output::print_output(&profile, output_format)?;
}
@ -540,7 +500,7 @@ fn run() -> Result<()> {
}
}
AgentCommands::SetBackground {
AgentCommands::Background {
agent_id,
content,
no_update_personality,
@ -627,7 +587,11 @@ fn run() -> Result<()> {
match response {
Ok(result) => {
output::print_output(&result, output_format)?;
if output_format == OutputFormat::Pretty {
ui::print_search_results(&result, trace);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
@ -660,7 +624,11 @@ fn run() -> Result<()> {
match response {
Ok(result) => {
output::print_output(&result, output_format)?;
if output_format == OutputFormat::Pretty {
ui::print_think_response(&result);
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
@ -709,7 +677,8 @@ fn run() -> Result<()> {
println!(" Operation ID: {}", op_id);
println!(" Status: queued for background processing");
} else {
println!(" Stored count: {}", result.stored_count.unwrap_or(0));
let count = result.stored_count.or(result.items_count).unwrap_or(0);
println!(" Stored count: {}", count);
}
} else {
output::print_output(&result, output_format)?;
@ -825,7 +794,8 @@ fn run() -> Result<()> {
println!(" Operation ID: {}", op_id);
println!(" Status: queued for background processing");
} else {
println!(" Total units created: {}", result.stored_count.unwrap_or(0));
let count = result.stored_count.or(result.items_count).unwrap_or(0);
println!(" Total units created: {}", count);
}
} else {
output::print_output(&result, output_format)?;
@ -865,6 +835,64 @@ fn run() -> Result<()> {
Err(e) => Err(e)
}
}
MemoryCommands::Clear { agent_id, fact_type, yes } => {
// Confirmation prompt unless -y flag is used
if !yes && output_format == OutputFormat::Pretty {
let message = if let Some(ft) = &fact_type {
format!(
"Are you sure you want to clear all '{}' memories for agent '{}'? This cannot be undone.",
ft, agent_id
)
} else {
format!(
"Are you sure you want to clear ALL memories for agent '{}'? This cannot be undone.",
agent_id
)
};
let confirmed = ui::prompt_confirmation(&message)?;
if !confirmed {
ui::print_info("Operation cancelled");
return Ok(());
}
}
let spinner_msg = if let Some(ft) = &fact_type {
format!("Clearing {} memories...", ft)
} else {
"Clearing all memories...".to_string()
};
let spinner = if output_format == OutputFormat::Pretty {
Some(ui::create_spinner(&spinner_msg))
} else {
None
};
let response = client.clear_memories(&agent_id, fact_type.as_deref(), verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
}
match response {
Ok(result) => {
if output_format == OutputFormat::Pretty {
if result.success {
ui::print_success(&result.message);
} else {
ui::print_error(&result.message);
}
} else {
output::print_output(&result, output_format)?;
}
Ok(())
}
Err(e) => Err(e)
}
}
},
Commands::Document(doc_cmd) => match doc_cmd {

View file

@ -55,6 +55,11 @@ pub fn print_fact(fact: &Fact, show_activation: bool) {
println!(" {}: {}", "Date".bright_black(), event_date.bright_black());
}
// Show document ID if available
if let Some(document_id) = &fact.document_id {
println!(" {}: {}", "Document".bright_black(), document_id.bright_black());
}
println!();
}
@ -79,23 +84,12 @@ pub fn print_search_results(response: &SearchResponse, show_trace: bool) {
}
pub fn print_think_response(response: &ThinkResponse) {
print_section_header("Answer");
println!();
println!("{}", response.text.bright_white());
println!();
// Note: based_on facts are hidden in default output
// Use -o json to see the complete response including based_on facts
if !response.based_on.is_empty() {
println!(" {}", format!("(Based on {} facts - use -o json to see details)", response.based_on.len()).bright_black());
println!();
}
if !response.new_opinions.is_empty() {
print_section_header(&format!("New opinions formed ({})", response.new_opinions.len()));
for opinion in &response.new_opinions {
println!(" 💭 {}", opinion.bright_yellow());
}
println!();
println!("{}", format!("Based on {} memory units", response.based_on.len()).bright_black());
}
}
@ -219,155 +213,159 @@ pub fn prompt_confirmation(message: &str) -> io::Result<bool> {
pub fn print_profile(profile: &AgentProfile) {
print_section_header(&format!("Agent Profile: {}", profile.agent_id));
// Print name
println!("{} {}", "Name:".bright_cyan().bold(), profile.name.bright_white());
println!();
// Print background if available
if !profile.background.is_empty() {
println!("{}", "Background:".bright_yellow());
for line in profile.background.lines() {
println!("{}", line);
}
println!();
}
// Print personality traits
println!(" {}", "Personality Traits (Big Five):".bright_cyan().bold());
println!("{}", "─── Personality Traits ───".bright_yellow());
println!();
let traits = [
("Openness", profile.personality.openness, "🔓"),
("Conscientiousness", profile.personality.conscientiousness, "📋"),
("Extraversion", profile.personality.extraversion, "🗣️"),
("Agreeableness", profile.personality.agreeableness, "🤝"),
("Neuroticism", profile.personality.neuroticism, "😰"),
("Openness", profile.personality.openness, "🔓", "green"),
("Conscientiousness", profile.personality.conscientiousness, "📋", "yellow"),
("Extraversion", profile.personality.extraversion, "🗣️", "cyan"),
("Agreeableness", profile.personality.agreeableness, "🤝", "magenta"),
("Neuroticism", profile.personality.neuroticism, "😰", "yellow"),
];
for (name, value, emoji) in &traits {
let bar_length = 20;
for (name, value, emoji, color) in &traits {
let bar_length = 40;
let filled = (*value * bar_length as f32) as usize;
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let value_color = if *value >= 0.7 {
bar.bright_green()
} else if *value >= 0.4 {
bar.bright_yellow()
} else {
bar.bright_red()
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let colored_bar = match *color {
"green" => bar.bright_green(),
"yellow" => bar.bright_yellow(),
"cyan" => bar.bright_cyan(),
"magenta" => bar.bright_magenta(),
_ => bar.bright_white(),
};
println!(" {} {:<20} [{}] {:.0}%",
println!(" {} {:<20} [{}] {:.0}%",
emoji,
name,
value_color,
colored_bar,
value * 100.0
);
}
println!();
println!(" {}", "Bias Strength:".bright_cyan().bold());
println!("{}", "Bias Strength:".bright_yellow());
let bias = profile.personality.bias_strength;
let bar_length = 20;
let bar_length = 40;
let filled = (bias * bar_length as f32) as usize;
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let bias_color = if bias >= 0.7 {
bar.bright_green()
} else if bias >= 0.4 {
bar.bright_yellow()
} else {
bar.bright_red()
};
println!(" 💪 {:<20} [{}] {:.0}%",
println!(" 💪 {:<20} [{}] {:.0}%",
"Personality Influence",
bias_color,
bar.bright_green(),
bias * 100.0
);
println!(" {}", format!("(How much personality shapes opinions)").bright_black());
println!(" {}", "(how much personality shapes opinions)".bright_black());
println!();
// Print background
if !profile.background.is_empty() {
println!(" {}", "Background:".bright_cyan().bold());
println!();
for line in profile.background.lines() {
println!(" {}", line);
}
println!();
} else {
println!(" {}", "Background: (none)".bright_black());
println!();
}
}
pub fn print_personality_delta(old: &PersonalityTraits, new: &PersonalityTraits) {
print_section_header("Personality Changes");
println!();
println!("{}", "─── Personality Changes ───".bright_yellow());
println!();
let traits = [
("Openness", old.openness, new.openness, "🔓"),
("Conscientiousness", old.conscientiousness, new.conscientiousness, "📋"),
("Extraversion", old.extraversion, new.extraversion, "🗣️"),
("Agreeableness", old.agreeableness, new.agreeableness, "🤝"),
("Neuroticism", old.neuroticism, new.neuroticism, "😰"),
("Openness", old.openness, new.openness, "🔓", "green"),
("Conscientiousness", old.conscientiousness, new.conscientiousness, "📋", "yellow"),
("Extraversion", old.extraversion, new.extraversion, "🗣️", "cyan"),
("Agreeableness", old.agreeableness, new.agreeableness, "🤝", "magenta"),
("Neuroticism", old.neuroticism, new.neuroticism, "😰", "yellow"),
];
for (name, old_value, new_value, emoji) in &traits {
let bar_length = 20;
for (name, old_value, new_value, emoji, color) in &traits {
let bar_length = 40;
let filled = (*new_value * bar_length as f32) as usize;
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let value_color = if *new_value >= 0.7 {
bar.bright_green()
} else if *new_value >= 0.4 {
bar.bright_yellow()
} else {
bar.bright_red()
};
// Create bar with pattern if there's a change
let delta = new_value - old_value;
let delta_pct = (delta * 100.0).abs();
let delta_str = if delta.abs() < 0.01 {
"".to_string()
} else if delta > 0.0 {
format!(" {} {:.0}%", "".bright_green(), delta_pct)
let has_change = delta.abs() >= 0.01;
let bar = if has_change {
// Add pattern for changes (using different characters to show change)
let pattern_filled = filled.min(3);
format!("{}{}{}",
"".repeat(filled.saturating_sub(pattern_filled)),
"".repeat(pattern_filled),
"".repeat(empty)
)
} else {
format!(" {} {:.0}%", "".bright_red(), delta_pct)
format!("{}{}", "".repeat(filled), "".repeat(empty))
};
println!(" {} {:<20} [{}] {:.0}%{}",
let colored_bar = match *color {
"green" => bar.bright_green(),
"yellow" => bar.bright_yellow(),
"cyan" => bar.bright_cyan(),
"magenta" => bar.bright_magenta(),
_ => bar.bright_white(),
};
let delta_str = if has_change {
format!("{:.0}%", new_value * 100.0)
} else {
format!(" {:.0}%", new_value * 100.0)
};
println!(" {} {:<20} [{}]{}",
emoji,
name,
value_color,
new_value * 100.0,
colored_bar,
delta_str
);
}
println!();
println!(" {}", "Bias Strength:".bright_cyan().bold());
println!("{}", "Bias Strength:".bright_yellow());
let old_bias = old.bias_strength;
let new_bias = new.bias_strength;
let bar_length = 20;
let bar_length = 40;
let filled = (new_bias * bar_length as f32) as usize;
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
let bias_color = if new_bias >= 0.7 {
bar.bright_green()
} else if new_bias >= 0.4 {
bar.bright_yellow()
} else {
bar.bright_red()
};
let delta = new_bias - old_bias;
let delta_pct = (delta * 100.0).abs();
let delta_str = if delta.abs() < 0.01 {
"".to_string()
} else if delta > 0.0 {
format!(" {} {:.0}%", "".bright_green(), delta_pct)
let has_change = delta.abs() >= 0.01;
let bar = if has_change {
let pattern_filled = filled.min(3);
format!("{}{}{}",
"".repeat(filled.saturating_sub(pattern_filled)),
"".repeat(pattern_filled),
"".repeat(empty)
)
} else {
format!(" {} {:.0}%", "".bright_red(), delta_pct)
format!("{}{}", "".repeat(filled), "".repeat(empty))
};
println!(" 💪 {:<20} [{}] {:.0}%{}",
let delta_str = if has_change {
format!("{:.0}%", new_bias * 100.0)
} else {
format!(" {:.0}%", new_bias * 100.0)
};
println!(" 💪 {:<20} [{}]{}",
"Personality Influence",
bias_color,
new_bias * 100.0,
bar.bright_green(),
delta_str
);
println!(" {}", format!("(How much personality shapes opinions)").bright_black());
println!(" {}", "(how much personality shapes opinions)".bright_black());
println!();
}

View file

@ -11,7 +11,7 @@ from typing import Optional, List, Dict, Any, Union
from datetime import datetime
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
@ -25,7 +25,6 @@ class SearchRequest(BaseModel):
fact_type: Optional[List[str]] = None # List of fact types to search (defaults to all if not specified)
thinking_budget: int = 100
max_tokens: int = 4096
reranker: str = "heuristic"
trace: bool = False
question_date: Optional[str] = None # ISO format date string (e.g., "2023-05-30T23:40:00")
@ -36,7 +35,6 @@ class SearchRequest(BaseModel):
"fact_type": ["world", "agent"],
"thinking_budget": 100,
"max_tokens": 4096,
"reranker": "heuristic",
"trace": True,
"question_date": "2023-05-30T23:40:00"
}
@ -53,7 +51,8 @@ class SearchResult(BaseModel):
"text": "Alice works at Google on the AI team",
"type": "world",
"context": "work info",
"event_date": "2024-01-15T10:30:00Z"
"event_date": "2024-01-15T10:30:00Z",
"document_id": "session_abc123"
}
}
}
@ -63,6 +62,7 @@ class SearchResult(BaseModel):
type: Optional[str] = None # fact type: world, agent, opinion
context: Optional[str] = None
event_date: Optional[str] = None # ISO format date string
document_id: Optional[str] = None # Document this memory belongs to
class SearchResponse(BaseModel):
@ -510,6 +510,20 @@ class DocumentResponse(BaseModel):
}
class DeleteResponse(BaseModel):
"""Response model for delete operations."""
success: bool
message: str
class Config:
json_schema_extra = {
"example": {
"success": True,
"message": "Resource deleted successfully"
}
}
def create_app(memory: TemporalSemanticMemory, run_migrations: bool = True, initialize_memory: bool = True) -> FastAPI:
"""
Create and configure the FastAPI application.
@ -707,7 +721,6 @@ def _register_routes(app: FastAPI):
thinking_budget=request.thinking_budget,
max_tokens=request.max_tokens,
enable_trace=request.trace,
reranker=request.reranker,
fact_type=request.fact_type,
question_date=question_date
)
@ -1463,3 +1476,37 @@ This operation cannot be undone.
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
print(f"Error in /api/v1/agents/{agent_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/api/v1/agents/{agent_id}/memories",
response_model=DeleteResponse,
tags=["Agent Management"],
summary="Clear agent memories",
description="Delete memory units for an agent. Optionally filter by fact_type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The agent profile (personality and background) will be preserved."
)
async def api_clear_agent_memories(
agent_id: str,
fact_type: Optional[str] = Query(None, description="Optional fact type filter (world, agent, opinion)")
):
"""Clear memories for an agent, optionally filtered by fact_type."""
try:
result = await app.state.memory.delete_agent(agent_id, fact_type=fact_type)
units_deleted = result.get('memory_units_deleted', 0)
entities_deleted = result.get('entities_deleted', 0)
if fact_type:
message = f"Cleared {units_deleted} {fact_type} memories for agent '{agent_id}'"
else:
message = f"Cleared all memories for agent '{agent_id}': {units_deleted} memory units, {entities_deleted} entities deleted"
return DeleteResponse(
success=True,
message=message
)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
print(f"Error in /api/v1/agents/{agent_id}/memories: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -32,7 +32,7 @@ class ExtractedFact(BaseModel):
description="Absolute date/time when this fact occurred in ISO format (YYYY-MM-DDTHH:MM:SSZ). If text mentions relative time (yesterday, last week, this morning), calculate absolute date from the provided context date."
)
fact_type: Literal["world", "agent", "opinion"] = Field(
description="Type of fact: 'world' for general facts about the world (events, people, things others said/did), 'agent' for facts about what the memory owner (the person this memory belongs to, often identified as 'you' in context) specifically did, said, experienced, or actions they took - MUST be written in FIRST PERSON ('I did...', 'I said...'), 'opinion' for the memory owner's formed opinions and perspectives - also in first person"
description="Type of fact: 'world' for facts about others that don't involve you (the agent) directly, 'agent' for facts that involve YOU (the agent whose memory this is) - what you did, said, experienced, or participated in - MUST be written in FIRST PERSON ('I did...', 'I said...', 'I met...'), 'opinion' for YOUR formed opinions and perspectives - also in first person"
)
entities: List[Entity] = Field(
default_factory=list,
@ -98,7 +98,8 @@ async def _extract_facts_from_chunk(
event_date: datetime,
context: str,
llm_config: 'LLMConfig',
agent_name: str = None
agent_name: str = None,
extract_opinions: bool = False
) -> List[Dict[str, str]]:
"""
Extract facts from a single chunk (internal helper for parallel processing).
@ -106,13 +107,22 @@ async def _extract_facts_from_chunk(
# Format event_date for the prompt
event_date_str = event_date.strftime("%Y-%m-%dT%H:%M:%SZ")
agent_context = f"\n- Agent name (memory owner): {agent_name}" if agent_name else ""
agent_context = f"\n- Your name: {agent_name}" if agent_name else ""
prompt = f"""You are extracting comprehensive, narrative facts from conversations for an AI memory system.
# Determine which fact types to extract based on the flag
if extract_opinions:
fact_types_instruction = "Extract ONLY 'opinion' type facts (the agent's formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'agent' facts."
else:
fact_types_instruction = "Extract ONLY 'world' and 'agent' type facts. DO NOT extract 'opinion' type facts - opinions should never be created during normal memory storage."
prompt = f"""You are extracting comprehensive, narrative facts from conversations/document for an AI memory system.
{fact_types_instruction}
## CONTEXT INFORMATION
- Current reference date/time: {event_date_str}
- Context: {context if context else 'no context provided'}{agent_context}
- Today time: {datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")}
- Current document date/time: {event_date_str}
- Context: {context if context else 'no additional context provided'}{agent_context}
## CORE PRINCIPLE: Extract FEWER, MORE COMPREHENSIVE Facts
@ -190,22 +200,32 @@ Classify each fact as 'world', 'agent', or 'opinion':
- **'world'**: Facts about other people, events, things that happened in the world, what others said/did
- Written in third person (use names, "they", etc.)
- **'agent'**: Facts about what the MEMORY OWNER (the person this memory belongs to) specifically did, said, experienced, or actions they took
- The memory owner is typically identified in the context (e.g., "you (Marcus)" means Marcus is the memory owner)
- **CRITICAL**: MUST be written in FIRST PERSON using "I", "me", "my" (NOT the person's name)
- Examples: "I said I prefer coffee", "I attended the conference", "I completed the project"
- WRONG: "Marcus said he prefers coffee"
- CORRECT: "I said I prefer coffee"
- **'opinion'**: The memory owner's formed opinions, beliefs, and perspectives about topics
- Does NOT involve you (the agent) directly
- **'agent'**: Facts that involve YOU (the agent whose memory this is) - what you specifically did, said, experienced, or actions you took
- YOU are identified by the agent name in context (e.g., "Your name: Marcus" means you are Marcus)
- **CRITICAL**: Agent facts MUST be written in FIRST PERSON using "I", "me", "my" (NOT using your name)
- Agent facts capture things YOU did, said, or experienced - not just things that happened around you
- **SPEAKER ATTRIBUTION WARNING**: In conversations with speakers labeled (e.g., "Marcus: text" and "Jamie: text"), ONLY extract agent facts from lines where YOUR name appears as the speaker
- Examples: "I said I prefer coffee", "I attended the conference", "I completed the project", "I met with Jamie"
- WRONG: "Marcus said he prefers coffee" (using name instead of first person)
- CORRECT: "I said I prefer coffee" (first person)
- **'opinion'**: YOUR (the agent's) formed opinions, beliefs, and perspectives about topics
- Also written in first person: "I believe...", "I think..."
**CRITICAL**: If the context identifies someone as "you" or specifies whose memory this is, then facts about that person's actions/statements are 'agent' facts written in FIRST PERSON.
**CRITICAL SPEAKER ATTRIBUTION RULES**:
1. If text has format "Name: statement", ONLY extract 'agent' facts from lines where Name matches YOUR name from context
2. If context says "Your name: Marcus", then ONLY statements by "Marcus:" are YOUR statements
3. Statements by other speakers (e.g., "Jamie:") are 'world' facts about what THEY said/did
4. DO NOT confuse who said what - carefully check the speaker name before each statement
**Example**: If context says "podcast between you (Marcus) and Jamie":
- "I explained my approach to AI safety" 'agent' (first person, my action)
- "Jamie asked about neural networks" 'world' (someone else's action, third person)
- "Jamie and I discussed transformer architectures" 'world' (general conversation - could use first person here since it includes both)
- "I believe interpretability is crucial" 'opinion' (first person belief)
**Example**: If context says "Your name: Marcus" and text is:
```
Marcus: I predict the Rams will win 27-24.
Jamie: I predict the Niners will win 27-13.
```
- "I predicted the Rams will win 27-24" 'agent' (I/Marcus said this)
- "Jamie predicted the Niners will win 27-13" 'world' (Jamie said this, not me)
- WRONG: "I predicted the Niners will win 27-13" (this was Jamie's prediction, not mine!)
## ENTITY EXTRACTION
Extract ALL important entities (names of people, places, organizations, products, concepts, etc).
@ -379,7 +399,6 @@ Marcus: I think that's gonna do it for us today! Don't forget to subscribe and l
scope="memory_extract_facts",
temperature=0.1,
max_tokens=65000,
extra_body={"service_tier": "auto"}
)
chunk_facts = [fact.model_dump() for fact in extraction_response.facts]
return chunk_facts
@ -405,7 +424,8 @@ async def _extract_facts_with_auto_split(
event_date: datetime,
context: str,
llm_config: LLMConfig,
agent_name: str = None
agent_name: str = None,
extract_opinions: bool = False
) -> List[Dict[str, str]]:
"""
Extract facts from a chunk with automatic splitting if output exceeds token limits.
@ -421,6 +441,7 @@ async def _extract_facts_with_auto_split(
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name (memory owner)
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
List of fact dictionaries extracted from the chunk (possibly from sub-chunks)
@ -437,7 +458,8 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name
agent_name=agent_name,
extract_opinions=extract_opinions
)
except OutputTooLongError as e:
# Output exceeded token limits - split the chunk in half and retry
@ -482,7 +504,8 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name
agent_name=agent_name,
extract_opinions=extract_opinions
),
_extract_facts_with_auto_split(
chunk=second_half,
@ -491,7 +514,8 @@ async def _extract_facts_with_auto_split(
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name
agent_name=agent_name,
extract_opinions=extract_opinions
)
]
@ -515,6 +539,7 @@ async def extract_facts_from_text(
llm_config: LLMConfig,
agent_name: str,
context: str = "",
extract_opinions: bool = False,
) -> List[Dict[str, str]]:
"""
Extract semantic facts from conversational or narrative text using LLM.
@ -532,12 +557,12 @@ async def extract_facts_from_text(
llm_config: LLM configuration to use (if None, uses default from environment)
chunk_size: Maximum characters per chunk
agent_name: Optional agent name (memory owner)
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
List of fact dictionaries with 'fact' and 'date' keys
"""
chunks = chunk_text(text, max_chars=50_000)
logging.info(f"created {len(chunks)} chunks from text {len(text)}")
tasks = [
_extract_facts_with_auto_split(
chunk=chunk,
@ -546,7 +571,8 @@ async def extract_facts_from_text(
event_date=event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name
agent_name=agent_name,
extract_opinions=extract_opinions
)
for i, chunk in enumerate(chunks)
]

View file

@ -22,6 +22,7 @@ class MemoryFact(BaseModel):
fact_type: str = Field(description="Type of fact: 'world', 'agent', or 'opinion'")
context: Optional[str] = Field(None, description="Additional context for the memory")
event_date: Optional[str] = Field(None, description="ISO format date when the event occurred")
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
# Internal metrics (used by system but may not be exposed in API)
activation: Optional[float] = Field(None, description="Internal activation score")
@ -34,6 +35,7 @@ class MemoryFact(BaseModel):
"fact_type": "world",
"context": "work info",
"event_date": "2024-01-15T10:30:00Z",
"document_id": "session_abc123",
"activation": 0.95
}
}

View file

@ -7,11 +7,9 @@ Provides modular search architecture:
"""
from .retrieval import retrieve_parallel
from .reranking import Reranker, HeuristicReranker, CrossEncoderReranker
from .reranking import CrossEncoderReranker
__all__ = [
"retrieve_parallel",
"Reranker",
"HeuristicReranker",
"CrossEncoderReranker",
]

View file

@ -1,126 +1,11 @@
"""
Reranking abstraction for search results.
Supports multiple reranking strategies:
1. Heuristic: Weighted combination of semantic + BM25 + normalized boosts
2. Cross-encoder: Neural reranking using a transformer model
Cross-encoder neural reranking for search results.
"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any
from datetime import datetime, timezone
import numpy as np
def utcnow():
"""Get current UTC time."""
return datetime.now(timezone.utc)
def calculate_recency_weight(days_since: float) -> float:
"""Calculate recency weight using exponential decay."""
half_life_days = 30.0
return np.exp(-np.log(2) * days_since / half_life_days)
def calculate_frequency_weight(access_count: int) -> float:
"""Calculate frequency weight using logarithmic scale."""
return 1.0 + np.log1p(access_count) * 0.1
class Reranker(ABC):
"""Abstract base class for reranking strategies."""
@abstractmethod
def rerank(
self,
query: str,
candidates: List[Dict[str, Any]],
top_k: int
) -> List[Dict[str, Any]]:
"""
Rerank candidates and return top_k results.
Args:
query: Search query
candidates: List of candidate documents with scores
top_k: Number of top results to return
Returns:
Reranked list of candidates (not limited to top_k, that's done by MMR)
"""
pass
class HeuristicReranker(Reranker):
"""
Heuristic reranking using weighted combination of signals.
Scoring formula:
- Base: 60% semantic_similarity + 40% bm25_normalized
- Recency boost: +20% (normalized on deltas)
- Frequency boost: +10% (normalized on deltas)
"""
def __init__(self):
"""Initialize heuristic reranker."""
pass
def rerank(
self,
query: str,
candidates: List[Dict[str, Any]],
top_k: int
) -> List[Dict[str, Any]]:
"""Rerank using heuristic scoring."""
from ..search_helpers import normalize_scores_on_deltas
# Calculate recency and frequency for all candidates
for c in candidates:
event_date = c["event_date"]
if isinstance(event_date, str):
event_date = datetime.fromisoformat(event_date)
days_since = (utcnow() - event_date).total_seconds() / 86400
c["recency"] = calculate_recency_weight(days_since)
c["frequency"] = calculate_frequency_weight(c.get("access_count", 0))
# Normalize recency and frequency on deltas
candidates = normalize_scores_on_deltas(candidates, ["recency", "frequency"])
# Normalize BM25 scores
bm25_scores = [c["bm25_score"] for c in candidates if c["bm25_score"] > 0]
if bm25_scores:
max_bm25 = max(bm25_scores)
for c in candidates:
c["bm25_score_normalized"] = c["bm25_score"] / max_bm25 if max_bm25 > 0 else 0.0
else:
for c in candidates:
c["bm25_score_normalized"] = 0.0
# Calculate final score
for c in candidates:
# Base score: weighted combination of semantic and BM25
base_score = (
0.6 * c["semantic_similarity"] +
0.4 * c["bm25_score_normalized"]
)
# Apply normalized boosts
recency_boost = 1.0 + (0.2 * c.get("recency_normalized", 0.0))
frequency_boost = 1.0 + (0.1 * c.get("frequency_normalized", 0.0))
final_score = base_score * recency_boost * frequency_boost
c["weight"] = final_score
# Sort by final weight
candidates.sort(key=lambda x: x["weight"], reverse=True)
return candidates
class CrossEncoderReranker(Reranker):
class CrossEncoderReranker:
"""
Neural reranking using a cross-encoder model.
@ -146,8 +31,7 @@ class CrossEncoderReranker(Reranker):
def rerank(
self,
query: str,
candidates: List[Dict[str, Any]],
top_k: int
candidates: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Rerank using cross-encoder scores."""
if not candidates:

View file

@ -35,7 +35,7 @@ async def retrieve_semantic(
"""
results = await conn.fetch(
"""
SELECT id, text, context, event_date, access_count, embedding, fact_type,
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE agent_id = $2
@ -89,7 +89,7 @@ async def retrieve_bm25(
results = await conn.fetch(
"""
SELECT id, text, context, event_date, access_count, embedding, fact_type,
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
FROM memory_units
WHERE agent_id = $2
@ -126,7 +126,7 @@ async def retrieve_graph(
# Find entry points
entry_points = await conn.fetch(
"""
SELECT id, text, context, event_date, access_count, embedding, fact_type,
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE agent_id = $2
@ -163,7 +163,7 @@ async def retrieve_graph(
if budget_remaining > 0:
neighbors = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding, mu.fact_type,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding, mu.fact_type, mu.document_id,
ml.weight
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
@ -228,7 +228,7 @@ async def retrieve_temporal(
# Find entry points: facts in date range with semantic relevance
entry_points = await conn.fetch(
"""
SELECT id, text, context, event_date, access_count, embedding, fact_type,
SELECT id, text, context, event_date, access_count, embedding, fact_type, document_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE agent_id = $2
@ -277,7 +277,7 @@ async def retrieve_temporal(
if budget_remaining > 0:
neighbors = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding, mu.fact_type,
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.access_count, mu.embedding, mu.fact_type, mu.document_id,
ml.weight, ml.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM memory_links ml

View file

@ -30,7 +30,7 @@ from .operations import EmbeddingOperationsMixin, LinkOperationsMixin, ThinkOper
from .llm_wrapper import LLMConfig
from .response_models import SearchResult as SearchResultModel, ThinkResult, MemoryFact
from .task_backend import TaskBackend, AsyncIOQueueBackend
from .search.reranking import HeuristicReranker, CrossEncoderReranker
from .search.reranking import CrossEncoderReranker
def utcnow():
@ -141,8 +141,7 @@ class TemporalSemanticMemory(
self._llm_client = self._llm_config._client
self._llm_model = self._llm_config.model
# Initialize rerankers (cached for performance)
self._heuristic_reranker = HeuristicReranker()
# Initialize cross-encoder reranker (cached for performance)
self._cross_encoder_reranker = CrossEncoderReranker(cross_encoder=cross_encoder)
# Initialize task backend
@ -727,6 +726,9 @@ class TemporalSemanticMemory(
# Step 1: Extract facts from ALL contents in parallel
step_start = time.time()
# If fact_type_override is 'opinion', extract only opinions; otherwise extract world and agent facts
extract_opinions = (fact_type_override == 'opinion')
# Create tasks for parallel fact extraction using configured LLM
fact_extraction_tasks = []
for item in contents:
@ -734,7 +736,7 @@ class TemporalSemanticMemory(
context = item.get("context", "")
event_date = item.get("event_date") or utcnow()
task = extract_facts(content, event_date, context, llm_config=self._llm_config, agent_name=agent_name)
task = extract_facts(content, event_date, context, llm_config=self._llm_config, agent_name=agent_name, extract_opinions=extract_opinions)
fact_extraction_tasks.append((task, event_date, context))
# Wait for all fact extractions to complete
@ -779,6 +781,19 @@ class TemporalSemanticMemory(
if total_facts == 0:
return [[] for _ in contents]
# Step 1.5: Add time offsets to preserve fact ordering within each document
# This allows retrieval to distinguish between facts that happened earlier vs later
# in the same conversation, even when the base event_date is the same
SECONDS_PER_FACT = 10 # Each fact gets 10 seconds offset
for start_idx, end_idx in content_boundaries:
# For each content item, offset its facts sequentially
for i in range(start_idx, end_idx):
fact_position = i - start_idx # 0, 1, 2, ...
# Add incremental offset to preserve order (facts appear in extraction order)
all_fact_dates[i] = all_fact_dates[i] + timedelta(seconds=fact_position * SECONDS_PER_FACT)
log_buffer.append(f"[1.5] Added time offsets: {SECONDS_PER_FACT}s per fact to preserve ordering")
# Step 2: Augment fact texts with readable dates for better temporal matching
# This allows queries like "camping in June" to match facts that happened in June
augmented_texts = []
@ -1018,7 +1033,6 @@ class TemporalSemanticMemory(
thinking_budget: int = 50,
max_tokens: int = 4096,
enable_trace: bool = False,
reranker: str = "heuristic",
) -> tuple[List[Dict[str, Any]], Optional[Any]]:
"""
Search memories using 4-way parallel retrieval (synchronous wrapper).
@ -1033,14 +1047,13 @@ class TemporalSemanticMemory(
thinking_budget: How many units to explore (computational budget)
max_tokens: Maximum tokens to return (counts only 'text' field, default 4096)
enable_trace: If True, returns detailed SearchTrace object
reranker: Reranking strategy - "heuristic" (default) or "cross-encoder"
Returns:
Tuple of (results, trace)
"""
# Run async version synchronously
return asyncio.run(self.search_async(
agent_id, query, fact_type, thinking_budget, max_tokens, enable_trace, reranker
agent_id, query, fact_type, thinking_budget, max_tokens, enable_trace
))
async def search_async(
@ -1051,7 +1064,6 @@ class TemporalSemanticMemory(
thinking_budget: int = 50,
max_tokens: int = 4096,
enable_trace: bool = False,
reranker: str = "cross-encoder",
question_date: Optional[datetime] = None,
) -> SearchResultModel:
"""
@ -1073,9 +1085,6 @@ class TemporalSemanticMemory(
Results are returned until token budget is reached, stopping before
including a fact that would exceed the limit
enable_trace: Whether to return search trace for debugging (deprecated)
reranker: Reranking strategy - "heuristic" (default) or "cross-encoder"
- heuristic: 60% semantic + 40% BM25 + normalized boosts (fast)
- cross-encoder: Neural reranking with ms-marco-MiniLM-L-6-v2 (slower but more accurate)
question_date: Optional date when question was asked (for temporal filtering)
Returns:
@ -1090,7 +1099,7 @@ class TemporalSemanticMemory(
for attempt in range(max_retries + 1):
try:
return await self._search_with_retries(
agent_id, query, fact_type, thinking_budget, max_tokens, enable_trace, reranker, question_date
agent_id, query, fact_type, thinking_budget, max_tokens, enable_trace, question_date
)
except Exception as e:
# Check if it's a connection error
@ -1120,7 +1129,6 @@ class TemporalSemanticMemory(
thinking_budget: int,
max_tokens: int,
enable_trace: bool,
reranker: str,
question_date: Optional[datetime] = None,
) -> tuple[List[Dict[str, Any]], Optional[Any]]:
"""
@ -1140,7 +1148,6 @@ class TemporalSemanticMemory(
thinking_budget: Nodes to explore in graph traversal
max_tokens: Maximum tokens to return (counts only 'text' field)
enable_trace: Whether to return search trace (deprecated)
reranker: Reranking strategy ("heuristic" or "cross-encoder")
Returns:
(results, trace) tuple where trace is None (tracing removed)
@ -1324,18 +1331,12 @@ class TemporalSemanticMemory(
results.append(result_obj)
# Step 5: Rerank using selected strategy (use cached rerankers)
if reranker == "cross-encoder":
reranker_instance = self._cross_encoder_reranker
log_buffer.append(f" [4] Using cross-encoder reranker")
else:
reranker_instance = self._heuristic_reranker
log_buffer.append(f" [4] Using heuristic reranker")
# Step 5: Rerank using cross-encoder
reranker_instance = self._cross_encoder_reranker
log_buffer.append(f" [4] Using cross-encoder reranker")
# Rerank more candidates than we need (thinking_budget * 2)
# so token filtering has diverse options to choose from
rerank_limit = thinking_budget * 2
results = reranker_instance.rerank(query, results, rerank_limit)
# Rerank using cross-encoder
results = reranker_instance.rerank(query, results)
step_duration = time.time() - step_start
log_buffer.append(f" [4] Reranking: {len(results)} candidates scored in {step_duration:.3f}s")
@ -1572,7 +1573,7 @@ class TemporalSemanticMemory(
"message": "Memory unit and all its links deleted successfully" if deleted else "Memory unit not found"
}
async def delete_agent(self, agent_id: str) -> Dict[str, int]:
async def delete_agent(self, agent_id: str, fact_type: Optional[str] = None) -> Dict[str, int]:
"""
Delete all data for a specific agent (multi-tenant cleanup).
@ -1580,12 +1581,13 @@ class TemporalSemanticMemory(
multiple agents to coexist in the same database.
Deletes (with CASCADE):
- All memory units for this agent
- All entities for this agent
- All memory units for this agent (optionally filtered by fact_type)
- All entities for this agent (if deleting all memory units)
- All associated links, unit-entity associations, and co-occurrences
Args:
agent_id: Agent ID to delete
fact_type: Optional fact type filter (world, agent, opinion). If provided, only deletes memories of that type.
Returns:
Dictionary with counts of deleted items
@ -1594,20 +1596,38 @@ class TemporalSemanticMemory(
async with pool.acquire() as conn:
async with conn.transaction():
try:
# Count before deletion for reporting
units_count = await conn.fetchval("SELECT COUNT(*) FROM memory_units WHERE agent_id = $1", agent_id)
entities_count = await conn.fetchval("SELECT COUNT(*) FROM entities WHERE agent_id = $1", agent_id)
if fact_type:
# Delete only memories of a specific fact type
units_count = await conn.fetchval(
"SELECT COUNT(*) FROM memory_units WHERE agent_id = $1 AND fact_type = $2",
agent_id, fact_type
)
await conn.execute(
"DELETE FROM memory_units WHERE agent_id = $1 AND fact_type = $2",
agent_id, fact_type
)
# Delete memory units (cascades to unit_entities, memory_links)
await conn.execute("DELETE FROM memory_units WHERE agent_id = $1", agent_id)
# Note: We don't delete entities when fact_type is specified,
# as they may be referenced by other memory units
return {
"memory_units_deleted": units_count,
"entities_deleted": 0
}
else:
# Delete all data for the agent
units_count = await conn.fetchval("SELECT COUNT(*) FROM memory_units WHERE agent_id = $1", agent_id)
entities_count = await conn.fetchval("SELECT COUNT(*) FROM entities WHERE agent_id = $1", agent_id)
# Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id)
await conn.execute("DELETE FROM entities WHERE agent_id = $1", agent_id)
# Delete memory units (cascades to unit_entities, memory_links)
await conn.execute("DELETE FROM memory_units WHERE agent_id = $1", agent_id)
return {
"memory_units_deleted": units_count,
"entities_deleted": entities_count
}
# Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id)
await conn.execute("DELETE FROM entities WHERE agent_id = $1", agent_id)
return {
"memory_units_deleted": units_count,
"entities_deleted": entities_count
}
except Exception as e:
raise Exception(f"Failed to delete agent data: {str(e)}")

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
from .fact_extraction import extract_facts_from_text
async def extract_facts(text: str, event_date: datetime, context: str = "", llm_config: 'LLMConfig' = None, agent_name: str = None) -> List[Dict[str, str]]:
async def extract_facts(text: str, event_date: datetime, context: str = "", llm_config: 'LLMConfig' = None, agent_name: str = None, extract_opinions: bool = False) -> List[Dict[str, str]]:
"""
Extract semantic facts from text using LLM.
@ -27,6 +27,7 @@ async def extract_facts(text: str, event_date: datetime, context: str = "", llm_
context: Context about the conversation/document
llm_config: LLM configuration to use
agent_name: Optional agent name to help identify agent-related facts
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
Returns:
List of fact dictionaries with keys: 'fact' (text) and 'date' (ISO string)
@ -37,7 +38,7 @@ async def extract_facts(text: str, event_date: datetime, context: str = "", llm_
if not text or not text.strip():
return []
fact_dicts = await extract_facts_from_text(text, event_date, context=context, llm_config=llm_config, agent_name=agent_name)
fact_dicts = await extract_facts_from_text(text, event_date, context=context, llm_config=llm_config, agent_name=agent_name, extract_opinions=extract_opinions)
if not fact_dicts:
logging.warning(f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}")

View file

@ -130,6 +130,90 @@ We presented our findings to the team yesterday.
print(f" - {f['fact']}")
@pytest.mark.asyncio
async def test_speaker_attribution_predictions():
"""
Test that predictions made by different speakers are correctly attributed.
This addresses the issue where Jamie's prediction of "Niners 27-13" was being
incorrectly attributed to Marcus (the agent) in the extracted facts.
"""
# Simplified transcript with clear predictions from each speaker
transcript = """
Marcus: [excited] I'm calling it now, Rams will win twenty seven to twenty four, their defense is too strong!
Jamie: [laughs] No way, I predict the Niners will win twenty seven to thirteen, comfy win at home.
Marcus: [angry] That's ridiculous, I stand by my Rams prediction.
Jamie: [teasing] We'll see who's right, my Niners pick is solid.
"""
context = "podcast episode on match prediction of week 10 - Marcus (you) and Jamie - 14 nov"
agent_name = "Marcus"
llm_config = LLMConfig.for_memory()
facts = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 14),
context=context,
llm_config=llm_config,
agent_name=agent_name
)
assert len(facts) > 0, "Should extract at least one fact"
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. [{f['fact_type']}] {f['fact']}")
# Find agent facts (Marcus's statements)
agent_facts = [f for f in facts if f["fact_type"] == "agent"]
# Find world facts about Jamie
jamie_facts = [f for f in facts if f["fact_type"] == "world" and "Jamie" in f["fact"]]
print(f"\nAgent facts (Marcus): {len(agent_facts)}")
for f in agent_facts:
print(f" - {f['fact']}")
print(f"\nWorld facts (Jamie): {len(jamie_facts)}")
for f in jamie_facts:
print(f" - {f['fact']}")
# CRITICAL: Marcus's prediction should be in agent facts, NOT Jamie's prediction
# Marcus predicted: Rams 27-24
# Jamie predicted: Niners 27-13
agent_facts_text = " ".join([f["fact"].lower() for f in agent_facts])
# Marcus (agent) should have mentioned Rams 27-24
assert "rams" in agent_facts_text or "twenty seven to twenty four" in agent_facts_text or "27" in agent_facts_text, \
f"Agent facts should contain Marcus's Rams prediction. Agent facts: {[f['fact'] for f in agent_facts]}"
# Marcus (agent) should NOT have predicted Niners 27-13 (that was Jamie!)
# Check that agent facts don't incorrectly contain Jamie's prediction
has_niners_27_13 = False
for fact in agent_facts:
fact_lower = fact["fact"].lower()
# Look for patterns that suggest 27-13 Niners prediction
if ("niners" in fact_lower or "49ers" in fact_lower) and ("27" in fact_lower or "twenty seven") and ("13" in fact_lower or "thirteen"):
# This is Jamie's prediction, should NOT be in agent facts!
has_niners_27_13 = True
print(f"\n❌ ERROR: Found Jamie's Niners 27-13 prediction in agent facts: {fact['fact']}")
assert not has_niners_27_13, \
f"Agent facts should NOT contain Jamie's Niners 27-13 prediction! " \
f"Agent facts: {[f['fact'] for f in agent_facts]}"
# Jamie's facts should contain Niners prediction
if jamie_facts:
jamie_facts_text = " ".join([f["fact"].lower() for f in jamie_facts])
# Jamie predicted Niners, so world facts about Jamie might mention it
print(f"\n✅ Jamie facts correctly classified as world facts")
print(f"\n✅ Speaker attribution test passed: Predictions correctly attributed to their speakers")
@pytest.mark.asyncio
async def test_skip_podcast_meta_commentary():
"""

View file

@ -0,0 +1,213 @@
"""
Test that facts from the same conversation maintain temporal ordering.
This ensures that when multiple facts are extracted from a long conversation,
their relative order is preserved via time offsets, allowing retrieval to
distinguish between things said earlier vs later.
"""
import pytest
from datetime import datetime, timezone
from memora import TemporalSemanticMemory
import os
@pytest.mark.asyncio
async def test_fact_ordering_within_conversation():
"""
Test that facts extracted from one conversation get incremental time offsets
to preserve their ordering for retrieval.
"""
# Create memory instance
memory = TemporalSemanticMemory(
db_url=os.getenv("MEMORA_API_DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORA_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORA_API_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORA_API_LLM_MODEL", "openai/gpt-oss-20b"),
)
await memory.initialize()
agent_id = "test_ordering_agent"
# Clear any existing data
await memory.delete_agent(agent_id)
# Get/create agent (auto-creates with defaults)
await memory.get_agent_profile(agent_id)
# Update personality to match Marcus
await memory.update_agent_personality(agent_id, {
"openness": 0.7,
"conscientiousness": 0.6,
"extraversion": 0.8,
"agreeableness": 0.5,
"neuroticism": 0.3,
"bias_strength": 0.5
})
# A conversation where Marcus changes his position
conversation = """
Marcus: I think the Rams will win 27-24. Their defense is really strong.
Jamie: I disagree, I think Niners will win.
Marcus: Actually, after thinking about it more, I'm changing my prediction to Rams by 3 points only.
Jamie: That's more reasonable.
Marcus: Yeah, I realized I was being too optimistic about their defense.
"""
base_event_date = datetime(2024, 11, 14, 10, 0, 0, tzinfo=timezone.utc)
# Store the conversation
await memory.put_async(
agent_id=agent_id,
content=conversation,
context="podcast discussion about NFL game",
event_date=base_event_date,
document_id="test_conv_1"
)
# Search for all facts about Marcus's predictions
results = await memory.search_async(
agent_id=agent_id,
query="Marcus prediction Rams",
fact_type=['agent', 'world'],
thinking_budget=100,
max_tokens=8192
)
print(f"\n=== Retrieved {len(results['results'])} facts ===")
for i, result in enumerate(results['results']):
print(f"{i+1}. [{result['event_date']}] {result['text'][:100]}")
# Get all agent facts (Marcus's statements)
agent_facts = [r for r in results['results'] if r.get('fact_type') == 'agent']
print(f"\n=== Agent facts (Marcus's statements) ===")
for i, fact in enumerate(agent_facts):
print(f"{i+1}. [{fact['event_date']}] {fact['text']}")
# Check that agent facts have different timestamps
if len(agent_facts) >= 2:
timestamps = [datetime.fromisoformat(f['event_date'].replace('Z', '+00:00')) for f in agent_facts]
# Verify timestamps are different (have time offsets)
unique_timestamps = set(timestamps)
assert len(unique_timestamps) == len(timestamps), \
f"Expected unique timestamps for each fact, but got duplicates: {timestamps}"
# Verify timestamps are in order (ascending)
for i in range(len(timestamps) - 1):
assert timestamps[i] < timestamps[i + 1], \
f"Facts should be ordered by time. Fact {i} ({timestamps[i]}) >= Fact {i+1} ({timestamps[i+1]})"
# Verify reasonable time spacing (should be ~10 seconds apart)
time_diffs = [(timestamps[i+1] - timestamps[i]).total_seconds() for i in range(len(timestamps) - 1)]
print(f"\n=== Time differences between facts: {time_diffs} seconds ===")
# Each fact should be 10+ seconds apart (allowing for some flexibility)
for diff in time_diffs:
assert diff >= 5, f"Expected at least 5 seconds between facts, got {diff}"
print(f"\n✅ All {len(agent_facts)} agent facts have properly ordered timestamps")
# Verify that retrieval returns facts in chronological order
# The first prediction should come before the changed prediction
agent_texts = [f['text'].lower() for f in agent_facts]
# Look for evidence of the sequence
has_first_prediction = any('27' in text and '24' in text for text in agent_texts)
has_changed_prediction = any('chang' in text or 'by 3' in text or 'realized' in text for text in agent_texts)
if has_first_prediction and has_changed_prediction:
# Find indices
first_idx = next(i for i, text in enumerate(agent_texts) if '27' in text and '24' in text)
changed_idx = next(i for i, text in enumerate(agent_texts) if 'chang' in text or 'by 3' in text or 'realized' in text)
print(f"\nFirst prediction at index {first_idx}: {agent_facts[first_idx]['text'][:100]}")
print(f"Changed prediction at index {changed_idx}: {agent_facts[changed_idx]['text'][:100]}")
# The original prediction should come before the changed one
assert timestamps[first_idx] < timestamps[changed_idx], \
"Original prediction should have earlier timestamp than changed prediction"
print(f"\n✅ Temporal ordering preserved: First prediction came before changed prediction")
# Cleanup
await memory.delete_agent(agent_id)
print(f"\n✅ Test passed: Fact ordering within conversation is preserved")
@pytest.mark.asyncio
async def test_multiple_documents_ordering():
"""
Test that facts from different documents get separate time offsets,
so facts within each document maintain their order.
"""
memory = TemporalSemanticMemory(
db_url=os.getenv("MEMORA_API_DATABASE_URL"),
memory_llm_provider=os.getenv("MEMORA_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("MEMORA_API_LLM_API_KEY"),
memory_llm_model=os.getenv("MEMORA_API_LLM_MODEL", "openai/gpt-oss-20b"),
)
await memory.initialize()
agent_id = "test_multi_doc_agent"
# Clear and create agent
await memory.delete_agent(agent_id)
await memory.get_agent_profile(agent_id) # Auto-creates with defaults
# Two separate conversations with same base time
base_time = datetime(2024, 11, 14, 10, 0, 0, tzinfo=timezone.utc)
conv1 = """
Alice: I prefer React for this project.
Bob: Why React?
Alice: It has better tooling and I'm more familiar with it.
"""
conv2 = """
Alice: Actually, I'm thinking Vue might be better.
Bob: What changed your mind?
Alice: I reconsidered the team's experience level.
"""
# Store both conversations with batch
await memory.put_batch_async(
agent_id=agent_id,
contents=[
{"content": conv1, "context": "project discussion 1", "event_date": base_time},
{"content": conv2, "context": "project discussion 2", "event_date": base_time}
]
)
# Search for Alice's preferences
results = await memory.search_async(
agent_id=agent_id,
query="Alice preference React Vue",
fact_type=['agent'],
thinking_budget=100,
max_tokens=8192
)
print(f"\n=== Retrieved {len(results['results'])} agent facts ===")
agent_facts = [r for r in results['results'] if r.get('fact_type') == 'agent']
for i, fact in enumerate(agent_facts):
print(f"{i+1}. [{fact['event_date']}] {fact['text'][:80]}")
# Each conversation's facts should have different timestamps
if len(agent_facts) >= 2:
timestamps = [datetime.fromisoformat(f['event_date'].replace('Z', '+00:00')) for f in agent_facts]
unique_timestamps = set(timestamps)
assert len(unique_timestamps) >= 2, \
f"Expected multiple unique timestamps across conversations, got: {len(unique_timestamps)}"
print(f"\n✅ Facts from {len(agent_facts)} statements have {len(unique_timestamps)} unique timestamps")
# Cleanup
await memory.delete_agent(agent_id)
print(f"\n✅ Test passed: Multiple documents maintain separate ordering")