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:
parent
2f2db2a6e2
commit
2eb1019da9
2 changed files with 69 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue