Merge pull request #1320 from MemPalace/fix/1314-kg-temporal-params

fix(mcp): forward valid_to and source params in kg_add/kg_invalidate (#1314)
This commit is contained in:
Igor Lins e Silva
2026-05-03 03:51:29 -03:00
committed by GitHub
2 changed files with 146 additions and 9 deletions
+62 -9
View File
@@ -47,7 +47,7 @@ import json # noqa: E402
import logging # noqa: E402
import hashlib # noqa: E402
import time # noqa: E402
from datetime import datetime # noqa: E402
from datetime import date, datetime # noqa: E402
from pathlib import Path # noqa: E402
from .config import ( # noqa: E402
@@ -1068,9 +1068,26 @@ def tool_kg_query(entity: str, as_of: str = None, direction: str = "both"):
def tool_kg_add(
subject: str, predicate: str, object: str, valid_from: str = None, source_closet: str = None
subject: str,
predicate: str,
object: str,
valid_from: str = None,
valid_to: str = None,
source_closet: str = None,
source_file: str = None,
source_drawer_id: str = None,
):
"""Add a relationship to the knowledge graph."""
"""Add a relationship to the knowledge graph.
All temporal and provenance fields are optional. ``valid_to`` lets callers
backfill historical facts with a known end date in a single call (instead
of a separate ``kg_invalidate``). ``source_file`` and ``source_drawer_id``
are RFC 002 provenance fields populated by adapters / bulk importers.
TODO(#1283): once the ISO-8601 validation PR lands, wire ``validate_iso_date``
over ``valid_from`` / ``valid_to`` here so malformed dates fail fast at the
MCP boundary instead of silently producing empty query results.
"""
try:
subject = sanitize_kg_value(subject, "subject")
predicate = sanitize_name(predicate, "predicate")
@@ -1085,32 +1102,56 @@ def tool_kg_add(
"predicate": predicate,
"object": object,
"valid_from": valid_from,
"valid_to": valid_to,
"source_closet": source_closet,
"source_file": source_file,
"source_drawer_id": source_drawer_id,
},
)
triple_id = _kg.add_triple(
subject, predicate, object, valid_from=valid_from, source_closet=source_closet
subject,
predicate,
object,
valid_from=valid_from,
valid_to=valid_to,
source_closet=source_closet,
source_file=source_file,
source_drawer_id=source_drawer_id,
)
return {"success": True, "triple_id": triple_id, "fact": f"{subject}{predicate}{object}"}
def tool_kg_invalidate(subject: str, predicate: str, object: str, ended: str = None):
"""Mark a fact as no longer true (set end date)."""
"""Mark a fact as no longer true (set end date).
Returns the actual ``ended`` date that was stored — when the caller omits
``ended``, the underlying graph stamps ``date.today()``, and the response
reflects that resolved value (instead of the literal string ``"today"``)
so callers can verify what was persisted.
TODO(#1283): apply ``validate_iso_date`` to ``ended`` once that PR lands.
"""
try:
subject = sanitize_kg_value(subject, "subject")
predicate = sanitize_name(predicate, "predicate")
object = sanitize_kg_value(object, "object")
except ValueError as e:
return {"success": False, "error": str(e)}
resolved_ended = ended or date.today().isoformat()
_wal_log(
"kg_invalidate",
{"subject": subject, "predicate": predicate, "object": object, "ended": ended},
{
"subject": subject,
"predicate": predicate,
"object": object,
"ended": resolved_ended,
},
)
_kg.invalidate(subject, predicate, object, ended=ended)
_kg.invalidate(subject, predicate, object, ended=resolved_ended)
return {
"success": True,
"fact": f"{subject}{predicate}{object}",
"ended": ended or "today",
"ended": resolved_ended,
}
@@ -1456,7 +1497,7 @@ TOOLS = {
"handler": tool_kg_query,
},
"mempalace_kg_add": {
"description": "Add a fact to the knowledge graph. Subject → predicate → object with optional time window. E.g. ('Max', 'started_school', 'Year 7', valid_from='2026-09-01').",
"description": "Add a fact to the knowledge graph. Subject → predicate → object with optional time window. E.g. ('Max', 'started_school', 'Year 7', valid_from='2026-09-01'). Pass valid_to to backfill an already-ended historical fact in a single call.",
"input_schema": {
"type": "object",
"properties": {
@@ -1470,10 +1511,22 @@ TOOLS = {
"type": "string",
"description": "When this became true (YYYY-MM-DD, optional)",
},
"valid_to": {
"type": "string",
"description": "When this stopped being true (YYYY-MM-DD, optional). Use for backfilling already-ended historical facts.",
},
"source_closet": {
"type": "string",
"description": "Closet ID where this fact appears (optional)",
},
"source_file": {
"type": "string",
"description": "Source file path the fact was extracted from (optional)",
},
"source_drawer_id": {
"type": "string",
"description": "Drawer ID the fact was extracted from (optional, RFC 002 provenance)",
},
},
"required": ["subject", "predicate", "object"],
},
+84
View File
@@ -689,6 +689,90 @@ class TestKGTools:
ended="2026-03-01",
)
assert result["success"] is True
# Regression #1314: response must echo the actual ended date,
# not silently drop it and return the literal string "today".
assert result["ended"] == "2026-03-01"
def test_kg_add_forwards_valid_to(self, monkeypatch, config, palace_path, kg):
"""Regression #1314 case 1: valid_to must round-trip through kg_add."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
subject="_test_temporal",
predicate="had_value",
object="probe",
valid_from="2026-01-01",
valid_to="2026-04-28",
)
assert result["success"] is True
facts = kg.query_entity("_test_temporal")
assert len(facts) == 1
assert facts[0]["valid_from"] == "2026-01-01"
assert facts[0]["valid_to"] == "2026-04-28"
# An already-ended fact must not be reported as still current.
assert facts[0]["current"] is False
def test_kg_add_forwards_source_provenance(self, monkeypatch, config, palace_path, kg):
"""Regression #1314 case 3: source_file / source_drawer_id reach storage."""
_patch_mcp_server(monkeypatch, config, kg)
from mempalace.mcp_server import tool_kg_add
result = tool_kg_add(
subject="operating-verb",
predicate="candidate",
object="husbandry",
valid_from="2026-04-28",
source_closet="closet-42",
source_file="docs/decisions.md",
source_drawer_id="drawer_abc123",
)
assert result["success"] is True
triple_id = result["triple_id"]
# Read raw row to verify all provenance columns persisted.
with kg._lock:
row = (
kg._conn()
.execute(
"SELECT source_closet, source_file, source_drawer_id FROM triples WHERE id = ?",
(triple_id,),
)
.fetchone()
)
assert row is not None
assert row["source_closet"] == "closet-42"
assert row["source_file"] == "docs/decisions.md"
assert row["source_drawer_id"] == "drawer_abc123"
def test_kg_invalidate_returns_actual_ended_date(
self, monkeypatch, config, palace_path, seeded_kg
):
"""Regression #1314 case 2: response reports the resolved date, not 'today'."""
from datetime import date as _date
_patch_mcp_server(monkeypatch, config, seeded_kg)
from mempalace.mcp_server import tool_kg_invalidate
# Caller-supplied date round-trips into the response.
explicit = tool_kg_invalidate(
subject="Max",
predicate="does",
object="swimming",
ended="2026-04-28",
)
assert explicit["ended"] == "2026-04-28"
# Caller-omitted date resolves to today's ISO date — never the
# literal string "today" the buggy implementation used to return.
implicit = tool_kg_invalidate(
subject="Max",
predicate="loves",
object="Chess",
)
assert implicit["ended"] != "today"
assert implicit["ended"] == _date.today().isoformat()
def test_kg_timeline(self, monkeypatch, config, palace_path, seeded_kg):
_patch_mcp_server(monkeypatch, config, seeded_kg)