fix: keep embedded session controls visible and handle empty memory
Publish documentation / publish (push) Successful in 34s

Cap the embedded shell at its portal container height so steering and stop controls remain accessible. Skip vector retrieval for an empty authoritative Memory archive and compute SQL-rule embeddings lazily.

Validated with 54 Memory tests, 90 frontend tests, five browser scenarios, frontend and Docker builds, and a read-only comparison against the real empty Memory archive.
This commit is contained in:
User
2026-09-14 17:15:00 +02:00
parent 49333a2d35
commit d6cdffea62
8 changed files with 241 additions and 4 deletions
@@ -1067,3 +1067,36 @@ def test_hybrid_recall_with_configured_real_embedding_and_isolated_stores(databa
scope=scope)} == {rule, general, parent}
finally:
s.close()
def test_empty_archive_rules_skip_unavailable_embedding_and_vectors(service, monkeypatch):
import json
from types import SimpleNamespace
from typer.testing import CliRunner
from tht.cli import app, memory_cmd, vector_cmd
from tht.ports.vector import VectorStoreError
from tht.vectorstore.embeddings import EmbeddingsError
s, _ = service
snapshot = review_snapshot(s)
snapshot.decisions[:] = [d for d in snapshot.decisions
if not (d.type == "phase_approved" and int(d.subject.split(":")[1]) > 3)]
def unavailable_embedding(_):
raise EmbeddingsError("Embedding unavailable")
def unavailable_search(*args, **kwargs):
raise VectorStoreError("BM25 collection configuration mismatch")
cfg = SimpleNamespace(database=SimpleNamespace(database="dwh", db_schema="sales"), embeddings=None)
monkeypatch.setattr(memory_cmd, "_load_config_or_exit", lambda _: cfg)
monkeypatch.setattr(memory_cmd, "memory_service", lambda _: s)
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda *_: snapshot)
monkeypatch.setattr(s, "close", lambda: None)
monkeypatch.setattr(vector_cmd, "open_searcher", lambda _: SimpleNamespace(search=unavailable_search))
monkeypatch.setattr(vector_cmd, "make_embedder", lambda _: SimpleNamespace(embed_query=unavailable_embedding))
result = CliRunner().invoke(app, ["memory", "rules", "Order", "--session", snapshot.manifest.id, "--json"])
assert result.exit_code == 0, result.output
assert json.loads(result.output) == []
+41 -1
View File
@@ -157,7 +157,7 @@ def test_recall_cli_reconstructs_applied_and_rejected_memory_from_persisted_f2_s
cards = {r.id: Card(id=r.id, family="domain_clarification", subject=r.subject,
detail=r.detail, scope="psd-clinical", workspace_id="psd-clinical", origin="workflow",
created_at=r.ts, updated_at=r.ts, revision="r", indexed=True) for r in records}
archive = SimpleNamespace(list=lambda query: {}, get=lambda identity: cards[identity],
archive = SimpleNamespace(list=lambda query: {"total": len(cards)}, get=lambda identity: cards[identity],
close=lambda: None)
archive.operation = lambda: nullcontext(archive)
service = MemoryService(archive, PrincipalContext(issuer="local", subject="reviewer"),
@@ -179,3 +179,43 @@ def test_recall_cli_reconstructs_applied_and_rejected_memory_from_persisted_f2_s
assert current_phase(repository.get(session_id)) == 2
assert embedder.questions == ["active patients"]
assert searcher.calls == [([0.1, 0.2], 20, ["memory"])]
def test_empty_archive_recall_does_not_require_vector_projection(tmp_path, monkeypatch):
"""A fresh installation can have an empty dense-only Memory collection."""
from tht.ports.vector import VectorStoreError
class UnavailableSearcher:
def search(self, *args, **kwargs):
raise VectorStoreError("BM25 collection configuration mismatch")
archive = SimpleNamespace(list=lambda query: {"items": [], "total": 0}, close=lambda: None)
service = MemoryService(archive, PrincipalContext(issuer="local", subject="reviewer"),
store_factory=lambda: None, embedder_factory=Embedder)
embedder = Embedder()
monkeypatch.setattr("tht.cli.memory_cmd.memory_service", lambda cfg: service)
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda cfg: UnavailableSearcher())
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda cfg: embedder)
for command in ("search", "solved-search"):
response = CliRunner().invoke(app, ["memory", command, "active patients", "--json",
"-c", str(_workspace_config(tmp_path))])
assert response.exit_code == 0, response.output
assert json.loads(response.stdout) == []
assert embedder.questions == []
def test_recall_does_not_hide_an_unavailable_authoritative_archive():
import pytest
from tht.memory.models import MemoryUnavailable
def unavailable(query):
raise MemoryUnavailable("Memory archive is unavailable")
archive = SimpleNamespace(list=unavailable)
service = MemoryService(archive, PrincipalContext(issuer="local", subject="reviewer"),
store_factory=lambda: None, embedder_factory=Embedder)
embedder = Embedder()
with pytest.raises(MemoryUnavailable):
service.recall("active patients", searcher=Searcher([]), embedder=embedder)
assert embedder.questions == []
+3 -2
View File
@@ -296,6 +296,7 @@ def rules_cmd(question: str, session: str = typer.Option(..., "--session"),
filters: str = typer.Option("{}", "--filters"),
json_out: bool = typer.Option(False, "--json"), config: Path = CONFIG_OPT):
"""Consult SQL rules and explained errors in schema linking and SQL construction."""
from functools import cache
from types import SimpleNamespace
from tht.cli.vector_cmd import make_embedder, open_searcher
@@ -307,8 +308,8 @@ def rules_cmd(question: str, session: str = typer.Option(..., "--session"),
if current_phase(snapshot) not in {4, 6, 7}:
raise ValueError("Memory rules are consulted in schema linking or SQL construction")
scope = _recall_scope(cfg, filters)
vector = make_embedder(cfg.embeddings).embed_query(question)
embedder = SimpleNamespace(embed_query=lambda _: vector)
# Share one vector across both families, but only when the archive has cards.
embedder = SimpleNamespace(embed_query=cache(make_embedder(cfg.embeddings).embed_query))
searcher = open_searcher(cfg)
candidates = []
for family in ("sql_rule", "explained_error"):
+4 -1
View File
@@ -129,7 +129,10 @@ class MemoryService:
if not question.strip():
raise ValueError("Recall question must not be empty")
scope = scope or RecallScope()
self.repository.list(CardQuery(page_size=1))
# PostgreSQL is authoritative: an empty archive needs no search projection.
# Still read it first so archive failures cannot masquerade as zero results.
if self.repository.list(CardQuery(page_size=1))["total"] == 0:
return []
kinds = (["solved_question"] if family == "solved_question" else
["memory"] if family else ["memory", "solved_question"])
hits = searcher.search(embedder.embed_query(question),