From 61392c232f76a969606059a14615d83cc21fc528 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Wed, 14 Oct 2026 10:41:06 +0530 Subject: [PATCH 1/4] fix(memory): never merge a Hindi memory into a fact about someone else Facts that name someone else are no longer offered to the CUD model, and the near-duplicate shortcut, SKIP, UPDATE, DELETE and covered_by all require the same person. Devanagari facts get a subject (memory/scripts.py) and names match across scripts by a rough romanization. covered_by and the SKIP overrule now see words in non-Latin scripts. bge-m3 is suggested for memories in Hindi and other languages (hardware recommendation, onboarding hint, Ollama download chips), and a vector index is rebuilt when the embedding model changes even at the same dimension. Closes #339 --- CHANGELOG.md | 6 + desktop/src/features/onboarding/BrainStep.tsx | 22 +++ desktop/src/lib/models.ts | 6 +- desktop/src/lib/types.ts | 2 + docs/API.md | 14 +- sentient/config/schema.py | 6 + sentient/llm/hardware.py | 10 +- sentient/memory/episodic.py | 2 +- sentient/memory/facts.py | 82 ++++++-- sentient/memory/scripts.py | 171 +++++++++++++++++ sentient/memory/usermodel.py | 24 ++- sentient/memory/vectors.py | 32 +++- tests/memory/test_cross_script.py | 177 ++++++++++++++++++ tests/test_hardware_context.py | 8 + 14 files changed, 525 insertions(+), 37 deletions(-) create mode 100644 sentient/memory/scripts.py create mode 100644 tests/memory/test_cross_script.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 017b1a203..f9550e1fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -310,6 +310,12 @@ and versions follow [Semantic Versioning](https://semver.org/). - A task that repeats the same failing step three times now pauses as stuck and asks what to do, instead of failing. ### Fixed +- **Memories in Hindi stay with the right person:** a Hindi memory is no longer merged into, or skipped as a copy + of, a memory about someone else ("प्रिया पुणे में रहती है" next to "Riya lives in Pune", a Karan memory next to a + Kabir one). Names written in Devanagari are recognized and matched across scripts, so "माया" is still you when + your name is Maya. For people who write in Hindi or another language besides English, setup now suggests the + bge-m3 memory search model (1.2 GB), which finds those memories much better. Changing the memory search model + re-reads every memory once, also between two models of the same size. - **Sentient can now read a long result to its end:** when a web page or another result is too long to read at once, Sentient reads the rest of the saved copy part by part instead of seeing only its start again. Reading a file you downloaded, or that saved copy, now also counts as reading outside content, so Sentient asks before diff --git a/desktop/src/features/onboarding/BrainStep.tsx b/desktop/src/features/onboarding/BrainStep.tsx index 23978b48d..292d9e814 100644 --- a/desktop/src/features/onboarding/BrainStep.tsx +++ b/desktop/src/features/onboarding/BrainStep.tsx @@ -154,6 +154,7 @@ function LocalBrain() { d.set({ embedding })} className="flex-1" /> + m.name)} onShowDownloads={() => setShowPull(true)} /> {(showPull || !embedModels.length) && ( @@ -167,6 +168,27 @@ function LocalBrain() { ) } +/** "Writing in Hindi?" (#339): the default memory search model is English-first, so point to a multilingual one. */ +function MultilingualHint({ installed, onShowDownloads }: { installed: string[]; onShowDownloads: () => void }) { + const d = useOnboardingDraft() + const hardware = useHardware() + const rec = hardware.data?.recommendation.multilingual_embedding + if (!rec || d.embedding === rec.model) return null + const have = isInstalled(rec.name, installed) + return ( +

+ Writing in Hindi or another language besides English? {rec.name} ({rec.size}) keeps those memories apart and finds them better.{' '} + +

