Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions benchmarks/longmemeval/longmemeval_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,13 @@ async def _answer_question(
question_date = instance.get("question_date") or ""

retrieval_t0 = time.perf_counter()
use_expansion = question_type in ("multi-session", "temporal-reasoning")
search_results = await self.vektori_client.search(
query=question,
user_id=user_id,
depth=self.config.retrieval_depth,
reference_date=_parse_date(question_date) if question_date else None,
expand=use_expansion,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
)
retrieval_ms = (time.perf_counter() - retrieval_t0) * 1000

Expand Down
43 changes: 43 additions & 0 deletions test_conflict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import asyncio
from vektori.storage.memory import MemoryBackend
from vektori.ingestion.extractor import FactExtractor
from vektori.models.base import LLMProvider, EmbeddingProvider

class MockEmbedder(EmbeddingProvider):
@property
def dimension(self): return 2
async def embed(self, text):
if "hate" in text: return [0.5, -0.5]
return [0.5, 0.5]
async def embed_batch(self, texts):
return [await self.embed(t) for t in texts]

class MockLLM(LLMProvider):
async def generate(self, prompt, max_tokens=1000, **kwargs):
pass

async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)

# insert first fact
await extractor._process_facts(
[{"text": "User loves apples"}],
session_id="s1", user_id="u1", agent_id=None, conversation="User: I love apples"
)

# insert second fact
await extractor._process_facts(
[{"text": "User hates apples"}],
session_id="s2", user_id="u1", agent_id=None, conversation="User: Actually I hate apples"
)

facts = await db.get_active_facts("u1")
print("Active facts count:", len(facts))
for f in facts:
print(f["text"])

asyncio.run(test())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This harness never verifies the new contradiction path.

MockLLM.generate() returns None, so _check_dedup() falls back to the non-supersession path, and the module only prints the result afterward. As written, this file won’t catch regressions in the LLM-based conflict handling at all.

Suggested direction
 class MockLLM(LLMProvider):
     async def generate(self, prompt, max_tokens=1000, **kwargs):
-        pass
+        if "loves apples" in prompt and "hates apples" in prompt:
+            old_id = prompt.split("- [")[1].split("]")[0]
+            return f'{{"supersedes_id": "{old_id}"}}'
+        return '{"supersedes_id": null}'
@@
-    facts = await db.get_active_facts("u1")
-    print("Active facts count:", len(facts))
-    for f in facts:
-        print(f["text"])
-
-asyncio.run(test())
+    facts = await db.get_active_facts("u1")
+    assert [f["text"] for f in facts] == ["User hates apples"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
class MockLLM(LLMProvider):
async def generate(self, prompt, max_tokens=1000, **kwargs):
pass
async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)
# insert first fact
await extractor._process_facts(
[{"text": "User loves apples"}],
session_id="s1", user_id="u1", agent_id=None, conversation="User: I love apples"
)
# insert second fact
await extractor._process_facts(
[{"text": "User hates apples"}],
session_id="s2", user_id="u1", agent_id=None, conversation="User: Actually I hate apples"
)
facts = await db.get_active_facts("u1")
print("Active facts count:", len(facts))
for f in facts:
print(f["text"])
asyncio.run(test())
class MockLLM(LLMProvider):
async def generate(self, prompt, max_tokens=1000, **kwargs):
if "loves apples" in prompt and "hates apples" in prompt:
old_id = prompt.split("- [")[1].split("]")[0]
return f'{{"supersedes_id": "{old_id}"}}'
return '{"supersedes_id": null}'
async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)
# insert first fact
await extractor._process_facts(
[{"text": "User loves apples"}],
session_id="s1", user_id="u1", agent_id=None, conversation="User: I love apples"
)
# insert second fact
await extractor._process_facts(
[{"text": "User hates apples"}],
session_id="s2", user_id="u1", agent_id=None, conversation="User: Actually I hate apples"
)
facts = await db.get_active_facts("u1")
assert [f["text"] for f in facts] == ["User hates apples"]
asyncio.run(test())
🧰 Tools
🪛 GitHub Actions: CI

