temporal offset and others
This commit is contained in:
parent
3e618cb001
commit
e15cbb4a44
15 changed files with 772 additions and 390 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.md
17
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
|
||||
}'
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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!();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
213
memora/tests/test_fact_ordering.py
Normal file
213
memora/tests/test_fact_ordering.py
Normal 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")
|
||||
Loading…
Reference in a new issue