+ ) +} + function isInstalled(name: string, installed: string[]): boolean { return installed.some((n) => n === name || n === `${name}:latest`) } diff --git a/desktop/src/lib/models.ts b/desktop/src/lib/models.ts index 3fe5d3559..a9bf299ac 100644 --- a/desktop/src/lib/models.ts +++ b/desktop/src/lib/models.ts @@ -48,7 +48,8 @@ export const ROLE_META: Record = { embedding: { role: 'embedding', label: 'Embedding', - description: 'Turns memories into vectors so Sentient can find relevant ones. Changing it re-indexes memory.', + description: + 'Turns memories into vectors so Sentient can find relevant ones. Changing it re-reads every memory once. For memories in Hindi or other languages, bge-m3 works better.', required: true, embedding: true }, @@ -220,5 +221,6 @@ export function recommendFastLocal(local: LocalModels | undefined): string | nul export const SUGGESTED_PULLS = [ { name: 'qwen3:8b', size: '5.2 GB', note: 'Best all-rounder with tool use' }, { name: 'qwen3:4b', size: '2.5 GB', note: 'Lighter, good for the fast role' }, - { name: 'nomic-embed-text', size: '274 MB', note: 'Embeddings for memory' } + { name: 'nomic-embed-text', size: '274 MB', note: 'Embeddings for memory' }, + { name: 'bge-m3', size: '1.2 GB', note: 'Memory search in Hindi and other languages' } ] diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index d7206f59e..b4a81b10a 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -761,6 +761,8 @@ export interface HardwareRecommendation { /** "qwen3:8b, reading 8,192 tokens at a time" */ summary: string note: string + /** Memory search model for Hindi and other languages besides English (#339); null when a cloud model comes first. */ + multilingual_embedding?: { model: string; name: string; size: string; note: string } | null } export interface Hardware { diff --git a/docs/API.md b/docs/API.md index bc578c7e0..9308ec0a3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -424,7 +424,9 @@ accept or loosen a rule. "recommendation": {"tier": "gpu_8", "model": "ollama_chat/qwen3:8b", "name": "qwen3:8b", "context_length": 8192, "runs_on": "graphics|processor|unknown", "cloud_first": false, "summary": "qwen3:8b, reading 8,192 tokens at a time", - "note": "Fits on the graphics card, so replies stay quick."}} + "note": "Fits on the graphics card, so replies stay quick.", + "multilingual_embedding": {"model": "ollama/bge-m3", "name": "bge-m3", "size": "1.2 GB", + "note": "If you write to Sentient in Hindi or another language..."}}} ``` Memory comes from the operating system (psutil when installed, otherwise Windows, macOS or Linux APIs). NVIDIA cards from `nvidia-smi --query-gpu=name,memory.total`, other cards from the Windows registry or `/sys/class/drm` (AMD on @@ -436,7 +438,11 @@ accept or loosen a rule. that `cloud_first: true`: a cloud model is the recommendation and `model` is qwen3:4b only as a labelled chat-only fallback, never picked for the user, never used by "Local only" or the check-up's fixes); `tier: "unknown"` with today's defaults when nothing could be read. Onboarding shows it as "Recommended for this computer" and saves its context length with a - local brain; the "Local only" preset and the check-up use it too. + local brain; the "Local only" preset and the check-up use it too. `multilingual_embedding` (#339) is the memory search + model for people who write in Hindi or another language besides English (`MULTILINGUAL_EMBEDDING` in + `config/schema.py`; null when `cloud_first`): onboarding offers it under "Memory search". Switching the `embedding` + role re-embeds memory on its next use, also between two models with the same vector size (the model is recorded with + each vector index). - `GET /api/models/local` → `{ollama: {reachable, models: [{name, size, family, parameter_size, is_embedding, capabilities: string[]}]}, lm_studio: {reachable, models: [...]}}` (`capabilities` from Ollama, e.g. completion/tools/thinking/vision/embedding — a hint; `POST /api/models/test` is the authoritative tool-support check) - `POST /api/models/test` `{model, role?}` → `{ok, latency_ms, reply?, error?, supports_tools?}` - `POST /api/models/test-embedding` `{model}` → `{ok, dim?, error?}` @@ -1343,7 +1349,9 @@ Relationships & Social Life, Financial, Goals & Challenges, Miscellaneous. - `GET /api/memories?topic=&q=&source=&limit=&offset=` → `[Memory]` newest first, expired short-term facts and memories waiting for review excluded (`q` = hybrid search when embeddings are available: vector neighbours plus FTS5 keyword matches, ordered by `score` = `similarity` + `memory.keyword_weight` × share of query words present, each result carrying `similarity` and `score`; falls back to a keyword match) - `GET /api/memories/topics` → `[{name, description, count}]` - `GET /api/memories/graph` → `{nodes: [{id, label, title, content, topics, memory_type, source, created_at}], links: [{source, target, value}]}` (`label` = content truncated to 25 chars, `title` = full content, as in v2; a link means cosine similarity ≥ `memory.graph_link_similarity`, `value` is that similarity) -- `POST /api/memories` `{content, source?}` → `{action: "ADD"|"UPDATE"|"DELETE"|"SKIP", id, content, status?: "pending"}` (runs the CUD decision, so a duplicate returns `SKIP` with the existing id; `source` defaults to `manual`. The decision also sees up to 3 facts about the same person and attribute found by keyword (where they live, job, relationship, diet, health, ownership, routine), and a new current residence ("moved to Bengaluru") always UPDATEs the old one ("lives in Pune") rather than adding a second home; past-tense facts are left alone. An UPDATE only replaces a fact about the same person and the same action on the same thing ("doesn't want files written" or "doesn't want emails deleted" never replaces "doesn't want files deleted"; it is added instead), and a new fact that an existing one already says in full, with no new name, place or number ("sister lives in a city" next to "sister Meera lives in Lisbon"), returns `SKIP` with that fact) +- `POST /api/memories` `{content, source?}` → `{action: "ADD"|"UPDATE"|"DELETE"|"SKIP", id, content, status?: "pending"}` (runs the CUD decision, so a duplicate returns `SKIP` with the existing id; `source` defaults to `manual`. The decision also sees up to 3 facts about the same person and attribute found by keyword (where they live, job, relationship, diet, health, ownership, routine), and a new current residence ("moved to Bengaluru") always UPDATEs the old one ("lives in Pune") rather than adding a second home; past-tense facts are left alone. Facts that name someone else are never offered to the model, so it can't update, skip or delete them, and the +near-duplicate shortcut needs the same person too; names in Devanagari (Hindi) count, and match across scripts +("माया" is "Maya", "प्रिया" is not "Riya") (#339). An UPDATE only replaces a fact about the same person and the same action on the same thing ("doesn't want files written" or "doesn't want emails deleted" never replaces "doesn't want files deleted"; it is added instead), and a new fact that an existing one already says in full, with no new name, place or number ("sister lives in a city" next to "sister Meera lives in Lisbon"), returns `SKIP` with that fact) - `PUT /api/memories/{id}` `{content}` → `Memory` (id kept; topics, long/short-term and expiry re-analyzed; embedding refreshed; 404 if missing) - `DELETE /api/memories/{id}` → `{deleted: true}` (404 if missing) - `DELETE /api/memories/source/{source}` → `{deleted: n}` diff --git a/sentient/config/schema.py b/sentient/config/schema.py index 383f3b0ff..f140fabb3 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -96,6 +96,12 @@ class ModelRoles(BaseModel): ) +# The embedding model recommended for memories in Hindi and other languages besides English (#339). nomic-embed-text +# (the default) is English-first: it scores Hindi facts about different people as near-duplicates. bge-m3 covers 100+ +# languages and needs no query prefix. Measured on an RTX 4060 laptop with Ollama 0.9 (2026-10-14): MEASURED_NOTE +MULTILINGUAL_EMBEDDING: dict[str, str] = {"model": "ollama/bge-m3", "name": "bge-m3", "size": "1.2 GB"} + + class ModelPreset(BaseModel): """A saved model setup the user can switch to in one step. Fields left out keep their current value.""" diff --git a/sentient/llm/hardware.py b/sentient/llm/hardware.py index 5518ebf71..e1707f251 100644 --- a/sentient/llm/hardware.py +++ b/sentient/llm/hardware.py @@ -22,7 +22,7 @@ import httpx -from sentient.config.schema import LOCAL_MODEL_TIERS, ModelRoles, ModelsConfig +from sentient.config.schema import LOCAL_MODEL_TIERS, MULTILINGUAL_EMBEDDING, ModelRoles, ModelsConfig log = logging.getLogger(__name__) @@ -259,6 +259,12 @@ def summary(hw: dict[str, Any]) -> str: } +MULTILINGUAL_NOTE = ( + "If you write to Sentient in Hindi or another language besides English, this memory search model keeps those " + "memories apart and finds them better. Switching re-reads every memory once." +) + + def recommend(hw: dict[str, Any] | None) -> dict[str, Any]: """The local model and context length for this computer: the first ``LOCAL_MODEL_TIERS`` row it meets.""" hw = hw or {} @@ -289,6 +295,8 @@ def recommend(hw: dict[str, Any] | None) -> dict[str, Any]: "cloud_first": cloud_first, "summary": f"a cloud model ({summary} for chat only)" if cloud_first else summary, "note": NOTES[note_key], + # memory search in Hindi and other languages (#339); it fits wherever the local model does + "multilingual_embedding": None if cloud_first else {**MULTILINGUAL_EMBEDDING, "note": MULTILINGUAL_NOTE}, } diff --git a/sentient/memory/episodic.py b/sentient/memory/episodic.py index 3f2906a8b..c193b611a 100644 --- a/sentient/memory/episodic.py +++ b/sentient/memory/episodic.py @@ -56,7 +56,7 @@ def __init__(self, store: Store, llm: LLMProvider, config: SentientConfig): async def _embed(self, texts: list[str]) -> list[list[float]]: vecs = await self.llm.embed(texts) if vecs: - await self.vec.ensure(len(vecs[0]), self.reindex) + await self.vec.ensure(len(vecs[0]), self.reindex, self.llm.model_for("embedding")) return vecs async def reindex(self) -> int: diff --git a/sentient/memory/facts.py b/sentient/memory/facts.py index ed3853d6a..a5bda355d 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -37,6 +37,12 @@ from sentient.memory import review as reviews from sentient.memory.episodic import EpisodicMemory from sentient.memory.schema import ensure_memory_schema +from sentient.memory.scripts import ( + devanagari_subjects, + leads_with_devanagari, + names_match, + script_words, +) from sentient.memory.topics import DEFAULT_TOPIC, TOPICS, normalize_topics from sentient.memory.vectors import VecTable, cosine, pack from sentient.store.db import Store, now_iso @@ -287,19 +293,28 @@ def fact_actions(text: str) -> set[str]: return out +def subject_aliases(text: str, user_name: str = "") -> set[str]: + """:func:`fact_subjects`, or for a fact written in Devanagari (Hindi...) its subject from + :func:`scripts.devanagari_subjects`, which finds names without capital letters (#339).""" + if leads_with_devanagari(text): + return devanagari_subjects(text, user_name) + return fact_subjects(text, user_name) + + def _subject_keys(subjects: set[str], users: set[str]) -> set[tuple[str, str]]: """(owner, relation) per subject, with every name for the user as "user": "maya's sister" -> ("user", "sister").""" out = set() for s in subjects: owner, _, rel = s.partition("'s ") - out.add(("user" if owner in users else owner, rel)) + out.add(("user" if any(names_match(owner, u) for u in users) else owner, rel)) return out def same_subject(a: str, b: str, user_name: str = "") -> bool: - """False only when both facts clearly name different people or things ("Maya's sister" vs "Maya's brother"). - Unclear subjects count as the same, and so does "the user" next to a name while the user's name isn't set.""" - sa, sb = fact_subjects(a, user_name), fact_subjects(b, user_name) + """False only when both facts clearly name different people or things ("Maya's sister" vs "Maya's brother", + "प्रिया" vs "Riya"). Names match across scripts ("माया" is "Maya"). Unclear subjects count as the same, and so + does "the user" next to a name while the user's name isn't set.""" + sa, sb = subject_aliases(a, user_name), subject_aliases(b, user_name) if not sa or not sb or sa & sb: return True users = {"user", "the user", (user_name or "").strip().lower()} - {""} @@ -307,7 +322,7 @@ def same_subject(a: str, b: str, user_name: str = "") -> bool: ka, kb = _subject_keys(sa, users), _subject_keys(sb, users) for oa, ra in ka: for ob, rb in kb: - if ra == rb and (oa == ob or (unnamed and "user" in (oa, ob))): + if ra == rb and (names_match(oa, ob) or (unnamed and "user" in (oa, ob))): return True return False @@ -372,7 +387,8 @@ def covered_by(new_fact: str, existing: str, user_name: str = "") -> bool: return False if bool(PAST_RE.search(new_fact)) != bool(PAST_RE.search(existing)): return False - if new_details(new_fact, existing): + if new_details(new_fact, existing) or script_words(new_fact) - script_words(existing): + # a new name, place or number, or words in another script (Hindi) that the English word checks below miss return False if not same_subject(new_fact, existing, user_name): return _only_names_relation(new_fact, existing, user_name) @@ -520,7 +536,7 @@ def publish(self, action: str, fid: int | None, content: str | None = None, **ex async def _embed(self, texts: list[str]) -> list[list[float]]: vecs = await self.llm.embed(texts) if vecs: - await self.vec.ensure(len(vecs[0]), self.reindex) + await self.vec.ensure(len(vecs[0]), self.reindex, self.llm.model_for("embedding")) return vecs async def reindex(self) -> int: @@ -762,6 +778,17 @@ async def _related_facts(self, fact: str, exclude: set[int]) -> list[dict]: out.sort(key=lambda m: m["updated_at"] or "", reverse=True) return out[:3] + def _duplicate(self, fact: str, neighbour: dict) -> bool: + """A near-identical fact, skipped without asking the model: above ``duplicate_similarity``, about the same + person or thing, and with no word in Hindi (or another script without capitals) that the other lacks. + Embedding models score short texts in one script very close together (#339).""" + other = neighbour["content"] + return ( + neighbour["similarity"] >= self.config.memory.duplicate_similarity + and same_subject(fact, other, self.config.assistant.user_name) + and not script_words(fact) - script_words(other) + ) + def _residence_peer(self, fact: str, candidates: list[dict]) -> dict | None: """The current residence fact a new residence fact replaces ("moved to X" vs "lives in Y"), if any. Past-tense facts ("lived in Nagpur as a child") are history and never replaced.""" @@ -812,7 +839,7 @@ async def _hold(self, fact: str, *, source: str, use_llm: bool, review: dict) -> if exact: return {"action": "SKIP", "id": int(exact["id"]), "content": fact} neighbours = await self.recall(fact, top_k=1, min_similarity=0.0, track=False) - if neighbours and neighbours[0]["similarity"] >= self.config.memory.duplicate_similarity: + if neighbours and self._duplicate(fact, neighbours[0]): return {"action": "SKIP", "id": neighbours[0]["id"], "content": neighbours[0]["content"]} topics, memory_type, duration = await self.analyze(fact) if use_llm else ([DEFAULT_TOPIC], "long-term", None) fid = await self._insert( @@ -831,25 +858,36 @@ async def _remember(self, fact: str, *, source: str, use_llm: bool) -> dict[str, return {"action": "SKIP", "id": int(exact["id"]), "content": fact} [vec] = await self._embed([fact]) neighbours = await self.recall(fact, top_k=5, min_similarity=0.0, track=False) - if neighbours and neighbours[0]["similarity"] >= self.config.memory.duplicate_similarity: - return {"action": "SKIP", "id": neighbours[0]["id"], "content": neighbours[0]["content"]} + dup = next((n for n in neighbours if self._duplicate(fact, n)), None) + if dup is not None: + return {"action": "SKIP", "id": dup["id"], "content": dup["content"]} action, fid, content = "ADD", None, fact topics: list[str] | None = None memory_type, duration = "long-term", None + user = self.config.assistant.user_name close = [n for n in neighbours if n["similarity"] >= 0.5] related: list[dict] = [] + # The model may only update, skip or delete a fact about the same person or thing (#339): embedding models + # score "प्रिया पुणे में रहती है" close to "Riya lives in Pune", and a small model then calls it an update. + # Facts about someone else are left out of what it sees, and are still checked by ``covered_by`` below. + others = {n["id"] for n in close if not same_subject(fact, n["content"], user)} if use_llm: - user = self.config.assistant.user_name try: related = await self._related_facts(fact, {n["id"] for n in close}) except Exception as exc: log.debug("related-fact lookup skipped: %s", exc) + others |= {r["id"] for r in related if not same_subject(fact, r["content"], user)} + if others: + log.info("CUD for %r: facts %s are about someone else, not offered", fact, sorted(others)) similar = [ {"id": n["id"], "content": n["content"], "similarity": round(n["similarity"], 2)} | ({"same_subject_attribute": True} if conflict_strength(fact, n["content"], user) == 2 else {}) - for n in close - ] + [{"id": r["id"], "content": r["content"], "same_subject_attribute": True} for r in related] + for n in close if n["id"] not in others + ] + [ + {"id": r["id"], "content": r["content"], "same_subject_attribute": True} + for r in related if r["id"] not in others + ] try: raw = await self.llm.complete_json( "fast", @@ -875,18 +913,19 @@ async def _remember(self, fact: str, *, source: str, use_llm: bool) -> dict[str, log.warning("CUD decision failed, defaulting to ADD: %s", exc) candidates = [*close, *related] - valid_ids = {n["id"] for n in candidates} + valid_ids = {n["id"] for n in candidates} - others if action in {"UPDATE", "DELETE", "SKIP"} and fid not in valid_ids: action, content = "ADD", fact # the model pointed at something we did not show it if action == "SKIP": matched = next((n["content"] for n in candidates if n["id"] == fid), "") - if new_details(fact, matched): - # the "duplicate" is missing a name, place or number the new fact carries: keep it - log.info("CUD SKIP overruled for %r (new details: %s)", fact, sorted(new_details(fact, matched))) + extra = new_details(fact, matched) | (script_words(fact) - script_words(matched)) + if extra: + # the "duplicate" is missing a name, place or number the new fact carries (in Hindi and other + # scripts without capitals, any word of it): keep it + log.info("CUD SKIP overruled for %r (new details: %s)", fact, sorted(extra)) action, content, topics = "ADD", fact, None else: return {"action": "SKIP", "id": fid, "content": content} - user = self.config.assistant.user_name if action == "UPDATE": matched = next((n["content"] for n in candidates if n["id"] == fid), "") dropped = new_details(matched, content or "") if matched else set() @@ -895,9 +934,12 @@ async def _remember(self, fact: str, *, source: str, use_llm: bool) -> dict[str, # model's rewrite that would be stored: both facts hold log.info("CUD UPDATE of %s kept as ADD for %r (about something else)", fid, fact) action, content, topics = "ADD", fact, None - elif dropped and not new_details(fact, matched): + elif dropped and not ( + new_details(fact, matched) or (script_words(fact) and new_details(content, matched)) + ): # the rewrite would lose a name, place or number that the new fact does not replace - # ("moving to Berlin" rewritten as "pursuing her masters"): keep both facts instead + # ("moving to Berlin" rewritten as "pursuing her masters"): keep both facts instead. A Hindi fact's + # places can't be read in Latin letters, so there the rewrite has to bring one in their place log.info("CUD UPDATE of %s kept as ADD for %r (would drop %s)", fid, fact, sorted(dropped)) action, content, topics = "ADD", fact, None if action == "ADD": diff --git a/sentient/memory/scripts.py b/sentient/memory/scripts.py new file mode 100644 index 000000000..6028483dc --- /dev/null +++ b/sentient/memory/scripts.py @@ -0,0 +1,171 @@ +"""Facts written in Devanagari (Hindi, Marathi...): who they are about, and whether two names are the same person +across scripts (#339). + +:func:`facts.fact_subjects` reads Latin text: it finds a name by its capital letter. Devanagari has no capitals, so a +fact like "प्रिया पुणे में रहती है" ("Priya lives in Pune") had no subject at all, and an unclear subject counts as the +same as any other. That let a small model file it as an update of "Riya lives in Pune". This module gives such facts +a subject (the first word that isn't a time word or pronoun, as in an SOV sentence) and compares names across scripts +by a rough romanization, so "माया" matches "Maya" and "प्रिया" doesn't match "Riya". + +It is deliberately small and conservative: when it can't tell, two facts count as being about different people, +which keeps both facts instead of merging them. +""" + +from __future__ import annotations + +import re +import unicodedata + +_DEVANAGARI = re.compile(f"[{chr(0x900)}-{chr(0x97F)}]") +# letters and vowel signs of Devanagari (not the danda "।" or Devanagari digits), or Latin letters +_WORD = re.compile(f"[{chr(0x900)}-{chr(0x963)}{chr(0x971)}-{chr(0x97F)}]+|[A-Za-z][A-Za-z'{chr(0x2019)}]*") + +# time words, particles and helper verbs that start or fill a Hindi sentence but are never its subject +HINDI_SKIP = frozenset({ + "पिछले", "पिछला", "पिछली", "अगले", "अगला", "अगली", "इस", "उस", "हर", "साल", "महीने", "महीना", "हफ्ते", "हफ़्ते", + "सप्ताह", "दिन", "आज", "कल", "परसों", "अब", "अभी", "सुबह", "शाम", "रात", "कभी", "हमेशा", "फिर", "तब", "जब", + "पहले", "बाद", "में", "से", "को", "का", "की", "के", "ने", "और", "लेकिन", "पर", "भी", "तो", "ही", "आजकल", "हाल", + "शायद", "बस", "एक", "है", "हैं", "था", "थी", "थे", "हूँ", "हूं", "हो" +}) +# "he", "she", "this": the subject is someone named in another sentence, so it is unclear here +HINDI_PRONOUNS = frozenset({ + "वह", "वो", "यह", "ये", "वे", "उसने", "उन्होंने", "उसका", "उसकी", "उसके", "इसका", "इसकी", "इसके", "उनका", + "उनकी", "उनके" +}) +# "I", "my": the fact is about the user +HINDI_FIRST_PERSON = frozenset({"मैं", "मैंने", "मुझे", "मुझको", "हम", "हमने", "हमें"}) +HINDI_MY = frozenset({"मेरा", "मेरी", "मेरे", "हमारा", "हमारी", "हमारे"}) +HINDI_POSSESSIVE = frozenset({"का", "की", "के"}) +# relation words, in English so "माया की बहन" and "Maya's sister" have the same relation +HINDI_RELATIONS = { + "बहन": "sister", "दीदी": "sister", "भाई": "brother", "भैया": "brother", "माँ": "mother", "मां": "mother", + "मम्मी": "mother", "माता": "mother", "पिता": "father", "पापा": "father", "पिताजी": "father", "पत्नी": "wife", + "पति": "husband", "बेटा": "son", "बेटी": "daughter", "दोस्त": "friend", "मित्र": "friend", "सहेली": "friend", + "मैनेजर": "manager", "बॉस": "boss", "चाचा": "uncle", "मामा": "uncle", "चाची": "aunt", "मौसी": "aunt", + "बुआ": "aunt", "दादी": "grandmother", "नानी": "grandmother", "दादा": "grandfather", "नाना": "grandfather", + "कंपनी": "company", "कुत्ता": "dog", "बिल्ली": "cat", +} + +# ------------------------------------------------------------------------------------------------ romanization +_CONSONANTS = { + "क": "k", "ख": "kh", "ग": "g", "घ": "gh", "ङ": "n", "च": "ch", "छ": "chh", "ज": "j", "झ": "jh", "ञ": "n", + "ट": "t", "ठ": "th", "ड": "d", "ढ": "dh", "ण": "n", "त": "t", "थ": "th", "द": "d", "ध": "dh", "न": "n", + "प": "p", "फ": "ph", "ब": "b", "भ": "bh", "म": "m", "य": "y", "र": "r", "ल": "l", "ळ": "l", "व": "v", + "श": "sh", "ष": "sh", "स": "s", "ह": "h", +} +_NUKTA_FORMS = {"ज": "z", "फ": "f", "ड": "r", "ढ": "rh", "क": "k", "ख": "kh", "ग": "g"} +_VOWELS = { + "अ": "a", "आ": "aa", "इ": "i", "ई": "ii", "उ": "u", "ऊ": "uu", "ऋ": "ri", "ए": "e", "ऐ": "ai", "ओ": "o", + "औ": "au", "ऑ": "o", "ॲ": "a", +} +_SIGNS = { + "ा": "aa", "ि": "i", "ी": "ii", "ु": "u", "ू": "uu", "ृ": "ri", "े": "e", "ै": "ai", "ो": "o", "ौ": "au", + "ॉ": "o", "ॅ": "e", +} +_VIRAMA, _NUKTA = "्", "़" +_NASAL = {"ं": "n", "ँ": "n", chr(0x903): "h"} # visarga + + +def has_devanagari(text: str) -> bool: + return bool(_DEVANAGARI.search(text or "")) + + +def leads_with_devanagari(text: str) -> bool: + """Whether the first letter of ``text`` is Devanagari (the fact is written in Hindi, Marathi...).""" + for ch in text or "": + if unicodedata.category(ch)[0] in "LM": + return bool(_DEVANAGARI.match(ch)) + return False + + +def romanize(text: str) -> str: + """A rough Latin spelling of Devanagari text ("प्रिया" -> "priyaa"); other characters are kept.""" + text = unicodedata.normalize("NFD", text or "") # क़ -> क + nukta + out: list[str] = [] + pending = False # a consonant waits for its vowel (the inherent "a" unless a sign or virama follows) + for i, ch in enumerate(text): + nxt = text[i + 1] if i + 1 < len(text) else "" + if ch == _NUKTA: + continue + if ch in _CONSONANTS: + if pending: + out.append("a") + out.append(_NUKTA_FORMS.get(ch, _CONSONANTS[ch]) if nxt == _NUKTA else _CONSONANTS[ch]) + pending = True + continue + if ch in _SIGNS: + out.append(_SIGNS[ch]) + elif ch == _VIRAMA: + pass + else: + if pending: + out.append("a") + out.append(_VOWELS.get(ch) or _NASAL.get(ch) or ch) + pending = False + if pending: + out.append("a") + return "".join(out) + + +def name_key(name: str) -> str: + """A spelling-tolerant key for comparing one name across scripts: "Maya" and "माया" both give "my", "Priya" + and "प्रिया" give "pry", "Riya" gives "ry". Aspiration, long vowels, doubled letters and every "a" after the first + letter are dropped, since romanized Hindi spells them in many ways.""" + s = re.sub(r"[^a-z]", "", romanize(name).lower()) + s = s.replace("ph", "f").replace("ee", "i").replace("oo", "u") + s = re.sub(r"([^aeiouh])h", r"\1", s) # kh, sh, th, bh...: the h is optional in romanized Hindi + s = s.translate(str.maketrans({"w": "v", "z": "j", "q": "k", "c": "k", "x": "k"})) + s = re.sub(r"(.)\1+", r"\1", s) + return s[:1] + s[1:].replace("a", "") + + +def names_match(a: str, b: str) -> bool: + """The same name: exactly within one script, by :func:`name_key` across scripts ("माया" and "maya").""" + if a == b: + return True + if has_devanagari(a) == has_devanagari(b): + return unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b) + ka, kb = name_key(a), name_key(b) + return bool(ka) and ka == kb + + +# ------------------------------------------------------------------------------------------------ subjects +def devanagari_subjects(text: str, user_name: str = "") -> set[str]: + """Who a Devanagari fact is about, in the shape of :func:`facts.fact_subjects`: {"प्रिया"} for "पिछले साल प्रिया + पुणे गई", {"maya's sister"} for "मेरी बहन ..." when the user is Maya, {"माया's sister"} for "माया की बहन ...". + The first word that isn't a time word or pronoun, within the first four. Empty when unclear.""" + user = (user_name or "").strip().lower() or "user" + words = _WORD.findall(unicodedata.normalize("NFC", text or "")) + for i, w in enumerate(words[:4]): + if w in HINDI_FIRST_PERSON: + return {user} + nxt = words[i + 1] if i + 1 < len(words) else "" + if w in HINDI_MY: + return {f"{user}'s {HINDI_RELATIONS[nxt]}"} if nxt in HINDI_RELATIONS else {user} + if w in HINDI_PRONOUNS: + return set() + if w in HINDI_SKIP: + continue + name = w if has_devanagari(w) else w.lower().replace(chr(0x2019), "'").removesuffix("'s") + rel = words[i + 2] if i + 2 < len(words) else "" + if nxt in HINDI_POSSESSIVE and rel in HINDI_RELATIONS: + return {f"{name}'s {HINDI_RELATIONS[rel]}"} + return {name} + return set() + + +def script_words(text: str) -> set[str]: + """Words of ``text`` in a script other than Latin (Devanagari, Cyrillic...), which the English word helpers miss. + Small particles and time words of Hindi are left out.""" + words: set[str] = set() + current: list[str] = [] + for ch in unicodedata.normalize("NFC", text or "") + " ": + if unicodedata.category(ch)[0] in "LM" and (current or unicodedata.category(ch)[0] == "L"): + current.append(ch) + continue + if current: + word = "".join(current).lower() + if not word.isascii(): + words.add(word) + current = [] + return words - HINDI_SKIP - HINDI_PRONOUNS diff --git a/sentient/memory/usermodel.py b/sentient/memory/usermodel.py index 9edc3202c..cd27fe335 100644 --- a/sentient/memory/usermodel.py +++ b/sentient/memory/usermodel.py @@ -316,7 +316,7 @@ async def _embed_insight(self, rid: int, statement: str) -> None: vecs = await self.app.llm.embed([statement]) if not vecs: return - await self.vec.ensure(len(vecs[0]), self._reindex) + await self.vec.ensure(len(vecs[0]), self._reindex, self.app.llm.model_for("embedding")) await self.vec.upsert(rid, vecs[0]) except Exception as exc: log.debug("insight embedding skipped: %s", exc) @@ -888,11 +888,33 @@ async def _insight_vectors(self) -> dict[int, list[float]]: if self._vectors is None: vectors: dict[int, list[float]] = {} if self.app.store.vec_available and (self.vec.ready or await self.vec.exists()): + stored = await self.app.store.get_meta(f"{VEC_TABLE}_model") + if stored and stored != self.app.llm.model_for("embedding"): + # made by another embedding model, so they don't compare with today's query: re-embed them + self._start(self._reembed()) + return {} with contextlib.suppress(Exception): vectors = await self.vec.all_vectors() self._vectors = vectors return self._vectors + def _start(self, coro) -> None: + if any(t.get_name() == "user_model:reembed" for t in self._background): + coro.close() + return + task = asyncio.create_task(coro, name="user_model:reembed") + self._background.add(task) + task.add_done_callback(self._background.discard) + + async def _reembed(self) -> None: + """Rebuild the insight index with the current embedding model (``VecTable.ensure`` sees the new model).""" + try: + [vec] = await self.app.llm.embed(["user model"]) + await self.vec.ensure(len(vec), self._reindex, self.app.llm.model_for("embedding")) + except Exception as exc: + log.debug("re-embedding insights skipped: %s", exc) + self._vectors = None + async def _ranked(self, text: str, limit: int = 100) -> tuple[list[dict], list[dict]]: """(confirmed insights, active insights ordered by relevance to ``text`` then confidence).""" rows = await self._insights(("confirmed", "active"), limit=limit) diff --git a/sentient/memory/vectors.py b/sentient/memory/vectors.py index fbfb706aa..82c84258c 100644 --- a/sentient/memory/vectors.py +++ b/sentient/memory/vectors.py @@ -2,8 +2,11 @@ vec0 tables default to L2 distance. Every table here is created with ``distance_metric=cosine`` so ``similarity = 1 - distance``. Tables created by -older builds (L2, no metric recorded in ``meta``) or with a different embedding -dimension are dropped and rebuilt by re-embedding the source rows. +older builds (L2, no metric recorded in ``meta``), with a different embedding +dimension or by another embedding model are dropped and rebuilt by re-embedding +the source rows. Two models can share a dimension (nomic-embed-text and +paraphrase-multilingual are both 768, mxbai-embed-large and bge-m3 both 1024), +and their vectors don't compare, so the model is recorded too (#339). """ from __future__ import annotations @@ -47,6 +50,7 @@ def __init__(self, store: Store, name: str): self.store = store self.name = name self.dim: int | None = None + self.model: str | None = None self._lock = asyncio.Lock() @property @@ -57,23 +61,30 @@ async def exists(self) -> bool: row = await self.store.fetchone("SELECT name FROM sqlite_master WHERE name = ?", (self.name,)) return row is not None - async def ensure(self, dim: int, rebuild: Callable[[], Awaitable[None]] | None = None) -> bool: - """Create the table for ``dim``. Returns True when an existing index was rebuilt.""" - if self.dim == dim: + async def ensure( + self, dim: int, rebuild: Callable[[], Awaitable[None]] | None = None, model: str | None = None + ) -> bool: + """Create the table for ``dim`` vectors from embedding ``model``. Returns True when an existing index was + rebuilt: another dimension or metric, or another model when both the stored and the new model are known.""" + if self.dim == dim and (model is None or self.model == model): return False async with self._lock: - if self.dim == dim: + if self.dim == dim and (model is None or self.model == model): return False if not self.store.vec_available: raise RuntimeError("sqlite-vec extension is not available") stored_dim = await self.store.get_meta(f"{self.name}_dim") stored_metric = await self.store.get_meta(f"{self.name}_metric") + stored_model = await self.store.get_meta(f"{self.name}_model") had_table = await self.exists() - stale = had_table and (stored_metric != METRIC or (stored_dim is not None and int(stored_dim) != dim)) + other_model = bool(model and stored_model and stored_model != model) + stale = had_table and ( + stored_metric != METRIC or (stored_dim is not None and int(stored_dim) != dim) or other_model + ) if stale: log.warning( - "rebuilding vector index %s (dim %s->%s, metric %s->%s)", - self.name, stored_dim, dim, stored_metric, METRIC, + "rebuilding vector index %s (dim %s->%s, metric %s->%s, model %s->%s)", + self.name, stored_dim, dim, stored_metric, METRIC, stored_model, model, ) await self.store.execute(f"DROP TABLE IF EXISTS {self.name}") await self.store.execute( @@ -81,7 +92,10 @@ async def ensure(self, dim: int, rebuild: Callable[[], Awaitable[None]] | None = ) await self.store.set_meta(f"{self.name}_dim", str(dim)) await self.store.set_meta(f"{self.name}_metric", METRIC) + if model: + await self.store.set_meta(f"{self.name}_model", model) self.dim = dim + self.model = model or self.model if stale and rebuild is not None: try: await rebuild() diff --git a/tests/memory/test_cross_script.py b/tests/memory/test_cross_script.py new file mode 100644 index 000000000..e03cc4e45 --- /dev/null +++ b/tests/memory/test_cross_script.py @@ -0,0 +1,177 @@ +"""Hindi facts are never merged into a fact about someone else (#339). + +Seen with qwen3:8b and nomic-embed-text: "प्रिया पुणे में रहती है" was filed as an update to "Riya lives in Pune", and a +Hindi Karan fact was skipped as a duplicate of a Kabir fact. The embedding model scores such texts close together and +the small model then picks UPDATE or SKIP. Different subjects are now caught before the model decides.""" + +from sentient.memory.facts import FactMemory, covered_by, same_subject, same_thing, subject_aliases +from sentient.memory.scripts import devanagari_subjects, name_key, names_match, script_words +from sentient.memory.vectors import VecTable +from sentient.store.db import Store +from tests.conftest import FakeProvider + +ANALYSIS = {"topics": ["Relationships & Social Life"], "memory_type": "long-term", "duration": None} + + +class SameVectorProvider(FakeProvider): + """An embedding model that can't tell texts apart: every fact scores 1.0 against every other.""" + + async def embed(self, texts, *, model=None): + return [[1.0] + [0.0] * (self.dim - 1) for _ in texts] + + +def test_names_match_across_scripts(): + assert names_match("माया", "maya") and names_match("प्रिया", "priya") and names_match("कबीर", "kabir") + assert names_match("करण", "karan") and names_match("श्रेया", "shreya") and names_match("पूजा", "pooja") + assert not names_match("प्रिया", "riya") + assert not names_match("करण", "kabir") + assert not names_match("करण", "kiran") + assert not names_match("riya", "riyaz") + assert name_key("Maya") == name_key("माया") + + +def test_devanagari_subjects(): + assert devanagari_subjects("प्रिया पुणे में रहती है।") == {"प्रिया"} + assert devanagari_subjects("पिछले साल कबीर दिल्ली चला गया।") == {"कबीर"} # past "last year" + assert devanagari_subjects("मैं पुणे में रहती हूँ", "Maya") == {"maya"} + assert devanagari_subjects("मेरी बहन पुणे में रहती है", "Maya") == {"maya's sister"} + assert devanagari_subjects("माया की बहन रिया पुणे में रहती है") == {"माया's sister"} + assert devanagari_subjects("वह पुणे में रहती है") == set() # "she": unclear + # a fact that starts in Latin script keeps the English reading + assert subject_aliases("Priya पुणे में रहती है", "Maya") == {"priya"} + + +def test_same_subject_across_scripts(): + assert not same_subject("प्रिया पुणे में रहती है।", "Riya lives in Pune.", "Maya") + assert not same_subject("करण गोवा गया।", "Kabir moved to Delhi.", "Maya") + assert not same_subject("करण गोवा गया।", "पिछले साल कबीर दिल्ली चला गया।", "Maya") + assert same_subject("माया अब दिल्ली में रहती है।", "Maya lives in Pune.", "Maya") + assert same_subject("कबीर दिल्ली चला गया।", "Kabir moved to Delhi.", "Maya") + assert same_subject("मैं शाकाहारी हूँ", "Maya is vegetarian.", "Maya") # "I" is the user + assert not same_subject("माया की बहन पुणे में रहती है", "Maya lives in Pune.", "Maya") + assert not same_thing("प्रिया अब दिल्ली में रहती है।", "Riya lives in Pune.", "Maya") + + +def test_hindi_words_are_not_covered_by_an_english_fact(): + assert script_words("करण गोवा गया।") == {"करण", "गोवा", "गया"} + # nothing in the English word checks reads Hindi, so this used to count as "already said" + assert not covered_by("करण गोवा गया।", "Kabir went to Goa.", "Maya") + assert not covered_by("प्रिया पुणे में रहती है।", "Riya lives in Pune.", "Maya") + assert covered_by("प्रिया पुणे में रहती है", "प्रिया पुणे में रहती है।", "Maya") + + +async def _memory(config, isolated_home, name, llm=None): + store = await Store(isolated_home / name).open() + llm = llm or SameVectorProvider() + return store, llm, FactMemory(store, llm, config) + + +async def test_hindi_fact_about_someone_else_is_added_not_skipped(config, isolated_home): + # every fact embeds to the same vector, so on main the near-duplicate shortcut skipped the Hindi fact + store, _llm, mem = await _memory(config, isolated_home, "dup.db") + try: + riya = await mem.remember("Riya lives in Pune.", use_llm=False) + out = await mem.remember("प्रिया पुणे में रहती है।", use_llm=False) + assert out["action"] == "ADD" and out["id"] != riya["id"] + # the same person again is still a duplicate + again = await mem.remember("Riya lives in Pune now.", use_llm=False) + assert again["action"] == "SKIP" + finally: + await store.close() + + +async def test_model_update_or_skip_across_subjects_is_kept_as_new(config, isolated_home): + store, llm, mem = await _memory(config, isolated_home, "cud.db") + config.memory.duplicate_similarity = 0.999 + try: + riya = await mem.remember("Riya lives in Pune.", use_llm=False) + kabir = await mem.remember("Kabir moved to Delhi last year.", use_llm=False) + llm.json_replies.extend([ + {"action": "UPDATE", "fact_id": riya["id"], "content": "Riya lives in Pune.", "analysis": None}, + ANALYSIS, + {"action": "SKIP", "fact_id": kabir["id"], "content": "", "analysis": None}, + ANALYSIS, + {"action": "DELETE", "fact_id": riya["id"], "content": "", "analysis": None}, + ANALYSIS, + ]) + priya = await mem.remember("प्रिया अब दिल्ली में रहती है।") + karan = await mem.remember("करण गोवा गया।") + third = await mem.remember("पिछले साल प्रिया मुंबई गई।") + assert priya["action"] == karan["action"] == third["action"] == "ADD" + stored = {f["content"] for f in await mem.list_facts()} + assert {"Riya lives in Pune.", "Kabir moved to Delhi last year.", "प्रिया अब दिल्ली में रहती है।", + "करण गोवा गया।", "पिछले साल प्रिया मुंबई गई।"} <= stored + # the facts about other people were never offered to the model + prompt = llm.calls[0]["messages"][-1]["content"] + assert "Riya lives in Pune" not in prompt and "Kabir" not in prompt + finally: + await store.close() + + +async def test_same_person_across_scripts_can_still_be_updated(config, isolated_home): + store, llm, mem = await _memory(config, isolated_home, "same.db") + config.memory.duplicate_similarity = 0.999 + try: + maya = await mem.remember("Maya lives in Pune.", use_llm=False) + llm.json_replies.extend([ + {"action": "UPDATE", "fact_id": maya["id"], "content": "Maya lives in Delhi.", "analysis": None}, + ANALYSIS, + ]) + out = await mem.remember("माया अब दिल्ली में रहती है।") + assert out["action"] == "UPDATE" and out["id"] == maya["id"] + assert "Maya lives in Pune" in llm.calls[0]["messages"][-1]["content"] + finally: + await store.close() + + +class SwitchableProvider(FakeProvider): + """Two embedding models with the same dimension that place texts differently.""" + + def __init__(self): + super().__init__() + self.embedding = "ollama/nomic-embed-text" + + def model_for(self, role): + return self.embedding if role == "embedding" else super().model_for(role) + + async def embed(self, texts, *, model=None): + vecs = await super().embed(texts) + return vecs if self.embedding.endswith("nomic-embed-text") else [list(reversed(v)) for v in vecs] + + +async def test_switching_embedding_model_of_the_same_size_re_embeds_memory(config, isolated_home): + llm = SwitchableProvider() + store, llm, mem = await _memory(config, isolated_home, "switch.db", llm) + try: + first = await mem.remember("Maya likes long walks by the sea.", use_llm=False) + assert await store.get_meta("facts_vec_model") == "ollama/nomic-embed-text" + llm.embedding = "ollama/paraphrase-multilingual" # also 768 dimensions in real life + mem2 = FactMemory(store, llm, config) # a restart with the new setting + found = await mem2.recall("Maya likes long walks by the sea.", min_similarity=0.0, track=False) + assert found and found[0]["id"] == first["id"] and found[0]["similarity"] > 0.99 + assert await store.get_meta("facts_vec_model") == "ollama/paraphrase-multilingual" + stored = (await mem2.vec.all_vectors())[first["id"]] + [expected] = await llm.embed(["Maya likes long walks by the sea."]) + assert max(abs(a - b) for a, b in zip(stored, expected, strict=True)) < 1e-6 + finally: + await store.close() + + +async def test_vec_table_keeps_an_index_when_the_model_was_never_recorded(isolated_home): + store = await Store(isolated_home / "old.db").open() + try: + rebuilt: list[bool] = [] + + async def rebuild(): + rebuilt.append(True) + + old = VecTable(store, "t_vec") + await old.ensure(4) # an older build: no model recorded + await old.upsert(1, [1.0, 0.0, 0.0, 0.0]) + new = VecTable(store, "t_vec") + assert await new.ensure(4, rebuild, "ollama/bge-m3") is False and not rebuilt + assert await store.get_meta("t_vec_model") == "ollama/bge-m3" + other = VecTable(store, "t_vec") + assert await other.ensure(4, rebuild, "ollama/nomic-embed-text") is True and rebuilt + finally: + await store.close() diff --git a/tests/test_hardware_context.py b/tests/test_hardware_context.py index c93f760b8..714b68c0c 100644 --- a/tests/test_hardware_context.py +++ b/tests/test_hardware_context.py @@ -153,6 +153,14 @@ async def test_chat_only_fallback_never_reaches_presets_or_check_up_fixes(config assert tools["action"] == {"kind": "pull_model", "name": "qwen3:8b", "label": "Download qwen3:8b"} +def test_multilingual_memory_search_is_suggested_where_a_local_model_fits(): + # nomic-embed-text stays the default; bge-m3 is offered for Hindi and other languages (#339) + for vram, ram in ((8.0, 16.0), (None, 7.8), (24.0, 64.0), (None, None)): + multi = hardware.recommend(fake_hw(vram, ram))["multilingual_embedding"] + assert multi["model"] == "ollama/bge-m3" and multi["size"] == "1.2 GB" and "Hindi" in multi["note"] + assert hardware.recommend(fake_hw(None, 3.8))["multilingual_embedding"] is None # a cloud model comes first + + def test_eight_gb_card_keeps_qwen3_8b_at_8192(): rec = hardware.recommend(fake_hw(8.0, 16.0)) assert (rec["name"], rec["context_length"]) == ("qwen3:8b", 8192) From e04522fea6e46159d63d73185ab02d5a929c9cc7 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Wed, 14 Oct 2026 11:35:31 +0530 Subject: [PATCH 2/4] docs(memory): record the bge-m3 measurements next to MULTILINGUAL_EMBEDDING --- sentient/config/schema.py | 7 +++++-- sentient/llm/hardware.py | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/sentient/config/schema.py b/sentient/config/schema.py index f140fabb3..10bddf1b6 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -97,8 +97,11 @@ class ModelRoles(BaseModel): # The embedding model recommended for memories in Hindi and other languages besides English (#339). nomic-embed-text -# (the default) is English-first: it scores Hindi facts about different people as near-duplicates. bge-m3 covers 100+ -# languages and needs no query prefix. Measured on an RTX 4060 laptop with Ollama 0.9 (2026-10-14): MEASURED_NOTE +# (the default, 768 dimensions) is English-first: Hindi facts about different people score 0.71 to 0.85 against each +# other, and a Hindi question ranks the wrong person first. bge-m3 (1024 dimensions, 100+ languages, no query prefix) +# scores those pairs 0.35 to 0.56 and the same fact across Hindi and English 0.77 to 0.84. Measured on an RTX 4060 +# laptop with Ollama 0.9 (2026-10-14): 1.7 GB loaded on the graphics card (nomic-embed-text 0.85 GB), a memory search +# in 0.6 to 0.7 s (nomic-embed-text 0.5 s), and switching re-embedded 7 memories in 8 s including loading the model. MULTILINGUAL_EMBEDDING: dict[str, str] = {"model": "ollama/bge-m3", "name": "bge-m3", "size": "1.2 GB"} diff --git a/sentient/llm/hardware.py b/sentient/llm/hardware.py index e1707f251..f679f6b69 100644 --- a/sentient/llm/hardware.py +++ b/sentient/llm/hardware.py @@ -22,7 +22,12 @@ import httpx -from sentient.config.schema import LOCAL_MODEL_TIERS, MULTILINGUAL_EMBEDDING, ModelRoles, ModelsConfig +from sentient.config.schema import ( + LOCAL_MODEL_TIERS, + MULTILINGUAL_EMBEDDING, + ModelRoles, + ModelsConfig, +) log = logging.getLogger(__name__) From 234de7a354ce104830b584991c8daadea5aac821 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Wed, 14 Oct 2026 11:49:33 +0530 Subject: [PATCH 3/4] fix(memory): retry a failed re-embed after a restart, and drop cached insight vectors when the model changes --- sentient/memory/usermodel.py | 28 +++++++++++++---------- sentient/memory/vectors.py | 23 +++++++++++++------ tests/memory/test_cross_script.py | 37 +++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/sentient/memory/usermodel.py b/sentient/memory/usermodel.py index cd27fe335..1a7debe17 100644 --- a/sentient/memory/usermodel.py +++ b/sentient/memory/usermodel.py @@ -207,6 +207,7 @@ def __init__(self, app: SentientApp): self.clock = lambda: datetime.now(UTC) self._lock = asyncio.Lock() self._vectors: dict[int, list[float]] | None = None + self._vectors_model: str | None = None # the embedding model ``_vectors`` were read for self._background: set[asyncio.Task] = set() @property @@ -885,18 +886,21 @@ async def _query_vector(self, text: str) -> list[float] | None: return None async def _insight_vectors(self) -> dict[int, list[float]]: - if self._vectors is None: - vectors: dict[int, list[float]] = {} - if self.app.store.vec_available and (self.vec.ready or await self.vec.exists()): - stored = await self.app.store.get_meta(f"{VEC_TABLE}_model") - if stored and stored != self.app.llm.model_for("embedding"): - # made by another embedding model, so they don't compare with today's query: re-embed them - self._start(self._reembed()) - return {} - with contextlib.suppress(Exception): - vectors = await self.vec.all_vectors() - self._vectors = vectors - return self._vectors + current = self.app.llm.model_for("embedding") + if self._vectors is not None and self._vectors_model == current: + return self._vectors + vectors: dict[int, list[float]] = {} + if self.app.store.vec_available and (self.vec.ready or await self.vec.exists()): + stored = await self.app.store.get_meta(f"{VEC_TABLE}_model") + if stored and stored != current: + # made by another embedding model, so they don't compare with today's query: re-embed them + self._vectors = None + self._start(self._reembed()) + return {} + with contextlib.suppress(Exception): + vectors = await self.vec.all_vectors() + self._vectors, self._vectors_model = vectors, current + return vectors def _start(self, coro) -> None: if any(t.get_name() == "user_model:reembed" for t in self._background): diff --git a/sentient/memory/vectors.py b/sentient/memory/vectors.py index 82c84258c..40e6de9af 100644 --- a/sentient/memory/vectors.py +++ b/sentient/memory/vectors.py @@ -90,19 +90,28 @@ async def ensure( await self.store.execute( f"CREATE VIRTUAL TABLE IF NOT EXISTS {self.name} USING vec0(embedding float[{dim}] distance_metric={METRIC})" ) - await self.store.set_meta(f"{self.name}_dim", str(dim)) - await self.store.set_meta(f"{self.name}_metric", METRIC) - if model: - await self.store.set_meta(f"{self.name}_model", model) + if not stale: + await self._record(dim, model) self.dim = dim self.model = model or self.model - if stale and rebuild is not None: + if stale: try: - await rebuild() - except Exception as exc: # keep working with a partial index + if rebuild is not None: + await rebuild() + except Exception as exc: + # keep working with a partial index; the old dimension and model stay recorded, so the next start + # rebuilds it again instead of trusting it log.warning("re-embedding for %s failed: %s", self.name, exc) + else: + await self._record(dim, model) return stale + async def _record(self, dim: int, model: str | None) -> None: + await self.store.set_meta(f"{self.name}_dim", str(dim)) + await self.store.set_meta(f"{self.name}_metric", METRIC) + if model: + await self.store.set_meta(f"{self.name}_model", model) + async def upsert(self, rowid: int, vec: list[float]) -> None: await self.store.execute(f"DELETE FROM {self.name} WHERE rowid = ?", (rowid,)) await self.store.execute(f"INSERT INTO {self.name}(rowid, embedding) VALUES(?, ?)", (rowid, pack(vec))) diff --git a/tests/memory/test_cross_script.py b/tests/memory/test_cross_script.py index e03cc4e45..b209cf5cf 100644 --- a/tests/memory/test_cross_script.py +++ b/tests/memory/test_cross_script.py @@ -175,3 +175,40 @@ async def rebuild(): assert await other.ensure(4, rebuild, "ollama/nomic-embed-text") is True and rebuilt finally: await store.close() + + +async def test_a_failed_re_embed_is_tried_again_after_a_restart(isolated_home): + store = await Store(isolated_home / "retry.db").open() + try: + first = VecTable(store, "r_vec") + await first.ensure(4, None, "ollama/nomic-embed-text") + + async def broken(): + raise RuntimeError("Ollama went away") + + switched = VecTable(store, "r_vec") + assert await switched.ensure(4, broken, "ollama/bge-m3") is True + # the old model stays recorded, so the index isn't trusted on the next start + assert await store.get_meta("r_vec_model") == "ollama/nomic-embed-text" + rebuilt: list[bool] = [] + + async def works(): + rebuilt.append(True) + + again = VecTable(store, "r_vec") + assert await again.ensure(4, works, "ollama/bge-m3") is True and rebuilt + assert await store.get_meta("r_vec_model") == "ollama/bge-m3" + finally: + await store.close() + + +async def test_user_model_ignores_insight_vectors_from_another_embedding_model(app, monkeypatch): + um = app.user_model + await um.add_insight("Maya is vegetarian", "preferences") + assert await um._insight_vectors() # read once and cached for this model + monkeypatch.setattr(app.fake, "model_for", lambda role: "ollama/bge-m3" if role == "embedding" else "fake/x") + assert await um._insight_vectors() == {} # never compared with a query from the new model + for task in list(um._background): + await task + assert await app.store.get_meta("user_insights_vec_model") == "ollama/bge-m3" + assert await um._insight_vectors() # re-embedded with the new model From 1371eddd1bf564920ca55eaa4898b09565e60bbf Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Wed, 14 Oct 2026 11:57:16 +0530 Subject: [PATCH 4/4] docs(memory): note how bge-m3 shares the 8 GB card with qwen3:8b --- sentient/config/schema.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentient/config/schema.py b/sentient/config/schema.py index 10bddf1b6..83dcff956 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -101,7 +101,10 @@ class ModelRoles(BaseModel): # other, and a Hindi question ranks the wrong person first. bge-m3 (1024 dimensions, 100+ languages, no query prefix) # scores those pairs 0.35 to 0.56 and the same fact across Hindi and English 0.77 to 0.84. Measured on an RTX 4060 # laptop with Ollama 0.9 (2026-10-14): 1.7 GB loaded on the graphics card (nomic-embed-text 0.85 GB), a memory search -# in 0.6 to 0.7 s (nomic-embed-text 0.5 s), and switching re-embedded 7 memories in 8 s including loading the model. +# in 0.6 to 0.7 s once loaded (nomic-embed-text 0.5 to 0.7 s), and switching re-embedded 7 memories in 8 s including +# loading the model. Next to qwen3:8b at 8,192 tokens, Ollama swaps it in when needed: qwen3:8b stays at 7%/93% +# processor/graphics and about 29 tokens/s, and an embedding right after a chat reply takes 5.4 s to load against +# 2.3 s for nomic-embed-text. That cost is why it is offered, not the default. MULTILINGUAL_EMBEDDING: dict[str, str] = {"model": "ollama/bge-m3", "name": "bge-m3", "size": "1.2 GB"}