Compare commits

...
Author SHA1 Message Date
Nicolò Boschi ae4f14a346 test: verify tenant_id propagation to on_file_convert_complete hook 2026-03-06 09:11:49 +01:00
Nicolò Boschi afb20c1d66 test: add tests for on_file_convert_complete extension hook
Verifies that the hook is called with correct parameters on success,
called once per file for multi-file uploads, and not called when
file conversion fails.
2026-03-06 09:08:02 +01:00
Nicolò Boschi cebce86d05 remove parser field from FileRetainRequest API
Parser selection remains server-side only via HINDSIGHT_API_FILE_PARSER config.
2026-03-06 09:04:29 +01:00
Chris Bartholomew 9a79e42535 Add file upload API with parser selection and conversion hooks
- Add FileRetainRequest.parser field for per-request parser selection
- Add FileConvertResult dataclass and on_file_convert_complete extension hook
- Fire hook after file-to-markdown conversion with output text for metering
- Fix obstore.Bytes incompatibility with httpx in Iris parser (GCS returns
  obstore.Bytes instead of plain bytes)
- Export new types from extensions __init__
2026-03-05 20:32:44 -05:00
5 changed files with 291 additions and 1 deletions
@@ -671,6 +671,31 @@ class MemoryEngine(MemoryEngineInterface):
f"document_id={document_id}, {len(markdown_content)} chars. Submitting retain task."
)
# Fire file conversion hook (e.g., for Iris billing)
if self._operation_validator:
try:
from hindsight_api.extensions.operation_validator import FileConvertResult
from hindsight_api.models import RequestContext
convert_context = RequestContext(
internal=True,
user_initiated=True,
tenant_id=task_dict.get("_tenant_id"),
api_key_id=task_dict.get("_api_key_id"),
)
await self._operation_validator.on_file_convert_complete(
FileConvertResult(
bank_id=bank_id,
parser_name=task_dict.get("parser", "unknown"),
filename=filename,
output_chars=len(markdown_content),
output_text=markdown_content,
request_context=convert_context,
)
)
except Exception as e:
logger.warning(f"[FILE_CONVERT_RETAIN] on_file_convert_complete hook failed: {e}")
# Build retain task payload
retain_contents = [
{
@@ -75,9 +75,10 @@ class IrisParser(FileParser):
upload_url: str = init_data["uploadUrl"]
# Step 2: Upload the file bytes to the presigned URL (no auth header)
# Ensure file_data is plain bytes (GCS storage may return obstore.Bytes)
upload_resp = await client.put(
upload_url,
content=file_data,
content=bytes(file_data),
headers={"Content-Type": content_type},
)
_raise_for_status(upload_resp, filename, "file upload")
@@ -30,6 +30,8 @@ from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
# File Conversion
FileConvertResult,
# Mental Model operations
MentalModelGetContext,
MentalModelGetResult,
@@ -83,6 +85,8 @@ __all__ = [
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
# Operation Validator - File Conversion
"FileConvertResult",
# Operation Validator - Mental Model
"MentalModelGetContext",
"MentalModelGetResult",
@@ -289,6 +289,28 @@ class MentalModelRefreshResult:
error: str | None = None
# =============================================================================
# File Conversion Post-operation Context
# =============================================================================
@dataclass
class FileConvertResult:
"""Result context for post-file-conversion hook.
Fired after a file is converted to markdown, before the retain step.
"""
bank_id: str
parser_name: str
filename: str
output_chars: int
output_text: str
request_context: "RequestContext"
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect/consolidate operations.
@@ -496,6 +518,31 @@ class OperationValidatorExtension(Extension, ABC):
"""
pass
# =========================================================================
# File Conversion - Post-operation hook (optional - override to implement)
# =========================================================================
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
"""
Called after a file is converted to markdown (before the retain step).
Override to implement post-conversion logic such as:
- Billing for premium parsers (e.g., Iris)
- Usage tracking
- Audit logging
Args:
result: Result context containing:
- bank_id: Bank identifier
- parser_name: Name of the parser used (e.g., 'markitdown', 'iris')
- filename: Original filename
- output_chars: Character count of the converted markdown
- request_context: Request context with auth info
- success: Whether the conversion succeeded
- error: Error message (if failed)
"""
pass
# =========================================================================
# Mental Model - Pre-operation validation hook (optional - override to implement)
# =========================================================================
+213
View File
@@ -2,12 +2,22 @@
End-to-end tests for file retain (upload, convert, retain) functionality.
"""
import asyncio
import io
import json
import pytest
from httpx import ASGITransport, AsyncClient
from hindsight_api.extensions import FileConvertResult, OperationValidatorExtension, ValidationResult
from hindsight_api.extensions.operation_validator import (
RecallContext,
RecallResult,
ReflectContext,
RetainContext,
RetainResult,
)
@pytest.fixture
def sample_pdf_content():
@@ -551,3 +561,206 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
assert operation["error_message"] is not None
assert "Mock conversion error" in operation["error_message"]
assert "test.fail" in operation["error_message"]
class FileConvertTrackingValidator(OperationValidatorExtension):
"""Validator that tracks on_file_convert_complete hook calls."""
def __init__(self):
super().__init__({})
self.convert_calls: list[FileConvertResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
async def on_retain_complete(self, result: RetainResult) -> None:
pass
async def on_recall_complete(self, result: RecallResult) -> None:
pass
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
self.convert_calls.append(result)
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_called(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete hook is called after file conversion with correct parameters."""
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
context = RequestContext(internal=True, api_key_id="test-key-id", tenant_id="test-tenant")
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
mock_file = MockFile(sample_txt_content, "report.txt", "text/plain")
file_items = [
{
"file": mock_file,
"document_id": "hook_test_doc",
"context": "test context",
"metadata": {},
"tags": [],
"timestamp": None,
}
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="markitdown",
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.1)
assert len(validator.convert_calls) == 1
result = validator.convert_calls[0]
assert result.bank_id == bank_id
assert result.filename == "report.txt"
assert result.parser_name == "markitdown"
assert result.output_chars > 0
assert result.output_text is not None
assert len(result.output_text) == result.output_chars
assert result.success is True
assert result.error is None
assert result.request_context is not None
assert result.request_context.api_key_id == "test-key-id"
assert result.request_context.tenant_id == "test-tenant"
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_called_for_each_file(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete is called once per file when uploading multiple files."""
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_multi_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
context = RequestContext(internal=True)
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
file_items = [
{
"file": MockFile(b"First document content", "first.txt", "text/plain"),
"document_id": "doc_1",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
},
{
"file": MockFile(b"Second document content", "second.txt", "text/plain"),
"document_id": "doc_2",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
},
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="markitdown",
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.2)
assert len(validator.convert_calls) == 2
filenames = {r.filename for r in validator.convert_calls}
assert filenames == {"first.txt", "second.txt"}
for result in validator.convert_calls:
assert result.bank_id == bank_id
assert result.parser_name == "markitdown"
assert result.output_chars > 0
assert result.success is True
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_not_called_on_conversion_failure(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete is NOT called when file conversion fails."""
from hindsight_api.engine.parsers.base import FileParser
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_fail_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
class FailingParser(FileParser):
async def convert(self, file_data: bytes, filename: str) -> str:
raise RuntimeError("Mock conversion failure")
def supports(self, filename: str, content_type: str | None = None) -> bool:
return filename.endswith(".hookfail")
def name(self) -> str:
return "hookfail_parser"
memory_no_llm_verify._parser_registry.register(FailingParser())
context = RequestContext(internal=True)
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
file_items = [
{
"file": MockFile(sample_txt_content, "bad.hookfail", "application/octet-stream"),
"document_id": "fail_hook_doc",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
}
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="hookfail_parser",
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.2)
assert len(validator.convert_calls) == 0