fix(kg): tighten temporal handling and cleanup
This commit is contained in:
parent
29c0c8059e
commit
1fbcb739c9
|
|
@ -93,14 +93,16 @@ def sanitize_kg_value(value: str, field_name: str = "value") -> str:
|
|||
# Accepted:
|
||||
# YYYY-MM-DD
|
||||
# YYYY-MM-DDTHH:MM:SSZ
|
||||
# YYYY-MM-DDTHH:MM:SS+00:00 (normalized to ...Z)
|
||||
#
|
||||
# Rejected:
|
||||
# partial dates, naive datetimes, timezone offsets, fractional seconds,
|
||||
# and SQLite-style space-separated datetimes.
|
||||
# partial dates, naive datetimes, non-UTC timezone offsets, fractional
|
||||
# seconds, and SQLite-style space-separated datetimes.
|
||||
_ISO_DATE_RE = re.compile(r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$")
|
||||
|
||||
_ISO_UTC_DATETIME_RE = re.compile(
|
||||
r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])" r"T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\dZ$"
|
||||
r"^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])"
|
||||
r"T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|\+00:00)$"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -127,6 +129,7 @@ def sanitize_iso_temporal(value, field_name: str = "date"):
|
|||
|
||||
- ``YYYY-MM-DD``
|
||||
- ``YYYY-MM-DDTHH:MM:SSZ``
|
||||
- ``YYYY-MM-DDTHH:MM:SS+00:00`` normalized to ``...Z``
|
||||
|
||||
Partial dates are rejected because KG queries compare TEXT temporal values.
|
||||
Non-canonical datetime forms are rejected because mixed temporal string
|
||||
|
|
@ -148,6 +151,9 @@ def sanitize_iso_temporal(value, field_name: str = "date"):
|
|||
"(expected YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ)"
|
||||
) from None
|
||||
|
||||
if value.endswith("+00:00"):
|
||||
value = f"{value[:-6]}Z"
|
||||
|
||||
return value
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -81,20 +81,6 @@ def _temporal_end_key(value: Optional[str]) -> Optional[str]:
|
|||
return value
|
||||
|
||||
|
||||
def _triple_valid_at(valid_from: Optional[str], valid_to: Optional[str], as_of: str) -> bool:
|
||||
as_of_key = _temporal_start_key(as_of)
|
||||
valid_from_key = _temporal_start_key(valid_from)
|
||||
valid_to_key = _temporal_end_key(valid_to)
|
||||
|
||||
if valid_from_key is not None and valid_from_key > as_of_key:
|
||||
return False
|
||||
|
||||
if valid_to_key is not None and valid_to_key < as_of_key:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _sql_temporal_start_expr(column: str) -> str:
|
||||
"""SQLite expression for comparing valid_from-style temporal values."""
|
||||
|
||||
|
|
@ -230,15 +216,6 @@ class KnowledgeGraph:
|
|||
self.close()
|
||||
return False
|
||||
|
||||
def __del__(self):
|
||||
"""Best-effort cleanup for callers/tests that forget to call close()."""
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
# Destructors must never raise, especially during interpreter
|
||||
# shutdown when module globals may already be partially torn down.
|
||||
pass
|
||||
|
||||
def _entity_id(self, name: str) -> str:
|
||||
return name.lower().replace(" ", "_").replace("'", "")
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@ def _reset_mcp_cache():
|
|||
try:
|
||||
from mempalace import mcp_server
|
||||
|
||||
for kg in list(getattr(mcp_server, "_kg_by_path", {}).values()):
|
||||
close = getattr(kg, "close", None)
|
||||
if close is not None:
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(mcp_server, "_kg_by_path"):
|
||||
mcp_server._kg_by_path.clear()
|
||||
|
||||
mcp_server._client_cache = None
|
||||
mcp_server._collection_cache = None
|
||||
except (ImportError, AttributeError):
|
||||
|
|
|
|||
|
|
@ -341,3 +341,7 @@ def test_iso_temporal_rejects_invalid_calendar_date():
|
|||
def test_iso_temporal_error_names_field():
|
||||
with pytest.raises(ValueError, match="as_of"):
|
||||
sanitize_iso_temporal("2026-05-06T14:23", "as_of")
|
||||
|
||||
|
||||
def test_iso_temporal_normalizes_plus_zero_offset_to_z():
|
||||
assert sanitize_iso_temporal("2026-05-06T14:23:00+00:00") == "2026-05-06T14:23:00Z"
|
||||
|
|
|
|||
Loading…
Reference in New Issue