From 224b7b74c14bfcd3069d2b8a9097f02de83287e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Tue, 17 Feb 2026 18:15:03 +0100 Subject: [PATCH] feat: accept pdf, images and office files (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: accept pdf, images and office files * refactor: rename FileConverter to FileParser, simplify file retain API - Rename engine/converters/ → engine/parsers/, FileConverter → FileParser, ConverterRegistry → FileParserRegistry, MarkitdownConverter → MarkitdownParser - Rename env var HINDSIGHT_API_FILE_CONVERTER → HINDSIGHT_API_FILE_PARSER - Remove async/document_tags params from FileRetainRequest (always async now) - Add retain_files() to Python Hindsight client and retainFiles() to TypeScript client - Add sample.pdf to doc examples for working file upload demonstrations - Update test_file_retain.py to use new parser names and always-async behavior - Fix Go client missing os import in api_files.go - Simplify postgresql.py storage to minimal schema * fix: update rust CLI tests to use is_supported_file instead of is_text_file * fix: patch Go api_files.go to add missing 'os' import after generation * fix: insert 'os' import after 'net/url' in api_files.go patch for correct position * chore: regenerate OpenAPI spec and clients (converter→parser description update) --- .../s3-file-storage/docker-compose.yaml | 83 +++ docker/docker-compose/s3-file-storage/s3.json | 19 + .../a1b2c3d4e5f6_add_file_storage_table.py | 70 +++ hindsight-api/hindsight_api/api/http.py | 198 ++++++- hindsight-api/hindsight_api/config.py | 79 +++ .../hindsight_api/engine/memory_engine.py | 310 +++++++++- .../hindsight_api/engine/parsers/__init__.py | 60 ++ .../hindsight_api/engine/parsers/base.py | 49 ++ .../engine/parsers/markitdown.py | 109 ++++ .../hindsight_api/engine/storage/__init__.py | 77 +++ .../hindsight_api/engine/storage/azure.py | 62 ++ .../hindsight_api/engine/storage/base.py | 83 +++ .../hindsight_api/engine/storage/gcs.py | 59 ++ .../engine/storage/postgresql.py | 139 +++++ .../hindsight_api/engine/storage/s3.py | 71 +++ hindsight-api/hindsight_api/main.py | 16 + hindsight-api/hindsight_api/worker/poller.py | 17 +- hindsight-api/pyproject.toml | 4 + hindsight-api/tests/test_file_retain.py | 553 ++++++++++++++++++ hindsight-api/tests/test_file_storage_s3.py | 252 ++++++++ hindsight-api/tests/test_worker.py | 100 +++- hindsight-cli/Cargo.toml | 4 +- hindsight-cli/src/api.rs | 72 ++- hindsight-cli/src/commands/memory.rs | 252 ++++---- hindsight-clients/go/api/openapi.yaml | 106 ++++ hindsight-clients/go/api_files.go | 209 +++++++ hindsight-clients/go/client.go | 3 + hindsight-clients/go/model_features_info.go | 31 +- .../go/model_file_retain_response.go | 159 +++++ .../python/.openapi-generator/FILES | 2 + .../hindsight_client/hindsight_client.py | 41 +- .../python/hindsight_client_api/__init__.py | 2 + .../hindsight_client_api/api/__init__.py | 1 + .../hindsight_client_api/api/files_api.py | 361 ++++++++++++ .../hindsight_client_api/models/__init__.py | 1 + .../models/features_info.py | 6 +- .../models/file_retain_response.py | 87 +++ hindsight-clients/rust/build.rs | 33 ++ .../typescript/generated/sdk.gen.ts | 55 +- .../typescript/generated/types.gen.ts | 77 ++- hindsight-clients/typescript/src/index.ts | 36 ++ .../src/app/api/files/retain/route.ts | 52 ++ .../src/components/bank-selector.tsx | 257 ++++---- hindsight-control-plane/src/lib/api.ts | 59 ++ .../src/lib/hindsight-client.ts | 8 + hindsight-docs/docs/developer/api/retain.mdx | 45 +- .../docs/developer/configuration.md | 115 ++++ hindsight-docs/examples/api/retain.mjs | 16 + hindsight-docs/examples/api/retain.py | 33 ++ hindsight-docs/examples/api/retain.sh | 23 + hindsight-docs/examples/api/sample.pdf | Bin 0 -> 26662 bytes hindsight-docs/static/openapi.json | 129 +++- scripts/generate-clients.sh | 7 + uv.lock | 473 ++++++++++++++- 54 files changed, 4809 insertions(+), 356 deletions(-) create mode 100644 docker/docker-compose/s3-file-storage/docker-compose.yaml create mode 100644 docker/docker-compose/s3-file-storage/s3.json create mode 100644 hindsight-api/hindsight_api/alembic/versions/a1b2c3d4e5f6_add_file_storage_table.py create mode 100644 hindsight-api/hindsight_api/engine/parsers/__init__.py create mode 100644 hindsight-api/hindsight_api/engine/parsers/base.py create mode 100644 hindsight-api/hindsight_api/engine/parsers/markitdown.py create mode 100644 hindsight-api/hindsight_api/engine/storage/__init__.py create mode 100644 hindsight-api/hindsight_api/engine/storage/azure.py create mode 100644 hindsight-api/hindsight_api/engine/storage/base.py create mode 100644 hindsight-api/hindsight_api/engine/storage/gcs.py create mode 100644 hindsight-api/hindsight_api/engine/storage/postgresql.py create mode 100644 hindsight-api/hindsight_api/engine/storage/s3.py create mode 100644 hindsight-api/tests/test_file_retain.py create mode 100644 hindsight-api/tests/test_file_storage_s3.py create mode 100644 hindsight-clients/go/api_files.go create mode 100644 hindsight-clients/go/model_file_retain_response.go create mode 100644 hindsight-clients/python/hindsight_client_api/api/files_api.py create mode 100644 hindsight-clients/python/hindsight_client_api/models/file_retain_response.py create mode 100644 hindsight-control-plane/src/app/api/files/retain/route.ts create mode 100644 hindsight-docs/examples/api/sample.pdf diff --git a/docker/docker-compose/s3-file-storage/docker-compose.yaml b/docker/docker-compose/s3-file-storage/docker-compose.yaml new file mode 100644 index 00000000..57424b12 --- /dev/null +++ b/docker/docker-compose/s3-file-storage/docker-compose.yaml @@ -0,0 +1,83 @@ +# Docker Compose file for Hindsight with S3 file storage (SeaweedFS) +# +# SeaweedFS (Apache 2.0) provides an S3-compatible object storage backend +# for storing uploaded files instead of PostgreSQL BYTEA storage. +# +# Make sure to set the required environment variables before running: +# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user +# - Configure LLM provider variables as needed (see below in the hindsight service) +# +# Usage: +# docker compose up -d +# +# Optional environment variables with defaults: +# - HINDSIGHT_VERSION: Hindsight application version (default: latest) +# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user) +# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db) +# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18) +# - SEAWEEDFS_S3_ACCESS_KEY: S3 access key (default: hindsight_s3_key) +# - SEAWEEDFS_S3_SECRET_KEY: S3 secret key (default: hindsight_s3_secret) + +services: + db: + image: pgvector/pgvector:pg${HINDSIGHT_DB_VERSION:-18} + container_name: hindsight-db + restart: always + environment: + POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user} + POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable} + POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db} + volumes: + - pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker + networks: + - hindsight-net + + seaweedfs: + image: chrislusf/seaweedfs:latest + container_name: hindsight-seaweedfs + restart: always + # Single-node mode: master + volume + filer + S3 gateway all in one process + command: > + server + -s3 + -s3.port=8333 + -s3.config=/etc/seaweedfs/s3.json + -ip.bind=0.0.0.0 + volumes: + - seaweedfs_data:/data + - ./s3.json:/etc/seaweedfs/s3.json:ro + # Expose S3 API port (uncomment to access from host) + # ports: + # - "8333:8333" + networks: + - hindsight-net + + hindsight: + image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest} + container_name: hindsight-app + ports: + - "8888:8888" + - "9999:9999" + environment: + - HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable} + - HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db} + # S3 file storage configuration (SeaweedFS) + - HINDSIGHT_API_FILE_STORAGE_TYPE=s3 + - HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=hindsight + - HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT=http://seaweedfs:8333 + - HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1 + - HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=${SEAWEEDFS_S3_ACCESS_KEY:-hindsight_s3_key} + - HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=${SEAWEEDFS_S3_SECRET_KEY:-hindsight_s3_secret} + depends_on: + - db + - seaweedfs + networks: + - hindsight-net + +networks: + hindsight-net: + driver: bridge + +volumes: + pg_data: + seaweedfs_data: diff --git a/docker/docker-compose/s3-file-storage/s3.json b/docker/docker-compose/s3-file-storage/s3.json new file mode 100644 index 00000000..1c57429f --- /dev/null +++ b/docker/docker-compose/s3-file-storage/s3.json @@ -0,0 +1,19 @@ +{ + "identities": [ + { + "name": "hindsight", + "credentials": [ + { + "accessKey": "hindsight_s3_key", + "secretKey": "hindsight_s3_secret" + } + ], + "actions": [ + "Admin", + "Read", + "Write", + "List" + ] + } + ] +} diff --git a/hindsight-api/hindsight_api/alembic/versions/a1b2c3d4e5f6_add_file_storage_table.py b/hindsight-api/hindsight_api/alembic/versions/a1b2c3d4e5f6_add_file_storage_table.py new file mode 100644 index 00000000..4187081d --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/a1b2c3d4e5f6_add_file_storage_table.py @@ -0,0 +1,70 @@ +"""Add file_storage table for BYTEA-based file storage + +Revision ID: a1b2c3d4e5f6 +Revises: y0t1u2v3w4x5 +Create Date: 2026-02-16 + +Creates a dedicated table for storing uploaded files using BYTEA. +This provides zero-config file storage that "just works" for development +and small deployments. For production/scale, use S3-compatible storage. + +Files are stored in a separate table to avoid bloating the documents table. +""" + +from collections.abc import Sequence + +from alembic import context, op + +revision: str = "a1b2c3d4e5f6" +down_revision: str | Sequence[str] | None = "y0t1u2v3w4x5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Get schema prefix for table names (required for multi-tenant support).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def upgrade() -> None: + """Create file_storage table for BYTEA storage.""" + schema = _get_schema_prefix() + + # Create file_storage table (minimal: just key + data) + op.execute( + f""" + CREATE TABLE {schema}file_storage ( + storage_key TEXT PRIMARY KEY, + data BYTEA NOT NULL + ) + """ + ) + + # Add file tracking columns to documents table + op.execute( + f""" + ALTER TABLE {schema}documents + ADD COLUMN IF NOT EXISTS file_storage_key TEXT, + ADD COLUMN IF NOT EXISTS file_original_name TEXT, + ADD COLUMN IF NOT EXISTS file_content_type TEXT + """ + ) + + +def downgrade() -> None: + """Remove file_storage table and related columns.""" + schema = _get_schema_prefix() + + # Drop columns from documents table + op.execute( + f""" + ALTER TABLE {schema}documents + DROP COLUMN IF EXISTS file_storage_key, + DROP COLUMN IF EXISTS file_original_name, + DROP COLUMN IF EXISTS file_content_type + """ + ) + + # Drop file_storage table + op.execute(f"DROP TABLE IF EXISTS {schema}file_storage") diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index 31f2936f..f2b9cd96 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -13,7 +13,7 @@ from contextlib import asynccontextmanager from datetime import datetime from typing import Any, Literal -from fastapi import Depends, FastAPI, Header, HTTPException, Query +from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile from hindsight_api.extensions import AuthenticationError @@ -430,6 +430,36 @@ class RetainRequest(BaseModel): ) +class FileRetainMetadata(BaseModel): + """Metadata for a single file in file retain request.""" + + document_id: str | None = Field(default=None, description="Document ID (auto-generated if not provided)") + context: str | None = Field(default=None, description="Context for the file") + metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata") + tags: list[str] | None = Field(default=None, description="Tags for this file") + timestamp: str | None = Field(default=None, description="ISO timestamp") + + +class FileRetainRequest(BaseModel): + """Request model for file retain endpoint.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "files_metadata": [ + {"document_id": "report_2024", "tags": ["quarterly"]}, + {"context": "meeting notes"}, + ], + } + } + ) + + files_metadata: list[FileRetainMetadata] | None = Field( + default=None, + description="Metadata for each file (optional, must match number of files if provided)", + ) + + class RetainResponse(BaseModel): """Response model for retain endpoint.""" @@ -454,7 +484,7 @@ class RetainResponse(BaseModel): ) operation_id: str | None = Field( default=None, - description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations and find this ID. Only present when async=true.", + description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.", ) usage: TokenUsage | None = Field( default=None, @@ -462,6 +492,26 @@ class RetainResponse(BaseModel): ) +class FileRetainResponse(BaseModel): + """Response model for file upload endpoint.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "operation_ids": [ + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002", + ], + } + }, + ) + + operation_ids: list[str] = Field( + description="Operation IDs for tracking file conversion operations. Use GET /v1/default/banks/{bank_id}/operations to list operations." + ) + + class FactsIncludeOptions(BaseModel): """Options for including facts (based_on) in reflect results.""" @@ -1423,6 +1473,7 @@ class FeaturesInfo(BaseModel): mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: bool = Field(description="Whether the background worker is enabled") bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled") + file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled") class VersionResponse(BaseModel): @@ -1437,6 +1488,7 @@ class VersionResponse(BaseModel): "mcp": True, "worker": True, "bank_config_api": False, + "file_upload_api": True, }, } } @@ -1731,6 +1783,7 @@ def _register_routes(app: FastAPI): mcp=config.mcp_enabled, worker=config.worker_enabled, bank_config_api=config.enable_bank_config_api, + file_upload_api=config.enable_file_upload_api, ), ) @@ -3632,6 +3685,147 @@ def _register_routes(app: FastAPI): logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.post( + "/v1/default/banks/{bank_id}/files/retain", + response_model=FileRetainResponse, + summary="Convert files to memories", + description="Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\n" + "This endpoint handles file upload, conversion, and memory creation in a single operation.\n\n" + "**Features:**\n" + "- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n" + "- Automatic file-to-markdown conversion using pluggable parsers\n" + "- Files stored in object storage (PostgreSQL by default, S3 for production)\n" + "- Each file becomes a separate document with optional metadata/tags\n" + "- Always processes asynchronously — returns operation IDs immediately\n\n" + "**The system automatically:**\n" + "1. Stores uploaded files in object storage\n" + "2. Converts files to markdown\n" + "3. Creates document records with file metadata\n" + "4. Extracts facts and creates memory units (same as regular retain)\n\n" + "Use the operations endpoint to monitor progress.\n\n" + "**Request format:** multipart/form-data with:\n" + "- `files`: One or more files to upload\n" + "- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n" + "**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).", + operation_id="file_retain", + tags=["Files"], + ) + async def api_file_retain( + bank_id: str, + files: list[UploadFile] = File(..., description="Files to upload and convert"), + request: str = Form(..., description="JSON string with FileRetainRequest model"), + request_context: RequestContext = Depends(get_request_context), + ): + """Upload and convert files to memories.""" + from hindsight_api.config import get_config + + config = get_config() + + # Check if file upload API is enabled + if not config.enable_file_upload_api: + raise HTTPException( + status_code=404, + detail="File upload API is disabled. Set HINDSIGHT_API_ENABLE_FILE_UPLOAD_API=true to enable.", + ) + + try: + # Parse request JSON + try: + request_data = FileRetainRequest.model_validate_json(request) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Invalid request JSON: {str(e)}", + ) + + # Validate file count + if len(files) > config.file_conversion_max_batch_size: + raise HTTPException( + status_code=400, + detail=f"Too many files. Maximum {config.file_conversion_max_batch_size} files per request.", + ) + + # Validate files_metadata count matches files count if provided + if request_data.files_metadata and len(request_data.files_metadata) != len(files): + raise HTTPException( + status_code=400, + detail=f"files_metadata count ({len(request_data.files_metadata)}) must match files count ({len(files)})", + ) + + # Prepare file items and calculate total batch size + file_items = [] + total_batch_size = 0 + + for i, file in enumerate(files): + # Read file content to check size + file_content = await file.read() + size = len(file_content) + total_batch_size += size + + # Create a temporary file-like object from the bytes + import io + + file_obj = io.BytesIO(file_content) + + # Create a mock UploadFile with the necessary attributes + class FileWrapper: + def __init__(self, content, filename, content_type): + self._content = content + self.filename = filename + self.content_type = content_type + self._buffer = io.BytesIO(content) + + async def read(self): + return self._content + + wrapped_file = FileWrapper(file_content, file.filename, file.content_type) + + # Get per-file metadata + file_meta = request_data.files_metadata[i] if request_data.files_metadata else FileRetainMetadata() + doc_id = file_meta.document_id or f"file_{uuid.uuid4()}" + + item = { + "file": wrapped_file, + "document_id": doc_id, + "context": file_meta.context, + "metadata": file_meta.metadata or {}, + "tags": file_meta.tags or [], + "timestamp": file_meta.timestamp, + } + file_items.append(item) + + # Check total batch size after processing all files + if total_batch_size > config.file_conversion_max_batch_size_bytes: + total_mb = total_batch_size / (1024 * 1024) + raise HTTPException( + status_code=400, + detail=f"Total batch size ({total_mb:.1f}MB) exceeds maximum of {config.file_conversion_max_batch_size_mb}MB", + ) + + result = await app.state.memory.submit_async_file_retain( + bank_id=bank_id, + file_items=file_items, + parser=config.file_parser, + document_tags=None, + request_context=request_context, + ) + return FileRetainResponse.model_validate( + { + "operation_ids": result["operation_ids"], + } + ) + + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in /v1/default/banks/{bank_id}/files/retain: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.delete( "/v1/default/banks/{bank_id}/memories", response_model=DeleteResponse, diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index 48531015..dcb42aa3 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -259,6 +259,24 @@ ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS" ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED" ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS" +# File storage configuration +ENV_FILE_STORAGE_TYPE = "HINDSIGHT_API_FILE_STORAGE_TYPE" +ENV_FILE_STORAGE_S3_BUCKET = "HINDSIGHT_API_FILE_STORAGE_S3_BUCKET" +ENV_FILE_STORAGE_S3_REGION = "HINDSIGHT_API_FILE_STORAGE_S3_REGION" +ENV_FILE_STORAGE_S3_ENDPOINT = "HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT" +ENV_FILE_STORAGE_S3_ACCESS_KEY_ID = "HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID" +ENV_FILE_STORAGE_S3_SECRET_ACCESS_KEY = "HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY" +ENV_FILE_STORAGE_GCS_BUCKET = "HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET" +ENV_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY" +ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER" +ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME" +ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY" +ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER" +ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB" +ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE" +ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API" +ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN" + # Observations settings (consolidated knowledge from facts) ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS" ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE" @@ -383,6 +401,14 @@ DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True) DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds +# File storage defaults +DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage +DEFAULT_FILE_PARSER = "markitdown" # File parser to use (markitdown is the only supported parser) +DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined) +DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload +DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint +DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage) + # Observations defaults (consolidated knowledge from facts) DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization) @@ -607,6 +633,24 @@ class HindsightConfig: retain_batch_enabled: bool retain_batch_poll_interval_seconds: int + # File storage (static - server-level only) + file_storage_type: str # "native" (PostgreSQL) or "s3" (S3-compatible) + file_storage_s3_bucket: str | None # S3 bucket name (required for s3 storage) + file_storage_s3_region: str | None # S3 region (optional, uses SDK default) + file_storage_s3_endpoint: str | None # S3 endpoint URL (for MinIO, R2, etc.) + file_storage_s3_access_key_id: str | None # S3 access key (optional, uses env/IAM) + file_storage_s3_secret_access_key: str | None # S3 secret key (optional, uses env/IAM) + file_storage_gcs_bucket: str | None # GCS bucket name (required for gcs storage) + file_storage_gcs_service_account_key: str | None # GCS service account key JSON (optional, uses ADC) + file_storage_azure_container: str | None # Azure container name (required for azure storage) + file_storage_azure_account_name: str | None # Azure storage account name + file_storage_azure_account_key: str | None # Azure storage account key + file_parser: str # File parser to use (e.g., "markitdown") + file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined) + file_conversion_max_batch_size: int # Max files per request + enable_file_upload_api: bool + file_delete_after_retain: bool + # Observations settings (consolidated knowledge from facts) enable_observations: bool consolidation_batch_size: int @@ -663,6 +707,11 @@ class HindsightConfig: "reranker_cohere_base_url", # Service Account Keys "llm_vertexai_service_account_key", + # File storage credentials + "file_storage_s3_access_key_id", + "file_storage_s3_secret_access_key", + "file_storage_gcs_service_account_key", + "file_storage_azure_account_key", } # CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank @@ -677,6 +726,11 @@ class HindsightConfig: "enable_observations", } + @property + def file_conversion_max_batch_size_bytes(self) -> int: + """Get maximum total batch size in bytes.""" + return self.file_conversion_max_batch_size_mb * 1024 * 1024 + @classmethod def get_configurable_fields(cls) -> set[str]: """ @@ -963,6 +1017,31 @@ class HindsightConfig: retain_batch_poll_interval_seconds=int( os.getenv(ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS, str(DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS)) ), + # File storage + file_storage_type=os.getenv(ENV_FILE_STORAGE_TYPE, DEFAULT_FILE_STORAGE_TYPE), + file_storage_s3_bucket=os.getenv(ENV_FILE_STORAGE_S3_BUCKET) or None, + file_storage_s3_region=os.getenv(ENV_FILE_STORAGE_S3_REGION) or None, + file_storage_s3_endpoint=os.getenv(ENV_FILE_STORAGE_S3_ENDPOINT) or None, + file_storage_s3_access_key_id=os.getenv(ENV_FILE_STORAGE_S3_ACCESS_KEY_ID) or None, + file_storage_s3_secret_access_key=os.getenv(ENV_FILE_STORAGE_S3_SECRET_ACCESS_KEY) or None, + file_storage_gcs_bucket=os.getenv(ENV_FILE_STORAGE_GCS_BUCKET) or None, + file_storage_gcs_service_account_key=os.getenv(ENV_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY) or None, + file_storage_azure_container=os.getenv(ENV_FILE_STORAGE_AZURE_CONTAINER) or None, + file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None, + file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None, + file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER), + file_conversion_max_batch_size_mb=int( + os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB)) + ), + file_conversion_max_batch_size=int( + os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE)) + ), + enable_file_upload_api=os.getenv(ENV_ENABLE_FILE_UPLOAD_API, str(DEFAULT_ENABLE_FILE_UPLOAD_API)).lower() + == "true", + file_delete_after_retain=os.getenv( + ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN) + ).lower() + == "true", # Observations settings (consolidated knowledge from facts) enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true", consolidation_batch_size=int( diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 3114e137..61522536 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -79,6 +79,7 @@ _PROTECTED_TABLES = frozenset( "documents", "chunks", "async_operations", + "file_storage", ] ) @@ -585,8 +586,165 @@ class MemoryEngine(MemoryEngineInterface): operation_id=operation_id, ) + # If this retain was triggered by file conversion, update document with file metadata + file_metadata = task_dict.get("_file_metadata") + if file_metadata and len(contents) == 1: + doc_id = contents[0].get("document_id") + if doc_id: + pool = await self._get_pool() + async with acquire_with_retry(pool) as conn: + await conn.execute( + f""" + UPDATE {fq_table("documents")} + SET file_storage_key = $3, + file_original_name = $4, + file_content_type = $5, + updated_at = NOW() + WHERE id = $1 AND bank_id = $2 + """, + doc_id, + bank_id, + file_metadata["file_storage_key"], + file_metadata["file_original_name"], + file_metadata["file_content_type"], + ) + logger.info(f"[BATCH_RETAIN_TASK] Completed background batch retain for bank_id={bank_id}") + async def _handle_file_convert_retain(self, task_dict: dict[str, Any]): + """ + Handler for file conversion tasks. + + Converts a file to markdown, then submits a separate async retain operation + and marks this conversion as completed — all in a single transaction. + This avoids holding a worker slot during the expensive retain pipeline. + + Args: + task_dict: Dict with 'bank_id', 'storage_key', 'parser', etc. + + Raises: + ValueError: If required fields are missing + Exception: Any exception from conversion (includes filename in error) + """ + bank_id = task_dict.get("bank_id") + storage_key = task_dict.get("storage_key") + document_id = task_dict.get("document_id") + operation_id = task_dict.get("operation_id") + filename = task_dict.get("original_filename", "unknown") + + if not all([bank_id, storage_key, document_id]): + raise ValueError("bank_id, storage_key, and document_id are required for file_convert_retain task") + + logger.info(f"[FILE_CONVERT_RETAIN] Starting for bank_id={bank_id}, document_id={document_id}, file={filename}") + + try: + # Retrieve file from storage + file_data = await self._file_storage.retrieve(storage_key) + + # Convert to markdown + parser = self._parser_registry.get_parser( + name=task_dict.get("parser"), + filename=filename, + content_type=task_dict.get("content_type"), + ) + markdown_content = await parser.convert(file_data, filename) + except Exception as e: + # Re-raise with filename context for better error reporting + error_msg = f"Failed to parse file '{filename}': {str(e)}" + logger.error(f"[FILE_CONVERT_RETAIN] {error_msg}") + raise RuntimeError(error_msg) from e + + logger.info( + f"[FILE_CONVERT_RETAIN] Converted file for bank_id={bank_id}, " + f"document_id={document_id}, {len(markdown_content)} chars. Submitting retain task." + ) + + # Build retain task payload + retain_contents = [ + { + "content": markdown_content, + "document_id": document_id, + "context": task_dict.get("context"), + "metadata": task_dict.get("metadata", {}), + "tags": task_dict.get("tags", []), + "timestamp": task_dict.get("timestamp"), + } + ] + document_tags = task_dict.get("document_tags") + + retain_task_payload: dict[str, Any] = {"contents": retain_contents} + if document_tags: + retain_task_payload["document_tags"] = document_tags + + # Pass tenant/api_key context through to retain task + if task_dict.get("_tenant_id"): + retain_task_payload["_tenant_id"] = task_dict["_tenant_id"] + if task_dict.get("_api_key_id"): + retain_task_payload["_api_key_id"] = task_dict["_api_key_id"] + + # File metadata to attach after retain creates the document + retain_task_payload["_file_metadata"] = { + "file_storage_key": storage_key, + "file_original_name": task_dict["original_filename"], + "file_content_type": task_dict["content_type"], + } + + # In one transaction: create the retain async operation AND mark this conversion as completed + retain_operation_id = uuid.uuid4() + pool = await self._get_pool() + async with acquire_with_retry(pool) as conn: + async with conn.transaction(): + # Create the retain operation record + await conn.execute( + f""" + INSERT INTO {fq_table("async_operations")} + (operation_id, bank_id, operation_type, result_metadata, status) + VALUES ($1, $2, $3, $4, $5) + """, + retain_operation_id, + bank_id, + "retain", + json.dumps({}), + "pending", + ) + + # Mark this file_convert_retain operation as completed + if operation_id: + await conn.execute( + f""" + UPDATE {fq_table("async_operations")} + SET status = 'completed', updated_at = NOW(), completed_at = NOW() + WHERE operation_id = $1 + """, + uuid.UUID(operation_id), + ) + + # Submit the retain task to the task backend (outside the transaction) + full_retain_payload = { + "type": "batch_retain", + "operation_id": str(retain_operation_id), + "bank_id": bank_id, + **retain_task_payload, + } + await self._task_backend.submit_task(full_retain_payload) + + logger.info( + f"[FILE_CONVERT_RETAIN] Completed conversion for bank_id={bank_id}, " + f"document_id={document_id}. Retain task submitted as operation {retain_operation_id}" + ) + + # Delete file bytes from storage if configured (saves storage costs) + from ..config import get_config + + config = get_config() + if config.file_delete_after_retain: + try: + await self._file_storage.delete(storage_key) + logger.info(f"[FILE_CONVERT_RETAIN] Deleted file bytes for {storage_key} (conversion completed)") + except Exception as e: + # Non-fatal - log and continue + logger.warning(f"[FILE_CONVERT_RETAIN] Failed to delete file {storage_key}: {e}") + async def _handle_consolidation(self, task_dict: dict[str, Any]): """ Handler for consolidation tasks. @@ -798,6 +956,8 @@ class MemoryEngine(MemoryEngineInterface): try: if task_type == "batch_retain": await self._handle_batch_retain(task_dict) + elif task_type == "file_convert_retain": + await self._handle_file_convert_retain(task_dict) elif task_type == "consolidation": await self._handle_consolidation(task_dict) elif task_type == "refresh_mental_model": @@ -810,7 +970,8 @@ class MemoryEngine(MemoryEngineInterface): return # Task succeeded - mark operation as completed - if operation_id: + # file_convert_retain marks itself as completed in a transaction, skip double-marking + if operation_id and task_type != "file_convert_retain": await self._mark_operation_completed(operation_id) except Exception as e: @@ -823,14 +984,19 @@ class MemoryEngine(MemoryEngineInterface): error_traceback = traceback.format_exc() traceback.print_exc() - if retry_count < max_retries: + # Don't retry file conversion - if conversion fails, it won't succeed on retry + # (missing OCR, corrupted file, unsupported format, etc.) + should_retry = retry_count < max_retries and task_type != "file_convert_retain" + + if should_retry: # Reschedule with incremented retry count task_dict["retry_count"] = retry_count + 1 logger.info(f"Rescheduling task {task_type} (retry {retry_count + 1}/{max_retries})") await self._task_backend.submit_task(task_dict) else: - # Max retries exceeded - mark operation as failed - logger.error(f"Max retries exceeded for task {task_type}, marking as failed") + # Max retries exceeded or non-retryable task - mark operation as failed + reason = "non-retryable task type" if task_type == "file_convert_retain" else "max retries exceeded" + logger.error(f"Not retrying task {task_type} ({reason}), marking as failed") if operation_id: await self._mark_operation_failed(operation_id, str(e), error_traceback) @@ -1196,6 +1362,27 @@ class MemoryEngine(MemoryEngineInterface): self._config_resolver = ConfigResolver(pool=self._pool, tenant_extension=self._tenant_extension) logger.debug("Config resolver initialized for hierarchical configuration") + # Initialize file storage + from .storage import create_file_storage + + config = get_config() + self._file_storage = create_file_storage( + storage_type=config.file_storage_type, + pool_getter=lambda: self._pool, + schema=get_current_schema() if get_current_schema() != config.database_schema else None, + ) + logger.debug(f"File storage initialized ({config.file_storage_type})") + + # Initialize parser registry + from .parsers import FileParserRegistry, MarkitdownParser + + self._parser_registry = FileParserRegistry() + try: + self._parser_registry.register(MarkitdownParser()) + logger.debug("Registered markitdown parser") + except ImportError: + logger.warning("markitdown not available - file parsing disabled") + # Set executor for task backend and initialize self._task_backend.set_executor(self.execute_task) await self._task_backend.initialize() @@ -5938,13 +6125,14 @@ class MemoryEngine(MemoryEngineInterface): async with acquire_with_retry(pool) as conn: await conn.execute( f""" - INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata) - VALUES ($1, $2, $3, $4) + INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status) + VALUES ($1, $2, $3, $4, $5) """, operation_id, bank_id, operation_type, json.dumps(result_metadata or {}), + "pending", ) # Build and submit task payload @@ -6092,6 +6280,116 @@ class MemoryEngine(MemoryEngineInterface): "items_count": len(contents), } + async def submit_async_file_retain( + self, + bank_id: str, + file_items: list[dict[str, Any]], + parser: str, + document_tags: list[str] | None, + request_context: "RequestContext", + ) -> dict[str, Any]: + """ + Submit batch file conversion + retain operation. + + Each file is converted to markdown and then retained as a memory. + Files are stored in object storage and conversion happens asynchronously. + + Args: + bank_id: Bank ID + file_items: List of file items, each containing: + - file: UploadFile object (FastAPI) + - document_id: Document ID + - context: Optional context + - metadata: Optional metadata dict + - tags: Optional tags list + - timestamp: Optional timestamp + parser: Parser name (e.g., "markitdown") + document_tags: Tags applied to all documents + request_context: Request context for authentication + + Returns: + dict with operation_id and files_count + """ + await self._authenticate_tenant(request_context) + + config = get_config() + + # Validate file count + if len(file_items) > config.file_conversion_max_batch_size: + raise ValueError(f"Too many files. Maximum {config.file_conversion_max_batch_size} files per request.") + + # Read all files and validate total batch size + files_data = [] + total_batch_size = 0 + + for item in file_items: + file = item["file"] + file_data = await file.read() + total_batch_size += len(file_data) + files_data.append((item, file, file_data)) + + # Validate total batch size + if total_batch_size > config.file_conversion_max_batch_size_bytes: + total_mb = total_batch_size / (1024 * 1024) + raise ValueError( + f"Total batch size ({total_mb:.1f}MB) exceeds maximum of {config.file_conversion_max_batch_size_mb}MB" + ) + + # Submit individual operation for each file + operation_ids = [] + for item, file, file_data in files_data: + # Generate storage key + storage_key = f"banks/{bank_id}/files/{item['document_id']}/{file.filename}" + + # Store file in object storage + await self._file_storage.store( + file_data=file_data, + key=storage_key, + metadata={ + "content_type": file.content_type or "application/octet-stream", + "original_filename": file.filename, + "bank_id": bank_id, + "document_id": item["document_id"], + }, + ) + + # Create individual operation and submit task + task_payload: dict[str, Any] = { + "document_id": item["document_id"], + "storage_key": storage_key, + "original_filename": file.filename, + "content_type": file.content_type or "application/octet-stream", + "parser": parser, + "context": item.get("context"), + "metadata": item.get("metadata", {}), + "tags": item.get("tags", []), + "document_tags": document_tags or [], + "timestamp": item.get("timestamp"), + } + + # Pass tenant_id and api_key_id through task payload + if request_context.tenant_id: + task_payload["_tenant_id"] = request_context.tenant_id + if request_context.api_key_id: + task_payload["_api_key_id"] = request_context.api_key_id + + result = await self._submit_async_operation( + bank_id=bank_id, + operation_type="file_convert_retain", + task_type="file_convert_retain", + task_payload=task_payload, + result_metadata={ + "original_filename": file.filename, + }, + dedupe_by_bank=False, + ) + operation_ids.append(result["operation_id"]) + + return { + "operation_ids": operation_ids, + "files_count": len(file_items), + } + async def submit_async_consolidation( self, bank_id: str, diff --git a/hindsight-api/hindsight_api/engine/parsers/__init__.py b/hindsight-api/hindsight_api/engine/parsers/__init__.py new file mode 100644 index 00000000..8476e1bc --- /dev/null +++ b/hindsight-api/hindsight_api/engine/parsers/__init__.py @@ -0,0 +1,60 @@ +"""File parser implementations.""" + +from .base import FileParser +from .markitdown import MarkitdownParser + +__all__ = ["FileParser", "MarkitdownParser", "FileParserRegistry"] + + +class FileParserRegistry: + """Registry for file parsers with auto-detection.""" + + def __init__(self): + """Initialize empty parser registry.""" + self._parsers: dict[str, FileParser] = {} + + def register(self, parser: FileParser): + """ + Register a parser. + + Args: + parser: FileParser instance + """ + self._parsers[parser.name()] = parser + + def get_parser( + self, + name: str | None, + filename: str, + content_type: str | None = None, + ) -> FileParser: + """ + Get parser by name or auto-detect. + + Args: + name: Parser name (e.g., "markitdown") or None for auto-detect + filename: File name for auto-detection + content_type: MIME type (optional) + + Returns: + FileParser instance + + Raises: + ValueError: If no suitable parser found + """ + if name: + # Explicit parser requested + if name not in self._parsers: + raise ValueError(f"Parser '{name}' not found. Available: {list(self._parsers.keys())}") + return self._parsers[name] + + # Auto-detect parser + for parser in self._parsers.values(): + if parser.supports(filename, content_type): + return parser + + raise ValueError(f"No parser found for {filename}. Available parsers: {list(self._parsers.keys())}") + + def list_parsers(self) -> list[str]: + """Get list of registered parser names.""" + return list(self._parsers.keys()) diff --git a/hindsight-api/hindsight_api/engine/parsers/base.py b/hindsight-api/hindsight_api/engine/parsers/base.py new file mode 100644 index 00000000..30f9d50c --- /dev/null +++ b/hindsight-api/hindsight_api/engine/parsers/base.py @@ -0,0 +1,49 @@ +"""Abstract base class for file parsers.""" + +from abc import ABC, abstractmethod + + +class FileParser(ABC): + """Abstract base for file to markdown parsers.""" + + @abstractmethod + async def convert(self, file_data: bytes, filename: str) -> str: + """ + Parse file to markdown. + + Args: + file_data: Raw file bytes + filename: Original filename (used for format detection) + + Returns: + Markdown content as string + + Raises: + ValueError: If file format is not supported + RuntimeError: If parsing fails + """ + pass + + @abstractmethod + def supports(self, filename: str, content_type: str | None = None) -> bool: + """ + Check if parser supports this file type. + + Args: + filename: File name (used for extension check) + content_type: MIME type (optional) + + Returns: + True if this parser can handle the file + """ + pass + + @abstractmethod + def name(self) -> str: + """ + Get parser name. + + Returns: + Parser name (e.g., "markitdown") + """ + pass diff --git a/hindsight-api/hindsight_api/engine/parsers/markitdown.py b/hindsight-api/hindsight_api/engine/parsers/markitdown.py new file mode 100644 index 00000000..370e5d2a --- /dev/null +++ b/hindsight-api/hindsight_api/engine/parsers/markitdown.py @@ -0,0 +1,109 @@ +"""Markitdown parser implementation.""" + +import asyncio +import logging +import tempfile +from pathlib import Path + +from .base import FileParser + +logger = logging.getLogger(__name__) + + +class MarkitdownParser(FileParser): + """ + Markitdown file parser. + + Uses Microsoft's markitdown library to convert various file formats + to markdown including PDF, Office docs, images (via OCR), audio, HTML. + + Supported formats: + - PDF (.pdf) + - Word (.docx, .doc) + - PowerPoint (.pptx, .ppt) + - Excel (.xlsx, .xls) + - Images (.jpg, .jpeg, .png) - with OCR + - HTML (.html, .htm) + - Text (.txt, .md) + - Audio (.mp3, .wav) - with transcription + """ + + def __init__(self): + """Initialize markitdown parser.""" + # Lazy import to avoid requiring markitdown for all users + try: + from markitdown import MarkItDown + + self._markitdown = MarkItDown() + except ImportError as e: + raise ImportError( + "markitdown package is required for file parsing. Install with: pip install markitdown" + ) from e + + async def convert(self, file_data: bytes, filename: str) -> str: + """Parse file to markdown using markitdown.""" + # markitdown is synchronous, so we run it in executor to avoid blocking + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._convert_sync, file_data, filename) + + def _convert_sync(self, file_data: bytes, filename: str) -> str: + """Synchronous parsing (runs in thread pool).""" + # Write to temp file (markitdown requires file path) + with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp: + tmp.write(file_data) + tmp_path = tmp.name + + try: + # Parse using markitdown + result = self._markitdown.convert(tmp_path) + + if not result or not result.text_content: + raise RuntimeError(f"No content extracted from '{filename}'") + + return result.text_content + + except Exception as e: + logger.error(f"Markitdown parsing failed for {filename}: {e}") + raise RuntimeError(f"Failed to parse '{filename}': {e}") from e + + finally: + # Clean up temp file + try: + Path(tmp_path).unlink() + except Exception: + pass + + def supports(self, filename: str, content_type: str | None = None) -> bool: + """Check if markitdown supports this file type.""" + # Supported extensions (from markitdown docs) + supported_extensions = { + # Documents + ".pdf", + ".docx", + ".doc", + ".pptx", + ".ppt", + ".xlsx", + ".xls", + # Images (with OCR) + ".jpg", + ".jpeg", + ".png", + # Web + ".html", + ".htm", + # Text + ".txt", + ".md", + ".csv", + # Audio (with transcription) + ".mp3", + ".wav", + } + + ext = Path(filename).suffix.lower() + return ext in supported_extensions + + def name(self) -> str: + """Get parser name.""" + return "markitdown" diff --git a/hindsight-api/hindsight_api/engine/storage/__init__.py b/hindsight-api/hindsight_api/engine/storage/__init__.py new file mode 100644 index 00000000..6d3949b3 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/storage/__init__.py @@ -0,0 +1,77 @@ +"""File storage backends for uploaded files.""" + +from collections.abc import Callable + +from .base import FileStorage +from .postgresql import PostgreSQLFileStorage + +__all__ = ["FileStorage", "PostgreSQLFileStorage", "create_file_storage"] + + +def create_file_storage( + storage_type: str, + pool_getter: Callable | None = None, + schema: str | None = None, + **kwargs, +) -> FileStorage: + """ + Create file storage backend based on configuration. + + Args: + storage_type: "native" (PostgreSQL BYTEA) or "s3" (S3-compatible object storage) + pool_getter: Database pool getter (required for native) + schema: Database schema (for native multi-tenant) + **kwargs: Additional args passed to storage backend + + Returns: + FileStorage instance + + Raises: + ValueError: If storage_type is unknown or required args are missing + """ + if storage_type == "native": + if not pool_getter: + raise ValueError("pool_getter required for native (PostgreSQL) storage") + return PostgreSQLFileStorage(pool_getter=pool_getter, schema=schema) + elif storage_type == "s3": + from ...config import get_config + from .s3 import S3FileStorage + + config = get_config() + bucket = config.file_storage_s3_bucket + if not bucket: + raise ValueError("HINDSIGHT_API_FILE_STORAGE_S3_BUCKET is required for S3 storage") + return S3FileStorage( + bucket=bucket, + region=config.file_storage_s3_region, + endpoint=config.file_storage_s3_endpoint, + access_key_id=config.file_storage_s3_access_key_id, + secret_access_key=config.file_storage_s3_secret_access_key, + ) + elif storage_type == "gcs": + from ...config import get_config + from .gcs import GCSFileStorage + + config = get_config() + bucket = config.file_storage_gcs_bucket + if not bucket: + raise ValueError("HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET is required for GCS storage") + return GCSFileStorage( + bucket=bucket, + service_account_key=config.file_storage_gcs_service_account_key, + ) + elif storage_type == "azure": + from ...config import get_config + from .azure import AzureFileStorage + + config = get_config() + container = config.file_storage_azure_container + if not container: + raise ValueError("HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER is required for Azure storage") + return AzureFileStorage( + container_name=container, + account_name=config.file_storage_azure_account_name, + account_key=config.file_storage_azure_account_key, + ) + else: + raise ValueError(f"Unknown storage type: {storage_type}. Supported: 'native', 's3', 'gcs', 'azure'.") diff --git a/hindsight-api/hindsight_api/engine/storage/azure.py b/hindsight-api/hindsight_api/engine/storage/azure.py new file mode 100644 index 00000000..0493a7c9 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/storage/azure.py @@ -0,0 +1,62 @@ +"""Azure Blob Storage backend using obstore.""" + +import logging +from datetime import timedelta + +import obstore as obs +from obstore.store import AzureStore + +from .base import FileStorage + +logger = logging.getLogger(__name__) + + +class AzureFileStorage(FileStorage): + """ + Azure Blob Storage backend. + + Uses obstore (Rust-backed) for high-throughput async access to Azure Blob Storage. + Supports account key, SAS token, and default Azure credentials. + """ + + def __init__( + self, + container_name: str, + account_name: str | None = None, + account_key: str | None = None, + ): + kwargs: dict = {} + if account_name: + kwargs["account_name"] = account_name + if account_key: + kwargs["account_key"] = account_key + + self._store = AzureStore(container_name, **kwargs) + logger.info(f"Initialized Azure file storage: container={container_name}, account={account_name}") + + async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str: + await obs.put_async(self._store, key, file_data) + logger.debug(f"Stored file {key} ({len(file_data)} bytes) in Azure") + return key + + async def retrieve(self, key: str) -> bytes: + try: + response = await obs.get_async(self._store, key) + return await response.bytes_async() + except Exception as e: + if "not found" in str(e).lower() or "BlobNotFound" in str(e): + raise FileNotFoundError(f"File not found: {key}") from e + raise + + async def delete(self, key: str) -> None: + await obs.delete_async(self._store, key) + + async def exists(self, key: str) -> bool: + try: + await obs.head_async(self._store, key) + return True + except Exception: + return False + + async def get_download_url(self, key: str, expires_in: int = 3600) -> str: + return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in)) diff --git a/hindsight-api/hindsight_api/engine/storage/base.py b/hindsight-api/hindsight_api/engine/storage/base.py new file mode 100644 index 00000000..ee3058a4 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/storage/base.py @@ -0,0 +1,83 @@ +"""Abstract base class for file storage backends.""" + +from abc import ABC, abstractmethod + + +class FileStorage(ABC): + """Abstract base for file storage backends.""" + + @abstractmethod + async def store( + self, + file_data: bytes, + key: str, + metadata: dict[str, str] | None = None, + ) -> str: + """ + Store file and return storage key. + + Args: + file_data: Raw file bytes + key: Storage key (e.g., "banks/{bank_id}/files/{file_id}.pdf") + metadata: Optional metadata to store with file + + Returns: + Storage key that can be used to retrieve the file + """ + pass + + @abstractmethod + async def retrieve(self, key: str) -> bytes: + """ + Retrieve file by storage key. + + Args: + key: Storage key + + Returns: + File data as bytes + + Raises: + FileNotFoundError: If file does not exist + """ + pass + + @abstractmethod + async def delete(self, key: str) -> None: + """ + Delete file by storage key. + + Args: + key: Storage key + """ + pass + + @abstractmethod + async def exists(self, key: str) -> bool: + """ + Check if file exists. + + Args: + key: Storage key + + Returns: + True if file exists, False otherwise + """ + pass + + @abstractmethod + async def get_download_url(self, key: str, expires_in: int = 3600) -> str: + """ + Get a URL for downloading the file. + + For PostgreSQL storage, this might be a relative API path. + For S3, this would be a pre-signed URL. + + Args: + key: Storage key + expires_in: Expiration time in seconds (may be ignored for some backends) + + Returns: + Download URL or path + """ + pass diff --git a/hindsight-api/hindsight_api/engine/storage/gcs.py b/hindsight-api/hindsight_api/engine/storage/gcs.py new file mode 100644 index 00000000..f44968bf --- /dev/null +++ b/hindsight-api/hindsight_api/engine/storage/gcs.py @@ -0,0 +1,59 @@ +"""Google Cloud Storage backend using obstore.""" + +import logging +from datetime import timedelta + +import obstore as obs +from obstore.store import GCSStore + +from .base import FileStorage + +logger = logging.getLogger(__name__) + + +class GCSFileStorage(FileStorage): + """ + Google Cloud Storage backend. + + Uses obstore (Rust-backed) for high-throughput async access to GCS. + Supports Application Default Credentials, service account keys, and explicit credentials. + """ + + def __init__( + self, + bucket: str, + service_account_key: str | None = None, + ): + kwargs: dict = {} + if service_account_key: + kwargs["service_account_key"] = service_account_key + + self._store = GCSStore(bucket, **kwargs) + logger.info(f"Initialized GCS file storage: bucket={bucket}") + + async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str: + await obs.put_async(self._store, key, file_data) + logger.debug(f"Stored file {key} ({len(file_data)} bytes) in GCS") + return key + + async def retrieve(self, key: str) -> bytes: + try: + response = await obs.get_async(self._store, key) + return await response.bytes_async() + except Exception as e: + if "not found" in str(e).lower(): + raise FileNotFoundError(f"File not found: {key}") from e + raise + + async def delete(self, key: str) -> None: + await obs.delete_async(self._store, key) + + async def exists(self, key: str) -> bool: + try: + await obs.head_async(self._store, key) + return True + except Exception: + return False + + async def get_download_url(self, key: str, expires_in: int = 3600) -> str: + return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in)) diff --git a/hindsight-api/hindsight_api/engine/storage/postgresql.py b/hindsight-api/hindsight_api/engine/storage/postgresql.py new file mode 100644 index 00000000..9dafdfd2 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/storage/postgresql.py @@ -0,0 +1,139 @@ +"""PostgreSQL BYTEA-based file storage (default, zero-config).""" + +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import asyncpg + +from .base import FileStorage + +logger = logging.getLogger(__name__) + + +def fq_table(table: str, schema: str | None = None) -> str: + """Get fully-qualified table name with optional schema prefix.""" + if schema: + return f'"{schema}".{table}' + return table + + +class PostgreSQLFileStorage(FileStorage): + """ + PostgreSQL BYTEA-based file storage. + + Stores files directly in PostgreSQL using BYTEA columns. + This is the default storage backend - zero configuration required! + + Pros: + - Works out of the box (no external dependencies) + - Transactional consistency with database + - Simple backups (included in pg_dump) + - Good performance for <10MB files + + Cons: + - Database bloat for large/many files + - Not ideal for distributed deployments + - Higher cost than object storage at scale + + For production/scale, consider S3FileStorage instead. + """ + + def __init__(self, pool_getter: Callable[[], "asyncpg.Pool"], schema: str | None = None): + """ + Initialize PostgreSQL file storage. + + Args: + pool_getter: Function that returns asyncpg connection pool + schema: Database schema (for multi-tenant support) + """ + self._pool_getter = pool_getter + self._schema = schema + + async def store( + self, + file_data: bytes, + key: str, + metadata: dict[str, str] | None = None, + ) -> str: + """Store file in PostgreSQL.""" + pool = self._pool_getter() + + async with pool.acquire() as conn: + await conn.execute( + f""" + INSERT INTO {fq_table("file_storage", self._schema)} + (storage_key, data) + VALUES ($1, $2) + ON CONFLICT (storage_key) DO UPDATE SET + data = EXCLUDED.data + """, + key, + file_data, + ) + + logger.debug(f"Stored file {key} ({len(file_data)} bytes) in PostgreSQL") + return key + + async def retrieve(self, key: str) -> bytes: + """Retrieve file from PostgreSQL.""" + pool = self._pool_getter() + + async with pool.acquire() as conn: + row = await conn.fetchrow( + f""" + SELECT data FROM {fq_table("file_storage", self._schema)} + WHERE storage_key = $1 + """, + key, + ) + + if not row: + raise FileNotFoundError(f"File not found: {key}") + + return bytes(row["data"]) + + async def delete(self, key: str) -> None: + """Delete file from PostgreSQL.""" + pool = self._pool_getter() + + async with pool.acquire() as conn: + result = await conn.execute( + f""" + DELETE FROM {fq_table("file_storage", self._schema)} + WHERE storage_key = $1 + """, + key, + ) + + # Check if anything was deleted + if result == "DELETE 0": + logger.warning(f"Attempted to delete non-existent file: {key}") + + async def exists(self, key: str) -> bool: + """Check if file exists in PostgreSQL.""" + pool = self._pool_getter() + + async with pool.acquire() as conn: + row = await conn.fetchrow( + f""" + SELECT 1 FROM {fq_table("file_storage", self._schema)} + WHERE storage_key = $1 + """, + key, + ) + + return row is not None + + async def get_download_url(self, key: str, expires_in: int = 3600) -> str: + """ + Get download URL for PostgreSQL-stored file. + + Returns an API endpoint path (not a pre-signed URL since the file + is stored in the database). The expires_in parameter is ignored + for PostgreSQL storage. + """ + # Return API path for download endpoint + # (expires_in ignored for database storage - auth handled at API level) + return f"/v1/default/files/download/{key}" diff --git a/hindsight-api/hindsight_api/engine/storage/s3.py b/hindsight-api/hindsight_api/engine/storage/s3.py new file mode 100644 index 00000000..2c2f7873 --- /dev/null +++ b/hindsight-api/hindsight_api/engine/storage/s3.py @@ -0,0 +1,71 @@ +"""S3 object storage backend using obstore.""" + +import logging +from datetime import timedelta + +import obstore as obs +from obstore.store import S3Store + +from .base import FileStorage + +logger = logging.getLogger(__name__) + + +class S3FileStorage(FileStorage): + """ + S3-compatible object storage backend. + + Uses obstore (Rust-backed) for high-throughput async access to + Amazon S3, MinIO, Cloudflare R2, and other S3-compliant APIs. + """ + + def __init__( + self, + bucket: str, + region: str | None = None, + endpoint: str | None = None, + access_key_id: str | None = None, + secret_access_key: str | None = None, + ): + kwargs: dict = {} + if region: + kwargs["region"] = region + if endpoint: + kwargs["endpoint"] = endpoint + # Allow plain HTTP for local S3-compatible services (MinIO, LocalStack, etc.) + if endpoint.startswith("http://"): + kwargs["allow_http"] = True + if access_key_id: + kwargs["access_key_id"] = access_key_id + if secret_access_key: + kwargs["secret_access_key"] = secret_access_key + + self._store = S3Store(bucket, **kwargs) + logger.info(f"Initialized S3 file storage: bucket={bucket}, region={region}, endpoint={endpoint}") + + async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str: + await obs.put_async(self._store, key, file_data) + logger.debug(f"Stored file {key} ({len(file_data)} bytes) in S3") + return key + + async def retrieve(self, key: str) -> bytes: + try: + response = await obs.get_async(self._store, key) + return await response.bytes_async() + except Exception as e: + if "not found" in str(e).lower() or "NoSuchKey" in str(e): + raise FileNotFoundError(f"File not found: {key}") from e + raise + + async def delete(self, key: str) -> None: + await obs.delete_async(self._store, key) + + async def exists(self, key: str) -> bool: + try: + await obs.head_async(self._store, key) + return True + except Exception: + return False + + async def get_download_url(self, key: str, expires_in: int = 3600) -> str: + return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in)) diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index 81578def..365852e0 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -250,6 +250,22 @@ def main(): retain_batch_tokens=config.retain_batch_tokens, retain_batch_enabled=config.retain_batch_enabled, retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds, + file_storage_type=config.file_storage_type, + file_storage_s3_bucket=config.file_storage_s3_bucket, + file_storage_s3_region=config.file_storage_s3_region, + file_storage_s3_endpoint=config.file_storage_s3_endpoint, + file_storage_s3_access_key_id=config.file_storage_s3_access_key_id, + file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key, + file_storage_gcs_bucket=config.file_storage_gcs_bucket, + file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key, + file_storage_azure_container=config.file_storage_azure_container, + file_storage_azure_account_name=config.file_storage_azure_account_name, + file_storage_azure_account_key=config.file_storage_azure_account_key, + file_parser=config.file_parser, + file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb, + file_conversion_max_batch_size=config.file_conversion_max_batch_size, + enable_file_upload_api=config.enable_file_upload_api, + file_delete_after_retain=config.file_delete_after_retain, enable_observations=config.enable_observations, consolidation_batch_size=config.consolidation_batch_size, consolidation_max_tokens=config.consolidation_max_tokens, diff --git a/hindsight-api/hindsight_api/worker/poller.py b/hindsight-api/hindsight_api/worker/poller.py index b425f627..c7420278 100644 --- a/hindsight-api/hindsight_api/worker/poller.py +++ b/hindsight-api/hindsight_api/worker/poller.py @@ -376,7 +376,12 @@ class WorkerPoller: del self._in_flight_by_type[operation_type] async def _execute_task_inner(self, task: ClaimedTask): - """Inner task execution with error handling.""" + """Inner task execution with error handling. + + Note: The executor (MemoryEngine.execute_task) handles status marking internally + (marking operations as completed/failed and handling retries). This method should + NOT override those status updates. + """ task_type = task.task_dict.get("type", "unknown") bank_id = task.task_dict.get("bank_id", "unknown") @@ -386,12 +391,12 @@ class WorkerPoller: if task.schema: task.task_dict["_schema"] = task.schema await self._executor(task.task_dict) - await self._mark_completed(task.operation_id, task.schema) - logger.debug(f"Task {task.operation_id} completed successfully") + logger.debug(f"Task {task.operation_id} execution finished") except Exception as e: - error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" - logger.error(f"Task {task.operation_id} failed: {e}") - await self._retry_or_fail(task.operation_id, error_msg, task.schema) + # The executor should handle its own errors, but if an unexpected exception + # propagates (e.g., from schema setup), log it as a warning + logger.error(f"Task {task.operation_id} raised unexpected exception: {e}") + traceback.print_exc() async def recover_own_tasks(self) -> int: """ diff --git a/hindsight-api/pyproject.toml b/hindsight-api/pyproject.toml index 36f04a71..1586edd6 100644 --- a/hindsight-api/pyproject.toml +++ b/hindsight-api/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ "cohere>=5.0.0", "flashrank>=0.2.0", "litellm>=1.0.0", + "markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion + "obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed) # Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false "sentence-transformers>=3.3.0", "transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities @@ -65,6 +67,7 @@ test = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.0.0", "filelock>=3.20.1", # TOCTOU race condition fix + "testcontainers>=4.0.0", ] [project.scripts] @@ -114,6 +117,7 @@ dev = [ "filelock>=3.20.1", # TOCTOU race condition fix "ruff>=0.8.0", "ty>=0.0.1", + "testcontainers>=4.0.0", ] [tool.ruff] diff --git a/hindsight-api/tests/test_file_retain.py b/hindsight-api/tests/test_file_retain.py new file mode 100644 index 00000000..0f83a68f --- /dev/null +++ b/hindsight-api/tests/test_file_retain.py @@ -0,0 +1,553 @@ +""" +End-to-end tests for file retain (upload, convert, retain) functionality. +""" + +import io +import json + +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.fixture +def sample_pdf_content(): + """Create a simple PDF-like content for testing.""" + # This is a minimal PDF that markitdown can parse + return b"""%PDF-1.4 +1 0 obj +<< +/Type /Catalog +/Pages 2 0 R +>> +endobj +2 0 obj +<< +/Type /Pages +/Kids [3 0 R] +/Count 1 +>> +endobj +3 0 obj +<< +/Type /Page +/Parent 2 0 R +/MediaBox [0 0 612 792] +/Contents 4 0 R +/Resources << +/Font << +/F1 << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +endobj +4 0 obj +<< +/Length 44 +>> +stream +BT +/F1 12 Tf +100 700 Td +(Test Document) Tj +ET +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000317 00000 n +trailer +<< +/Size 5 +/Root 1 0 R +>> +startxref +410 +%%EOF +""" + + +@pytest.fixture +def sample_txt_content(): + """Create simple text content.""" + return b"This is a test document.\nIt contains some important information.\nAlice works at Google." + + +@pytest.mark.asyncio +async def test_file_retain_basic(memory_no_llm_verify, sample_txt_content): + """Test basic file upload and conversion.""" + from hindsight_api.api.http import create_app + + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Create a bank first + bank_response = await client.put("/v1/default/banks/test-file-bank", json={"name": "Test File Bank"}) + assert bank_response.status_code in (200, 201) + + # Upload file + request_data = { + "document_tags": ["test"], + "async": True, + } + + files = {"files": ("test.txt", sample_txt_content, "text/plain")} + data = {"request": json.dumps(request_data)} + + response = await client.post( + "/v1/default/banks/test-file-bank/files/retain", + files=files, + data=data, + ) + + assert response.status_code == 200 + result = response.json() + assert "operation_ids" in result + assert len(result["operation_ids"]) == 1 + + +@pytest.mark.asyncio +async def test_file_retain_with_metadata(memory_no_llm_verify, sample_txt_content): + """Test file upload with per-file metadata.""" + from hindsight_api.api.http import create_app + + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Create bank + bank_response = await client.put("/v1/default/banks/test-file-meta-bank", json={"name": "Test Meta Bank"}) + assert bank_response.status_code in (200, 201) + + # Upload file with metadata + request_data = { + "document_tags": ["work", "reports"], + "async": True, + "files_metadata": [ + { + "document_id": "test_doc_123", + "context": "quarterly report", + "metadata": {"author": "Alice", "year": "2024"}, + "tags": ["Q1"], + } + ], + } + + files = {"files": ("report.txt", sample_txt_content, "text/plain")} + data = {"request": json.dumps(request_data)} + + response = await client.post( + "/v1/default/banks/test-file-meta-bank/files/retain", + files=files, + data=data, + ) + + assert response.status_code == 200 + result = response.json() + assert "operation_ids" in result + assert len(result["operation_ids"]) == 1 + + +@pytest.mark.asyncio +async def test_file_retain_multiple_files(memory_no_llm_verify, sample_txt_content): + """Test uploading multiple files at once.""" + from hindsight_api.api.http import create_app + + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Create bank + bank_response = await client.put("/v1/default/banks/test-multi-file-bank", json={"name": "Test Multi Bank"}) + assert bank_response.status_code in (200, 201) + + # Upload multiple files + request_data = { + "async": True, + "files_metadata": [ + {"document_id": "doc1", "tags": ["file1"]}, + {"document_id": "doc2", "tags": ["file2"]}, + ], + } + + content1 = b"First document content" + content2 = b"Second document content" + + files = [ + ("files", ("file1.txt", content1, "text/plain")), + ("files", ("file2.txt", content2, "text/plain")), + ] + data = {"request": json.dumps(request_data)} + + response = await client.post( + "/v1/default/banks/test-multi-file-bank/files/retain", + files=files, + data=data, + ) + + assert response.status_code == 200 + result = response.json() + assert "operation_ids" in result + assert len(result["operation_ids"]) == 2 + + +@pytest.mark.asyncio +async def test_file_retain_validation_errors(memory_no_llm_verify): + """Test validation errors.""" + from hindsight_api.api.http import create_app + + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Create bank + bank_response = await client.put("/v1/default/banks/test-validation-bank", json={"name": "Test Validation Bank"}) + assert bank_response.status_code in (200, 201) + + # Test: metadata count mismatch + request_data = { + "async": True, + "files_metadata": [ + {"document_id": "doc1"}, + {"document_id": "doc2"}, # 2 metadata entries + ], + } + + files = {"files": ("file1.txt", b"content", "text/plain")} # But only 1 file + data = {"request": json.dumps(request_data)} + + response = await client.post( + "/v1/default/banks/test-validation-bank/files/retain", + files=files, + data=data, + ) + + assert response.status_code == 400 + assert "files_metadata count" in response.json()["detail"] + + +@pytest.mark.asyncio +async def test_file_retain_no_files(memory_no_llm_verify): + """Test error when no files provided.""" + from hindsight_api.api.http import create_app + + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Create bank + bank_response = await client.put("/v1/default/banks/test-no-files-bank", json={"name": "Test No Files Bank"}) + assert bank_response.status_code in (200, 201) + + request_data = { + "async": True, + } + + # No files provided + data = {"request": json.dumps(request_data)} + + response = await client.post( + "/v1/default/banks/test-no-files-bank/files/retain", + data=data, + ) + + # FastAPI will return 422 for missing required field + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_file_retain_sync_not_supported(memory_no_llm_verify, sample_txt_content): + """Test that file retain is always async (sync is not supported).""" + from hindsight_api.api.http import create_app + + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + # Create bank + bank_response = await client.put("/v1/default/banks/test-sync-bank", json={"name": "Test Sync Bank"}) + assert bank_response.status_code in (200, 201) + + # File retain is always async - just verify it succeeds and returns operation_ids + files = {"files": ("test.txt", sample_txt_content, "text/plain")} + data = {"request": json.dumps({})} + + response = await client.post( + "/v1/default/banks/test-sync-bank/files/retain", + files=files, + data=data, + ) + + assert response.status_code == 200 + result = response.json() + assert "operation_ids" in result + + +@pytest.mark.asyncio +async def test_file_storage_postgresql(memory_no_llm_verify, sample_txt_content): + """Test file storage in PostgreSQL.""" + # Test that files are stored and retrieved correctly + storage = memory_no_llm_verify._file_storage + + # Store a file + key = "test/file1.txt" + stored_key = await storage.store( + file_data=sample_txt_content, + key=key, + metadata={"content_type": "text/plain"}, + ) + + assert stored_key == key + + # Retrieve the file + retrieved = await storage.retrieve(key) + assert retrieved == sample_txt_content + + # Check if file exists + exists = await storage.exists(key) + assert exists is True + + # Delete the file + await storage.delete(key) + + # Check file no longer exists + exists_after = await storage.exists(key) + assert exists_after is False + + +@pytest.mark.asyncio +async def test_markitdown_converter(): + """Test markitdown parser.""" + from hindsight_api.engine.parsers import MarkitdownParser + + parser = MarkitdownParser() + + # Test simple text file + text_content = b"This is a test document.\nWith multiple lines." + result = await parser.convert(text_content, "test.txt") + + assert isinstance(result, str) + assert len(result) > 0 + assert "test document" in result.lower() or "multiple lines" in result.lower() + + +@pytest.mark.asyncio +async def test_converter_registry(): + """Test file parser registry.""" + from hindsight_api.engine.parsers import FileParserRegistry, MarkitdownParser + + registry = FileParserRegistry() + parser = MarkitdownParser() + registry.register(parser) + + # Test get by name + retrieved = registry.get_parser("markitdown", "test.txt") + assert retrieved is parser + + # Test auto-detection + auto = registry.get_parser(None, "test.pdf") + assert auto is parser + + # Test unsupported format + with pytest.raises(ValueError, match="No parser found"): + registry.get_parser(None, "test.xyz") + + +@pytest.mark.asyncio +async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_verify, sample_txt_content): + """Test that file conversion and retain are two separate async operations. + + The file_convert_retain task should: + 1. Convert the file to markdown + 2. In a single transaction: create a separate 'retain' operation AND mark itself as 'completed' + 3. Free the worker slot immediately after conversion + + The retain then runs as its own task. This prevents deadlocks where file conversion + tasks hold worker slots while waiting for inline retain to finish. + """ + from hindsight_api.models import RequestContext + + bank_id = "test_file_two_phase_bank" + + 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 + + mock_file = MockFile(sample_txt_content, "test.txt", "text/plain") + + file_items = [ + { + "file": mock_file, + "document_id": "test_doc_two_phase", + "context": "test context", + "metadata": {"source": "test"}, + "tags": ["test_tag"], + "timestamp": None, + } + ] + + result = await memory_no_llm_verify.submit_async_file_retain( + bank_id=bank_id, + file_items=file_items, + parser="markitdown", + document_tags=["two_phase_test"], + request_context=context, + ) + + assert "operation_ids" in result + assert len(result["operation_ids"]) == 1 + convert_operation_id = result["operation_ids"][0] + + import asyncio + + await asyncio.sleep(0.1) + + pool = await memory_no_llm_verify._get_pool() + from hindsight_api.engine.memory_engine import get_current_schema + + schema = get_current_schema() + + async with pool.acquire() as conn: + # 1. The file_convert_retain operation must be completed + convert_op = await conn.fetchrow( + f"SELECT status, operation_type FROM {schema}.async_operations WHERE operation_id = $1", + convert_operation_id, + ) + assert convert_op is not None + assert convert_op["operation_type"] == "file_convert_retain" + assert convert_op["status"] == "completed", ( + f"file_convert_retain should be 'completed' after conversion, got '{convert_op['status']}'" + ) + + # 2. A separate retain operation must have been created + retain_op = await conn.fetchrow( + f""" + SELECT status, operation_type + FROM {schema}.async_operations + WHERE bank_id = $1 AND operation_type = 'retain' AND operation_id != $2 + """, + bank_id, + convert_operation_id, + ) + assert retain_op is not None, "A separate 'retain' operation should have been created by file conversion" + # With SyncTaskBackend the retain runs immediately, so it should be completed + assert retain_op["status"] == "completed" + + # 3. The document should exist with file metadata and retained content + doc = await conn.fetchrow( + f""" + SELECT id, original_text, file_original_name, file_content_type + FROM {schema}.documents + WHERE id = $1 AND bank_id = $2 + """, + "test_doc_two_phase", + bank_id, + ) + + assert doc is not None + assert doc["file_original_name"] == "test.txt" + assert doc["file_content_type"] == "text/plain" + assert doc["original_text"] is not None + assert len(doc["original_text"]) > 0 + + +@pytest.mark.asyncio +async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verify, sample_txt_content): + """Test that when file conversion fails, the operation status is set to 'failed' not 'completed'.""" + from hindsight_api.engine.parsers.base import FileParser + from hindsight_api.models import RequestContext + + bank_id = "test_file_failure_bank" + + # Create a mock parser that always fails + class FailingParser(FileParser): + """Mock parser that raises an error.""" + + async def convert(self, file_data: bytes, filename: str) -> str: + # Simulate conversion failure + raise RuntimeError(f"Failed to convert '{filename}': Mock conversion error") + + def supports(self, filename: str, content_type: str | None = None) -> bool: + return filename.endswith(".fail") + + def name(self) -> str: + return "failing_converter" + + # Register the failing parser + failing_converter = FailingParser() + memory_no_llm_verify._parser_registry.register(failing_converter) + + # Create bank + context = RequestContext(internal=True) + await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context) + + # Create mock file + 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, "test.fail", "application/octet-stream") + + file_items = [ + { + "file": mock_file, + "document_id": "test_doc_fail", + "context": None, + "metadata": {}, + "tags": [], + "timestamp": None, + } + ] + + # Submit async file retain with failing parser + result = await memory_no_llm_verify.submit_async_file_retain( + bank_id=bank_id, + file_items=file_items, + parser="failing_converter", + document_tags=None, + request_context=context, + ) + + assert "operation_ids" in result + assert len(result["operation_ids"]) == 1 + operation_id = result["operation_ids"][0] + + # Wait for async processing (with SyncTaskBackend, this is immediate) + import asyncio + + await asyncio.sleep(0.2) + + # Check operation status - should be 'failed' not 'completed' + pool = await memory_no_llm_verify._get_pool() + from hindsight_api.engine.memory_engine import get_current_schema + + async with pool.acquire() as conn: + operation = await conn.fetchrow( + f""" + SELECT status, error_message + FROM {get_current_schema()}.async_operations + WHERE operation_id = $1 + """, + operation_id, + ) + + assert operation is not None, f"Operation {operation_id} not found" + assert operation["status"] == "failed", f"Expected status 'failed' but got '{operation['status']}'" + assert operation["error_message"] is not None + assert "Mock conversion error" in operation["error_message"] + assert "test.fail" in operation["error_message"] diff --git a/hindsight-api/tests/test_file_storage_s3.py b/hindsight-api/tests/test_file_storage_s3.py new file mode 100644 index 00000000..47a85dc3 --- /dev/null +++ b/hindsight-api/tests/test_file_storage_s3.py @@ -0,0 +1,252 @@ +""" +Integration tests for S3FileStorage against a SeaweedFS Docker container. + +SeaweedFS (Apache 2.0) provides an S3-compatible API via `weed server -s3`. +Requires Docker to be running. Tests are skipped automatically if Docker is unavailable. +""" + +import json +import logging +import subprocess +import tempfile +import time +import uuid + +import httpx +import pytest +from httpx import ASGITransport, AsyncClient + +logger = logging.getLogger(__name__) + +try: + from testcontainers.core.container import DockerContainer + + _has_testcontainers = True +except ImportError: + _has_testcontainers = False + +pytestmark = [ + pytest.mark.skipif(not _has_testcontainers, reason="testcontainers not installed"), +] + +SEAWEEDFS_S3_PORT = 8333 +TEST_BUCKET = "hindsight-test" +ACCESS_KEY = "test_access_key" +SECRET_KEY = "test_secret_key" + +# SeaweedFS S3 IAM config granting full access to our test credentials +_S3_CONFIG = { + "identities": [ + { + "name": "test-user", + "credentials": [{"accessKey": ACCESS_KEY, "secretKey": SECRET_KEY}], + "actions": ["Admin", "Read", "Write", "List"], + } + ] +} + + +def _docker_available() -> bool: + """Check if Docker daemon is running.""" + try: + result = subprocess.run( + ["docker", "info"], + capture_output=True, + timeout=5, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _wait_for_seaweedfs(endpoint: str, timeout: int = 30) -> None: + """Poll SeaweedFS S3 endpoint until ready.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + resp = httpx.get(endpoint, timeout=2) + # 200 = no auth, 403 = auth enabled but gateway is up — either means ready + if resp.status_code in (200, 403): + logger.info("SeaweedFS S3 is ready at %s", endpoint) + return + except httpx.HTTPError: + pass + time.sleep(0.5) + raise TimeoutError(f"SeaweedFS did not become ready at {endpoint} within {timeout}s") + + +@pytest.fixture(scope="module") +def seaweedfs_container(): + """Start a SeaweedFS container for the test module, shared across all tests. + + Mounts an s3.json config file to set up S3 credentials for the test user. + """ + if not _docker_available(): + pytest.skip("Docker is not available") + + # Write S3 IAM config to a temp file that persists for the module scope + s3_config_file = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) + json.dump(_S3_CONFIG, s3_config_file) + s3_config_file.flush() + + container = ( + DockerContainer(image="chrislusf/seaweedfs:latest") + .with_exposed_ports(SEAWEEDFS_S3_PORT) + .with_volume_mapping(s3_config_file.name, "/etc/seaweedfs/s3.json", "ro") + .with_command( + f"server -s3 -s3.port={SEAWEEDFS_S3_PORT} -s3.config=/etc/seaweedfs/s3.json -ip.bind=0.0.0.0" + ) + ) + + container.start() + + try: + host = container.get_container_host_ip() + port = container.get_exposed_port(SEAWEEDFS_S3_PORT) + endpoint = f"http://{host}:{port}" + + _wait_for_seaweedfs(endpoint) + + # Create test bucket using obstore (proper SigV4 signing) + import obstore as obs + from obstore.store import S3Store + + admin_store = S3Store( + TEST_BUCKET, + endpoint=endpoint, + region="us-east-1", + access_key_id=ACCESS_KEY, + secret_access_key=SECRET_KEY, + allow_http=True, + ) + # SeaweedFS auto-creates buckets on first write + obs.put(admin_store, ".bucket-init", b"") + obs.delete(admin_store, ".bucket-init") + logger.info("Test bucket '%s' is ready", TEST_BUCKET) + + yield { + "endpoint": endpoint, + "access_key": ACCESS_KEY, + "secret_key": SECRET_KEY, + "bucket": TEST_BUCKET, + } + finally: + container.stop() + import os + + os.unlink(s3_config_file.name) + + +@pytest.fixture +def s3_storage(seaweedfs_container): + """Create an S3FileStorage instance pointing at the SeaweedFS container.""" + from hindsight_api.engine.storage.s3 import S3FileStorage + + return S3FileStorage( + bucket=seaweedfs_container["bucket"], + region="us-east-1", + endpoint=seaweedfs_container["endpoint"], + access_key_id=seaweedfs_container["access_key"], + secret_access_key=seaweedfs_container["secret_key"], + ) + + +@pytest.mark.asyncio +async def test_s3_storage_store_and_retrieve(s3_storage): + """Store a file, retrieve it, verify bytes match.""" + content = b"Hello, SeaweedFS! This is a test file." + key = f"test/{uuid.uuid4()}.txt" + + stored_key = await s3_storage.store( + file_data=content, + key=key, + metadata={"content_type": "text/plain"}, + ) + assert stored_key == key + + retrieved = await s3_storage.retrieve(key) + assert retrieved == content + + +@pytest.mark.asyncio +async def test_s3_storage_exists_and_delete(s3_storage): + """Store, check exists=True, delete, check exists=False.""" + content = b"File to be deleted." + key = f"test/{uuid.uuid4()}.txt" + + await s3_storage.store(file_data=content, key=key) + + assert await s3_storage.exists(key) is True + + await s3_storage.delete(key) + + assert await s3_storage.exists(key) is False + + +@pytest.mark.asyncio +async def test_s3_storage_file_not_found(s3_storage): + """Retrieve a non-existent key, expect FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + await s3_storage.retrieve(f"nonexistent/{uuid.uuid4()}.txt") + + +@pytest.mark.asyncio +async def test_s3_storage_get_download_url(s3_storage): + """Store a file, get a presigned URL, verify it's a valid URL string.""" + content = b"Presigned URL test content." + key = f"test/{uuid.uuid4()}.txt" + + await s3_storage.store(file_data=content, key=key) + + url = await s3_storage.get_download_url(key, expires_in=300) + assert isinstance(url, str) + assert url.startswith("http") + assert key in url + + +@pytest.mark.asyncio +async def test_s3_file_retain_api_end_to_end(seaweedfs_container, memory_no_llm_verify): + """Full HTTP API flow: upload file via /files/retain with S3 storage backend.""" + from hindsight_api.api.http import create_app + from hindsight_api.engine.storage.s3 import S3FileStorage + + # Swap the engine's file storage to use the SeaweedFS-backed S3 storage + original_storage = memory_no_llm_verify._file_storage + s3_storage = S3FileStorage( + bucket=seaweedfs_container["bucket"], + region="us-east-1", + endpoint=seaweedfs_container["endpoint"], + access_key_id=seaweedfs_container["access_key"], + secret_access_key=seaweedfs_container["secret_key"], + ) + memory_no_llm_verify._file_storage = s3_storage + + try: + app = create_app(memory_no_llm_verify, initialize_memory=False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + bank_id = f"test-s3-bank-{uuid.uuid4().hex[:8]}" + bank_response = await client.put(f"/v1/default/banks/{bank_id}", json={"name": "S3 Test Bank"}) + assert bank_response.status_code in (200, 201) + + txt_content = b"Alice works at Acme Corp. She joined in 2024." + request_data = { + "document_tags": ["s3-test"], + "async": True, + } + + files = {"files": ("notes.txt", txt_content, "text/plain")} + data = {"request": json.dumps(request_data)} + + response = await client.post( + f"/v1/default/banks/{bank_id}/files/retain", + files=files, + data=data, + ) + + assert response.status_code == 200 + result = response.json() + assert "operation_ids" in result + assert len(result["operation_ids"]) == 1 + finally: + memory_no_llm_verify._file_storage = original_storage diff --git a/hindsight-api/tests/test_worker.py b/hindsight-api/tests/test_worker.py index 21618c8e..2f8d5d95 100644 --- a/hindsight-api/tests/test_worker.py +++ b/hindsight-api/tests/test_worker.py @@ -206,8 +206,12 @@ class TestWorkerPoller: assert len(claimed) == 3 @pytest.mark.asyncio - async def test_execute_task_marks_completed(self, pool, clean_operations): - """Test that successful task execution marks task as completed.""" + async def test_execute_task_executor_marks_completed(self, pool, clean_operations): + """Test that executor's status marking is preserved by the poller. + + The executor (MemoryEngine.execute_task) handles marking operations as completed/failed. + The poller should NOT override those status updates. + """ from hindsight_api.worker import WorkerPoller from hindsight_api.worker.poller import ClaimedTask @@ -228,7 +232,16 @@ class TestWorkerPoller: executed = [] async def mock_executor(task_dict): + """Executor that marks its own status as completed (like MemoryEngine.execute_task).""" executed.append(task_dict) + await pool.execute( + """ + UPDATE async_operations + SET status = 'completed', completed_at = now(), updated_at = now() + WHERE operation_id = $1 + """, + op_id, + ) poller = WorkerPoller( pool=pool, @@ -246,7 +259,7 @@ class TestWorkerPoller: assert completed, "Task did not complete within timeout" assert len(executed) == 1 - # Verify task is marked as completed + # Verify task is marked as completed (by executor, not overridden by poller) row = await pool.fetchrow( "SELECT status, completed_at FROM async_operations WHERE operation_id = $1", op_id, @@ -255,19 +268,24 @@ class TestWorkerPoller: assert row["completed_at"] is not None @pytest.mark.asyncio - async def test_execute_task_retries_on_failure(self, pool, clean_operations): - """Test that failed task execution triggers retry mechanism.""" + async def test_executor_exception_does_not_crash_poller(self, pool, clean_operations): + """Test that unexpected exceptions from executor are caught and don't crash the poller. + + If the executor raises an unexpected exception (which MemoryEngine.execute_task should NOT do, + but could happen from schema setup or other infrastructure issues), the poller should catch it + gracefully. Status remains 'processing' since neither executor nor poller handled it. + """ from hindsight_api.worker import WorkerPoller from hindsight_api.worker.poller import ClaimedTask - # Create a pending task with retry_count=0 + # Create a pending task bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" op_id = uuid.uuid4() payload = json.dumps({"type": "test_task", "operation_id": str(op_id), "bank_id": bank_id}) await pool.execute( """ - INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, retry_count) - VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1', 0) + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id) + VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1') """, op_id, bank_id, @@ -275,16 +293,15 @@ class TestWorkerPoller: ) async def failing_executor(task_dict): - raise ValueError("Simulated failure") + raise ValueError("Unexpected infrastructure failure") poller = WorkerPoller( pool=pool, worker_id="test-worker-1", executor=failing_executor, - max_retries=3, ) - # Execute (should fail and retry) - fire-and-forget + # Execute - should catch exception without crashing task_dict = json.loads(payload) claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None) await poller.execute_task(claimed_task) @@ -293,61 +310,84 @@ class TestWorkerPoller: completed = await poller.wait_for_active_tasks(timeout=5.0) assert completed, "Task did not complete within timeout" - # Verify task is back to pending with incremented retry_count + # Status stays 'processing' since the poller no longer manages status row = await pool.fetchrow( - "SELECT status, retry_count, worker_id FROM async_operations WHERE operation_id = $1", + "SELECT status FROM async_operations WHERE operation_id = $1", op_id, ) - assert row["status"] == "pending" - assert row["retry_count"] == 1 - assert row["worker_id"] is None # Worker ID cleared for retry + assert row["status"] == "processing" @pytest.mark.asyncio - async def test_execute_task_fails_after_max_retries(self, pool, clean_operations): - """Test that task is marked failed after exceeding max retries.""" + async def test_executor_failed_status_not_overridden(self, pool, clean_operations): + """REGRESSION TEST: Verify poller does NOT overwrite executor's 'failed' status to 'completed'. + + This test catches the bug where the poller always called _mark_completed() after executor + returned, overwriting the 'failed' status that the executor had already set. + + Scenario: + 1. Executor catches an internal error and marks the operation as 'failed' in the DB + 2. Executor returns normally (does NOT re-raise) - this is how MemoryEngine.execute_task works + 3. The poller must NOT overwrite the 'failed' status to 'completed' + + With the old buggy code, this test would FAIL (status would be 'completed'). + """ from hindsight_api.worker import WorkerPoller from hindsight_api.worker.poller import ClaimedTask - # Create a task that has already used all retries bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" op_id = uuid.uuid4() payload = json.dumps({"type": "test_task", "operation_id": str(op_id), "bank_id": bank_id}) await pool.execute( """ - INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, retry_count) - VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1', 3) + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id) + VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1') """, op_id, bank_id, payload, ) - async def failing_executor(task_dict): - raise ValueError("Simulated failure") + async def executor_that_marks_failed(task_dict): + """Simulates MemoryEngine.execute_task behavior on internal error. + + The executor catches the error, marks the operation as 'failed', + and returns normally (does NOT re-raise the exception). + """ + # Simulate internal failure handling (like MemoryEngine._mark_operation_failed) + await pool.execute( + """ + UPDATE async_operations + SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now() + WHERE operation_id = $1 + """, + op_id, + "Simulated conversion error: file format not supported", + ) + # Returns normally - this is the key: executor does NOT re-raise poller = WorkerPoller( pool=pool, worker_id="test-worker-1", - executor=failing_executor, - max_retries=3, + executor=executor_that_marks_failed, ) - # Execute (should fail permanently) - fire-and-forget task_dict = json.loads(payload) claimed_task = ClaimedTask(operation_id=str(op_id), task_dict=task_dict, schema=None) await poller.execute_task(claimed_task) - # Wait for background task to complete completed = await poller.wait_for_active_tasks(timeout=5.0) assert completed, "Task did not complete within timeout" - # Verify task is marked as failed + # THE KEY ASSERTION: Status must be 'failed', NOT 'completed' row = await pool.fetchrow( "SELECT status, error_message FROM async_operations WHERE operation_id = $1", op_id, ) - assert row["status"] == "failed" - assert "Max retries" in row["error_message"] + assert row["status"] == "failed", ( + f"REGRESSION: Poller overwrote executor's 'failed' status to '{row['status']}'. " + "The poller must not override status set by the executor." + ) + assert "Simulated conversion error" in row["error_message"] @pytest.mark.asyncio async def test_claim_batch_skips_consolidation_when_same_bank_processing(self, pool, clean_operations): diff --git a/hindsight-cli/Cargo.toml b/hindsight-cli/Cargo.toml index 8d10e06c..9c901a58 100644 --- a/hindsight-cli/Cargo.toml +++ b/hindsight-cli/Cargo.toml @@ -20,8 +20,8 @@ clap = { version = "4.5", features = ["derive", "env"] } # Async runtime tokio = { version = "1", features = ["full"] } -# HTTP client (for timeout configuration) -reqwest = "0.12" +# HTTP client (for timeout configuration and multipart file uploads) +reqwest = { version = "0.12", features = ["multipart"] } # Serialization (for config and output formatting) serde = { version = "1.0", features = ["derive"] } diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index b700272e..6529f75d 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -58,9 +58,16 @@ pub struct MemoryPutResult { pub operation_id: Option, } +#[derive(Debug, Serialize, Deserialize)] +pub struct FileRetainResult { + pub operation_ids: Vec, +} + #[derive(Clone)] pub struct ApiClient { client: AsyncClient, + http_client: reqwest::Client, + base_url: String, runtime: std::sync::Arc, } @@ -84,8 +91,8 @@ impl ApiClient { let http_client = client_builder.build()?; - let client = AsyncClient::new_with_client(&base_url, http_client); - Ok(ApiClient { client, runtime }) + let client = AsyncClient::new_with_client(&base_url, http_client.clone()); + Ok(ApiClient { client, http_client, base_url, runtime }) } pub fn list_agents(&self, _verbose: bool) -> Result> { @@ -168,6 +175,67 @@ impl ApiClient { }) } + /// Upload files to the file retain endpoint (multipart/form-data). + /// Returns a list of operation IDs for tracking. Always async server-side. + pub fn file_retain( + &self, + bank_id: &str, + files: Vec<(String, Vec)>, + context: Option, + verbose: bool, + ) -> Result { + self.runtime.block_on(async { + let url = format!("{}/v1/default/banks/{}/files/retain", self.base_url, bank_id); + + let files_metadata: Vec = files + .iter() + .map(|(name, _)| { + let mut meta = serde_json::json!({}); + if let Some(ctx) = &context { + meta["context"] = serde_json::Value::String(ctx.clone()); + } + // Use filename stem as document_id for deduplication + if let Some(stem) = std::path::Path::new(name) + .file_stem() + .and_then(|s| s.to_str()) + { + meta["document_id"] = serde_json::Value::String(stem.to_string()); + } + meta + }) + .collect(); + + let request_json = serde_json::json!({ + "files_metadata": files_metadata, + }); + + let mut form = reqwest::multipart::Form::new() + .text("request", request_json.to_string()); + + for (filename, content) in files { + let part = reqwest::multipart::Part::bytes(content) + .file_name(filename) + .mime_str("application/octet-stream")?; + form = form.part("files", part); + } + + if verbose { + eprintln!("POST {}", url); + } + + let response = self.http_client.post(&url).multipart(form).send().await?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + anyhow::bail!("File retain failed ({}): {}", status, text); + } + + let result: FileRetainResult = response.json().await?; + Ok(result) + }) + } + /// Poll an operation until it completes or fails. /// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error. pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option)> { diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index 524e443b..a116446c 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -220,14 +220,23 @@ pub fn get( } } -// Helper function to check if a file has a text-based extension -fn is_text_file(path: &std::path::Path) -> bool { - const TEXT_EXTENSIONS: &[&str] = &[ - "txt", "md", "json", "yaml", "yml", "toml", "xml", "csv", "log", "rst", "adoc", +// Helper function to check if a file is supported by the file converter (markitdown) +fn is_supported_file(path: &std::path::Path) -> bool { + const SUPPORTED_EXTENSIONS: &[&str] = &[ + // Documents + "pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", + // Images (OCR) + "jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", + // Web / markup + "html", "htm", + // Text / data + "txt", "md", "csv", "json", "yaml", "yml", "toml", "xml", "rst", "adoc", "log", + // Audio (transcription) + "mp3", "wav", "ogg", "flac", ]; path.extension() .and_then(|ext| ext.to_str()) - .map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str())) + .map(|ext| SUPPORTED_EXTENSIONS.contains(&ext.to_lowercase().as_str())) .unwrap_or(false) } @@ -427,10 +436,10 @@ pub fn retain_files( anyhow::bail!("Path does not exist: {}", path.display()); } - let mut files = Vec::new(); + let mut file_paths = Vec::new(); if path.is_file() { - files.push(path); + file_paths.push(path); } else if path.is_dir() { if recursive { for entry in WalkDir::new(&path) @@ -438,133 +447,110 @@ pub fn retain_files( .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file()) { - let path = entry.path(); - if is_text_file(&path) { - files.push(path.to_path_buf()); + let file_path = entry.path(); + if is_supported_file(file_path) { + file_paths.push(file_path.to_path_buf()); } } } else { for entry in fs::read_dir(&path)? { let entry = entry?; - let path = entry.path(); - if path.is_file() && is_text_file(&path) { - files.push(path); + let file_path = entry.path(); + if file_path.is_file() && is_supported_file(&file_path) { + file_paths.push(file_path); } } } } - if files.is_empty() { - ui::print_warning("No text files found (supported: txt, md, json, yaml, yml, toml, xml, csv, log, rst, adoc)"); + if file_paths.is_empty() { + ui::print_warning("No supported files found. Supported formats: pdf, docx, pptx, xlsx, jpg, png, html, txt, md, csv, mp3, wav, and more."); return Ok(()); } - ui::print_info(&format!("Found {} files to import", files.len())); + ui::print_info(&format!("Found {} file(s) to import", file_paths.len())); - let pb = ui::create_progress_bar(files.len() as u64, "Processing files"); + // Batch files (max 10 per request) + const BATCH_SIZE: usize = 10; + let batches: Vec<&[PathBuf]> = file_paths.chunks(BATCH_SIZE).collect(); + let mut all_operation_ids: Vec = Vec::new(); - let mut items = Vec::new(); + let pb = ui::create_progress_bar(file_paths.len() as u64, "Uploading files"); - for file_path in &files { - let content = fs::read_to_string(file_path) - .with_context(|| format!("Failed to read file: {}", file_path.display()))?; + for batch in &batches { + let mut file_data: Vec<(String, Vec)> = Vec::new(); + for file_path in *batch { + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "file".to_string()); + let content = fs::read(file_path) + .with_context(|| format!("Failed to read file: {}", file_path.display()))?; + file_data.push((filename, content)); + pb.inc(1); + } - let doc_id = file_path - .file_stem() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - .unwrap_or_else(config::generate_doc_id); - - items.push(MemoryItem { - content, - context: context.clone(), - metadata: None, - timestamp: None, - document_id: Some(doc_id), - entities: None, - tags: None, - }); - - pb.inc(1); + let result = client.file_retain(agent_id, file_data, context.clone(), verbose)?; + all_operation_ids.extend(result.operation_ids); } - pb.finish_with_message("Files processed"); + pb.finish_with_message("Files uploaded"); - // Always use async mode for the API call - let request = RetainRequest { - items, - async_: true, - document_tags: None, - }; - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Submitting retain request...")) + if r#async { + if output_format == OutputFormat::Pretty { + ui::print_success("Files queued for processing"); + println!(" Files: {}", file_paths.len()); + for op_id in &all_operation_ids { + println!(" Operation ID: {}", op_id); + } + } else { + let result = serde_json::json!({ "operation_ids": all_operation_ids }); + output::print_output(&result, output_format)?; + } } else { - None - }; + // Poll all operations until they complete + let poll_spinner = if output_format == OutputFormat::Pretty { + Some(ui::create_spinner("Processing files...")) + } else { + None + }; - let response = client.retain(agent_id, &request, true, verbose); + let mut failed = Vec::new(); + for op_id in &all_operation_ids { + let (success, error_msg) = client.poll_operation(agent_id, op_id, verbose)?; + if !success { + failed.push(error_msg.unwrap_or_else(|| "Unknown error".to_string())); + } + } - if let Some(mut sp) = spinner { - sp.finish(); - } + if let Some(mut sp) = poll_spinner { + sp.finish(); + } - match response { - Ok(result) => { - if r#async { - // User requested async mode - return immediately - if output_format == OutputFormat::Pretty { - ui::print_success("Files queued for processing"); - println!(" Items: {}", result.items_count); - if let Some(op_id) = &result.operation_id { - println!(" Operation ID: {}", op_id); - } - } else { - output::print_output(&result, output_format)?; - } + if failed.is_empty() { + if output_format == OutputFormat::Pretty { + ui::print_success("Files retained successfully"); + println!(" Files processed: {}", file_paths.len()); } else { - // Poll until completion - if let Some(operation_id) = &result.operation_id { - let poll_spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Processing memories...")) - } else { - None - }; - - let (success, error_msg) = client.poll_operation(agent_id, operation_id, verbose)?; - - if let Some(mut sp) = poll_spinner { - sp.finish(); - } - - if success { - if output_format == OutputFormat::Pretty { - ui::print_success("Files retained successfully"); - println!(" Items processed: {}", result.items_count); - } else { - output::print_output(&result, output_format)?; - } - } else { - let msg = error_msg.unwrap_or_else(|| "Unknown error".to_string()); - if output_format == OutputFormat::Pretty { - ui::print_error(&format!("Retain operation failed: {}", msg)); - } - anyhow::bail!("Retain operation failed: {}", msg); - } - } else { - // No operation ID returned, shouldn't happen with async=true - if output_format == OutputFormat::Pretty { - ui::print_success("Files retained successfully"); - println!(" Items processed: {}", result.items_count); - } else { - output::print_output(&result, output_format)?; - } + let result = serde_json::json!({ + "success": true, + "files_count": file_paths.len(), + "operation_ids": all_operation_ids, + }); + output::print_output(&result, output_format)?; + } + } else { + for msg in &failed { + if output_format == OutputFormat::Pretty { + ui::print_error(&format!("Retain operation failed: {}", msg)); } } - Ok(()) + anyhow::bail!("{} operation(s) failed", failed.len()); } - Err(e) => Err(e) } + + Ok(()) } pub fn delete( @@ -679,55 +665,71 @@ mod tests { use std::path::Path; #[test] - fn test_is_text_file_supported_extensions() { + fn test_is_supported_file_text_extensions() { let supported = [ "file.txt", "file.md", "file.json", "file.yaml", "file.yml", "file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc", ]; for filename in supported { assert!( - is_text_file(Path::new(filename)), - "{} should be recognized as a text file", + is_supported_file(Path::new(filename)), + "{} should be recognized as a supported file", filename ); } } #[test] - fn test_is_text_file_case_insensitive() { - assert!(is_text_file(Path::new("file.JSON"))); - assert!(is_text_file(Path::new("file.TXT"))); - assert!(is_text_file(Path::new("file.Md"))); - assert!(is_text_file(Path::new("file.YAML"))); + fn test_is_supported_file_binary_extensions() { + let supported = [ + "file.pdf", "file.docx", "file.pptx", "file.xlsx", + "file.png", "file.jpg", "file.jpeg", "file.gif", + "file.mp3", "file.wav", + ]; + for filename in supported { + assert!( + is_supported_file(Path::new(filename)), + "{} should be recognized as a supported file", + filename + ); + } } #[test] - fn test_is_text_file_unsupported_extensions() { + fn test_is_supported_file_case_insensitive() { + assert!(is_supported_file(Path::new("file.JSON"))); + assert!(is_supported_file(Path::new("file.TXT"))); + assert!(is_supported_file(Path::new("file.Md"))); + assert!(is_supported_file(Path::new("file.YAML"))); + assert!(is_supported_file(Path::new("file.PDF"))); + } + + #[test] + fn test_is_supported_file_unsupported_extensions() { let unsupported = [ - "file.pdf", "file.doc", "file.docx", "file.png", "file.jpg", "file.exe", "file.bin", "file.zip", "file.tar", "file.gz", ]; for filename in unsupported { assert!( - !is_text_file(Path::new(filename)), - "{} should NOT be recognized as a text file", + !is_supported_file(Path::new(filename)), + "{} should NOT be recognized as a supported file", filename ); } } #[test] - fn test_is_text_file_no_extension() { - assert!(!is_text_file(Path::new("README"))); - assert!(!is_text_file(Path::new("Makefile"))); - assert!(!is_text_file(Path::new(".gitignore"))); + fn test_is_supported_file_no_extension() { + assert!(!is_supported_file(Path::new("README"))); + assert!(!is_supported_file(Path::new("Makefile"))); + assert!(!is_supported_file(Path::new(".gitignore"))); } #[test] - fn test_is_text_file_with_path() { - assert!(is_text_file(Path::new("/some/path/to/file.json"))); - assert!(is_text_file(Path::new("../relative/path/file.md"))); - assert!(!is_text_file(Path::new("/path/to/image.png"))); + fn test_is_supported_file_with_path() { + assert!(is_supported_file(Path::new("/some/path/to/file.json"))); + assert!(is_supported_file(Path::new("../relative/path/file.md"))); + assert!(is_supported_file(Path::new("/path/to/image.png"))); } #[test] diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 4837072c..65ec99ce 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -2124,6 +2124,73 @@ paths: summary: Retain memories tags: - Memory + /v1/default/banks/{bank_id}/files/retain: + post: + description: |- + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. + + This endpoint handles file upload, conversion, and memory creation in a single operation. + + **Features:** + - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) + - Automatic file-to-markdown conversion using pluggable parsers + - Files stored in object storage (PostgreSQL by default, S3 for production) + - Each file becomes a separate document with optional metadata/tags + - Always processes asynchronously — returns operation IDs immediately + + **The system automatically:** + 1. Stores uploaded files in object storage + 2. Converts files to markdown + 3. Creates document records with file metadata + 4. Extracts facts and creates memory units (same as regular retain) + + Use the operations endpoint to monitor progress. + + **Request format:** multipart/form-data with: + - `files`: One or more files to upload + - `request`: JSON string with FileRetainRequest model (files_metadata) + + **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown). + operationId: file_retain + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/Body_file_retain' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/FileRetainResponse' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Convert files to memories + tags: + - Files components: schemas: AddBackgroundRequest: @@ -2394,6 +2461,22 @@ components: - total_links - total_nodes title: BankStatsResponse + Body_file_retain: + properties: + files: + description: Files to upload and convert + items: + format: binary + type: string + type: array + request: + description: JSON string with FileRetainRequest model + title: Request + type: string + required: + - files + - request + title: Body_file_retain Budget: description: Budget levels for recall/reflect operations. enum: @@ -3040,12 +3123,34 @@ components: description: Whether per-bank configuration API is enabled title: Bank Config Api type: boolean + file_upload_api: + description: Whether file upload/conversion API is enabled + title: File Upload Api + type: boolean required: - bank_config_api + - file_upload_api - mcp - observations - worker title: FeaturesInfo + FileRetainResponse: + description: Response model for file upload endpoint. + example: + operation_ids: + - 550e8400-e29b-41d4-a716-446655440000 + - 550e8400-e29b-41d4-a716-446655440001 + - 550e8400-e29b-41d4-a716-446655440002 + properties: + operation_ids: + description: "Operation IDs for tracking file conversion operations. Use\ + \ GET /v1/default/banks/{bank_id}/operations to list operations." + items: + type: string + type: array + required: + - operation_ids + title: FileRetainResponse GraphDataResponse: description: Response model for graph data endpoint. example: @@ -4197,6 +4302,7 @@ components: api_version: 0.4.0 features: bank_config_api: false + file_upload_api: true mcp: true observations: false worker: true diff --git a/hindsight-clients/go/api_files.go b/hindsight-clients/go/api_files.go new file mode 100644 index 00000000..be37f1c1 --- /dev/null +++ b/hindsight-clients/go/api_files.go @@ -0,0 +1,209 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.4.11 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "os" + "strings" +) + + +// FilesAPIService FilesAPI service +type FilesAPIService service + +type ApiFileRetainRequest struct { + ctx context.Context + ApiService *FilesAPIService + bankId string + files []*os.File + request *string + authorization *string +} + +// Files to upload and convert +func (r ApiFileRetainRequest) Files(files []*os.File) ApiFileRetainRequest { + r.files = files + return r +} + +// JSON string with FileRetainRequest model +func (r ApiFileRetainRequest) Request(request string) ApiFileRetainRequest { + r.request = &request + return r +} + +func (r ApiFileRetainRequest) Authorization(authorization string) ApiFileRetainRequest { + r.authorization = &authorization + return r +} + +func (r ApiFileRetainRequest) Execute() (*FileRetainResponse, *http.Response, error) { + return r.ApiService.FileRetainExecute(r) +} + +/* +FileRetain Convert files to memories + +Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. + +This endpoint handles file upload, conversion, and memory creation in a single operation. + +**Features:** +- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) +- Automatic file-to-markdown conversion using pluggable parsers +- Files stored in object storage (PostgreSQL by default, S3 for production) +- Each file becomes a separate document with optional metadata/tags +- Always processes asynchronously — returns operation IDs immediately + +**The system automatically:** +1. Stores uploaded files in object storage +2. Converts files to markdown +3. Creates document records with file metadata +4. Extracts facts and creates memory units (same as regular retain) + +Use the operations endpoint to monitor progress. + +**Request format:** multipart/form-data with: +- `files`: One or more files to upload +- `request`: JSON string with FileRetainRequest model (files_metadata) + +**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown). + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiFileRetainRequest +*/ +func (a *FilesAPIService) FileRetain(ctx context.Context, bankId string) ApiFileRetainRequest { + return ApiFileRetainRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return FileRetainResponse +func (a *FilesAPIService) FileRetainExecute(r ApiFileRetainRequest) (*FileRetainResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *FileRetainResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "FilesAPIService.FileRetain") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/files/retain" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.files == nil { + return localVarReturnValue, nil, reportError("files is required and must be specified") + } + if r.request == nil { + return localVarReturnValue, nil, reportError("request is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"multipart/form-data"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + var filesLocalVarFormFileName string + var filesLocalVarFileName string + var filesLocalVarFileBytes []byte + + filesLocalVarFormFileName = "files" + filesLocalVarFile := r.files + + if filesLocalVarFile != nil { + // loop through the array to prepare multiple files upload + for _, filesLocalVarFileValue := range filesLocalVarFile { + fbs, _ := io.ReadAll(filesLocalVarFileValue) + + filesLocalVarFileBytes = fbs + filesLocalVarFileName = filesLocalVarFileValue.Name() + filesLocalVarFileValue.Close() + formFiles = append(formFiles, formFile{fileBytes: filesLocalVarFileBytes, fileName: filesLocalVarFileName, formFileName: filesLocalVarFormFileName}) + } + } + parameterAddToHeaderOrQuery(localVarFormParams, "request", r.request, "", "") + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/hindsight-clients/go/client.go b/hindsight-clients/go/client.go index 02f2d095..2b426826 100644 --- a/hindsight-clients/go/client.go +++ b/hindsight-clients/go/client.go @@ -57,6 +57,8 @@ type APIClient struct { EntitiesAPI *EntitiesAPIService + FilesAPI *FilesAPIService + MemoryAPI *MemoryAPIService MentalModelsAPI *MentalModelsAPIService @@ -86,6 +88,7 @@ func NewAPIClient(cfg *Configuration) *APIClient { c.DirectivesAPI = (*DirectivesAPIService)(&c.common) c.DocumentsAPI = (*DocumentsAPIService)(&c.common) c.EntitiesAPI = (*EntitiesAPIService)(&c.common) + c.FilesAPI = (*FilesAPIService)(&c.common) c.MemoryAPI = (*MemoryAPIService)(&c.common) c.MentalModelsAPI = (*MentalModelsAPIService)(&c.common) c.MonitoringAPI = (*MonitoringAPIService)(&c.common) diff --git a/hindsight-clients/go/model_features_info.go b/hindsight-clients/go/model_features_info.go index 8752b96c..81703f68 100644 --- a/hindsight-clients/go/model_features_info.go +++ b/hindsight-clients/go/model_features_info.go @@ -29,6 +29,8 @@ type FeaturesInfo struct { Worker bool `json:"worker"` // Whether per-bank configuration API is enabled BankConfigApi bool `json:"bank_config_api"` + // Whether file upload/conversion API is enabled + FileUploadApi bool `json:"file_upload_api"` } type _FeaturesInfo FeaturesInfo @@ -37,12 +39,13 @@ type _FeaturesInfo FeaturesInfo // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool) *FeaturesInfo { +func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool, fileUploadApi bool) *FeaturesInfo { this := FeaturesInfo{} this.Observations = observations this.Mcp = mcp this.Worker = worker this.BankConfigApi = bankConfigApi + this.FileUploadApi = fileUploadApi return &this } @@ -150,6 +153,30 @@ func (o *FeaturesInfo) SetBankConfigApi(v bool) { o.BankConfigApi = v } +// GetFileUploadApi returns the FileUploadApi field value +func (o *FeaturesInfo) GetFileUploadApi() bool { + if o == nil { + var ret bool + return ret + } + + return o.FileUploadApi +} + +// GetFileUploadApiOk returns a tuple with the FileUploadApi field value +// and a boolean to check if the value has been set. +func (o *FeaturesInfo) GetFileUploadApiOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.FileUploadApi, true +} + +// SetFileUploadApi sets field value +func (o *FeaturesInfo) SetFileUploadApi(v bool) { + o.FileUploadApi = v +} + func (o FeaturesInfo) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -164,6 +191,7 @@ func (o FeaturesInfo) ToMap() (map[string]interface{}, error) { toSerialize["mcp"] = o.Mcp toSerialize["worker"] = o.Worker toSerialize["bank_config_api"] = o.BankConfigApi + toSerialize["file_upload_api"] = o.FileUploadApi return toSerialize, nil } @@ -176,6 +204,7 @@ func (o *FeaturesInfo) UnmarshalJSON(data []byte) (err error) { "mcp", "worker", "bank_config_api", + "file_upload_api", } allProperties := make(map[string]interface{}) diff --git a/hindsight-clients/go/model_file_retain_response.go b/hindsight-clients/go/model_file_retain_response.go new file mode 100644 index 00000000..d9515159 --- /dev/null +++ b/hindsight-clients/go/model_file_retain_response.go @@ -0,0 +1,159 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.4.11 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the FileRetainResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &FileRetainResponse{} + +// FileRetainResponse Response model for file upload endpoint. +type FileRetainResponse struct { + // Operation IDs for tracking file conversion operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. + OperationIds []string `json:"operation_ids"` +} + +type _FileRetainResponse FileRetainResponse + +// NewFileRetainResponse instantiates a new FileRetainResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewFileRetainResponse(operationIds []string) *FileRetainResponse { + this := FileRetainResponse{} + this.OperationIds = operationIds + return &this +} + +// NewFileRetainResponseWithDefaults instantiates a new FileRetainResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewFileRetainResponseWithDefaults() *FileRetainResponse { + this := FileRetainResponse{} + return &this +} + +// GetOperationIds returns the OperationIds field value +func (o *FileRetainResponse) GetOperationIds() []string { + if o == nil { + var ret []string + return ret + } + + return o.OperationIds +} + +// GetOperationIdsOk returns a tuple with the OperationIds field value +// and a boolean to check if the value has been set. +func (o *FileRetainResponse) GetOperationIdsOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.OperationIds, true +} + +// SetOperationIds sets field value +func (o *FileRetainResponse) SetOperationIds(v []string) { + o.OperationIds = v +} + +func (o FileRetainResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o FileRetainResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["operation_ids"] = o.OperationIds + return toSerialize, nil +} + +func (o *FileRetainResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "operation_ids", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varFileRetainResponse := _FileRetainResponse{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varFileRetainResponse) + + if err != nil { + return err + } + + *o = FileRetainResponse(varFileRetainResponse) + + return err +} + +type NullableFileRetainResponse struct { + value *FileRetainResponse + isSet bool +} + +func (v NullableFileRetainResponse) Get() *FileRetainResponse { + return v.value +} + +func (v *NullableFileRetainResponse) Set(val *FileRetainResponse) { + v.value = val + v.isSet = true +} + +func (v NullableFileRetainResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableFileRetainResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableFileRetainResponse(val *FileRetainResponse) *NullableFileRetainResponse { + return &NullableFileRetainResponse{value: val, isSet: true} +} + +func (v NullableFileRetainResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableFileRetainResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 01d35830..726609cd 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -4,6 +4,7 @@ hindsight_client_api/api/banks_api.py hindsight_client_api/api/directives_api.py hindsight_client_api/api/documents_api.py hindsight_client_api/api/entities_api.py +hindsight_client_api/api/files_api.py hindsight_client_api/api/memory_api.py hindsight_client_api/api/mental_models_api.py hindsight_client_api/api/monitoring_api.py @@ -47,6 +48,7 @@ hindsight_client_api/models/entity_list_response.py hindsight_client_api/models/entity_observation_response.py hindsight_client_api/models/entity_state_response.py hindsight_client_api/models/features_info.py +hindsight_client_api/models/file_retain_response.py hindsight_client_api/models/graph_data_response.py hindsight_client_api/models/http_validation_error.py hindsight_client_api/models/include_options.py diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index c876b239..824d71f8 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -6,11 +6,13 @@ easy-to-use interface on top of the auto-generated OpenAPI client. """ import asyncio +import json from datetime import datetime +from pathlib import Path from typing import Any, Literal import hindsight_client_api -from hindsight_client_api.api import banks_api, directives_api, memory_api, mental_models_api +from hindsight_client_api.api import banks_api, directives_api, files_api, memory_api, mental_models_api from hindsight_client_api.models import ( memory_item, recall_request, @@ -18,6 +20,7 @@ from hindsight_client_api.models import ( retain_request, ) from hindsight_client_api.models.bank_profile_response import BankProfileResponse +from hindsight_client_api.models.file_retain_response import FileRetainResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.recall_response import RecallResponse from hindsight_client_api.models.recall_result import RecallResult @@ -80,6 +83,7 @@ class Hindsight: self._banks_api = banks_api.BanksApi(self._api_client) self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client) self._directives_api = directives_api.DirectivesApi(self._api_client) + self._files_api = files_api.FilesApi(self._api_client) def __enter__(self): """Context manager entry.""" @@ -199,6 +203,41 @@ class Hindsight: return _run_async(self._memory_api.retain_memories(bank_id, request_obj)) + def retain_files( + self, + bank_id: str, + files: list[str | Path], + context: str | None = None, + files_metadata: list[dict[str, Any]] | None = None, + ) -> FileRetainResponse: + """ + Upload files and retain their contents as memories. + + Files are automatically converted to text (PDF, DOCX, images via OCR, audio via + transcription, and more) and ingested as memories. Processing is always asynchronous + — use the returned operation IDs to track progress. + + Args: + bank_id: The memory bank ID + files: List of file paths to upload + context: Optional context description applied to all files + files_metadata: Optional per-file metadata list. If provided, must match the + length of `files`. Each entry can have: context, document_id, tags, metadata. + + Returns: + FileRetainResponse with operation_ids for tracking progress + """ + file_data = [] + for file_path in files: + path = Path(file_path) + file_data.append((path.name, path.read_bytes())) + + meta = files_metadata or [{"context": context} if context else {} for _ in files] + + request_body = json.dumps({"files_metadata": meta}) + + return _run_async(self._files_api.file_retain(bank_id=bank_id, files=file_data, request=request_body)) + def recall( self, bank_id: str, diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 6fa1da7c..585aa87e 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -21,6 +21,7 @@ from hindsight_client_api.api.banks_api import BanksApi from hindsight_client_api.api.directives_api import DirectivesApi from hindsight_client_api.api.documents_api import DocumentsApi from hindsight_client_api.api.entities_api import EntitiesApi +from hindsight_client_api.api.files_api import FilesApi from hindsight_client_api.api.memory_api import MemoryApi from hindsight_client_api.api.mental_models_api import MentalModelsApi from hindsight_client_api.api.monitoring_api import MonitoringApi @@ -72,6 +73,7 @@ from hindsight_client_api.models.entity_list_response import EntityListResponse from hindsight_client_api.models.entity_observation_response import EntityObservationResponse from hindsight_client_api.models.entity_state_response import EntityStateResponse from hindsight_client_api.models.features_info import FeaturesInfo +from hindsight_client_api.models.file_retain_response import FileRetainResponse from hindsight_client_api.models.graph_data_response import GraphDataResponse from hindsight_client_api.models.http_validation_error import HTTPValidationError from hindsight_client_api.models.include_options import IncludeOptions diff --git a/hindsight-clients/python/hindsight_client_api/api/__init__.py b/hindsight-clients/python/hindsight_client_api/api/__init__.py index dda78a34..7fc27c93 100644 --- a/hindsight-clients/python/hindsight_client_api/api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/api/__init__.py @@ -5,6 +5,7 @@ from hindsight_client_api.api.banks_api import BanksApi from hindsight_client_api.api.directives_api import DirectivesApi from hindsight_client_api.api.documents_api import DocumentsApi from hindsight_client_api.api.entities_api import EntitiesApi +from hindsight_client_api.api.files_api import FilesApi from hindsight_client_api.api.memory_api import MemoryApi from hindsight_client_api.api.mental_models_api import MentalModelsApi from hindsight_client_api.api.monitoring_api import MonitoringApi diff --git a/hindsight-clients/python/hindsight_client_api/api/files_api.py b/hindsight-clients/python/hindsight_client_api/api/files_api.py new file mode 100644 index 00000000..3c023467 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/api/files_api.py @@ -0,0 +1,361 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.4.11 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + +import warnings +from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt +from typing import Any, Dict, List, Optional, Tuple, Union +from typing_extensions import Annotated + +from pydantic import Field, StrictBytes, StrictStr +from typing import List, Optional, Tuple, Union +from typing_extensions import Annotated +from hindsight_client_api.models.file_retain_response import FileRetainResponse + +from hindsight_client_api.api_client import ApiClient, RequestSerialized +from hindsight_client_api.api_response import ApiResponse +from hindsight_client_api.rest import RESTResponseType + + +class FilesApi: + """NOTE: This class is auto generated by OpenAPI Generator + Ref: https://openapi-generator.tech + + Do not edit the class manually. + """ + + def __init__(self, api_client=None) -> None: + if api_client is None: + api_client = ApiClient.get_default() + self.api_client = api_client + + + @validate_call + async def file_retain( + self, + bank_id: StrictStr, + files: Annotated[List[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="Files to upload and convert")], + request: Annotated[StrictStr, Field(description="JSON string with FileRetainRequest model")], + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> FileRetainResponse: + """Convert files to memories + + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown). + + :param bank_id: (required) + :type bank_id: str + :param files: Files to upload and convert (required) + :type files: List[bytearray] + :param request: JSON string with FileRetainRequest model (required) + :type request: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._file_retain_serialize( + bank_id=bank_id, + files=files, + request=request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "FileRetainResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def file_retain_with_http_info( + self, + bank_id: StrictStr, + files: Annotated[List[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="Files to upload and convert")], + request: Annotated[StrictStr, Field(description="JSON string with FileRetainRequest model")], + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[FileRetainResponse]: + """Convert files to memories + + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown). + + :param bank_id: (required) + :type bank_id: str + :param files: Files to upload and convert (required) + :type files: List[bytearray] + :param request: JSON string with FileRetainRequest model (required) + :type request: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._file_retain_serialize( + bank_id=bank_id, + files=files, + request=request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "FileRetainResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def file_retain_without_preload_content( + self, + bank_id: StrictStr, + files: Annotated[List[Union[StrictBytes, StrictStr, Tuple[StrictStr, StrictBytes]]], Field(description="Files to upload and convert")], + request: Annotated[StrictStr, Field(description="JSON string with FileRetainRequest model")], + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Convert files to memories + + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model (files_metadata) **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown). + + :param bank_id: (required) + :type bank_id: str + :param files: Files to upload and convert (required) + :type files: List[bytearray] + :param request: JSON string with FileRetainRequest model (required) + :type request: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._file_retain_serialize( + bank_id=bank_id, + files=files, + request=request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "FileRetainResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _file_retain_serialize( + self, + bank_id, + files, + request, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + 'files': 'csv', + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + if files is not None: + _files['files'] = files + if request is not None: + _form_params.append(('request', request)) + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'multipart/form-data' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/default/banks/{bank_id}/files/retain', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 0b3b710a..030ff1e6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -48,6 +48,7 @@ from hindsight_client_api.models.entity_list_response import EntityListResponse from hindsight_client_api.models.entity_observation_response import EntityObservationResponse from hindsight_client_api.models.entity_state_response import EntityStateResponse from hindsight_client_api.models.features_info import FeaturesInfo +from hindsight_client_api.models.file_retain_response import FileRetainResponse from hindsight_client_api.models.graph_data_response import GraphDataResponse from hindsight_client_api.models.http_validation_error import HTTPValidationError from hindsight_client_api.models.include_options import IncludeOptions diff --git a/hindsight-clients/python/hindsight_client_api/models/features_info.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py index 4f70ed78..3ddb3526 100644 --- a/hindsight-clients/python/hindsight_client_api/models/features_info.py +++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py @@ -30,7 +30,8 @@ class FeaturesInfo(BaseModel): mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: StrictBool = Field(description="Whether the background worker is enabled") bank_config_api: StrictBool = Field(description="Whether per-bank configuration API is enabled") - __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api"] + file_upload_api: StrictBool = Field(description="Whether file upload/conversion API is enabled") + __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api", "file_upload_api"] model_config = ConfigDict( populate_by_name=True, @@ -86,7 +87,8 @@ class FeaturesInfo(BaseModel): "observations": obj.get("observations"), "mcp": obj.get("mcp"), "worker": obj.get("worker"), - "bank_config_api": obj.get("bank_config_api") + "bank_config_api": obj.get("bank_config_api"), + "file_upload_api": obj.get("file_upload_api") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py b/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py new file mode 100644 index 00000000..9630621f --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py @@ -0,0 +1,87 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.4.11 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class FileRetainResponse(BaseModel): + """ + Response model for file upload endpoint. + """ # noqa: E501 + operation_ids: List[StrictStr] = Field(description="Operation IDs for tracking file conversion operations. Use GET /v1/default/banks/{bank_id}/operations to list operations.") + __properties: ClassVar[List[str]] = ["operation_ids"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of FileRetainResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of FileRetainResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "operation_ids": obj.get("operation_ids") + }) + return _obj + + diff --git a/hindsight-clients/rust/build.rs b/hindsight-clients/rust/build.rs index 66bff92e..630bdfcd 100644 --- a/hindsight-clients/rust/build.rs +++ b/hindsight-clients/rust/build.rs @@ -13,6 +13,36 @@ fn convert_31_to_30(spec: &mut serde_json::Value) { convert_anyof_to_nullable(spec); } +/// Remove paths with multipart/form-data content type (not supported by progenitor) +fn filter_multipart_endpoints(spec: &mut serde_json::Value) { + if let Some(paths) = spec.get_mut("paths").and_then(|v| v.as_object_mut()) { + let mut paths_to_remove = Vec::new(); + + for (path_name, path_item) in paths.iter() { + if let Some(operations) = path_item.as_object() { + for (_method, operation) in operations.iter() { + if let Some(request_body) = operation.get("requestBody") { + if let Some(content) = request_body.get("content") { + if let Some(content_obj) = content.as_object() { + if content_obj.contains_key("multipart/form-data") { + eprintln!("Filtering out endpoint with multipart/form-data: {}", path_name); + paths_to_remove.push(path_name.clone()); + break; + } + } + } + } + } + } + } + + // Remove the paths + for path in paths_to_remove { + paths.remove(&path); + } + } +} + fn convert_anyof_to_nullable(value: &mut serde_json::Value) { match value { serde_json::Value::Object(obj) => { @@ -103,6 +133,9 @@ fn main() { } } + // Filter out multipart/form-data endpoints (progenitor doesn't support them) + filter_multipart_endpoints(&mut spec_json); + // Now parse as OpenAPI struct let spec: openapiv3::OpenAPI = serde_json::from_value(spec_json) .expect("Failed to parse converted OpenAPI spec"); diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 365935be..141eef35 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -1,6 +1,11 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { Client, Options as Options2, TDataShape } from "./client"; +import { + type Client, + formDataBodySerializer, + type Options as Options2, + type TDataShape, +} from "./client"; import { client } from "./client.gen"; import type { AddBankBackgroundData, @@ -36,6 +41,9 @@ import type { DeleteMentalModelData, DeleteMentalModelErrors, DeleteMentalModelResponses, + FileRetainData, + FileRetainErrors, + FileRetainResponses, GetAgentStatsData, GetAgentStatsErrors, GetAgentStatsResponses, @@ -937,3 +945,48 @@ export const retainMemories = ( ...options.headers, }, }); + +/** + * Convert files to memories + * + * Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. + * + * This endpoint handles file upload, conversion, and memory creation in a single operation. + * + * **Features:** + * - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) + * - Automatic file-to-markdown conversion using pluggable parsers + * - Files stored in object storage (PostgreSQL by default, S3 for production) + * - Each file becomes a separate document with optional metadata/tags + * - Always processes asynchronously — returns operation IDs immediately + * + * **The system automatically:** + * 1. Stores uploaded files in object storage + * 2. Converts files to markdown + * 3. Creates document records with file metadata + * 4. Extracts facts and creates memory units (same as regular retain) + * + * Use the operations endpoint to monitor progress. + * + * **Request format:** multipart/form-data with: + * - `files`: One or more files to upload + * - `request`: JSON string with FileRetainRequest model (files_metadata) + * + * **Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown). + */ +export const fileRetain = ( + options: Options, +) => + (options.client ?? client).post< + FileRetainResponses, + FileRetainErrors, + ThrowOnError + >({ + ...formDataBodySerializer, + url: "/v1/default/banks/{bank_id}/files/retain", + ...options, + headers: { + "Content-Type": null, + ...options.headers, + }, + }); diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 5121dfe0..8bcf22ba 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -251,6 +251,24 @@ export type BankStatsResponse = { total_observations?: number; }; +/** + * Body_file_retain + */ +export type BodyFileRetain = { + /** + * Files + * + * Files to upload and convert + */ + files: Array; + /** + * Request + * + * JSON string with FileRetainRequest model + */ + request: string; +}; + /** * Budget * @@ -896,6 +914,26 @@ export type FeaturesInfo = { * Whether per-bank configuration API is enabled */ bank_config_api: boolean; + /** + * File Upload Api + * + * Whether file upload/conversion API is enabled + */ + file_upload_api: boolean; +}; + +/** + * FileRetainResponse + * + * Response model for file upload endpoint. + */ +export type FileRetainResponse = { + /** + * Operation Ids + * + * Operation IDs for tracking file conversion operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. + */ + operation_ids: Array; }; /** @@ -1760,7 +1798,7 @@ export type RetainResponse = { /** * Operation Id * - * Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations and find this ID. Only present when async=true. + * Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. */ operation_id?: string | null; /** @@ -3753,3 +3791,40 @@ export type RetainMemoriesResponses = { export type RetainMemoriesResponse = RetainMemoriesResponses[keyof RetainMemoriesResponses]; + +export type FileRetainData = { + body: BodyFileRetain; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/files/retain"; +}; + +export type FileRetainErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type FileRetainError = FileRetainErrors[keyof FileRetainErrors]; + +export type FileRetainResponses = { + /** + * Successful Response + */ + 200: FileRetainResponse; +}; + +export type FileRetainResponse2 = + FileRetainResponses[keyof FileRetainResponses]; diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 653c97c3..b6f93ef4 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -36,6 +36,7 @@ import type { RecallResult, ReflectRequest, ReflectResponse, + FileRetainResponse, ListMemoryUnitsResponse, BankProfileResponse, CreateBankRequest, @@ -211,6 +212,40 @@ export class HindsightClient { return this.validateResponse(response, 'retainBatch'); } + /** + * Upload files and retain their contents as memories. + * + * Files are automatically converted to text (PDF, DOCX, images via OCR, audio via + * transcription, and more) and ingested as memories. Processing is always asynchronous — + * use the returned operation IDs to track progress via the operations endpoint. + * + * @param bankId - The memory bank ID + * @param files - Array of File or Blob objects to upload + * @param options - Optional settings: context, documentTags, filesMetadata + */ + async retainFiles( + bankId: string, + files: Array, + options?: { + context?: string; + filesMetadata?: Array<{ context?: string; document_id?: string; tags?: string[]; metadata?: Record }>; + } + ): Promise { + const meta = options?.filesMetadata ?? files.map(() => options?.context ? { context: options.context } : {}); + + const requestBody = JSON.stringify({ + files_metadata: meta, + }); + + const response = await sdk.fileRetain({ + client: this.client, + path: { bank_id: bankId }, + body: { files, request: requestBody }, + }); + + return this.validateResponse(response, 'retainFiles'); + } + /** * Recall memories with a natural language query. */ @@ -580,6 +615,7 @@ export type { RecallResult, ReflectRequest, ReflectResponse, + FileRetainResponse, ListMemoryUnitsResponse, BankProfileResponse, CreateBankRequest, diff --git a/hindsight-control-plane/src/app/api/files/retain/route.ts b/hindsight-control-plane/src/app/api/files/retain/route.ts new file mode 100644 index 00000000..303ca407 --- /dev/null +++ b/hindsight-control-plane/src/app/api/files/retain/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { DATAPLANE_URL } from "@/lib/hindsight-client"; + +export async function POST(request: NextRequest) { + try { + // Clone the form data to read bank_id without consuming it + const formData = await request.formData(); + + // Extract bank_id from request JSON + const requestJson = formData.get("request"); + if (!requestJson || typeof requestJson !== "string") { + return NextResponse.json({ error: "Missing request data" }, { status: 400 }); + } + + const requestData = JSON.parse(requestJson); + const bankId = requestData.bank_id; + + if (!bankId) { + return NextResponse.json({ error: "Missing bank_id" }, { status: 400 }); + } + + // Use the shared dataplane URL configuration + const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/files/retain`; + + // Forward the form data to the dataplane + const response = await fetch(url, { + method: "POST", + body: formData, + // Don't set Content-Type - let fetch handle multipart boundary + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorData; + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { error: errorText }; + } + return NextResponse.json(errorData, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error("Error uploading files:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to upload files" }, + { status: 500 } + ); + } +} diff --git a/hindsight-control-plane/src/components/bank-selector.tsx b/hindsight-control-plane/src/components/bank-selector.tsx index 103db1dc..5c42064a 100644 --- a/hindsight-control-plane/src/components/bank-selector.tsx +++ b/hindsight-control-plane/src/components/bank-selector.tsx @@ -34,6 +34,7 @@ import { Tag, Upload, X, + Lock, } from "lucide-react"; import { useTheme } from "@/lib/theme-context"; import Image from "next/image"; @@ -42,22 +43,6 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; -// Supported text file extensions (matching CLI) -const TEXT_EXTENSIONS = [ - "txt", - "md", - "json", - "yaml", - "yml", - "toml", - "xml", - "csv", - "log", - "rst", - "adoc", -]; -const ACCEPT_FILES = TEXT_EXTENSIONS.map((ext) => `.${ext}`).join(","); - function BankSelectorInner() { const router = useRouter(); const searchParams = useSearchParams(); @@ -85,6 +70,21 @@ function BankSelectorInner() { const [uploadProgress, setUploadProgress] = React.useState(""); const fileInputRef = React.useRef(null); + // Feature flags + const [fileUploadEnabled, setFileUploadEnabled] = React.useState(null); + + // Load feature flags + React.useEffect(() => { + client + .getVersion() + .then((version) => { + setFileUploadEnabled(version.features.file_upload_api); + }) + .catch(() => { + setFileUploadEnabled(false); + }); + }, []); + const sortedBanks = React.useMemo(() => { return [...banks].sort((a, b) => a.localeCompare(b)); }, [banks]); @@ -112,12 +112,7 @@ function BankSelectorInner() { const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); - // Filter to only supported extensions - const validFiles = files.filter((file) => { - const ext = file.name.split(".").pop()?.toLowerCase(); - return ext && TEXT_EXTENSIONS.includes(ext); - }); - setSelectedFiles((prev) => [...prev, ...validFiles]); + setSelectedFiles((prev) => [...prev, ...files]); // Reset input to allow selecting same file again if (fileInputRef.current) { fileInputRef.current.value = ""; @@ -128,15 +123,6 @@ function BankSelectorInner() { setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); }; - const readFileContent = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => resolve(e.target?.result as string); - reader.onerror = (e) => reject(e); - reader.readAsText(file); - }); - }; - const handleUploadFiles = async () => { if (!currentBank || selectedFiles.length === 0) return; @@ -149,33 +135,15 @@ function BankSelectorInner() { .map((t) => t.trim()) .filter((t) => t.length > 0); - // Read all files and create items - const items: any[] = []; - for (let i = 0; i < selectedFiles.length; i++) { - const file = selectedFiles[i]; - setUploadProgress(`Reading file ${i + 1}/${selectedFiles.length}: ${file.name}`); - const content = await readFileContent(file); - const item: any = { - content, - context: `File: ${file.name}`, - }; - if (parsedTags.length > 0) item.tags = parsedTags; - items.push(item); - } + setUploadProgress(`Uploading ${selectedFiles.length} file(s)...`); - setUploadProgress(`Uploading ${items.length} file(s)...`); - - const params: any = { + // Use the new file upload API (always async, converter configured server-side) + await client.uploadFiles({ bank_id: currentBank, - items, - }; - if (parsedTags.length > 0) params.document_tags = parsedTags; - - if (docAsync) { - await client.retain({ ...params, async: true }); - } else { - await client.retain(params); - } + files: selectedFiles, + document_tags: parsedTags.length > 0 ? parsedTags : undefined, + async: true, + }); // Reset form and close dialog setDocDialogOpen(false); @@ -422,8 +390,16 @@ function BankSelectorInner() { Text - - + + {fileUploadEnabled === false ? ( + + ) : ( + + )} Upload Files @@ -505,91 +481,96 @@ function BankSelectorInner() { -
- - -
- - {selectedFiles.length > 0 && ( -
- -
- {selectedFiles.map((file, index) => ( -
-
- - {file.name} - - ({(file.size / 1024).toFixed(1)} KB) - -
- -
- ))} + {fileUploadEnabled === false ? ( +
+ +
+

File Upload API Disabled

+

+ File upload is not enabled on this server. +

+

+ To enable, set{" "} + + HINDSIGHT_API_ENABLE_FILE_UPLOAD_API=true + +

- )} + ) : ( + <> +
+ + +
-
- - setDocTags(e.target.value)} - placeholder="user_alice, session_123, project_x" - /> -

- Comma-separated tags for filtering during recall/reflect -

-
+ {selectedFiles.length > 0 && ( +
+ +
+ {selectedFiles.map((file, index) => ( +
+
+ + {file.name} + + ({(file.size / 1024).toFixed(1)} KB) + +
+ +
+ ))} +
+
+ )} -
- setDocAsync(checked as boolean)} - /> - -
+
+ + setDocTags(e.target.value)} + placeholder="user_alice, session_123, project_x" + /> +

