diff --git a/scripts/odysseus-docs b/scripts/odysseus-docs index 26802bf5e..2ad55e56d 100755 --- a/scripts/odysseus-docs +++ b/scripts/odysseus-docs @@ -27,6 +27,7 @@ from pathlib import Path try: from core.database import SessionLocal, Document, DocumentVersion + from src.session_search import escape_like_pattern quiet_logs() except ModuleNotFoundError as e: sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n") @@ -139,10 +140,17 @@ def cmd_export(args): def cmd_search(args): db = SessionLocal() try: - like = f"%{args.query}%" - rows = db.query(Document).filter( - (Document.title.ilike(like)) | (Document.current_content.ilike(like)) - ).order_by(Document.updated_at.desc()).limit(args.limit).all() + # Per-term AND search: a single `%foo bar%` LIKE only matched the + # exact phrase, so "machine learning" missed docs containing both + # words apart. LIKE wildcards are escaped per token. + q = db.query(Document) + for tok in args.query.split(): + term = f"%{escape_like_pattern(tok)}%" + q = q.filter( + Document.title.ilike(term, escape="\\") + | Document.current_content.ilike(term, escape="\\") + ) + rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all() emit([_serialize(d) for d in rows], args) finally: db.close() diff --git a/tests/cli/test_docs_cli_content_length.py b/tests/cli/test_docs_cli_content_length.py index 962d17bc2..6c11d4b2e 100644 --- a/tests/cli/test_docs_cli_content_length.py +++ b/tests/cli/test_docs_cli_content_length.py @@ -3,7 +3,7 @@ from tests.helpers.db_stubs import make_core_db_stub def test_text_len_ignores_non_string_values(monkeypatch): - make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"]) + make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion", "ChatMessage", "Session"]) cli = load_script("odysseus-docs") assert cli._text_len("hello") == 5 diff --git a/tests/cli/test_docs_cli_search.py b/tests/cli/test_docs_cli_search.py new file mode 100644 index 000000000..60c0450dd --- /dev/null +++ b/tests/cli/test_docs_cli_search.py @@ -0,0 +1,103 @@ +"""Regression for #2123 (CLI part): `odysseus-docs search` must match +multi-word queries and treat % / _ literally. + +Old code built one ``%{query}%`` LIKE over title/current_content, so +"machine learning" only matched the exact adjacent phrase, and a literal +"%" matched every document. +""" + +import json +from types import SimpleNamespace + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import Document +from tests.helpers.cli_loader import load_script + + +def _cli_with_documents(monkeypatch, tmp_path): + engine = create_engine( + f"sqlite:///{tmp_path / 'docs_cli_search.db'}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, + ) + cdb.Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + db = session_factory() + try: + db.add_all( + [ + Document( + id="doc-multiword", + title="ML notes", + current_content="machine notes on learning", + ), + Document( + id="doc-percent", + title="Fabric", + current_content="100% cotton", + ), + Document( + id="doc-underscore", + title="sale_2024", + current_content="spring campaign", + ), + Document( + id="doc-backslash", + title="Path", + current_content=r"C:\photos\sunset", + ), + ] + ) + db.commit() + finally: + db.close() + + cli = load_script("odysseus-docs") + monkeypatch.setattr(cli, "SessionLocal", session_factory) + return cli + + +def _search_ids(cli, capsys, query): + cli.cmd_search(SimpleNamespace(query=query, limit=50, pretty=False)) + return {doc["id"] for doc in json.loads(capsys.readouterr().out)} + + +def test_search_matches_words_in_any_order(monkeypatch, tmp_path, capsys): + cli = _cli_with_documents(monkeypatch, tmp_path) + + assert _search_ids(cli, capsys, "machine learning") == {"doc-multiword"} + + +def test_search_escapes_percent_wildcard(monkeypatch, tmp_path, capsys): + cli = _cli_with_documents(monkeypatch, tmp_path) + + assert _search_ids(cli, capsys, "%") == {"doc-percent"} + + +def test_search_escapes_underscore_wildcard(monkeypatch, tmp_path, capsys): + cli = _cli_with_documents(monkeypatch, tmp_path) + + # Unescaped, "_" matches any single char so every document matches. + assert _search_ids(cli, capsys, "_") == {"doc-underscore"} + + +def test_search_escapes_backslash(monkeypatch, tmp_path, capsys): + cli = _cli_with_documents(monkeypatch, tmp_path) + + assert _search_ids(cli, capsys, "\\") == {"doc-backslash"} + + +def test_search_whitespace_query_returns_all(monkeypatch, tmp_path, capsys): + cli = _cli_with_documents(monkeypatch, tmp_path) + + assert _search_ids(cli, capsys, " ") == { + "doc-multiword", + "doc-percent", + "doc-underscore", + "doc-backslash", + }