fix: harden reviewer workflow and memory handling

This commit is contained in:
2026-07-23 12:34:23 +02:00
parent 00761ae2ca
commit 694b7dd21f
23 changed files with 728 additions and 75 deletions
@@ -34,9 +34,10 @@ def test_save_one_upserts_to_real_pgvector(l2_env):
record = MemoryRecord(
id="mem-l2test", ts=datetime.now(), session_id="l2-self-test",
decision_seq=999, type="table_promoted", subject="fct_ricoveri",
detail="ablazione", rationale="L2 self-test (idempotent)",
question_context="ablazione 2025", tables=["fct_ricoveri"], concepts=[],
decision_seq=999, type="concept_clarified", subject="ablazione recente",
detail="evento di ablazione negli ultimi 15 anni",
rationale="L2 self-test (idempotent)",
question_context="ablazione 2025", tables=[], concepts=["ablazione recente"],
)
from tht.adapters.vector import ThothHttpVectorStore
@@ -68,9 +68,11 @@ def test_memory_command_writes_through_factory_vector_store(monkeypatch):
cfg = SimpleNamespace(profile="server", embeddings=object(), vector_write_rest=None)
manifest = SimpleNamespace(id="s1")
snapshot = SimpleNamespace(manifest=manifest, decisions=[], artifacts={})
record = MemoryRecord(id="m1", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=7, type="table_promoted", subject="t",
question_context="q")
record = MemoryRecord(
id="m1", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=7, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", question_context="q",
)
monkeypatch.setattr(memory_cmd, "_load_config_or_exit", lambda path: cfg)
monkeypatch.setattr(memory_cmd, "load_snapshot_or_exit", lambda cfg, session: snapshot)
monkeypatch.setattr(memory_cmd, "registry_path", lambda cfg: None)
+1 -1
View File
@@ -16,7 +16,7 @@ from tht.workflow import load_workflow
("concept_clarified", 1),
("memory_rejected", 2),
("question_rewritten", 3),
("table_promoted", 2),
("table_promoted", 4),
("column_corrected", 4),
("evidence_accepted", 4),
("value_grounded", 4),
+9 -9
View File
@@ -13,9 +13,9 @@ from tht.memory import MemoryRecord, memory_vector_records
def _record(**kw) -> MemoryRecord:
base = dict(
id="mem-x", ts=datetime(2025, 1, 1), session_id="s", decision_seq=1,
type="table_promoted", subject="dim_pazienti", detail="promossa",
type="concept_clarified", subject="paziente attivo", detail="flag_attivo = TRUE",
rationale="perche' serve", question_context="dammi pazienti",
tables=["t"], concepts=[],
tables=[], concepts=["paziente attivo"],
)
base.update(kw)
return MemoryRecord(**base)
@@ -23,17 +23,17 @@ def _record(**kw) -> MemoryRecord:
def test_memory_vector_record_has_subject_detail_rationale_in_metadata():
vr = memory_vector_records([_record()])[0]
assert vr.metadata["subject"] == "dim_pazienti"
assert vr.metadata["detail"] == "promossa"
assert vr.metadata["subject"] == "paziente attivo"
assert vr.metadata["detail"] == "flag_attivo = TRUE"
assert vr.metadata["rationale"] == "perche' serve"
def test_memory_vector_record_metadata_keeps_existing_fields():
vr = memory_vector_records([_record()])[0]
# i campi che gia' c'erano restano (backward compat)
assert vr.metadata["type"] == "table_promoted"
assert vr.metadata["tables"] == ["t"]
assert vr.metadata["concepts"] == []
assert vr.metadata["type"] == "concept_clarified"
assert vr.metadata["tables"] == []
assert vr.metadata["concepts"] == ["paziente attivo"]
assert vr.metadata["session_id"] == "s"
@@ -62,6 +62,6 @@ def test_save_one_memory_preserves_subject_through_upsert_row():
save_one_memory([_record(decision_seq=1)], decision_seq=1, store=writer, embedder=embedder)
row = writer.upsert.call_args[0][1][0]
md = row.record.metadata
assert md["subject"] == "dim_pazienti"
assert md["detail"] == "promossa"
assert md["subject"] == "paziente attivo"
assert md["detail"] == "flag_attivo = TRUE"
assert md["rationale"] == "perche' serve"
+52 -1
View File
@@ -9,7 +9,13 @@ la rende un passo del workflow. Questi test fissano il contratto harness-side:
from datetime import datetime
from tht.decisions import DecisionRecord, append_decision
from tht.memory import declined_promotion_seqs, reusable_promotions
from tht.memory import (
MemoryRecord,
declined_promotion_seqs,
memory_vector_records,
promote,
reusable_promotions,
)
from tht.session.models import SessionManifest
from tht.workflow import load_workflow
@@ -62,3 +68,48 @@ def test_reusable_promotions_exclude_declined(tmp_path):
subject="fact_a", detail="seq:1") # seq 3
cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl")
assert [c.decision_seq for c in cand] == [2]
def test_reusable_promotions_deduplicate_identical_memory_content(tmp_path):
append_decision(tmp_path, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", rationale="scelta reviewer")
append_decision(tmp_path, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", rationale="scelta reviewer")
cand = reusable_promotions(tmp_path, _manifest(), tmp_path / "registry.jsonl")
assert [c.decision_seq for c in cand] == [1]
def test_only_concept_clarified_is_proposed_or_promoted(tmp_path):
append_decision(tmp_path, type="table_promoted", subject="fact_a",
detail="tabella principale")
append_decision(tmp_path, type="table_excluded", subject="fact_b",
detail="tabella non pertinente")
append_decision(tmp_path, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE")
registry = tmp_path / "registry.jsonl"
candidates = reusable_promotions(tmp_path, _manifest(), registry)
promoted = promote(tmp_path, _manifest(), seqs=[1, 2, 3], registry_path=registry)
assert [(c.decision_seq, c.type) for c in candidates] == [(3, "concept_clarified")]
assert [(c.decision_seq, c.type) for c in promoted] == [(3, "concept_clarified")]
def test_legacy_table_records_are_not_published_as_memory_vectors():
records = [
MemoryRecord(
id="mem-0001", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=1, type="table_promoted", subject="fact_a",
),
MemoryRecord(
id="mem-0002", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=2, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE",
),
]
vectors = memory_vector_records(records)
assert [record.ref for record in vectors] == ["mem-0002"]
+3 -2
View File
@@ -16,8 +16,9 @@ from tht.memory import MemoryRecord, memory_vector_record_for_decision, save_one
def _record(seq: int = 7, **kw) -> MemoryRecord:
base = dict(
id="mem-0007", ts=datetime(2025, 1, 1), session_id="s1", decision_seq=seq,
type="table_promoted", subject="pazienti", detail="promossa", rationale="r",
question_context="dammi i pazienti", tables=["pazienti"], concepts=[],
type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE", rationale="r",
question_context="dammi i pazienti", tables=[], concepts=["paziente attivo"],
)
base.update(kw)
return MemoryRecord(**base)
@@ -54,6 +54,10 @@ def test_psd_overlay_uses_generated_workspace_for_default_and_named_commands():
assert core["networks"]["default"]["aliases"] == ["core", "thothii-core"]
frontend = compose["services"]["frontend"]
assert frontend["ports"] == ["127.0.0.1:8099:8080"]
assert frontend["build"]["args"] == {
"VITE_BASE": "/",
"VITE_BACKEND_URL": "/api",
}
assert frontend["networks"] == {
"default": {"aliases": ["frontend", "thothii-frontend"]}
}
+43
View File
@@ -7,10 +7,12 @@ puro (`[]` in modalita' --json) ed exit 0, cosi' il modello prosegue senza
exemplar. Il finalize-hook gestisce gia' lo stesso scenario in modo analogo.
"""
import json
from datetime import datetime
from typer.testing import CliRunner
from tht.cli import app
from tht.memory import MemoryRecord, save_registry
from tht.ports.vector import VectorReadUnavailable
from tht.vectorstore.rest_client import VectorRestError
from tht.vectorstore.store import VectorHit
@@ -97,3 +99,44 @@ def test_solved_search_json_maps_hit_metadata(tmp_path, monkeypatch):
"session_id": "s1", "question": "quante ablazioni nel 2023",
"sql": "SELECT 1", "tables": ["fact_seeablazione"], "score": 0.91,
}]
def test_memory_search_excludes_legacy_table_records(tmp_path, monkeypatch):
records = [
MemoryRecord(
id="mem-0001", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=1, type="table_promoted", subject="fact_pazienti",
),
MemoryRecord(
id="mem-0002", ts=datetime(2026, 1, 1), session_id="s1",
decision_seq=2, type="concept_clarified", subject="paziente attivo",
detail="flag_attivo = TRUE",
),
]
cfg = _cfg(tmp_path)
save_registry(records, tmp_path / "a" / "memory" / "registry.jsonl")
class FakeSearcher:
def search(self, vec, top_n=10, kinds=None):
return [
VectorHit(
id=f"memory:{record.id}", kind="memory", ref=record.id,
title=record.subject, content=record.detail, metadata={},
similarity=0.9,
)
for record in records
]
class FakeEmbedder:
def embed_query(self, text):
return [0.1] * 8
monkeypatch.setattr("tht.cli.vector_cmd.open_searcher", lambda workspace: FakeSearcher())
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda embeddings: FakeEmbedder())
res = CliRunner().invoke(
app, ["memory", "search", "pazienti", "--json", "-c", str(cfg)]
)
assert res.exit_code == 0, res.output
assert [record["id"] for record in json.loads(res.stdout)] == ["mem-0002"]