+ Comma-separated tags for filtering during recall/reflect +

+
- {uploadProgress && ( -

{uploadProgress}

+ {uploadProgress && ( +

{uploadProgress}

+ )} + )} diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 1f3d0166..a490e35e 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -745,10 +745,69 @@ export class ControlPlaneClient { mcp: boolean; worker: boolean; bank_config_api: boolean; + file_upload_api: boolean; }; }>("/api/version"); } + /** + * Upload files for retain (uses file conversion API) + * Requires file_upload_api feature flag to be enabled + * Converter is configured server-side via HINDSIGHT_API_FILE_CONVERTER + */ + async uploadFiles(params: { + bank_id: string; + files: File[]; + document_tags?: string[]; + async?: boolean; + files_metadata?: Array<{ + document_id?: string; + context?: string; + metadata?: Record; + tags?: string[]; + timestamp?: string; + }>; + }) { + const formData = new FormData(); + + // Add files + params.files.forEach((file) => { + formData.append("files", file); + }); + + // Add request JSON (including bank_id) + const requestData: any = { + bank_id: params.bank_id, + async: params.async ?? true, + }; + if (params.document_tags) requestData.document_tags = params.document_tags; + if (params.files_metadata) requestData.files_metadata = params.files_metadata; + + formData.append("request", JSON.stringify(requestData)); + + // Use fetch directly for multipart/form-data + const response = await fetch(`/api/files/retain`, { + method: "POST", + body: formData, + // Don't set Content-Type - browser will set it with boundary + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorMessage; + } catch { + // Ignore parse errors + } + const error = new Error(errorMessage); + (error as any).status = response.status; + throw error; + } + + return response.json(); + } + /** * Get bank configuration (resolved with hierarchy) */ diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index b41a84a6..4a813071 100644 --- a/hindsight-control-plane/src/lib/hindsight-client.ts +++ b/hindsight-control-plane/src/lib/hindsight-client.ts @@ -14,6 +14,14 @@ import { export const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; const DATAPLANE_API_KEY = process.env.HINDSIGHT_CP_DATAPLANE_API_KEY || ""; +// Log dataplane configuration at startup +console.log(`[Control Plane] Connecting to dataplane at: ${DATAPLANE_URL}`); +if (DATAPLANE_API_KEY) { + console.log("[Control Plane] Using API key authentication"); +} else { + console.log("[Control Plane] No API key configured (public access)"); +} + /** * Auth headers for direct fetch calls to the dataplane API. */ diff --git a/hindsight-docs/docs/developer/api/retain.mdx b/hindsight-docs/docs/developer/api/retain.mdx index c9073567..d2d63616 100644 --- a/hindsight-docs/docs/developer/api/retain.mdx +++ b/hindsight-docs/docs/developer/api/retain.mdx @@ -102,20 +102,47 @@ The `document_id` groups related memories for later management. ## Store from Files +Upload files directly — Hindsight automatically converts them to text and extracts memories. File processing always runs asynchronously and returns operation IDs for tracking. + +**Supported formats:** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG, GIF, etc. — OCR), audio (MP3, WAV, FLAC, etc. — transcription), HTML, and plain text formats (TXT, MD, CSV, JSON, YAML, etc.) + - -```bash -# Single file -hindsight memory retain-files my-bank document.txt - -# Directory (recursive by default) -hindsight memory retain-files my-bank ./documents/ -``` - + + + + + + + + + + +### File Retain Response + +The file retain endpoint always returns asynchronously: + +| Field | Type | Description | +|-------|------|-------------| +| `operation_ids` | string[] | One operation ID per uploaded file. Use `GET /v1/default/banks/{bank_id}/operations` to track progress. | + +### Batch File Uploads + +Upload up to 10 files per request (max 100 MB total). Each file becomes a separate document with optional per-file metadata: + + + + + + + +:::info File Storage +Uploaded files are stored server-side (PostgreSQL by default, or S3/GCS/Azure for production). Configure storage via `HINDSIGHT_API_FILE_STORAGE_TYPE`. See [Configuration](../configuration#file-processing) for details. +::: + ## Async Ingestion diff --git a/hindsight-docs/docs/developer/configuration.md b/hindsight-docs/docs/developer/configuration.md index 134a5410..eeb82c0d 100644 --- a/hindsight-docs/docs/developer/configuration.md +++ b/hindsight-docs/docs/developer/configuration.md @@ -629,6 +629,114 @@ CONSOLIDATE related technical discussions into ONE fact when possible. Ask yourself: 'Would this technical context be useful in 6 months?' If no, skip it." ``` +### File Processing + +Configuration for the file upload and conversion pipeline (used by `POST /v1/default/banks/{bank_id}/files/retain`). + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_ENABLE_FILE_UPLOAD_API` | Enable the file upload API endpoint | `true` | +| `HINDSIGHT_API_FILE_PARSER` | File parser to use (`markitdown`) | `markitdown` | +| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE` | Max files per upload request | `10` | +| `HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB` | Max total upload size per request (MB) | `100` | +| `HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN` | Delete stored files after memory extraction completes | `true` | + +**Supported formats (via markitdown):** PDF, DOCX, DOC, PPTX, PPT, XLSX, XLS, images (JPG, PNG, GIF — OCR), audio (MP3, WAV — transcription), HTML, TXT, MD, CSV, and more. + +```bash +# Increase batch limits for large file imports +export HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE=20 +export HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB=500 + +# Keep files after processing (useful for debugging or re-processing) +export HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN=false +``` + +### File Storage + +Files uploaded via the file retain API are stored in an object storage backend before conversion. Choose the backend that fits your infrastructure. + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_FILE_STORAGE_TYPE` | Storage backend: `native`, `s3`, `gcs`, or `azure` | `native` | + +#### Native (PostgreSQL) + +Files are stored as `BYTEA` in the `file_storage` table. No additional infrastructure required. Suitable for development and small deployments. + +```bash +# Native storage is the default — no additional configuration needed +export HINDSIGHT_API_FILE_STORAGE_TYPE=native +``` + +#### S3 / S3-Compatible + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_FILE_STORAGE_S3_BUCKET` | S3 bucket name | - | +| `HINDSIGHT_API_FILE_STORAGE_S3_REGION` | AWS region | - | +| `HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT` | Custom endpoint URL (for S3-compatible stores like MinIO, Cloudflare R2) | AWS default | +| `HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID` | AWS access key ID | - | +| `HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY` | AWS secret access key | - | + +```bash +# AWS S3 +export HINDSIGHT_API_FILE_STORAGE_TYPE=s3 +export HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-hindsight-files +export HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1 +export HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +export HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# S3-compatible (MinIO, Cloudflare R2, etc.) +export HINDSIGHT_API_FILE_STORAGE_TYPE=s3 +export HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-bucket +export HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT=https://your-minio.example.com +export HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=minioadmin +export HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=minioadmin +``` + +#### Google Cloud Storage + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET` | GCS bucket name | - | +| `HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY` | Path to service account JSON key file | ADC if not set | + +```bash +export HINDSIGHT_API_FILE_STORAGE_TYPE=gcs +export HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET=my-hindsight-files +# Optional: use service account key file (otherwise falls back to ADC) +export HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY=/path/to/key.json +``` + +#### Azure Blob Storage + +| Variable | Description | Default | +|----------|-------------|---------| +| `HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER` | Azure container name | - | +| `HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME` | Azure storage account name | - | +| `HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY` | Azure storage account key | - | + +```bash +export HINDSIGHT_API_FILE_STORAGE_TYPE=azure +export HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER=hindsight-files +export HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME=mystorageaccount +export HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY=base64encodedkey== +``` + +#### Storage Backend Comparison + +| Backend | Best For | Notes | +|---------|----------|-------| +| `native` | Development, small deployments | No extra infrastructure, stored in PostgreSQL | +| `s3` | Production, AWS deployments | Works with any S3-compatible store | +| `gcs` | Production, GCP deployments | Supports ADC for keyless auth | +| `azure` | Production, Azure deployments | Uses account key auth | + +:::tip Production Recommendation +For production deployments, use `s3`, `gcs`, or `azure` to avoid storing large binary files in your PostgreSQL database. Set `HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN=true` (the default) to delete files after memory extraction, which minimizes storage costs. +::: + ### Observations (Experimental) Observations are consolidated knowledge synthesized from facts. @@ -989,6 +1097,13 @@ HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx # HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension # HINDSIGHT_API_TENANT_API_KEY=your-secret-api-key +# File storage (optional, defaults to PostgreSQL native storage) +# HINDSIGHT_API_FILE_STORAGE_TYPE=s3 +# HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=my-hindsight-files +# HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1 +# HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +# HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + # Control Plane HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888 ``` diff --git a/hindsight-docs/examples/api/retain.mjs b/hindsight-docs/examples/api/retain.mjs index 3aa3e8d2..f6bec7c3 100644 --- a/hindsight-docs/examples/api/retain.mjs +++ b/hindsight-docs/examples/api/retain.mjs @@ -49,6 +49,22 @@ await client.retainBatch('my-bank', [ // [/docs:retain-async] +// [docs:retain-files] +// Upload files and retain their contents as memories. +// Supports: PDF, DOCX, PPTX, XLSX, images (OCR), audio (transcription), and text formats. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pdfBytes = readFileSync(join(__dirname, 'sample.pdf')); +const result = await client.retainFiles('my-bank', [ + new File([pdfBytes], 'sample.pdf'), +], { context: 'quarterly report' }); +console.log(result.operation_ids); // Track processing via the operations endpoint +// [/docs:retain-files] + + // ============================================================================= // Cleanup (not shown in docs) // ============================================================================= diff --git a/hindsight-docs/examples/api/retain.py b/hindsight-docs/examples/api/retain.py index 5c57c415..fe718760 100644 --- a/hindsight-docs/examples/api/retain.py +++ b/hindsight-docs/examples/api/retain.py @@ -5,8 +5,10 @@ Run: python examples/api/retain.py """ import os import requests +from pathlib import Path HINDSIGHT_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888") +EXAMPLES_DIR = Path(__file__).parent # ============================================================================= # Setup (not shown in docs) @@ -98,6 +100,37 @@ client.retain_batch( # [/docs:retain-with-document-tags] +# [docs:retain-files] +# Upload files and retain their contents as memories. +# Supports: PDF, DOCX, PPTX, XLSX, images (OCR), audio (transcription), and text formats. +# Pass file paths — the client reads and uploads them automatically. +result = client.retain_files( + bank_id="my-bank", + files=[EXAMPLES_DIR / "sample.pdf"], + context="quarterly report", +) +print(result.operation_ids) # Track processing via the operations endpoint +# [/docs:retain-files] + + +# [docs:retain-files-batch] +# Upload multiple files with per-file metadata (up to 10 files per batch). +# Each file gets its own context, document_id, and tags. +result = client.retain_files( + bank_id="my-bank", + files=[ + EXAMPLES_DIR / "sample.pdf", + EXAMPLES_DIR / "sample.pdf", # Replace with a second file path + ], + files_metadata=[ + {"context": "quarterly report", "document_id": "q1-report", "tags": ["project:alpha"]}, + {"context": "meeting notes", "document_id": "q1-notes", "tags": ["project:alpha"]}, + ], +) +print(result.operation_ids) # One operation ID per file +# [/docs:retain-files-batch] + + # [docs:retain-list-tags] # List all tags in a bank response = requests.get(f"{HINDSIGHT_URL}/v1/default/banks/my-bank/tags") diff --git a/hindsight-docs/examples/api/retain.sh b/hindsight-docs/examples/api/retain.sh index 0b2ad2bf..465bbf95 100755 --- a/hindsight-docs/examples/api/retain.sh +++ b/hindsight-docs/examples/api/retain.sh @@ -26,6 +26,29 @@ hindsight memory retain my-bank "Meeting notes" --async # [/docs:retain-async] +# [docs:retain-files] +# Upload a single file (PDF, DOCX, PPTX, XLSX, images, audio, and more) +hindsight memory retain-files my-bank report.pdf + +# Upload a directory of files +hindsight memory retain-files my-bank ./documents/ + +# Upload and wait for processing to complete (polls until done) +hindsight memory retain-files my-bank report.pdf + +# Queue files for background processing (returns immediately) +hindsight memory retain-files my-bank ./documents/ --async +# [/docs:retain-files] + + +# [docs:retain-files-curl] +# Via HTTP API (multipart/form-data) +curl -X POST "${HINDSIGHT_URL}/v1/default/banks/my-bank/files/retain" \ + -F "files=@report.pdf;type=application/octet-stream" \ + -F "request={\"files_metadata\": [{\"context\": \"quarterly report\"}]}" +# [/docs:retain-files-curl] + + # ============================================================================= # Cleanup (not shown in docs) # ============================================================================= diff --git a/hindsight-docs/examples/api/sample.pdf b/hindsight-docs/examples/api/sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..c83d54bd901f1d6ecb5a52f6cf9edc8b6b558578 GIT binary patch literal 26662 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 literal 0 HcmV?d00001 diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 0e9e7ce1..73cc5e5d 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -3134,6 +3134,75 @@ } } } + }, + "/v1/default/banks/{bank_id}/files/retain": { + "post": { + "tags": [ + "Files" + ], + "summary": "Convert files to memories", + "description": "Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\nThis endpoint handles file upload, conversion, and memory creation in a single operation.\n\n**Features:**\n- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n- Automatic file-to-markdown conversion using pluggable parsers\n- Files stored in object storage (PostgreSQL by default, S3 for production)\n- Each file becomes a separate document with optional metadata/tags\n- Always processes asynchronously \u2014 returns operation IDs immediately\n\n**The system automatically:**\n1. Stores uploaded files in object storage\n2. Converts files to markdown\n3. Creates document records with file metadata\n4. Extracts facts and creates memory units (same as regular retain)\n\nUse the operations endpoint to monitor progress.\n\n**Request format:** multipart/form-data with:\n- `files`: One or more files to upload\n- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).", + "operationId": "file_retain", + "parameters": [ + { + "name": "bank_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Bank Id" + } + }, + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_file_retain" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileRetainResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -3566,6 +3635,30 @@ "total_observations": 45 } }, + "Body_file_retain": { + "properties": { + "files": { + "items": { + "type": "string", + "format": "binary" + }, + "type": "array", + "title": "Files", + "description": "Files to upload and convert" + }, + "request": { + "type": "string", + "title": "Request", + "description": "JSON string with FileRetainRequest model" + } + }, + "type": "object", + "required": [ + "files", + "request" + ], + "title": "Body_file_retain" + }, "Budget": { "type": "string", "enum": [ @@ -4547,6 +4640,11 @@ "type": "boolean", "title": "Bank Config Api", "description": "Whether per-bank configuration API is enabled" + }, + "file_upload_api": { + "type": "boolean", + "title": "File Upload Api", + "description": "Whether file upload/conversion API is enabled" } }, "type": "object", @@ -4554,11 +4652,37 @@ "observations", "mcp", "worker", - "bank_config_api" + "bank_config_api", + "file_upload_api" ], "title": "FeaturesInfo", "description": "Feature flags indicating which capabilities are enabled." }, + "FileRetainResponse": { + "properties": { + "operation_ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Operation Ids", + "description": "Operation IDs for tracking file conversion operations. Use GET /v1/default/banks/{bank_id}/operations to list operations." + } + }, + "type": "object", + "required": [ + "operation_ids" + ], + "title": "FileRetainResponse", + "description": "Response model for file upload endpoint.", + "example": { + "operation_ids": [ + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + "550e8400-e29b-41d4-a716-446655440002" + ] + } + }, "GraphDataResponse": { "properties": { "nodes": { @@ -6272,7 +6396,7 @@ } ], "title": "Operation Id", - "description": "Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations and find this ID. Only present when async=true." + "description": "Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true." }, "usage": { "anyOf": [ @@ -6593,6 +6717,7 @@ "api_version": "0.4.0", "features": { "bank_config_api": false, + "file_upload_api": true, "mcp": true, "observations": false, "worker": true diff --git a/scripts/generate-clients.sh b/scripts/generate-clients.sh index 8c23d4f5..1da5a7fd 100755 --- a/scripts/generate-clients.sh +++ b/scripts/generate-clients.sh @@ -396,6 +396,13 @@ else [ -f "$TEMP_DIR/trace_test.go" ] && mv "$TEMP_DIR/trace_test.go" . rm -rf "$TEMP_DIR" + # Fix known generator issue: api_files.go uses os.File but generator omits "os" import + if [ -f "api_files.go" ] && grep -q 'os\.File' api_files.go && ! grep -q '"os"' api_files.go; then + echo "Patching api_files.go: adding missing 'os' import..." + sed -i.bak 's|"net/url"|"net/url"\n\t"os"|' api_files.go + rm -f api_files.go.bak + fi + # Initialize module and build echo "Building Go client..." go mod tidy diff --git a/uv.lock b/uv.lock index 7fb239dc..6613fa72 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,14 @@ version = 1 revision = 1 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", ] [manifest] @@ -627,6 +633,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228 }, ] +[[package]] +name = "cobble" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/7a/a507c709be2c96e1bb6102eb7b7f4026c5e5e223ef7d745a17d239e9d844/cobble-0.1.4.tar.gz", hash = "sha256:de38be1539992c8a06e569630717c485a5f91be2192c461ea2b220607dfa78aa", size = 3805 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/e1/3714a2f371985215c219c2a70953d38e3eed81ef165aed061d21de0e998b/cobble-0.1.4-py3-none-any.whl", hash = "sha256:36c91b1655e599fd428e2b95fdd5f0da1ca2e9f1abb0bc871dec21a0e78a2b44", size = 3984 }, +] + [[package]] name = "cohere" version = "5.20.1" @@ -759,6 +774,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453 }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + [[package]] name = "diskcache" version = "5.6.3" @@ -786,6 +810,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774 }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -817,6 +855,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -1423,6 +1470,8 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "litellm" }, + { name = "markitdown", extra = ["docx", "pdf", "pptx", "xls", "xlsx"] }, + { name = "obstore" }, { name = "openai" }, { name = "opentelemetry-api" }, { name = "opentelemetry-exporter-otlp-proto-http" }, @@ -1458,6 +1507,7 @@ test = [ { name = "pytest-asyncio" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, + { name = "testcontainers" }, ] [package.dev-dependencies] @@ -1469,6 +1519,7 @@ dev = [ { name = "pytest-xdist" }, { name = "python-dotenv" }, { name = "ruff" }, + { name = "testcontainers" }, { name = "ty" }, ] @@ -1494,6 +1545,8 @@ requires-dist = [ { name = "langchain-core", specifier = ">=1.2.5" }, { name = "langchain-text-splitters", specifier = ">=0.3.0" }, { name = "litellm", specifier = ">=1.0.0" }, + { name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], specifier = ">=0.1.4" }, + { name = "obstore", specifier = ">=0.4.0" }, { name = "openai", specifier = ">=1.0.0" }, { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.20.0" }, @@ -1516,6 +1569,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.0.0" }, { name = "sentence-transformers", specifier = ">=3.3.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, + { name = "testcontainers", marker = "extra == 'test'", specifier = ">=4.0.0" }, { name = "tiktoken", specifier = ">=0.12.0" }, { name = "torch", specifier = ">=2.6.0" }, { name = "transformers", specifier = ">=4.53.0" }, @@ -1536,6 +1590,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "ruff", specifier = ">=0.8.0" }, + { name = "testcontainers", specifier = ">=4.0.0" }, { name = "ty", specifier = ">=0.0.1" }, ] @@ -2160,6 +2215,126 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818 }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365 }, + { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793 }, + { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362 }, + { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152 }, + { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539 }, + { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853 }, + { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133 }, + { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944 }, + { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535 }, + { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343 }, + { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419 }, + { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008 }, + { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906 }, + { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357 }, + { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583 }, + { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591 }, + { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887 }, + { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818 }, + { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807 }, + { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179 }, + { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044 }, + { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685 }, + { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127 }, + { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958 }, + { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541 }, + { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426 }, + { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917 }, + { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795 }, + { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759 }, + { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666 }, + { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989 }, + { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456 }, + { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793 }, + { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836 }, + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494 }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146 }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932 }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060 }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000 }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496 }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779 }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072 }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171 }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175 }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688 }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655 }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695 }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841 }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700 }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347 }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248 }, + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801 }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403 }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974 }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953 }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054 }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421 }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684 }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463 }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437 }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890 }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185 }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895 }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246 }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797 }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404 }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072 }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617 }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930 }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380 }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632 }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171 }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109 }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061 }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233 }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739 }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119 }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665 }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997 }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957 }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372 }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653 }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795 }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023 }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420 }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837 }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205 }, + { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829 }, + { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277 }, + { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433 }, + { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119 }, + { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314 }, + { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768 }, +] + +[[package]] +name = "magika" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474 }, + { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841 }, + { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831 }, +] + [[package]] name = "mako" version = "1.3.10" @@ -2172,6 +2347,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509 }, ] +[[package]] +name = "mammoth" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cobble" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/3c/a58418d2af00f2da60d4a51e18cd0311307b72d48d2fffec36a97b4a5e44/mammoth-1.11.0.tar.gz", hash = "sha256:a0f59e442f34d5b6447f4b0999306cbf3e67aaabfa8cb516f878fb1456744637", size = 53142 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/54/2e39566a131b13f6d8d193f974cb6a34e81bb7cc2fa6f7e03de067b36588/mammoth-1.11.0-py2.py3-none-any.whl", hash = "sha256:c077ab0d450bd7c0c6ecd529a23bf7e0fa8190c929e28998308ff4eada3f063b", size = 54752 }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -2184,6 +2371,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, ] +[[package]] +name = "markdownify" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724 }, +] + +[[package]] +name = "markitdown" +version = "0.1.5b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "charset-normalizer" }, + { name = "defusedxml" }, + { name = "magika" }, + { name = "markdownify" }, + { name = "onnxruntime", marker = "sys_platform == 'win32'" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/ae/c1f5e3fbd63883f27aec6bb6f8af2852253928bc673617bd02f2740a3057/markitdown-0.1.5b1.tar.gz", hash = "sha256:d5bffdb9d7aff9cac423a6d80d9e3a970937cec61338167bd855555f8a1c591c", size = 44408 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/10/e3a4e65265c239b15b34199ca340b56b1163ffbc8d0a658a734f7fa2f8b3/markitdown-0.1.5b1-py3-none-any.whl", hash = "sha256:31b667ce9858bc7ff50b7c7aec5fab2c3103d3ca2cb69203b3edabdda5d3a568", size = 62710 }, +] + +[package.optional-dependencies] +docx = [ + { name = "lxml" }, + { name = "mammoth" }, +] +pdf = [ + { name = "pdfminer-six" }, + { name = "pdfplumber" }, +] +pptx = [ + { name = "python-pptx" }, +] +xls = [ + { name = "pandas" }, + { name = "xlrd" }, +] +xlsx = [ + { name = "openpyxl" }, + { name = "pandas" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -2563,7 +2802,7 @@ name = "nvidia-cudnn-cu12" version = "9.10.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467 }, @@ -2574,7 +2813,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695 }, @@ -2601,9 +2840,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905 }, @@ -2614,7 +2853,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466 }, @@ -2669,9 +2908,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, ] +[[package]] +name = "obstore" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/8c/9ec984edd0f3b72226adfaa19b1c61b15823b35b52f311ca4af36d009d15/obstore-0.8.2.tar.gz", hash = "sha256:a467bc4e97169e2ba749981b4fd0936015428d9b8f3fb83a5528536b1b6f377f", size = 168852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/c4/018f90701f1e5ea3fbd57f61463f42e1ef5218e548d3adcf12b6be021c34/obstore-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2edaa97687c191c5324bb939d72f6fe86a7aa8191c410f1648c14e8296d05c1c", size = 3622568 }, + { url = "https://files.pythonhosted.org/packages/a8/62/72dd1e7d52fc554bb1fdb1a9499bda219cf3facea5865a1d97fdc00b3a1b/obstore-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c4fb7ef8108f08d14edc8bec9e9a6a2e5c4d14eddb8819f5d0da498aff6e8888", size = 3356109 }, + { url = "https://files.pythonhosted.org/packages/e0/ae/089fe5b9207091252fe5ce352551214f04560f85eb8f2cc4f716a6a1a57e/obstore-0.8.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fda8f658c0edf799ab1e264f9b12c7c184cd09a5272dc645d42e987810ff2772", size = 3454588 }, + { url = "https://files.pythonhosted.org/packages/ea/10/1865ae2d1ba45e8ae85fb0c1aada2dc9533baf60c4dfe74dab905348d74a/obstore-0.8.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87fe2bc15ce4051ecb56abd484feca323c2416628beb62c1c7b6712114564d6e", size = 3688627 }, + { url = "https://files.pythonhosted.org/packages/a6/09/5d7ba6d0aeac563ea5f5586401c677bace4f782af83522b1fdf15430e152/obstore-0.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2482aa2562ab6a4ca40250b26bea33f8375b59898a9b5615fd412cab81098123", size = 3959896 }, + { url = "https://files.pythonhosted.org/packages/16/15/2b3eda59914761a9ff4d840e2daec5697fd29b293bd18d3dc11c593aed06/obstore-0.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4153b928f5d2e9c6cb645e83668a53e0b42253d1e8bcb4e16571fc0a1434599a", size = 3933162 }, + { url = "https://files.pythonhosted.org/packages/14/7a/5fc63b41526587067537fb1498c59a210884664c65ccf0d1f8f823b0875a/obstore-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbfa9c38620cc191be98c8b5558c62071e495dc6b1cc724f38293ee439aa9f92", size = 3769605 }, + { url = "https://files.pythonhosted.org/packages/77/4e/2208ab6e1fc021bf8b7e117249a10ab75d0ed24e0f2de1a8d7cd67d885b5/obstore-0.8.2-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:0822836eae8d52499f10daef17f26855b4c123119c6eb984aa4f2d525ec2678d", size = 3534396 }, + { url = "https://files.pythonhosted.org/packages/1d/8f/a0e2882edd6bd285c82b8a5851c4ecf386c93fe75b6e340d5d9d30e809fc/obstore-0.8.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ef6435dfd586d83b4f778e7927a5d5b0d8b771e9ba914bc809a13d7805410e6", size = 3697777 }, + { url = "https://files.pythonhosted.org/packages/94/78/ebf0c33bed5c9a8eed3b00eefafbcc0a687eeb1e05451c76fcf199d29ff8/obstore-0.8.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0f2cba91f4271ca95a932a51aa8dda1537160342b33f7836c75e1eb9d40621a2", size = 3681546 }, + { url = "https://files.pythonhosted.org/packages/af/21/9bf4fb9e53fd5f01af580b6538de2eae857e31d24b0ebfc4d916c306a1e4/obstore-0.8.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:23c876d603af0627627808d19a58d43eb5d8bfd02eecd29460bc9a58030fed55", size = 3765336 }, + { url = "https://files.pythonhosted.org/packages/dd/3c/7f6895c23719482d231b2d6ed328e3223fdf99785f6850fba8d2fc5a86ee/obstore-0.8.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff3c4b5d07629b70b9dee494cd6b94fff8465c3864752181a1cb81a77190fe42", size = 3941142 }, + { url = "https://files.pythonhosted.org/packages/93/a4/56ccdb756161595680a28f4b0def2c04f7048ffacf128029be8394367b26/obstore-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:aadb2cb72de7227d07f4570f82729625ffc77522fadca5cf13c3a37fbe8c8de9", size = 3970172 }, + { url = "https://files.pythonhosted.org/packages/2b/dc/60fefbb5736e69eab56657bca04ca64dc07fdeccb3814164a31b62ad066b/obstore-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bb70ce297a47392b1d9a3e310f18d59cd5ebbb9453428210fef02ed60e4d75d1", size = 3612955 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/844e8f382e5a12b8a3796a05d76a03e12c7aedc13d6900419e39207d7868/obstore-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1619bf618428abf1f607e0b219b2e230a966dcf697b717deccfa0983dd91f646", size = 3346564 }, + { url = "https://files.pythonhosted.org/packages/89/73/8537f99e09a38a54a6a15ede907aa25d4da089f767a808f0b2edd9c03cec/obstore-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4605c3ed7c9515aeb4c619b5f7f2c9986ed4a79fe6045e536b5e59b804b1476", size = 3460809 }, + { url = "https://files.pythonhosted.org/packages/b4/99/7714dec721e43f521d6325a82303a002cddad089437640f92542b84e9cc8/obstore-0.8.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce42670417876dd8668cbb8659e860e9725e5f26bbc86449fd259970e2dd9d18", size = 3692081 }, + { url = "https://files.pythonhosted.org/packages/ec/bd/4ac4175fe95a24c220a96021c25c432bcc0c0212f618be0737184eebbaad/obstore-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a3e893b2a06585f651c541c1972fe1e3bf999ae2a5fda052ee55eb7e6516f5", size = 3957466 }, + { url = "https://files.pythonhosted.org/packages/4e/04/caa288fb735484fc5cb019bdf3d896eaccfae0ac4622e520d05692c46790/obstore-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08462b32f95a9948ed56ed63e88406e2e5a4cae1fde198f9682e0fb8487100ed", size = 3951293 }, + { url = "https://files.pythonhosted.org/packages/44/2f/d380239da2d6a1fda82e17df5dae600a404e8a93a065784518ff8325d5f6/obstore-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a0bf7763292a8fc47d01cd66e6f19002c5c6ad4b3ed4e6b2729f5e190fa8a0d", size = 3766199 }, + { url = "https://files.pythonhosted.org/packages/28/41/d391be069d3da82969b54266948b2582aeca5dd735abeda4d63dba36e07b/obstore-0.8.2-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:bcd47f8126cb192cbe86942b8f73b1c45a651ce7e14c9a82c5641dfbf8be7603", size = 3529678 }, + { url = "https://files.pythonhosted.org/packages/b9/4c/4862fdd1a3abde459ee8eea699b1797df638a460af235b18ca82c8fffb72/obstore-0.8.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:57eda9fd8c757c3b4fe36cf3918d7e589cc1286591295cc10b34122fa36dd3fd", size = 3698079 }, + { url = "https://files.pythonhosted.org/packages/68/ca/014e747bc53b570059c27e3565b2316fbe5c107d4134551f4cd3e24aa667/obstore-0.8.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ea44442aad8992166baa69f5069750979e4c5d9ffce772e61565945eea5774b9", size = 3687154 }, + { url = "https://files.pythonhosted.org/packages/6f/89/6db5f8edd93028e5b8bfbeee15e6bd3e56f72106107d31cb208b57659de4/obstore-0.8.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:41496a3ab8527402db4142aaaf0d42df9d7d354b13ba10d9c33e0e48dd49dd96", size = 3773444 }, + { url = "https://files.pythonhosted.org/packages/26/e5/c9e2cc540689c873beb61246e1615d6e38301e6a34dec424f5a5c63c1afd/obstore-0.8.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43da209803f052df96c7c3cbec512d310982efd2407e4a435632841a51143170", size = 3939315 }, + { url = "https://files.pythonhosted.org/packages/4d/c9/bb53280ca50103c1ffda373cdc9b0f835431060039c2897cbc87ddd92e42/obstore-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:1836f5dcd49f9f2950c75889ab5c51fb290d3ea93cdc39a514541e0be3af016e", size = 3978234 }, + { url = "https://files.pythonhosted.org/packages/f0/5d/8c3316cc958d386d5e6ab03e9db9ddc27f8e2141cee4a6777ae5b92f3aac/obstore-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:212f033e53fe6e53d64957923c5c88949a400e9027f7038c705ec2e9038be563", size = 3612027 }, + { url = "https://files.pythonhosted.org/packages/ea/4d/699359774ce6330130536d008bfc32827fab0c25a00238d015a5974a3d1d/obstore-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bee21fa4ba148d08fa90e47a96df11161661ed31e09c056a373cb2154b0f2852", size = 3344686 }, + { url = "https://files.pythonhosted.org/packages/82/37/55437341f10512906e02fd9fa69a8a95ad3f2f6a916d3233fda01763d110/obstore-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c66594b59832ff1ced4c72575d9beb8b5f9b4e404ac1150a42bfb226617fd50", size = 3459860 }, + { url = "https://files.pythonhosted.org/packages/7a/51/4245a616c94ee4851965e33f7a563ab4090cc81f52cc73227ff9ceca2e46/obstore-0.8.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:089f33af5c2fe132d00214a0c1f40601b28f23a38e24ef9f79fb0576f2730b74", size = 3691648 }, + { url = "https://files.pythonhosted.org/packages/4e/f1/4e2fb24171e3ca3641a4653f006be826e7e17634b11688a5190553b00b83/obstore-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d87f658dfd340d5d9ea2d86a7c90d44da77a0db9e00c034367dca335735110cf", size = 3956867 }, + { url = "https://files.pythonhosted.org/packages/42/f5/b703115361c798c9c1744e1e700d5908d904a8c2e2bd38bec759c9ffb469/obstore-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e2e4fa92828c4fbc2d487f3da2d3588701a1b67d9f6ca3c97cc2afc912e9c63", size = 3950599 }, + { url = "https://files.pythonhosted.org/packages/53/20/08c6dc0f20c1394e2324b9344838e4e7af770cdcb52c30757a475f50daeb/obstore-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab440e89c5c37a8ec230857dd65147d4b923e0cada33297135d05e0f937d696a", size = 3765865 }, + { url = "https://files.pythonhosted.org/packages/77/20/77907765e29b2eba6bd8821872284d91170d7084f670855b2dfcb249ea14/obstore-0.8.2-cp313-cp313-manylinux_2_24_aarch64.whl", hash = "sha256:b9beed107c5c9cd995d4a73263861fcfbc414d58773ed65c14f80eb18258a932", size = 3529807 }, + { url = "https://files.pythonhosted.org/packages/a5/f5/f629d39cc30d050f52b1bf927e4d65c1cc7d7ffbb8a635cd546b5c5219a0/obstore-0.8.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b75b4e7746292c785e31edcd5aadc8b758238372a19d4c5e394db5c305d7d175", size = 3693629 }, + { url = "https://files.pythonhosted.org/packages/30/ff/106763fd10f2a1cb47f2ef1162293c78ad52f4e73223d8d43fc6b755445d/obstore-0.8.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f33e6c366869d05ab0b7f12efe63269e631c5450d95d6b4ba4c5faf63f69de70", size = 3686176 }, + { url = "https://files.pythonhosted.org/packages/ce/0c/d2ccb6f32feeca906d5a7c4255340df5262af8838441ca06c9e4e37b67d5/obstore-0.8.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:12c885a9ce5ceb09d13cc186586c0c10b62597eff21b985f6ce8ff9dab963ad3", size = 3773081 }, + { url = "https://files.pythonhosted.org/packages/fa/79/40d1cc504cefc89c9b3dd8874287f3fddc7d963a8748d6dffc5880222013/obstore-0.8.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4accc883b93349a81c9931e15dd318cc703b02bbef2805d964724c73d006d00e", size = 3938589 }, + { url = "https://files.pythonhosted.org/packages/14/dd/916c6777222db3271e9fb3cf9a97ed92b3a9b3e465bdeec96de9ab809d53/obstore-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ec850adf9980e5788a826ccfd5819989724e2a2f712bfa3258e85966c8d9981e", size = 3977768 }, + { url = "https://files.pythonhosted.org/packages/f1/61/66f8dc98bbf5613bbfe5bf21747b4c8091442977f4bd897945895ab7325c/obstore-0.8.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1431e40e9bb4773a261e51b192ea6489d0799b9d4d7dbdf175cdf813eb8c0503", size = 3623364 }, + { url = "https://files.pythonhosted.org/packages/1a/66/6d527b3027e42f625c8fc816ac7d19b0d6228f95bfe7666e4d6b081d2348/obstore-0.8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ddb39d4da303f50b959da000aa42734f6da7ac0cc0be2d5a7838b62c97055bb9", size = 3347764 }, + { url = "https://files.pythonhosted.org/packages/0d/79/c00103302b620192ea447a948921ad3fed031ce3d19e989f038e1183f607/obstore-0.8.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e01f4e13783db453e17e005a4a3ceff09c41c262e44649ba169d253098c775e8", size = 3460981 }, + { url = "https://files.pythonhosted.org/packages/3d/d9/bfe4ed4b1aebc45b56644dd5b943cf8e1673505cccb352e66878a457e807/obstore-0.8.2-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df0fc2d0bc17caff9b538564ddc26d7616f7e8b7c65b1a3c90b5048a8ad2e797", size = 3692711 }, + { url = "https://files.pythonhosted.org/packages/13/47/cd6c2cbb18e1f40c77e7957a4a03d2d83f1859a2e876a408f1ece81cad4c/obstore-0.8.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e439d06c99a140348f046c9f598ee349cc2dcd9105c15540a4b231f9cc48bbae", size = 3958362 }, + { url = "https://files.pythonhosted.org/packages/3d/ea/5ee82bf23abd71c7d6a3f2d008197ae8f8f569d41314c26a8f75318245be/obstore-0.8.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e37d9046669fcc59522d0faf1d105fcbfd09c84cccaaa1e809227d8e030f32c", size = 3957082 }, + { url = "https://files.pythonhosted.org/packages/cb/ee/46650405e50fdaa8d95f30375491f9c91fac9517980e8a28a4a6af66927f/obstore-0.8.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2646fdcc4bbe92dc2bb5bcdff15574da1211f5806c002b66d514cee2a23c7cb8", size = 3775539 }, + { url = "https://files.pythonhosted.org/packages/35/d6/348a7ebebe2ca3d94dfc75344ea19675ae45472823e372c1852844078307/obstore-0.8.2-cp314-cp314-manylinux_2_24_aarch64.whl", hash = "sha256:e31a7d37675056d93dfc244605089dee67f5bba30f37c88436623c8c5ad9ba9d", size = 3535048 }, + { url = "https://files.pythonhosted.org/packages/41/07/b7a16cc0da91a4b902d47880ad24016abfe7880c63f7cdafda45d89a2f91/obstore-0.8.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:656313dd8170dde0f0cd471433283337a63912e8e790a121f7cc7639c83e3816", size = 3699035 }, + { url = "https://files.pythonhosted.org/packages/7f/74/3269a3a58347e0b019742d888612c4b765293c9c75efa44e144b1e884c0d/obstore-0.8.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329038c9645d6d1741e77fe1a53e28a14b1a5c1461cfe4086082ad39ebabf981", size = 3687307 }, + { url = "https://files.pythonhosted.org/packages/01/f9/4fd4819ad6a49d2f462a45be453561f4caebded0dc40112deeffc34b89b1/obstore-0.8.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1e4df99b369790c97c752d126b286dc86484ea49bff5782843a265221406566f", size = 3776076 }, + { url = "https://files.pythonhosted.org/packages/14/dd/7c4f958fa0b9fc4778fb3d232e38b37db8c6b260f641022fbba48b049d7e/obstore-0.8.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9e1c65c65e20cc990414a8a9af88209b1bbc0dd9521b5f6b0293c60e19439bb7", size = 3947445 }, +] + [[package]] name = "onnxruntime" -version = "1.23.2" +version = "1.20.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coloredlogs" }, @@ -2682,23 +2983,22 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113 }, - { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857 }, - { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095 }, - { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080 }, - { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349 }, - { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929 }, - { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705 }, - { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915 }, - { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649 }, - { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528 }, - { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337 }, - { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691 }, - { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898 }, - { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518 }, - { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276 }, - { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610 }, - { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184 }, + { url = "https://files.pythonhosted.org/packages/95/8d/2634e2959b34aa8a0037989f4229e9abcfa484e9c228f99633b3241768a6/onnxruntime-1.20.1-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:06bfbf02ca9ab5f28946e0f912a562a5f005301d0c419283dc57b3ed7969bb7b", size = 30998725 }, + { url = "https://files.pythonhosted.org/packages/a5/da/c44bf9bd66cd6d9018a921f053f28d819445c4d84b4dd4777271b0fe52a2/onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6243e34d74423bdd1edf0ae9596dd61023b260f546ee17d701723915f06a9f7", size = 11955227 }, + { url = "https://files.pythonhosted.org/packages/11/ac/4120dfb74c8e45cce1c664fc7f7ce010edd587ba67ac41489f7432eb9381/onnxruntime-1.20.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eec64c0269dcdb8d9a9a53dc4d64f87b9e0c19801d9321246a53b7eb5a7d1bc", size = 13331703 }, + { url = "https://files.pythonhosted.org/packages/12/f1/cefacac137f7bb7bfba57c50c478150fcd3c54aca72762ac2c05ce0532c1/onnxruntime-1.20.1-cp311-cp311-win32.whl", hash = "sha256:a19bc6e8c70e2485a1725b3d517a2319603acc14c1f1a017dda0afe6d4665b41", size = 9813977 }, + { url = "https://files.pythonhosted.org/packages/2c/2d/2d4d202c0bcfb3a4cc2b171abb9328672d7f91d7af9ea52572722c6d8d96/onnxruntime-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:8508887eb1c5f9537a4071768723ec7c30c28eb2518a00d0adcd32c89dea3221", size = 11329895 }, + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580 }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833 }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562 }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482 }, + { url = "https://files.pythonhosted.org/packages/f7/71/c5d980ac4189589267a06f758bd6c5667d07e55656bed6c6c0580733ad07/onnxruntime-1.20.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:cc01437a32d0042b606f462245c8bbae269e5442797f6213e36ce61d5abdd8cc", size = 31007574 }, + { url = "https://files.pythonhosted.org/packages/81/0d/13bbd9489be2a6944f4a940084bfe388f1100472f38c07080a46fbd4ab96/onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb44b08e017a648924dbe91b82d89b0c105b1adcfe31e90d1dc06b8677ad37be", size = 11951459 }, + { url = "https://files.pythonhosted.org/packages/c0/ea/4454ae122874fd52bbb8a961262de81c5f932edeb1b72217f594c700d6ef/onnxruntime-1.20.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda6aebdf7917c1d811f21d41633df00c58aff2bef2f598f69289c1f1dabc4b3", size = 13331620 }, + { url = "https://files.pythonhosted.org/packages/d8/e0/50db43188ca1c945decaa8fc2a024c33446d31afed40149897d4f9de505f/onnxruntime-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:d30367df7e70f1d9fc5a6a68106f5961686d39b54d3221f760085524e8d38e16", size = 11331758 }, + { url = "https://files.pythonhosted.org/packages/d8/55/3821c5fd60b52a6c82a00bba18531793c93c4addfe64fbf061e235c5617a/onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9158465745423b2b5d97ed25aa7740c7d38d2993ee2e5c3bfacb0c4145c49d8", size = 11950342 }, + { url = "https://files.pythonhosted.org/packages/14/56/fd990ca222cef4f9f4a9400567b9a15b220dee2eafffb16b2adbc55c8281/onnxruntime-1.20.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0df6f2df83d61f46e842dbcde610ede27218947c33e994545a22333491e72a3b", size = 13337040 }, ] [[package]] @@ -2732,6 +3032,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, +] + [[package]] name = "opentelemetry-api" version = "1.39.1" @@ -3033,6 +3345,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305 }, ] +[[package]] +name = "pdfminer-six" +version = "20251230" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909 }, +] + +[[package]] +name = "pdfplumber" +version = "0.11.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pdfminer-six" }, + { name = "pillow" }, + { name = "pypdfium2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045 }, +] + [[package]] name = "pg0-embedded" version = "0.11.0" @@ -3634,6 +3973,35 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pypdfium2" +version = "5.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/23/b3979a1d4f536fabce02e3d9f332e8aeeed064d9df9391f2a77160f4ab36/pypdfium2-5.4.0.tar.gz", hash = "sha256:7219e55048fb3999fc8adcaea467088507207df4676ff9e521a3ae15a67d99c4", size = 269136 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/c0/3d707bff5e973272b5412556d19e8c6889ce859a235465f0049cc8d35bc3/pypdfium2-5.4.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:8bc51a12a8c8eabbdbd7499d3e5ec47bcf56ba18e07b52bdd07d321cc1252c90", size = 2759769 }, + { url = "https://files.pythonhosted.org/packages/1b/6b/306cafcb0b18d5fab41687d9ed76eabea86a9ff78bc568bee1bfa34e526d/pypdfium2-5.4.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:a414ef5b685824cc6c7acbe19b7dbc735de2023cf473321a8ebfe8d7f5d8a41f", size = 2301913 }, + { url = "https://files.pythonhosted.org/packages/7a/37/3d737c7eb84fb22939ab0a643aa0183dbc0745c309e962b4d61eeff8211b/pypdfium2-5.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0e83657db8da5971434ff5683bf3faa007ee1f3a56b61f245b8aa5b60442c23a", size = 2814181 }, + { url = "https://files.pythonhosted.org/packages/96/d7/0895737ec3d95ad607ade42e98fa8868b91e35b1170ec39b8c1b5fdb124c/pypdfium2-5.4.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:e42b1d14db642e96bb3a57167f620b4247e9c843d22b9fb569b16a7c35a18f47", size = 2943476 }, + { url = "https://files.pythonhosted.org/packages/9a/53/f8ab449997d3efa52737b8e6c494f1c3f09dc0642161fadc934f16a57cf0/pypdfium2-5.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0698c9a002f839127e74ec0185147e08b64e47a1e6caeaee95df434c05b26e8c", size = 2976675 }, + { url = "https://files.pythonhosted.org/packages/c6/28/b8a4d4c1557019101bb722c88ba532ec9c14640117ab1c272c80774d83d7/pypdfium2-5.4.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22e9d4c73fc48b18b022977ea6fe78df43adf95440e1135020ed35fea9595017", size = 2762396 }, + { url = "https://files.pythonhosted.org/packages/0b/4a/6c765f6e0b69d792e2d4c7ef2359301896c82df265d60f9a56e87618ec50/pypdfium2-5.4.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f0619f8a8ae3eb71b2cdc1fbd2a8f5d43f0fc6bee66d1b3aac2c9c23e44a3bf", size = 3068559 }, + { url = "https://files.pythonhosted.org/packages/1c/17/4464e4ab6dd98ac3783c10eb799d8da49cb551a769c987eb9c6ba72a5ccf/pypdfium2-5.4.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50124415d815c41de8ce7e21cee5450f74f6f1240a140573bb71ccac804d5e5f", size = 3419384 }, + { url = "https://files.pythonhosted.org/packages/92/08/fa315a2ab353b41501b7088be72dc6cf8ad2bd4f1ebdfdb90c41b7f29155/pypdfium2-5.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce482d76e5447e745d761307401eaa366616ca44032b86cf7fbe6be918ade64e", size = 2998123 }, + { url = "https://files.pythonhosted.org/packages/02/7a/a171d313d54a028d9437dea2c5d07fc9e1592f4daf5c39cbf514fca75242/pypdfium2-5.4.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16b9c6b07f3dbe7eda209bf7aaf131ca9614e1dae527e9764180dd58bcbaf411", size = 3673594 }, + { url = "https://files.pythonhosted.org/packages/0b/c0/60416f011f7e5a4ca29f40ae94907f34975239f3c6dd7fcb51f99e110f3b/pypdfium2-5.4.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b08d48b7cca3b51aefaad7855bc0e9e251432a6eef1356d532ff438be84855e", size = 2965025 }, + { url = "https://files.pythonhosted.org/packages/75/e2/8e36144b5e933c707b6aeab7dc6638eee8208697925b48b5b78ef68fb52a/pypdfium2-5.4.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0a1526e2a2bde7f2f13bec0f471d9fd475f7bbac2c0c860d48c35af8394d5931", size = 4130551 }, + { url = "https://files.pythonhosted.org/packages/a0/64/8cda96259a8fdecd457f5d14a9d650315d7bdf496f96055d1d55900b3881/pypdfium2-5.4.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:40cea0bceb1e60a71b3855e2b04d175d2199b7da06212bb80f0c78067d065810", size = 3746587 }, + { url = "https://files.pythonhosted.org/packages/33/6b/7764491269f188a922bd6b254359d718899fc3092c90f0f68c2f6e451921/pypdfium2-5.4.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7a116f8fbeae7aa3a18ff2d1fa331ac647831cc16b589d4fbbbb66d64ecc8793", size = 4336703 }, + { url = "https://files.pythonhosted.org/packages/87/b0/2484bd3c20ead51ecea2082deaf94a3e91bad709fa14f049ca7fb598dc9a/pypdfium2-5.4.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:55c7fc894718db5fa2981d46dee45fe3a4fcd60d26f5095ad8f7779600fa8b6f", size = 4375051 }, + { url = "https://files.pythonhosted.org/packages/c0/ac/5f0536be885c3cadc09422de0324a193a21c165488a574029d9d2db92ecb/pypdfium2-5.4.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:dfc1c0c7e6e7ba258ebb338aaf664eb933bff1854cda76e4ee530886ea39b31a", size = 3928935 }, + { url = "https://files.pythonhosted.org/packages/13/b9/693b665df0939555491bece0777cafda1270e208734e925006de313abb5b/pypdfium2-5.4.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:4c0a48ede7180f804c029c509c2b6ea0c66813a3fde9eb9afc390183f947164d", size = 4997642 }, + { url = "https://files.pythonhosted.org/packages/fb/ea/ba585acdfbefe309ee2fe5ebfeb097e36abe1d33c2a5108828c493c070bb/pypdfium2-5.4.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dea22d15c44a275702fd95ad664ba6eaa3c493d53d58b4d69272a04bdfb0df70", size = 4179914 }, + { url = "https://files.pythonhosted.org/packages/97/47/238383e89081a0ed1ca2bf4ef44f7e512fa0c72ffc51adc7df83bfcfd9b9/pypdfium2-5.4.0-py3-none-win32.whl", hash = "sha256:35c643827ed0f4dae9cedf3caf836f94cba5b31bd2c115b80a7c85f004636de9", size = 2995844 }, + { url = "https://files.pythonhosted.org/packages/08/37/f1338a0600c6c6e31759f8f80d7ab20aa0bc43b11594da67091300e051d4/pypdfium2-5.4.0-py3-none-win_amd64.whl", hash = "sha256:f9d9ce3c6901294d6984004d4a797dea110f8248b1bde33a823d25b45d3c2685", size = 3104198 }, + { url = "https://files.pythonhosted.org/packages/65/17/18ad82f070da18ab970928f730fbd44d9b05aafcb52a2ebb6470eaae53f9/pypdfium2-5.4.0-py3-none-win_arm64.whl", hash = "sha256:2b78ea216fb92e7709b61c46241ebf2cc0c60cf18ad2fb4633af665d7b4e21e6", size = 2938727 }, +] + [[package]] name = "pyperclip" version = "1.11.0" @@ -3766,6 +4134,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, ] +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788 }, +] + [[package]] name = "pytz" version = "2025.2" @@ -4421,8 +4804,8 @@ name = "secretstorage" version = "3.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/32/8a/ed6747b1cc723c81f526d4c12c1b1d43d07190e1e8258dbf934392fc850e/secretstorage-3.4.1.tar.gz", hash = "sha256:a799acf5be9fb93db609ebaa4ab6e8f1f3ed5ae640e0fa732bfea59e9c3b50e8", size = 19871 } wheels = [ @@ -4635,6 +5018,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248 }, ] +[[package]] +name = "testcontainers" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640 }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -5266,6 +5665,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, ] +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555 }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315 }, +] + [[package]] name = "yarl" version = "1.22.0"