diff --git a/changes/95.fixed.md b/changes/95.fixed.md new file mode 100644 index 00000000..872a7e2e --- /dev/null +++ b/changes/95.fixed.md @@ -0,0 +1,3 @@ +**Contradictions are caught in other languages and long sentences too.** A newer memory in Hindi or another +language, or a long sentence that names the person a few words in, now replaces the older memory it contradicts +instead of sitting next to it. diff --git a/docs/API.md b/docs/API.md index 7ca994a3..b0b68e43 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2903,6 +2903,8 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a overlap ≥ `merge_min_overlap`, never facts whose names/places/numbers conflict; the kept fact keeps its id), settle contradictions: facts are paired when they are about the same subject (name, possessive relation like "Maya's sister Riya", or the user as "I"/"the user") and share an attribute family or differ in names/places/numbers or negation; + families come from English keywords, and a fact they miss (often one that isn't in English) gets the family whose + short description its embedding matches with similarity ≥ `memory.family_similarity` (default 0.78); place, job, relationship and diet pairs are always checked, broader families (routine, ownership, health) need similarity ≥ `contradiction_similarity`; the model confirms and names the current fact (the newer one wins unless the model picks the older and only the older has explicit recent wording such as "now" or "this month"); the loser is diff --git a/sentient/config/schema.py b/sentient/config/schema.py index c295002d..7a642c24 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -518,6 +518,12 @@ class MemoryConfig(BaseModel): 6000, ge=500, description="Max characters injected from each workspace markdown file." ) graph_link_similarity: float = Field(0.80, ge=0.0, le=1.0, description="Memory graph edge threshold.") + family_similarity: float = Field( + 0.78, ge=0.0, le=1.0, + description="When the English keyword attribute families (residence, job, ...) find nothing in a fact - " + "often because it isn't English - a family is assigned instead above this embedding similarity to a short " + "description of it. Local embedding models score unrelated text around 0.65, so keep it high.", + ) summarize_after_minutes: int = Field( 60, ge=5, description="Conversation chunks older than this are summarized into episodic memory." ) diff --git a/sentient/memory/dreaming.py b/sentient/memory/dreaming.py index 4acbc381..16e8c694 100644 --- a/sentient/memory/dreaming.py +++ b/sentient/memory/dreaming.py @@ -36,7 +36,7 @@ TIME_WORDING_RE, conflict_strength, new_details, - profile_fact, + profile_fact_async, word_overlap, ) from sentient.memory.schema import ensure_memory_schema @@ -552,15 +552,20 @@ def _clearer(self, hindi: dict, other: dict, facts: list[dict]) -> tuple[dict, d def contradiction_candidate(a: str, b: str, user_name: str = "") -> bool: return conflict_strength(a, b, user_name) > 0 - def contradiction_pairs(self, facts: list[dict]) -> list[tuple[int, float, int, int]]: + async def contradiction_pairs(self, facts: list[dict]) -> list[tuple[int, float, int, int]]: """Candidate pairs ``(strength, similarity, i, j)``, strongest first. Facts are grouped by subject (the person or entity a fact is about, including possessive relations and the user's own name), so "Maya moved to Bengaluru" meets "Maya lives in Pune" without sharing a leading word or a high embedding score. Pairs sharing only a broad attribute also need - ``contradiction_similarity``.""" + ``contradiction_similarity``. A fact's attribute family falls back to embedding similarity when the + English keyword families miss it, e.g. non-English text (``profile_fact_async``).""" user = self.app.config.assistant.user_name - profiles = [profile_fact(f["content"], user) for f in facts] + mem = self.app.memory + embed_one = mem.embed_query if mem is not None else None + model_key = mem.llm.model_for("embedding") if mem is not None else "" + threshold = self.app.config.memory.family_similarity + profiles = [await profile_fact_async(f["content"], embed_one, user, model_key, threshold) for f in facts] groups: dict[str, list[int]] = {} for n, prof in enumerate(profiles): for s in prof.subjects: @@ -596,7 +601,7 @@ async def _contradiction_step(self, stats: dict, notes: dict, budget: list[int]) checked = set(checked_list) gone: set[int] = set() name = self._name() - for _strength, _sim, i, j in self.contradiction_pairs(facts): + for _strength, _sim, i, j in await self.contradiction_pairs(facts): if budget[0] <= 0: break older, newer = sorted((facts[i], facts[j]), key=lambda f: (f["updated_at"], f["id"])) diff --git a/sentient/memory/facts.py b/sentient/memory/facts.py index 547ac4c7..b82970fc 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -28,8 +28,10 @@ import json import logging import re +import unicodedata from collections import OrderedDict -from dataclasses import dataclass +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -140,13 +142,42 @@ def new_details(new_fact: str, existing: str) -> set[str]: # ---------------------------------------------------------------------- subjects and attribute families _RSQUO = chr(0x2019) # curly apostrophe some models write in possessives -_NAME_WORD_RE = re.compile(r"[A-Za-z][\w'-]*") -_LEAD_SKIP = STOPWORDS | { + + +def _name_words(text: str) -> list[str]: + """Words of ``text`` for :func:`fact_subjects`: a letter followed by letters, marks, digits, apostrophes + or hyphens. Any Unicode letter starts a word, not just A-Z, so a Devanagari, Cyrillic or CJK name is one + too - and marks continue it, since ``\\w`` alone stops at the first one: Devanagari (and other Indic + scripts') vowel signs are combining marks, not letters, so plain ``\\w`` truncated "माया" to "म".""" + words: list[str] = [] + current: list[str] = [] + for ch in text: + category = unicodedata.category(ch)[0] + if category in "LMN" or ch in "'-": + if current or category != "M": # a mark can't start a word + current.append(ch) + elif current: + words.append("".join(current)) + current = [] + if current: + words.append("".join(current)) + return words + + +_MONTHS = { + "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", + "november", "december", +} +_WEEKDAYS = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} +_LEAD_SKIP = STOPWORDS | _MONTHS | _WEEKDAYS | { "every", "last", "next", "since", "today", "tonight", "yesterday", "tomorrow", "recently", "currently", "lately", "usually", "always", "sometimes", "often", "earlier", "later", "after", "before", "during", "once", "still", "morning", "evening", "night", "week", "weekend", "month", "year", "day", "until", "yes", "okay", "actually", "update", "correction", "oh", "well", } +# Enough to clear a short leading clause ("Last year in March, after the wedding, Riya ..."); the subject is +# still whichever word looks name-like first, so this is a bound on how far to look, not a guess of where it is. +_SUBJECT_SCAN_WORDS = 12 _RELATION_WORDS = { "sister", "brother", "mother", "mom", "mum", "father", "dad", "wife", "husband", "partner", "girlfriend", "boyfriend", "son", "daughter", "friend", "manager", "boss", "colleague", "cousin", "uncle", "aunt", "grandmother", @@ -203,23 +234,33 @@ def new_details(new_fact: str, existing: str) -> set[str]: ) +def _cased(ch: str) -> bool: + """Whether ``ch`` belongs to a script with a case distinction (Latin, Cyrillic, Greek, ...). + Devanagari, CJK, Arabic and similar scripts have no upper/lower case, so ``isupper()`` can + never say "this looks like a proper noun" there - every letter counts as name-like instead.""" + return ch.isupper() or ch.islower() + + def fact_subjects(text: str, user_name: str = "") -> set[str]: """Who a fact is about, as lowercase aliases: {"maya"}, {"maya's sister", "riya"} for - "Maya's sister Riya ...". "I", "my" and "the user" mean ``user_name``. Empty when unclear.""" + "Maya's sister Riya ...". "I", "my" and "the user" mean ``user_name``. Empty when unclear. + + Name-like words are capitalized ones (the English/Latin signal) plus, in scripts without case, + any word at all - there is no capitalization to check, so this is necessarily cruder there and + only reliable when the subject leads the sentence.""" user = user_name.strip().lower() or "user" text = (text or "").replace(_RSQUO, "'").strip() text = re.sub(r"^(the user's|my)\b", f"{user}'s", text, flags=re.IGNORECASE) text = re.sub(r"^(the user|i)\b", user, text, flags=re.IGNORECASE) - words = _NAME_WORD_RE.findall(text) - for i, w in enumerate(words[:6]): + words = _name_words(text) + for i, w in enumerate(words[:_SUBJECT_SCAN_WORDS]): low = w.lower() poss = low.endswith("'s") root = low[:-2] if poss else low - is_name = (w[0].isupper() and root not in _LEAD_SKIP) or root == user + namelike = w[0].isupper() or not _cased(w[0]) + is_name = (namelike and root not in _LEAD_SKIP) or root == user if not is_name: - if w[0].isupper() or root in _LEAD_SKIP: - continue # sentence starters like "Every", "Since", "Last" - return set() + continue # a stopword, a sentence starter, or an ordinary word earlier in the clause nxt = words[i + 1].lower() if i + 1 < len(words) else "" if poss and nxt in _RELATION_WORDS: aliases = {f"{root}'s {nxt}"} @@ -236,6 +277,40 @@ def attribute_families(text: str) -> set[str]: return {name for name, rx in ATTRIBUTE_FAMILIES.items() if rx.search(low)} +# A short description of each family, embedded once per model and cached, for text the English keyword +# regexes above miss - chiefly non-English facts, whose words never match ATTRIBUTE_FAMILIES at all. +_FAMILY_PROTOTYPES: dict[str, str] = { + "residence": "where someone lives, has lived, or has moved to", + "job": "someone's job, employer, role or career", + "relationship": "someone's romantic relationship or marital status", + "diet": "what someone eats, or their diet", + "health": "someone's health, an illness, allergy or medical condition", + "ownership": "something someone owns, bought or sold", + "schedule": "someone's daily routine or schedule", +} +_family_proto_cache: dict[str, dict[str, list[float]]] = {} # embedding-model key -> family -> prototype vector + + +async def embedding_attribute_families( + text: str, + embed_one: Callable[[str], Awaitable[list[float]]], + model_key: str, + threshold: float, +) -> set[str]: + """Attribute families for ``text`` found by embedding similarity to a short description of each one, + rather than the English keyword families above. Meant as a fallback for text those regexes find + nothing in - typically non-English facts - not a replacement: the keyword path is nearly free and + exact, so callers should only reach for this once it comes back empty.""" + if not (text or "").strip(): + return set() + cache = _family_proto_cache.get(model_key) + if cache is None: + cache = {name: await embed_one(proto) for name, proto in _FAMILY_PROTOTYPES.items()} + _family_proto_cache[model_key] = cache + vec = await embed_one(text) + return {name for name, proto_vec in cache.items() if cosine(vec, proto_vec) >= threshold} + + PAST_RE = re.compile( r"\b(lived|used to|grew up|as a (?:child|kid|teenager)|born|formerly|previously|childhood|in (?:19|20)\d\d)\b", re.IGNORECASE, @@ -271,6 +346,23 @@ def profile_fact(text: str, user_name: str = "") -> FactProfile: ) +async def profile_fact_async( + text: str, + embed_one: Callable[[str], Awaitable[list[float]]] | None, + user_name: str = "", + model_key: str = "", + threshold: float = 0.78, +) -> FactProfile: + """Like :func:`profile_fact`, but when the keyword attribute families come back empty - usually because + ``text`` isn't English - falls back to :func:`embedding_attribute_families`. ``embed_one`` is omitted + (``None``) wherever an embedder isn't on hand (e.g. the dreaming service with no configured memory).""" + prof = profile_fact(text, user_name) + if prof.families or embed_one is None: + return prof + families = await embedding_attribute_families(text, embed_one, model_key, threshold) + return prof if not families else replace(prof, families=frozenset(families)) + + # ---------------------------------------------------------------------- is a new fact about the same thing? # verbs that name what a preference or habit is about ("does not want files deleted" vs "... written") _ACTION_VERBS = frozenset({ @@ -1255,9 +1347,12 @@ async def _related_facts( self, fact: str, exclude: set[int], scopes: list[str] | None = None ) -> list[dict]: """Up to 3 facts about the same subject and attribute family as ``fact``, found by keyword rather - than embedding ("moved to Bengaluru last month" scores low against "lives in Pune").""" + than embedding ("moved to Bengaluru last month" scores low against "lives in Pune"). The family + itself falls back to embedding similarity when the keyword families miss, e.g. non-English text.""" user = self.config.assistant.user_name - prof = profile_fact(fact, user) + model_key = self.llm.model_for("embedding") + threshold = self.config.memory.family_similarity + prof = await profile_fact_async(fact, self.embed_query, user, model_key, threshold) if not prof.subjects or not prof.families: return [] terms = [t for s in sorted(prof.subjects) for t in keyword_tokens(s)] @@ -1276,7 +1371,7 @@ async def _related_facts( ) out = [] for r in rows: - other = profile_fact(r["content"], user) + other = await profile_fact_async(r["content"], self.embed_query, user, model_key, threshold) if prof.families & other.families and conflict_strength(prof, other) > 0: out.append(_row_to_memory(r)) out.sort(key=lambda m: m["updated_at"] or "", reverse=True) diff --git a/tests/memory/test_dreaming.py b/tests/memory/test_dreaming.py index 2719557b..6127eead 100644 --- a/tests/memory/test_dreaming.py +++ b/tests/memory/test_dreaming.py @@ -5,7 +5,13 @@ import pytest from sentient.memory.dreaming import DreamingService, covers, parse_hhmm -from sentient.memory.facts import attribute_families, fact_subjects +from sentient.memory.facts import ( + _FAMILY_PROTOTYPES, # white-box: the real prototype text per family + attribute_families, + embedding_attribute_families, + fact_subjects, + profile_fact_async, +) from tests.memory.helpers import add_fact, drain @@ -36,6 +42,54 @@ def test_subjects_and_families(): assert "residence" in attribute_families("Maya moved to Bengaluru last month") +def test_subject_in_non_english_script(): + # Devanagari has no upper/lower case, so the English capitalization signal doesn't exist here - both + # facts still resolve to the same subject because it leads the sentence in both (#95). + assert fact_subjects("माया पुणे में रहती है") == {"माया"} # "Maya lives in Pune" + assert fact_subjects("माया बेंगलुरु चली गई") == {"माया"} # "Maya moved to Bengaluru" + + +def test_subject_past_a_long_leading_clause(): + # "March" and "wedding" used to be mistaken for (or block) the subject: "March" is capitalized but is a + # month, not a name, and the old code gave up entirely on the first lowercase, unrecognised word (#95). + assert fact_subjects("Last year in March, after the wedding, Riya moved to Mumbai.") == {"riya"} + + +async def test_embedding_attribute_families_matches_the_closest_prototype(): + # One-hot vectors per family: the fact text lands exactly on "residence" and nowhere else. + names = list(_FAMILY_PROTOTYPES) + one_hot = {name: [1.0 if n == name else 0.0 for n in names] for name in names} + fact_text = "माया बेंगलुरु चली गई" # "Maya moved to Bengaluru" + + async def embed_one(text: str) -> list[float]: + for name, proto in _FAMILY_PROTOTYPES.items(): + if text == proto: + return one_hot[name] + return one_hot["residence"] # the fact text itself + + assert await embedding_attribute_families(fact_text, embed_one, "test-model", threshold=0.5) == {"residence"} + # orthogonal to every other family, and even "residence" isn't similar enough above its own score + assert await embedding_attribute_families(fact_text, embed_one, "test-model", threshold=1.5) == set() + + +async def test_profile_fact_async_only_falls_back_when_keywords_find_nothing(): + calls: list[str] = [] + + async def embed_one(text: str) -> list[float]: + calls.append(text) + return [1.0, 0.0, 0.0] + + # English text: the keyword family already matches, so the embedder is never called + prof = await profile_fact_async("Maya moved to Bengaluru", embed_one, "Maya", "test-model") + assert prof.families == {"residence"} and calls == [] + + # non-English text with no keyword match: falls back, calling the embedder for the + # prototypes (once, then cached) plus the fact text + prof = await profile_fact_async("माया बेंगलुरु चली गई", embed_one, "Maya", "test-model-2", threshold=0.5) + assert calls # the fallback ran + assert prof.families # every family ties with this embedder, but at least one was assigned + + EXAMPLES = [ ("Maya lives in Pune", "Maya moved to Bengaluru last month"), ("Maya's sister Riya works at Infosys", "Riya got a job at Google"),