mirror of
https://github.com/run-llama/llama_cloud_services.git
synced 2026-07-21 12:05:23 -04:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9239498945 | |||
| 19cbb25631 | |||
| 812e2f7d72 | |||
| d7864afe3f | |||
| ade8d027a5 | |||
| 997bcc8531 | |||
| 8be554c234 | |||
| f777cab0c5 | |||
| b9b83c953d | |||
| 3ec7024626 | |||
| d5b18a03fa | |||
| 18dd04b6de | |||
| 685a5e6ccc | |||
| 576c3d9076 | |||
| c8321d2bc5 | |||
| 131bbed7aa | |||
| 41c8ac2348 | |||
| 32c53cdf96 | |||
| 71db318fc2 | |||
| dac0f79e51 | |||
| 32487763d5 | |||
| 06c3c556e6 | |||
| e5dcaa83df | |||
| 1b7198dc62 | |||
| 9cfe074206 | |||
| ae30990ada | |||
| 8f1c359abc | |||
| 0a110de9c7 | |||
| d705b16923 | |||
| ca781132c8 | |||
| 7a68b0fb68 | |||
| 87dec5433d | |||
| 99f4eba8d0 | |||
| 54561e2dd2 | |||
| bfaec79a8f | |||
| 3e0e522a6b | |||
| f70b6d87ec | |||
| 693b5b83b1 |
@@ -12,6 +12,7 @@ env:
|
||||
jobs:
|
||||
test_e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
# You can use PyPy versions in python-version.
|
||||
# For example, pypy-2.7 and pypy-3.8
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
sample_files/
|
||||
@@ -0,0 +1,807 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-0",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Batch Parse with LlamaCloud Directories\n",
|
||||
"\n",
|
||||
"This notebook demonstrates how to use LlamaCloud's batch processing API to parse multiple files in a directory. The workflow includes:\n",
|
||||
"\n",
|
||||
"1. **Creating a Directory** - Set up a directory to organize your files\n",
|
||||
"2. **Uploading Files** - Upload multiple files to the directory\n",
|
||||
"3. **Starting a Batch Parse Job** - Kick off batch processing on all files\n",
|
||||
"4. **Monitoring Progress** - Check the status and view results\n",
|
||||
"\n",
|
||||
"This is useful when you need to parse many documents at once, as the batch API handles the orchestration and provides progress tracking."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup and Installation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"%pip install llama-cloud python-dotenv"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"import httpx\n",
|
||||
"\n",
|
||||
"# Load environment variables\n",
|
||||
"load_dotenv()\n",
|
||||
"\n",
|
||||
"# Set your API key\n",
|
||||
"LLAMA_CLOUD_API_KEY = os.environ.get(\"LLAMA_CLOUD_API_KEY\", \"llx-...\")\n",
|
||||
"\n",
|
||||
"# Optional: Set base URL (defaults to https://api.cloud.llamaindex.ai if not set)\n",
|
||||
"LLAMA_CLOUD_BASE_URL = os.environ.get(\n",
|
||||
" \"LLAMA_CLOUD_BASE_URL\", \"https://api.cloud.llamaindex.ai\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"# Optional: Set project_id if you have one, otherwise it will use your default project\n",
|
||||
"PROJECT_ID = os.environ.get(\"LLAMA_CLOUD_PROJECT_ID\", None)\n",
|
||||
"\n",
|
||||
"print(\"✅ API key configured\")\n",
|
||||
"print(f\" Base URL: {LLAMA_CLOUD_BASE_URL}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup HTTP Client\n",
|
||||
"\n",
|
||||
"Since the current version of the llama-cloud SDK has some issues with the beta endpoints, we'll use direct HTTP requests with httpx for reliability."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-5",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Create HTTP client with authentication\n",
|
||||
"headers = {\n",
|
||||
" \"Authorization\": f\"Bearer {LLAMA_CLOUD_API_KEY}\",\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"print(\"✅ HTTP client configured\")\n",
|
||||
"print(f\" Using base URL: {LLAMA_CLOUD_BASE_URL}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 1: Create a Directory\n",
|
||||
"\n",
|
||||
"First, we'll create a directory to organize our files. Directories help you group related files together for batch processing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from datetime import datetime\n",
|
||||
"\n",
|
||||
"# Create a directory with a timestamp in the name\n",
|
||||
"timestamp = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n",
|
||||
"directory_name = f\"batch-parse-demo-{timestamp}\"\n",
|
||||
"\n",
|
||||
"# Create directory using HTTP request\n",
|
||||
"response = httpx.post(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/directories\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": PROJECT_ID},\n",
|
||||
" json={\n",
|
||||
" \"name\": directory_name,\n",
|
||||
" \"description\": \"Demo directory for batch parse example\",\n",
|
||||
" },\n",
|
||||
" timeout=60.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"if response.status_code in [200, 201]:\n",
|
||||
" directory = response.json()\n",
|
||||
" directory_id = directory[\"id\"]\n",
|
||||
" project_id = directory[\"project_id\"]\n",
|
||||
"\n",
|
||||
" print(f\"✅ Created directory: {directory['name']}\")\n",
|
||||
" print(f\" Directory ID: {directory_id}\")\n",
|
||||
" print(f\" Project ID: {project_id}\")\n",
|
||||
"else:\n",
|
||||
" raise Exception(\n",
|
||||
" f\"Failed to create directory: {response.status_code} - {response.text}\"\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 2: Upload Files to the Directory\n",
|
||||
"\n",
|
||||
"Now we'll upload some files to our directory. For this demo, we'll download some sample PDFs and upload them.\n",
|
||||
"\n",
|
||||
"You can replace these with your own files."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Create a directory for sample files\n",
|
||||
"import requests\n",
|
||||
"\n",
|
||||
"os.makedirs(\"sample_files\", exist_ok=True)\n",
|
||||
"\n",
|
||||
"# Sample documents to download\n",
|
||||
"sample_docs = {\n",
|
||||
" \"attention.pdf\": \"https://arxiv.org/pdf/1706.03762.pdf\",\n",
|
||||
" \"bert.pdf\": \"https://arxiv.org/pdf/1810.04805.pdf\",\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"# Download sample documents\n",
|
||||
"for filename, url in sample_docs.items():\n",
|
||||
" filepath = f\"sample_files/{filename}\"\n",
|
||||
" if not os.path.exists(filepath):\n",
|
||||
" print(f\"📥 Downloading {filename}...\")\n",
|
||||
" response = requests.get(url)\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" with open(filepath, \"wb\") as f:\n",
|
||||
" f.write(response.content)\n",
|
||||
" print(f\" ✅ Downloaded {filename}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" ❌ Failed to download {filename}\")\n",
|
||||
" else:\n",
|
||||
" print(f\"📁 {filename} already exists\")\n",
|
||||
"\n",
|
||||
"print(\"\\n✅ Sample files ready!\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-10",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Upload Files to Directory\n",
|
||||
"\n",
|
||||
"Now let's upload the files to our directory using the `upload_file_to_directory` endpoint."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-11",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"uploaded_files = []\n",
|
||||
"\n",
|
||||
"# Workaround: Use direct HTTP requests instead of SDK due to SDK bug\n",
|
||||
"import httpx\n",
|
||||
"\n",
|
||||
"for filename in os.listdir(\"sample_files\"):\n",
|
||||
" if filename.endswith(\".pdf\"):\n",
|
||||
" filepath = f\"sample_files/{filename}\"\n",
|
||||
"\n",
|
||||
" print(f\"📤 Uploading {filename}...\")\n",
|
||||
"\n",
|
||||
" # Upload file using direct HTTP request (SDK has a bug with file uploads)\n",
|
||||
" with open(filepath, \"rb\") as f:\n",
|
||||
" # Prepare the multipart form data correctly\n",
|
||||
" files = {\"upload_file\": (filename, f, \"application/pdf\")}\n",
|
||||
"\n",
|
||||
" # Make the request directly\n",
|
||||
" response = httpx.post(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/directories/{directory_id}/files/upload\",\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" files=files,\n",
|
||||
" headers={\"Authorization\": f\"Bearer {LLAMA_CLOUD_API_KEY}\"},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code in [200, 201]:\n",
|
||||
" directory_file = response.json()\n",
|
||||
" uploaded_files.append(directory_file)\n",
|
||||
" print(f\" ✅ Uploaded: {directory_file.get('display_name')}\")\n",
|
||||
" print(f\" File ID: {directory_file.get('id')}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" ❌ Upload failed: {response.status_code}\")\n",
|
||||
" print(f\" Error: {response.text[:200]}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\n✅ Uploaded {len(uploaded_files)} files to directory\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-12",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 3: Create a Batch Parse Job\n",
|
||||
"\n",
|
||||
"Now that we have files in our directory, let's create a batch parse job to process them all at once.\n",
|
||||
"\n",
|
||||
"The batch processing API uses the same configuration as LlamaParse."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-13",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Configure the parse job\n",
|
||||
"# This configuration will apply to all files in the directory\n",
|
||||
"job_config = {\n",
|
||||
" \"job_name\": \"parse_raw_file_job\", # Must match the JobNames enum value\n",
|
||||
" \"partitions\": {},\n",
|
||||
" \"parameters\": {\n",
|
||||
" \"type\": \"parse\",\n",
|
||||
" \"lang\": \"en\",\n",
|
||||
" \"fast_mode\": True,\n",
|
||||
" },\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"print(\"✅ Job configuration created\")\n",
|
||||
"print(f\" Language: {job_config['parameters']['lang']}\")\n",
|
||||
"print(f\" Fast mode: {job_config['parameters']['fast_mode']}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-14",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Submit the Batch Job\n",
|
||||
"\n",
|
||||
"Now let's submit the batch job to process all files in the directory."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-15",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"print(f\"🚀 Submitting batch parse job for directory: {directory_id}\")\n",
|
||||
"print(f\" Processing {len(uploaded_files)} files...\\n\")\n",
|
||||
"\n",
|
||||
"# Submit batch job using HTTP request\n",
|
||||
"response = httpx.post(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/batch-processing\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" json={\n",
|
||||
" \"directory_id\": directory_id,\n",
|
||||
" \"job_config\": job_config,\n",
|
||||
" \"page_size\": 100, # Number of files to fetch per batch\n",
|
||||
" \"continue_as_new_threshold\": 10, # Workflow continuation threshold\n",
|
||||
" },\n",
|
||||
" timeout=60.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"if response.status_code in [200, 201]:\n",
|
||||
" batch_job = response.json()\n",
|
||||
" batch_job_id = batch_job[\"id\"]\n",
|
||||
"\n",
|
||||
" print(\"✅ Batch job submitted successfully!\")\n",
|
||||
" print(f\" Batch Job ID: {batch_job_id}\")\n",
|
||||
" print(f\" Workflow ID: {batch_job.get('workflow_id')}\")\n",
|
||||
" print(f\" Status: {batch_job.get('status')}\")\n",
|
||||
" print(f\" Total Items: {batch_job.get('total_items')}\")\n",
|
||||
"else:\n",
|
||||
" raise Exception(\n",
|
||||
" f\"Failed to create batch job: {response.status_code} - {response.text}\"\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-16",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 4: Monitor Job Progress\n",
|
||||
"\n",
|
||||
"Now let's monitor the batch job progress. We'll poll the status endpoint to see how the job is progressing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-17",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import time\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"def print_job_status(status_data):\n",
|
||||
" \"\"\"Helper function to print job status in a readable format.\"\"\"\n",
|
||||
" job = status_data[\"job\"]\n",
|
||||
" progress_pct = status_data[\"progress_percentage\"]\n",
|
||||
"\n",
|
||||
" print(f\"\\n{'='*60}\")\n",
|
||||
" print(f\"Job Status: {job['status']}\")\n",
|
||||
" print(f\"{'='*60}\")\n",
|
||||
" print(f\"Total Items: {job['total_items']}\")\n",
|
||||
" print(f\"Completed: {job['processed_items']}\")\n",
|
||||
" print(f\"Failed: {job['failed_items']}\")\n",
|
||||
" print(f\"Skipped: {job['skipped_items']}\")\n",
|
||||
" print(f\"Progress: {progress_pct:.1f}%\")\n",
|
||||
"\n",
|
||||
" if job.get(\"completed_at\"):\n",
|
||||
" print(f\"Completed At: {job['completed_at']}\")\n",
|
||||
" elif job.get(\"started_at\"):\n",
|
||||
" print(f\"Started At: {job['started_at']}\")\n",
|
||||
"\n",
|
||||
" print(f\"{'='*60}\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# Poll for status updates\n",
|
||||
"print(\"🔄 Monitoring batch job progress...\")\n",
|
||||
"print(\n",
|
||||
" \"Note: It may take a few seconds for the workflow to initialize and count files.\\n\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"max_polls = 60 # Maximum number of status checks (increased for longer jobs)\n",
|
||||
"poll_interval = 10 # Seconds between checks\n",
|
||||
"\n",
|
||||
"for i in range(max_polls):\n",
|
||||
" response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/batch-processing/{batch_job_id}\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" status_data = response.json()\n",
|
||||
" print_job_status(status_data)\n",
|
||||
"\n",
|
||||
" # Check if job is complete\n",
|
||||
" job_status = status_data[\"job\"][\"status\"]\n",
|
||||
" if job_status in [\"completed\", \"failed\", \"cancelled\"]:\n",
|
||||
" print(f\"\\n✅ Job finished with status: {job_status}\")\n",
|
||||
" break\n",
|
||||
"\n",
|
||||
" if i < max_polls - 1:\n",
|
||||
" print(f\"\\n⏳ Waiting {poll_interval} seconds before next check...\")\n",
|
||||
" time.sleep(poll_interval)\n",
|
||||
" else:\n",
|
||||
" print(f\"Error getting status: {response.status_code} - {response.text}\")\n",
|
||||
" break\n",
|
||||
"else:\n",
|
||||
" print(f\"\\n⚠️ Reached maximum polling attempts. Job may still be running.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-18",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 5: View Job Items\n",
|
||||
"\n",
|
||||
"Let's look at the individual items in the batch job to see which files were processed successfully."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-19",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Get all items in the batch job\n",
|
||||
"response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/batch-processing/{batch_job_id}/items\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id, \"limit\": 100},\n",
|
||||
" timeout=60.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"if response.status_code == 200:\n",
|
||||
" items_response = response.json()\n",
|
||||
"\n",
|
||||
" print(f\"\\n📋 Batch Job Items ({items_response['total_size']} total)\")\n",
|
||||
" print(f\"{'='*80}\\n\")\n",
|
||||
"\n",
|
||||
" for item in items_response[\"items\"]:\n",
|
||||
" status_emoji = (\n",
|
||||
" \"✅\"\n",
|
||||
" if item[\"status\"] == \"completed\"\n",
|
||||
" else \"❌\"\n",
|
||||
" if item[\"status\"] == \"failed\"\n",
|
||||
" else \"⏳\"\n",
|
||||
" )\n",
|
||||
" print(f\"{status_emoji} {item['item_name']}\")\n",
|
||||
" print(f\" Status: {item['status']}\")\n",
|
||||
" print(f\" Item ID: {item['item_id']}\")\n",
|
||||
"\n",
|
||||
" if item.get(\"error_message\"):\n",
|
||||
" print(f\" Error: {item['error_message']}\")\n",
|
||||
"\n",
|
||||
" print()\n",
|
||||
"else:\n",
|
||||
" print(f\"Error listing items: {response.status_code} - {response.text}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-20",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 6: Retrieve Processing Results\n",
|
||||
"\n",
|
||||
"For each completed file, we can retrieve the processing results to see where the parsed output is stored."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-21",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Get processing results for a specific item\n",
|
||||
"if items_response[\"items\"]:\n",
|
||||
" first_item = items_response[\"items\"][0]\n",
|
||||
"\n",
|
||||
" print(f\"\\n🔍 Processing results for: {first_item['item_name']}\")\n",
|
||||
" print(f\"{'='*80}\\n\")\n",
|
||||
"\n",
|
||||
" response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/batch-processing/items/{first_item['item_id']}/processing-results\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" results = response.json()\n",
|
||||
"\n",
|
||||
" print(f\"Item: {results['item_name']}\")\n",
|
||||
" print(f\"Total processing runs: {len(results['processing_results'])}\\n\")\n",
|
||||
"\n",
|
||||
" for i, result in enumerate(results[\"processing_results\"], 1):\n",
|
||||
" print(f\"Run {i}:\")\n",
|
||||
" print(f\" Job Type: {result['job_type']}\")\n",
|
||||
" print(f\" Processed At: {result['processed_at']}\")\n",
|
||||
" print(f\" Parameters Hash: {result['parameters_hash']}\")\n",
|
||||
"\n",
|
||||
" if result.get(\"output_s3_path\"):\n",
|
||||
" print(f\" Output S3 Path: {result['output_s3_path']}\")\n",
|
||||
"\n",
|
||||
" if result.get(\"output_metadata\"):\n",
|
||||
" print(f\" Output Metadata: {result['output_metadata']}\")\n",
|
||||
"\n",
|
||||
" print()\n",
|
||||
" else:\n",
|
||||
" print(f\"Error getting results: {response.status_code} - {response.text}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cell-22",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Optional: List All Batch Jobs\n",
|
||||
"\n",
|
||||
"You can also list all batch jobs in your project to see the history of batch processing operations."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "cell-23",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# List all parse jobs in the project\n",
|
||||
"response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/beta/batch-processing\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id, \"job_type\": \"parse\", \"limit\": 10},\n",
|
||||
" timeout=60.0,\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"if response.status_code == 200:\n",
|
||||
" jobs_response = response.json()\n",
|
||||
"\n",
|
||||
" print(f\"\\n📊 Recent Batch Parse Jobs ({jobs_response['total_size']} total)\")\n",
|
||||
" print(f\"{'='*80}\\n\")\n",
|
||||
"\n",
|
||||
" for job in jobs_response[\"items\"]:\n",
|
||||
" status_emoji = (\n",
|
||||
" \"✅\"\n",
|
||||
" if job[\"status\"] == \"completed\"\n",
|
||||
" else \"❌\"\n",
|
||||
" if job[\"status\"] == \"failed\"\n",
|
||||
" else \"⏳\"\n",
|
||||
" )\n",
|
||||
" print(f\"{status_emoji} Job ID: {job['id']}\")\n",
|
||||
" print(f\" Status: {job['status']}\")\n",
|
||||
" print(f\" Directory: {job['directory_id']}\")\n",
|
||||
" print(f\" Total Items: {job['total_items']}\")\n",
|
||||
" print(f\" Completed: {job['processed_items']}\")\n",
|
||||
" print(f\" Created: {job['created_at']}\")\n",
|
||||
" print()\n",
|
||||
"else:\n",
|
||||
" print(f\"Error listing jobs: {response.status_code} - {response.text}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "uug7591rkq",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 7: Retrieve Parsed Text Results\n",
|
||||
"\n",
|
||||
"Once the batch job is complete, each BatchJobItem will have a `job_id` field that maps to a parse job ID. We can use this ID with the standard parse client methods to fetch the actual parsed text results."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "vpp0vxtc0y",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Get all completed items and their job IDs\n",
|
||||
"completed_items = [\n",
|
||||
" item for item in items_response[\"items\"] if item[\"status\"] == \"completed\"\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"print(f\"📄 Found {len(completed_items)} completed items\\n\")\n",
|
||||
"print(f\"{'='*80}\\n\")\n",
|
||||
"\n",
|
||||
"# Display the job_id for each completed item\n",
|
||||
"for item in completed_items:\n",
|
||||
" print(f\"📝 {item['item_name']}\")\n",
|
||||
" print(f\" Item ID: {item['item_id']}\")\n",
|
||||
" print(f\" Parse Job ID: {item['job_id']}\")\n",
|
||||
" print()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "4gck6hwpnl6",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Fetch Parsed Text for a Specific Document\n",
|
||||
"\n",
|
||||
"Now let's use the `job_id` to retrieve the actual parsed text content using the parse client methods."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g191kvgxxvk",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Get the parsed text for the first completed item\n",
|
||||
"if completed_items:\n",
|
||||
" first_completed = completed_items[0]\n",
|
||||
"\n",
|
||||
" print(f\"📖 Retrieving parsed text for: {first_completed['item_name']}\")\n",
|
||||
" print(f\" Using Parse Job ID: {first_completed['job_id']}\\n\")\n",
|
||||
" print(f\"{'='*80}\\n\")\n",
|
||||
"\n",
|
||||
" # Use the job_id to fetch the parse result\n",
|
||||
" response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/parsing/job/{first_completed['job_id']}/result/text\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" parse_result = response.text\n",
|
||||
"\n",
|
||||
" print(f\"✅ Retrieved parsed text ({len(parse_result)} characters)\\n\")\n",
|
||||
"\n",
|
||||
" # Display first 1000 characters as a preview\n",
|
||||
" print(\"Preview (first 1000 characters):\")\n",
|
||||
" print(\"-\" * 80)\n",
|
||||
" print(parse_result[:1000])\n",
|
||||
" print(\"-\" * 80)\n",
|
||||
"\n",
|
||||
" if len(parse_result) > 1000:\n",
|
||||
" print(f\"\\n... and {len(parse_result) - 1000} more characters\")\n",
|
||||
" else:\n",
|
||||
" print(\n",
|
||||
" f\"Error retrieving parse result: {response.status_code} - {response.text}\"\n",
|
||||
" )\n",
|
||||
"else:\n",
|
||||
" print(\"⚠️ No completed items found to retrieve results from\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2olccb4l8fj",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Retrieve Parsed Results in Other Formats\n",
|
||||
"\n",
|
||||
"You can also retrieve the parsed results in JSON or Markdown format using different client methods."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "lcqsfxiw0sr",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if completed_items:\n",
|
||||
" first_completed = completed_items[0]\n",
|
||||
"\n",
|
||||
" print(\n",
|
||||
" f\"📋 Retrieving parse results in different formats for: {first_completed['item_name']}\\n\"\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" # Get as JSON (includes structured data with pages, images, etc.)\n",
|
||||
" print(\"1️⃣ Retrieving as JSON...\")\n",
|
||||
" response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/parsing/job/{first_completed['job_id']}/result/json\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" json_result = response.json()\n",
|
||||
" print(f\" ✅ JSON result with {len(json_result['pages'])} pages\")\n",
|
||||
" print(f\" Keys: {list(json_result.keys())}\\n\")\n",
|
||||
" else:\n",
|
||||
" print(f\" Error: {response.status_code}\\n\")\n",
|
||||
"\n",
|
||||
" # Get as Markdown\n",
|
||||
" print(\"2️⃣ Retrieving as Markdown...\")\n",
|
||||
" response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/parsing/job/{first_completed['job_id']}/result/markdown\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" markdown_result = response.text\n",
|
||||
" print(f\" ✅ Markdown result ({len(markdown_result)} characters)\\n\")\n",
|
||||
"\n",
|
||||
" # Display markdown preview\n",
|
||||
" print(\"Markdown Preview (first 500 characters):\")\n",
|
||||
" print(\"-\" * 80)\n",
|
||||
" print(markdown_result[:500])\n",
|
||||
" print(\"-\" * 80)\n",
|
||||
"\n",
|
||||
" if len(markdown_result) > 500:\n",
|
||||
" print(f\"\\n... and {len(markdown_result) - 500} more characters\")\n",
|
||||
" else:\n",
|
||||
" print(f\" Error: {response.status_code}\")\n",
|
||||
"else:\n",
|
||||
" print(\"⚠️ No completed items found to retrieve results from\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "lr61wqkfq3",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Batch Process All Parsed Results\n",
|
||||
"\n",
|
||||
"You can also loop through all completed items to retrieve and process all the parsed results."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "kltydf9xzkl",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Process all completed items\n",
|
||||
"print(f\"🔄 Processing all {len(completed_items)} completed items...\\n\")\n",
|
||||
"print(f\"{'='*80}\\n\")\n",
|
||||
"\n",
|
||||
"all_results = {}\n",
|
||||
"\n",
|
||||
"for item in completed_items:\n",
|
||||
" print(f\"📄 Processing: {item['item_name']}\")\n",
|
||||
" print(f\" Parse Job ID: {item['job_id']}\")\n",
|
||||
"\n",
|
||||
" try:\n",
|
||||
" # Retrieve the parsed text for this item\n",
|
||||
" response = httpx.get(\n",
|
||||
" f\"{LLAMA_CLOUD_BASE_URL}/api/v1/parsing/job/{item['job_id']}/result/text\",\n",
|
||||
" headers=headers,\n",
|
||||
" params={\"project_id\": project_id},\n",
|
||||
" timeout=60.0,\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" if response.status_code == 200:\n",
|
||||
" parsed_text = response.text\n",
|
||||
"\n",
|
||||
" all_results[item[\"item_name\"]] = {\n",
|
||||
" \"job_id\": item[\"job_id\"],\n",
|
||||
" \"text\": parsed_text,\n",
|
||||
" \"length\": len(parsed_text),\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" print(f\" ✅ Retrieved {len(parsed_text)} characters\")\n",
|
||||
" else:\n",
|
||||
" all_results[item[\"item_name\"]] = {\n",
|
||||
" \"job_id\": item[\"job_id\"],\n",
|
||||
" \"error\": f\"HTTP {response.status_code}\",\n",
|
||||
" }\n",
|
||||
" print(f\" ❌ Error: HTTP {response.status_code}\")\n",
|
||||
"\n",
|
||||
" except Exception as e:\n",
|
||||
" print(f\" ❌ Error: {str(e)}\")\n",
|
||||
" all_results[item[\"item_name\"]] = {\"job_id\": item[\"job_id\"], \"error\": str(e)}\n",
|
||||
"\n",
|
||||
" print()\n",
|
||||
"\n",
|
||||
"print(f\"{'='*80}\")\n",
|
||||
"print(f\"\\n✅ Processed {len(all_results)} items\")\n",
|
||||
"print(f\"\\nSummary:\")\n",
|
||||
"for name, result in all_results.items():\n",
|
||||
" if \"error\" in result:\n",
|
||||
" print(f\" ❌ {name}: Error - {result['error']}\")\n",
|
||||
" else:\n",
|
||||
" print(f\" ✅ {name}: {result['length']:,} characters\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Example: Batch Processing a Folder of PDFs with LlamaParse
|
||||
|
||||
This script demonstrates how to process multiple PDFs from a folder
|
||||
using LlamaParse with controlled concurrency using asyncio and semaphores.
|
||||
|
||||
Usage:
|
||||
python batch_parse_folder.py --input-dir ./pdfs --max-concurrent 5
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from llama_cloud_services import LlamaParse
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
async def parse_single_file(
|
||||
parser: LlamaParse,
|
||||
file_path: Path,
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse a single PDF file with concurrency control.
|
||||
|
||||
Args:
|
||||
parser: LlamaParse instance
|
||||
file_path: Path to the PDF file
|
||||
semaphore: Semaphore to control concurrent requests
|
||||
|
||||
Returns:
|
||||
Dictionary with file info and parse result
|
||||
"""
|
||||
async with semaphore:
|
||||
try:
|
||||
print(f"Starting parse: {file_path.name}")
|
||||
|
||||
result = await parser.aparse(str(file_path))
|
||||
|
||||
print(f"✓ Completed: {file_path.name} ({len(result.pages)} pages)")
|
||||
|
||||
return {
|
||||
"file": file_path.name,
|
||||
"status": "success",
|
||||
"result": result,
|
||||
"pages": len(result.pages) if result.pages else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"✗ Error parsing {file_path.name}: {str(e)}")
|
||||
return {
|
||||
"file": file_path.name,
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
async def parse_folder(
|
||||
input_dir: Path,
|
||||
max_concurrent: int = 5,
|
||||
api_key: str = None,
|
||||
) -> List[Dict[str, any]]:
|
||||
"""
|
||||
Parse all PDFs in a folder with controlled concurrency.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing PDF files
|
||||
max_concurrent: Maximum number of concurrent parse operations
|
||||
api_key: LlamaCloud API key (loaded from .env file)
|
||||
|
||||
Returns:
|
||||
List of parse results for each file
|
||||
"""
|
||||
# Find all PDF files
|
||||
pdf_files = list(input_dir.glob("*.pdf"))
|
||||
|
||||
if not pdf_files:
|
||||
print(f"No PDF files found in {input_dir}")
|
||||
return []
|
||||
|
||||
print(f"Found {len(pdf_files)} PDF files to parse")
|
||||
|
||||
# Initialize parser
|
||||
parser = LlamaParse(
|
||||
api_key=api_key,
|
||||
num_workers=1, # We control concurrency with semaphore
|
||||
show_progress=False, # We'll show our own progress
|
||||
)
|
||||
|
||||
# Create semaphore to limit concurrent requests
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
# Create tasks for all files
|
||||
tasks = [parse_single_file(parser, pdf_file, semaphore) for pdf_file in pdf_files]
|
||||
|
||||
# Run all tasks concurrently (but limited by semaphore)
|
||||
print(
|
||||
f"Processing {len(tasks)} files with max {max_concurrent} concurrent operations..."
|
||||
)
|
||||
start_time = datetime.now()
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
end_time = datetime.now()
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
# Process results
|
||||
successful = [
|
||||
r for r in results if isinstance(r, dict) and r.get("status") == "success"
|
||||
]
|
||||
failed = [r for r in results if isinstance(r, dict) and r.get("status") == "error"]
|
||||
|
||||
# Print summary
|
||||
print("PARSE SUMMARY \n")
|
||||
print(f"Total files: {len(pdf_files)}")
|
||||
print(f"Successful: {len(successful)}")
|
||||
print(f"Failed: {len(failed)}")
|
||||
print(f"Total time: {duration:.2f} seconds")
|
||||
print(f"Average time per file: {duration / len(pdf_files):.2f} seconds")
|
||||
|
||||
if failed:
|
||||
print("\nFailed files:")
|
||||
for result in failed:
|
||||
print(f" - {result['file']}: {result.get('error', 'Unknown error')}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the script."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch process PDFs in a folder with LlamaParse"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-dir",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Directory containing PDF files to parse",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-concurrent",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Maximum number of concurrent parse operations (default: 5)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
input_dir = Path(args.input_dir)
|
||||
|
||||
# Validate input directory
|
||||
if not input_dir.exists():
|
||||
print(f"Error: Input directory does not exist: {input_dir}")
|
||||
return
|
||||
|
||||
if not input_dir.is_dir():
|
||||
print(f"Error: Input path is not a directory: {input_dir}")
|
||||
return
|
||||
|
||||
# Get API key from environment (loaded from .env file)
|
||||
api_key = os.getenv("LLAMA_CLOUD_API_KEY")
|
||||
if not api_key:
|
||||
print("Error: LLAMA_CLOUD_API_KEY not found. Please set it in your .env file")
|
||||
return
|
||||
|
||||
# Run async function
|
||||
asyncio.run(
|
||||
parse_folder(
|
||||
input_dir=input_dir,
|
||||
max_concurrent=args.max_concurrent,
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -24,8 +24,8 @@ from workflows import Context
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# Global context for loaded dataframes
|
||||
_dataframe_context: Dict[str, Any] = {}
|
||||
# Global context for executed code
|
||||
_code_context: Dict[str, Any] = {}
|
||||
|
||||
|
||||
# Helper function for initial agent context
|
||||
@@ -79,36 +79,30 @@ def list_extracted_data(data_dir: str = "data") -> str:
|
||||
|
||||
|
||||
# Agent tool for code execution against dataframes
|
||||
def execute_dataframe_code(
|
||||
code: str, load_files: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
def execute_code(code: str) -> str:
|
||||
"""
|
||||
Execute Python pandas code against LlamaSheets extracted data.
|
||||
Execute Python pandas code against LlamaSheets extracted data.
|
||||
|
||||
This tool allows flexible data analysis by executing arbitrary pandas code.
|
||||
You can load parquet files, manipulate dataframes, and return results.
|
||||
This tool allows flexible data analysis by executing arbitrary pandas code.
|
||||
You can load parquet files, manipulate dataframes, and return results.
|
||||
|
||||
The code executes in a context where:
|
||||
- pandas is available as 'pd'
|
||||
- json is available for formatting output
|
||||
- Previously loaded dataframes are accessible by their variable names
|
||||
The code executes in a context where:
|
||||
- pandas is available as 'pd'
|
||||
- json is available for formatting output
|
||||
|
||||
Args:
|
||||
code: Python code to execute. Any print() statements or stdout/stderr
|
||||
will be captured and returned. Optionally set a 'result' variable
|
||||
for structured output.
|
||||
load_files: Optional dict mapping variable names to file paths to load
|
||||
Example: {"df": "data/sales_region_1.parquet",
|
||||
"meta": "data/sales_metadata_1.parquet"}
|
||||
Args:
|
||||
code: Python code to execute. Any print() statements or stdout/stderr
|
||||
will be captured and returned. Optionally set a 'result' variable
|
||||
for structured output.
|
||||
|
||||
Returns:
|
||||
String containing:
|
||||
- Any stdout/stderr output from the code execution
|
||||
- The 'result' variable if it was set (formatted appropriately)
|
||||
- Error message if execution failed
|
||||
Returns:
|
||||
String containing:
|
||||
- Any stdout/stderr output from the code execution
|
||||
- The 'result' variable if it was set (formatted appropriately)
|
||||
- Error message if execution failed
|
||||
|
||||
Example usage:
|
||||
code = '''
|
||||
Example usage:
|
||||
code = '''
|
||||
# Load and inspect data
|
||||
df = pd.read_parquet("data/sales_region_1.parquet")
|
||||
print(f"Loaded {len(df)} rows")
|
||||
@@ -118,9 +112,9 @@ def execute_dataframe_code(
|
||||
"columns": list(df.columns),
|
||||
"sample": df.head(3).to_dict(orient="records")
|
||||
}
|
||||
'''
|
||||
'''
|
||||
"""
|
||||
global _dataframe_context
|
||||
global _code_context
|
||||
|
||||
# Capture stdout and stderr
|
||||
stdout_capture = io.StringIO()
|
||||
@@ -138,24 +132,17 @@ def execute_dataframe_code(
|
||||
"pd": pd,
|
||||
"json": json,
|
||||
"Path": Path,
|
||||
**_dataframe_context, # Include previously loaded dataframes
|
||||
**_code_context, # Include previously loaded dataframes
|
||||
}
|
||||
|
||||
# Load any requested files into context
|
||||
if load_files:
|
||||
for var_name, file_path in load_files.items():
|
||||
if file_path.endswith(".parquet"):
|
||||
exec_context[var_name] = pd.read_parquet(file_path)
|
||||
# Also save to global context for future calls
|
||||
_dataframe_context[var_name] = exec_context[var_name]
|
||||
elif file_path.endswith(".json"):
|
||||
with open(file_path, "r") as f:
|
||||
exec_context[var_name] = json.load(f)
|
||||
_dataframe_context[var_name] = exec_context[var_name]
|
||||
|
||||
# Execute the code
|
||||
exec(code, exec_context)
|
||||
|
||||
# Update global context with any new variables (excluding built-ins and modules)
|
||||
for key, value in exec_context.items():
|
||||
if not key.startswith("_") and key not in ["pd", "json", "Path"]:
|
||||
_code_context[key] = value
|
||||
|
||||
# Restore stdout/stderr
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
@@ -223,8 +210,8 @@ def create_llamasheets_agent(
|
||||
# Initialize LLM
|
||||
llm = OpenAI(model=llm_model, api_key=api_key)
|
||||
|
||||
# Create tools - just 4 simple but powerful tools
|
||||
tools = [execute_dataframe_code]
|
||||
# Create tools list
|
||||
tools = [execute_code]
|
||||
|
||||
# System prompt to guide the agent
|
||||
available_regions = list_extracted_data()
|
||||
@@ -238,11 +225,8 @@ LlamaSheets extracts messy spreadsheets into clean parquet files with two types
|
||||
- Type detection: data_type, is_date_like, is_percentage, is_currency
|
||||
- Layout: is_in_first_row, is_merged_cell, horizontal_alignment
|
||||
|
||||
Your approach:
|
||||
1. Use list_extracted_data() to discover available files
|
||||
2. Use execute_dataframe_code() to load and analyze data with pandas
|
||||
3. Use metadata to understand structure (bold = headers, colors = groups)
|
||||
4. Use save_dataframe() to export results
|
||||
You have access to tools that allow you to execute Python pandas code against these files.
|
||||
Use these tools to load the parquet files, analyze the data, and return results.
|
||||
|
||||
Key tips:
|
||||
- Bold cells in metadata often indicate headers
|
||||
@@ -299,7 +283,7 @@ async def main():
|
||||
print(ev.delta, end="", flush=True)
|
||||
|
||||
_ = await handler
|
||||
print("=== End Query ===\n")
|
||||
print("\n=== End Query ===\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,540 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Document Splitting with LlamaCloud\n",
|
||||
"\n",
|
||||
"This notebook demonstrates how to use the LlamaCloud **Split** API to automatically segment a concatenated PDF into logical document sections based on content categories.\n",
|
||||
"\n",
|
||||
"## Use Case\n",
|
||||
"\n",
|
||||
"When dealing with large PDFs that contain multiple distinct documents or sections (e.g., a bundle of research papers, a collection of reports), you often need to split them into individual segments. The Split API uses AI to:\n",
|
||||
"\n",
|
||||
"1. Analyze each page's content\n",
|
||||
"2. Classify pages into user-defined categories\n",
|
||||
"3. Group consecutive pages of the same category into segments\n",
|
||||
"\n",
|
||||
"## Example Document\n",
|
||||
"\n",
|
||||
"We'll use a PDF containing three concatenated documents:\n",
|
||||
"- **Alan Turing's essay** \"Intelligent Machinery, A Heretical Theory\" (an essay)\n",
|
||||
"- **ImageNet paper** (a research paper)\n",
|
||||
"- **\"Attention is All You Need\"** paper (a research paper)\n",
|
||||
"\n",
|
||||
"We'll split this into segments categorized as either `essay` or `research_paper`.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Requirement already satisfied: llama-cloud in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (0.1.44)\n",
|
||||
"Requirement already satisfied: python-dotenv in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (1.2.1)\n",
|
||||
"Requirement already satisfied: requests in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (2.32.5)\n",
|
||||
"Requirement already satisfied: certifi>=2024.7.4 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from llama-cloud) (2025.11.12)\n",
|
||||
"Requirement already satisfied: httpx>=0.20.0 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from llama-cloud) (0.28.1)\n",
|
||||
"Requirement already satisfied: pydantic>=1.10 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from llama-cloud) (2.12.5)\n",
|
||||
"Requirement already satisfied: charset_normalizer<4,>=2 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from requests) (3.4.4)\n",
|
||||
"Requirement already satisfied: idna<4,>=2.5 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from requests) (3.11)\n",
|
||||
"Requirement already satisfied: urllib3<3,>=1.21.1 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from requests) (2.5.0)\n",
|
||||
"Requirement already satisfied: anyio in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from httpx>=0.20.0->llama-cloud) (4.11.0)\n",
|
||||
"Requirement already satisfied: httpcore==1.* in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from httpx>=0.20.0->llama-cloud) (1.0.9)\n",
|
||||
"Requirement already satisfied: h11>=0.16 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from httpcore==1.*->httpx>=0.20.0->llama-cloud) (0.16.0)\n",
|
||||
"Requirement already satisfied: annotated-types>=0.6.0 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from pydantic>=1.10->llama-cloud) (0.7.0)\n",
|
||||
"Requirement already satisfied: pydantic-core==2.41.5 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from pydantic>=1.10->llama-cloud) (2.41.5)\n",
|
||||
"Requirement already satisfied: typing-extensions>=4.14.1 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from pydantic>=1.10->llama-cloud) (4.15.0)\n",
|
||||
"Requirement already satisfied: typing-inspection>=0.4.2 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from pydantic>=1.10->llama-cloud) (0.4.2)\n",
|
||||
"Requirement already satisfied: sniffio>=1.1 in /Users/javier/llama_cloud_services/.venv/lib/python3.11/site-packages (from anyio->httpx>=0.20.0->llama-cloud) (1.3.1)\n",
|
||||
"\n",
|
||||
"\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m25.0.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m25.3\u001b[0m\n",
|
||||
"\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n",
|
||||
"Note: you may need to restart the kernel to use updated packages.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Install required packages\n",
|
||||
"%pip install llama-cloud python-dotenv requests"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"✅ API configured with base URL: https://api.cloud.llamaindex.ai\n",
|
||||
"✅ Project ID: using default project\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"import time\n",
|
||||
"import requests\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"\n",
|
||||
"# Load environment variables\n",
|
||||
"load_dotenv()\n",
|
||||
"\n",
|
||||
"# Configuration\n",
|
||||
"LLAMA_CLOUD_API_KEY = os.environ.get(\"LLAMA_CLOUD_API_KEY\", \"llx-...\")\n",
|
||||
"BASE_URL = os.environ.get(\"LLAMA_CLOUD_BASE_URL\", \"https://api.cloud.llamaindex.ai\")\n",
|
||||
"PROJECT_ID = os.environ.get(\"LLAMA_CLOUD_PROJECT_ID\", None)\n",
|
||||
"\n",
|
||||
"# Headers for API requests\n",
|
||||
"headers = {\n",
|
||||
" \"Authorization\": f\"Bearer {LLAMA_CLOUD_API_KEY}\",\n",
|
||||
" \"Content-Type\": \"application/json\",\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"print(f\"✅ API configured with base URL: {BASE_URL}\")\n",
|
||||
"print(f\"✅ Project ID: {PROJECT_ID or 'using default project'}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 1: Upload the PDF File\n",
|
||||
"\n",
|
||||
"First, we'll upload our concatenated PDF to LlamaCloud using the Files API. This can be done using the `llama-cloud` SDK.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"📤 Uploading ./data/turing+imagenet+attention.pdf...\n",
|
||||
"✅ File uploaded successfully!\n",
|
||||
" File name: turing+imagenet+attention.pdf\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from llama_cloud.client import LlamaCloud\n",
|
||||
"\n",
|
||||
"# Initialize the client\n",
|
||||
"client = LlamaCloud(token=LLAMA_CLOUD_API_KEY, base_url=BASE_URL)\n",
|
||||
"\n",
|
||||
"# Path to the PDF file\n",
|
||||
"pdf_path = \"./data/turing+imagenet+attention.pdf\"\n",
|
||||
"\n",
|
||||
"# Upload the file\n",
|
||||
"print(f\"📤 Uploading {pdf_path}...\")\n",
|
||||
"\n",
|
||||
"with open(pdf_path, \"rb\") as f:\n",
|
||||
" uploaded_file = client.files.upload_file(upload_file=f, project_id=PROJECT_ID)\n",
|
||||
"\n",
|
||||
"file_id = uploaded_file.id\n",
|
||||
"print(f\"✅ File uploaded successfully!\")\n",
|
||||
"print(f\" File name: {uploaded_file.name}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 2: Create a Split Job\n",
|
||||
"\n",
|
||||
"Now we'll create a split job using the Split API. Since the Split API is in beta and not yet available in the SDK, we'll use raw HTTP requests.\n",
|
||||
"\n",
|
||||
"We define two categories:\n",
|
||||
"- **essay**: For philosophical or reflective writing\n",
|
||||
"- **research_paper**: For formal academic documents with methodology and citations\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"🔄 Creating split job...\n",
|
||||
"✅ Split job created!\n",
|
||||
" Job ID: spl-zsssb632a742aikliu96pqkb56t5\n",
|
||||
" Status: pending\n",
|
||||
" Categories: ['essay', 'research_paper']\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Define the split job request\n",
|
||||
"split_request = {\n",
|
||||
" \"document_input\": {\n",
|
||||
" \"type\": \"file_id\", # only file_id is supported for now\n",
|
||||
" \"value\": file_id,\n",
|
||||
" },\n",
|
||||
" \"categories\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"essay\",\n",
|
||||
" \"description\": \"A philosophical or reflective piece of writing that presents personal viewpoints, arguments, or thoughts on a topic without strict formal structure\",\n",
|
||||
" },\n",
|
||||
" {\n",
|
||||
" \"name\": \"research_paper\",\n",
|
||||
" \"description\": \"A formal academic document presenting original research, methodology, experiments, results, and conclusions with citations and references\",\n",
|
||||
" },\n",
|
||||
" ],\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"# Create the split job\n",
|
||||
"print(\"🔄 Creating split job...\")\n",
|
||||
"response = requests.post(\n",
|
||||
" f\"{BASE_URL}/api/v1/beta/split/jobs\",\n",
|
||||
" params={\"project_id\": PROJECT_ID},\n",
|
||||
" headers=headers,\n",
|
||||
" json=split_request,\n",
|
||||
")\n",
|
||||
"response.raise_for_status()\n",
|
||||
"\n",
|
||||
"split_job = response.json()\n",
|
||||
"job_id = split_job[\"id\"]\n",
|
||||
"\n",
|
||||
"print(f\"✅ Split job created!\")\n",
|
||||
"print(f\" Job ID: {job_id}\")\n",
|
||||
"print(f\" Status: {split_job['status']}\")\n",
|
||||
"print(f\" Categories: {[c['name'] for c in split_job['categories']]}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 3: Poll for Job Completion\n",
|
||||
"\n",
|
||||
"The split job runs asynchronously. We'll poll the job status until it completes.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"⏳ Waiting for split job to complete...\n",
|
||||
" Status: processing (elapsed: 0s)\n",
|
||||
" Status: processing (elapsed: 5s)\n",
|
||||
" Status: processing (elapsed: 11s)\n",
|
||||
" Status: completed (elapsed: 16s)\n",
|
||||
"\n",
|
||||
"✅ Split job completed successfully!\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def poll_split_job(job_id: str, max_wait_seconds: int = 180, poll_interval: int = 5):\n",
|
||||
" \"\"\"\n",
|
||||
" Poll a split job until it reaches a terminal state.\n",
|
||||
"\n",
|
||||
" Args:\n",
|
||||
" job_id: The split job ID\n",
|
||||
" max_wait_seconds: Maximum time to wait for completion\n",
|
||||
" poll_interval: Seconds between poll attempts\n",
|
||||
"\n",
|
||||
" Returns:\n",
|
||||
" The completed job response\n",
|
||||
" \"\"\"\n",
|
||||
" start_time = time.time()\n",
|
||||
"\n",
|
||||
" while (time.time() - start_time) < max_wait_seconds:\n",
|
||||
" response = requests.get(\n",
|
||||
" f\"{BASE_URL}/api/v1/beta/split/jobs/{job_id}\",\n",
|
||||
" params={\"project_id\": PROJECT_ID},\n",
|
||||
" headers=headers,\n",
|
||||
" )\n",
|
||||
" response.raise_for_status()\n",
|
||||
" job = response.json()\n",
|
||||
"\n",
|
||||
" status = job[\"status\"]\n",
|
||||
" elapsed = int(time.time() - start_time)\n",
|
||||
" print(f\" Status: {status} (elapsed: {elapsed}s)\")\n",
|
||||
"\n",
|
||||
" if status in [\"completed\", \"failed\"]:\n",
|
||||
" return job\n",
|
||||
"\n",
|
||||
" time.sleep(poll_interval)\n",
|
||||
"\n",
|
||||
" raise TimeoutError(f\"Job did not complete within {max_wait_seconds} seconds\")\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"print(\"⏳ Waiting for split job to complete...\")\n",
|
||||
"completed_job = poll_split_job(job_id)\n",
|
||||
"\n",
|
||||
"if completed_job[\"status\"] == \"completed\":\n",
|
||||
" print(\"\\n✅ Split job completed successfully!\")\n",
|
||||
"else:\n",
|
||||
" print(\n",
|
||||
" f\"\\n❌ Split job failed: {completed_job.get('error_message', 'Unknown error')}\"\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Step 4: Analyze the Results\n",
|
||||
"\n",
|
||||
"Let's examine the split results to see how the document was segmented.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"📊 Split Results Summary\n",
|
||||
"==================================================\n",
|
||||
"Total segments found: 3\n",
|
||||
"\n",
|
||||
"Segments by category:\n",
|
||||
" • essay: 1 segment(s)\n",
|
||||
" • research_paper: 2 segment(s)\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Get the segments from the result\n",
|
||||
"segments = completed_job.get(\"result\", {}).get(\"segments\", [])\n",
|
||||
"\n",
|
||||
"print(f\"📊 Split Results Summary\")\n",
|
||||
"print(f\"=\" * 50)\n",
|
||||
"print(f\"Total segments found: {len(segments)}\")\n",
|
||||
"print()\n",
|
||||
"\n",
|
||||
"# Count by category\n",
|
||||
"category_counts = {}\n",
|
||||
"for segment in segments:\n",
|
||||
" cat = segment[\"category\"]\n",
|
||||
" category_counts[cat] = category_counts.get(cat, 0) + 1\n",
|
||||
"\n",
|
||||
"print(\"Segments by category:\")\n",
|
||||
"for cat, count in category_counts.items():\n",
|
||||
" print(f\" • {cat}: {count} segment(s)\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"\n",
|
||||
"📄 Segment Details\n",
|
||||
"==================================================\n",
|
||||
"\n",
|
||||
"Segment 1:\n",
|
||||
" Category: essay\n",
|
||||
" Pages 1-4 (4 pages)\n",
|
||||
" Confidence: high\n",
|
||||
"\n",
|
||||
"Segment 2:\n",
|
||||
" Category: research_paper\n",
|
||||
" Pages 5-13 (9 pages)\n",
|
||||
" Confidence: high\n",
|
||||
"\n",
|
||||
"Segment 3:\n",
|
||||
" Category: research_paper\n",
|
||||
" Pages 14-24 (11 pages)\n",
|
||||
" Confidence: high\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Display detailed segment information\n",
|
||||
"print(f\"\\n📄 Segment Details\")\n",
|
||||
"print(f\"=\" * 50)\n",
|
||||
"\n",
|
||||
"for i, segment in enumerate(segments, 1):\n",
|
||||
" category = segment[\"category\"]\n",
|
||||
" pages = segment[\"pages\"]\n",
|
||||
" confidence = segment[\"confidence_category\"]\n",
|
||||
"\n",
|
||||
" # Format page range\n",
|
||||
" if len(pages) == 1:\n",
|
||||
" page_range = f\"Page {pages[0]}\"\n",
|
||||
" else:\n",
|
||||
" page_range = f\"Pages {min(pages)}-{max(pages)}\"\n",
|
||||
"\n",
|
||||
" print(f\"\\nSegment {i}:\")\n",
|
||||
" print(f\" Category: {category}\")\n",
|
||||
" print(f\" {page_range} ({len(pages)} page{'s' if len(pages) > 1 else ''})\")\n",
|
||||
" print(f\" Confidence: {confidence}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Expected Results\n",
|
||||
"\n",
|
||||
"Based on our test document, we expect:\n",
|
||||
"- **1 essay segment**: Alan Turing's \"Intelligent Machinery, A Heretical Theory\"\n",
|
||||
"- **2 research paper segments**: ImageNet paper and \"Attention is All You Need\" paper\n",
|
||||
"\n",
|
||||
"The pages should be grouped consecutively, with no overlap between segments.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"\n",
|
||||
"✅ Validation\n",
|
||||
"==================================================\n",
|
||||
"Total pages assigned: 24\n",
|
||||
"Unique pages: 24\n",
|
||||
"✅ No page overlap detected - each page belongs to exactly one segment\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Verify no page overlap\n",
|
||||
"all_pages = []\n",
|
||||
"for segment in segments:\n",
|
||||
" all_pages.extend(segment[\"pages\"])\n",
|
||||
"\n",
|
||||
"unique_pages = set(all_pages)\n",
|
||||
"\n",
|
||||
"print(f\"\\n✅ Validation\")\n",
|
||||
"print(f\"=\" * 50)\n",
|
||||
"print(f\"Total pages assigned: {len(all_pages)}\")\n",
|
||||
"print(f\"Unique pages: {len(unique_pages)}\")\n",
|
||||
"\n",
|
||||
"if len(all_pages) == len(unique_pages):\n",
|
||||
" print(f\"✅ No page overlap detected - each page belongs to exactly one segment\")\n",
|
||||
"else:\n",
|
||||
" print(\n",
|
||||
" f\"⚠️ Page overlap detected - {len(all_pages) - len(unique_pages)} duplicate assignments\"\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Using `allow_uncategorized` Strategy\n",
|
||||
"\n",
|
||||
"You can also use the `allow_uncategorized` splitting strategy. This is useful when you want to capture pages that don't match any defined category.\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"📝 With allow_uncategorized=True and only 'essay' category defined,\n",
|
||||
" pages that don't match 'essay' will be grouped as 'uncategorized'.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Example with allow_uncategorized strategy\n",
|
||||
"split_request_uncategorized = {\n",
|
||||
" \"document_input\": {\"type\": \"file_id\", \"value\": file_id},\n",
|
||||
" \"categories\": [\n",
|
||||
" {\n",
|
||||
" \"name\": \"essay\",\n",
|
||||
" \"description\": \"A philosophical or reflective piece of writing that presents personal viewpoints, arguments, or thoughts on a topic\",\n",
|
||||
" }\n",
|
||||
" # Note: We only define 'essay' category\n",
|
||||
" # Research papers will be classified as 'uncategorized'\n",
|
||||
" ],\n",
|
||||
" \"splitting_strategy\": {\"allow_uncategorized\": True},\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"print(\"📝 With allow_uncategorized=True and only 'essay' category defined,\")\n",
|
||||
"print(\" pages that don't match 'essay' will be grouped as 'uncategorized'.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Conclusion\n",
|
||||
"\n",
|
||||
"The LlamaCloud Split API provides a powerful way to automatically segment concatenated documents based on content categories. This is useful for:\n",
|
||||
"\n",
|
||||
"- **Document processing pipelines**: Automatically separate bundled documents before further processing\n",
|
||||
"- **Content organization**: Categorize and organize mixed document collections\n",
|
||||
"- **Information extraction**: Identify different document types within a single file\n",
|
||||
"\n",
|
||||
"### Key Features\n",
|
||||
"\n",
|
||||
"- **AI-powered classification**: Uses LLMs to understand page content and assign categories\n",
|
||||
"- **Flexible categories**: Define any categories relevant to your use case\n",
|
||||
"- **Confidence scoring**: Each segment includes a confidence level\n",
|
||||
"- **Page-level granularity**: Results include exact page numbers for each segment\n",
|
||||
"\n",
|
||||
"### API Reference\n",
|
||||
"\n",
|
||||
"- **Create Split Job**: `POST /api/v1/beta/split/jobs`\n",
|
||||
"- **Get Split Job**: `GET /api/v1/beta/split/jobs/{job_id}`\n",
|
||||
"- **List Split Jobs**: `GET /api/v1/beta/split/jobs`\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
||||
+1
-1
@@ -398,6 +398,6 @@ Another option (orthogonal to the above) is to break the document into smaller s
|
||||
## Additional Resources
|
||||
|
||||
- [Extract Documentation](https://docs.cloud.llamaindex.ai/llamaextract/getting_started) - Details on Extract features, API and examples.
|
||||
- [Example Notebook](docs/examples-py/extract/resume_screening.ipynb) - Detailed walkthrough of resume parsing
|
||||
- [Example Notebook](examples/extract/resume_screening.ipynb) - Detailed walkthrough of resume parsing
|
||||
- [Example Application with TypeScript](./examples-ts/extract/) - End-to-end examples using LlamaExtract TypeScript client.
|
||||
- [Discord Community](https://discord.com/invite/eN6D2HQ4aX) - Get help and share feedback
|
||||
|
||||
@@ -97,7 +97,7 @@ for page in result.pages:
|
||||
print(page.structuredData)
|
||||
```
|
||||
|
||||
See more details about the result object in the [example notebook](./docs/examples-py/parse/demo_json_tour.ipynb).
|
||||
See more details about the result object in the [example notebook](./examples/parse/demo_json_tour.ipynb).
|
||||
|
||||
### Using with file object / bytes
|
||||
|
||||
@@ -153,10 +153,10 @@ Full documentation for `SimpleDirectoryReader` can be found on the [LlamaIndex D
|
||||
|
||||
Several end-to-end indexing examples can be found in the examples folder
|
||||
|
||||
- [Getting Started](docs/examples-py/parse/demo_basic.ipynb)
|
||||
- [Advanced RAG Example](docs/examples-py/parse/demo_advanced.ipynb)
|
||||
- [Raw API Usage](docs/examples-py/parse/demo_api.ipynb)
|
||||
- [Result Object Tour](docs/examples-py/parse/demo_json_tour.ipynb)
|
||||
- [Getting Started](examples/parse/demo_basic.ipynb)
|
||||
- [Advanced RAG Example](examples/parse/demo_advanced.ipynb)
|
||||
- [Raw API Usage](examples/parse/demo_api.ipynb)
|
||||
- [Result Object Tour](examples/parse/demo_json_tour.ipynb)
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
Generated
+48
-3260
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,59 @@
|
||||
# llama-cloud-services-py
|
||||
|
||||
## 0.6.90
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 19cbb25: Remove extension filter
|
||||
|
||||
## 0.6.89
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b9b83c9: Parse bounding boxes from extract jobs results in agent data
|
||||
|
||||
## 0.6.88
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 71db318: Add tier and version
|
||||
|
||||
## 0.6.87
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 06c3c55: Update spreadsheet parsing config
|
||||
|
||||
## 0.6.86
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1b7198d: Update extract to have confidence scores available in all modes
|
||||
|
||||
## 0.6.85
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ae30990: Add line-level bbox support
|
||||
|
||||
## 0.6.84
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0a110de: Release to re-align versions
|
||||
|
||||
## 0.6.83
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ca78113: Do not use presigned URLs by default in files client
|
||||
|
||||
## 0.6.82
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bfaec79: Update for new page number params
|
||||
|
||||
## 0.6.81
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ test: ## Run unit tests via pytest
|
||||
|
||||
.PHONY: e2e
|
||||
e2e: ## Run all tests. Run with high parallelism using xdist since tests are bottlenecked bound by the slow backend parsing
|
||||
uv run pytest -v -n 32 tests/
|
||||
uv run pytest -v -n 32 --timeout=300 --session-timeout=1740 tests/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from llama_cloud_services.parse import LlamaParse
|
||||
from llama_cloud_services.extract import LlamaExtract, ExtractionAgent
|
||||
from llama_cloud_services.testing_utils import FakeLlamaCloudServer
|
||||
from llama_cloud_services.utils import SourceText, FileInput
|
||||
from llama_cloud_services.constants import EU_BASE_URL
|
||||
from llama_cloud_services.index import (
|
||||
@@ -19,5 +18,4 @@ __all__ = [
|
||||
"LlamaCloudIndex",
|
||||
"LlamaCloudRetriever",
|
||||
"LlamaCloudCompositeRetriever",
|
||||
"FakeLlamaCloudServer",
|
||||
]
|
||||
|
||||
@@ -11,6 +11,9 @@ from .schema import (
|
||||
InvalidExtractionData,
|
||||
ExtractedFieldMetadata,
|
||||
ExtractedFieldMetaDataDict,
|
||||
FieldCitation,
|
||||
BoundingBox,
|
||||
PageDimensions,
|
||||
)
|
||||
from .client import AsyncAgentDataClient
|
||||
|
||||
@@ -28,4 +31,7 @@ __all__ = [
|
||||
"InvalidExtractionData",
|
||||
"ExtractedFieldMetadata",
|
||||
"ExtractedFieldMetaDataDict",
|
||||
"FieldCitation",
|
||||
"BoundingBox",
|
||||
"PageDimensions",
|
||||
]
|
||||
|
||||
@@ -174,6 +174,22 @@ class TypedAgentDataItems(BaseModel, Generic[AgentDataT]):
|
||||
)
|
||||
|
||||
|
||||
class BoundingBox(BaseModel):
|
||||
"""Bounding box coordinates for a citation location on a page."""
|
||||
|
||||
x: float = Field(description="X coordinate of the bounding box origin")
|
||||
y: float = Field(description="Y coordinate of the bounding box origin")
|
||||
w: float = Field(description="Width of the bounding box")
|
||||
h: float = Field(description="Height of the bounding box")
|
||||
|
||||
|
||||
class PageDimensions(BaseModel):
|
||||
"""Dimensions of a page in the source document."""
|
||||
|
||||
width: float = Field(description="Width of the page")
|
||||
height: float = Field(description="Height of the page")
|
||||
|
||||
|
||||
class FieldCitation(BaseModel):
|
||||
page: Optional[int] = Field(
|
||||
None, description="The page number that the field occurred on"
|
||||
@@ -182,6 +198,14 @@ class FieldCitation(BaseModel):
|
||||
None,
|
||||
description="The original text this field's value was derived from",
|
||||
)
|
||||
bounding_boxes: Optional[List[BoundingBox]] = Field(
|
||||
None,
|
||||
description="Bounding boxes indicating where the citation appears on the page",
|
||||
)
|
||||
page_dimensions: Optional[PageDimensions] = Field(
|
||||
None,
|
||||
description="Dimensions of the page containing the citation",
|
||||
)
|
||||
|
||||
|
||||
class ExtractedFieldMetadata(BaseModel):
|
||||
@@ -201,6 +225,10 @@ class ExtractedFieldMetadata(BaseModel):
|
||||
None,
|
||||
description="The confidence score for the field based on the extracted text only",
|
||||
)
|
||||
parsing_confidence: Optional[float] = Field(
|
||||
None,
|
||||
description="The confidence score for the field based on the parsing/OCR quality",
|
||||
)
|
||||
citation: Optional[List[FieldCitation]] = Field(
|
||||
None,
|
||||
description="The citation for the field, including page number and matching text",
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any, Dict, TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
@@ -70,6 +68,8 @@ class LlamaSheets:
|
||||
max_timeout: int = 300,
|
||||
poll_interval: int = 5,
|
||||
max_retries: int = 3,
|
||||
project_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
async_httpx_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize the LlamaSheets client.
|
||||
@@ -80,6 +80,8 @@ class LlamaSheets:
|
||||
max_timeout: Maximum time to wait for job completion in seconds
|
||||
poll_interval: Interval between status checks in seconds
|
||||
max_retries: Maximum number of retries for failed requests
|
||||
project_id: Project ID for file operations. If not provided, will use LLAMA_CLOUD_PROJECT_ID env var
|
||||
organization_id: Organization ID for file operations. If not provided, will use LLAMA_CLOUD_ORGANIZATION_ID env var
|
||||
async_httpx_client: Optional custom async httpx client
|
||||
"""
|
||||
self.api_key = api_key or os.environ.get("LLAMA_CLOUD_API_KEY")
|
||||
@@ -95,15 +97,32 @@ class LlamaSheets:
|
||||
self.poll_interval = poll_interval
|
||||
self.max_retries = max_retries
|
||||
|
||||
self.project_id = project_id or os.environ.get("LLAMA_CLOUD_PROJECT_ID")
|
||||
self.organization_id = organization_id or os.environ.get(
|
||||
"LLAMA_CLOUD_ORGANIZATION_ID"
|
||||
)
|
||||
|
||||
self._async_client: httpx.AsyncClient | None = async_httpx_client
|
||||
self._files_client = FileClient(
|
||||
AsyncLlamaCloud(
|
||||
token=self.api_key,
|
||||
base_url=self.base_url,
|
||||
httpx_client=async_httpx_client,
|
||||
)
|
||||
),
|
||||
project_id=self.project_id,
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
|
||||
def _get_default_params(self) -> dict[str, str]:
|
||||
"""Get default query parameters for API requests"""
|
||||
params = {}
|
||||
if self.project_id is not None:
|
||||
params["project_id"] = self.project_id
|
||||
if self.organization_id is not None:
|
||||
params["organization_id"] = self.organization_id
|
||||
|
||||
return params
|
||||
|
||||
def _get_async_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create the async httpx client"""
|
||||
if self._async_client is None:
|
||||
@@ -308,6 +327,8 @@ class LlamaSheets:
|
||||
"config": config.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
|
||||
params = self._get_default_params()
|
||||
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
@@ -320,6 +341,7 @@ class LlamaSheets:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/beta/sheets/jobs",
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -349,12 +371,17 @@ class LlamaSheets:
|
||||
):
|
||||
with attempt:
|
||||
client = self._get_async_client()
|
||||
params: Dict[str, Any] = {
|
||||
"include_results": include_results_metadata,
|
||||
**self._get_default_params(),
|
||||
}
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/beta/sheets/jobs/{job_id}",
|
||||
headers=self._get_headers(),
|
||||
params={"include_results": include_results_metadata},
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return SpreadsheetJobResult.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise SpreadsheetAPIError(f"Failed to get job status: {e}") from e
|
||||
@@ -417,6 +444,8 @@ class LlamaSheets:
|
||||
# Get presigned URL
|
||||
presigned_response = None
|
||||
result_type_str = str(result_type)
|
||||
params = self._get_default_params()
|
||||
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
@@ -429,6 +458,7 @@ class LlamaSheets:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/beta/sheets/jobs/{job_id}/regions/{region_id}/result/{result_type_str}",
|
||||
headers=self._get_headers(),
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
presigned_response = PresignedUrlResponse.model_validate(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
@@ -65,7 +64,7 @@ class SpreadsheetParseResult(BaseModel):
|
||||
class SpreadsheetParsingConfig(BaseModel):
|
||||
"""Configuration for spreadsheet parsing and region extraction"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
sheet_names: list[str] | None = Field(
|
||||
default=None,
|
||||
@@ -88,6 +87,16 @@ class SpreadsheetParsingConfig(BaseModel):
|
||||
description="Enables experimental processing. Accuracy may be impacted.",
|
||||
)
|
||||
|
||||
flatten_hierarchical_tables: bool = Field(
|
||||
default=False,
|
||||
description="Return a flattened dataframe when a detected table is recognized as hierarchical.",
|
||||
)
|
||||
|
||||
table_merge_sensitivity: Literal["strong", "weak"] = Field(
|
||||
default="strong",
|
||||
description="Influences how likely similar-looking regions are merged into a single table. Useful for spreadsheets that either have sparse tables (strong merging) or many distinct tables close together (weak merging).",
|
||||
)
|
||||
|
||||
|
||||
class SpreadsheetJob(BaseModel):
|
||||
"""A spreadsheet parsing job"""
|
||||
|
||||
@@ -240,11 +240,6 @@ def _extraction_config_warning(config: ExtractConfig) -> None:
|
||||
raise ValueError(
|
||||
"`cite_sources` is only supported with MULTIMODAL or PREMIUM extraction modes."
|
||||
)
|
||||
if config.confidence_scores:
|
||||
if config.extraction_mode in (ExtractMode.FAST, ExtractMode.BALANCED):
|
||||
raise ValueError(
|
||||
"`confidence_scores` is only supported with MULTIMODAL or PREMIUM extraction modes."
|
||||
)
|
||||
|
||||
|
||||
class ExtractionAgent:
|
||||
|
||||
@@ -11,7 +11,7 @@ from llama_cloud_services.utils import SourceText, FileInput
|
||||
class FileClient:
|
||||
"""
|
||||
Higher-level client for interacting with the LlamaCloud Files API.
|
||||
Uses presigned URLs for uploads by default.
|
||||
Optionally uses presigned URLs for uploads.
|
||||
|
||||
Args:
|
||||
client: The LlamaCloud client to use.
|
||||
@@ -25,7 +25,7 @@ class FileClient:
|
||||
client: AsyncLlamaCloud,
|
||||
project_id: Optional[str] = None,
|
||||
organization_id: Optional[str] = None,
|
||||
use_presigned_url: bool = True,
|
||||
use_presigned_url: bool = False,
|
||||
):
|
||||
self.client = client
|
||||
self.project_id = project_id
|
||||
@@ -91,6 +91,10 @@ class FileClient:
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
else:
|
||||
# Set buffer.name if not already set, so the upload uses external_file_id
|
||||
# for file type detection
|
||||
if not getattr(buffer, "name", None):
|
||||
setattr(buffer, "name", external_file_id)
|
||||
return await self.client.files.upload_file(
|
||||
upload_file=buffer,
|
||||
external_file_id=external_file_id,
|
||||
|
||||
@@ -654,6 +654,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
],
|
||||
)
|
||||
|
||||
# Trigger a sync
|
||||
client.pipelines.sync_pipeline(pipeline_id=index.pipeline.id)
|
||||
|
||||
doc_ids = [doc.id for doc in upserted_documents]
|
||||
index.wait_for_completion(
|
||||
doc_ids=doc_ids, verbose=verbose, raise_on_error=raise_on_error
|
||||
@@ -738,6 +741,10 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# Trigger a sync
|
||||
self._client.pipelines.sync_pipeline(pipeline_id=self.pipeline.id)
|
||||
|
||||
upserted_document = upserted_documents[0]
|
||||
self.wait_for_completion(
|
||||
doc_ids=[upserted_document.id], verbose=verbose, raise_on_error=True
|
||||
@@ -760,6 +767,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
)
|
||||
],
|
||||
)
|
||||
# Trigger a sync
|
||||
await self._aclient.pipelines.sync_pipeline(pipeline_id=self.pipeline.id)
|
||||
|
||||
upserted_document = upserted_documents[0]
|
||||
await self.await_for_completion(
|
||||
doc_ids=[upserted_document.id], verbose=verbose, raise_on_error=True
|
||||
@@ -782,6 +792,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
)
|
||||
],
|
||||
)
|
||||
# Trigger a sync
|
||||
self._client.pipelines.sync_pipeline(pipeline_id=self.pipeline.id)
|
||||
|
||||
upserted_document = upserted_documents[0]
|
||||
self.wait_for_completion(
|
||||
doc_ids=[upserted_document.id], verbose=verbose, raise_on_error=True
|
||||
@@ -804,6 +817,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
)
|
||||
],
|
||||
)
|
||||
# Trigger a sync
|
||||
await self._aclient.pipelines.sync_pipeline(pipeline_id=self.pipeline.id)
|
||||
|
||||
upserted_document = upserted_documents[0]
|
||||
await self.await_for_completion(
|
||||
doc_ids=[upserted_document.id], verbose=verbose, raise_on_error=True
|
||||
@@ -827,6 +843,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
for doc in documents
|
||||
],
|
||||
)
|
||||
# Trigger a sync
|
||||
self._client.pipelines.sync_pipeline(pipeline_id=self.pipeline.id)
|
||||
|
||||
doc_ids = [doc.id for doc in upserted_documents]
|
||||
self.wait_for_completion(doc_ids=doc_ids, verbose=True, raise_on_error=True)
|
||||
return [True] * len(doc_ids)
|
||||
@@ -849,6 +868,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
for doc in documents
|
||||
],
|
||||
)
|
||||
# Trigger a sync
|
||||
await self._aclient.pipelines.sync_pipeline(pipeline_id=self.pipeline.id)
|
||||
|
||||
doc_ids = [doc.id for doc in upserted_documents]
|
||||
await self.await_for_completion(
|
||||
doc_ids=doc_ids, verbose=True, raise_on_error=True
|
||||
|
||||
@@ -285,7 +285,7 @@ class LlamaParse(BasePydanticReader):
|
||||
description="Note: Non compatible with gpt-4o. If set to true, the parser will use a faster mode to extract text from documents. This mode will skip OCR of images, and table/heading reconstruction.",
|
||||
)
|
||||
|
||||
guess_xlsx_sheet_names: Optional[bool] = Field(
|
||||
guess_xlsx_sheet_name: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Whether to guess the sheet names of the xlsx file.",
|
||||
)
|
||||
@@ -313,6 +313,10 @@ class LlamaParse(BasePydanticReader):
|
||||
default=False,
|
||||
description="If set to true, the parser will ignore document elements for layout detection and only rely on a vision model.",
|
||||
)
|
||||
inline_images_in_markdown: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will inline images in the markdown output.",
|
||||
)
|
||||
input_s3_region: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The region of the input S3 bucket if input_s3_path is specified.",
|
||||
@@ -329,6 +333,10 @@ class LlamaParse(BasePydanticReader):
|
||||
default=None,
|
||||
description="The maximum timeout in seconds to wait for the parsing to finish. Override default timeout of 30 minutes. Minimum is 120 seconds.",
|
||||
)
|
||||
keep_page_separator_when_merging_tables: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will keep the page separator when merging tables across pages.",
|
||||
)
|
||||
language: Optional[str] = Field(
|
||||
default="en", description="The language of the text to parse."
|
||||
)
|
||||
@@ -400,6 +408,10 @@ class LlamaParse(BasePydanticReader):
|
||||
default=False,
|
||||
description="If set, the parser will try to preserve very small text lines. This can be useful for documents containing vector graphics with very small text lines that may not be recognized by OCR or a vision model (such as in CAD drawings).",
|
||||
)
|
||||
presentation_out_of_bounds_content: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will include out-of-bounds content in presentation files.",
|
||||
)
|
||||
precise_bounding_box: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will use a more precise bounding box to extract text from documents. This will increase the accuracy of the parsing job, but reduce the speed.",
|
||||
@@ -416,6 +428,14 @@ class LlamaParse(BasePydanticReader):
|
||||
default=None,
|
||||
description="A suffix to add after error message in failed pages. If not set, no suffix will be used.",
|
||||
)
|
||||
remove_hidden_text: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will remove hidden text from the document.",
|
||||
)
|
||||
save_images: Optional[bool] = Field(
|
||||
default=True,
|
||||
description="If set to true, the parser will save images extracted from the document.",
|
||||
)
|
||||
skip_diagonal_text: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will ignore diagonal text (when the text rotation in degrees modulo 90 is not 0).",
|
||||
@@ -440,6 +460,10 @@ class LlamaParse(BasePydanticReader):
|
||||
default=False,
|
||||
description="If set to true, the parser will use a specialized one-shot chart parsing model to extract data from charts. This model is able to understand the chart type and extract the data accordingly. It is more accurate than the efficient model, but also more expensive.",
|
||||
)
|
||||
specialized_image_parsing: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will use a specialized image parsing model to extract data from images.",
|
||||
)
|
||||
strict_mode_buggy_font: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will fail if it can't extract text from a document because of a buggy font.",
|
||||
@@ -536,6 +560,21 @@ class LlamaParse(BasePydanticReader):
|
||||
default=None,
|
||||
description="A prefix to add to the page footer in the output markdown.",
|
||||
)
|
||||
extract_printed_page_number: Optional[bool] = Field(
|
||||
default=None,
|
||||
description="Whether to extract the printed page numbers from pages in the document.",
|
||||
)
|
||||
line_level_bounding_box: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will include line-level bounding boxes in the result.",
|
||||
)
|
||||
tier: Optional[str] = Field(
|
||||
default=None, description="The tier to use for the parsing job."
|
||||
)
|
||||
version: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The version of the parser to use at the specified tier.",
|
||||
)
|
||||
|
||||
# Deprecated
|
||||
bounding_box: Optional[str] = Field(
|
||||
@@ -580,6 +619,23 @@ class LlamaParse(BasePydanticReader):
|
||||
description="Automatically check for Python SDK updates.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def handle_deprecated_params(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Handle deprecated guess_xlsx_sheet_names -> guess_xlsx_sheet_name
|
||||
if "guess_xlsx_sheet_names" in data:
|
||||
warnings.warn(
|
||||
"The parameter 'guess_xlsx_sheet_names' is deprecated and will be removed in a future release. "
|
||||
"Use 'guess_xlsx_sheet_name' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# Only set the new parameter if it's not already explicitly set
|
||||
if "guess_xlsx_sheet_name" not in data:
|
||||
data["guess_xlsx_sheet_name"] = data["guess_xlsx_sheet_names"]
|
||||
del data["guess_xlsx_sheet_names"]
|
||||
return data
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def warn_extra_params(cls, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -695,11 +751,9 @@ class LlamaParse(BasePydanticReader):
|
||||
file_path = str(file_input)
|
||||
file_ext = os.path.splitext(file_path)[1].lower()
|
||||
if file_ext not in SUPPORTED_FILE_TYPES:
|
||||
raise Exception(
|
||||
f"Currently, only the following file types are supported: {SUPPORTED_FILE_TYPES}\n"
|
||||
f"Current file type: {file_ext}"
|
||||
)
|
||||
mime_type = mimetypes.guess_type(file_path)[0]
|
||||
mime_type = "application/octet-stream"
|
||||
else:
|
||||
mime_type = mimetypes.guess_type(file_path)[0]
|
||||
# Open the file here for the duration of the async context
|
||||
# load data, set the mime type
|
||||
fs = fs or get_default_fs()
|
||||
@@ -820,8 +874,8 @@ class LlamaParse(BasePydanticReader):
|
||||
)
|
||||
data["formatting_instruction"] = self.formatting_instruction
|
||||
|
||||
if self.guess_xlsx_sheet_names:
|
||||
data["guess_xlsx_sheet_names"] = self.guess_xlsx_sheet_names
|
||||
if self.guess_xlsx_sheet_name:
|
||||
data["guess_xlsx_sheet_name"] = self.guess_xlsx_sheet_name
|
||||
|
||||
if self.html_make_all_elements_visible:
|
||||
data["html_make_all_elements_visible"] = self.html_make_all_elements_visible
|
||||
@@ -845,6 +899,9 @@ class LlamaParse(BasePydanticReader):
|
||||
"ignore_document_elements_for_layout_detection"
|
||||
] = self.ignore_document_elements_for_layout_detection
|
||||
|
||||
if self.inline_images_in_markdown:
|
||||
data["inline_images_in_markdown"] = self.inline_images_in_markdown
|
||||
|
||||
if input_url is not None:
|
||||
files = None
|
||||
data["input_url"] = str(input_url)
|
||||
@@ -873,6 +930,11 @@ class LlamaParse(BasePydanticReader):
|
||||
if self.job_timeout_in_seconds is not None:
|
||||
data["job_timeout_in_seconds"] = self.job_timeout_in_seconds
|
||||
|
||||
if self.keep_page_separator_when_merging_tables:
|
||||
data[
|
||||
"keep_page_separator_when_merging_tables"
|
||||
] = self.keep_page_separator_when_merging_tables
|
||||
|
||||
if self.language:
|
||||
data["language"] = self.language
|
||||
|
||||
@@ -951,6 +1013,11 @@ class LlamaParse(BasePydanticReader):
|
||||
if self.preserve_very_small_text:
|
||||
data["preserve_very_small_text"] = self.preserve_very_small_text
|
||||
|
||||
if self.presentation_out_of_bounds_content:
|
||||
data[
|
||||
"presentation_out_of_bounds_content"
|
||||
] = self.presentation_out_of_bounds_content
|
||||
|
||||
if self.preset is not None:
|
||||
data["preset"] = self.preset
|
||||
|
||||
@@ -970,6 +1037,11 @@ class LlamaParse(BasePydanticReader):
|
||||
"replace_failed_page_with_error_message_suffix"
|
||||
] = self.replace_failed_page_with_error_message_suffix
|
||||
|
||||
if self.remove_hidden_text:
|
||||
data["remove_hidden_text"] = self.remove_hidden_text
|
||||
|
||||
data["save_images"] = self.save_images
|
||||
|
||||
if self.skip_diagonal_text:
|
||||
data["skip_diagonal_text"] = self.skip_diagonal_text
|
||||
|
||||
@@ -994,6 +1066,9 @@ class LlamaParse(BasePydanticReader):
|
||||
if self.specialized_chart_parsing_plus:
|
||||
data["specialized_chart_parsing_plus"] = self.specialized_chart_parsing_plus
|
||||
|
||||
if self.specialized_image_parsing:
|
||||
data["specialized_image_parsing"] = self.specialized_image_parsing
|
||||
|
||||
if self.strict_mode_buggy_font:
|
||||
data["strict_mode_buggy_font"] = self.strict_mode_buggy_font
|
||||
|
||||
@@ -1049,6 +1124,18 @@ class LlamaParse(BasePydanticReader):
|
||||
"markdown_table_multiline_header_separator"
|
||||
] = self.markdown_table_multiline_header_separator
|
||||
|
||||
if self.extract_printed_page_number is not None:
|
||||
data["extract_printed_page_number"] = self.extract_printed_page_number
|
||||
|
||||
if self.line_level_bounding_box is not None:
|
||||
data["line_level_bounding_box"] = self.line_level_bounding_box
|
||||
|
||||
if self.tier is not None:
|
||||
data["tier"] = self.tier
|
||||
|
||||
if self.version is not None:
|
||||
data["version"] = self.version
|
||||
|
||||
# Deprecated
|
||||
if self.bounding_box is not None:
|
||||
data["bounding_box"] = self.bounding_box
|
||||
|
||||
@@ -115,6 +115,26 @@ class BBox(SafeBaseModel):
|
||||
)
|
||||
|
||||
|
||||
class LineLevelBboxItem(SafeBaseModel):
|
||||
"""A line-level bounding box item."""
|
||||
|
||||
md: Optional[str] = Field(
|
||||
default=None, description="The markdown-formatted content of the line."
|
||||
)
|
||||
text: Optional[str] = Field(
|
||||
default=None, description="The text content of the line."
|
||||
)
|
||||
bBox: Optional[BBox] = Field(
|
||||
default=None, description="The bounding box of the line."
|
||||
)
|
||||
startIndex: Optional[int] = Field(
|
||||
default=None, description="The start index of the line in the page text."
|
||||
)
|
||||
endIndex: Optional[int] = Field(
|
||||
default=None, description="The end index of the line in the page text."
|
||||
)
|
||||
|
||||
|
||||
class PageItem(SafeBaseModel):
|
||||
"""An item in a page."""
|
||||
|
||||
@@ -138,6 +158,9 @@ class PageItem(SafeBaseModel):
|
||||
default=None,
|
||||
description="The HTML-formatted content of the item. Only applicable for table items when output_tables_as_HTML=True.",
|
||||
)
|
||||
lines: Optional[List[LineLevelBboxItem]] = Field(
|
||||
default=None, description="The line-level bounding box items of the item."
|
||||
)
|
||||
|
||||
|
||||
class ImageItem(SafeBaseModel):
|
||||
@@ -250,6 +273,19 @@ class Page(SafeBaseModel):
|
||||
slideSpeakerNotes: Optional[str] = Field(
|
||||
default=None, description="The speaker notes for the slide."
|
||||
)
|
||||
confidence: Optional[float] = Field(
|
||||
default=None, description="The confidence of the page parsing."
|
||||
)
|
||||
printedPageNumber: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The printed page number on the page, if found and extractPrintedPageNumber is set to true.",
|
||||
)
|
||||
pageHeaderMarkdown: Optional[str] = Field(
|
||||
default=None, description="The page header in markdown format."
|
||||
)
|
||||
pageFooterMarkdown: Optional[str] = Field(
|
||||
default=None, description="The page footer in markdown format."
|
||||
)
|
||||
|
||||
|
||||
class JobResult(SafeBaseModel):
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from .matchers import FileMatcher, RequestMatcher, SchemaMatcher
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
__all__ = [
|
||||
"FakeLlamaCloudServer",
|
||||
"FileMatcher",
|
||||
"SchemaMatcher",
|
||||
"RequestMatcher",
|
||||
]
|
||||
@@ -1,192 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Mapping, MutableMapping
|
||||
|
||||
|
||||
def hash_chunks(chunks: Iterable[bytes]) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for chunk in chunks:
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def fingerprint_file(content: bytes, filename: str | None = None) -> str:
|
||||
name_bytes = filename.encode("utf-8") if filename else b""
|
||||
return hash_chunks((content, name_bytes))
|
||||
|
||||
|
||||
def hash_schema(schema: Any) -> str:
|
||||
json_string = json.dumps(
|
||||
_to_serializable(schema),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(json_string.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def combined_seed(*parts: str) -> int:
|
||||
digest = hash_chunks(tuple(part.encode("utf-8") for part in parts))
|
||||
return int(digest[:16], 16)
|
||||
|
||||
|
||||
def generate_data_from_schema(schema: Any, seed: int) -> Any:
|
||||
rng = random.Random(seed)
|
||||
return _generate_value(schema, rng, depth=0)
|
||||
|
||||
|
||||
def generate_text_blob(seed: int, sentences: int = 3) -> str:
|
||||
rng = random.Random(seed)
|
||||
words = [
|
||||
"aurora",
|
||||
"copper",
|
||||
"delta",
|
||||
"ember",
|
||||
"fable",
|
||||
"glyph",
|
||||
"harbor",
|
||||
"iris",
|
||||
"juniper",
|
||||
"kepler",
|
||||
"lumen",
|
||||
"monarch",
|
||||
"nylon",
|
||||
"onyx",
|
||||
"paragon",
|
||||
"quartz",
|
||||
"raptor",
|
||||
"solstice",
|
||||
"topaz",
|
||||
"umbra",
|
||||
"verdant",
|
||||
"willow",
|
||||
"xenon",
|
||||
"yonder",
|
||||
"zephyr",
|
||||
]
|
||||
sentence_pieces = []
|
||||
for _ in range(sentences):
|
||||
length = rng.randint(6, 12)
|
||||
chosen = rng.sample(words, k=length)
|
||||
sentence = " ".join(chosen).capitalize() + "."
|
||||
sentence_pieces.append(sentence)
|
||||
return " ".join(sentence_pieces)
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _to_serializable(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="ignore")
|
||||
if isinstance(value, Mapping):
|
||||
return {key: _to_serializable(val) for key, val in value.items()}
|
||||
if isinstance(value, MutableMapping):
|
||||
return {key: _to_serializable(val) for key, val in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_to_serializable(item) for item in value]
|
||||
if hasattr(value, "model_dump_json"):
|
||||
return json.loads(value.model_dump_json())
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict"):
|
||||
return value.dict() # type: ignore[call-arg]
|
||||
if hasattr(value, "model_json_schema"):
|
||||
return value.model_json_schema()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _generate_value(schema: Any, rng: random.Random, depth: int) -> Any:
|
||||
if depth > 8:
|
||||
return rng.choice(
|
||||
(
|
||||
rng.randint(1, 999),
|
||||
rng.random(),
|
||||
generate_text_blob(rng.randint(0, 1_000_000), sentences=1),
|
||||
)
|
||||
)
|
||||
|
||||
if schema is None:
|
||||
return generate_text_blob(rng.randint(0, 1_000_000), sentences=1)
|
||||
|
||||
if isinstance(schema, list):
|
||||
return [_generate_value(item, rng, depth + 1) for item in schema]
|
||||
|
||||
if isinstance(schema, str):
|
||||
return f"{schema}-{rng.randint(100, 999)}"
|
||||
|
||||
if isinstance(schema, Mapping):
|
||||
if "enum" in schema:
|
||||
options = schema["enum"]
|
||||
if options:
|
||||
index = rng.randint(0, len(options) - 1)
|
||||
return options[index]
|
||||
|
||||
schema_type = schema.get("type")
|
||||
|
||||
if schema_type == "object":
|
||||
properties = schema.get("properties", {})
|
||||
result = {}
|
||||
for key, subschema in properties.items():
|
||||
result[key] = _generate_value(subschema, rng, depth + 1)
|
||||
return result
|
||||
|
||||
if schema_type == "array":
|
||||
items_schema = schema.get("items", {})
|
||||
min_items = schema.get("minItems", 1)
|
||||
max_items = schema.get("maxItems", max(3, min_items))
|
||||
length = rng.randint(min_items, min(min_items + 2, max_items))
|
||||
return [
|
||||
_generate_value(items_schema, rng, depth + 1) for _ in range(length)
|
||||
]
|
||||
|
||||
if schema_type == "integer":
|
||||
minimum = schema.get("minimum", 0)
|
||||
maximum = schema.get("maximum", minimum + 500)
|
||||
return rng.randint(int(minimum), int(maximum))
|
||||
|
||||
if schema_type == "number":
|
||||
minimum = schema.get("minimum", 0.0)
|
||||
maximum = schema.get("maximum", minimum + 500.0)
|
||||
value = rng.uniform(float(minimum), float(maximum))
|
||||
return round(value, 2)
|
||||
|
||||
if schema_type == "boolean":
|
||||
return rng.choice((True, False))
|
||||
|
||||
if schema_type == "string":
|
||||
fmt = schema.get("format")
|
||||
if fmt == "date-time":
|
||||
timestamp = utcnow().isoformat()
|
||||
return timestamp
|
||||
if fmt == "email":
|
||||
return f"user{rng.randint(1000, 9999)}@example.com"
|
||||
if fmt == "uri":
|
||||
return f"https://example.com/{rng.randint(1000, 9999)}"
|
||||
min_length = schema.get("minLength", 5)
|
||||
max_length = schema.get("maxLength", max(10, min_length))
|
||||
length = rng.randint(min_length, min(min_length + 5, max_length))
|
||||
return generate_text_blob(
|
||||
rng.randint(0, 1_000_000), sentences=max(1, length // 5)
|
||||
)
|
||||
|
||||
if schema_type == "null":
|
||||
return None
|
||||
|
||||
if "oneOf" in schema:
|
||||
option = rng.choice(schema["oneOf"])
|
||||
return _generate_value(option, rng, depth + 1)
|
||||
|
||||
if "anyOf" in schema:
|
||||
option = rng.choice(schema["anyOf"])
|
||||
return _generate_value(option, rng, depth + 1)
|
||||
|
||||
return generate_text_blob(rng.randint(0, 1_000_000), sentences=1)
|
||||
@@ -1,335 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
from ._deterministic import utcnow, hash_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredAgentData:
|
||||
data: dict[str, Any]
|
||||
id: str
|
||||
collection: str
|
||||
deployment_name: str
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return self.data.get(name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if name in ("data", "id", "collection", "deployment_name"):
|
||||
super().__setattr__(name, value)
|
||||
else:
|
||||
self.data[name] = value
|
||||
|
||||
@classmethod
|
||||
def from_request_data(cls, data: dict[str, Any]) -> "StoredAgentData":
|
||||
return cls(
|
||||
data=data.get("data", {}),
|
||||
collection=data.get("collection", "default"),
|
||||
deployment_name=data.get("deployment_name", ""),
|
||||
id=hash_schema(data.get("data", {}))[:7],
|
||||
)
|
||||
|
||||
|
||||
def apply_filter(data: dict, filters: dict) -> bool:
|
||||
"""Check if data matches all filters"""
|
||||
ops = {
|
||||
"gt": lambda a, b: a > b,
|
||||
"gte": lambda a, b: a >= b,
|
||||
"lt": lambda a, b: a < b,
|
||||
"lte": lambda a, b: a <= b,
|
||||
"eq": lambda a, b: a == b,
|
||||
"ne": lambda a, b: a != b,
|
||||
"in": lambda a, b: a in b,
|
||||
"nin": lambda a, b: a not in b,
|
||||
}
|
||||
|
||||
for key, condition in filters.items():
|
||||
if key not in data:
|
||||
return False
|
||||
|
||||
if isinstance(condition, dict):
|
||||
for op, value in condition.items():
|
||||
if op in ops:
|
||||
if not ops[op](data[key], value):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
if data[key] != condition:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class FakeAgentDataNamespace:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
server: "FakeLlamaCloudServer",
|
||||
) -> None:
|
||||
self._server = server
|
||||
self.stored: list[StoredAgentData] = []
|
||||
self.routes: Dict[str, Any] = {}
|
||||
|
||||
def _create_data(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request=request)
|
||||
data = StoredAgentData.from_request_data(payload)
|
||||
self.stored.append(data)
|
||||
response = {
|
||||
"data": data.data,
|
||||
"collection": data.collection,
|
||||
"deployment_name": data.deployment_name,
|
||||
"created_at": utcnow().isoformat(),
|
||||
"updated_at": None,
|
||||
"id": data.id,
|
||||
"project_id": None,
|
||||
"organization_id": None,
|
||||
}
|
||||
return self._server.json_response(response, status_code=200)
|
||||
|
||||
def _delete_data_by_query(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request=request)
|
||||
delete_count = 0
|
||||
if (filters := payload.get("filter")) is not None:
|
||||
to_keep = []
|
||||
for data in self.stored:
|
||||
if data.collection == payload.get(
|
||||
"collection", "default"
|
||||
) and data.deployment_name == payload.get("deployment_name"):
|
||||
if not apply_filter(data.data, filters):
|
||||
to_keep.append(data)
|
||||
else:
|
||||
delete_count += 1
|
||||
self.stored = to_keep
|
||||
return self._server.json_response(
|
||||
{"deleted_count": delete_count}, status_code=200
|
||||
)
|
||||
|
||||
def _delete_data_by_id(self, request: httpx.Request) -> httpx.Response:
|
||||
item_id = self._find_item_id(request=request)
|
||||
if not item_id:
|
||||
return self._server.json_response(
|
||||
{
|
||||
"detail": "An item_id path parameter is required to perform this operation"
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
if not any(data.id == item_id for data in self.stored):
|
||||
return self._server.json_response(
|
||||
{"detail": f"No data with ID: {item_id}"}, status_code=404
|
||||
)
|
||||
self.stored = [data for data in self.stored if data.id != item_id]
|
||||
return self._server.json_response({}, status_code=200)
|
||||
|
||||
def _get_data_by_id(self, request: httpx.Request) -> httpx.Response:
|
||||
item_id = self._find_item_id(request=request)
|
||||
if not item_id:
|
||||
return self._server.json_response(
|
||||
{
|
||||
"detail": "An item_id path parameter is required to perform this operation"
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
data = [data for data in self.stored if data.id == item_id]
|
||||
if data:
|
||||
response = {
|
||||
"data": data[0].data,
|
||||
"collection": data[0].collection,
|
||||
"deployment_name": data[0].deployment_name,
|
||||
"created_at": utcnow().isoformat(),
|
||||
"updated_at": None,
|
||||
"id": data[0].id,
|
||||
"project_id": None,
|
||||
"organization_id": None,
|
||||
}
|
||||
return self._server.json_response(response, status_code=200)
|
||||
else:
|
||||
return self._server.json_response(
|
||||
{"detail": f"No data with ID: {item_id}"}, status_code=404
|
||||
)
|
||||
|
||||
def _search_data(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request=request)
|
||||
found = []
|
||||
if (filters := payload.get("filter")) is not None:
|
||||
for data in self.stored:
|
||||
if data.collection == payload.get(
|
||||
"collection", "default"
|
||||
) and data.deployment_name == payload.get("deployment_name"):
|
||||
if apply_filter(data.data, filters):
|
||||
found.append(
|
||||
{
|
||||
"data": data.data,
|
||||
"collection": data.collection,
|
||||
"deployment_name": data.deployment_name,
|
||||
"created_at": utcnow().isoformat(),
|
||||
"updated_at": None,
|
||||
"id": data.id,
|
||||
"project_id": None,
|
||||
"organization_id": None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
for data in self.stored:
|
||||
if data.collection == payload.get(
|
||||
"collection", "default"
|
||||
) and data.deployment_name == payload.get("deployment_name"):
|
||||
found.append(
|
||||
{
|
||||
"data": data.data,
|
||||
"collection": data.collection,
|
||||
"deployment_name": data.deployment_name,
|
||||
"created_at": utcnow().isoformat(),
|
||||
"updated_at": None,
|
||||
"id": data.id,
|
||||
"project_id": None,
|
||||
"organization_id": None,
|
||||
}
|
||||
)
|
||||
return self._server.json_response(
|
||||
{"items": found, "next_page_token": None, "total_size": len(found)},
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
def _update_data(self, request: httpx.Request) -> httpx.Response:
|
||||
item_id = self._find_item_id(request=request)
|
||||
payload = self._server.json(request=request)
|
||||
if not item_id:
|
||||
return self._server.json_response(
|
||||
{
|
||||
"detail": "An item_id path parameter is required to perform this operation"
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
updated = None
|
||||
for i, data in enumerate(self.stored):
|
||||
if data.id == item_id:
|
||||
updated = data
|
||||
updated.data = payload.get("data", data.data)
|
||||
self.stored[i] = updated
|
||||
print(updated)
|
||||
if updated is not None:
|
||||
response = {
|
||||
"data": updated.data,
|
||||
"collection": updated.collection,
|
||||
"deployment_name": updated.deployment_name,
|
||||
"created_at": None,
|
||||
"updated_at": utcnow().isoformat(),
|
||||
"id": updated.id,
|
||||
"project_id": None,
|
||||
"organization_id": None,
|
||||
}
|
||||
status_code = 200
|
||||
else:
|
||||
response = {"detail": f"Record with id {item_id} not found"}
|
||||
status_code = 404
|
||||
return self._server.json_response(response, status_code=status_code)
|
||||
|
||||
def _aggregate_data(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request=request)
|
||||
add_count = payload.get("count", False)
|
||||
group_bys: list[str] = payload.get("group_by", [])
|
||||
groups: dict[str, dict[str, list[dict]]] = {key: {} for key in group_bys}
|
||||
if (filters := payload.get("filter")) is not None:
|
||||
for data in self.stored:
|
||||
if data.collection == payload.get(
|
||||
"collection", "default"
|
||||
) and data.deployment_name == payload.get("deployment_name"):
|
||||
if apply_filter(data.data, filters):
|
||||
for key in group_bys:
|
||||
if key in data.data and data.data[key] in groups[key]:
|
||||
groups[key][data.data[key]].append(data.data)
|
||||
elif key in data.data and data.data[key] not in groups[key]:
|
||||
groups[key][data.data[key]] = [data.data]
|
||||
else:
|
||||
for data in self.stored:
|
||||
if data.collection == payload.get(
|
||||
"collection", "default"
|
||||
) and data.deployment_name == payload.get("deployment_name"):
|
||||
for key in group_bys:
|
||||
if key in data.data and data.data[key] in groups[key]:
|
||||
groups[key][data.data[key]].append(data.data)
|
||||
elif key in data.data and data.data[key] not in groups[key]:
|
||||
groups[key][data.data[key]] = [data.data]
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"items": [],
|
||||
"next_page_token": None,
|
||||
"total_size": 0,
|
||||
}
|
||||
for k in groups:
|
||||
if len(groups[k]) > 0:
|
||||
for v in groups[k]:
|
||||
if groups[k][v]:
|
||||
first_element = groups[k][v][0]
|
||||
else:
|
||||
first_element = None
|
||||
response["items"].append(
|
||||
{
|
||||
"first_item": first_element,
|
||||
"count": len(groups[k][v]) if add_count else None,
|
||||
"group_key": {k: v},
|
||||
}
|
||||
)
|
||||
response["total_size"] = len(response["items"])
|
||||
return self._server.json_response(response, status_code=200)
|
||||
|
||||
def _find_item_id(self, request: httpx.Request) -> str | None:
|
||||
matchgroups = re.search(r"/agent-data\/([^\/]+)$", request.url.path)
|
||||
return matchgroups.group(1) if matchgroups is not None else None
|
||||
|
||||
def register(self) -> None:
|
||||
server = self._server
|
||||
route = server.add_route(
|
||||
"POST",
|
||||
"/api/v1/beta/agent-data",
|
||||
self._create_data,
|
||||
namespace="create_item",
|
||||
)
|
||||
self.routes["stateless_run"] = route
|
||||
self.stateless_run = route
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/beta/agent-data/:aggregate",
|
||||
self._aggregate_data,
|
||||
namespace="untyped_aggregate",
|
||||
alias="aggregate",
|
||||
)
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/beta/agent-data/:delete",
|
||||
self._delete_data_by_query,
|
||||
namespace="delete",
|
||||
)
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/beta/agent-data/:search",
|
||||
self._search_data,
|
||||
namespace="untyped_search",
|
||||
alias="search",
|
||||
)
|
||||
server.add_route(
|
||||
"DELETE",
|
||||
"/api/v1/beta/agent-data/{item_id}",
|
||||
self._delete_data_by_id,
|
||||
namespace="delete_item",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/beta/agent-data/{item_id}",
|
||||
self._get_data_by_id,
|
||||
namespace="untyped_get_item",
|
||||
alias="get_item",
|
||||
)
|
||||
server.add_route(
|
||||
"PUT",
|
||||
"/api/v1/beta/agent-data/{item_id}",
|
||||
self._update_data,
|
||||
namespace="update_item",
|
||||
)
|
||||
@@ -1,153 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List
|
||||
|
||||
import httpx
|
||||
from llama_cloud.types import (
|
||||
ClassifierRule,
|
||||
ClassifyJob,
|
||||
ClassifyJobResults,
|
||||
ClassificationResult,
|
||||
FileClassification,
|
||||
StatusEnum,
|
||||
)
|
||||
|
||||
from ._deterministic import combined_seed, utcnow
|
||||
from .files import FakeFilesNamespace, StoredFile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationJobRecord:
|
||||
job: ClassifyJob
|
||||
results: ClassifyJobResults
|
||||
files: List[StoredFile]
|
||||
|
||||
|
||||
class FakeClassifyNamespace:
|
||||
def __init__(
|
||||
self, *, server: "FakeLlamaCloudServer", files: FakeFilesNamespace
|
||||
) -> None:
|
||||
self._server = server
|
||||
self._files = files
|
||||
self._jobs: Dict[str, ClassificationJobRecord] = {}
|
||||
|
||||
def register(self) -> None:
|
||||
server = self._server
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/classifier/jobs",
|
||||
self._handle_create_job,
|
||||
namespace="classify",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/classifier/jobs",
|
||||
self._handle_list_jobs,
|
||||
namespace="classify",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/classifier/jobs/{job_id}",
|
||||
self._handle_get_job,
|
||||
namespace="classify",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/classifier/jobs/{job_id}/results",
|
||||
self._handle_get_results,
|
||||
namespace="classify",
|
||||
)
|
||||
|
||||
def _handle_create_job(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request)
|
||||
file_ids = payload.get("file_ids", [])
|
||||
rules_payload = payload.get("rules", [])
|
||||
rules = [ClassifierRule.parse_obj(rule) for rule in rules_payload]
|
||||
stored_files = []
|
||||
for file_id in file_ids:
|
||||
stored = self._files.get(file_id)
|
||||
if not stored:
|
||||
return self._server.json_response(
|
||||
{"detail": f"File {file_id} not found"}, status_code=404
|
||||
)
|
||||
stored_files.append(stored)
|
||||
|
||||
job_id = self._server.new_id("classify-job")
|
||||
job = ClassifyJob(
|
||||
id=job_id,
|
||||
project_id=request.url.params.get(
|
||||
"project_id", self._server.default_project_id
|
||||
),
|
||||
user_id="fake-user",
|
||||
rules=rules,
|
||||
parsing_configuration=None,
|
||||
status=StatusEnum.SUCCESS,
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
effective_at=utcnow(),
|
||||
error_message=None,
|
||||
job_record_id=None,
|
||||
)
|
||||
results = self._build_results(job_id, stored_files, rules)
|
||||
record = ClassificationJobRecord(job=job, results=results, files=stored_files)
|
||||
self._jobs[job_id] = record
|
||||
return self._server.json_response(job.dict())
|
||||
|
||||
def _handle_list_jobs(self, request: httpx.Request) -> httpx.Response:
|
||||
return self._server.json_response(
|
||||
[record.job.dict() for record in self._jobs.values()]
|
||||
)
|
||||
|
||||
def _handle_get_job(self, request: httpx.Request) -> httpx.Response:
|
||||
job_id = request.url.path.split("/")[-1]
|
||||
record = self._jobs.get(job_id)
|
||||
if not record:
|
||||
return self._server.json_response(
|
||||
{"detail": "Job not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(record.job.dict())
|
||||
|
||||
def _handle_get_results(self, request: httpx.Request) -> httpx.Response:
|
||||
job_id = request.url.path.split("/")[-2]
|
||||
record = self._jobs.get(job_id)
|
||||
if not record:
|
||||
return self._server.json_response(
|
||||
{"detail": "Results not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(record.results.dict())
|
||||
|
||||
def _build_results(
|
||||
self,
|
||||
job_id: str,
|
||||
stored_files: List[StoredFile],
|
||||
rules: List[ClassifierRule],
|
||||
) -> ClassifyJobResults:
|
||||
items: List[FileClassification] = []
|
||||
for stored in stored_files:
|
||||
seed = combined_seed(stored.sha256, job_id)
|
||||
rule_index = seed % len(rules) if rules else 0
|
||||
predicted_type = rules[rule_index].type if rules else "unlabeled"
|
||||
confidence = 0.55 + (seed % 40) / 100
|
||||
reasoning = (
|
||||
f"Selected rule '{predicted_type}' using deterministic seed {seed}."
|
||||
)
|
||||
classification = FileClassification(
|
||||
id=self._server.new_id("classification"),
|
||||
file_id=stored.file.id,
|
||||
classify_job_id=job_id,
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
result=ClassificationResult(
|
||||
type=predicted_type,
|
||||
confidence=min(confidence, 0.95),
|
||||
reasoning=reasoning,
|
||||
),
|
||||
)
|
||||
items.append(classification)
|
||||
return ClassifyJobResults(
|
||||
items=items, next_page_token=None, total_size=len(items)
|
||||
)
|
||||
@@ -1,673 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from llama_cloud.types import (
|
||||
ExtractAgent,
|
||||
ExtractConfig,
|
||||
ExtractJob,
|
||||
ExtractRun,
|
||||
ExtractState,
|
||||
File as CloudFile,
|
||||
PaginatedExtractRunsResponse,
|
||||
StatusEnum,
|
||||
)
|
||||
|
||||
from ._deterministic import (
|
||||
combined_seed,
|
||||
generate_data_from_schema,
|
||||
hash_schema,
|
||||
utcnow,
|
||||
)
|
||||
from ._deterministic import fingerprint_file
|
||||
from .files import FakeFilesNamespace, StoredFile
|
||||
from .matchers import RequestContext, RequestMatcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractRunStub:
|
||||
matcher: Optional[RequestMatcher]
|
||||
data: Optional[Any]
|
||||
status: Optional[str]
|
||||
metadata: Optional[Dict[str, Any]]
|
||||
error: Optional[str]
|
||||
job_status: Optional[str]
|
||||
once: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentRunStub:
|
||||
agent_id: str
|
||||
matcher: Optional[RequestMatcher]
|
||||
job_status: Optional[str]
|
||||
run_status: Optional[str]
|
||||
error: Optional[str]
|
||||
once: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredRun:
|
||||
job: ExtractJob
|
||||
run: ExtractRun
|
||||
|
||||
|
||||
class FakeExtractNamespace:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
server: "FakeLlamaCloudServer",
|
||||
files: FakeFilesNamespace,
|
||||
) -> None:
|
||||
self._server = server
|
||||
self._files = files
|
||||
self._jobs: Dict[str, StoredRun] = {}
|
||||
self._runs: Dict[str, ExtractRun] = {}
|
||||
self._agents: Dict[str, ExtractAgent] = {}
|
||||
self._agents_by_name: Dict[str, str] = {}
|
||||
self._run_stubs: List[ExtractRunStub] = []
|
||||
self._agent_run_stubs: List[AgentRunStub] = []
|
||||
self.routes: Dict[str, Any] = {}
|
||||
|
||||
# Public APIs ----------------------------------------------------
|
||||
def stub_run(
|
||||
self,
|
||||
matcher: Optional[RequestMatcher],
|
||||
*,
|
||||
data: Optional[Any] = None,
|
||||
status: Optional[str] = None,
|
||||
job_status: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
error: Optional[str] = None,
|
||||
once: bool = True,
|
||||
) -> None:
|
||||
self._run_stubs.append(
|
||||
ExtractRunStub(
|
||||
matcher=matcher,
|
||||
data=data,
|
||||
status=status,
|
||||
metadata=metadata,
|
||||
error=error,
|
||||
job_status=job_status,
|
||||
once=once,
|
||||
)
|
||||
)
|
||||
|
||||
def stub_agent_run(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
matcher: Optional[RequestMatcher],
|
||||
job_status: Optional[str] = None,
|
||||
run_status: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
once: bool = True,
|
||||
) -> None:
|
||||
self._agent_run_stubs.append(
|
||||
AgentRunStub(
|
||||
agent_id=agent_id,
|
||||
matcher=matcher,
|
||||
job_status=job_status,
|
||||
run_status=run_status,
|
||||
error=error,
|
||||
once=once,
|
||||
)
|
||||
)
|
||||
|
||||
# Route registration ---------------------------------------------
|
||||
def register(self) -> None:
|
||||
server = self._server
|
||||
route = server.add_route(
|
||||
"POST",
|
||||
"/api/v1/extraction/run",
|
||||
self._handle_stateless_run,
|
||||
namespace="extract",
|
||||
alias="extract_run",
|
||||
)
|
||||
self.routes["stateless_run"] = route
|
||||
self.stateless_run = route
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/extraction/extraction-agents",
|
||||
self._handle_create_agent,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"PATCH",
|
||||
"/api/v1/extraction/extraction-agents/{agent_id}",
|
||||
self._handle_update_agent,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/extraction-agents/{agent_id}",
|
||||
self._handle_get_agent,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/extraction-agents/by-name/{name}",
|
||||
self._handle_get_agent_by_name,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/extraction-agents",
|
||||
self._handle_list_agents,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/extraction-agents/default",
|
||||
self._handle_get_default_agent,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"DELETE",
|
||||
"/api/v1/extraction/extraction-agents/{agent_id}",
|
||||
self._handle_delete_agent,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/extraction/extraction-agents/schema/validation",
|
||||
self._handle_validate_schema,
|
||||
namespace="extract",
|
||||
)
|
||||
agent_job_route = server.add_route(
|
||||
"POST",
|
||||
"/api/v1/extraction/jobs",
|
||||
self._handle_agent_job,
|
||||
namespace="extract",
|
||||
alias="agent_job",
|
||||
)
|
||||
self.routes["agent_job"] = agent_job_route
|
||||
self.agent_job = agent_job_route
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/v1/extraction/jobs/batch",
|
||||
self._handle_agent_job_batch,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/jobs",
|
||||
self._handle_list_jobs,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/jobs/{job_id}",
|
||||
self._handle_get_job,
|
||||
namespace="extract",
|
||||
)
|
||||
agent_run_route = server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/runs/by-job/{job_id}",
|
||||
self._handle_get_run_by_job,
|
||||
namespace="extract",
|
||||
alias="agent_run",
|
||||
)
|
||||
self.routes["agent_run"] = agent_run_route
|
||||
self.agent_run = agent_run_route
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/runs/{run_id}",
|
||||
self._handle_get_run,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"DELETE",
|
||||
"/api/v1/extraction/runs/{run_id}",
|
||||
self._handle_delete_run,
|
||||
namespace="extract",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/extraction/runs",
|
||||
self._handle_list_runs,
|
||||
namespace="extract",
|
||||
)
|
||||
|
||||
# Handlers -------------------------------------------------------
|
||||
def _handle_stateless_run(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request)
|
||||
config = ExtractConfig.parse_obj(payload["config"])
|
||||
data_schema = payload["data_schema"]
|
||||
schema_hash = hash_schema(data_schema)
|
||||
|
||||
file_info = self._extract_file_info(payload, request)
|
||||
agent = self._build_ephemeral_agent(
|
||||
config, data_schema, file_info.file.project_id
|
||||
)
|
||||
|
||||
context = RequestContext(
|
||||
request=request,
|
||||
json=payload,
|
||||
file_id=file_info.file.id,
|
||||
filename=file_info.file.name,
|
||||
file_sha256=file_info.sha256,
|
||||
schema_hash=schema_hash,
|
||||
project_id=file_info.file.project_id,
|
||||
organization_id=self._server.default_organization_id,
|
||||
)
|
||||
|
||||
stub = self._pop_stub(self._run_stubs, context)
|
||||
job_status = StatusEnum.SUCCESS
|
||||
run_status = ExtractState.SUCCESS
|
||||
metadata = {"deterministic": {"value": True}}
|
||||
error = None
|
||||
run_data = self._generate_run_data(data_schema, file_info.sha256)
|
||||
|
||||
if stub:
|
||||
if stub.job_status:
|
||||
job_status = StatusEnum(stub.job_status)
|
||||
if stub.status:
|
||||
run_status = ExtractState(stub.status)
|
||||
if stub.metadata:
|
||||
metadata = stub.metadata
|
||||
if stub.error:
|
||||
error = stub.error
|
||||
if stub.data is not None:
|
||||
if callable(stub.data):
|
||||
run_data = stub.data(payload) # type: ignore[assignment]
|
||||
else:
|
||||
run_data = stub.data
|
||||
|
||||
stored = self._create_job_and_run(
|
||||
agent=agent,
|
||||
config=config,
|
||||
data_schema=data_schema,
|
||||
file_info=file_info,
|
||||
job_status=job_status,
|
||||
run_status=run_status,
|
||||
metadata=metadata,
|
||||
data=run_data,
|
||||
error=error,
|
||||
project_id=file_info.file.project_id,
|
||||
)
|
||||
return self._server.json_response(stored.job.dict())
|
||||
|
||||
def _handle_create_agent(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request)
|
||||
name = payload["name"]
|
||||
config = ExtractConfig.parse_obj(payload["config"])
|
||||
data_schema = payload["data_schema"]
|
||||
agent_id = self._server.new_id("agent")
|
||||
agent = ExtractAgent(
|
||||
id=agent_id,
|
||||
name=name,
|
||||
config=config,
|
||||
data_schema=data_schema,
|
||||
project_id=request.url.params.get(
|
||||
"project_id", self._server.default_project_id
|
||||
),
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
custom_configuration=None,
|
||||
)
|
||||
self._agents[agent_id] = agent
|
||||
self._agents_by_name[name] = agent_id
|
||||
return self._server.json_response(agent.dict())
|
||||
|
||||
def _handle_update_agent(self, request: httpx.Request) -> httpx.Response:
|
||||
agent_id = request.url.path.split("/")[-1]
|
||||
if agent_id not in self._agents:
|
||||
return self._server.json_response(
|
||||
{"detail": "Agent not found"}, status_code=404
|
||||
)
|
||||
payload = self._server.json(request)
|
||||
agent = self._agents[agent_id]
|
||||
config = payload.get("config", agent.config)
|
||||
data_schema = payload.get("data_schema", agent.data_schema)
|
||||
updated = agent.copy(
|
||||
update={
|
||||
"config": ExtractConfig.parse_obj(config)
|
||||
if isinstance(config, dict)
|
||||
else config,
|
||||
"data_schema": data_schema,
|
||||
"updated_at": utcnow(),
|
||||
}
|
||||
)
|
||||
self._agents[agent_id] = updated
|
||||
return self._server.json_response(updated.dict())
|
||||
|
||||
def _handle_get_agent(self, request: httpx.Request) -> httpx.Response:
|
||||
agent_id = request.url.path.split("/")[-1]
|
||||
agent = self._agents.get(agent_id)
|
||||
if not agent:
|
||||
return self._server.json_response(
|
||||
{"detail": "Agent not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(agent.dict())
|
||||
|
||||
def _handle_get_agent_by_name(self, request: httpx.Request) -> httpx.Response:
|
||||
name = request.url.path.split("/")[-1]
|
||||
agent_id = self._agents_by_name.get(name)
|
||||
if not agent_id:
|
||||
return self._server.json_response(
|
||||
{"detail": "Agent not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(self._agents[agent_id].dict())
|
||||
|
||||
def _handle_list_agents(self, request: httpx.Request) -> httpx.Response:
|
||||
include_default = (
|
||||
request.url.params.get("include_default", "false").lower() == "true"
|
||||
)
|
||||
agents = list(self._agents.values())
|
||||
if include_default and not agents:
|
||||
default_agent = self._build_ephemeral_agent(
|
||||
ExtractConfig(),
|
||||
{"type": "object", "properties": {}},
|
||||
self._server.default_project_id,
|
||||
)
|
||||
agents.append(default_agent)
|
||||
return self._server.json_response([agent.dict() for agent in agents])
|
||||
|
||||
def _handle_get_default_agent(self, request: httpx.Request) -> httpx.Response:
|
||||
if self._agents:
|
||||
agent = next(iter(self._agents.values()))
|
||||
else:
|
||||
agent = self._build_ephemeral_agent(
|
||||
ExtractConfig(),
|
||||
{"type": "object", "properties": {}},
|
||||
self._server.default_project_id,
|
||||
)
|
||||
return self._server.json_response(agent.dict())
|
||||
|
||||
def _handle_delete_agent(self, request: httpx.Request) -> httpx.Response:
|
||||
agent_id = request.url.path.split("/")[-1]
|
||||
agent = self._agents.pop(agent_id, None)
|
||||
if agent:
|
||||
self._agents_by_name.pop(agent.name, None)
|
||||
return self._server.json_response({}, status_code=200)
|
||||
|
||||
def _handle_validate_schema(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request)
|
||||
return self._server.json_response({"data_schema": payload["data_schema"]})
|
||||
|
||||
def _handle_agent_job(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request)
|
||||
agent_id = payload["extraction_agent_id"]
|
||||
agent = self._agents.get(agent_id)
|
||||
if not agent:
|
||||
return self._server.json_response(
|
||||
{"detail": "Agent not found"}, status_code=404
|
||||
)
|
||||
|
||||
file_id = payload["file_id"]
|
||||
stored_file = self._files._files.get(file_id)
|
||||
if not stored_file:
|
||||
return self._server.json_response(
|
||||
{"detail": "File not found"}, status_code=404
|
||||
)
|
||||
|
||||
schema = payload.get("data_schema_override", agent.data_schema)
|
||||
config_payload = payload.get("config_override", agent.config)
|
||||
config = (
|
||||
ExtractConfig.parse_obj(config_payload)
|
||||
if isinstance(config_payload, dict)
|
||||
else config_payload
|
||||
)
|
||||
|
||||
stub = self._pop_agent_stub(
|
||||
agent_id, RequestContext(request=request, json=payload)
|
||||
)
|
||||
job_status = StatusEnum.SUCCESS
|
||||
run_status = ExtractState.SUCCESS
|
||||
error = None
|
||||
if stub:
|
||||
if stub.job_status:
|
||||
job_status = StatusEnum(stub.job_status)
|
||||
if stub.run_status:
|
||||
run_status = ExtractState(stub.run_status)
|
||||
if stub.error:
|
||||
error = stub.error
|
||||
|
||||
stored = self._create_job_and_run(
|
||||
agent=agent,
|
||||
config=config,
|
||||
data_schema=schema,
|
||||
file_info=stored_file,
|
||||
job_status=job_status,
|
||||
run_status=run_status,
|
||||
metadata={"agent": {"value": agent.id}},
|
||||
data=self._generate_run_data(schema, stored_file.sha256),
|
||||
error=error,
|
||||
project_id=agent.project_id,
|
||||
)
|
||||
return self._server.json_response(stored.job.dict())
|
||||
|
||||
def _handle_agent_job_batch(self, request: httpx.Request) -> httpx.Response:
|
||||
payload = self._server.json(request)
|
||||
file_ids = payload.get("file_ids", [])
|
||||
jobs = []
|
||||
for file_id in file_ids:
|
||||
request_body = payload.copy()
|
||||
request_body["file_id"] = file_id
|
||||
fake_request = request.copy()
|
||||
fake_request._content = self._server.encode_json(request_body)
|
||||
response = self._handle_agent_job(fake_request)
|
||||
if response.status_code != 200:
|
||||
return response
|
||||
jobs.append(response.json())
|
||||
return self._server.json_response(jobs)
|
||||
|
||||
def _handle_list_jobs(self, request: httpx.Request) -> httpx.Response:
|
||||
agent_id = request.url.params.get("extraction_agent_id")
|
||||
items = []
|
||||
for stored in self._jobs.values():
|
||||
if agent_id and stored.job.extraction_agent.id != agent_id:
|
||||
continue
|
||||
items.append(stored.job.dict())
|
||||
return self._server.json_response(items)
|
||||
|
||||
def _handle_get_job(self, request: httpx.Request) -> httpx.Response:
|
||||
job_id = request.url.path.split("/")[-1]
|
||||
stored = self._jobs.get(job_id)
|
||||
if not stored:
|
||||
return self._server.json_response(
|
||||
{"detail": "Job not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(stored.job.dict())
|
||||
|
||||
def _handle_get_run_by_job(self, request: httpx.Request) -> httpx.Response:
|
||||
job_id = request.url.path.split("/")[-1]
|
||||
stored = self._jobs.get(job_id)
|
||||
if not stored:
|
||||
return self._server.json_response(
|
||||
{"detail": "Run not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(stored.run.dict())
|
||||
|
||||
def _handle_get_run(self, request: httpx.Request) -> httpx.Response:
|
||||
run_id = request.url.path.split("/")[-1]
|
||||
run = self._runs.get(run_id)
|
||||
if not run:
|
||||
return self._server.json_response(
|
||||
{"detail": "Run not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(run.dict())
|
||||
|
||||
def _handle_delete_run(self, request: httpx.Request) -> httpx.Response:
|
||||
run_id = request.url.path.split("/")[-1]
|
||||
self._runs.pop(run_id, None)
|
||||
to_delete = [
|
||||
job_id for job_id, stored in self._jobs.items() if stored.run.id == run_id
|
||||
]
|
||||
for job_id in to_delete:
|
||||
self._jobs.pop(job_id, None)
|
||||
return self._server.json_response({}, status_code=200)
|
||||
|
||||
def _handle_list_runs(self, request: httpx.Request) -> httpx.Response:
|
||||
agent_id = request.url.params.get("extraction_agent_id")
|
||||
skip = int(request.url.params.get("skip", "0"))
|
||||
limit = int(request.url.params.get("limit", "50"))
|
||||
filtered = [
|
||||
stored.run
|
||||
for stored in self._jobs.values()
|
||||
if not agent_id or stored.job.extraction_agent.id == agent_id
|
||||
]
|
||||
page = filtered[skip : skip + limit]
|
||||
response = PaginatedExtractRunsResponse(
|
||||
items=page,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
total=len(filtered),
|
||||
)
|
||||
return self._server.json_response(response.dict())
|
||||
|
||||
# Internal helpers -----------------------------------------------
|
||||
def _extract_file_info(
|
||||
self, payload: Dict[str, Any], request: httpx.Request
|
||||
) -> StoredFile:
|
||||
if "file_id" in payload:
|
||||
file_id = payload["file_id"]
|
||||
stored = self._files.get(file_id)
|
||||
if not stored:
|
||||
raise ValueError("file_id not found in fake store")
|
||||
return stored
|
||||
if "file" in payload:
|
||||
content, filename = self._files.decode_file_data(payload)
|
||||
file_id = self._server.new_id("file")
|
||||
stored = StoredFile(
|
||||
file=CloudFile(
|
||||
id=file_id,
|
||||
name=filename or f"inline-{file_id}",
|
||||
project_id=request.url.params.get(
|
||||
"project_id", self._server.default_project_id
|
||||
),
|
||||
external_file_id=None,
|
||||
file_size=len(content),
|
||||
file_type=None,
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
data_source_id=None,
|
||||
permission_info=None,
|
||||
resource_info=None,
|
||||
last_modified_at=utcnow(),
|
||||
),
|
||||
content=content,
|
||||
sha256=fingerprint_file(content, filename),
|
||||
)
|
||||
return stored
|
||||
if "text" in payload:
|
||||
text_bytes = payload["text"].encode("utf-8")
|
||||
file_id = self._server.new_id("file")
|
||||
stored = StoredFile(
|
||||
file=CloudFile(
|
||||
id=file_id,
|
||||
name=f"text-{file_id}.txt",
|
||||
project_id=self._server.default_project_id,
|
||||
external_file_id=None,
|
||||
file_size=len(text_bytes),
|
||||
file_type="text/plain",
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
data_source_id=None,
|
||||
permission_info=None,
|
||||
resource_info=None,
|
||||
last_modified_at=utcnow(),
|
||||
),
|
||||
content=text_bytes,
|
||||
sha256=fingerprint_file(text_bytes, None),
|
||||
)
|
||||
return stored
|
||||
raise ValueError("file payload missing")
|
||||
|
||||
def _build_ephemeral_agent(
|
||||
self,
|
||||
config: ExtractConfig,
|
||||
data_schema: Dict[str, Any],
|
||||
project_id: str,
|
||||
) -> ExtractAgent:
|
||||
return ExtractAgent(
|
||||
id=self._server.new_id("agent"),
|
||||
name="stateless-agent",
|
||||
config=config,
|
||||
data_schema=data_schema,
|
||||
project_id=project_id,
|
||||
created_at=utcnow(),
|
||||
updated_at=utcnow(),
|
||||
custom_configuration=None,
|
||||
)
|
||||
|
||||
def _generate_run_data(self, schema: Dict[str, Any], file_hash: str) -> Any:
|
||||
seed = combined_seed(file_hash, hash_schema(schema))
|
||||
return generate_data_from_schema(schema, seed)
|
||||
|
||||
def _create_job_and_run(
|
||||
self,
|
||||
*,
|
||||
agent: ExtractAgent,
|
||||
config: ExtractConfig,
|
||||
data_schema: Dict[str, Any],
|
||||
file_info: StoredFile,
|
||||
job_status: StatusEnum,
|
||||
run_status: ExtractState,
|
||||
metadata: Dict[str, Any],
|
||||
data: Any,
|
||||
error: Optional[str],
|
||||
project_id: str,
|
||||
) -> StoredRun:
|
||||
job_id = self._server.new_id("job")
|
||||
run_id = self._server.new_id("run")
|
||||
now = utcnow()
|
||||
|
||||
job = ExtractJob(
|
||||
id=job_id,
|
||||
file=file_info.file,
|
||||
extraction_agent=agent,
|
||||
status=job_status,
|
||||
error=error,
|
||||
)
|
||||
run = ExtractRun(
|
||||
id=run_id,
|
||||
job_id=job_id,
|
||||
file=file_info.file,
|
||||
extraction_agent_id=agent.id,
|
||||
status=run_status,
|
||||
config=config,
|
||||
data_schema=data_schema,
|
||||
data=data,
|
||||
extraction_metadata=metadata,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
from_ui=False,
|
||||
error=error,
|
||||
project_id=project_id,
|
||||
)
|
||||
stored = StoredRun(job=job, run=run)
|
||||
self._jobs[job_id] = stored
|
||||
self._runs[run_id] = run
|
||||
return stored
|
||||
|
||||
def _pop_stub(
|
||||
self,
|
||||
stubs: List[ExtractRunStub],
|
||||
context: RequestContext,
|
||||
) -> Optional[ExtractRunStub]:
|
||||
for index, stub in enumerate(list(stubs)):
|
||||
if context.matches(stub.matcher):
|
||||
if stub.once:
|
||||
stubs.pop(index)
|
||||
return stub
|
||||
return None
|
||||
|
||||
def _pop_agent_stub(
|
||||
self,
|
||||
agent_id: str,
|
||||
context: RequestContext,
|
||||
) -> Optional[AgentRunStub]:
|
||||
for index, stub in enumerate(list(self._agent_run_stubs)):
|
||||
if stub.agent_id != agent_id:
|
||||
continue
|
||||
if context.matches(stub.matcher):
|
||||
if stub.once:
|
||||
self._agent_run_stubs.pop(index)
|
||||
return stub
|
||||
return None
|
||||
@@ -1,324 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
from llama_cloud.types import File as CloudFile
|
||||
from llama_cloud.types import FileIdPresignedUrl, PresignedUrl
|
||||
|
||||
from ._deterministic import fingerprint_file, hash_chunks, utcnow
|
||||
from .matchers import RequestContext, RequestMatcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredFile:
|
||||
file: CloudFile
|
||||
content: bytes
|
||||
sha256: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingUpload:
|
||||
file_id: str
|
||||
filename: str
|
||||
project_id: str
|
||||
organization_id: str
|
||||
external_file_id: Optional[str]
|
||||
expected_size: Optional[int]
|
||||
|
||||
|
||||
class FakeFilesNamespace:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
server: "FakeLlamaCloudServer",
|
||||
upload_base_url: str,
|
||||
download_base_url: str,
|
||||
) -> None:
|
||||
self._server = server
|
||||
self._upload_base_url = upload_base_url.rstrip("/")
|
||||
self._download_base_url = download_base_url.rstrip("/")
|
||||
self._files: Dict[str, StoredFile] = {}
|
||||
self._pending: Dict[str, PendingUpload] = {}
|
||||
self._upload_stubs: List[
|
||||
tuple[RequestMatcher | None, int, Dict[str, Any], bool]
|
||||
] = []
|
||||
self.routes: Dict[str, respx.Route] = {}
|
||||
|
||||
# Public helpers -------------------------------------------------
|
||||
def preload(self, *, path: str | Path, filename: Optional[str] = None) -> str:
|
||||
path = Path(path)
|
||||
content = path.read_bytes()
|
||||
file_id = self._server.new_id("file")
|
||||
name = filename or path.name
|
||||
stored = self._build_file(
|
||||
file_id=file_id,
|
||||
name=name,
|
||||
project_id=self._server.default_project_id,
|
||||
organization_id=self._server.default_organization_id,
|
||||
content=content,
|
||||
external_file_id=None,
|
||||
)
|
||||
self._files[file_id] = stored
|
||||
return file_id
|
||||
|
||||
def read(self, file_id: str) -> bytes:
|
||||
return self._files[file_id].content
|
||||
|
||||
def get(self, file_id: str) -> Optional[StoredFile]:
|
||||
return self._files.get(file_id)
|
||||
|
||||
def stub_upload(
|
||||
self,
|
||||
matcher: Optional[RequestMatcher],
|
||||
*,
|
||||
status_code: int = 413,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
once: bool = True,
|
||||
) -> None:
|
||||
body = json_body or {"detail": "upload rejected by fake server"}
|
||||
self._upload_stubs.append((matcher, status_code, body, once))
|
||||
|
||||
# Route registration ---------------------------------------------
|
||||
def register(self) -> None:
|
||||
server = self._server
|
||||
server.add_route(
|
||||
"PUT",
|
||||
"/api/v1/files",
|
||||
self._handle_generate_presigned_url,
|
||||
namespace="files",
|
||||
alias="generate_presigned_url",
|
||||
)
|
||||
upload_route = server.add_route(
|
||||
"POST",
|
||||
"/api/v1/files",
|
||||
self._handle_direct_upload,
|
||||
namespace="files",
|
||||
alias="upload",
|
||||
)
|
||||
self.routes["upload"] = upload_route
|
||||
get_route = server.add_route(
|
||||
"GET",
|
||||
"/api/v1/files/{file_id}",
|
||||
self._handle_get_metadata,
|
||||
namespace="files",
|
||||
alias="get",
|
||||
)
|
||||
self.routes["get"] = get_route
|
||||
server.add_route(
|
||||
"DELETE",
|
||||
"/api/v1/files/{file_id}",
|
||||
self._handle_delete,
|
||||
namespace="files",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/v1/files/{file_id}/content",
|
||||
self._handle_read_content,
|
||||
namespace="files",
|
||||
alias="read_content",
|
||||
)
|
||||
server.add_route(
|
||||
"PUT",
|
||||
"/upload/{file_id}",
|
||||
self._handle_presigned_upload,
|
||||
namespace="files",
|
||||
base_urls=[self._upload_base_url],
|
||||
alias="presigned_upload",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/files/{file_id}",
|
||||
self._handle_presigned_download,
|
||||
namespace="files",
|
||||
base_urls=[self._download_base_url],
|
||||
alias="download",
|
||||
)
|
||||
|
||||
# Handlers -------------------------------------------------------
|
||||
def _handle_generate_presigned_url(self, request: httpx.Request) -> httpx.Response:
|
||||
data = self._server.json(request)
|
||||
now = utcnow()
|
||||
file_id = self._server.new_id("file")
|
||||
name = data.get("name") or f"upload-{file_id}.bin"
|
||||
pending = PendingUpload(
|
||||
file_id=file_id,
|
||||
filename=name,
|
||||
project_id=request.url.params.get(
|
||||
"project_id", self._server.default_project_id
|
||||
),
|
||||
organization_id=request.url.params.get(
|
||||
"organization_id", self._server.default_organization_id
|
||||
),
|
||||
external_file_id=data.get("external_file_id"),
|
||||
expected_size=data.get("file_size"),
|
||||
)
|
||||
self._pending[file_id] = pending
|
||||
presigned = FileIdPresignedUrl(
|
||||
file_id=file_id,
|
||||
url=f"{self._upload_base_url}/upload/{file_id}",
|
||||
expires_at=now,
|
||||
form_fields=None,
|
||||
)
|
||||
return self._server.json_response(presigned.dict())
|
||||
|
||||
def _handle_direct_upload(self, request: httpx.Request) -> httpx.Response:
|
||||
file_bytes, filename = self._extract_multipart_file(request)
|
||||
file_id = self._server.new_id("file")
|
||||
stored = self._build_file(
|
||||
file_id=file_id,
|
||||
name=filename or f"upload-{file_id}.bin",
|
||||
project_id=request.url.params.get(
|
||||
"project_id", self._server.default_project_id
|
||||
),
|
||||
organization_id=request.url.params.get(
|
||||
"organization_id", self._server.default_organization_id
|
||||
),
|
||||
content=file_bytes,
|
||||
external_file_id=request.url.params.get("external_file_id"),
|
||||
)
|
||||
self._files[file_id] = stored
|
||||
return self._server.json_response(stored.file.dict())
|
||||
|
||||
def _handle_get_metadata(self, request: httpx.Request) -> httpx.Response:
|
||||
file_id = request.url.path.split("/")[-1]
|
||||
if file_id not in self._files:
|
||||
return self._server.json_response(
|
||||
{"detail": "File not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(self._files[file_id].file.dict())
|
||||
|
||||
def _handle_delete(self, request: httpx.Request) -> httpx.Response:
|
||||
file_id = request.url.path.split("/")[-1]
|
||||
self._files.pop(file_id, None)
|
||||
self._pending.pop(file_id, None)
|
||||
return self._server.json_response({}, status_code=200)
|
||||
|
||||
def _handle_read_content(self, request: httpx.Request) -> httpx.Response:
|
||||
file_id = request.url.path.split("/")[-2]
|
||||
if file_id not in self._files:
|
||||
return self._server.json_response(
|
||||
{"detail": "File not found"}, status_code=404
|
||||
)
|
||||
presigned = PresignedUrl(
|
||||
url=f"{self._download_base_url}/files/{file_id}?{urlencode({'token': 'fake'})}",
|
||||
expires_at=utcnow(),
|
||||
form_fields=None,
|
||||
)
|
||||
return self._server.json_response(presigned.dict())
|
||||
|
||||
def _handle_presigned_upload(self, request: httpx.Request) -> httpx.Response:
|
||||
file_id = request.url.path.split("/")[-1]
|
||||
pending = self._pending.get(file_id)
|
||||
|
||||
context = RequestContext(
|
||||
request=request,
|
||||
json=None,
|
||||
file_id=file_id,
|
||||
filename=pending.filename if pending else None,
|
||||
file_sha256=hash_chunks([request.content]),
|
||||
)
|
||||
|
||||
for index, (matcher, status, body, once) in enumerate(list(self._upload_stubs)):
|
||||
if context.matches(matcher):
|
||||
if once:
|
||||
self._upload_stubs.pop(index)
|
||||
return self._server.json_response(body, status_code=status)
|
||||
|
||||
if pending is None:
|
||||
return self._server.json_response(
|
||||
{"detail": "Unknown file"}, status_code=404
|
||||
)
|
||||
|
||||
stored = self._build_file(
|
||||
file_id=file_id,
|
||||
name=pending.filename,
|
||||
project_id=pending.project_id,
|
||||
organization_id=pending.organization_id,
|
||||
content=request.content,
|
||||
external_file_id=pending.external_file_id,
|
||||
)
|
||||
self._files[file_id] = stored
|
||||
self._pending.pop(file_id, None)
|
||||
return httpx.Response(204)
|
||||
|
||||
def _handle_presigned_download(self, request: httpx.Request) -> httpx.Response:
|
||||
file_id = request.url.path.split("/")[-1]
|
||||
stored = self._files.get(file_id)
|
||||
if not stored:
|
||||
return httpx.Response(404, json={"detail": "File not found"})
|
||||
return httpx.Response(200, content=stored.content)
|
||||
|
||||
# Internal helpers -----------------------------------------------
|
||||
def _build_file(
|
||||
self,
|
||||
*,
|
||||
file_id: str,
|
||||
name: str,
|
||||
project_id: str,
|
||||
organization_id: str,
|
||||
content: bytes,
|
||||
external_file_id: Optional[str],
|
||||
) -> StoredFile:
|
||||
sha256 = fingerprint_file(content, name)
|
||||
now = utcnow()
|
||||
cloud_file = CloudFile(
|
||||
id=file_id,
|
||||
name=name,
|
||||
project_id=project_id,
|
||||
external_file_id=external_file_id,
|
||||
file_size=len(content),
|
||||
file_type=Path(name).suffix or "application/octet-stream",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
data_source_id=None,
|
||||
permission_info=None,
|
||||
resource_info=None,
|
||||
last_modified_at=now,
|
||||
)
|
||||
return StoredFile(file=cloud_file, content=content, sha256=sha256)
|
||||
|
||||
def _extract_multipart_file(
|
||||
self, request: httpx.Request
|
||||
) -> tuple[bytes, Optional[str]]:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "multipart/form-data" not in content_type:
|
||||
raise ValueError("Expected multipart upload")
|
||||
|
||||
boundary = content_type.split("boundary=")[-1]
|
||||
boundary_bytes = boundary.encode("utf-8")
|
||||
body = request.content
|
||||
delimiter = b"--" + boundary_bytes
|
||||
parts = [
|
||||
part
|
||||
for part in body.split(delimiter)
|
||||
if part.strip(b"\r\n") and part.strip(b"\r\n") != b"--"
|
||||
]
|
||||
for part in parts:
|
||||
headers, _, payload = part.partition(b"\r\n\r\n")
|
||||
header_text = headers.decode("utf-8", errors="ignore")
|
||||
if 'name="upload_file"' in header_text or 'name="file"' in header_text:
|
||||
filename = None
|
||||
if "filename=" in header_text:
|
||||
filename = (
|
||||
header_text.split("filename=")[-1].strip().strip('"').strip("'")
|
||||
)
|
||||
return payload.rstrip(b"\r\n"), filename
|
||||
raise ValueError("upload file part not found")
|
||||
|
||||
def decode_file_data(self, data: Dict[str, Any]) -> tuple[bytes, Optional[str]]:
|
||||
if "file" not in data:
|
||||
raise ValueError("file payload missing")
|
||||
file_payload = data["file"]
|
||||
encoded = file_payload["data"]
|
||||
content = base64.b64decode(encoded)
|
||||
filename = file_payload.get("filename")
|
||||
return content, filename
|
||||
@@ -1,100 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
MatcherPredicate = Callable[[httpx.Request], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMatcher:
|
||||
filename: Optional[str] = None
|
||||
sha256: Optional[str] = None
|
||||
file_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemaMatcher:
|
||||
model: Optional[type] = None
|
||||
schema_hash: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestMatcher:
|
||||
file: Optional[FileMatcher | MatcherPredicate] = None
|
||||
schema: Optional[SchemaMatcher] = None
|
||||
agent_id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
organization_id: Optional[str] = None
|
||||
predicate: Optional[MatcherPredicate] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestContext:
|
||||
request: httpx.Request
|
||||
json: Optional[dict[str, Any]]
|
||||
file_id: Optional[str] = None
|
||||
filename: Optional[str] = None
|
||||
file_sha256: Optional[str] = None
|
||||
schema_hash: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
organization_id: Optional[str] = None
|
||||
|
||||
def matches(self, matcher: Optional[RequestMatcher]) -> bool:
|
||||
if matcher is None:
|
||||
return True
|
||||
|
||||
if matcher.project_id and matcher.project_id != self.project_id:
|
||||
return False
|
||||
|
||||
if matcher.organization_id and matcher.organization_id != self.organization_id:
|
||||
return False
|
||||
|
||||
if matcher.agent_id and matcher.agent_id != self.agent_id:
|
||||
return False
|
||||
|
||||
if matcher.file:
|
||||
if isinstance(matcher.file, FileMatcher):
|
||||
if matcher.file.filename and matcher.file.filename != self.filename:
|
||||
return False
|
||||
if matcher.file.file_id and matcher.file.file_id != self.file_id:
|
||||
return False
|
||||
if matcher.file.sha256 and matcher.file.sha256 != self.file_sha256:
|
||||
return False
|
||||
else:
|
||||
if not matcher.file(self.request):
|
||||
return False
|
||||
|
||||
if matcher.schema:
|
||||
if (
|
||||
matcher.schema.schema_hash
|
||||
and matcher.schema.schema_hash != self.schema_hash
|
||||
):
|
||||
return False
|
||||
if matcher.schema.model and matcher.schema.schema_hash:
|
||||
return matcher.schema.schema_hash == self.schema_hash
|
||||
if matcher.schema.model and matcher.schema.schema_hash is None:
|
||||
expected = _schema_hash_from_model(matcher.schema.model)
|
||||
return expected == self.schema_hash
|
||||
|
||||
if matcher.predicate and not matcher.predicate(self.request):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _schema_hash_from_model(model: type) -> Optional[str]:
|
||||
if hasattr(model, "model_json_schema"):
|
||||
schema = model.model_json_schema()
|
||||
elif hasattr(model, "schema"):
|
||||
schema = model.schema() # type: ignore[attr-defined]
|
||||
else:
|
||||
return None
|
||||
|
||||
from ._deterministic import hash_schema
|
||||
|
||||
return hash_schema(schema)
|
||||
@@ -1,153 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from ._deterministic import generate_text_blob, hash_schema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseJobRecord:
|
||||
job_id: str
|
||||
file_name: str
|
||||
status: str
|
||||
result: Dict[str, Any]
|
||||
content: bytes
|
||||
|
||||
|
||||
class FakeParseNamespace:
|
||||
def __init__(self, *, server: "FakeLlamaCloudServer") -> None:
|
||||
self._server = server
|
||||
self._jobs: Dict[str, ParseJobRecord] = {}
|
||||
self.routes: Dict[str, Any] = {}
|
||||
|
||||
def register(self) -> None:
|
||||
server = self._server
|
||||
server.add_route(
|
||||
"POST",
|
||||
"/api/parsing/upload",
|
||||
self._handle_upload,
|
||||
namespace="parse",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/parsing/job/{job_id}",
|
||||
self._handle_job_status,
|
||||
namespace="parse",
|
||||
)
|
||||
server.add_route(
|
||||
"GET",
|
||||
"/api/parsing/job/{job_id}/result/{result_type}",
|
||||
self._handle_job_result,
|
||||
namespace="parse",
|
||||
)
|
||||
|
||||
def _handle_upload(self, request: httpx.Request) -> httpx.Response:
|
||||
file_bytes, filename, form_data = self._split_multipart(request)
|
||||
job_id = self._server.new_id("parse-job")
|
||||
seed_hash = hash_schema({"filename": filename, "form": form_data})
|
||||
seed = int(seed_hash[:16], 16)
|
||||
page_text = generate_text_blob(seed, sentences=3)
|
||||
pages: list[Dict[str, Any]] = [
|
||||
{
|
||||
"page": index + 1,
|
||||
"text": f"{page_text} (page {index + 1})",
|
||||
"md": f"{page_text} (page {index + 1})",
|
||||
"images": [],
|
||||
"charts": [],
|
||||
"tables": [],
|
||||
"layout": [],
|
||||
"items": [],
|
||||
"status": "SUCCESS",
|
||||
"links": [],
|
||||
"width": 8.5,
|
||||
"height": 11.0,
|
||||
"parsingMode": "deterministic",
|
||||
"structuredData": {},
|
||||
"noStructuredContent": False,
|
||||
"noTextContent": False,
|
||||
"isAudioTranscript": False,
|
||||
"durationInSeconds": None,
|
||||
"slideSpeakerNotes": None,
|
||||
}
|
||||
for index in range(1)
|
||||
]
|
||||
result = {
|
||||
"job_id": job_id,
|
||||
"status": "SUCCESS",
|
||||
"file_name": filename,
|
||||
"is_done": True,
|
||||
"pages": pages,
|
||||
"job_metadata": {"job_pages": len(pages)},
|
||||
"text": "\n\n".join(str(page["text"]) for page in pages),
|
||||
"markdown": "\n\n".join(str(page["md"]) for page in pages),
|
||||
"json": {"pages": pages},
|
||||
}
|
||||
record = ParseJobRecord(
|
||||
job_id=job_id,
|
||||
file_name=filename,
|
||||
status="SUCCESS",
|
||||
result=result,
|
||||
content=file_bytes,
|
||||
)
|
||||
self._jobs[job_id] = record
|
||||
return self._server.json_response({"id": job_id})
|
||||
|
||||
def _handle_job_status(self, request: httpx.Request) -> httpx.Response:
|
||||
job_id = request.url.path.split("/")[-1]
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return self._server.json_response(
|
||||
{"detail": "Job not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response({"id": job_id, "status": job.status})
|
||||
|
||||
def _handle_job_result(self, request: httpx.Request) -> httpx.Response:
|
||||
job_id = request.url.path.split("/")[-3]
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return self._server.json_response(
|
||||
{"detail": "Result not found"}, status_code=404
|
||||
)
|
||||
return self._server.json_response(job.result)
|
||||
|
||||
def _split_multipart(
|
||||
self, request: httpx.Request
|
||||
) -> tuple[bytes, str, Dict[str, str]]:
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "multipart/form-data" not in content_type:
|
||||
raise ValueError("Expected multipart form data for parse upload")
|
||||
boundary = content_type.split("boundary=")[-1]
|
||||
delimiter = f"--{boundary}".encode()
|
||||
closing = f"--{boundary}--".encode()
|
||||
parts = []
|
||||
body = request.content
|
||||
for chunk in body.split(delimiter):
|
||||
chunk = chunk.strip()
|
||||
if not chunk or chunk == closing:
|
||||
continue
|
||||
parts.append(chunk)
|
||||
|
||||
file_bytes = b""
|
||||
filename = "upload.pdf"
|
||||
form_data: Dict[str, str] = {}
|
||||
for part in parts:
|
||||
header_blob, _, payload = part.partition(b"\r\n\r\n")
|
||||
payload = payload.rstrip(b"\r\n")
|
||||
header_text = header_blob.decode("utf-8", errors="ignore")
|
||||
if "filename=" in header_text:
|
||||
filename = (
|
||||
header_text.split("filename=")[-1].strip().strip('"').strip("'")
|
||||
)
|
||||
file_bytes = payload
|
||||
else:
|
||||
name = header_text.split('name="')[-1].split('"')[0].strip()
|
||||
form_data[name] = payload.decode("utf-8", errors="ignore")
|
||||
if not file_bytes:
|
||||
raise ValueError("File part missing from multipart payload")
|
||||
return file_bytes, filename, form_data
|
||||
@@ -1,173 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, Optional, Sequence
|
||||
|
||||
import httpx
|
||||
import respx
|
||||
|
||||
from .classify import FakeClassifyNamespace
|
||||
from .extract import FakeExtractNamespace
|
||||
from .files import FakeFilesNamespace
|
||||
from .parse import FakeParseNamespace
|
||||
from .agent_data import FakeAgentDataNamespace
|
||||
|
||||
Handler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
class FakeLlamaCloudServer:
|
||||
DEFAULT_BASE_URL = "https://api.cloud.llamaindex.ai"
|
||||
DEFAULT_UPLOAD_BASE = "https://uploads.fake-llama.test"
|
||||
DEFAULT_DOWNLOAD_BASE = "https://downloads.fake-llama.test"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_urls: Optional[Sequence[str]] = None,
|
||||
namespaces: Optional[Sequence[str]] = None,
|
||||
upload_base_url: Optional[str] = None,
|
||||
download_base_url: Optional[str] = None,
|
||||
default_project_id: str = "proj-test",
|
||||
default_organization_id: str = "org-test",
|
||||
) -> None:
|
||||
self.base_urls = tuple(base_urls or (self.DEFAULT_BASE_URL,))
|
||||
selected = namespaces or ("files", "extract", "parse", "classify", "agent_data")
|
||||
self._namespace_names = {name.lower() for name in selected}
|
||||
self._upload_base_url = upload_base_url or self.DEFAULT_UPLOAD_BASE
|
||||
self._download_base_url = download_base_url or self.DEFAULT_DOWNLOAD_BASE
|
||||
self.default_project_id = default_project_id
|
||||
self.default_organization_id = default_organization_id
|
||||
self.router = respx.MockRouter(assert_all_called=False)
|
||||
self._installed = False
|
||||
self._registered = False
|
||||
|
||||
self.files = FakeFilesNamespace(
|
||||
server=self,
|
||||
upload_base_url=self._upload_base_url,
|
||||
download_base_url=self._download_base_url,
|
||||
)
|
||||
self.extract = FakeExtractNamespace(server=self, files=self.files)
|
||||
self.parse = FakeParseNamespace(server=self)
|
||||
self.classify = FakeClassifyNamespace(server=self, files=self.files)
|
||||
self.agent_data = FakeAgentDataNamespace(server=self)
|
||||
|
||||
# Context management ----------------------------------------------
|
||||
def install(self) -> "FakeLlamaCloudServer":
|
||||
if not self._registered:
|
||||
self._register_namespaces()
|
||||
if not self._installed:
|
||||
self.router.__enter__()
|
||||
self._installed = True
|
||||
return self
|
||||
|
||||
def uninstall(self) -> None:
|
||||
if self._installed:
|
||||
self.router.__exit__(None, None, None)
|
||||
self._installed = False
|
||||
|
||||
def __enter__(self) -> "FakeLlamaCloudServer":
|
||||
return self.install()
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
self.uninstall()
|
||||
|
||||
# Route utilities -------------------------------------------------
|
||||
def add_route(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
handler: Handler,
|
||||
*,
|
||||
namespace: str,
|
||||
alias: Optional[str] = None,
|
||||
base_urls: Optional[Sequence[str]] = None,
|
||||
) -> respx.Route:
|
||||
urls = base_urls or self.base_urls
|
||||
first_route: Optional[respx.Route] = None
|
||||
for base in urls:
|
||||
route = self._register_route(method, base, path, handler)
|
||||
if first_route is None:
|
||||
first_route = route
|
||||
if alias and first_route:
|
||||
setattr(self, alias, first_route)
|
||||
return first_route # type: ignore[return-value]
|
||||
|
||||
def _register_route(
|
||||
self,
|
||||
method: str,
|
||||
base: str,
|
||||
path: str,
|
||||
handler: Handler,
|
||||
) -> respx.Route:
|
||||
url = self._build_url(base, path)
|
||||
if "{" in path:
|
||||
regex = self._compile_regex(base, path)
|
||||
route = self.router.route(method=method, url__regex=regex)
|
||||
else:
|
||||
route = self.router.route(method=method, url=url)
|
||||
route.mock(side_effect=lambda request, func=handler: func(request))
|
||||
return route
|
||||
|
||||
def _build_url(self, base: str, path: str) -> str:
|
||||
base = base.rstrip("/")
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return f"{base}{path}"
|
||||
|
||||
def _compile_regex(self, base: str, path: str) -> re.Pattern[str]:
|
||||
escaped = re.escape(base.rstrip("/"))
|
||||
regex_path = re.sub(r"\{[^/]+\}", r"[^/]+", path)
|
||||
pattern = f"^{escaped}{regex_path}$"
|
||||
return re.compile(pattern)
|
||||
|
||||
# Helpers ---------------------------------------------------------
|
||||
def json(self, request: httpx.Request) -> Dict[str, Any]:
|
||||
if not request.content:
|
||||
return {}
|
||||
return json.loads(request.content.decode("utf-8"))
|
||||
|
||||
def encode_json(self, payload: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload).encode("utf-8")
|
||||
|
||||
def json_response(self, payload: Any, *, status_code: int = 200) -> httpx.Response:
|
||||
body = json.dumps(payload, default=self._json_default).encode("utf-8")
|
||||
headers = {"content-type": "application/json"}
|
||||
return httpx.Response(status_code=status_code, headers=headers, content=body)
|
||||
|
||||
def new_id(self, prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Internal --------------------------------------------------------
|
||||
def _json_default(self, value: Any) -> Any:
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump()
|
||||
if hasattr(value, "dict"):
|
||||
return value.dict()
|
||||
if isinstance(value, (set, frozenset)):
|
||||
return list(value)
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return value.decode("utf-8")
|
||||
if hasattr(value, "isoformat"):
|
||||
try:
|
||||
return value.isoformat() # datetime/date support
|
||||
except Exception:
|
||||
pass
|
||||
raise TypeError(f"{value!r} is not JSON serializable")
|
||||
|
||||
def _register_namespaces(self) -> None:
|
||||
if "files" in self._namespace_names:
|
||||
self.files.register()
|
||||
if "extract" in self._namespace_names:
|
||||
self.extract.register()
|
||||
if "parse" in self._namespace_names:
|
||||
self.parse.register()
|
||||
if "classify" in self._namespace_names:
|
||||
self.classify.register()
|
||||
if "agent_data" in self._namespace_names:
|
||||
self.agent_data.register()
|
||||
self._registered = True
|
||||
|
||||
|
||||
__all__ = ["FakeLlamaCloudServer"]
|
||||
@@ -1,5 +1,70 @@
|
||||
# llama_parse
|
||||
|
||||
## 0.6.90
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 19cbb25: Remove extension filter
|
||||
- Updated dependencies [19cbb25]
|
||||
- llama-cloud-services-py@0.6.90
|
||||
|
||||
## 0.6.89
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [b9b83c9]
|
||||
- llama-cloud-services-py@0.6.89
|
||||
|
||||
## 0.6.88
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [71db318]
|
||||
- llama-cloud-services-py@0.6.88
|
||||
|
||||
## 0.6.87
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [06c3c55]
|
||||
- llama-cloud-services-py@0.6.87
|
||||
|
||||
## 0.6.86
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1b7198d: Update extract to have confidence scores available in all modes
|
||||
- Updated dependencies [1b7198d]
|
||||
- llama-cloud-services-py@0.6.86
|
||||
|
||||
## 0.6.85
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ae30990]
|
||||
- llama-cloud-services-py@0.6.85
|
||||
|
||||
## 0.6.84
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [0a110de]
|
||||
- llama-cloud-services-py@0.6.84
|
||||
|
||||
## 0.6.83
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [ca78113]
|
||||
- llama-cloud-services-py@0.6.83
|
||||
|
||||
## 0.6.82
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [bfaec79]
|
||||
- llama-cloud-services-py@0.6.82
|
||||
|
||||
## 0.6.81
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -146,9 +146,9 @@ Full documentation for `SimpleDirectoryReader` can be found on the [LlamaIndex D
|
||||
|
||||
Several end-to-end indexing examples can be found in the examples folder
|
||||
|
||||
- [Getting Started](/docs/examples-py/parse/demo_basic.ipynb)
|
||||
- [Advanced RAG Example](/docs/examples-py/parse/demo_advanced.ipynb)
|
||||
- [Raw API Usage](/docs/examples-py/parse/demo_api.ipynb)
|
||||
- [Getting Started](../../examples/parse/demo_basic.ipynb)
|
||||
- [Advanced RAG Example](../../examples/parse/demo_advanced.ipynb)
|
||||
- [Raw API Usage](../../examples/parse/demo_api.ipynb)
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "llama_parse",
|
||||
"version": "0.6.81",
|
||||
"version": "0.6.90",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"private": false,
|
||||
|
||||
@@ -11,13 +11,13 @@ dev = [
|
||||
|
||||
[project]
|
||||
name = "llama-parse"
|
||||
version = "0.6.81"
|
||||
version = "0.6.90"
|
||||
description = "Parse files into RAG-Optimized formats."
|
||||
authors = [{name = "Logan Markewich", email = "logan@llamaindex.ai"}]
|
||||
requires-python = ">=3.9,<4.0"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = ["llama-cloud-services>=0.6.81"]
|
||||
dependencies = ["llama-cloud-services>=0.6.90"]
|
||||
|
||||
[project.scripts]
|
||||
llama-parse = "llama_parse.cli.main:parse"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "llama-cloud-services-py",
|
||||
"version": "0.6.81",
|
||||
"version": "0.6.90",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"scripts": {},
|
||||
|
||||
+4
-4
@@ -7,6 +7,7 @@ dev = [
|
||||
"pytest>=8.0.0,<9",
|
||||
"pytest-xdist>=3.6.1,<4",
|
||||
"pytest-asyncio",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"ipykernel>=6.29.0,<7",
|
||||
"pre-commit==3.2.0",
|
||||
"autoevals>=0.0.114,<0.0.115",
|
||||
@@ -22,7 +23,7 @@ dev = [
|
||||
|
||||
[project]
|
||||
name = "llama-cloud-services"
|
||||
version = "0.6.81"
|
||||
version = "0.6.90"
|
||||
description = "Tailored SDK clients for LlamaCloud services."
|
||||
authors = [{name = "Logan Markewich", email = "logan@runllama.ai"}]
|
||||
requires-python = ">=3.9,<4.0"
|
||||
@@ -30,15 +31,14 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = [
|
||||
"llama-index-core>=0.12.0",
|
||||
"llama-cloud==0.1.44",
|
||||
"llama-cloud==0.1.45",
|
||||
"pydantic>=2.8,!=2.10",
|
||||
"click>=8.1.7,<9",
|
||||
"python-dotenv>=1.0.1,<2",
|
||||
"eval-type-backport>=0.2.0,<0.3 ; python_version < '3.10'",
|
||||
"platformdirs>=4.3.7,<5",
|
||||
"tenacity>=8.5.0, <10.0",
|
||||
"packaging>=23.0",
|
||||
"respx[tests]>=0.22.0"
|
||||
"packaging>=23.0"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Testing Utils Implementation Plan
|
||||
|
||||
## Goal
|
||||
Build a `FakeLlamaCloudServer` that intercepts all SDK HTTP traffic (extract, parse, classify, files, etc.) and returns deterministic responses so offline tests behave like production, per `testing_utils_spec.md`.
|
||||
|
||||
## High-Level Phases
|
||||
1. **Router + lifecycle scaffold**
|
||||
- Implement `FakeLlamaCloudServer` with `respx.Router` that can be used as a context manager or via explicit `install()` / `uninstall()`.
|
||||
- Support multiple base URLs and namespace filtering so only selected APIs are intercepted.
|
||||
2. **State stores + deterministic generators**
|
||||
- Create in-memory stores for files, jobs, runs, parse results, and classification predictions.
|
||||
- Implement deterministic data generation seeded by (file hash, schema hash, namespace) as described in the spec.
|
||||
3. **Namespace handlers**
|
||||
- Extract: stub `/api/v1/extraction/extraction-agents/*`, `/api/v1/extraction/run`, `/api/v1/extraction/jobs*`, `/api/v1/extraction/runs/by-job/{id}`.
|
||||
- Files: stub `/api/v1/files/**` plus presigned upload/download workflows.
|
||||
- Parse: stub `/api/parsing/upload`, `/api/parsing/job/{id}`, `/api/parsing/job/{id}/result/{result_type}`.
|
||||
- Classify: stub `/api/v1/classifier/*` (job creation, polling, results).
|
||||
4. **Matcher + override system**
|
||||
- Implement `RequestMatcher`, `FileMatcher`, `SchemaMatcher`, etc., and expose helper APIs like `fake.extract.stub_run`.
|
||||
5. **Ergonomic utilities**
|
||||
- Provide helper shortcuts (`fake.extract.stateless_run`) and spy APIs (call count assertions).
|
||||
6. **Docs + tests**
|
||||
- Document usage in `testing_utils_spec.md`.
|
||||
- Add tests that demonstrate end-to-end flows using the fake.
|
||||
|
||||
## Detailed Steps & Considerations
|
||||
|
||||
### 1. Router & Lifecycle
|
||||
- Create a single `FakeLlamaCloudServer` class that holds a `respx.Router` configured for each base URL.
|
||||
- Provide `__enter__/__exit__` plus `install()/uninstall()` to attach/detach the router.
|
||||
- Complexity: need to handle both sync and async clients since `LlamaParse` uses raw `httpx.AsyncClient` instances constructed on the fly. Ensure `respx.mock(assert_all_called=False)` works for both.
|
||||
|
||||
### 2. State Management & Determinism
|
||||
- Implement a `FileStore` that tracks uploaded file bytes, metadata, generated IDs, and seeded RNG values.
|
||||
- Implement `ExtractStore`, `ParseStore`, `ClassifyStore` to track job lifecycles and generated runs.
|
||||
- Deterministic generator design:
|
||||
- Compute SHA256 of (file bytes + filename) and of normalized schema JSON.
|
||||
- Combine into a seed (e.g., `seed = sha256(file_hash + schema_hash)`).
|
||||
- Use that seed for namespace-specific RNG (extract uses schema walk, parse uses layout heuristics, classify uses label sets).
|
||||
- Complexity: schema normalization requires Pydantic `model_json_schema()` ordering; ensure we match production ordering to avoid drift.
|
||||
|
||||
### 3. Namespace Handlers
|
||||
#### Extract
|
||||
- Stub endpoints listed under `LlamaExtract` usage (`create_extraction_agent`, `run_job`, stateless run, poll job/run).
|
||||
- Mirror response bodies (`ExtractJob`, `ExtractRun`, `PaginatedExtractRunsResponse`) so the SDK’s type deserialization works.
|
||||
- Manage transitions `PENDING → SUCCESS/FAILED` with realistic timestamps.
|
||||
#### Files
|
||||
- Implement both presigned workflow and direct upload fallback:
|
||||
- `POST /api/v1/files` (or equivalent) returns a fake presigned URL (e.g., `https://fake-upload.local/{file_id}`) that our router also intercepts.
|
||||
- The subsequent `PUT` should store the bytes and mark upload complete.
|
||||
- `GET /api/v1/files/{id}` returns stored metadata; `read_file_content` returns presigned download URLs or raw bytes.
|
||||
- Complexity: need to intercept arbitrary presigned hostnames (e.g., AWS S3). Spec does not clarify if presigned URLs live on the same base; we may need to whitelist custom domains or provide a fake S3 host.
|
||||
#### Parse
|
||||
- Because `LlamaParse` manually constructs `/api/parsing/*` URLs, ensure the fake registers these exact routes against every provided base URL.
|
||||
- Store job configs, return deterministic `JobResult` payloads (text, markdown, JSON), and support partitioned jobs.
|
||||
#### Classify
|
||||
- Stub job creation/polling/responses, ensuring statuses transition according to `StatusEnum`.
|
||||
- Return deterministically chosen labels based on input payload + rules (seed derived from contents).
|
||||
|
||||
### 4. Matcher / Override System
|
||||
- Provide dataclasses from the spec (`FileMatcher`, `SchemaMatcher`, `RequestMatcher`).
|
||||
- Implement matcher evaluation order with `once=True` behavior to remove one-time overrides.
|
||||
- Expose helper APIs:
|
||||
- `fake.extract.stub_run(...)`
|
||||
- `fake.parse.stub_parse(...)`
|
||||
- `fake.classify.stub_prediction(...)`
|
||||
- `fake.files.stub_upload(...)`, etc.
|
||||
- Complexity: Need to ensure matcher evaluation can inspect raw `httpx.Request` bodies/headers for both sync and async flows without consuming the stream twice.
|
||||
|
||||
### 5. Assertions & Spies
|
||||
- Expose convenience attributes pointing to `respx.Route` objects for frequently used paths (e.g., `fake.extract.stateless_run`).
|
||||
- Provide helper methods for call counts, captured requests, etc.
|
||||
- Ensure naming stays stable to avoid brittle tests.
|
||||
|
||||
### 6. Testing Strategy
|
||||
- Add pytest fixtures to install the fake server globally for integration tests.
|
||||
- Cover scenarios:
|
||||
- Stateless extract returns deterministic payload.
|
||||
- Agent-backed extract polls job/runs.
|
||||
- Files API handles presigned upload and retrieval.
|
||||
- Parse job lifecycle for both success and failure.
|
||||
- Classification job with deterministic label output.
|
||||
- Matcher overrides injection & once-only behavior.
|
||||
- Mixed namespace configurations (e.g., intercept extract only, let parse hit real network).
|
||||
|
||||
## Extra Complexity & Spec Concerns
|
||||
- **Presigned URL scope**: Spec assumes presigned uploads can be intercepted the same way as SaaS APIs, but actual presigned URLs often point to AWS domains outside `base_urls`. Need a strategy (e.g., generate fake host names the SDK will call, or rewrite responses to use local URLs).
|
||||
- **Async client coverage**: LlamaParse builds new `httpx.AsyncClient` objects; the spec’s install/uninstall story must ensure respx patches all clients, not just the global one.
|
||||
- **Deterministic generators**: The spec outlines hashing inputs but doesn’t define exact algorithms. Without mirroring production generator logic, fixtures might diverge. We may need to document any intentional differences.
|
||||
- **Job state timelines**: The spec expects transitions (`PENDING → SUCCESS`) with realistic timestamps. Need to ensure we schedule async updates or respond with multi-step polling; otherwise, tests relying on delays may behave differently.
|
||||
- **Namespace toggling**: Clarify behavior when a namespace is disabled—should unmatched routes fall through automatically or raise? Current spec implies fall-through to real network, but that could be surprising in CI.
|
||||
- **Schema handling**: `_validate_schema` currently calls production for dict schemas. The fake must emulate validation; otherwise tests will still hit SaaS. Spec doesn’t detail validation logic, so we must decide on a simplified validator or deterministic echo.
|
||||
- **Parse partitioning**: `LlamaParse` can partition jobs and expects consistent pagination semantics. Need to ensure deterministic results respect `target_pages`, `partition_pages`, etc., or document limitations.
|
||||
|
||||
## Next Actions
|
||||
1. Prototype router + lifecycle with namespace toggles.
|
||||
2. Implement FileStore + presigned workflow since other namespaces depend on file IDs.
|
||||
3. Build deterministic generators and stores for extract/parse/classify.
|
||||
4. Layer matcher/override system on top of the stores.
|
||||
5. Write initial tests per namespace and refine spec gaps (presigned host, validation behavior).
|
||||
6. Update `testing_utils_spec.md` with any clarifications discovered above.
|
||||
@@ -1,199 +0,0 @@
|
||||
# Testing Utils Research
|
||||
|
||||
## Objective
|
||||
|
||||
The `FakeLlamaCloudServer` proposal aims to intercept raw HTTP traffic for extract, parse, classify, and files APIs so local tests behave like SaaS without per-test stubbing. The mock must respect base URLs, support context-manager or long-lived install modes, and deterministically synthesize payloads from file + schema hashes while still allowing targeted overrides.
|
||||
|
||||
```1:67:py/testing_utils_spec.md
|
||||
## Local Testing Utilities 2.0 (Spec Draft)
|
||||
|
||||
- **Everything mocked by default** …
|
||||
- **Context manager optional** …
|
||||
- **Pydantic-first ergonomics** …
|
||||
- **API-only contract** …
|
||||
```
|
||||
|
||||
## Python Hand-Written SDK Surfaces
|
||||
|
||||
These modules sit on top of the generated `llama_cloud` client and are what most application tests exercise. `FakeLlamaCloudServer` must satisfy the HTTP contracts they rely on.
|
||||
|
||||
### Extract flow (`py/llama_cloud_services/extract/extract.py`)
|
||||
|
||||
- The class imports resource types from `llama_cloud` and owns an `AsyncLlamaCloud` client shared across both stateless and agent-backed flows.
|
||||
|
||||
```17:37:py/llama_cloud_services/extract/extract.py
|
||||
from llama_cloud import (
|
||||
ExtractAgent as CloudExtractAgent,
|
||||
ExtractConfig,
|
||||
…
|
||||
)
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
```
|
||||
|
||||
- Schema validation always calls `POST /api/v1/extraction/extraction-agents/schema/validation` through `client.llama_extract.validate_extraction_schema`, so the fake must mimic that endpoint.
|
||||
|
||||
```65:82:py/llama_cloud_services/extract/extract.py
|
||||
async def _validate_schema(...):
|
||||
…
|
||||
validated_schema = await client.llama_extract.validate_extraction_schema(
|
||||
data_schema=processed_schema
|
||||
)
|
||||
```
|
||||
|
||||
- Agent creation, listing, and run management use the `llama_extract` namespace. These calls surface the same request/response bodies as the SaaS API, so tests that interact through agents expect consistent metadata (IDs, status enums, etc.).
|
||||
|
||||
```636:738:py/llama_cloud_services/extract/extract.py
|
||||
def create_agent(...):
|
||||
agent = self._run_in_thread(
|
||||
self._async_client.llama_extract.create_extraction_agent(
|
||||
project_id=self._project_id,
|
||||
…
|
||||
)
|
||||
)
|
||||
return ExtractionAgent(...)
|
||||
```
|
||||
|
||||
- Stateless extraction queues work by converting file inputs into either `file_id`, inline text, or base64 payloads and forwarding them to `POST /api/v1/extraction/run`. Deterministic responses from the fake should be keyed off `processed_schema` + whichever file representation the SDK sent.
|
||||
|
||||
```921:1018:py/llama_cloud_services/extract/extract.py
|
||||
async def queue_extraction(...):
|
||||
processed_schema = await _validate_schema(...)
|
||||
…
|
||||
job = await self._async_client.llama_extract.extract_stateless(
|
||||
project_id=self._project_id,
|
||||
organization_id=self._organization_id,
|
||||
data_schema=processed_schema,
|
||||
config=config,
|
||||
**file_args,
|
||||
)
|
||||
```
|
||||
|
||||
### File uploads (`py/llama_cloud_services/files/client.py`)
|
||||
|
||||
- All higher-level services route file uploads/downloads through `FileClient`. When `use_presigned_url` is enabled, the client first calls `POST /api/v1/files` (generate URL), then performs the PUT upload directly, then fetches metadata via `GET /api/v1/files/{id}`. The fake must intercept both the API calls and the presigned PUT hops to return consistent `file_id`s and stored bytes.
|
||||
|
||||
```63:140:py/llama_cloud_services/files/client.py
|
||||
presigned_url = await self.client.files.generate_presigned_url(...)
|
||||
upload_response = await httpx_client.put(presigned_url.url, data=buffer.read())
|
||||
…
|
||||
return await self.client.files.get_file(presigned_url.file_id, …)
|
||||
```
|
||||
|
||||
### Parse reader (`py/llama_cloud_services/parse/base.py`)
|
||||
|
||||
- `LlamaParse` is a bespoke reader that talks straight to HTTP routes defined in the module (e.g., `/api/parsing/upload`, `/api/parsing/job/{id}`). Unlike `LlamaExtract`, it does not go through the generated client; instead it builds URLs manually and uses `httpx`/`make_api_request`. A fake server must therefore implement these exact paths.
|
||||
|
||||
```49:66:py/llama_cloud_services/parse/base.py
|
||||
JOB_RESULT_URL = "/api/parsing/job/{job_id}/result/{result_type}"
|
||||
JOB_STATUS_ROUTE = "/api/parsing/job/{job_id}"
|
||||
JOB_UPLOAD_ROUTE = "/api/parsing/upload"
|
||||
```
|
||||
|
||||
```1056:1070:py/llama_cloud_services/parse/base.py
|
||||
url = build_url(JOB_UPLOAD_ROUTE, self.organization_id, self.project_id)
|
||||
resp = await make_api_request(self.aclient, "POST", url, …, files=files, data=data)
|
||||
```
|
||||
|
||||
### Classifier beta client (`py/llama_cloud_services/beta/classifier/client.py`)
|
||||
|
||||
- Classification flows reuse `FileClient` for uploads and then call `AsyncLlamaCloud.classifier` endpoints (`create_classify_job`, `get_classify_job`, `get_classification_job_results`). Long-running tests poll until status becomes terminal, so mocking needs to cover both the enqueue POST and the follow-up GETs.
|
||||
|
||||
```75:151:py/llama_cloud_services/beta/classifier/client.py
|
||||
return await self.client.classifier.create_classify_job(...)
|
||||
…
|
||||
results = await self.client.classifier.get_classification_job_results(
|
||||
classify_job_with_status.id,
|
||||
project_id=self.project_id,
|
||||
)
|
||||
```
|
||||
|
||||
## Generated Python Client
|
||||
|
||||
- The repo depends on the published `llama_cloud` package (currently 0.1.44) which is itself generated from the OpenAPI spec. All hand-written modules import types and service clients from this package, so the fake server may need to mirror whatever transport settings `AsyncLlamaCloud` expects (headers, pagination, etc.).
|
||||
|
||||
```1605:1608:py/uv.lock
|
||||
sdist = { … "llama_cloud-0.1.44.tar.gz", … }
|
||||
wheels = [{ … "llama_cloud-0.1.44-py3-none-any.whl", … }]
|
||||
```
|
||||
|
||||
- Since `AsyncLlamaCloud` handles auth headers and base URLs, integrating the fake server typically means pointing `LLAMA_CLOUD_BASE_URL` at the mock and letting the generated client continue to build resource paths.
|
||||
|
||||
## TypeScript + OpenAPI Assets
|
||||
|
||||
- The canonical OpenAPI document lives in `ts/llama_cloud_services/openapi.json`. It defines every path/operation used by both the generated TypeScript SDK and the Python client. For example, the stateless extract endpoint is captured as `POST /api/v1/extraction/run`.
|
||||
|
||||
```13825:13872:ts/llama_cloud_services/openapi.json
|
||||
"/api/v1/extraction/run": {
|
||||
"post": {
|
||||
"summary": "Extract Stateless",
|
||||
"description": "… Requires data_schema, config, and either file_id, text, or base64 encoded file data.",
|
||||
…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The OpenAPI document is downloaded from production via `scripts/download.mjs` and then fed into `@hey-api/openapi-ts` to regenerate the TypeScript client and schema wrappers. Keeping the fake server in sync with the spec means you can diff regenerated clients when the API evolves.
|
||||
|
||||
```3:21:ts/llama_cloud_services/scripts/download.mjs
|
||||
const response = await fetch('https://api.cloud.llamaindex.ai/api/openapi.json');
|
||||
…
|
||||
fs.writeFileSync('openapi.json', JSON.stringify(data, null, 2));
|
||||
```
|
||||
|
||||
```1:24:ts/llama_cloud_services/openapi-ts.config.ts
|
||||
export default defineConfig({
|
||||
input: "./openapi.json",
|
||||
output: { path: "./src/client", format: "prettier", lint: "eslint" },
|
||||
plugins: [ … "@hey-api/sdk", "@hey-api/typescript" ],
|
||||
});
|
||||
```
|
||||
|
||||
- The public TypeScript surface (`src/LlamaClassify.ts`, etc.) already consumes the generated client by injecting auth headers and delegating to `classify(...)`. If Python tests eventually need to reuse the same fake server, TypeScript examples provide another reference for how SDK consumers expect responses to look.
|
||||
|
||||
```12:74:ts/llama_cloud_services/src/LlamaClassify.ts
|
||||
export class LlamaClassify {
|
||||
constructor(apiKey?: string, baseUrl?: string, region?: string) {
|
||||
…
|
||||
this.client = createClient(createConfig({ baseUrl: url, headers: { Authorization: `Bearer ${key}` }}));
|
||||
}
|
||||
|
||||
async classify(rules, configuration, { fileContents, filePaths, projectId, … }) {
|
||||
const result = await classify(rules, configuration, {
|
||||
fileContents,
|
||||
filePaths,
|
||||
projectId: projectId ?? undefined,
|
||||
client: this.client,
|
||||
…
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint Map to Stub First
|
||||
|
||||
Cross-referencing the Python call-sites with the OpenAPI spec yields the minimum set of HTTP routes the fake server must implement:
|
||||
|
||||
1. `/api/v1/extraction/run` for stateless jobs, plus the agent CRUD endpoints under `/api/v1/extraction/extraction-agents`, `/api/v1/extraction/jobs`, and `/api/v1/extraction/runs/by-job/{id}` (see `LlamaExtract` usage above).
|
||||
2. `/api/v1/files/**` for upload/generate-presigned/list/get/delete, plus any presigned `PUT` destinations (`FileClient`).
|
||||
3. `/api/parsing/upload`, `/api/parsing/job/{job_id}`, `/api/parsing/job/{job_id}/result/{result_type}` (direct HTTPX calls in `LlamaParse`).
|
||||
4. `/api/v1/classifier/**` for job creation, polling, and result retrieval (`LlamaClassify` and `ClassifyClient`).
|
||||
|
||||
Having deterministic handlers for these routes unlocks end-to-end coverage of extract/parse/classify flows without touching live SaaS.
|
||||
|
||||
## Implementation Reminders from the Spec
|
||||
|
||||
- Namespace toggles let tests intercept a subset of APIs while letting others fall through—mirror this by allowing `FakeLlamaCloudServer(namespaces=[...])` to selectively register respx routes.
|
||||
- Deterministic payloads should derive from uploaded file bytes + schema hashes for extract, layout characteristics for parse, and label sets for classify so that rerunning the same test yields identical responses (reducing fixture churn).
|
||||
- Keep the matcher system (`RequestMatcher`, `FileMatcher`, etc.) flexible so individual tests can stub failures (e.g., presigned upload errors, job timeouts) without reconfiguring the entire fake.
|
||||
|
||||
```44:210:py/testing_utils_spec.md
|
||||
with FakeLlamaCloudServer() as fake:
|
||||
extractor = LlamaExtract(...)
|
||||
parser = LlamaParse(...)
|
||||
classifier = LlamaClassify(...)
|
||||
…
|
||||
fake.extract.stub_run(... RequestMatcher ...)
|
||||
```
|
||||
|
||||
Armed with the file map above, a new developer can trace any SDK call from the hand-written layers down to the generated client and the authoritative OpenAPI route, making it clear where the fake server needs to hook in.
|
||||
@@ -1,329 +0,0 @@
|
||||
## Local Testing Utilities 2.0 (Spec Draft)
|
||||
|
||||
Offline testing should feel identical to calling the public LlamaCloud API. The new utilities center a single `FakeLlamaCloudServer` that intercepts HTTP traffic at the API boundary, deterministically generates responses from real files + schemas, and only requires overrides when a test needs to exercise edge cases.
|
||||
|
||||
### Design goals
|
||||
- **Everything mocked by default**: instantiating `FakeLlamaCloudServer()` wires up every public LlamaCloud namespace (extract, parse, classify, files, etc.) so the SDK behaves as if it were talking to production. Deterministic responses are returned without any additional wiring.
|
||||
- **Context manager optional**: `FakeLlamaCloudServer` still supports `with ...` for pytest isolation, but you can call `install()` / `uninstall()` to keep the mock server active inside a long-running process (e.g., a FastAPI dev server that proxies to the fake).
|
||||
- **Pydantic-first ergonomics**: all documentation and helpers assume schemas are declared as `BaseModel` subclasses. JSON Schema dictionaries are still accepted for compatibility.
|
||||
- **API-only contract**: handlers talk raw HTTP (request dicts, status codes, JSON payloads) so we can reuse the mock in future SDKs or other languages without depending on `LlamaExtract`.
|
||||
|
||||
### Quick start (pytest-friendly, deterministic by default)
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
from llama_cloud import ExtractConfig, ExtractMode
|
||||
from llama_cloud_services.extract import LlamaExtract
|
||||
from llama_cloud_services.testing_utils import FakeLlamaCloudServer
|
||||
|
||||
|
||||
class Receipt(BaseModel):
|
||||
merchant: str = Field(description="Vendor name")
|
||||
total: float = Field(description="Grand total in USD")
|
||||
|
||||
|
||||
config = ExtractConfig(extraction_mode=ExtractMode.FAST)
|
||||
pdf_path = Path("tests/fixtures/receipt.pdf")
|
||||
|
||||
|
||||
with FakeLlamaCloudServer() as fake:
|
||||
extractor = LlamaExtract(
|
||||
api_key="test-key",
|
||||
verify=False,
|
||||
)
|
||||
run = extractor.extract(Receipt, config, pdf_path)
|
||||
assert run.status.value == "SUCCESS"
|
||||
assert run.data["total"] > 0 # generated entirely from file + schema
|
||||
```
|
||||
|
||||
Key points:
|
||||
- No manual stubbing required. The fake server hashes the uploaded file bytes + schema JSON to derive a deterministic seed and walks the schema to produce stable mock data.
|
||||
- `FakeLlamaCloudServer` automatically intercepts the default SaaS URL (`https://api.cloud.llamaindex.ai`). If your tests point at another host (e.g., BYOC), pass it via `FakeLlamaCloudServer(base_urls=["https://byoc.dev/api"])`; otherwise, keep using your normal SDK base URL.
|
||||
|
||||
### Works across extract, parse, classify
|
||||
|
||||
```python
|
||||
from llama_cloud_services.testing_utils import FakeLlamaCloudServer
|
||||
from llama_cloud_services.extract import LlamaExtract
|
||||
from llama_cloud_services.parse import LlamaParse
|
||||
from llama_cloud_services.classify import LlamaClassify
|
||||
|
||||
|
||||
with FakeLlamaCloudServer() as fake:
|
||||
extractor = LlamaExtract(api_key="test-key")
|
||||
parser = LlamaParse(api_key="test-key")
|
||||
classifier = LlamaClassify(api_key="test-key")
|
||||
|
||||
run = extractor.extract(
|
||||
Receipt, config, "noisebridge.pdf"
|
||||
) # reuse quick-start schema/config
|
||||
parse_result = parser.parse("noisebridge.pdf")
|
||||
classification = classifier.classify({"text": "foo"})
|
||||
|
||||
assert run.status.value == "SUCCESS"
|
||||
assert parse_result.documents[0].text # deterministically generated
|
||||
assert classification.prediction in {
|
||||
"A",
|
||||
"B",
|
||||
} # stable RNG driven by payload
|
||||
```
|
||||
|
||||
Every namespace uses its own deterministic generator (schema-driven for extract, layout-driven for parse, label-driven for classify) but shares the same matcher/override system described below.
|
||||
|
||||
### Limiting intercepted APIs
|
||||
|
||||
If you only need a subset of APIs (e.g., extract + files during early bring-up), pass `namespaces` explicitly. Anything omitted will fall through to the real network, which is handy for hybrid tests.
|
||||
|
||||
```python
|
||||
fake = FakeLlamaCloudServer(
|
||||
namespaces=["extract", "files"],
|
||||
base_urls=["https://api.cloud.llamaindex.ai"], # optionally point at BYOC
|
||||
)
|
||||
|
||||
with fake:
|
||||
extractor = LlamaExtract(api_key="test-key")
|
||||
extractor.extract(Receipt, config, "noisebridge.pdf")
|
||||
```
|
||||
|
||||
### Long-lived install for iterative development
|
||||
|
||||
```python
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
|
||||
fake = FakeLlamaCloudServer(
|
||||
namespaces=["extract"],
|
||||
base_urls=[
|
||||
os.environ.get(
|
||||
"LLAMA_CLOUD_BASE_URL", "https://api.cloud.llamaindex.ai"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
fake.install()
|
||||
app.state.extractor = LlamaExtract(
|
||||
api_key="dev",
|
||||
verify=False,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fake.uninstall()
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
```
|
||||
|
||||
`install()`/`uninstall()` simply wrap the respx router lifecycle so you can keep the mock server hot for REPLs, background workers, or manual QA sessions without relying on a context manager.
|
||||
|
||||
### Files API behavior
|
||||
|
||||
`FakeLlamaCloudServer` ships with a first-class fake for `/api/v1/files/*` because uploads sit on the critical path for both extract and downstream workflows that pre-stage files.
|
||||
|
||||
- Every call to `POST /files/generate-presigned-url`, the subsequent `PUT` upload, and the follow-up `GET /files/{file_id}` is intercepted and stored in-memory. The response objects mirror the real API so `FileClient` keeps working unchanged.
|
||||
- `fake.files.preload(path="tests/fixtures/plan.pdf", filename="plan.pdf")` ingests local fixtures ahead of time and returns a reusable `file_id`, which is useful when tests pass `SourceText(file_id=...)`.
|
||||
- `fake.files.stub_upload(...)` lets you simulate storage failures (e.g., 413 "file too large") using the same matcher system as extract.
|
||||
- You can download what the SDK uploaded via `fake.files.read(file_id)` to assert on the bytes or to feed downstream mocks.
|
||||
|
||||
```python
|
||||
from llama_cloud_services.extract import SourceText
|
||||
|
||||
with FakeLlamaCloudServer() as fake:
|
||||
file_id = fake.files.preload(path="tests/fixtures/noisebridge.pdf")
|
||||
extractor = LlamaExtract(api_key="test-key")
|
||||
run = extractor.extract(Receipt, config, SourceText(file_id=file_id))
|
||||
assert fake.files.read(file_id).startswith(b"%PDF")
|
||||
```
|
||||
|
||||
### Deterministic response generation
|
||||
1. Files uploaded via `/api/v1/files` (or inlined via `extract_stateless`) are fingerprinted using SHA256 (file content bytes + filename).
|
||||
2. Schemas are normalized (Pydantic `model_json_schema()` plus sorted keys) and hashed.
|
||||
3. A seed derived from `sha256(file_fingerprint + schema_digest)` feeds a tiny RNG that walks the schema to synthesize values (numbers, strings, arrays) while respecting field metadata (descriptions hint names, numeric ranges, etc.).
|
||||
4. Runs transition through the same states as production (`PENDING` → `SUCCESS`) and return realistic timestamps, metadata, and config echoes.
|
||||
|
||||
Because the seed is stable, rerunning the same schema/file pair yields identical mock payloads without stubbing.
|
||||
|
||||
### Stubbing, spying, and assertions
|
||||
Most tests only need the deterministic defaults, but the fake server provides a layered set of helpers for overriding responses, asserting call counts, and finally dropping down to raw `respx` when necessary.
|
||||
|
||||
#### Matcher API
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
from httpx import Request
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileMatcher:
|
||||
filename: str | None = None
|
||||
sha256: str | None = None
|
||||
file_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemaMatcher:
|
||||
model: type[BaseModel] | None = None
|
||||
schema_hash: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestMatcher:
|
||||
file: FileMatcher | Callable[[Request], bool] | None = None
|
||||
schema: SchemaMatcher | None = None
|
||||
agent_id: str | None = None
|
||||
project_id: str | None = None
|
||||
organization_id: str | None = None
|
||||
predicate: Callable[[Request], bool] | None = None
|
||||
```
|
||||
|
||||
Every callable matcher receives the raw `httpx.Request` object that respx captured, so you can inspect headers, cookies, bodies, etc., without learning another wrapper type. The helper dataclasses (`FileMatcher`, `SchemaMatcher`) just cover the common cases; mix and match as needed. Stubs are evaluated in registration order, and `once=True` removes the stub after the first match.
|
||||
|
||||
#### Stateless extraction example
|
||||
|
||||
```python
|
||||
fake.extract.stub_run(
|
||||
matcher=RequestMatcher(file=FileMatcher(filename="noisebridge.pdf")),
|
||||
data={"merchant": "Noisebridge", "total": 42.0},
|
||||
status="SUCCESS", # defaults to deterministic timeline when omitted
|
||||
metadata={"source": "unit-test"},
|
||||
once=True,
|
||||
)
|
||||
|
||||
run = extractor.extract(Receipt, config, "noisebridge.pdf")
|
||||
assert run.data["merchant"] == "Noisebridge"
|
||||
```
|
||||
|
||||
`data` accepts dictionaries, Pydantic models, or callables (`Callable[[Request], dict]`). If you omit `status`, the stub only replaces the payload while preserving the deterministic job/run lifecycle.
|
||||
|
||||
#### Agent extraction example
|
||||
|
||||
```python
|
||||
agent = extractor.create_agent(
|
||||
name="tests", data_schema=Receipt, config=config
|
||||
)
|
||||
|
||||
fake.extract.stub_agent_run(
|
||||
agent_id=agent.id,
|
||||
matcher=RequestMatcher(file=FileMatcher(filename="bad.pdf")),
|
||||
job_status="FAILED", # overrides POST /extraction/jobs
|
||||
run_status="FAILED", # overrides GET /runs/by-job
|
||||
error={"message": "Schema mismatch"},
|
||||
)
|
||||
|
||||
with pytest.raises(ApiError):
|
||||
agent.extract("bad.pdf")
|
||||
```
|
||||
|
||||
`stub_agent_run` targets the stateful job pipeline (`/extraction/jobs`, `/extraction/jobs/{id}`, `/extraction/runs/by-job/{id}`) so you can mimic long-running failures, retries, or partial completions without hand-writing multiple HTTP handlers.
|
||||
|
||||
#### Parse, classify, and files stubs
|
||||
|
||||
- `fake.parse.stub_parse(...)` lets you override document splits, token counts, or even return structured HTML for specific file IDs.
|
||||
- `fake.classify.stub_prediction(...)` accepts label sets and score distributions so you can test downstream logic that inspects confidences.
|
||||
- `fake.files.stub_upload(...)` / `fake.files.stub_download(...)` simulate storage edge cases such as timeouts or corrupted content.
|
||||
|
||||
Because every namespace uses the same matcher primitives, you can coordinate multi-API scenarios (e.g., stub the file upload and the subsequent extract run) without duplicating predicates.
|
||||
|
||||
#### Assertions without extra abstractions
|
||||
|
||||
Since everything runs through the same `respx.MockRouter` you already use elsewhere, assertions stay lightweight:
|
||||
|
||||
```python
|
||||
with FakeLlamaCloudServer() as fake:
|
||||
route = fake.router["POST", "/api/v1/extraction/run"]
|
||||
extractor = LlamaExtract(api_key="test-key")
|
||||
extractor.extract(Receipt, config, "noisebridge.pdf")
|
||||
|
||||
assert route.called
|
||||
assert route.call_count == 1
|
||||
req = route.calls[0].request # this is httpx.Request
|
||||
assert req.headers["authorization"].startswith("Bearer ")
|
||||
```
|
||||
|
||||
For friendlier names, every frequently used route is also pinned to a stable attribute:
|
||||
|
||||
- `fake.extract.stateless_run` (alias: `fake.extract_run`) → `POST /api/v1/extraction/run`
|
||||
- `fake.extract.agent_job` → `POST /api/v1/extraction/jobs`
|
||||
- `fake.extract.agent_run` → `GET /api/v1/extraction/runs/by-job/{job_id}`
|
||||
- `fake.files.upload` → `POST /api/v1/files/upload` (or the presigned PUT hop, depending on mode)
|
||||
- `fake.files.get` → `GET /api/v1/files/{file_id}`
|
||||
|
||||
Each attribute is the underlying `respx.Route`, so assertions feel natural:
|
||||
|
||||
```python
|
||||
assert fake.extract.stateless_run.called
|
||||
fake.extract.stateless_run.assert_called_once()
|
||||
assert fake.files.upload.call_count == 1
|
||||
assert fake.extract_run.called # global alias for the same route
|
||||
```
|
||||
|
||||
If you ever need the full mapping, `fake.extract.routes["stateless_run"] is fake.extract.stateless_run`.
|
||||
|
||||
#### Advanced (respx-level) overrides
|
||||
|
||||
When you need total control, drop straight into respx:
|
||||
|
||||
```python
|
||||
route = fake.router["POST", "/api/v1/extraction/run"]
|
||||
route.mock(side_effect=lambda request: (418, {"detail": "I'm a teapot"}))
|
||||
```
|
||||
|
||||
Or use the attribute shortcuts:
|
||||
|
||||
```python
|
||||
fake.extract.stateless_run.mock(
|
||||
side_effect=lambda request: (500, {"detail": "boom"})
|
||||
)
|
||||
```
|
||||
|
||||
Either way you're dealing with the canonical respx objects, so regex paths, call assertions, and other ecosystem tools keep working. The only convention is that handlers should return `(status_code, json_body | bytes)` so logging and deterministic fallbacks remain consistent.
|
||||
|
||||
### API-layer implementation hints
|
||||
- Route decorators such as `server.add_handler("POST", "/api/v1/extraction/run")` install handlers for **every** registered base URL declared in the constructor, keeping the mock independent of SDK client classes.
|
||||
- Request objects passed to handlers expose method, URL, headers, query params, JSON body, and raw bytes—everything needed to mirror production without importing internal models.
|
||||
- Namespaces self-register via the constructor (e.g., `namespaces=["extract"]`) so no additional attach helpers are required; future SDKs can opt into the same HTTP contracts by toggling the namespaces they care about.
|
||||
|
||||
## Research: Extract SDK surface map
|
||||
|
||||
The current Python SDK (`py/llama_cloud_services/extract/extract.py`) is a thin wrapper over the HTTP API exposed in `ts/llama_cloud_services/openapi.json`. Understanding this mapping helps ensure the fake server mirrors the real contract.
|
||||
|
||||
### Core classes
|
||||
- `LlamaExtract`: factory that owns an `AsyncLlamaCloud` client, manages thread pools, and exposes both stateless extraction (`extract`, `aextract`, `queue_extraction`) and agent CRUD helpers.
|
||||
- `ExtractionAgent`: wraps an existing agent returned by the API and provides methods for queuing files, polling jobs, listing runs, updating schemas/configs, and deleting runs.
|
||||
- `FileClient`: abstracts the `/api/v1/files` upload + download flow, including presigned URL handling for uploads.
|
||||
|
||||
### Stateless extraction flow
|
||||
1. `LlamaExtract.queue_extraction(data_schema, config, files)` validates schemas via `POST /api/v1/extraction/extraction-agents/schema/validation`, converts input files into either `file_id`, `file` (base64 body), or inline `text`.
|
||||
2. For each file the SDK calls `POST /api/v1/extraction/run` with the processed schema + config + file payload. The API responds with an `ExtractJob`.
|
||||
3. `LlamaExtract.aextract` waits for completion by polling `_wait_for_job_result`, which hits `GET /api/v1/extraction/jobs/{job_id}` until the job is `SUCCESS`/`FAILED`, then fetches the run via `GET /api/v1/extraction/runs/by-job/{job_id}`. The synchronous `extract` just wraps this coroutine in a worker thread.
|
||||
|
||||
### Agent-backed flow
|
||||
1. `create_agent` issues `POST /api/v1/extraction/extraction-agents` with name, schema, and config; responses seed `ExtractionAgent`.
|
||||
2. `ExtractionAgent.queue_extraction` uploads files via `FileClient`, then enqueues jobs with `POST /api/v1/extraction/jobs` (or `/jobs/file` for multipart uploads). Returned job IDs are polled via `_wait_for_job_result` just like the stateless path.
|
||||
3. `ExtractionAgent.list_extraction_runs` and `delete_extraction_run` map to `GET /api/v1/extraction/runs` (with pagination) and `DELETE /api/v1/extraction/runs/{run_id}` respectively.
|
||||
4. Manual inspection helpers (`get_extraction_job`, `get_extraction_run_for_job`, `get_extraction_run`) call `GET /api/v1/extraction/jobs/{job_id}` and `GET /api/v1/extraction/runs/by-job/{job_id}` / `GET /api/v1/extraction/runs/{run_id}`.
|
||||
|
||||
### Files API touch points
|
||||
- Uploads default to presigned URLs: the SDK first calls `POST /api/v1/files/generate-presigned-url`, then performs an HTTP PUT to the returned URL, and finally fetches the file metadata via `GET /api/v1/files/{file_id}`.
|
||||
- When BYOC deployments disable presigned uploads, `FileClient` falls back to `POST /api/v1/files/upload`.
|
||||
|
||||
### Implications for the fake server
|
||||
- **API-level parity**: mocking should happen at the HTTP layer (matching the endpoints listed above) so new SDKs can reuse the fake by simply pointing their base URL at it.
|
||||
- **State surfaces**: to emulate production, the fake needs in-memory stores for files, jobs, and runs keyed by UUIDs, plus schema validation stubs that mimic the `/schema/validation` endpoint.
|
||||
- **Deterministic generators**: since `ExtractRun.data` is derived from schema + file, implementing the generator once at the API layer ensures consistency across SDKs.
|
||||
- **Error simulation hooks**: overrides should let us short-circuit any endpoint (jobs, runs, schema validation) without changing SDK code, mirroring how the real API might fail.
|
||||
|
||||
This map should serve as the checklist when we implement the mock: if an SDK method calls a certain path, our fake server must expose the same path with compatible request/response bodies so we can eventually lift these utilities into a standalone package.
|
||||
|
||||
## Implementation status
|
||||
|
||||
- `FakeLlamaCloudServer` now lives in `llama_cloud_services.testing_utils` and registers the files, extract, parse, and classify namespaces by default. It can be used as `with FakeLlamaCloudServer(): ...` or by calling `install()` / `uninstall()` explicitly.
|
||||
- Deterministic payloads derive from file fingerprints plus schema hashes for extraction, seeded layout information for parse, and rule/file hashes for classification. The helper exposes matcher-driven overrides (`stub_run`, `stub_agent_run`, `files.stub_upload`) so tests can simulate errors.
|
||||
- Common `respx.Route` handles are exposed via namespaces (`fake.extract.stateless_run`, `fake.extract.agent_job`, `fake.files.routes["upload"]`, etc.) for assertions that mirror the examples in this spec.
|
||||
- End-to-end usage is covered in `py/unit_tests/testing_utils/test_fake_server.py`, which exercises stateless extraction, agent-backed uploads, and LlamaParse readers entirely against the fake server without external network calls.
|
||||
@@ -2,24 +2,58 @@ import os
|
||||
import tempfile
|
||||
import pytest
|
||||
import pandas as pd
|
||||
from pydantic import ValidationError
|
||||
|
||||
from llama_cloud_services.beta.sheets import LlamaSheets
|
||||
from llama_cloud_services.beta.sheets.types import SpreadsheetParsingConfig
|
||||
|
||||
|
||||
class TestSpreadsheetParsingConfig:
|
||||
"""Unit tests for SpreadsheetParsingConfig."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test that default values are set correctly."""
|
||||
config = SpreadsheetParsingConfig()
|
||||
assert config.flatten_hierarchical_tables is False
|
||||
assert config.table_merge_sensitivity == "strong"
|
||||
|
||||
def test_custom_values(self):
|
||||
"""Test setting custom values for new fields."""
|
||||
config = SpreadsheetParsingConfig(
|
||||
flatten_hierarchical_tables=True,
|
||||
table_merge_sensitivity="weak",
|
||||
)
|
||||
assert config.flatten_hierarchical_tables is True
|
||||
assert config.table_merge_sensitivity == "weak"
|
||||
|
||||
def test_table_merge_sensitivity_validation(self):
|
||||
"""Test that invalid table_merge_sensitivity values are rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
SpreadsheetParsingConfig(table_merge_sensitivity="invalid")
|
||||
|
||||
def test_unknown_fields_ignored(self):
|
||||
"""Test that unknown fields are silently ignored."""
|
||||
config = SpreadsheetParsingConfig(
|
||||
unknown_field="test",
|
||||
another_unknown=123,
|
||||
)
|
||||
assert not hasattr(config, "unknown_field")
|
||||
assert not hasattr(config, "another_unknown")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sheets_client():
|
||||
"""Create a LlamaSheets client for testing."""
|
||||
api_key = os.getenv(
|
||||
"LLAMA_CLOUD_API_KEY", "llx-3AEorIw5v0lnJPzEOI9xSl0N8yFx3fguw0Zn8QJHzGWmwg5r"
|
||||
)
|
||||
base_url = os.getenv("LLAMA_CLOUD_BASE_URL", "https://api.staging.llamaindex.ai")
|
||||
api_key = os.getenv("LLAMA_CLOUD_API_KEY")
|
||||
base_url = os.getenv("LLAMA_CLOUD_BASE_URL", "https://api.cloud.llamaindex.ai")
|
||||
project_id = os.getenv("LLAMA_CLOUD_PROJECT_ID")
|
||||
|
||||
client = LlamaSheets(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
max_timeout=300,
|
||||
poll_interval=2,
|
||||
project_id=project_id,
|
||||
)
|
||||
return client
|
||||
|
||||
@@ -51,10 +85,7 @@ def sample_excel_file():
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get(
|
||||
"LLAMA_CLOUD_API_KEY", "llx-3AEorIw5v0lnJPzEOI9xSl0N8yFx3fguw0Zn8QJHzGWmwg5r"
|
||||
)
|
||||
== "",
|
||||
os.environ.get("LLAMA_CLOUD_API_KEY", "") == "",
|
||||
reason="LLAMA_CLOUD_API_KEY not set",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@@ -134,10 +165,7 @@ async def test_spreadsheet_extraction_e2e(
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get(
|
||||
"LLAMA_CLOUD_API_KEY", "llx-3AEorIw5v0lnJPzEOI9xSl0N8yFx3fguw0Zn8QJHzGWmwg5r"
|
||||
)
|
||||
== "",
|
||||
os.environ.get("LLAMA_CLOUD_API_KEY", "") == "",
|
||||
reason="LLAMA_CLOUD_API_KEY not set",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from llama_index.core.constants import DEFAULT_BASE_URL
|
||||
from pydantic import Field, SecretStr
|
||||
|
||||
@@ -78,7 +78,7 @@ async def test_upload_bytes(
|
||||
uploaded_file = await file_client.upload_bytes(file_bytes, external_file_id)
|
||||
|
||||
assert isinstance(uploaded_file, File)
|
||||
expected_name = external_file_id if use_presigned_url else "upload"
|
||||
expected_name = external_file_id
|
||||
assert uploaded_file.name == expected_name
|
||||
assert uploaded_file.external_file_id == external_file_id
|
||||
|
||||
@@ -100,7 +100,7 @@ async def test_upload_buffer(
|
||||
uploaded_file = await file_client.upload_buffer(buffer, external_file_id, file_size)
|
||||
|
||||
assert isinstance(uploaded_file, File)
|
||||
expected_name = external_file_id if use_presigned_url else "upload"
|
||||
expected_name = external_file_id
|
||||
assert uploaded_file.name == expected_name
|
||||
assert uploaded_file.external_file_id == external_file_id
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ from llama_cloud.types.aggregate_group import AggregateGroup
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from llama_cloud_services.beta.agent_data.schema import (
|
||||
BoundingBox,
|
||||
ExtractedData,
|
||||
ExtractedFieldMetadata,
|
||||
FieldCitation,
|
||||
InvalidExtractionData,
|
||||
PageDimensions,
|
||||
TypedAgentData,
|
||||
TypedAggregateGroup,
|
||||
calculate_overall_confidence,
|
||||
@@ -663,3 +665,69 @@ def test_field_conflict_in_schema():
|
||||
assert isinstance(
|
||||
extracted["majority_opinion"]["reasoning"], ExtractedFieldMetadata
|
||||
)
|
||||
|
||||
|
||||
def test_parse_extracted_field_metadata_with_bounding_boxes():
|
||||
"""Test parse_extracted_field_metadata with bounding boxes and page dimensions."""
|
||||
raw_metadata = {
|
||||
"document_type": {
|
||||
"citation": [
|
||||
{
|
||||
"page": 1,
|
||||
"matching_text": "FACTURE ORIGINALE",
|
||||
"bounding_boxes": [{"x": 77.28, "y": 615.12, "w": 70.6, "h": 7.2}],
|
||||
"page_dimensions": {"width": 222.24, "height": 736.56},
|
||||
}
|
||||
],
|
||||
"parsing_confidence": 1.0,
|
||||
"extraction_confidence": 0.7252506422636493,
|
||||
"confidence": 0.7252506422636493,
|
||||
},
|
||||
"summary": {
|
||||
"citation": [
|
||||
{
|
||||
"page": 1,
|
||||
"matching_text": "FACTURE ORIGINALE",
|
||||
"bounding_boxes": [{"x": 77.28, "y": 615.12, "w": 70.6, "h": 7.2}],
|
||||
"page_dimensions": {"width": 222.24, "height": 736.56},
|
||||
},
|
||||
{
|
||||
"page": 1,
|
||||
"matching_text": "Café filtre assiette — $1.90",
|
||||
"bounding_boxes": [
|
||||
{"x": 10.56, "y": 172.83, "w": 171.85, "h": 497.01}
|
||||
],
|
||||
"page_dimensions": {"width": 222.24, "height": 736.56},
|
||||
},
|
||||
],
|
||||
"parsing_confidence": 1.0,
|
||||
"extraction_confidence": 0.5700013128334419,
|
||||
"confidence": 0.5700013128334419,
|
||||
},
|
||||
}
|
||||
|
||||
result = parse_extracted_field_metadata(raw_metadata)
|
||||
|
||||
# Verify document_type citation with bounding boxes
|
||||
assert isinstance(result["document_type"], ExtractedFieldMetadata)
|
||||
assert result["document_type"].parsing_confidence == 1.0
|
||||
assert result["document_type"].extraction_confidence == 0.7252506422636493
|
||||
assert result["document_type"].confidence == 0.7252506422636493
|
||||
assert len(result["document_type"].citation) == 1
|
||||
|
||||
citation = result["document_type"].citation[0]
|
||||
assert citation.page == 1
|
||||
assert citation.matching_text == "FACTURE ORIGINALE"
|
||||
assert len(citation.bounding_boxes) == 1
|
||||
assert citation.bounding_boxes[0] == BoundingBox(x=77.28, y=615.12, w=70.6, h=7.2)
|
||||
assert citation.page_dimensions == PageDimensions(width=222.24, height=736.56)
|
||||
|
||||
# Verify summary citation with multiple bounding boxes
|
||||
assert isinstance(result["summary"], ExtractedFieldMetadata)
|
||||
assert len(result["summary"].citation) == 2
|
||||
assert result["summary"].citation[0].bounding_boxes[0].x == 77.28
|
||||
assert result["summary"].citation[1].bounding_boxes[0].x == 10.56
|
||||
|
||||
# Verify round-trip serialization
|
||||
result2 = parse_extracted_field_metadata(result)
|
||||
assert result2 == result
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from llama_cloud import ExtractConfig
|
||||
from llama_cloud.types import ExtractMode
|
||||
from llama_cloud.core.api_error import ApiError
|
||||
from llama_cloud_services.extract import LlamaExtract
|
||||
from llama_cloud_services.parse import LlamaParse
|
||||
from llama_cloud_services.beta.agent_data import AsyncAgentDataClient
|
||||
from llama_cloud_services.testing_utils import FakeLlamaCloudServer
|
||||
from llama_cloud_services.testing_utils._deterministic import hash_schema
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Receipt(BaseModel):
|
||||
merchant: str = Field(description="Vendor name")
|
||||
total: float = Field(description="Grand total")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LLAMA_CLOUD_API_KEY", "unit-test-key")
|
||||
monkeypatch.setenv("LLAMA_CLOUD_BASE_URL", FakeLlamaCloudServer.DEFAULT_BASE_URL)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_server() -> FakeLlamaCloudServer:
|
||||
with FakeLlamaCloudServer() as server:
|
||||
yield server
|
||||
|
||||
|
||||
def _write_sample_file(tmp_path: Path, name: str, content: str) -> Path:
|
||||
target = tmp_path / name
|
||||
target.write_text(content)
|
||||
return target
|
||||
|
||||
|
||||
def test_stateless_extract_is_deterministic(
|
||||
fake_server: FakeLlamaCloudServer, tmp_path: Path
|
||||
) -> None:
|
||||
extractor = LlamaExtract(api_key="unit-test-key", verify=False)
|
||||
config = ExtractConfig(extraction_mode=ExtractMode.FAST)
|
||||
sample_path = _write_sample_file(
|
||||
tmp_path, "receipt.txt", "Merchant: Lunar Bistro\nTotal: 123.45"
|
||||
)
|
||||
|
||||
first_run = extractor.extract(Receipt, config, sample_path)
|
||||
second_run = extractor.extract(Receipt, config, sample_path)
|
||||
|
||||
assert first_run.status.value == "SUCCESS"
|
||||
assert second_run.data == first_run.data
|
||||
assert "merchant" in first_run.data
|
||||
assert fake_server.extract.stateless_run.called
|
||||
|
||||
|
||||
def test_agent_flow_uploads_and_processes_files(
|
||||
fake_server: FakeLlamaCloudServer, tmp_path: Path
|
||||
) -> None:
|
||||
extractor = LlamaExtract(api_key="unit-test-key", verify=False)
|
||||
config = ExtractConfig(extraction_mode=ExtractMode.FAST)
|
||||
agent = extractor.create_agent(
|
||||
name="unit-test-agent", data_schema=Receipt, config=config
|
||||
)
|
||||
|
||||
sample_path = _write_sample_file(
|
||||
tmp_path, "contract.pdf", "Agreement between parties."
|
||||
)
|
||||
run = agent.extract(sample_path)
|
||||
|
||||
assert run.status.value == "SUCCESS"
|
||||
assert "merchant" in run.data
|
||||
|
||||
uploaded_bytes = fake_server.files.read(run.file.id)
|
||||
assert uploaded_bytes.startswith(b"Agreement")
|
||||
assert fake_server.extract.agent_job.called
|
||||
assert fake_server.extract.agent_run.called
|
||||
|
||||
|
||||
def test_parse_load_data_returns_documents(
|
||||
fake_server: FakeLlamaCloudServer, tmp_path: Path
|
||||
) -> None:
|
||||
parser = LlamaParse(
|
||||
api_key="unit-test-key", base_url=FakeLlamaCloudServer.DEFAULT_BASE_URL
|
||||
)
|
||||
sample_path = _write_sample_file(
|
||||
tmp_path, "report.pdf", "Executive summary of quarterly goals."
|
||||
)
|
||||
|
||||
documents = parser.load_data(sample_path)
|
||||
|
||||
assert documents
|
||||
assert "(page 1)" in documents[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_create(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data = Receipt(merchant="Test Inc", total=1000)
|
||||
item = await client.create_item(data)
|
||||
assert item.id == hash_schema(data)[:7]
|
||||
assert item.data.merchant == data.merchant and item.data.total == data.total
|
||||
assert item.collection == "extracted_data"
|
||||
assert item.deployment_name == "extraction_agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_update(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data = Receipt(merchant="Test Inc", total=1000)
|
||||
item = await client.create_item(data)
|
||||
assert item.id is not None
|
||||
updated_data = Receipt(merchant="Testing Inc", total=1100)
|
||||
updated_item = await client.update_item(item_id=item.id, data=updated_data)
|
||||
# ensure that the data actually changed
|
||||
assert (
|
||||
updated_item.data.merchant == updated_data.merchant
|
||||
and updated_item.data.total == updated_data.total
|
||||
)
|
||||
# make sure nothing else changed
|
||||
assert updated_item.id == item.id
|
||||
assert updated_item.collection == item.collection
|
||||
assert updated_item.deployment_name == item.deployment_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_search(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data1 = Receipt(merchant="Test Inc", total=1000)
|
||||
data2 = Receipt(merchant="Test Inc", total=1300)
|
||||
data3 = Receipt(merchant="Testing Inc", total=1100)
|
||||
item1 = await client.create_item(data1)
|
||||
item2 = await client.create_item(data2)
|
||||
item3 = await client.create_item(data3)
|
||||
result = await client.search(filter={"merchant": {"eq": "Test Inc"}})
|
||||
assert result.total == 2
|
||||
assert any(item.id == item1.id for item in result.items) and any(
|
||||
item.id == item2.id for item in result.items
|
||||
)
|
||||
assert all(item.data.merchant == "Test Inc" for item in result.items)
|
||||
result1 = await client.search(filter={"total": {"lt": 1200}})
|
||||
assert result.total == 2
|
||||
assert any(item.id == item1.id for item in result1.items) and any(
|
||||
item.id == item3.id for item in result1.items
|
||||
)
|
||||
assert all(item.data.total < 1200 for item in result1.items)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_aggregate(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data1 = Receipt(merchant="Test Inc", total=1000)
|
||||
data2 = Receipt(merchant="Test Inc", total=1300)
|
||||
data3 = Receipt(merchant="Testing Inc", total=1100)
|
||||
await client.create_item(data1)
|
||||
await client.create_item(data2)
|
||||
await client.create_item(data3)
|
||||
result = await client.aggregate(
|
||||
filter={"merchant": {"eq": "Test Inc"}},
|
||||
group_by=["merchant"],
|
||||
count=True,
|
||||
)
|
||||
# filtering for 'Test Inc' on merchant means that only data with 'Test Inc' are left, meaning that there is only one group of data for merchant, i.e. the 'Test Inc' group
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].count == 2
|
||||
assert result.items[0].first_item is not None
|
||||
assert result.items[0].first_item.merchant == data1.merchant
|
||||
assert result.items[0].first_item.total == data1.total
|
||||
assert result.items[0].group_key == {"merchant": "Test Inc"}
|
||||
result = await client.aggregate(
|
||||
group_by=["merchant"],
|
||||
count=True,
|
||||
)
|
||||
assert len(result.items) == 2
|
||||
assert result.items[0].count == 2
|
||||
assert result.items[0].first_item is not None
|
||||
assert result.items[0].first_item.merchant == data1.merchant
|
||||
assert result.items[0].first_item.total == data1.total
|
||||
assert result.items[0].group_key == {"merchant": "Test Inc"}
|
||||
assert result.items[1].count == 1
|
||||
assert result.items[1].first_item is not None
|
||||
assert result.items[1].first_item.merchant == data3.merchant
|
||||
assert result.items[1].first_item.total == data3.total
|
||||
assert result.items[1].group_key == {"merchant": "Testing Inc"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_get(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data1 = Receipt(merchant="Test Inc", total=1000)
|
||||
data2 = Receipt(merchant="Test Inc", total=1300)
|
||||
item1 = await client.create_item(data1)
|
||||
assert item1.id is not None
|
||||
item2 = await client.create_item(data2)
|
||||
assert item2.id is not None
|
||||
item = await client.get_item(item1.id)
|
||||
assert item.collection == item1.collection
|
||||
assert item.deployment_name == item1.deployment_name
|
||||
assert item.data.merchant == data1.merchant
|
||||
assert item.data.total == data1.total
|
||||
# using this pattern instead of `with pytest.raise` for more granual control over the error itself
|
||||
try:
|
||||
notitem = await client.get_item(item2.id + "thisdoesnotexist")
|
||||
e = None
|
||||
except ApiError as err:
|
||||
e = err
|
||||
notitem = None
|
||||
assert notitem is None
|
||||
assert e is not None
|
||||
assert e.status_code == 404
|
||||
assert e.body == {"detail": f"No data with ID: {item2.id+'thisdoesnotexist'}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_delete_by_id(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data = Receipt(merchant="Test Inc", total=1300)
|
||||
item = await client.create_item(data)
|
||||
assert item.id is not None
|
||||
await client.delete_item(item.id)
|
||||
# using this pattern instead of `with pytest.raise` for more granual control over the error itself
|
||||
try:
|
||||
notitem = await client.get_item(item.id)
|
||||
e = None
|
||||
except ApiError as err:
|
||||
e = err
|
||||
notitem = None
|
||||
assert notitem is None
|
||||
assert e is not None
|
||||
assert e.status_code == 404
|
||||
assert e.body == {"detail": f"No data with ID: {item.id}"}
|
||||
# using this pattern instead of `with pytest.raise` for more granual control over the error itself
|
||||
try:
|
||||
await client.delete_item(item.id)
|
||||
e = None
|
||||
except ApiError as err:
|
||||
e = err
|
||||
assert e is not None
|
||||
assert e.status_code == 404
|
||||
assert e.body == {"detail": f"No data with ID: {item.id}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_data_delete_by_query(fake_server: FakeLlamaCloudServer):
|
||||
with fake_server as _:
|
||||
client = AsyncAgentDataClient(
|
||||
Receipt,
|
||||
collection="extracted_data",
|
||||
deployment_name="extraction_agent",
|
||||
token="fake-api-key",
|
||||
)
|
||||
data1 = Receipt(merchant="Test Inc", total=1000)
|
||||
data2 = Receipt(merchant="Test Inc", total=1300)
|
||||
data3 = Receipt(merchant="Testing Inc", total=1100)
|
||||
item1 = await client.create_item(data1)
|
||||
item2 = await client.create_item(data2)
|
||||
item3 = await client.create_item(data3)
|
||||
result = await client.delete(filter={"merchant": {"eq": "Test Inc"}})
|
||||
assert result == 2
|
||||
for item in (item1, item2):
|
||||
assert item.id is not None
|
||||
try:
|
||||
notitem = await client.get_item(item.id)
|
||||
e = None
|
||||
except ApiError as err:
|
||||
e = err
|
||||
notitem = None
|
||||
assert notitem is None
|
||||
assert e is not None
|
||||
assert e.status_code == 404
|
||||
assert e.body == {"detail": f"No data with ID: {item.id}"}
|
||||
assert item3.id is not None
|
||||
itemfound = await client.get_item(item3.id)
|
||||
assert itemfound.id == item3.id
|
||||
Generated
+19
-19
@@ -1595,21 +1595,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "llama-cloud"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/eb/16e31fb0fc4df91b08fa19cc3f28ac6e3c7d4df0bcbb71dd2bf596e9586f/llama_cloud-0.1.44.tar.gz", hash = "sha256:276a2b4f94463da037431ca3063331b3b6be398bbfb003113ee76b7c2a873b53", size = 120502, upload-time = "2025-11-04T00:51:58.578Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/3a2a209f1c3fa516de172cb13e03f5a897adea5523f2ee0f544d035e3704/llama_cloud-0.1.45.tar.gz", hash = "sha256:140244008cc5710e31ae97c6043973a3a9969a51b0f38155fa33a8434078e8aa", size = 140968, upload-time = "2025-12-03T02:22:49.484Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/0a/fabe54c21d5927d626550cb9560a20e51e42468355f5f0fb300f84806e28/llama_cloud-0.1.44-py3-none-any.whl", hash = "sha256:dfdcc4932353711fc8639f14261cbb54a88139b7790ebdd3ed4fde29bbbc0b88", size = 332779, upload-time = "2025-11-04T00:51:57.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/1d/466b0df69b81ce9410ad6ec7229a1e6601ff69640f02f246e06cfcc7428c/llama_cloud-0.1.45-py3-none-any.whl", hash = "sha256:500299a6d3f25f97bcf6755d6338523023564fa8f376955c2cf299bbc9561cc2", size = 397184, upload-time = "2025-12-03T02:22:48.335Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-cloud-services"
|
||||
version = "0.6.81"
|
||||
version = "0.6.88"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
@@ -1621,7 +1621,6 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "respx" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
|
||||
@@ -1642,6 +1641,7 @@ dev = [
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "pytest-xdist" },
|
||||
]
|
||||
|
||||
@@ -1649,13 +1649,12 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.7,<9" },
|
||||
{ name = "eval-type-backport", marker = "python_full_version < '3.10'", specifier = ">=0.2.0,<0.3" },
|
||||
{ name = "llama-cloud", specifier = "==0.1.44" },
|
||||
{ name = "llama-cloud", specifier = "==0.1.45" },
|
||||
{ name = "llama-index-core", specifier = ">=0.12.0" },
|
||||
{ name = "packaging", specifier = ">=23.0" },
|
||||
{ name = "platformdirs", specifier = ">=4.3.7,<5" },
|
||||
{ name = "pydantic", specifier = ">=2.8,!=2.10" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1,<2" },
|
||||
{ name = "respx", extras = ["tests"], specifier = ">=0.22.0" },
|
||||
{ name = "tenacity", specifier = ">=8.5.0,<10.0" },
|
||||
]
|
||||
|
||||
@@ -1674,6 +1673,7 @@ dev = [
|
||||
{ name = "pydantic-settings", specifier = ">=2.10.1" },
|
||||
{ name = "pytest", specifier = ">=8.0.0,<9" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.6.1,<4" },
|
||||
]
|
||||
|
||||
@@ -3145,6 +3145,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-timeout"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.8.0"
|
||||
@@ -3591,18 +3603,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "respx"
|
||||
version = "0.22.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3339-validator"
|
||||
version = "0.1.4"
|
||||
|
||||
@@ -1,5 +1,50 @@
|
||||
# llama-cloud-services
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d7864af: bugfixes in retry logic for LlamaExtract and LlamaClassify
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 997bcc8: Add types for bounding boxes
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d5b18a0: Fix publishing
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 576c3d9: feat: support zod v4 & v3
|
||||
|
||||
Adds support for zod v4 while maintaining backward compatibility with v3.
|
||||
- Updated zod peer dependency to accept both v3 and v4: `^3.25.76 || ^4.0.0`
|
||||
- Migrated all import statements to use `zod/v4` import path for compatibility
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c8321d2: Improve parse results polling
|
||||
- 576c3d9: Support zod v3 an v4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 71db318: Add tier and version
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bfaec79: Update for new page number params
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+9469
-14839
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "llama-cloud-services",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"get-openapi": "node ./scripts/get-openapi.js",
|
||||
"generate": "./node_modules/.bin/openapi-ts",
|
||||
"build": "pnpm run generate && bunchee",
|
||||
"build": "bunchee",
|
||||
"dev": "bunchee --watch",
|
||||
"lint": "eslint src/ --ignore-pattern client/*.ts --no-warn-ignored",
|
||||
"format": "prettier --write ./src/ tests/",
|
||||
@@ -116,9 +116,9 @@
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@hey-api/client-fetch": "^0.10.1",
|
||||
"@hey-api/openapi-ts": "^0.67.5",
|
||||
"@llamaindex/core": "^0.6.19",
|
||||
"@llamaindex/core": "^0.6.22",
|
||||
"@llamaindex/env": "^0.1.30",
|
||||
"@llamaindex/workflow-core": "^0.4.1",
|
||||
"@llamaindex/workflow-core": "^1.3.3",
|
||||
"@types/node": "^20.19.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
@@ -131,18 +131,19 @@
|
||||
"turbo": "^2.5.5",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vitest": "^2.0.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@llamaindex/core": "^0.6.19",
|
||||
"@llamaindex/env": "^0.1.30",
|
||||
"@llamaindex/workflow-core": "^0.4.1"
|
||||
"@llamaindex/workflow-core": "^1.3.3",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"file-type": "^21.0.0",
|
||||
"p-retry": "^6.2.1",
|
||||
"zod": "^3.25.76"
|
||||
"p-retry": "^6.2.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.8.1"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
addFilesToPipelineApiApiV1PipelinesPipelineIdFilesPut,
|
||||
getPipelineFileStatusApiV1PipelinesPipelineIdFilesFileIdStatusGet,
|
||||
listPipelineFilesApiV1PipelinesPipelineIdFilesGet,
|
||||
listPipelineFiles2ApiV1PipelinesPipelineIdFiles2Get,
|
||||
listProjectsApiV1ProjectsGet,
|
||||
readFileContentApiV1FilesIdContentGet,
|
||||
searchPipelinesApiV1PipelinesGet,
|
||||
@@ -97,21 +97,20 @@ export class LLamaCloudFileService {
|
||||
*/
|
||||
public static async getFileUrl(pipelineId: string, filename: string) {
|
||||
initService();
|
||||
const { data: allPipelineFiles } =
|
||||
await listPipelineFilesApiV1PipelinesPipelineIdFilesGet({
|
||||
path: {
|
||||
pipeline_id: pipelineId,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
const file = allPipelineFiles.find((file) => file.name === filename);
|
||||
const response = await listPipelineFiles2ApiV1PipelinesPipelineIdFiles2Get({
|
||||
path: {
|
||||
pipeline_id: pipelineId,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
const file = response.data.files.find((file) => file.name === filename);
|
||||
if (!file?.file_id) return null;
|
||||
const { data: fileContent } = await readFileContentApiV1FilesIdContentGet({
|
||||
path: {
|
||||
id: file.file_id,
|
||||
},
|
||||
query: {
|
||||
project_id: file.project_id,
|
||||
project_id: file.project_id || null,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ export { AgentClient, createAgentDataClient } from "./client";
|
||||
|
||||
export type {
|
||||
AggregateAgentDataOptions,
|
||||
BoundingBox,
|
||||
ComparisonOperator,
|
||||
ExtractedData,
|
||||
ExtractedFieldMetadata,
|
||||
ExtractedFieldMetadataDict,
|
||||
FieldCitation,
|
||||
FilterOperation,
|
||||
PageDimensions,
|
||||
SearchAgentDataOptions,
|
||||
StatusType,
|
||||
TypedAgentData,
|
||||
|
||||
@@ -28,6 +28,44 @@ export type ComparisonOperator =
|
||||
*/
|
||||
export type FilterOperation = RawFilterOperation;
|
||||
|
||||
/**
|
||||
* Bounding box coordinates for a citation location on a page
|
||||
*/
|
||||
export interface BoundingBox {
|
||||
/** X coordinate of the bounding box origin */
|
||||
x: number;
|
||||
/** Y coordinate of the bounding box origin */
|
||||
y: number;
|
||||
/** Width of the bounding box */
|
||||
w: number;
|
||||
/** Height of the bounding box */
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions of a page in the source document
|
||||
*/
|
||||
export interface PageDimensions {
|
||||
/** Width of the page */
|
||||
width: number;
|
||||
/** Height of the page */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Citation information for an extracted field
|
||||
*/
|
||||
export interface FieldCitation {
|
||||
/** The page number that the field occurred on */
|
||||
page?: number;
|
||||
/** The original text this field's value was derived from */
|
||||
matching_text?: string;
|
||||
/** Bounding boxes indicating where the citation appears on the page */
|
||||
bounding_boxes?: BoundingBox[];
|
||||
/** Dimensions of the page containing the citation */
|
||||
page_dimensions?: PageDimensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for an extracted field, including confidence and citation information
|
||||
*/
|
||||
@@ -38,16 +76,11 @@ export interface ExtractedFieldMetadata {
|
||||
confidence?: number;
|
||||
/** The confidence score for the field based on the extracted text only */
|
||||
extraction_confidence?: number;
|
||||
/** The confidence score for the field based on the parsing/OCR quality */
|
||||
parsing_confidence?: number;
|
||||
citation?: FieldCitation[];
|
||||
}
|
||||
|
||||
export interface FieldCitation {
|
||||
/** The page number that the field occurred on */
|
||||
page?: number;
|
||||
/** The original text this field's value was derived from */
|
||||
matching_text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dictionary mapping field names to their metadata
|
||||
* Values can be ExtractedFieldMetadata objects, nested dictionaries, or arrays
|
||||
|
||||
@@ -108,20 +108,19 @@ async function pollForJobCompletion({
|
||||
}
|
||||
const response =
|
||||
await getClassifyJobApiV1ClassifierJobsClassifyJobIdGet(jobOptions);
|
||||
if (!response.response.ok) {
|
||||
numIterations++;
|
||||
}
|
||||
if (typeof response.data != "undefined") {
|
||||
status = response.data.status as StatusEnum;
|
||||
if (status == StatusEnum.CANCELLED || status == StatusEnum.ERROR) {
|
||||
throw new Error("There was an error during the classification job.");
|
||||
} else if (status == StatusEnum.SUCCESS) {
|
||||
throw new Error("There was an error extracting data from your file.");
|
||||
} else if (
|
||||
status == StatusEnum.SUCCESS ||
|
||||
status == StatusEnum.PARTIAL_SUCCESS
|
||||
) {
|
||||
return true;
|
||||
} else {
|
||||
numIterations++;
|
||||
await sleep(interval * 1000);
|
||||
}
|
||||
}
|
||||
numIterations++;
|
||||
await sleep(interval * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +168,7 @@ async function getJobResult({
|
||||
retries++;
|
||||
await sleep(retryInterval * 1000);
|
||||
}
|
||||
if (typeof response.data != "undefined") {
|
||||
if (response.response.ok && typeof response.data != "undefined") {
|
||||
return response.data as ClassifyJobResults;
|
||||
} else {
|
||||
throw new Error(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { workflowEvent } from "@llamaindex/workflow-core";
|
||||
import { zodEvent } from "@llamaindex/workflow-core/util/zod";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
import { parseFormSchema } from "./schema";
|
||||
|
||||
export const uploadEvent = zodEvent(
|
||||
|
||||
@@ -296,20 +296,16 @@ async function pollForJobCompletion(
|
||||
return false;
|
||||
}
|
||||
const response = await getJobApiV1ExtractionJobsJobIdGet(jobOptions);
|
||||
if (!response.response.ok) {
|
||||
numIterations++;
|
||||
}
|
||||
if (typeof response.data != "undefined") {
|
||||
status = response.data.status as StatusEnum;
|
||||
if (status == StatusEnum.CANCELLED || status == StatusEnum.ERROR) {
|
||||
throw new Error("There was an error extracting data from your file.");
|
||||
} else if (status == StatusEnum.SUCCESS) {
|
||||
return true;
|
||||
} else {
|
||||
numIterations++;
|
||||
await sleep(interval * 1000);
|
||||
}
|
||||
}
|
||||
numIterations++;
|
||||
await sleep(interval * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +346,7 @@ async function getJobResult(
|
||||
retries++;
|
||||
await sleep(retryInterval * 1000);
|
||||
}
|
||||
if (typeof response.data != "undefined") {
|
||||
if (response.response.ok && typeof response.data != "undefined") {
|
||||
return {
|
||||
data: response.data.data,
|
||||
extractionMetadata: response.data.extraction_metadata,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { JSONValue } from "@llamaindex/core/global";
|
||||
import type { ToolMetadata } from "@llamaindex/core/llms";
|
||||
import type { BaseQueryEngine } from "@llamaindex/core/query-engine";
|
||||
import { tool } from "@llamaindex/core/tools";
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const DEFAULT_NAME = "llama_cloud_index_tool";
|
||||
const DEFAULT_DESCRIPTION =
|
||||
@@ -21,9 +21,7 @@ export function createQueryEngineTool(
|
||||
name: metadata?.name ?? DEFAULT_NAME,
|
||||
description: metadata?.description ?? DEFAULT_DESCRIPTION,
|
||||
parameters: z.object({
|
||||
query: z.string({
|
||||
description: "The query to search for",
|
||||
}),
|
||||
query: z.string().describe("The query to search for"),
|
||||
}),
|
||||
execute: async ({ query }) => {
|
||||
const response = await queryEngine.query({ query });
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { type Client, createClient, createConfig } from "@hey-api/client-fetch";
|
||||
import { type FailedAttemptError } from "p-retry";
|
||||
import { Document, FileReader } from "@llamaindex/core/schema";
|
||||
import { fs, getEnv, path } from "@llamaindex/env";
|
||||
import {
|
||||
type BodyUploadFileApiParsingUploadPost,
|
||||
type BodyUploadFileApiV1ParsingUploadPost,
|
||||
type FailPageMode,
|
||||
type ParserLanguages,
|
||||
type ParsingMode,
|
||||
@@ -32,6 +33,33 @@ type WriteStream = {
|
||||
// eslint-disable-next-line no-var
|
||||
var process: any;
|
||||
|
||||
function handleFailedAttempt(
|
||||
error: FailedAttemptError,
|
||||
jobId: string,
|
||||
verbose: boolean,
|
||||
) {
|
||||
// Retry only on 5XX or socket errors.
|
||||
const status = (error.cause as any)?.response?.status;
|
||||
if (
|
||||
!(
|
||||
(status && status >= 500 && status < 600) ||
|
||||
((error.cause as any)?.code &&
|
||||
((error.cause as any).code === "ECONNRESET" ||
|
||||
(error.cause as any).code === "ETIMEDOUT" ||
|
||||
(error.cause as any).code === "ECONNREFUSED")) ||
|
||||
(status && status === 404)
|
||||
)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (verbose) {
|
||||
console.warn(
|
||||
`Attempting to get job ${jobId} result (attempt ${error.attemptNumber}) failed. Retrying...`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a reader for parsing files using the LlamaParse API.
|
||||
* See https://github.com/run-llama/llama_parse
|
||||
@@ -185,6 +213,19 @@ export class LlamaParseReader extends FileReader {
|
||||
page_footer_prefix?: string | undefined;
|
||||
page_footer_suffix?: string | undefined;
|
||||
merge_tables_across_pages_in_markdown?: boolean | undefined;
|
||||
extract_printed_page_number?: boolean | undefined;
|
||||
tier?: string | undefined;
|
||||
version?: string | undefined;
|
||||
layout_aware?: boolean | undefined;
|
||||
line_level_bounding_box?: boolean | undefined;
|
||||
specialized_image_parsing?: boolean | undefined;
|
||||
aggressive_table_extraction?: boolean | undefined;
|
||||
preserve_very_small_text?: boolean | undefined;
|
||||
spreadsheet_force_formula_computation?: boolean | undefined;
|
||||
inline_images_in_markdown?: boolean | undefined;
|
||||
keep_page_separator_when_merging_tables?: boolean | undefined;
|
||||
remove_hidden_text?: boolean | undefined;
|
||||
presentation_out_of_bounds_content?: boolean | undefined;
|
||||
|
||||
constructor(
|
||||
params: Partial<Omit<LlamaParseReader, "language" | "apiKey">> & {
|
||||
@@ -381,11 +422,28 @@ export class LlamaParseReader extends FileReader {
|
||||
page_footer_suffix: this.page_footer_suffix,
|
||||
merge_tables_across_pages_in_markdown:
|
||||
this.merge_tables_across_pages_in_markdown,
|
||||
extract_printed_page_number: this.extract_printed_page_number,
|
||||
tier: this.tier,
|
||||
version: this.version,
|
||||
layout_aware: this.layout_aware,
|
||||
line_level_bounding_box: this.line_level_bounding_box,
|
||||
specialized_image_parsing: this.specialized_image_parsing,
|
||||
aggressive_table_extraction: this.aggressive_table_extraction,
|
||||
preserve_very_small_text: this.preserve_very_small_text,
|
||||
spreadsheet_force_formula_computation:
|
||||
this.spreadsheet_force_formula_computation,
|
||||
inline_images_in_markdown: this.inline_images_in_markdown,
|
||||
webhook_configurations: undefined,
|
||||
keep_page_separator_when_merging_tables:
|
||||
this.keep_page_separator_when_merging_tables,
|
||||
remove_hidden_text: this.remove_hidden_text,
|
||||
presentation_out_of_bounds_content:
|
||||
this.presentation_out_of_bounds_content,
|
||||
} satisfies {
|
||||
[Key in keyof BodyUploadFileApiParsingUploadPost]-?:
|
||||
| BodyUploadFileApiParsingUploadPost[Key]
|
||||
[Key in keyof BodyUploadFileApiV1ParsingUploadPost]-?:
|
||||
| BodyUploadFileApiV1ParsingUploadPost[Key]
|
||||
| undefined;
|
||||
} as unknown as BodyUploadFileApiParsingUploadPost;
|
||||
} as unknown as BodyUploadFileApiV1ParsingUploadPost;
|
||||
|
||||
const response = await uploadFileApiV1ParsingUploadPost({
|
||||
client: this.#client,
|
||||
@@ -437,26 +495,8 @@ export class LlamaParseReader extends FileReader {
|
||||
}),
|
||||
{
|
||||
retries: this.maxErrorCount,
|
||||
onFailedAttempt: (error) => {
|
||||
// Retry only on 5XX or socket errors.
|
||||
const status = (error.cause as any)?.response?.status;
|
||||
if (
|
||||
!(
|
||||
(status && status >= 500 && status < 600) ||
|
||||
((error.cause as any)?.code &&
|
||||
((error.cause as any).code === "ECONNRESET" ||
|
||||
(error.cause as any).code === "ETIMEDOUT" ||
|
||||
(error.cause as any).code === "ECONNREFUSED"))
|
||||
)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (this.verbose) {
|
||||
console.warn(
|
||||
`Attempting to get job ${jobId} result (attempt ${error.attemptNumber}) failed. Retrying...`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onFailedAttempt: (error) =>
|
||||
handleFailedAttempt(error, jobId, this.verbose),
|
||||
},
|
||||
);
|
||||
} catch (e: any) {
|
||||
@@ -469,49 +509,69 @@ export class LlamaParseReader extends FileReader {
|
||||
const status = (data as Record<string, unknown>)["status"];
|
||||
|
||||
if (status === "SUCCESS") {
|
||||
let resultData;
|
||||
switch (resultType) {
|
||||
case "json": {
|
||||
resultData =
|
||||
await getJobJsonResultApiV1ParsingJobJobIdResultJsonGet({
|
||||
client: this.#client,
|
||||
throwOnError: true,
|
||||
path: { job_id: jobId },
|
||||
query: {
|
||||
organization_id: this.organization_id ?? null,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.maxTimeout * 1000),
|
||||
});
|
||||
break;
|
||||
const resultData = await pRetry(
|
||||
() =>
|
||||
getJobJsonResultApiV1ParsingJobJobIdResultJsonGet({
|
||||
client: this.#client,
|
||||
throwOnError: true,
|
||||
path: { job_id: jobId },
|
||||
query: {
|
||||
organization_id: this.organization_id ?? null,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.maxTimeout * 1000),
|
||||
}),
|
||||
{
|
||||
retries: this.maxErrorCount,
|
||||
onFailedAttempt: (error) =>
|
||||
handleFailedAttempt(error, jobId, this.verbose),
|
||||
},
|
||||
);
|
||||
return resultData.data;
|
||||
}
|
||||
case "markdown": {
|
||||
resultData =
|
||||
await getJobResultApiV1ParsingJobJobIdResultMarkdownGet({
|
||||
client: this.#client,
|
||||
throwOnError: true,
|
||||
path: { job_id: jobId },
|
||||
query: {
|
||||
organization_id: this.organization_id ?? null,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.maxTimeout * 1000),
|
||||
});
|
||||
break;
|
||||
const resultData = await pRetry(
|
||||
() =>
|
||||
getJobResultApiV1ParsingJobJobIdResultMarkdownGet({
|
||||
client: this.#client,
|
||||
throwOnError: true,
|
||||
path: { job_id: jobId },
|
||||
query: {
|
||||
organization_id: this.organization_id ?? null,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.maxTimeout * 1000),
|
||||
}),
|
||||
{
|
||||
retries: this.maxErrorCount,
|
||||
onFailedAttempt: (error) =>
|
||||
handleFailedAttempt(error, jobId, this.verbose),
|
||||
},
|
||||
);
|
||||
return resultData.data;
|
||||
}
|
||||
case "text": {
|
||||
resultData =
|
||||
await getJobTextResultApiV1ParsingJobJobIdResultTextGet({
|
||||
client: this.#client,
|
||||
throwOnError: true,
|
||||
path: { job_id: jobId },
|
||||
query: {
|
||||
organization_id: this.organization_id ?? null,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.maxTimeout * 1000),
|
||||
});
|
||||
break;
|
||||
const resultData = await pRetry(
|
||||
() =>
|
||||
getJobTextResultApiV1ParsingJobJobIdResultTextGet({
|
||||
client: this.#client,
|
||||
throwOnError: true,
|
||||
path: { job_id: jobId },
|
||||
query: {
|
||||
organization_id: this.organization_id ?? null,
|
||||
},
|
||||
signal: AbortSignal.timeout(this.maxTimeout * 1000),
|
||||
}),
|
||||
{
|
||||
retries: this.maxErrorCount,
|
||||
onFailedAttempt: (error) =>
|
||||
handleFailedAttempt(error, jobId, this.verbose),
|
||||
},
|
||||
);
|
||||
|
||||
return resultData.data;
|
||||
}
|
||||
}
|
||||
return resultData.data;
|
||||
} else if (status === "PENDING") {
|
||||
if (this.verbose && tries % 10 === 0) {
|
||||
this.stdout?.write(".");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FailPageMode, ParserLanguages, ParsingMode } from "./client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
type Language = ParserLanguages;
|
||||
const VALUES: [Language, ...Language[]] = [
|
||||
@@ -52,9 +52,10 @@ export const parseFormSchema = z.object({
|
||||
html_remove_navigation_elements: z.boolean().optional(),
|
||||
http_proxy: z
|
||||
.string()
|
||||
.url(
|
||||
'Set a valid URL for the HTTP proxy, e.g., "http://proxy.example.com:8080"',
|
||||
)
|
||||
.url({
|
||||
error:
|
||||
'Set a valid URL for the HTTP proxy, e.g., "http://proxy.example.com:8080"',
|
||||
})
|
||||
.refine(
|
||||
(url) => {
|
||||
try {
|
||||
@@ -67,7 +68,7 @@ export const parseFormSchema = z.object({
|
||||
}
|
||||
},
|
||||
{
|
||||
message: "Invalid HTTP proxy URL",
|
||||
error: "Invalid HTTP proxy URL",
|
||||
},
|
||||
)
|
||||
.optional(),
|
||||
@@ -100,7 +101,7 @@ export const parseFormSchema = z.object({
|
||||
vendor_multimodal_model_name: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
webhook_url: z.string().url().optional(),
|
||||
parse_mode: z.nativeEnum(ParsingMode).nullable().optional(),
|
||||
parse_mode: z.enum(ParsingMode).nullable().optional(),
|
||||
system_prompt: z.string().optional(),
|
||||
system_prompt_append: z.string().optional(),
|
||||
user_prompt: z.string().optional(),
|
||||
@@ -129,7 +130,7 @@ export const parseFormSchema = z.object({
|
||||
compact_markdown_table: z.boolean().optional(),
|
||||
markdown_table_multiline_header_separator: z.string().optional(),
|
||||
page_error_tolerance: z.number().min(0).max(1).optional(),
|
||||
replace_failed_page_mode: z.nativeEnum(FailPageMode).nullable().optional(),
|
||||
replace_failed_page_mode: z.enum(FailPageMode).nullable().optional(),
|
||||
replace_failed_page_with_error_message_prefix: z.string().optional(),
|
||||
replace_failed_page_with_error_message_suffix: z.string().optional(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user