[warning] 25-25: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[warning] 31-31: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[warning] 37-37: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test_conflict.py` around lines 15 - 43, The test never triggers the LLM-based
contradiction path because MockLLM.generate returns None; update
MockLLM.generate to return a deterministic, non-None response shaped like the
real LLM output that FactExtractor._check_dedup expects (e.g., the
classification/supersession verdict or JSON string it parses) so the
supersession branch runs, then modify the test to assert that the second
inserted fact was marked superseded or replaced (use
MemoryBackend.get_active_facts and any supersession flag) instead of only
printing results; reference MockLLM.generate, FactExtractor._check_dedup, and
MemoryBackend.get_active_facts to locate and change the code.

39 changes: 39 additions & 0 deletions test_conflict2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import asyncio
from vektori.storage.memory import MemoryBackend
from vektori.ingestion.extractor import FactExtractor
from vektori.models.base import LLMProvider, EmbeddingProvider
import logging

logging.basicConfig(level=logging.DEBUG)

class MockEmbedder(EmbeddingProvider):
@property
def dimension(self): return 2
async def embed(self, text):
if "hate" in text: return [0.5, 0.4] # High similarity to trigger check
return [0.5, 0.5]
async def embed_batch(self, texts):
return [await self.embed(t) for t in texts]

class MockLLM(LLMProvider):
async def generate(self, prompt, max_tokens=1000, **kwargs):
if "loves apples" in prompt and "hates apples" in prompt:
old_id = prompt.split("- [")[1].split("]")[0]
return f'{{"supersedes_id": "{old_id}"}}'
return "{}"

async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)

await extractor._process_facts([{"text": "User loves apples"}], "s1", "u1", None, "")
await extractor._process_facts([{"text": "User hates apples"}], "s2", "u1", None, "")

facts = await db.get_active_facts("u1")
print("Active facts count:", len(facts))
for f in facts: print("-", f["text"])

asyncio.run(test())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This is still a demo script, not an automated test.

The module executes immediately and logs the outcome instead of asserting it. If supersession stops working, nothing here will fail the suite.

Suggested direction
 async def test():
@@
     facts = await db.get_active_facts("u1")
-    print("Active facts count:", len(facts))
-    for f in facts: print("-", f["text"])
-
-asyncio.run(test())
+    assert [f["text"] for f in facts] == ["User hates apples"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)
await extractor._process_facts([{"text": "User loves apples"}], "s1", "u1", None, "")
await extractor._process_facts([{"text": "User hates apples"}], "s2", "u1", None, "")
facts = await db.get_active_facts("u1")
print("Active facts count:", len(facts))
for f in facts: print("-", f["text"])
asyncio.run(test())
async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)
await extractor._process_facts([{"text": "User loves apples"}], "s1", "u1", None, "")
await extractor._process_facts([{"text": "User hates apples"}], "s2", "u1", None, "")
facts = await db.get_active_facts("u1")
assert [f["text"] for f in facts] == ["User hates apples"]
🧰 Tools
🪛 GitHub Actions: CI

[warning] 31-31: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[warning] 34-34: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[error] 37-37: Ruff E701: Multiple statements on one line (colon).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test_conflict2.py` around lines 25 - 39, The file defines an ad-hoc demo
async test that runs on import (async def test() + asyncio.run(test())) and only
prints results; convert this into a proper automated test that uses assertions
and does not execute on import: replace the top-level asyncio.run call with a
test function recognizable by the test runner (e.g., async def
test_fact_supersession() with `@pytest.mark.asyncio` or an asyncio-compatible
fixture), call MemoryBackend(), FactExtractor(...), await
extractor._process_facts(...), then assert expected outcomes using
db.get_active_facts("u1") (e.g., assert len(facts) == X and expected texts
present/absent) and remove any print statements so the module does not execute
when imported.

33 changes: 33 additions & 0 deletions test_llm_dedup_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import asyncio
from vektori.storage.memory import MemoryBackend
from vektori.ingestion.extractor import FactExtractor
from vektori.models.base import LLMProvider, EmbeddingProvider

