diff --git a/mempalace/mcp_server.py b/mempalace/mcp_server.py index 2169255525..4ea934e1dd 100644 --- a/mempalace/mcp_server.py +++ b/mempalace/mcp_server.py @@ -170,13 +170,14 @@ def tool_get_taxonomy(): return {"taxonomy": taxonomy} -def tool_search(query: str, limit: int = 5, wing: str = None, room: str = None): +def tool_search(query: str, limit: int = 5, wing: str = None, room: str = None, verify: bool = False): return search_memories( query, palace_path=_config.palace_path, wing=wing, room=room, n_results=limit, + verify=verify, ) @@ -586,7 +587,7 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "handler": tool_graph_stats, }, "mempalace_search": { - "description": "Semantic search. Returns verbatim drawer content with similarity scores.", + "description": "Semantic search. Returns verbatim drawer content with similarity scores. Set verify=true to check results for contradictions using Tardygrada (requires tardygrada binary on PATH).", "input_schema": { "type": "object", "properties": { @@ -594,6 +595,10 @@ def tool_diary_read(agent_name: str, last_n: int = 10): "limit": {"type": "integer", "description": "Max results (default 5)"}, "wing": {"type": "string", "description": "Filter by wing (optional)"}, "room": {"type": "string", "description": "Filter by room (optional)"}, + "verify": { + "type": "boolean", + "description": "Run tardygrada verify-doc on results to detect contradictions (default: false, requires tardygrada binary)", + }, }, "required": ["query"], }, diff --git a/mempalace/searcher.py b/mempalace/searcher.py index 163abd88c5..4fe3c97801 100644 --- a/mempalace/searcher.py +++ b/mempalace/searcher.py @@ -7,6 +7,10 @@ """ import logging +import os +import re +import subprocess +import tempfile from pathlib import Path import chromadb @@ -18,6 +22,81 @@ class SearchError(Exception): """Raised when search cannot proceed (e.g. no palace found).""" +def _verify_with_tardygrada(hits: list) -> dict: + """ + Run tardygrada verify-doc on search results to detect contradictions. + Returns {"contradictions": [...]} or {"contradictions": None, "verify_warning": "..."}. + """ + if not hits: + return {"contradictions": []} + + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".md", delete=False, prefix="mempalace_verify_" + ) as f: + for i, hit in enumerate(hits, 1): + f.write( + f"## [{i}] {hit['wing']} / {hit['room']} (similarity: {hit['similarity']})\n" + ) + f.write(hit["text"].strip() + "\n\n") + tmp_path = f.name + + result = subprocess.run( + ["tardygrada", "verify-doc", tmp_path], + capture_output=True, + text=True, + timeout=10, + shell=False, + ) + if result.returncode != 0: + return { + "contradictions": None, + "verify_warning": f"tardygrada exited with code {result.returncode}: {result.stderr[:200]}", + } + return {"contradictions": _parse_conflicts(result.stdout)} + except FileNotFoundError: + return { + "contradictions": None, + "verify_warning": "tardygrada binary not found on PATH — install from https://github.com/fabio-rovai/tardygrada", + } + except subprocess.TimeoutExpired: + return { + "contradictions": None, + "verify_warning": "tardygrada verify-doc timeout after 10s", + } + except OSError as exc: + return { + "contradictions": None, + "verify_warning": f"tardygrada execution error: {exc}", + } + finally: + if tmp_path and os.path.exists(tmp_path): + os.unlink(tmp_path) + + +def _parse_conflicts(stdout: str) -> list: + """Parse tardygrada verify-doc output into structured conflict objects.""" + conflicts = [] + pattern = re.compile( + r'\[CONFLICT\] Lines? (\d+) vs (\d+):\s*\n' + r'\s*"([^"]+)"\s*\n' + r'\s*"([^"]+)"\s*\n' + r'\s*-> [^\n]+\n' + r'\s*Confidence: ([\d.]+)', + re.MULTILINE, + ) + for match in pattern.finditer(stdout): + conflicts.append({ + "line_a": int(match.group(1)), + "line_b": int(match.group(2)), + "claim_a": match.group(3), + "claim_b": match.group(4), + "confidence": float(match.group(5)), + }) + return conflicts + + def search(query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5): """ Search the palace. Returns verbatim drawer content. @@ -91,7 +170,8 @@ def search(query: str, palace_path: str, wing: str = None, room: str = None, n_r def search_memories( - query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5 + query: str, palace_path: str, wing: str = None, room: str = None, n_results: int = 5, + verify: bool = False, ) -> dict: """ Programmatic search — returns a dict instead of printing. @@ -145,8 +225,15 @@ def search_memories( } ) - return { + response = { "query": query, "filters": {"wing": wing, "room": room}, "results": hits, } + + if verify: + verification = _verify_with_tardygrada(hits) + response["contradictions"] = verification.get("contradictions") + response["verify_warning"] = verification.get("verify_warning") + + return response diff --git a/tests/test_verify.py b/tests/test_verify.py new file mode 100644 index 0000000000..86870e2dfb --- /dev/null +++ b/tests/test_verify.py @@ -0,0 +1,104 @@ +""" +test_verify.py — Tests for tardygrada contradiction detection on search results. +""" + +import subprocess +from unittest.mock import patch, MagicMock + +from mempalace.searcher import search_memories + +# Sample tardygrada verify-doc output with a conflict +CONFLICT_OUTPUT = """=== Tardygrada Document Verification === +File: /tmp/mempalace_verify_abc123.md +Sentences: 4 +Triples extracted: 6 +Entity groups: 2 +Pairs checked: 1 + +[CONFLICT] Lines 1 vs 3: + "project completed on time" + "project delayed 3 months" + -> Triple conflict: (project, status, completed) vs (project, status, delayed) + Confidence: 0.85 + +Summary: 1 contradiction found, 1 potential conflict checked, 4 sentences verified +Time: 3ms +""" + +CLEAN_OUTPUT = """=== Tardygrada Document Verification === +File: /tmp/mempalace_verify_abc123.md +Sentences: 4 +Triples extracted: 6 +Entity groups: 2 +Pairs checked: 1 + +Summary: 0 contradictions found, 1 potential conflict checked, 4 sentences verified +Time: 2ms +""" + + +class TestVerifyWithTardygrada: + def test_verify_detects_conflicts(self, palace_path, seeded_collection): + mock_result = MagicMock() + mock_result.stdout = CONFLICT_OUTPUT + mock_result.returncode = 0 + + with patch("mempalace.searcher.subprocess.run", return_value=mock_result): + result = search_memories("authentication", palace_path, verify=True) + + assert "contradictions" in result + assert len(result["contradictions"]) == 1 + c = result["contradictions"][0] + assert "project completed on time" in c["claim_a"] + assert "project delayed 3 months" in c["claim_b"] + assert c["confidence"] == 0.85 + + def test_verify_clean_results(self, palace_path, seeded_collection): + mock_result = MagicMock() + mock_result.stdout = CLEAN_OUTPUT + mock_result.returncode = 0 + + with patch("mempalace.searcher.subprocess.run", return_value=mock_result): + result = search_memories("authentication", palace_path, verify=True) + + assert "contradictions" in result + assert result["contradictions"] == [] + + def test_verify_missing_binary(self, palace_path, seeded_collection): + with patch( + "mempalace.searcher.subprocess.run", + side_effect=FileNotFoundError("tardygrada not found"), + ): + result = search_memories("authentication", palace_path, verify=True) + + assert result["contradictions"] is None + assert "tardygrada" in result["verify_warning"].lower() + + def test_verify_timeout(self, palace_path, seeded_collection): + with patch( + "mempalace.searcher.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="tardygrada", timeout=10), + ): + result = search_memories("authentication", palace_path, verify=True) + + assert result["contradictions"] is None + assert "timeout" in result["verify_warning"].lower() + + def test_verify_default_off(self, palace_path, seeded_collection): + with patch("mempalace.searcher.subprocess.run") as mock_run: + result = search_memories("authentication", palace_path) + + mock_run.assert_not_called() + assert "contradictions" not in result + + def test_verify_preserves_results(self, palace_path, seeded_collection): + mock_result = MagicMock() + mock_result.stdout = CLEAN_OUTPUT + mock_result.returncode = 0 + + with patch("mempalace.searcher.subprocess.run", return_value=mock_result): + result = search_memories("authentication", palace_path, verify=True) + + assert "results" in result + assert len(result["results"]) > 0 + assert result["results"][0]["text"]