diff --git a/src/memory.py b/src/memory.py index 92efbf5b2..edd551bf0 100644 --- a/src/memory.py +++ b/src/memory.py @@ -364,32 +364,32 @@ class MemoryManager: return [] # Define keyword categories for semantic matching - identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"] + identity_words = ["name", "called", "identity", "myself"] contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"] preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"] task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"] fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"] query_lower = query.lower() + query_words = set(re.findall(r'\w+', query_lower)) + query_tokens = set(tokenize(query_lower)) # Determine query type based on keywords query_type = None - if any(word in query_lower for word in identity_words): + if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower): query_type = "identity" - elif any(word in query_lower for word in contact_words): + elif query_words.intersection(contact_words): query_type = "contact" - elif any(word in query_lower for word in preference_words): + elif query_words.intersection(preference_words): query_type = "preference" - elif any(word in query_lower for word in task_words): + elif query_words.intersection(task_words): query_type = "task" - elif any(word in query_lower for word in fact_words): + elif query_words.intersection(fact_words): query_type = "fact" relevant = [] - identity_memories = [] - other_memories = [] - - # Separate identity memories from others + # Every memory participates in the same ranking and threshold check. + # Query type is a scoring hint, not an unconditional insertion path. for memory in memories: memory_text = memory["text"].lower() # Check if this is an identity memory (contains name patterns or identity indicators) @@ -397,22 +397,7 @@ class MemoryManager: re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]), any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"]) ]) - if is_identity: - identity_memories.append(memory) - else: - other_memories.append(memory) - - # For identity queries, include all identity memories regardless of similarity - if query_type == "identity" and identity_memories: - # Give them high scores to ensure they're included first - for memory in identity_memories: - relevant.append((0.9, memory)) # High score for identity memories in identity queries - - # Process other memories with similarity scoring - for memory in other_memories: - memory_text = memory["text"].lower() memory_tokens = set(tokenize(memory_text)) - query_tokens = set(tokenize(query_lower)) # Calculate base Jaccard similarity if not query_tokens or not memory_tokens: @@ -422,7 +407,11 @@ class MemoryManager: final_score = base_similarity # Apply boosts based on semantic matching - if query_type == "contact": + if query_type == "identity" and is_identity: + # Keep explicit name/identity recall useful without outranking + # a closer textual match or bypassing the caller's threshold. + final_score = max(final_score * 1.3, 0.6) + elif query_type == "contact": # Boost memories with contact information has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com", "phone", "number", "address", diff --git a/tests/test_memory_relevance_ranking.py b/tests/test_memory_relevance_ranking.py new file mode 100644 index 000000000..a6b23820f --- /dev/null +++ b/tests/test_memory_relevance_ranking.py @@ -0,0 +1,52 @@ +"""Native memory ranking must not promote names on substring/first-person guesses.""" +import pytest + +from src.memory import MemoryManager + + +@pytest.fixture +def manager(tmp_path): + return MemoryManager(str(tmp_path)) + + +@pytest.mark.parametrize('query', ['bitcoin price', 'list tasks', 'time forecast', 'summary']) +def test_unrelated_query_does_not_force_name_into_results(manager, query): + name = {'id':'name', 'text':'My name is Rowan Example'} + match = {'id':'match', 'text':query} + assert manager.get_relevant_memories(query, [name, match]) == [match] + + +@pytest.mark.parametrize('query, text', [ + ('what is my email', 'email is contact@example.test'), + ('I like jazz', 'I like jazz music'), + ('remind me about dentist', 'dentist appointment reminder'), +]) +def test_first_person_words_do_not_override_the_actual_topic(manager, query, text): + name = {'id':'name', 'text':'My name is Rowan Example'} + topic = {'id':'topic', 'text':text} + assert manager.get_relevant_memories(query, [name, topic], max_items=1) == [topic] + + +@pytest.mark.parametrize('query', ['Who am I?', 'what am I called', 'my name', 'NAME:']) +def test_explicit_identity_query_still_finds_name(manager, query): + name = {'id':'name', 'text':'My name is Rowan Example'} + assert manager.get_relevant_memories(query, [name]) == [name] + + +def test_closer_match_outranks_identity_signal_and_threshold_still_applies(manager): + name = {'id':'name', 'text':'My name is Rowan Example'} + project = {'id':'project', 'text':'the project name is Polaris'} + assert manager.get_relevant_memories('what is the project name', [name, project], max_items=1) == [project] + assert manager.get_relevant_memories('who am I', [name], threshold=0.7) == [] + + +def test_name_is_still_searchable_by_its_actual_text_without_a_type_keyword(manager): + name = {'id':'name', 'text':'My name is Rowan Example'} + assert manager.get_relevant_memories('Rowan', [name]) == [name] + + +def test_empty_queries_and_limits_keep_their_contract(manager): + memories = [{'text':'tea preference'}, {'text':'tea recipe'}] + assert manager.get_relevant_memories(' ', memories) == [] + assert manager.get_relevant_memories('tea', []) == [] + assert len(manager.get_relevant_memories('tea', memories, max_items=1)) == 1