fix: keep embedded session controls visible and handle empty memory
Publish documentation / publish (push) Successful in 34s
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:
@@ -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) == []
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user