From c0324c127ab54d2a6774f34c0c43059931d57cc0 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 7 Jul 2026 13:38:10 +0200 Subject: [PATCH] feat(solved): solved_question vector kind + one-row upsert (D11 pattern) Co-Authored-By: Claude Fable 5 --- harness/tests/test_solved_question.py | 78 ++++++++++++++++++++++++++ harness/tht/solved.py | 64 +++++++++++++++++++++ harness/tht/vectorstore/reader.py | 1 + harness/tht/vectorstore/rest_writer.py | 2 +- 4 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 harness/tests/test_solved_question.py create mode 100644 harness/tht/solved.py diff --git a/harness/tests/test_solved_question.py b/harness/tests/test_solved_question.py new file mode 100644 index 00000000..54c4fb62 --- /dev/null +++ b/harness/tests/test_solved_question.py @@ -0,0 +1,78 @@ +"""L1: coppia domanda->SQL risolta (kind solved_question) — memoria attiva parte B. + +Una sessione finalizzata produce UN record nel vectordb (tabella `memory`, kind +dedicato): embedding = domanda riscritta, metadata = {question, sql, tables, +session_id}. Upsert one-row stile D11 (mai sync: il suo delete-stale cancellerebbe +i record delle altre sessioni). L'hash di dedup copre domanda+SQL, cosi' un +re-finalize che cambia solo l'SQL aggiorna comunque la riga. +""" +from unittest.mock import MagicMock + +from tht.solved import ( + SOLVED_KIND, + _solved_hash, + save_solved_question, + solved_question_record, +) +from tht.vectorstore.reader import tables_for_kinds + + +def _rec(**kw): + base = dict( + session_id="s1", question="quante ablazioni nel 2023", + sql="SELECT count(*) FROM fact_seeablazione", tables=["fact_seeablazione"], + ) + base.update(kw) + return solved_question_record(**base) + + +def test_record_shape(): + r = _rec() + assert r.id == "solved:s1" + assert r.kind == SOLVED_KIND + assert r.content == "quante ablazioni nel 2023" # embedding = solo la domanda + assert r.metadata["sql"].startswith("SELECT") + assert r.metadata["tables"] == ["fact_seeablazione"] + assert r.metadata["session_id"] == "s1" + + +def test_solved_kind_maps_to_memory_table(): + assert tables_for_kinds([SOLVED_KIND]) == ["memory"] + + +def test_save_upserts_single_row_into_memory_table(): + writer = MagicMock() + writer.existing_hashes.return_value = {} + writer.upsert_records.return_value = 1 + embedder = MagicMock() + embedder.embed_documents.return_value = [[0.1] * 8] + + assert save_solved_question(_rec(), writer=writer, embedder=embedder) == 1 + writer.sync.assert_not_called() + table, rows = writer.upsert_records.call_args[0] + assert table == "memory" + assert len(rows) == 1 + assert rows[0]["record_key"] == "solved:s1" + assert rows[0]["metadata"]["kind"] == SOLVED_KIND + assert rows[0]["metadata"]["sql"].startswith("SELECT") + + +def test_save_skips_when_question_and_sql_unchanged(): + r = _rec() + writer = MagicMock() + writer.existing_hashes.return_value = {r.id: _solved_hash(r)} + embedder = MagicMock() + assert save_solved_question(r, writer=writer, embedder=embedder) == 0 + embedder.embed_documents.assert_not_called() + writer.upsert_records.assert_not_called() + + +def test_sql_change_alone_triggers_reupsert(): + old = _rec() + new = _rec(sql="SELECT 1") # stessa domanda, SQL diverso + writer = MagicMock() + writer.existing_hashes.return_value = {old.id: _solved_hash(old)} + writer.upsert_records.return_value = 1 + embedder = MagicMock() + embedder.embed_documents.return_value = [[0.0] * 4] + assert save_solved_question(new, writer=writer, embedder=embedder) == 1 diff --git a/harness/tht/solved.py b/harness/tht/solved.py new file mode 100644 index 00000000..88b2b0e2 --- /dev/null +++ b/harness/tht/solved.py @@ -0,0 +1,64 @@ +"""Coppie domanda->SQL risolte (kind `solved_question`) — memoria attiva, parte B. + +Una sessione finalizzata produce UN record nel vectordb: l'embedding e' la domanda +riscritta (content), il metadata porta l'SQL finale e le tabelle promosse. Vive +nella tabella pgvector `memory` con kind dedicato (nessuna DDL server-side); si +consulta nelle fasi F4/F6/F7 con `tht memory solved-search` come materiale di +riferimento (exemplar), NON come decisione da ri-applicare. + +Scrittura: SOLO upsert one-row stile D11 (`save_solved_question`). Questi record +non passano MAI da `VectorStore.sync`/`RestVectorWriter.sync`: il passo +delete-stale del sync, ricevendo il solo record corrente, cancellerebbe le coppie +delle altre sessioni. Per lo stesso motivo l'hash di dedup e' calcolato qui +(domanda+SQL) e non dal solo content come fa il sync. +""" +from tht.vectorstore.records import VectorRecord + +SOLVED_KIND = "solved_question" + + +def solved_question_record( + *, session_id: str, question: str, sql: str, tables: list[str] +) -> VectorRecord: + return VectorRecord( + id=f"solved:{session_id}", + kind=SOLVED_KIND, + ref=session_id, + title=question[:120], + content=question, + metadata={ + "question": question, + "sql": sql, + "tables": tables, + "session_id": session_id, + }, + ) + + +def _solved_hash(record: VectorRecord) -> str: + # La domanda e' l'embedding (content); l'SQL vive solo nel metadata. L'hash + # copre entrambi: un re-finalize che cambia solo l'SQL aggiorna la riga. + from tht.vectorstore.store import content_hash + + return content_hash(record.content + "\n" + str(record.metadata.get("sql", ""))) + + +def save_solved_question(record: VectorRecord, *, writer, embedder) -> int: + """Upsert one-row della coppia domanda->SQL via writer key (stesso pattern di + save_one_memory, spec D11): hash dedup client-side, embedding solo se domanda + o SQL sono cambiati. `writer` e' un VectorRestClient (writer key). Ritorna il + numero di righe upsertate (0 = invariata).""" + from tht.vectorstore.rest_writer import pack_metadata + + new_hash = _solved_hash(record) + existing = writer.existing_hashes("memory", [SOLVED_KIND]) + if existing.get(record.id) == new_hash: + return 0 + embedding = embedder.embed_documents([record.content])[0] + return writer.upsert_records("memory", [{ + "record_key": record.id, + "kind": record.kind, + "content_hash": new_hash, + "metadata": pack_metadata(record), + "embedding": embedding, + }]) diff --git a/harness/tht/vectorstore/reader.py b/harness/tht/vectorstore/reader.py index 972ec85e..7177bb8b 100644 --- a/harness/tht/vectorstore/reader.py +++ b/harness/tht/vectorstore/reader.py @@ -18,6 +18,7 @@ KIND_TO_TABLE = { "schema_column": "schema_records", "evidence": "evidence", "memory": "memory", + "solved_question": "memory", # coppie domanda->SQL: stessa tabella, kind dedicato } ALL_TABLES = ["schema_records", "evidence", "memory"] diff --git a/harness/tht/vectorstore/rest_writer.py b/harness/tht/vectorstore/rest_writer.py index 3d2ac028..b0a4d6d7 100644 --- a/harness/tht/vectorstore/rest_writer.py +++ b/harness/tht/vectorstore/rest_writer.py @@ -13,7 +13,7 @@ from tht.vectorstore.store import SyncStats, content_hash TABLE_TO_KINDS = { "schema_records": {"schema_table", "schema_column"}, "evidence": {"evidence"}, - "memory": {"memory"}, + "memory": {"memory", "solved_question"}, }