From 1d17dea2f1f8e970d716d612a87c93525320d5a3 Mon Sep 17 00:00:00 2001 From: Chris Bartholomew Date: Fri, 6 Mar 2026 03:56:53 -0500 Subject: [PATCH] Add on_file_convert_complete extension hook after file-to-markdown conversion (#507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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__ * remove parser field from FileRetainRequest API Parser selection remains server-side only via HINDSIGHT_API_FILE_PARSER config. * 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. * test: verify tenant_id propagation to on_file_convert_complete hook --------- Co-authored-by: Nicolò Boschi --- .../hindsight_api/engine/memory_engine.py | 25 ++ .../hindsight_api/engine/parsers/iris.py | 3 +- .../hindsight_api/extensions/__init__.py | 4 + .../extensions/operation_validator.py | 47 ++++ hindsight-api/tests/test_file_retain.py | 213 ++++++++++++++++++ 5 files changed, 291 insertions(+), 1 deletion(-) diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index b92c4eae..e91189df 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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 = [ { diff --git a/hindsight-api/hindsight_api/engine/parsers/iris.py b/hindsight-api/hindsight_api/engine/parsers/iris.py index 8d95da00..7314b9fa 100644 --- a/hindsight-api/hindsight_api/engine/parsers/iris.py +++ b/hindsight-api/hindsight_api/engine/parsers/iris.py @@ -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") diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py index 0e93225a..55fa640f 100644 --- a/hindsight-api/hindsight_api/extensions/__init__.py +++ b/hindsight-api/hindsight_api/extensions/__init__.py @@ -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", diff --git a/hindsight-api/hindsight_api/extensions/operation_validator.py b/hindsight-api/hindsight_api/extensions/operation_validator.py index 56334418..6fd9d1b4 100644 --- a/hindsight-api/hindsight_api/extensions/operation_validator.py +++ b/hindsight-api/hindsight_api/extensions/operation_validator.py @@ -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) # ========================================================================= diff --git a/hindsight-api/tests/test_file_retain.py b/hindsight-api/tests/test_file_retain.py index 0f83a68f..8cc0d730 100644 --- a/hindsight-api/tests/test_file_retain.py +++ b/hindsight-api/tests/test_file_retain.py @@ -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