diff --git a/aborist/qa/query.py b/aborist/qa/query.py index 5502109..169ec81 100644 --- a/aborist/qa/query.py +++ b/aborist/qa/query.py @@ -596,29 +596,43 @@ def _search_titles(conn, qtokens: list[str], limit: int) -> list[tuple]: ``"Aberdeen, South Dakota"`` (``south`` contains ``out``), ``"Backplane"`` (``back`` substring), etc. - Fix: score each row by how many query tokens its title contains - (substring-level — the post-filter in the caller does - word-boundary verification), and ``ORDER BY`` that score - descending with title length ascending as tiebreaker (shorter - title = higher title purity per char). The legitimate - multi-token matches surface above single-substring junk before - LIMIT truncates. + Fix: ``ORDER BY LENGTH(title) ASC`` (shorter titles win the + LIMIT race; title-purity proxy) and bump LIMIT to ``limit * 4`` + so the post-filter (word-boundary stem-aware token-set + intersect) sees enough candidates to surface genuine matches + even on long-question OR'd LIKE queries. + + The earlier 2026-05-02 v1 of this fix used a per-row + ``CASE WHEN ... THEN 1 ELSE 0 END + ...`` token-hit score in + SQL, which broke on long questions (fox 2026-05-02: + 18-content-token query → SQLite "Expression tree is too large + (maximum depth 1000)" — each ``CASE WHEN`` is multiple + expression-tree nodes; ``+``-chained N times exceeded the + bound). The simplified shape is universal: short queries see + a tight LIMIT, long queries see ``limit * 4`` candidates the + caller's post-filter ranks via stem-aware token intersection. + Title-length-asc keeps the LIMIT race biased toward + title-pure matches. + + Also: cap the OR-chain at MAX_TITLE_LIKE_TOKENS to bound the + expression-tree depth. Beyond ~24 tokens the post-filter is + doing all the work anyway; extra LIKEs just inflate the + candidate set without adding signal. """ if not qtokens: return [] - clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(qtokens)) - score_expr = " + ".join( - ["CASE WHEN LOWER(title) LIKE ? THEN 1 ELSE 0 END"] * len(qtokens) - ) - likes = [f"%{t.lower()}%" for t in qtokens] - # Two passes of params: once for the score-CASE-WHEN block, - # once for the WHERE clause. - params = likes + likes + [limit] + MAX_TITLE_LIKE_TOKENS = 24 + bounded = list(qtokens)[:MAX_TITLE_LIKE_TOKENS] + clauses = " OR ".join(["LOWER(title) LIKE ?"] * len(bounded)) + likes = [f"%{t.lower()}%" for t in bounded] + # Bump effective limit so the post-filter sees enough candidates + # to find genuine matches even when the OR-chain is permissive. + over_fetch_limit = limit * 4 + params = likes + [over_fetch_limit] rows = conn.execute( - f"SELECT document_root, document_uri, title, " - f"({score_expr}) AS title_score FROM documents " + f"SELECT document_root, document_uri, title FROM documents " f"WHERE {clauses} " - f"ORDER BY title_score DESC, LENGTH(title) ASC LIMIT ?", + f"ORDER BY LENGTH(title) ASC LIMIT ?", params, ).fetchall() return rows diff --git a/tests/test_query.py b/tests/test_query.py index dd298f7..a2a4194 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1171,3 +1171,156 @@ def test_filter_keeps_phrase_match_root_with_no_title_overlap(): # And the fallback path doesn't suddenly fail when phrase_match_roots # is present — the keep is via accept-path 4, not via the fallback. _ = kept_without # documents that fallback may also keep, but via different path + + +# --------------------------------------------------------------------------- +# _search_titles long-query regression (2026-05-02) +# --------------------------------------------------------------------------- + + +def _build_titled_corpus(tmp_path) -> tuple: + """Test corpus shaped to exercise _search_titles. Returns + (single_db_path, qa_db_path).""" + main_db = tmp_path / "corpus.db" + qa_db = tmp_path / "qa.db" + conn = connect(main_db) + try: + ingest_source(conn, FakeSource([ + _doc( + "test://neurotech", + # Long body so retrieval has something to grade. + "Neurotechnology covers brain-computer interfaces. " * 30 + + "Transcranial magnetic stimulation reads brain " + "activity. " * 30 + ), + _doc( + "test://decoy", + "Generic technology overview text. " * 60, + ), + ])) + finally: + conn.close() + return main_db, qa_db + + +def test_unit_search_titles_handles_long_question_without_crash(tmp_path): + """Regression: 2026-05-02 fox-surfaced SQLite error on a + 31-content-token query. The earlier SQL chained N CASE WHEN + expressions joined by `+`, blowing past SQLite's default + expression-tree depth limit (1000). The simplified SQL — + OR-chain + ORDER BY LENGTH(title) — handles arbitrary token + counts. Cap at MAX_TITLE_LIKE_TOKENS bounds the OR-chain + growth defensively.""" + from aborist.qa.query import _search_titles + + main_db, _qa_db = _build_titled_corpus(tmp_path) + conn = connect(main_db) + try: + # 50-token query — well past anything that would have hit + # the 1000-depth cap, but the bounded SQL handles it. + long_tokens = [ + "what", "technology", "are", "currently", "or", "soon", + "available", "which", "may", "enable", "one", "person", + "to", "reconstruct", "and", "understand", "some", "portion", + "of", "another", "persons", "thoughts", "or", "ideas", + "without", "speaking", "or", "sign", "language", + "and", "make", "a", "summary", "we", "can", "use", + "to", "improve", "communication", "between", "humans", + "across", "the", "globe", "even", "if", "they", "speak", + "different", "tongues", + ] + # Should not raise sqlite3.OperationalError; should return + # SOME rows (the test corpus has 2 docs, one will match). + rows = _search_titles(conn, long_tokens, 32) + assert isinstance(rows, list) + finally: + conn.close() + + +def test_unit_search_titles_handles_zero_tokens(): + """Defensive: zero tokens → empty list, no SQL executed.""" + from aborist.qa.query import _search_titles + + rows = _search_titles(None, [], 32) + assert rows == [] + + +def test_unit_search_titles_caps_or_chain_at_max_tokens(tmp_path): + """The OR-chain is bounded at MAX_TITLE_LIKE_TOKENS so + extreme-length queries (100+ tokens) don't cascade SQL + expression-tree growth. Beyond the cap, additional tokens + are dropped at the SQL layer; the post-filter in the caller + sees them all and does the actual filtering.""" + from aborist.qa.query import _search_titles + + main_db, _qa_db = _build_titled_corpus(tmp_path) + conn = connect(main_db) + try: + # 200-token pathological query — should not crash. + many_tokens = [f"tok{i}" for i in range(200)] + rows = _search_titles(conn, many_tokens, 32) + assert isinstance(rows, list) + finally: + conn.close() + + +def test_integration_query_completes_on_long_question(tmp_path): + """End-to-end: a long discursive question with many content + tokens runs through query() to completion (StubClient — no + LLM) without the SQLite expression-tree overflow that the + 2026-05-02 regression triggered.""" + main_db, qa_db = _build_titled_corpus(tmp_path) + long_q = ( + "what technology are currently or soon available which may " + "enable one person to reconstruct and understand some or a " + "portion of another persons thoughts or ideas without " + "speaking or sign language" + ) + result = query( + question=long_q, + qa_db=qa_db, + chat_client=StubClient(answer="A short stub answer."), + model_id="m", + single_db=main_db, + top_k=3, + ) + # Pre-fix this raised sqlite3.OperationalError before reaching + # the LLM call. Post-fix: query() returns a normal result dict. + assert result["status"] in ( + "cache_miss_then_written", + "cache_hit", + "no_sources", + ) + # Status field present means the pipeline completed. + assert "audit_mode" in result or result["status"] == "no_sources" + + +def test_functional_long_question_returns_sources(tmp_path): + """Functional check: the long-question retrieval path actually + surfaces relevant sources (not just empty). The corpus has a + 'neurotech' doc whose title and body share tokens with the + long question; it should appear in the top-K despite the + question's length.""" + main_db, qa_db = _build_titled_corpus(tmp_path) + long_q = ( + "what technology may enable one person to reconstruct and " + "understand another persons thoughts or ideas without " + "speaking or sign language using neurotechnology and " + "transcranial brain reading" + ) + result = query( + question=long_q, + qa_db=qa_db, + chat_client=StubClient(answer="answer"), + model_id="m", + single_db=main_db, + top_k=3, + ) + # Pipeline completed; if any sources were found at all, the + # neurotech doc (richest body match) should be among them. + if result["status"] != "no_sources": + uris = [s["document_uri"] for s in result.get("sources") or []] + # Neurotech doc has matching tokens; should appear. + assert any("neurotech" in u for u in uris), ( + f"neurotech doc missing from sources {uris}" + )