"""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 nel semantic store workspace-scoped, nel gruppo logico `memory` con kind dedicato; 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 (`index_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 dataclasses import dataclass from tht.phase import effective_decisions from tht.ports.vector import VectorWriteRecord from tht.session.models import SessionSnapshot from tht.vectorstore.records import VectorRecord from tht.vectorstore.store import content_hash from .core import question_context 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. return content_hash(record.content + "\n" + str(record.metadata.get("sql", ""))) def _save_solved_question(record: VectorRecord, *, store, 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. Ritorna il numero di righe upsertate (0 = invariata).""" new_hash = _solved_hash(record) existing = store.existing_hashes("memory", [SOLVED_KIND]) if existing.get(record.id) == new_hash: return 0 embedding = embedder.embed_documents([record.content])[0] return store.upsert( "memory", [VectorWriteRecord(record=record, embedding=embedding, content_hash=new_hash)], ) class SolvedIndexError(Exception): """La sessione non ha (ancora) gli artefatti per il record solved_question.""" def _build_solved_snapshot( snapshot: SessionSnapshot, promoted_tables: set[str] | None, ) -> VectorRecord: sql = snapshot.artifacts.get("sql_final") if sql is None: raise SolvedIndexError("sql_final.sql assente") decisions = effective_decisions(snapshot) if not any(d.type == "sql_approved" for d in decisions): raise SolvedIndexError("decisione sql_approved assente") return _solved_question_record( session_id=snapshot.manifest.id, question=question_context(decisions, snapshot.manifest), sql=sql.strip(), tables=sorted(promoted_tables or set()), ) def index_solved_question( snapshot: SessionSnapshot, promoted_tables: set[str] | None, *, store, embedder, ) -> int: """Index the finalized question through Memory's one-row, idempotent policy.""" return _save_solved_question( _build_solved_snapshot(snapshot, promoted_tables), store=store, embedder=embedder, ) @dataclass(frozen=True) class SolvedIndexOutcome: upserted: int | None error: str | None = None def index_solved_question_best_effort( snapshot: SessionSnapshot, promoted_tables: set[str] | None, *, store_factory, embedder_factory, ) -> SolvedIndexOutcome: """Attempt derived indexing without turning it into workflow success state.""" try: upserted = index_solved_question( snapshot, promoted_tables, store=store_factory(), embedder=embedder_factory(), ) except Exception as error: # noqa: BLE001 - callers receive a best-effort outcome return SolvedIndexOutcome(upserted=None, error=str(error)) return SolvedIndexOutcome(upserted=upserted) def search_solved_questions(question: str, *, searcher, embedder, top: int = 3) -> list[dict]: """Return solved-question exemplars in semantic-search rank order.""" hits = searcher.search( embedder.embed_query(question), top_n=top, kinds=[SOLVED_KIND], ) return [ { "session_id": hit.metadata.get("session_id", hit.ref), "question": hit.metadata.get("question", hit.content), "sql": hit.metadata.get("sql", ""), "tables": hit.metadata.get("tables", []), "score": round(hit.similarity, 4), } for hit in hits ]