Skip to content

Commit fbad611

Browse files
committed
feat(knowledge): add temporal validity windows and as-of filtering
1 parent 896ca77 commit fbad611

7 files changed

Lines changed: 351 additions & 6 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -858,15 +858,15 @@ When you configure workflows-mcp, your AI assistant gets these tools:
858858
### Knowledge Tools
859859

860860
- **search_knowledge** - Hybrid search (vector + full-text + RRF fusion)
861-
- `query`, `source`, `categories`, `min_confidence`, `limit`
861+
- `query`, `source`, `categories`, `as_of`, `min_confidence`, `limit`
862862

863863
- **store_knowledge** - Persist a new fact with auto-computed embedding
864-
- `content`, `source`, `confidence` (default 0.8), `categories`
864+
- `content`, `source`, `path`, `valid_from`, `valid_to`, `confidence` (default 0.8), `categories`
865865
- `authority`: `AGENT` (default), `EXTRACTED`, `COMMUNITY_SUMMARY`, or `USER_VALIDATED`
866866
- `lifecycle_state`: `ACTIVE` (default) or `QUARANTINED`
867867

868868
- **recall_knowledge** - Filter-based retrieval (no semantic search)
869-
- `source`, `categories`, `lifecycle_state`, `min_confidence`, `limit`, `order`
869+
- `source`, `categories`, `as_of`, `lifecycle_state`, `min_confidence`, `limit`, `order`
870870

871871
- **forget_knowledge** - Archive propositions (transition to ARCHIVED state)
872872
- By ID: `proposition_ids` (list of UUIDs)
@@ -887,7 +887,7 @@ When you configure workflows-mcp, your AI assistant gets these tools:
887887
- Returns `invalidated_count`
888888

889889
- **knowledge_context** - Token-budgeted context assembly for LLM prompts
890-
- `query`, `source`, `categories`, `max_tokens`, `diversity`
890+
- `query`, `source`, `categories`, `as_of`, `max_tokens`, `diversity`
891891

892892
---
893893

src/workflows_mcp/engine/executors_knowledge.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import logging
1414
import os
1515
import uuid
16+
from datetime import datetime
1617
from typing import Any, ClassVar, Literal
1718

1819
from pydantic import Field, model_validator
@@ -84,6 +85,17 @@ def _get_auth_method(context: Execution) -> str:
8485
return "SYSTEM"
8586

8687

88+
def _coerce_iso_datetime(value: str | None, field_name: str) -> datetime | None:
89+
"""Parse ISO datetime strings (including trailing Z) to datetime for DB bindings."""
90+
if value is None:
91+
return None
92+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
93+
try:
94+
return datetime.fromisoformat(normalized)
95+
except ValueError as e:
96+
raise ValueError(f"'{field_name}' must be a valid ISO datetime") from e
97+
98+
8799
# ===========================================================================
88100
# Input / Output Models
89101
# ===========================================================================
@@ -156,6 +168,10 @@ class KnowledgeInput(BlockInput):
156168
default=None,
157169
description="Filter by category UUIDs",
158170
)
171+
as_of: str | None = Field(
172+
default=None,
173+
description="Point-in-time filter (ISO datetime) against proposition validity window",
174+
)
159175
min_confidence: float | None = Field(
160176
default=None,
161177
description="Minimum confidence threshold",
@@ -222,6 +238,14 @@ class KnowledgeInput(BlockInput):
222238
"Required for document-derived propositions; omit for agent observations."
223239
),
224240
)
241+
valid_from: str | None = Field(
242+
default=None,
243+
description="World-truth start datetime (ISO). NULL means open start",
244+
)
245+
valid_to: str | None = Field(
246+
default=None,
247+
description="World-truth end datetime (ISO). NULL means open end",
248+
)
225249

