Repository navigation
feat(memory): catch contradictions in other languages and long sentences #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a8f7bff
a719826
7fa5a54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||
|
Comment on lines
+156
to
+158
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Digits, The docstring says a word starts with a letter. The code lets
Allow only a letter to start a word. Proposed fix--- "a/sentient/memory/facts.py"
+++ "b/sentient/memory/facts.py"
@@ -135,9 +135,8 @@
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)
+ if category == "L" or (current and (category in "MN" or ch in "'-")):
+ current.append(ch) # only a letter starts a word; marks, digits, ' and - continue it
elif current:
words.append("".join(current))
current = []📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||
|
Comment on lines
+237
to
+263
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '155,250p' sentient/memory/facts.py
sed -n '35,60p' tests/memory/test_dreaming.pyRepository: existence-master/Sentient Length of output: 7355 🏁 Script executed: set -eu
printf '%s\n' '--- facts definitions and subject/profile flow ---'
sed -n '1,170p' sentient/memory/facts.py
sed -n '210,390p' sentient/memory/facts.py
printf '%s\n' '--- related-fact callers and contradiction path ---'
rg -n -F --glob '*.py' -e 'def _related_facts' -e '_related_facts(' -e 'contradiction_pairs' -e 'fact_subjects(' sentient tests/memory || test "$?" -eq 1
printf '%s\n' '--- relevant tests and Hindi/leading-clause cases ---'
rg -n -F --glob '*.py' -e 'आज' -e 'माया' -e 'test_related' -e 'contradiction' tests/memory sentient/memory || test "$?" -eq 1Repository: existence-master/Sentient Length of output: 23949 🏁 Script executed: set -eu
printf '%s\n' '--- lead skip and tokenizer ---'
nl -ba sentient/memory/facts.py | sed -n '110,245p'
printf '%s\n' '--- related facts implementation and callers ---'
rg -n -F --glob '*.py' -e 'def _related_facts' -e '_related_facts(' -e 'def contradiction_pairs' -e 'contradiction_pairs(' sentient tests/memory || test "$?" -eq 1
printf '%s\n' '--- bounded memory implementation ---'
nl -ba sentient/memory/facts.py | sed -n '390,560p'
nl -ba sentient/memory/dreaming.py | sed -n '1,180p'Repository: existence-master/Sentient Length of output: 25892 🏁 Script executed: set -eu
printf '%s\n' '--- FactMemory related-fact path ---'
nl -ba sentient/memory/facts.py | sed -n '810,885p'
nl -ba sentient/memory/facts.py | sed -n '920,950p'
printf '%s\n' '--- Dreaming contradiction path ---'
nl -ba sentient/memory/dreaming.py | sed -n '430,500p'Repository: existence-master/Sentient Length of output: 10575 Skip supported Hindi lead words before uncased-script detection. For Requiring letters at token starts does not fix this case because Suggested fix _LEAD_SKIP = STOPWORDS | _MONTHS | _WEEKDAYS | {
+ "आज",
"every", "last", "next", "since", "today", "tonight", "yesterday", "tomorrow", "recently", "currently",🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| 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)) | ||||||||||||||||||||||||||||||
|
Comment on lines
+359
to
+363
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Embedding failure aborts profiling. Fall back to the keyword profile.
If the embedding provider is down, unconfigured, or rate-limited, the exception propagates. In The fallback is optional by design. Catch the exception, log it at debug level, and return the keyword profile. Proposed fix- families = await embedding_attribute_families(text, embed_one, model_key, threshold)
+ try:
+ families = await embedding_attribute_families(text, embed_one, model_key, threshold)
+ except Exception as exc:
+ log.debug("embedding family fallback unavailable: %s", exc)
+ return prof📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| # ---------------------------------------------------------------------- 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) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift
Reuse stored vectors while profiling dream facts.
active_factsalready supplies each fact's vector, but this loop callsembed_queryfor every fact without a keyword family. Distinct facts can therefore require sequential embedding requests before pairing starts, even when the contradiction model-call budget is exhausted. Pass the stored vector into family classification and embed only facts without a usable vector.🤖 Prompt for AI Agents
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Match subjects across scripts when forming contradiction pairs.
For
माया दिल्ली में रहती हैandMaya lives in Pune, successful family profiling still yields subject keysमायाandmaya. The exact-key groups never pair them.conflict_strengthalso rejects profiles whose subject sets do not intersect. Use the cross-script name-matching semantics in both checks so a bilingual contradiction can reach the model.🤖 Prompt for AI Agents