feat(extensions): add context enrichment to OperationValidatorExtension (#639)

Validators can now return enriched data via ValidationResult.accept_with()
instead of only accepting or rejecting operations. The engine applies
returned fields (contents, tags, tag_groups) to the operation parameters.

- Add accept_with() factory to ValidationResult with optional enrichment
  fields: contents, tags, tags_match, tag_groups
- Add tags, tags_match, tag_groups to RecallContext so validators can
  see current filter state
- Update _validate_operation to return ValidationResult
- Apply enrichment from result at all retain (2 sites) and recall call
  sites in MemoryEngine
- Existing validators using accept()/reject() work unchanged
This commit is contained in:
Mr. Khachaturov 2026-03-23 10:57:02 +03:00 committed by GitHub
parent 2f2db2a6e2
commit 2eb1019da9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 69 additions and 9 deletions

View file

@ -499,24 +499,28 @@ class MemoryEngine(MemoryEngineInterface):
"""The configured tenant extension, if any."""
return self._tenant_extension
async def _validate_operation(self, validation_coro) -> None:
async def _validate_operation(self, validation_coro) -> "ValidationResult | None":
"""
Run validation if an operation validator is configured.
Args:
validation_coro: Coroutine that returns a ValidationResult
Returns:
The ValidationResult (may contain enrichment fields), or None if no validator.
Raises:
OperationValidationError: If validation fails
"""
if self._operation_validator is None:
return
return None
from hindsight_api.extensions import OperationValidationError
from hindsight_api.extensions import OperationValidationError, ValidationResult
result = await validation_coro
if not result.allowed:
raise OperationValidationError(result.reason or "Operation not allowed", result.status_code)
return result
async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str:
"""
@ -2052,7 +2056,9 @@ class MemoryEngine(MemoryEngineInterface):
fact_type_override=fact_type_override,
confidence_score=confidence_score,
)
await self._validate_operation(self._operation_validator.validate_retain(ctx))
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
if result and result.contents is not None:
contents = result.contents
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
if document_id:
@ -2419,8 +2425,18 @@ class MemoryEngine(MemoryEngineInterface):
max_entity_tokens=max_entity_tokens,
include_chunks=include_chunks,
max_chunk_tokens=max_chunk_tokens,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
await self._validate_operation(self._operation_validator.validate_recall(ctx))
result = await self._validate_operation(self._operation_validator.validate_recall(ctx))
if result:
if result.tags is not None:
tags = result.tags
if result.tags_match is not None:
tags_match = result.tags_match
if result.tag_groups is not None:
tag_groups = result.tag_groups
# Map budget enum to thinking_budget number (default to MID if None)
budget_mapping = {Budget.LOW: 100, Budget.MID: 300, Budget.HIGH: 1000}
@ -7511,7 +7527,9 @@ class MemoryEngine(MemoryEngineInterface):
contents=[dict(c) for c in contents],
request_context=request_context,
)
await self._validate_operation(self._operation_validator.validate_retain(ctx))
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
if result and result.contents is not None:
contents = result.contents
# Validate no duplicate document_ids in the batch
# Having duplicate document_ids causes race conditions in document upserts during parallel processing

View file

@ -11,6 +11,7 @@ if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
from hindsight_api.engine.response_models import ReflectResult
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.models import RequestContext
@ -25,17 +26,51 @@ class OperationValidationError(Exception):
@dataclass
class ValidationResult:
"""Result of an operation validation."""
"""Result of an operation validation.
Validators return this to accept or reject an operation. When accepting,
validators can optionally return modified data that the engine will use
instead of the original request parameters. This enables context enrichment
(e.g., injecting tags or tag_groups).
"""
allowed: bool
reason: str | None = None
status_code: int = 403 # Default to Forbidden
# Optional enrichment fields — returned by validator, used by engine if present.
# None means "no modification" (engine uses original values).
contents: list[dict] | None = None # Enriched retain contents (e.g., injected tags/strategy)
tags: list[str] | None = None # Enriched recall tags
tags_match: "TagsMatch | None" = None # Enriched recall tags match mode
tag_groups: "list[TagGroup] | None" = None # Enriched recall tag_groups
@classmethod
def accept(cls) -> "ValidationResult":
"""Create an accepted validation result."""
"""Create an accepted validation result (no enrichment)."""
return cls(allowed=True)
@classmethod
def accept_with(
cls,
*,
contents: list[dict] | None = None,
tags: list[str] | None = None,
tags_match: "TagsMatch | None" = None,
tag_groups: "list[TagGroup] | None" = None,
) -> "ValidationResult":
"""Create an accepted validation result with enriched data.
The engine will use the returned values instead of the original request
parameters. Only non-None fields are applied; None means "keep original".
"""
return cls(
allowed=True,
contents=contents,
tags=tags,
tags_match=tags_match,
tag_groups=tag_groups,
)
@classmethod
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
"""Create a rejected validation result with a reason and HTTP status code."""
@ -52,10 +87,12 @@ class RetainContext:
"""Context for a retain operation validation (pre-operation).
Contains ALL user-provided parameters for the retain operation.
To enrich contents (e.g., inject tags or strategy), return them
via ValidationResult.accept_with(contents=...).
"""
bank_id: str
contents: list[dict] # List of {content, context, event_date, document_id}
contents: list[dict] # List of {content, context, event_date, document_id, tags, strategy}
request_context: "RequestContext"
document_id: str | None = None
fact_type_override: str | None = None
@ -67,6 +104,8 @@ class RecallContext:
"""Context for a recall operation validation (pre-operation).
Contains ALL user-provided parameters for the recall operation.
To enrich tag filters (e.g., inject tag_groups), return them
via ValidationResult.accept_with(tag_groups=...).
"""
bank_id: str
@ -81,6 +120,9 @@ class RecallContext:
max_entity_tokens: int = 500
include_chunks: bool = False
max_chunk_tokens: int = 8192
tags: list[str] | None = None
tags_match: "TagsMatch" = "any"
tag_groups: "list[TagGroup] | None" = None
@dataclass