226250
# --- Recall fields ---
227251
where: dict[str, Any] | None = Field(
@@ -510,6 +534,7 @@ async def _op_search(
510534
resolved_categories = None
511535
if inputs.categories:
512536
resolved_categories = await self._resolve_categories(inputs.categories, backend)
537+
as_of = _coerce_iso_datetime(inputs.as_of, "as_of")
513538

514539
# Compute query embedding
515540
embedding, _, _, _ = await compute_embedding(
@@ -523,6 +548,7 @@ async def _op_search(
523548
query_embedding=embedding,
524549
source=inputs.source,
525550
categories=resolved_categories,
551+
as_of=as_of,
526552
min_confidence=inputs.min_confidence
527553
if inputs.min_confidence is not None
528554
else DEFAULT_MIN_CONFIDENCE,
@@ -537,6 +563,7 @@ async def _op_search(
537563
query_text=inputs.query,
538564
source=inputs.source,
539565
categories=resolved_categories,
566+
as_of=as_of,
540567
min_confidence=inputs.min_confidence
541568
if inputs.min_confidence is not None
542569
else DEFAULT_MIN_CONFIDENCE,
@@ -674,6 +701,8 @@ async def _op_store(
674701
created_by = _get_audit_user_id(context)
675702
auth_method = _get_auth_method(context)
676703
user_string = _get_user_string_id(context)
704+
valid_from = _coerce_iso_datetime(inputs.valid_from, "valid_from")
705+
valid_to = _coerce_iso_datetime(inputs.valid_to, "valid_to")
677706

678707
# Build metadata JSON string with auth info
679708
if auth_method and isinstance(auth_method, str):
@@ -689,13 +718,15 @@ async def _op_store(
689718
(id, item_id, content, embedding, search_vector,
690719
authority, lifecycle_state, confidence,
691720
embedding_model, embedding_dimensions, metadata,
721+
valid_from, valid_to,
692722
created_by, auth_method, source_name, source_type)
693723
VALUES
694724
($1::uuid, $2::uuid, $3, $4::vector,
695725
to_tsvector('english', $3),
696726
$5, $6, $7,
697727
$8, $9, $10::jsonb,
698-
$11::uuid, $12, $13, $14)
728+
$11::timestamptz, $12::timestamptz,
729+
$13::uuid, $14, $15, $16)
699730
""",
700731
(
701732
prop_id,
@@ -708,6 +739,8 @@ async def _op_store(
708739
model_name,
709740
dimensions,
710741
metadata_json,
742+
valid_from,
743+
valid_to,
711744
str(created_by),
712745
auth_method,
713746
source_name,
@@ -881,6 +914,15 @@ def next_param(value: Any) -> str:
881914
f"kp.created_at <= {next_param(inputs.created_before)}::timestamptz"
882915
)
883916

917+
if inputs.as_of:
918+
as_of_param = next_param(_coerce_iso_datetime(inputs.as_of, "as_of"))
919+
where_clauses.append(
920+
f"(kp.valid_from IS NULL OR kp.valid_from <= {as_of_param}::timestamptz)"
921+
)
922+
where_clauses.append(
923+
f"(kp.valid_to IS NULL OR kp.valid_to >= {as_of_param}::timestamptz)"
924+
)
925+
884926
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
885927
return where_sql, params
886928

@@ -1259,6 +1301,7 @@ async def _search_with_embeddings(
12591301
resolved_categories = None
12601302
if inputs.categories:
12611303
resolved_categories = await self._resolve_categories(inputs.categories, backend)
1304+
as_of = _coerce_iso_datetime(inputs.as_of, "as_of")
12621305

12631306
query_embedding, _, _, _ = await compute_embedding(
12641307
text=inputs.query,
@@ -1271,6 +1314,7 @@ async def _search_with_embeddings(
12711314
query_embedding=query_embedding,
12721315
source=inputs.source,
12731316
categories=resolved_categories,
1317+
as_of=as_of,
12741318
min_confidence=inputs.min_confidence
12751319
if inputs.min_confidence is not None
12761320
else DEFAULT_MIN_CONFIDENCE,
@@ -1286,6 +1330,7 @@ async def _search_with_embeddings(
12861330
query_text=inputs.query,
12871331
source=inputs.source,
12881332
categories=resolved_categories,
1333+
as_of=as_of,
12891334
min_confidence=inputs.min_confidence
12901335
if inputs.min_confidence is not None
12911336
else DEFAULT_MIN_CONFIDENCE,

src/workflows_mcp/engine/knowledge/schema.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,28 @@
479479
DELETE FROM knowledge_entities WHERE entity_type = 'category';
480480
""",
481481
),
482+
(
483+
11,
484+
"Add temporal validity window columns to knowledge_propositions",
485+
"""
486+
DO $$
487+
BEGIN
488+
IF NOT EXISTS (
489+
SELECT 1 FROM information_schema.columns
490+
WHERE table_name = 'knowledge_propositions' AND column_name = 'valid_from'
491+
) THEN
492+
ALTER TABLE knowledge_propositions ADD COLUMN valid_from TIMESTAMPTZ NULL;
493+
END IF;
494+
495+
IF NOT EXISTS (
496+
SELECT 1 FROM information_schema.columns
497+
WHERE table_name = 'knowledge_propositions' AND column_name = 'valid_to'
498+
) THEN
499+
ALTER TABLE knowledge_propositions ADD COLUMN valid_to TIMESTAMPTZ NULL;
500+
END IF;
501+
END $$;
502+
""",
503+
),
482504
]
483505

484506
SCHEMA_VERSION = MIGRATIONS[-1][0] if MIGRATIONS else 0

src/workflows_mcp/engine/knowledge/search.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import logging
11+
from datetime import datetime
1112
from typing import Any
1213

1314
from .constants import (
@@ -27,6 +28,7 @@ def build_vector_search_query(
2728
*,
2829
source: str | None = None,
2930
categories: list[str] | None = None,
31+
as_of: datetime | None = None,
3032
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
3133
lifecycle_state: str = LifecycleState.ACTIVE,
3234
limit: int = DEFAULT_LIMIT,
@@ -89,6 +91,13 @@ def next_param(value: Any) -> str:
8991
f")"
9092
)
9193

94+
if as_of:
95+
as_of_param = next_param(as_of)
96+
where_clauses.append(
97+
f"(kp.valid_from IS NULL OR kp.valid_from <= {as_of_param}::timestamptz)"
98+
)
99+
where_clauses.append(f"(kp.valid_to IS NULL OR kp.valid_to >= {as_of_param}::timestamptz)")
100+
92101
where_clause = " AND ".join(where_clauses)
93102

94103
embedding_col = ", kp.embedding" if include_embeddings else ""
@@ -114,6 +123,7 @@ def build_fts_search_query(
114123
*,
115124
source: str | None = None,
116125
categories: list[str] | None = None,
126+
as_of: datetime | None = None,
117127
min_confidence: float = DEFAULT_MIN_CONFIDENCE,
118128
lifecycle_state: str = LifecycleState.ACTIVE,
119129
limit: int = DEFAULT_LIMIT,
@@ -167,6 +177,13 @@ def next_param(value: Any) -> str:
167177
f")"
168178
)
169179

180+
if as_of:
181+
as_of_param = next_param(as_of)
182+
where_clauses.append(
183+
f"(kp.valid_from IS NULL OR kp.valid_from <= {as_of_param}::timestamptz)"
184+
)
185+
where_clauses.append(f"(kp.valid_to IS NULL OR kp.valid_to >= {as_of_param}::timestamptz)")
186+
170187
where_clause = " AND ".join(where_clauses)
171188

172189
sql = f"""

0 commit comments

Comments
 (0)