diff --git a/PAPER_RETRIEVAL.md b/PAPER_RETRIEVAL.md index 9d75b6be..4855b1f9 100644 --- a/PAPER_RETRIEVAL.md +++ b/PAPER_RETRIEVAL.md @@ -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 diff --git a/README.md b/README.md index f16e6057..9d4bdbb9 100644 --- a/README.md +++ b/README.md @@ -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 }' ``` diff --git a/memora-cli/src/api.rs b/memora-cli/src/api.rs index 962a4dcd..baf8aecb 100644 --- a/memora-cli/src/api.rs +++ b/memora-cli/src/api.rs @@ -39,6 +39,8 @@ pub struct Fact { pub context: Option, #[serde(default)] pub event_date: Option, + #[serde(default)] + pub document_id: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -78,6 +80,7 @@ pub struct BatchMemoryRequest { pub struct BatchMemoryResponse { pub success: bool, pub stored_count: Option, + pub items_count: Option, pub error: Option, pub job_id: Option, } @@ -419,6 +422,57 @@ impl ApiClient { Ok(result) } + pub fn update_agent_name( + &self, + agent_id: &str, + name: &str, + verbose: bool, + ) -> Result { + #[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 { + 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) + } } diff --git a/memora-cli/src/main.rs b/memora-cli/src/main.rs index 2ed1a7a4..5cab85a1 100644 --- a/memora-cli/src/main.rs +++ b/memora-cli/src/main.rs @@ -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, + + /// 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 { diff --git a/memora-cli/src/ui.rs b/memora-cli/src/ui.rs index fb55e4dd..5018dc22 100644 --- a/memora-cli/src/ui.rs +++ b/memora-cli/src/ui.rs @@ -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 { 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!(); } diff --git a/memora/memora/api.py b/memora/memora/api.py index 60672871..d2925256 100644 --- a/memora/memora/api.py +++ b/memora/memora/api.py @@ -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)) diff --git a/memora/memora/fact_extraction.py b/memora/memora/fact_extraction.py index 0af09b78..7af057d3 100644 --- a/memora/memora/fact_extraction.py +++ b/memora/memora/fact_extraction.py @@ -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) ] diff --git a/memora/memora/response_models.py b/memora/memora/response_models.py index dfbc4879..bb2a248e 100644 --- a/memora/memora/response_models.py +++ b/memora/memora/response_models.py @@ -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 } } diff --git a/memora/memora/search/__init__.py b/memora/memora/search/__init__.py index 136b58cd..aef63cd5 100644 --- a/memora/memora/search/__init__.py +++ b/memora/memora/search/__init__.py @@ -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", ] diff --git a/memora/memora/search/reranking.py b/memora/memora/search/reranking.py index 3dd9777d..71a57a3c 100644 --- a/memora/memora/search/reranking.py +++ b/memora/memora/search/reranking.py @@ -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: diff --git a/memora/memora/search/retrieval.py b/memora/memora/search/retrieval.py index 6042bc59..bc65d23f 100644 --- a/memora/memora/search/retrieval.py +++ b/memora/memora/search/retrieval.py @@ -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 diff --git a/memora/memora/temporal_semantic_memory.py b/memora/memora/temporal_semantic_memory.py index 4dea1d9c..63f81a86 100644 --- a/memora/memora/temporal_semantic_memory.py +++ b/memora/memora/temporal_semantic_memory.py @@ -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)}") diff --git a/memora/memora/utils.py b/memora/memora/utils.py index 4f96ac34..c086d3ce 100644 --- a/memora/memora/utils.py +++ b/memora/memora/utils.py @@ -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}") diff --git a/memora/tests/test_fact_classification.py b/memora/tests/test_fact_classification.py index e7c6175a..0cc4abb2 100644 --- a/memora/tests/test_fact_classification.py +++ b/memora/tests/test_fact_classification.py @@ -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(): """ diff --git a/memora/tests/test_fact_ordering.py b/memora/tests/test_fact_ordering.py new file mode 100644 index 00000000..c2e50e2f --- /dev/null +++ b/memora/tests/test_fact_ordering.py @@ -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")