class MockEmbedder(EmbeddingProvider):
@property
def dimension(self): return 2
async def embed(self, text):
if "hate" in text: return [0.5, 0.4] # Give sim around 0.8 to test LLM
return [0.5, 0.5]
async def embed_batch(self, texts):
return [await self.embed(t) for t in texts]

class MockLLM(LLMProvider):
async def generate(self, prompt, max_tokens=1000, **kwargs):
if "contradict" in prompt.lower():
# If it has "hate", it contradicts the "love" one
if "hates apples" in prompt and "loves apples" in prompt:
return '{"contradicts": "1"}'
return '{"contradicts": null}'
pass

async def test():
db = MemoryBackend()
await db.initialize()
embedder = MockEmbedder()
llm = MockLLM()
extractor = FactExtractor(db, embedder, llm)

# Needs actual logic in Extractor...

asyncio.run(test())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The mock contract no longer matches the extractor.

_check_dedup() now looks for supersedes_id, but this mock returns contradicts, so the new LLM dedup branch would still behave like a no-op. The body is also empty, so this feature remains untested.

Minimal fix
 class MockLLM(LLMProvider):
     async def generate(self, prompt, max_tokens=1000, **kwargs):
         if "contradict" in prompt.lower():
             if "hates apples" in prompt and "loves apples" in prompt:
-                return '{"contradicts": "1"}'
-            return '{"contradicts": null}'
-        pass
+                old_id = prompt.split("- [")[1].split("]")[0]
+                return f'{{"supersedes_id": "{old_id}"}}'
+            return '{"supersedes_id": null}'
+        return '{"supersedes_id": null}'
🧰 Tools
🪛 GitHub Actions: CI

[error] 29-29: Ruff F841: Local variable extractor is assigned to but never used. Remove unused assignment.


