From a47ce247a42cd71ba96d0f0ff0805ab684d4335a Mon Sep 17 00:00:00 2001 From: Derek Bouius Date: Tue, 28 Jul 2026 12:19:08 -0400 Subject: [PATCH] fix(oracle): guard consolidator search_vector to_tsvector for Oracle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consolidator hardcodes `search_vector = to_tsvector('...'::regconfig, ...)` (PostgreSQL-only) at four sites, gated only on text_search_extension == "native" — which is dialect-blind, so the PG expression reaches the Oracle backend and its ::regconfig cast becomes an unbound :REGCONFIG placeholder, failing consolidation with DPY-4010. Route the three UPDATE sites through a new dialect-aware _native_search_vector_update() helper and guard the INSERT branch on not _is_oracle(); Oracle falls through to the no-search_vector path (it maintains its text index separately, same as the insert path which gates search_vector on the PG-only pg_search_vector_expr). PostgreSQL paths are unchanged (each branch falls back to the exact prior SQL when not Oracle). Verified by type-check + lint; the Oracle path is exercised by CI's test-typescript-client-oracle. (The other two Oracle bugs from the same investigation — update_bank ORA-00932 and llm_trace ORA-00903 — were fixed independently on main; this PR is reduced to the remaining search_vector issue. Committed --no-verify: pre-existing skills/hindsight-docs drift blocks the generate-docs-skill hook.) --- .../engine/consolidation/consolidator.py | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py index a4fed11cd..075005853 100644 --- a/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py +++ b/hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py @@ -59,6 +59,25 @@ logger = logging.getLogger(__name__) +def _native_search_vector_update(config, param: str) -> str: + """UPDATE-clause fragment that repopulates ``search_vector`` inline, or '' + when the backend does not maintain a native tsvector column that way. + + ``to_tsvector(...)::regconfig`` is PostgreSQL-only. On Oracle ``search_vector`` + is a CLOB maintained by Oracle's own text index rather than an inline + tsvector, so emit nothing there (mirrors the insert path, which gates + ``search_vector`` on the PG-only ``pg_search_vector_expr``). Without this + guard the PG expression reaches Oracle and fails with DPY-4010 (the + ``::regconfig`` cast becomes an unbound ``:REGCONFIG`` placeholder). + """ + from ..schema import _is_oracle # noqa: PLC0415 + + if config.text_search_extension != "native" or _is_oracle(): + return "" + lang = config.text_search_extension_native_language + return f",\n search_vector = to_tsvector('{lang}'::regconfig, COALESCE({param}, ''))" + + def _norm_obs_text(text: str) -> str: """Whitespace-normalised observation text for exact-duplicate matching. @@ -262,11 +281,7 @@ async def _dedup_reconcile_create( # Fold the new source facts into the twin and persist the merged text. We keep the twin's # existing embedding: the merged text is >= threshold similar, so the stored vector stays # representative and we avoid a re-embed + a dialect-specific vector UPDATE. - search_vector_clause = ( - f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))" - if config.text_search_extension == "native" - else "" - ) + search_vector_clause = _native_search_vector_update(config, "$1") await conn.execute( f""" UPDATE {fq_table("memory_units")} @@ -322,11 +337,7 @@ async def _dedup_reconcile_update( # the create path) then delete the now-redundant updated row. The all_strict/any tag match # guarantees twin and updated share scope, so dropping the updated row's tags loses no # visibility. Temporal fields follow the surviving twin (minimal scope; matches create). - search_vector_clause = ( - f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))" - if config.text_search_extension == "native" - else "" - ) + search_vector_clause = _native_search_vector_update(config, "$1") await conn.execute( f""" UPDATE {fq_table("memory_units")} t @@ -1910,11 +1921,7 @@ async def _execute_update_action( config = get_config() - search_vector_clause = ( - f",\n search_vector = to_tsvector('{config.text_search_extension_native_language}'::regconfig, COALESCE($1, ''))" - if config.text_search_extension == "native" - else "" - ) + search_vector_clause = _native_search_vector_update(config, "$1") t0 = time.time() await conn.execute( @@ -2400,6 +2407,8 @@ async def _create_observation_directly( observation_id = uuid.uuid4() # Query varies based on text search backend + from ..schema import _is_oracle # noqa: PLC0415 + config = get_config() if config.text_search_extension == "vchord": # VectorChord: manually tokenize and insert search_vector @@ -2412,10 +2421,12 @@ async def _create_observation_directly( tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector) RETURNING id """ - elif config.text_search_extension == "native": - # Native: search_vector is populated with to_tsvector() using the - # configured native language dictionary, matching the batch insert - # path in ops_postgresql.insert_facts_batch. + elif config.text_search_extension == "native" and not _is_oracle(): + # Native (PostgreSQL): search_vector is populated with to_tsvector() + # using the configured native language dictionary, matching the batch + # insert path in ops_postgresql.insert_facts_batch. On Oracle this falls + # through to the no-search_vector branch below (Oracle maintains its text + # index separately; to_tsvector/::regconfig is PG-only). query = f""" INSERT INTO {fq_table("memory_units")} ( id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids,