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 0ff5dd36a..4fcdec5f0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3181,13 +3181,19 @@ A device ("node") is a phone, a pair of smart glasses, a watch, or the desktop a message for `dreaming.require_idle_minutes`. Steps: merge near-duplicates (similarity ≥ `merge_similarity` and word 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; - 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 - deleted and its text kept as the winner's `previous_content`; pairs the model cleared are not asked again until either - fact changes. Between the two (`dreaming.merge_languages`, default on, #353), the same memory saved in two languages - is kept once: a fact led by Devanagari is paired with up to 3 facts without Devanagari that are clearly about the same + Riya", or the user as "I"/"the user" - a fact led by Devanagari gets its subject from `scripts.devanagari_subjects` + instead, since it has no capital letters to read, #95) 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 + whichever family's short description its embedding is closest to, as long as it's ahead of the runner-up by at least + `memory.family_margin` (default 0.02) - a fixed similarity floor doesn't travel between embedding models, so this + compares the best match against the next-best instead of an absolute score, and reuses the fact's own stored + embedding rather than computing a new one; 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 deleted and its text kept as the winner's `previous_content`; pairs the + model cleared are not asked again until either fact changes. Between the two (`dreaming.merge_languages`, default on, + #353), the same memory saved in two languages is kept once: a fact led by Devanagari is paired with up to 3 facts + without Devanagari that are clearly about the same person (both have a subject and the names match across scripts, #351), have the same numbers, and have no name or place the Hindi one lacks; with a multilingual embedding model (bge-m3) also similarity ≥ `dreaming.language_merge_similarity` (0.7), otherwise the similarity only orders them. The `fast`-role model (the diff --git a/sentient/config/schema.py b/sentient/config/schema.py index afdff7cf8..0a2917758 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -518,6 +518,14 @@ 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_margin: float = Field( + 0.02, 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, whichever embeds closest to a short " + "description of it, as long as it's ahead of the next closest by at least this much. A fixed similarity " + "floor doesn't travel between embedding models (one scored an unrelated Hindi fact 0.44-0.49 against " + "every family alike), so this compares the best match against the runner-up instead of an absolute score.", + ) 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 9ef242bf7..be957f209 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 @@ -556,15 +556,25 @@ def _clearer(self, hindi: dict, other: dict, facts: list[dict]) -> tuple[dict, d def contradiction_candidate(a: str, b: str, user_name: str = "") -> bool: return conflict_strength(a, b, user_name) > 0 - def contradiction_pairs(self, facts: list[dict]) -> list[tuple[int, float, int, int]]: + async def contradiction_pairs(self, facts: list[dict]) -> list[tuple[int, float, int, int]]: """Candidate pairs ``(strength, similarity, i, j)``, strongest first. Facts are grouped by subject (the person or entity a fact is about, including possessive relations and the user's own name), so "Maya moved to Bengaluru" meets "Maya lives in Pune" without sharing a leading word or a high embedding score. Pairs sharing only a broad attribute also need - ``contradiction_similarity``.""" + ``contradiction_similarity``. A fact's attribute family falls back to embedding similarity when the + English keyword families miss it, e.g. non-English text (``profile_fact_async``).""" user = self.app.config.assistant.user_name - profiles = [profile_fact(f["content"], user) for f in facts] + mem = self.app.memory + embed_one = mem.embed_query if mem is not None else None + model_key = mem.llm.model_for("embedding") if mem is not None else "" + margin = self.app.config.memory.family_margin + # every active fact already has a stored vector (it was embedded on insert): reusing it here, instead + # of embedding the same text again, was the difference between 241 s and 14.5 s over 500 facts (#95) + profiles = [ + await profile_fact_async(f["content"], embed_one, user, model_key, margin, vector=f.get("vector")) + for f in facts + ] groups: dict[str, list[int]] = {} for n, prof in enumerate(profiles): for s in prof.subjects: @@ -600,7 +610,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 023e7bc1d..e71dd4424 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -29,7 +29,8 @@ import logging import re 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 @@ -151,13 +152,35 @@ 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 | { +# Any Unicode letter, not just A-Z: a Cyrillic or Greek name is a word here too. (Devanagari and other +# scripts without a case distinction go through scripts.devanagari_subjects instead, via subject_aliases.) +# A number ("2021") still matches - ``isupper()`` below is false for it either way, so it's never a name - so +# that it keeps its own position in ``words``, instead of collapsing "In 2021 Kabir" to "In" directly before +# "Kabir" and making the preposition check below (mis)fire on "Kabir". +_NAME_WORD_RE = re.compile(r"[^\W_][\w'-]*") +_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", } +# A capitalized word right after one of these is a place or a time, never a subject ("the trip to Goa", "moved +# to Austin"): without this, scanning past a leading clause (below) could pick it up as a person. +_PREPOSITIONS = frozenset({ + "in", "to", "from", "at", "after", "before", "near", "on", "during", "via", "through", "toward", "towards", + "into", "onto", "within", +}) +# 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", @@ -248,6 +271,19 @@ def fact_subjects(text: str, user_name: str = "") -> set[str]: text = (text or "").replace(_RSQUO, "'").strip() 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 + # 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 trailing_words > 1 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() @@ -269,7 +305,7 @@ def relation(owner: str, j: int) -> set[str] | None: # "'s sister" (and aliases.add(name) return aliases - for i, w in enumerate(words[:6]): + for i, w in enumerate(words[:_SUBJECT_SCAN_WORDS]): low = w.lower() poss = low.endswith("'s") root = low[:-2] if poss else low @@ -278,10 +314,12 @@ def relation(owner: str, j: int) -> set[str] | None: # "'s sister" (and return (relation(user, i + 1) or {user}) if low == "my" or poss else {user} if low in _OWNER_PRONOUNS: return relation(SOMEONE, i + 1) or set() - is_name = (w[0].isupper() and root not in _LEAD_SKIP) or root == user + # a place or a time right after a preposition is never the subject ("the trip to Goa", "moved to Austin") + after_prep = i > 0 and words[i - 1].lower() in _PREPOSITIONS + is_name = (w[0].isupper() and root not in _LEAD_SKIP and not after_prep) or root == user if not is_name: - if w[0].isupper() or root in _LEAD_SKIP: - continue # sentence starters like "Every", "Since", "Last" + if w[0].isupper() or root in _LEAD_SKIP or i < permissive_until or w.isdigit(): + continue # a sentence starter, a bare year ("In 2021 ..."), or inside a leading clause return set() # a full name, up to the word that owns something: "Maya Rao's sister" is Maya's sister, not Maya end = i @@ -307,6 +345,57 @@ 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, + margin: float, + vector: list[float] | None = None, +) -> set[str]: + """The attribute family whose short description embeds closest to ``text``, 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. + + A fixed similarity floor doesn't travel between embedding models: tested against a real local model, + a Hindi fact scored 0.44-0.49 against every family alike, well under a 0.78 floor calibrated by guesswork, + so nothing ever matched. Comparing the best score against the next-best instead needs no calibration to + the model's own noise floor - only that the right family scores further out than the rest, which held even + at 0.44-0.49 - and naturally returns nothing when the scores are this close because none of them stand out, + rather than because they're all "too low". + + ``vector`` reuses a stored embedding instead of embedding ``text`` again (dreaming has one for every active + fact already), but only when its dimension matches the prototypes': a fact embedded under a since-changed + model can't be compared to them, the same guard ``contradiction_pairs`` already applies before any ``cosine`` + call.""" + 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 + dim = len(next(iter(cache.values()), [])) + vec = vector if vector and len(vector) == dim else await embed_one(text) + if not vec: + return set() + ranked = sorted(((cosine(vec, proto_vec), name) for name, proto_vec in cache.items()), reverse=True) + if len(ranked) > 1 and ranked[0][0] - ranked[1][0] < margin: + return set() + return {ranked[0][1]} if ranked else set() + + 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, @@ -333,7 +422,9 @@ def profile_fact(text: str, user_name: str = "") -> FactProfile: if base[:1].isdigit() or (i > 0 and base[:1].isupper()): details.add(base) return FactProfile( - subjects=frozenset(fact_subjects(text, user_name)), + # subject_aliases, not fact_subjects: a Devanagari fact needs scripts.devanagari_subjects to get a + # subject at all (#95) - fact_subjects only reads capital letters, which Devanagari doesn't have. + subjects=frozenset(subject_aliases(text, user_name)), families=frozenset(attribute_families(text)), negation=bool(NEGATION_RE.search(text or "")), details=frozenset(details), @@ -342,6 +433,36 @@ 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 = "", + margin: float = 0.02, + vector: list[float] | None = None, +) -> 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). + + ``vector`` is the fact's own embedding, already computed and stored, when the caller has it (dreaming + reviews every active fact and each one has one) - reused instead of embedding the same text again, which + against 500 facts with no keyword family was the difference between 241 s and 14.5 s. A fresh embed call + is the fallback for text that has no stored vector yet (a fact not yet saved). + + An embedding call failing (a local model briefly unavailable, say) falls back to the keyword-only profile + rather than failing the whole contradiction step over one fact.""" + prof = profile_fact(text, user_name) + if prof.families or embed_one is None: + return prof + try: + families = await embedding_attribute_families(text, embed_one, model_key, margin, vector) + except Exception: + log.warning("embedding attribute families failed for a fact, keeping the keyword-only profile", exc_info=True) + return prof + 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({ @@ -1518,9 +1639,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") + margin = self.config.memory.family_margin + prof = await profile_fact_async(fact, self.embed_query, user, model_key, margin) if not prof.subjects or not prof.families: return [] terms = [t for s in sorted(prof.subjects) for t in keyword_tokens(s)] @@ -1539,7 +1663,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, margin) 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 2719557bf..54265aedc 100644 --- a/tests/memory/test_dreaming.py +++ b/tests/memory/test_dreaming.py @@ -5,7 +5,15 @@ 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, + profile_fact_async, + subject_aliases, +) from tests.memory.helpers import add_fact, drain @@ -36,6 +44,113 @@ def test_subjects_and_families(): assert "residence" in attribute_families("Maya moved to Bengaluru last month") +def test_subject_in_non_english_script(): + # fact_subjects only reads capital letters (Devanagari has none), so it finds nothing here - a Devanagari + # fact's subject comes from scripts.devanagari_subjects instead, via subject_aliases (and profile_fact, + # which dreaming's contradiction grouping actually uses) (#95). + assert fact_subjects("माया पुणे में रहती है") == set() # "Maya lives in Pune" + assert subject_aliases("माया पुणे में रहती है") == {"माया"} + assert subject_aliases("माया बेंगलुरु चली गई") == {"माया"} # "Maya moved to Bengaluru" + assert profile_fact("माया पुणे में रहती है").subjects == {"माया"} + + +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"} + # with no leading clause (no comma), an unrecognised word still gives up as before - no guessing + assert fact_subjects("She moved to Mumbai.") == set() + + +def test_subject_never_a_leading_number_or_a_place_after_a_preposition(): + # Found in review of #95: scanning past a leading clause must not pick up the clause's own words as the + # subject. A bare year is never name-like (no capital letter), and a capitalized word straight after a + # preposition is a place or a time, not a person - both differ only by the actual subject's name, or the + # old code paired the two facts below as a contradiction about the same "person". + assert fact_subjects("In 2021 Kabir moved to Delhi.") == {"kabir"} + assert fact_subjects("In 2021 Karan moved to Goa.") == {"karan"} + assert fact_subjects("After the trip to Goa, Kabir started working at Infosys.") == {"kabir"} + 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() + + +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). + names = list(_FAMILY_PROTOTYPES) + vectors = {name: [1.0, float(n)] for n, name in enumerate(names)} # residence=[1,0], job=[1,1], ... + 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 vectors[name] + return [1.0, 0.05] # the fact text itself: closest to "residence" ([1, 0]), next closest a bit further + + assert await embedding_attribute_families(fact_text, embed_one, "test-model", margin=0.01) == {"residence"} + # a wider margin than the real gap between the top two scores: neither stands out enough to call it a match + assert await embedding_attribute_families(fact_text, embed_one, "test-model", margin=0.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) + for n, (_name, proto) in enumerate(_FAMILY_PROTOTYPES.items()): + if text == proto: + return [1.0, float(n)] + return [1.0, 0.0] # the fact text itself: lands on "residence" ([1, 0], the first prototype) + + # 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", margin=0.01) + assert calls # the fallback ran + assert prof.families == {"residence"} + calls.clear() + + # a stored vector of the right dimension is reused instead of embedding the text again + prof = await profile_fact_async( + "माया बेंगलुरु चली गई", embed_one, "Maya", "test-model-2", margin=0.01, vector=[1.0, 0.0] + ) + assert calls == [] # only the (already-cached) prototypes would have needed embedding, and nothing did + assert prof.families == {"residence"} + + +async def test_profile_fact_async_falls_back_to_the_keyword_profile_when_embedding_fails(): + async def broken(text: str) -> list[float]: + raise RuntimeError("embedding provider unavailable") + + prof = await profile_fact_async("माया बेंगलुरु चली गई", broken, "Maya", "test-model-3", margin=0.01) + assert prof.families == set() # no exception, and no family guessed + + EXAMPLES = [ ("Maya lives in Pune", "Maya moved to Bengaluru last month"), ("Maya's sister Riya works at Infosys", "Riya got a job at Google"),