[warning] 30-30: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[warning] 32-32: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test_llm_dedup_check.py` around lines 15 - 33, The MockLLM.generate mock no
longer matches the extractor's expected LLM contract: _check_dedup() now expects
a JSON key named "supersedes_id" but MockLLM.generate returns "contradicts" and
its body is effectively a no-op, so the dedup branch is untested; update
MockLLM.generate to parse the prompt conditions and return the JSON shape the
extractor expects (i.e., include "supersedes_id" with appropriate id or null),
and add a test prompt in async test() that triggers the extractor._check_dedup
path (use the same trigger text you check for in MockLLM.generate) so the dedup
behavior is exercised by the test harness (refer to MockLLM.generate,
_check_dedup, FactExtractor, and the test() entrypoint).

26 changes: 26 additions & 0 deletions test_synthesis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import asyncio
from vektori import Vektori

async def test():
v = Vektori(storage_backend="memory")
await v._ensure_initialized()
# Mock LLM to just return a synthesis fact
async def mock_gen(*args, **kwargs):
return '{"facts": [{"text": "User loves fruit overall."}]}'
async def mock_emb(*args, **kwargs):
return [[0.1, 0.2]]
v.llm.generate = mock_gen
v.embedder.embed_batch = mock_emb

# add dummy facts so synthesize triggers (needs >=5 base facts)
for i in range(5):
await v.db.insert_fact(f"fact {i}", [0.1, 0.2], "u1", confidence=1.0)

n = await v.synthesize("u1")
print("New synthesized facts:", n)
facts = await v.db.get_active_facts("u1", limit=100)
for f in facts:
if f.get("metadata", {}).get("source") == "synthesis":
print("Synthesized:", f["text"])

asyncio.run(test())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Convert this import-time script into an actual test.

This module executes during import and only prints results, so synthesis regressions won’t fail CI. Please assert on the synthesized count and stored facts instead of relying on print().

Suggested direction
-async def test():
+async def test_synthesize_inserts_fact():
     v = Vektori(storage_backend="memory")
     await v._ensure_initialized()
@@
     n = await v.synthesize("u1")
-    print("New synthesized facts:", n)
+    assert n == 1
     facts = await v.db.get_active_facts("u1", limit=100)
-    for f in facts:
-        if f.get("metadata", {}).get("source") == "synthesis":
-            print("Synthesized:", f["text"])
-            
-asyncio.run(test())
+    synthesized = [
+        f["text"]
+        for f in facts
+        if f.get("metadata", {}).get("source") == "synthesis"
+    ]
+    assert synthesized == ["User loves fruit overall."]
🧰 Tools
🪛 GitHub Actions: CI

[warning] 14-14: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[warning] 18-18: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.


[warning] 25-25: Ruff W293: Blank line contains whitespace. Remove whitespace from blank line.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test_synthesis.py` around lines 4 - 26, The current file runs an import-time
script that prints results; convert it into a proper pytest async test that
asserts behavior. Replace the top-level async test() and asyncio.run call with a
pytest test function (e.g., use `@pytest.mark.asyncio` async def
test_synthesis_creates_and_stores():), instantiate Vektori and call await
v._ensure_initialized(), mock v.llm.generate and v.embedder.embed_batch as
before, insert the 5 dummy facts, call n = await v.synthesize("u1") and assert
on n (expected synthesized count/structure) and on facts = await
v.db.get_active_facts("u1", limit=100") asserting the number of facts with
metadata.source == "synthesis" and their text, and finally clean up/close v if
needed; reference Vektori, v._ensure_initialized, v.llm.generate,
v.embedder.embed_batch, v.db.insert_fact, v.synthesize, and
v.db.get_active_facts to locate code.

53 changes: 52 additions & 1 deletion vektori/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def __init__(
context_window: int = 3,
temporal_decay_rate: float = 0.001,
async_extraction: bool = True,
synthesis_interval: int | None = 5,
qdrant_api_key: str | None = None,
milvus_token: str | None = None,
agent_type: str = "general",
Expand All @@ -61,6 +62,7 @@ def __init__(
context_window=context_window,
temporal_decay_rate=temporal_decay_rate,
async_extraction=async_extraction,
synthesis_interval=synthesis_interval,
qdrant_api_key=qdrant_api_key,
milvus_token=milvus_token,
extraction_config=ec,
Expand Down Expand Up @@ -101,6 +103,13 @@ async def _initialize(self) -> None:
max_output_tokens=self.config.max_extraction_output_tokens,
extraction_config=self.config.extraction_config,
)
# Import syntheizer lazily or at module level. Let's do it inside the file at the top or here.
from vektori.ingestion.synthesis import Synthesizer
self._synthesizer = Synthesizer(
db=self.db,
embedder=self.embedder,
llm=self.llm,
)
self._search = SearchPipeline(
db=self.db,
embedder=self.embedder,
Expand Down Expand Up @@ -155,10 +164,52 @@ async def add(
{"status": "ok", "sentences_stored": N, "extraction": "queued"|"done"|"skipped"}
"""
await self._ensure_initialized()
return await self._pipeline.ingest(

result = await self._pipeline.ingest(
messages, session_id, user_id, agent_id, metadata, session_time=session_time
)

n = self.config.synthesis_interval
if n is not None and n > 0:
# We don't want to block the caller on synthesis since it could be slow.
# However, if async_extraction is False, we should do it inline.
if getattr(self.config, "async_extraction", True):
import asyncio
if getattr(self, "_bg_tasks", None) is None:
self._bg_tasks = set()
# Fire and forget safely
async def _bg_synthesize(uid: str, aid: str | None) -> None:
try:
count = await self.db.count_sessions(user_id=uid, agent_id=aid)
if count > 0 and count % n == 0:
await self.synthesize(user_id=uid, agent_id=aid)
except Exception as e:
logger.error("Auto-synthesis failed: %s", e)

task = asyncio.create_task(_bg_synthesize(user_id, agent_id))
self._bg_tasks.add(task)
task.add_done_callback(self._bg_tasks.discard)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
count = await self.db.count_sessions(user_id=user_id, agent_id=agent_id)
if count > 0 and count % n == 0:
await self.synthesize(user_id=user_id, agent_id=agent_id)

return result

async def synthesize(self, user_id: str, agent_id: str | None = None) -> int:
"""
Run a cross-session synthesis pass for the user.
Examines active facts across sessions to form aggregate/macro facts
(e.g., "User consistently prefers X", "User has done Y across multiple sessions").

Returns the number of new aggregate facts inserted.
"""
await self._ensure_initialized()
if not hasattr(self, "_synthesizer"):
logger.warning("Synthesizer not initialized.")
return 0
return await self._synthesizer.synthesize(user_id, agent_id)

async def search(
self,
query: str,
Expand Down
4 changes: 4 additions & 0 deletions vektori/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ class VektoriConfig:
# Token-threshold batching — fire extraction once buffered input exceeds this
token_batch_threshold: int = 800 # ~800 tokens ≈ 3-4 turns before extraction fires

# Automatically synthesize aggregate cross-session facts every N sessions
# (None disables auto-synthesis)
synthesis_interval: int | None = 5

# Hard token limits for extraction LLM calls (API-level, not prompt hints)
max_extraction_input_tokens: int = 4000 # truncate conversation before sending to LLM
max_extraction_output_tokens: int = (
Expand Down
41 changes: 39 additions & 2 deletions vektori/ingestion/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,7 +685,7 @@ async def _process_facts(

# Write-time semantic dedup
dedup = await self._check_dedup(
fact_embedding, session_id, user_id, agent_id, subject
fact_data["text"], fact_embedding, session_id, user_id, agent_id, subject
)

if dedup is not None:
Expand Down Expand Up @@ -787,7 +787,9 @@ async def replay_facts(

# Dedup check — in a fresh user context this always returns None,
# but we run it anyway for correctness if sessions overlap.
dedup = await self._check_dedup(fact_emb, session_id, user_id, agent_id, subject)
dedup = await self._check_dedup(
fact_data["text"], fact_emb, session_id, user_id, agent_id, subject
)
if dedup is not None:
existing_id, same_session = dedup
await self.db.increment_fact_mentions(existing_id)
Expand Down Expand Up @@ -834,6 +836,7 @@ async def replay_facts(

async def _check_dedup(
self,
fact_text: str,
fact_embedding: list[float],
session_id: str,
user_id: str,
Expand Down Expand Up @@ -869,6 +872,26 @@ async def _check_dedup(
return (best["id"], True)
if not same_session and sim > 0.85:
return (best["id"], False)

# Contradiction LLM check for near-misses
# If the fact wasn't deduped by bare similarity, check if it contradicts.
# Only consider same-subject facts that are reasonably close (sim > 0.65).
plausible_conflicts = [
c for c in candidates
if (1.0 - c.get("distance", 1.0)) > 0.65
]
if plausible_conflicts:
facts_text = "\n".join(f"- [{c['id']}] {c['text']}" for c in plausible_conflicts)
prompt = CONTRADICTION_PROMPT.format(fact_text=fact_text, existing_facts=facts_text)
try:
response = await self.llm.generate(prompt, max_tokens=100)
data = _parse_json_response(response)
supersedes_id = data.get("supersedes_id")
if supersedes_id and any(c["id"] == supersedes_id for c in plausible_conflicts):
return (supersedes_id, False)
except Exception as e:
logger.debug("Contradiction check failed: %s", e)

except Exception as e:
logger.warning("Dedup lookup failed: %s", e)
return None
Expand Down Expand Up @@ -1042,3 +1065,17 @@ def _parse_json_response(response: str) -> dict[str, Any]:
except json.JSONDecodeError as e:
logger.error("Failed to parse extraction JSON: %s\nResponse: %.500s", e, response)
return {"facts": []}

# ── Contradiction prompt ──────────────────────────────────────────────────────
CONTRADICTION_PROMPT = """Do any of the existing facts contradict or get superseded by the new fact?
New fact: "{fact_text}"

Existing facts:
{existing_facts}

If the new fact updates, contradicts, or replaces an existing fact, return its ID. Otherwise return null.

Return ONLY JSON:
{{
"supersedes_id": "fact_id_here" or null
}}"""
Loading
Loading