Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
19 changes: 11 additions & 8 deletions core/wren/src/wren/memory/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,16 @@ def index(
project_path = discover_project_path(explicit=None)

md_pairs = load_query_pairs(project_path)
if md_pairs:
# upsert → re-running index converges on the markdown content.
res = mem_store.load_queries(md_pairs, upsert=True)
res = mem_store.sync_markdown_queries(md_pairs)
if res["loaded"] or res["updated"] or res["forgotten"]:
typer.echo(
f"Indexed {res['loaded'] + res['updated']} pair(s) from "
f"knowledge/sql/.",
f"knowledge/sql/."
+ (
f" Forgot {res['forgotten']} stale pair(s)."
if res["forgotten"]
else ""
),
err=True,
)

Expand Down Expand Up @@ -647,13 +651,12 @@ def _reindex() -> None:
from wren.memory.markdown import load_query_pairs # noqa: PLC0415

md_pairs = load_query_pairs(project_path)
loaded = 0
if md_pairs:
res = mem_store.load_queries(md_pairs, upsert=True)
loaded = res["loaded"] + res["updated"]
res = mem_store.sync_markdown_queries(md_pairs)
loaded = res["loaded"] + res["updated"]
typer.echo(
f"Reindexed {result['schema_items']} schema item(s)"
+ (f", {loaded} pair(s)" if loaded else "")
+ (f", forgot {res['forgotten']} stale pair(s)" if res["forgotten"] else "")
+ "."
)

Expand Down
4 changes: 1 addition & 3 deletions core/wren/src/wren/memory/index_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ def store(self):

def rebuild(self) -> dict:
pairs = load_query_pairs(self._project)
if not pairs:
return {"backend": self.name, "loaded": 0, "updated": 0}
res = self._store.load_queries(pairs, upsert=True)
res = self._store.sync_markdown_queries(pairs)
return {"backend": self.name, **res}

def reset(self) -> None:
Expand Down
44 changes: 44 additions & 0 deletions core/wren/src/wren/memory/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,24 @@
_SCHEMA_TABLE = "schema_items"
_QUERY_TABLE = "query_history"

# query_history sources that are derived from the manifest, not from
# knowledge/sql/*.md, so a markdown sync must never forget them.
_NON_MARKDOWN_SOURCES = frozenset({"seed", "view"})


def _esc(value: str) -> str:
"""Escape single quotes for LanceDB where-clause literals."""
return value.replace("'", "''")


def _tag_source(tags: str | None) -> str:
"""Extract the ``source:`` value from a query_history tags string."""
for part in (tags or "").split():
if part.startswith("source:"):
return part[len("source:") :]
return "user"


def _schema_items_arrow_schema(dim: int = _DEFAULT_DIM) -> pa.Schema:
return pa.schema(
[
Expand Down Expand Up @@ -624,6 +636,38 @@ def load_queries(

return {"loaded": loaded, "skipped": skipped, "updated": 0}

def sync_markdown_queries(self, pairs: list[dict]) -> dict[str, int]:
"""Upsert *pairs* and forget any indexed pair absent from them.

*pairs* must be the complete current ``knowledge/sql/*.md`` set (the
source of truth). Mirrors the "stale" definition the ``check`` command
already reports (any source other than seed/view whose ``nl_query``
is no longer in the markdown) and acts on it, so a deletion or rename
actually clears out of the index instead of lingering and still being
recalled.

Returns ``{"loaded": N, "skipped": M, "updated": U, "forgotten": F}``.
"""
existing_rows, _ = self.list_queries(limit=1_000_000)
protected_nls = {
row["nl_query"]
for row in existing_rows
if _tag_source(row.get("tags")) in _NON_MARKDOWN_SOURCES
}
pairs = [p for p in pairs if p["nl"] not in protected_nls]

result = self.load_queries(pairs, upsert=True)
current_nls = {p["nl"] for p in pairs}
rows, _ = self.list_queries(limit=1_000_000)
stale_ids = [
row["_row_id"]
for row in rows
if _tag_source(row.get("tags")) not in _NON_MARKDOWN_SOURCES
and row["nl_query"] not in current_nls
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]
forgotten = self.forget_queries_by_ids(stale_ids) if stale_ids else 0
return {**result, "forgotten": forgotten}

# ── Housekeeping ──────────────────────────────────────────────────────

def status(self) -> dict:
Expand Down
157 changes: 157 additions & 0 deletions core/wren/tests/unit/test_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1477,6 +1477,163 @@ def test_load_query_pairs_index_is_convergent(self, memory_store, tmp_path):
second, _ = memory_store.list_queries(limit=100)
assert len(first) == len(second) == 2

def test_sync_forgets_pair_deleted_from_markdown(self, memory_store, tmp_path):
from wren.memory.markdown import ( # noqa: PLC0415
load_query_pairs,
write_query_markdown,
)

write_query_markdown(tmp_path, "Total revenue", "SELECT SUM(amount) FROM o")
write_query_markdown(tmp_path, "Count orders", "SELECT COUNT(*) FROM o")
memory_store.sync_markdown_queries(load_query_pairs(tmp_path))

(tmp_path / "knowledge" / "sql" / "count-orders.md").unlink()
result = memory_store.sync_markdown_queries(load_query_pairs(tmp_path))

assert result["forgotten"] == 1
remaining, total = memory_store.list_queries(limit=100)
assert total == 1
assert remaining[0]["nl_query"] == "Total revenue"
# the deleted example must no longer surface in recall either
hits = memory_store.recall_queries("count orders", limit=5)
assert all(h["nl_query"] != "Count orders" for h in hits)

def test_sync_preserves_seed_queries_absent_from_markdown(
self, memory_store, tmp_path
):
from wren.memory.markdown import ( # noqa: PLC0415
load_query_pairs,
write_query_markdown,
)

memory_store.index_schema(_MANIFEST) # generates seed queries
before, _ = memory_store.list_queries(limit=100, source="seed")
assert before # sanity: seeds exist

write_query_markdown(tmp_path, "Total revenue", "SELECT SUM(amount) FROM o")
memory_store.sync_markdown_queries(load_query_pairs(tmp_path))

after, _ = memory_store.list_queries(limit=100, source="seed")
assert len(after) == len(before) # seeds untouched by the markdown sync

def test_sync_preserves_seed_row_whose_nl_collides_with_markdown_pair(
self, memory_store, tmp_path
):
from wren.memory.markdown import ( # noqa: PLC0415
load_query_pairs,
write_query_markdown,
)

memory_store.store_query(
nl_query="Total revenue",
sql_query="SELECT SUM(o_totalprice) FROM orders",
tags="source:seed",
)
before, _ = memory_store.list_queries(limit=100, source="seed")
assert len(before) == 1

write_query_markdown(tmp_path, "Total revenue", "SELECT SUM(amount) FROM o")
result = memory_store.sync_markdown_queries(load_query_pairs(tmp_path))

after, _ = memory_store.list_queries(limit=100, source="seed")
assert len(after) == 1
assert after[0]["sql_query"] == "SELECT SUM(o_totalprice) FROM orders"
# the colliding markdown pair is skipped, not loaded as a second row
_, total = memory_store.list_queries(limit=100)
assert total == 1
assert result["loaded"] == 0

def test_sync_with_no_markdown_pairs_forgets_all_user_pairs(
self, memory_store, tmp_path
):
from wren.memory.markdown import ( # noqa: PLC0415
load_query_pairs,
write_query_markdown,
)

write_query_markdown(tmp_path, "Total revenue", "SELECT SUM(amount) FROM o")
memory_store.sync_markdown_queries(load_query_pairs(tmp_path))
(tmp_path / "knowledge" / "sql" / "total-revenue.md").unlink()

result = memory_store.sync_markdown_queries(load_query_pairs(tmp_path))
assert result["forgotten"] == 1
_, total = memory_store.list_queries(limit=100)
assert total == 0

def test_cli_index_lancedb_forgets_deleted_pair(self, tmp_path, monkeypatch):
"""`wren memory index` on the lancedb backend must actually forget a
pair deleted from knowledge/sql/*.md, not just report it as stale."""
pytest.importorskip("lancedb", reason="wren[memory] extras not installed")
pytest.importorskip(
"sentence_transformers", reason="wren[memory] extras not installed"
)
from typer.testing import CliRunner # noqa: PLC0415

from wren.cli import app # noqa: PLC0415
from wren.memory.markdown import write_query_markdown # noqa: PLC0415
from wren.memory.store import MemoryStore # noqa: PLC0415

monkeypatch.setenv("WREN_PROJECT_HOME", str(tmp_path))
monkeypatch.setenv("WREN_MEMORY_BACKEND", "lancedb")
(tmp_path / "target").mkdir()
(tmp_path / "target" / "mdl.json").write_text("{}", encoding="utf-8")
write_query_markdown(tmp_path, "Total revenue", "SELECT SUM(amount) FROM o")
write_query_markdown(tmp_path, "Count orders", "SELECT COUNT(*) FROM o")

cli = CliRunner()
first = cli.invoke(app, ["memory", "index"])
assert first.exit_code == 0, first.output

(tmp_path / "knowledge" / "sql" / "count-orders.md").unlink()
second = cli.invoke(app, ["memory", "index"])
assert second.exit_code == 0, second.output
assert "Forgot 1 stale pair" in second.output

store = MemoryStore(path=str(tmp_path / ".wren" / "memory"))
rows, total = store.list_queries(limit=100)
assert total == 1
assert rows[0]["nl_query"] == "Total revenue"

def test_cli_watch_reindex_on_start_lancedb_forgets_deleted_pair(
self, tmp_path, monkeypatch
):
"""`wren memory watch` calls the same reindex path as `index`, so a
deletion must clear on its next forced reindex too."""
pytest.importorskip("lancedb", reason="wren[memory] extras not installed")
pytest.importorskip(
"sentence_transformers", reason="wren[memory] extras not installed"
)
from typer.testing import CliRunner # noqa: PLC0415

from wren.cli import app # noqa: PLC0415
from wren.memory.markdown import write_query_markdown # noqa: PLC0415
from wren.memory.store import MemoryStore # noqa: PLC0415

monkeypatch.setenv("WREN_PROJECT_HOME", str(tmp_path))
monkeypatch.setenv("WREN_MEMORY_BACKEND", "lancedb")
(tmp_path / "wren_project.yml").write_text("name: t\n", encoding="utf-8")
(tmp_path / "target").mkdir()
(tmp_path / "target" / "mdl.json").write_text("{}", encoding="utf-8")
write_query_markdown(tmp_path, "Total revenue", "SELECT SUM(amount) FROM o")
write_query_markdown(tmp_path, "Count orders", "SELECT COUNT(*) FROM o")

cli = CliRunner()
first = cli.invoke(
app, ["memory", "watch", "--reindex-on-start", "--max-polls", "1"]
)
assert first.exit_code == 0, first.output

(tmp_path / "knowledge" / "sql" / "count-orders.md").unlink()
second = cli.invoke(
app, ["memory", "watch", "--reindex-on-start", "--max-polls", "1"]
)
assert second.exit_code == 0, second.output

store = MemoryStore(path=str(tmp_path / ".wren" / "memory"))
rows, total = store.list_queries(limit=100)
assert total == 1
assert rows[0]["nl_query"] == "Total revenue"

def test_reset_then_reindex_restores_from_markdown(self, memory_store, tmp_path):
from wren.memory.markdown import ( # noqa: PLC0415
load_query_pairs,
Expand Down
Loading