From a8f7bfff757170a5447336ad4c1aa0f17ae73e62 Mon Sep 17 00:00:00 2001 From: Anushka Nair Date: Tue, 13 Oct 2026 13:29:50 +0530 Subject: [PATCH 1/4] feat(memory): catch contradictions in other languages and long sentences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contradiction detection paired facts by subject and attribute family using English keyword matching, and the subject had to appear in the first 6 words with the right capitalization - so it missed any non-English fact, and long sentences like "Last year in March, after the wedding, Riya moved to Mumbai." fact_subjects (sentient/memory/facts.py): - Words are now tokenized by Unicode category (letter/mark/number) instead of an ASCII regex, so Devanagari, Cyrillic, CJK etc. names are found at all. Marks (vowel signs like Devanagari matras) continue a word instead of ending it - plain \w stops at the first one, which truncated "माया" to "म". - Scripts without a case distinction can't use capitalization as the "this looks like a name" signal, so any non-skip word is accepted there. - Fixed two bugs found along the way: capitalized month names ("March") were mistaken for a person's name, and the function gave up entirely on the first unrecognized lowercase word - exactly what broke the long-sentence example above, three words short of reaching "Riya". attribute_families keeps its English keyword regexes as the fast path, unchanged and free for existing English facts. A new embedding-based fallback (embedding_attribute_families / profile_fact_async) runs only when that comes back empty - typically non-English text - comparing the fact against a short description of each family (new MemoryConfig.family_similarity threshold). Wired into FactMemory._related_facts and DreamingService. contradiction_pairs (now async), the nightly consolidation pass this is mainly for. Tests cover a Hindi (Devanagari) example and the long-sentence case. Closes #95 Co-Authored-By: Claude Sonnet 5 --- sentient/config/schema.py | 6 ++ sentient/memory/dreaming.py | 15 +++-- sentient/memory/facts.py | 121 ++++++++++++++++++++++++++++++---- tests/memory/test_dreaming.py | 56 +++++++++++++++- 4 files changed, 179 insertions(+), 19 deletions(-) diff --git a/sentient/config/schema.py b/sentient/config/schema.py index f21863f34..189eb671e 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -425,6 +425,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 04d2ead5f..513ac451d 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 @@ -441,15 +441,20 @@ async def _model_merge(self, group: list[dict]) -> tuple[str, int | None]: 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: @@ -485,7 +490,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 ed3853d6a..1e633bae8 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -25,8 +25,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 @@ -122,12 +124,41 @@ 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", } +# 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", @@ -184,23 +215,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}"} @@ -217,6 +258,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, @@ -252,6 +327,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({ @@ -737,9 +829,12 @@ async def forget_source(self, source: str) -> int: async def _related_facts(self, fact: str, exclude: set[int]) -> 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)] @@ -756,7 +851,7 @@ async def _related_facts(self, fact: str, exclude: set[int]) -> list[dict]: ) 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 49dcf0669..9be73d547 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"), From 7fa5a5456622eee249278e9fcecdbd31b44bbaa1 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 18 Oct 2026 14:09:20 +0530 Subject: [PATCH 2/4] docs(memory): mention memory.family_similarity in Dreams, add the changelog entry --- changes/95.fixed.md | 3 +++ docs/API.md | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 changes/95.fixed.md diff --git a/changes/95.fixed.md b/changes/95.fixed.md new file mode 100644 index 000000000..872a7e2e3 --- /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 7ca994a3d..b0b68e43f 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 From 218e977ad5121aae7d7c3918aee7ea94c521f9dc Mon Sep 17 00:00:00 2001 From: Anushka Nair Date: Sat, 24 Oct 2026 19:42:53 +0530 Subject: [PATCH 3/4] fix(memory): a pronoun-led sentence stays unclear even with a later comma CodeRabbit review on #338: fact_subjects("She moved to Delhi, India.") was returning {"india"} - the comma in "Delhi, India" isn't a leading clause, but permissive_until treated it as one since it only checked whether any comma was present, not where. Gated it on the sentence's first word: a pronoun ("she", "he", "it", "they", "this", "that", "these", "those", "we") means no subject here on purpose, regardless of what a later comma looks like. Co-Authored-By: Claude Sonnet 5 --- sentient/memory/facts.py | 12 ++++++++++-- tests/memory/test_dreaming.py | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/sentient/memory/facts.py b/sentient/memory/facts.py index d5f446451..d00f635b0 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -178,6 +178,9 @@ def new_details(new_fact: str, existing: str) -> set[str]: # 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 +# A sentence that leads with one of these has no subject here on purpose ("She moved to Delhi, India." - the +# comma is "Delhi, India", not a leading clause): never treat it as one just because a comma turns up later. +_SUBJECT_PRONOUNS = frozenset({"he", "she", "it", "they", "this", "that", "these", "those", "we"}) _RELATION_WORDS = { "sister", "brother", "mother", "mom", "mum", "father", "dad", "wife", "husband", "partner", "girlfriend", "boyfriend", "son", "daughter", "friend", "manager", "boss", "colleague", "cousin", "uncle", "aunt", "grandmother", @@ -270,8 +273,13 @@ def fact_subjects(text: str, user_name: str = "") -> set[str]: words = [m.group() for m in found] # Ordinary words before the last comma of a leading clause ("Last year in March, after the wedding, Riya # ...") don't have to be recognised to be skipped - only words after it do, same as without a leading - # clause at all ("She moved to Delhi" still has no subject). - permissive_until = sum(len(_NAME_WORD_RE.findall(c)) for c in text.split(",")[:-1]) if "," in text else 0 + # clause at all ("She moved to Delhi" still has no subject). A sentence that leads with a pronoun never + # gets this treatment: a comma later on is something else entirely ("She moved to Delhi, India."). + permissive_until = ( + sum(len(_NAME_WORD_RE.findall(c)) for c in text.split(",")[:-1]) + if "," in text and (not words or words[0].lower() not in _SUBJECT_PRONOUNS) + else 0 + ) def joined(j: int) -> bool: # words j and j + 1 are only a space apart ("Maya Rao", not "Priya, Maya") return j + 1 < len(found) and not text[found[j].end():found[j + 1].start()].strip() diff --git a/tests/memory/test_dreaming.py b/tests/memory/test_dreaming.py index 46f745252..28dc2bac2 100644 --- a/tests/memory/test_dreaming.py +++ b/tests/memory/test_dreaming.py @@ -73,6 +73,14 @@ def test_subject_never_a_leading_number_or_a_place_after_a_preposition(): assert fact_subjects("After the trip to Goa, Karan started working at Wipro.") == {"karan"} +def test_subject_pronoun_stays_unclear_even_with_a_later_comma(): + # CodeRabbit review on #338: a comma doesn't always open a leading clause - "Delhi, India" is a place, and + # a sentence leading with a pronoun has no subject here regardless of what shows up after it. + assert fact_subjects("She moved to Delhi, India.") == set() + assert fact_subjects("He moved to Paris, France.") == set() + assert fact_subjects("They moved to Austin, Texas.") == set() + + async def test_embedding_attribute_families_matches_the_closest_prototype(): # Distinct, non-orthogonal vectors per family (orthogonal ones would make every margin equally wide, # which isn't how a real embedding model's scores for related-but-wrong families behave). From 5e6f0af064edf6036172311f02f728dc0454e476 Mon Sep 17 00:00:00 2001 From: Anushka Nair Date: Sun, 25 Oct 2026 12:35:31 +0530 Subject: [PATCH 4/4] fix(memory): a trailing "Place, Country." is never the subject either Live-engine review on #338: 218e977 only guarded the pronoun case, so any other sentence ending "..., Country." (no pronoun needed) still got the country as its subject - "The office moved to Pune, India." read as {"india"}. Two such facts then shared a subject and the residence family, so the second silently overwrote the first as an unasked-for correction. The real signal isn't whether the sentence leads with a pronoun, it's whether a real clause follows the last comma at all: "Last year in March, after the wedding, Riya moved to Mumbai." has one ("Riya moved to Mumbai"), "The office moved to Pune, India." doesn't - only "India." trails the last comma. permissive_until now requires more than one word after the last comma, which covers both the pronoun case and every other "..., Place." ending without needing to special-case either. Verified against every example in the review (both confirmed-broken and confirmed-still-working), plus the full existing suite. Co-Authored-By: Claude Sonnet 5 --- sentient/memory/facts.py | 12 ++++++++---- tests/memory/test_dreaming.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/sentient/memory/facts.py b/sentient/memory/facts.py index d00f635b0..6e83819b7 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -272,12 +272,16 @@ def fact_subjects(text: str, user_name: str = "") -> set[str]: found = list(_NAME_WORD_RE.finditer(text)) words = [m.group() for m in found] # Ordinary words before the last comma of a leading clause ("Last year in March, after the wedding, Riya - # ...") don't have to be recognised to be skipped - only words after it do, same as without a leading - # clause at all ("She moved to Delhi" still has no subject). A sentence that leads with a pronoun never - # gets this treatment: a comma later on is something else entirely ("She moved to Delhi, India."). + # moved to Mumbai.") don't have to be recognised to be skipped - only words after it do, same as without + # a leading clause at all ("She moved to Delhi" still has no subject). A real leading clause has a clause + # of its own after the last comma too ("Riya moved to Mumbai"), not just a trailing tag ("India." in "The + # office moved to Pune, India." - more than one word of ", Country." pattern never earns this either, + # pronoun-led or not: it isn't a leading clause, so treating it as one made the country the subject and, + # worse, a second "X moved to Y, Country." then read as a correction of the first, unrelated fact). + trailing_words = len(_NAME_WORD_RE.findall(text.rsplit(",", 1)[-1])) if "," in text else 0 permissive_until = ( sum(len(_NAME_WORD_RE.findall(c)) for c in text.split(",")[:-1]) - if "," in text and (not words or words[0].lower() not in _SUBJECT_PRONOUNS) + if "," in text and trailing_words > 1 and (not words or words[0].lower() not in _SUBJECT_PRONOUNS) else 0 ) diff --git a/tests/memory/test_dreaming.py b/tests/memory/test_dreaming.py index 28dc2bac2..54265aedc 100644 --- a/tests/memory/test_dreaming.py +++ b/tests/memory/test_dreaming.py @@ -81,6 +81,21 @@ def test_subject_pronoun_stays_unclear_even_with_a_later_comma(): assert fact_subjects("They moved to Austin, Texas.") == set() +def test_subject_a_trailing_place_country_is_never_the_subject(): + # itsskofficial's live-engine review on #338: a sentence ending "..., Country." isn't a leading clause + # either way, pronoun-led or not - only "India." follows the last comma, not a clause with its own verb. + # On the real engine this silently merged unrelated facts: each of these two pairs shares no subject. + assert fact_subjects("The office moved to Pune, India.") == set() + assert fact_subjects("Last week the dentist moved to Delhi, India.") == set() + assert fact_subjects("Yesterday the neighbours moved to Pune, India.") == set() + assert fact_subjects("Last year the bakery moved to Goa, India.") == set() + assert fact_subjects("Yesterday we went to Goa, India.") == set() + assert fact_subjects("Last week the team moved to Austin, Texas.") == set() + # still works when there's a real clause after the last comma, in or out of a "Place, Country" sentence + assert fact_subjects("Kabir lives in Delhi, India.") == {"kabir"} + assert fact_subjects("Kabir's sister Meera lives in Goa, India.") == {"kabir's sister", "meera"} + + async def test_embedding_attribute_families_matches_the_closest_prototype(): # Distinct, non-orthogonal vectors per family (orthogonal ones would make every margin equally wide, # which isn't how a real embedding model's scores for related-but-wrong families behave).