mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""Tests for KnowledgeStore — source-aware SQLite/FTS5 memory backend."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openjarvis.connectors.store import KnowledgeStore
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _store(ks: KnowledgeStore, **kwargs) -> str: # type: ignore[type-arg]
|
|
"""Convenience wrapper with sensible defaults."""
|
|
defaults = {
|
|
"content": "default content",
|
|
"source": "test",
|
|
"doc_type": "note",
|
|
"doc_id": None,
|
|
"title": "",
|
|
"author": "",
|
|
"participants": None,
|
|
"timestamp": None,
|
|
"thread_id": None,
|
|
"url": None,
|
|
"metadata": None,
|
|
"chunk_index": 0,
|
|
}
|
|
defaults.update(kwargs)
|
|
return ks.store(**defaults) # type: ignore[call-arg]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture()
|
|
def ks(tmp_path: Path) -> KnowledgeStore:
|
|
db = tmp_path / "test_knowledge.db"
|
|
return KnowledgeStore(db_path=db)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_store_and_retrieve_basic(ks: KnowledgeStore) -> None:
|
|
"""Stored content can be retrieved by a matching query."""
|
|
_store(
|
|
ks,
|
|
content="The quick brown fox jumps over the lazy dog",
|
|
source="notes",
|
|
doc_type="note",
|
|
)
|
|
results = ks.retrieve("quick brown fox", top_k=5)
|
|
assert len(results) >= 1
|
|
assert "fox" in results[0].content.lower() or "quick" in results[0].content.lower()
|
|
assert results[0].score > 0.0
|
|
|
|
|
|
def test_retrieve_filter_by_source(ks: KnowledgeStore) -> None:
|
|
"""retrieve() with source= returns only chunks from that source."""
|
|
_store(ks, content="Email about project alpha", source="gmail", doc_type="email")
|
|
_store(
|
|
ks,
|
|
content="Note about project alpha progress",
|
|
source="obsidian",
|
|
doc_type="note",
|
|
)
|
|
|
|
gmail_results = ks.retrieve("project alpha", top_k=10, source="gmail")
|
|
for r in gmail_results:
|
|
assert r.metadata.get("source") == "gmail"
|
|
|
|
obsidian_results = ks.retrieve("project alpha", top_k=10, source="obsidian")
|
|
for r in obsidian_results:
|
|
assert r.metadata.get("source") == "obsidian"
|
|
|
|
assert len(gmail_results) >= 1
|
|
assert len(obsidian_results) >= 1
|
|
|
|
|
|
def test_retrieve_filter_by_doc_type(ks: KnowledgeStore) -> None:
|
|
"""retrieve() with doc_type= filters correctly."""
|
|
_store(
|
|
ks,
|
|
content="Meeting notes from Monday",
|
|
source="calendar",
|
|
doc_type="meeting",
|
|
)
|
|
_store(
|
|
ks,
|
|
content="Meeting email received Tuesday",
|
|
source="gmail",
|
|
doc_type="email",
|
|
)
|
|
|
|
meeting_results = ks.retrieve("meeting", top_k=10, doc_type="meeting")
|
|
for r in meeting_results:
|
|
assert r.metadata.get("doc_type") == "meeting"
|
|
|
|
email_results = ks.retrieve("meeting", top_k=10, doc_type="email")
|
|
for r in email_results:
|
|
assert r.metadata.get("doc_type") == "email"
|
|
|
|
assert len(meeting_results) >= 1
|
|
assert len(email_results) >= 1
|
|
|
|
|
|
def test_retrieve_filter_by_author(ks: KnowledgeStore) -> None:
|
|
"""retrieve() with author= filters correctly."""
|
|
_store(
|
|
ks,
|
|
content="Alice wrote about machine learning research",
|
|
source="gmail",
|
|
doc_type="email",
|
|
author="alice@example.com",
|
|
)
|
|
_store(
|
|
ks,
|
|
content="Bob wrote about machine learning deployment",
|
|
source="gmail",
|
|
doc_type="email",
|
|
author="bob@example.com",
|
|
)
|
|
|
|
alice_results = ks.retrieve(
|
|
"machine learning", top_k=10, author="alice@example.com"
|
|
)
|
|
for r in alice_results:
|
|
assert r.metadata.get("author") == "alice@example.com"
|
|
|
|
bob_results = ks.retrieve("machine learning", top_k=10, author="bob@example.com")
|
|
for r in bob_results:
|
|
assert r.metadata.get("author") == "bob@example.com"
|
|
|
|
assert len(alice_results) >= 1
|
|
assert len(bob_results) >= 1
|
|
|
|
|
|
def test_retrieve_filter_by_timestamp_since(ks: KnowledgeStore) -> None:
|
|
"""retrieve() with since= excludes older documents."""
|
|
old_ts = datetime(2023, 1, 1, tzinfo=timezone.utc)
|
|
new_ts = datetime(2025, 6, 1, tzinfo=timezone.utc)
|
|
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
|
|
_store(ks, content="Old document about research", source="notes", timestamp=old_ts)
|
|
_store(ks, content="New document about research", source="notes", timestamp=new_ts)
|
|
|
|
results = ks.retrieve("document research", top_k=10, since=cutoff)
|
|
for r in results:
|
|
ts_str = r.metadata.get("timestamp", "")
|
|
assert ts_str >= cutoff.isoformat(), f"Got old doc in results: {ts_str}"
|
|
|
|
assert len(results) >= 1
|
|
|
|
|
|
def test_delete_by_doc_id(ks: KnowledgeStore) -> None:
|
|
"""delete() removes all chunks with matching doc_id."""
|
|
doc_id = "test:doc:001"
|
|
_store(
|
|
ks,
|
|
content="First chunk of the document",
|
|
source="notes",
|
|
doc_id=doc_id,
|
|
chunk_index=0,
|
|
)
|
|
_store(
|
|
ks,
|
|
content="Second chunk of the document",
|
|
source="notes",
|
|
doc_id=doc_id,
|
|
chunk_index=1,
|
|
)
|
|
_store(
|
|
ks,
|
|
content="Other document content",
|
|
source="notes",
|
|
doc_id="other:doc:001",
|
|
chunk_index=0,
|
|
)
|
|
|
|
# Confirm items are stored
|
|
before = ks.retrieve("document", top_k=10)
|
|
assert len(before) >= 3
|
|
|
|
deleted = ks.delete(doc_id)
|
|
assert deleted is True
|
|
|
|
# Only the other doc should remain
|
|
after = ks.retrieve("document", top_k=10)
|
|
for r in after:
|
|
assert r.metadata.get("doc_id") != doc_id
|
|
|
|
# Deleting non-existent doc_id returns False
|
|
assert ks.delete("nonexistent:doc") is False
|
|
|
|
|
|
def test_clear(ks: KnowledgeStore) -> None:
|
|
"""clear() removes all stored documents."""
|
|
_store(ks, content="Document one about research")
|
|
_store(ks, content="Document two about analysis")
|
|
_store(ks, content="Document three about synthesis")
|
|
|
|
before = ks.retrieve("document", top_k=10)
|
|
assert len(before) >= 3
|
|
|
|
ks.clear()
|
|
|
|
after = ks.retrieve("document", top_k=10)
|
|
assert len(after) == 0
|
|
|
|
|
|
def test_store_with_metadata(ks: KnowledgeStore) -> None:
|
|
"""Extra metadata and thread_id are preserved in retrieval results."""
|
|
custom_meta = {"labels": ["important", "action-needed"], "priority": "high"}
|
|
chunk_id = _store(
|
|
ks,
|
|
content="Meeting follow-up with action items",
|
|
source="gmail",
|
|
doc_type="email",
|
|
thread_id="thread:abc:123",
|
|
metadata=custom_meta,
|
|
)
|
|
assert chunk_id # non-empty string
|
|
|
|
results = ks.retrieve("action items", top_k=5)
|
|
assert len(results) >= 1
|
|
meta = results[0].metadata
|
|
assert meta.get("thread_id") == "thread:abc:123"
|
|
# Custom metadata should also be available
|
|
assert "labels" in meta or meta.get("metadata", {}).get("labels") is not None
|
|
|
|
|
|
def test_store_preserves_url(ks: KnowledgeStore) -> None:
|
|
"""URL field survives the store/retrieve round-trip."""
|
|
url = "https://mail.google.com/mail/u/0/#inbox/abc123"
|
|
_store(
|
|
ks,
|
|
content="Email with a specific URL for testing",
|
|
source="gmail",
|
|
doc_type="email",
|
|
url=url,
|
|
)
|
|
results = ks.retrieve("specific URL testing", top_k=5)
|
|
assert len(results) >= 1
|
|
assert results[0].metadata.get("url") == url
|
|
|
|
|
|
def test_retrieve_empty_store(ks: KnowledgeStore) -> None:
|
|
"""Querying an empty store returns an empty list (no crash)."""
|
|
results = ks.retrieve("anything", top_k=5)
|
|
assert results == []
|
|
|
|
|
|
def test_retrieve_top_k_respected(ks: KnowledgeStore) -> None:
|
|
"""top_k limits the number of returned results."""
|
|
for i in range(10):
|
|
_store(
|
|
ks,
|
|
content=f"Research document number {i} about topic X",
|
|
source="notes",
|
|
)
|
|
|
|
results = ks.retrieve("research topic", top_k=3)
|
|
assert len(results) <= 3
|
|
|
|
|
|
def test_store_returns_unique_chunk_ids(ks: KnowledgeStore) -> None:
|
|
"""Each store() call returns a unique chunk id."""
|
|
id1 = _store(ks, content="First chunk")
|
|
id2 = _store(ks, content="Second chunk")
|
|
assert id1 != id2
|
|
|
|
|
|
def test_retrieve_filter_by_until(ks: KnowledgeStore) -> None:
|
|
"""retrieve() with until= excludes newer documents."""
|
|
old_ts = datetime(2023, 6, 1, tzinfo=timezone.utc)
|
|
new_ts = datetime(2025, 6, 1, tzinfo=timezone.utc)
|
|
cutoff = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
|
|
_store(
|
|
ks,
|
|
content="Old quarterly report analysis",
|
|
source="notes",
|
|
timestamp=old_ts,
|
|
)
|
|
_store(
|
|
ks,
|
|
content="New quarterly report analysis",
|
|
source="notes",
|
|
timestamp=new_ts,
|
|
)
|
|
|
|
results = ks.retrieve("quarterly report", top_k=10, until=cutoff)
|
|
for r in results:
|
|
ts_str = r.metadata.get("timestamp", "")
|
|
assert ts_str <= cutoff.isoformat(), f"Got new doc in results: {ts_str}"
|
|
|
|
assert len(results) >= 1
|
|
|
|
|
|
def test_memory_store_event_emitted(tmp_path: Path) -> None:
|
|
"""MEMORY_STORE event is published on store()."""
|
|
from openjarvis.core.events import EventType, get_event_bus, reset_event_bus
|
|
|
|
reset_event_bus()
|
|
bus = get_event_bus(record_history=True)
|
|
ks = KnowledgeStore(db_path=tmp_path / "events.db")
|
|
|
|
_store(ks, content="Event emission test content", source="test")
|
|
|
|
types = [e.event_type for e in bus.history]
|
|
assert EventType.MEMORY_STORE in types
|
|
|
|
|
|
def test_memory_retrieve_event_emitted(tmp_path: Path) -> None:
|
|
"""MEMORY_RETRIEVE event is published on retrieve()."""
|
|
from openjarvis.core.events import EventType, get_event_bus, reset_event_bus
|
|
|
|
reset_event_bus()
|
|
bus = get_event_bus(record_history=True)
|
|
ks = KnowledgeStore(db_path=tmp_path / "events2.db")
|
|
|
|
_store(ks, content="Some content to retrieve", source="test")
|
|
ks.retrieve("some content", top_k=5)
|
|
|
|
types = [e.event_type for e in bus.history]
|
|
assert EventType.MEMORY_RETRIEVE in types
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# v1 schema tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_v1_columns_round_trip_via_metadata(ks: KnowledgeStore) -> None:
|
|
"""source_id, channel, content_hash, embedding_model_version, participants_raw
|
|
survive the store/retrieve round-trip via the metadata payload."""
|
|
_store(
|
|
ks,
|
|
content="Email about partnership review",
|
|
source="gmail",
|
|
source_id="msg-abc123",
|
|
channel="INBOX",
|
|
content_hash="deadbeefcafe",
|
|
embedding_model_version="text-embedding-3-small/v1",
|
|
participants_raw=["Alice Bose <alice@example.com>"],
|
|
)
|
|
results = ks.retrieve("partnership review", top_k=1)
|
|
assert len(results) == 1
|
|
meta = results[0].metadata
|
|
assert meta.get("source_id") == "msg-abc123"
|
|
assert meta.get("channel") == "INBOX"
|
|
assert meta.get("content_hash") == "deadbeefcafe"
|
|
assert meta.get("embedding_model_version") == "text-embedding-3-small/v1"
|
|
assert meta.get("participants_raw") == ["Alice Bose <alice@example.com>"]
|
|
|
|
|
|
def test_deleted_at_filters_retrieve(ks: KnowledgeStore) -> None:
|
|
"""Rows with non-NULL deleted_at are excluded from retrieve()."""
|
|
import time as _time
|
|
|
|
_store(ks, content="Live document about quarterly research")
|
|
_store(ks, content="Tombstoned document about quarterly research")
|
|
|
|
ks._conn.execute(
|
|
"UPDATE knowledge_chunks SET deleted_at = ? "
|
|
"WHERE content LIKE 'Tombstoned%'",
|
|
(_time.time(),),
|
|
)
|
|
ks._conn.commit()
|
|
|
|
results = ks.retrieve("quarterly research", top_k=10)
|
|
assert len(results) == 1
|
|
assert results[0].content.startswith("Live")
|
|
|
|
|
|
def test_unique_natural_key_constraint(ks: KnowledgeStore) -> None:
|
|
"""Duplicate (source, source_id, chunk_index) is silently skipped.
|
|
|
|
The store uses ``INSERT OR IGNORE`` so re-running a sync or replaying a
|
|
dogfood script over an already-populated store no longer crashes; the
|
|
original row stays put and the duplicate is dropped.
|
|
"""
|
|
first_id = _store(
|
|
ks,
|
|
content="First copy",
|
|
source="gmail",
|
|
source_id="msg-unique-1",
|
|
chunk_index=0,
|
|
)
|
|
second_id = _store(
|
|
ks,
|
|
content="Duplicate copy",
|
|
source="gmail",
|
|
source_id="msg-unique-1",
|
|
chunk_index=0,
|
|
)
|
|
# Same identity — the natural key collision returned the existing row's id.
|
|
assert second_id == first_id
|
|
# Content of the original row is preserved.
|
|
row = ks._conn.execute(
|
|
"SELECT content FROM knowledge_chunks WHERE id = ?", (first_id,)
|
|
).fetchone()
|
|
assert row["content"] == "First copy"
|
|
# Only one row exists for this natural key.
|
|
count = ks._conn.execute(
|
|
"SELECT COUNT(*) FROM knowledge_chunks "
|
|
"WHERE source = 'gmail' AND source_id = 'msg-unique-1' AND chunk_index = 0"
|
|
).fetchone()[0]
|
|
assert count == 1
|
|
|
|
|
|
def test_unique_constraint_skipped_when_source_id_empty(ks: KnowledgeStore) -> None:
|
|
"""Legacy rows with empty source_id can co-exist (partial index excludes them)."""
|
|
_store(ks, content="Legacy doc one", source="legacy", source_id="")
|
|
# Should not raise — partial index is WHERE source_id != ''
|
|
_store(ks, content="Legacy doc two", source="legacy", source_id="")
|
|
assert ks.count() == 2
|
|
|
|
|
|
def test_embedding_blob_round_trip(ks: KnowledgeStore) -> None:
|
|
"""A bytes embedding payload survives storage and reads back unchanged."""
|
|
payload = b"\x00\x01\x02\x03\x04\xff\xfe\xfd"
|
|
_store(
|
|
ks,
|
|
content="Vector-bearing document for embedding round trip",
|
|
source="test",
|
|
embedding=payload,
|
|
)
|
|
row = ks._conn.execute(
|
|
"SELECT embedding FROM knowledge_chunks LIMIT 1"
|
|
).fetchone()
|
|
assert bytes(row[0]) == payload
|
|
|
|
|
|
def test_context_manager_closes_connection(tmp_path: Path) -> None:
|
|
"""Used as a context manager, KnowledgeStore closes its connection on exit."""
|
|
import sqlite3
|
|
|
|
with KnowledgeStore(db_path=tmp_path / "ctx.db") as ks:
|
|
_store(ks, content="inside with", source="test")
|
|
|
|
with pytest.raises(sqlite3.ProgrammingError):
|
|
ks._conn.execute("SELECT 1")
|
|
|
|
|
|
def test_context_manager_closes_on_exception(tmp_path: Path) -> None:
|
|
"""The connection is closed even when the with block raises."""
|
|
import sqlite3
|
|
|
|
ks = KnowledgeStore(db_path=tmp_path / "ctx_exc.db")
|
|
with pytest.raises(RuntimeError):
|
|
with ks:
|
|
raise RuntimeError("boom")
|
|
|
|
with pytest.raises(sqlite3.ProgrammingError):
|
|
ks._conn.execute("SELECT 1")
|