mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-03 11:42:23 +01:00
fix(cli): multi-word and literal-wildcard search in odysseus-docs (#7007)
* fix(cli): multi-word and literal-wildcard search in odysseus-docs
odysseus-docs search wrapped the whole query in one `%{query}%` LIKE over title/current_content, so multi-word queries only matched the exact adjacent phrase and literal % / _ acted as SQL wildcards. Split on whitespace, AND per token, and escape LIKE wildcards with the shared escape_like_pattern() helper. Whitespace-only queries now match all documents, consistent with document-library search.
Fixes #2123.
Tests: tests/cli/test_docs_cli_search.py (5 tests; multi-word, %, _, backslash, whitespace); 4 fail pre-fix, 5 pass post-fix. tests/cli full suite: 82 passed.
* test(cli): name whitespace-query search test accurately
The test passes a whitespace-only query, not an empty one; the old name implied a different input. Behavior is unchanged.
This commit is contained in:
+12
-4
@@ -27,6 +27,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from core.database import SessionLocal, Document, DocumentVersion
|
from core.database import SessionLocal, Document, DocumentVersion
|
||||||
|
from src.session_search import escape_like_pattern
|
||||||
quiet_logs()
|
quiet_logs()
|
||||||
except ModuleNotFoundError as e:
|
except ModuleNotFoundError as e:
|
||||||
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
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):
|
def cmd_search(args):
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
like = f"%{args.query}%"
|
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||||
rows = db.query(Document).filter(
|
# exact phrase, so "machine learning" missed docs containing both
|
||||||
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
|
# words apart. LIKE wildcards are escaped per token.
|
||||||
).order_by(Document.updated_at.desc()).limit(args.limit).all()
|
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)
|
emit([_serialize(d) for d in rows], args)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from tests.helpers.db_stubs import make_core_db_stub
|
|||||||
|
|
||||||
|
|
||||||
def test_text_len_ignores_non_string_values(monkeypatch):
|
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")
|
cli = load_script("odysseus-docs")
|
||||||
|
|
||||||
assert cli._text_len("hello") == 5
|
assert cli._text_len("hello") == 5
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user