Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e110334273 | |||
| 3b48ea78c0 | |||
| 5a5986e59b | |||
| 8aa5ab0756 | |||
| 30e36f3cc3 | |||
| 549efbd8b9 | |||
| d312dc9890 | |||
| 10ab5fd2b9 | |||
| ad38ef5cd7 | |||
| 4c4c6e6575 | |||
| 740b47d9dc | |||
| f3233deb2e | |||
| fd45127678 | |||
| 0506c88735 | |||
| 4bc9eb6c0d | |||
| 5a3dac655c | |||
| 519254efbe | |||
| 6ab56b79f3 | |||
| e020e3e2b1 | |||
| f293547910 | |||
| 662bc37462 | |||
| 9f1ef4ef1f | |||
| 1243573924 | |||
| 407292b177 | |||
| a7df7c0912 | |||
| c758144bfe | |||
| fee516dd19 | |||
| 032fbd5768 | |||
| 970e864514 | |||
| d0649ece6e | |||
| 5d4cabd843 | |||
| 9070a6ac16 | |||
| 4f24f537f6 | |||
| 8859a203e2 | |||
| b091364054 | |||
| 43b1a013ca | |||
| f81532e7f2 | |||
| 986d3987d3 | |||
| 1bf522311f | |||
| 24166dcfc8 | |||
| bfb7f3973f | |||
| 979f643c77 | |||
| aefd89cf1b | |||
| 8ea2b2c64e | |||
| 4a9a2a21d8 | |||
| e6a7939206 | |||
| 104a03e829 | |||
| 6e0f2f4ca0 | |||
| 0708d11f8a | |||
| be19185503 | |||
| 7571b0d6c4 | |||
| ad6734bf80 | |||
| 9ec2a8322e | |||
| 51011b9f30 | |||
| 09805f9e15 | |||
| 8ced6f6eab | |||
| 081ddeca34 | |||
| 2460908789 | |||
| c226d6a54c | |||
| 5d4c682eb2 | |||
| f72d3535c8 | |||
| 1ea09a366e | |||
| d4bbeb6389 | |||
| d028397603 | |||
| 35ea8476db | |||
| 3e5f7c4f1e | |||
| 9d9b816644 | |||
| 83555f76e6 | |||
| 5edf5f914a | |||
| 22e4975cb2 | |||
| bc2f04379b | |||
| f9f951d5d8 | |||
| 355129fea5 |
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: "ts/llama_cloud_services/.nvmrc"
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: python
|
||||
dependency-caching: true
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: "ts/llama_cloud_services/.nvmrc"
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
name: Publish Release - Python
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
UV_VERSION: "0.7.20"
|
||||
|
||||
jobs:
|
||||
build-n-publish:
|
||||
name: Build and publish to PyPI
|
||||
if: github.repository == 'run-llama/llama_cloud_services'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install
|
||||
|
||||
- name: Display Python version
|
||||
run: python --version
|
||||
|
||||
- name: Build
|
||||
working-directory: py
|
||||
run: uv build
|
||||
|
||||
- name: Test installing built package
|
||||
shell: bash
|
||||
working-directory: py
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install dist/*.whl
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
working-directory: py
|
||||
run: uv publish --token ${{ secrets.LLAMA_PARSE_PYPI_TOKEN }}
|
||||
|
||||
- name: Build and publish llama-parse
|
||||
working-directory: py/llama_parse/
|
||||
run: |
|
||||
uv build
|
||||
uv publish --token ${{ secrets.LLAMA_PARSE_PYPI_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }} - LlamaCloud Services PY
|
||||
artifacts: "py/**/dist/*"
|
||||
generateReleaseNotes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Publish Release - TypeScript
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "llama-cloud-services@*"
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: "ts/llama_cloud_services/.nvmrc"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Run Build
|
||||
working-directory: ts/llama_cloud_services/
|
||||
run: pnpm build
|
||||
|
||||
- name: Build tarball
|
||||
run: |
|
||||
pnpm pack
|
||||
working-directory: ts/llama_cloud_services
|
||||
|
||||
- name: Setup npm authentication
|
||||
run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Release
|
||||
working-directory: ts/llama_cloud_services
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: pnpm publish --access public --no-git-checks
|
||||
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
artifacts: "ts/llama_cloud_services/llama-cloud-services*.tgz"
|
||||
name: Release ${{ github.ref_name }} - LlamaCloud Services TS
|
||||
generateReleaseNotes: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: "ts/llama_cloud_services/.nvmrc"
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Version Bump and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on main branch pushes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Add auth token to .npmrc file
|
||||
run: |
|
||||
cat << EOF >> ".npmrc"
|
||||
//registry.npmjs.org/:_authToken=$NPM_TOKEN
|
||||
EOF
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Create Release Pull Request or Publish packages
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: "chore: version packages"
|
||||
title: "chore: version packages"
|
||||
# Custom version script
|
||||
version: pnpm -w run version
|
||||
# Custom publish script
|
||||
publish: pnpm -w run publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
LLAMA_PARSE_PYPI_TOKEN: ${{ secrets.LLAMA_PARSE_PYPI_TOKEN }}
|
||||
@@ -9,3 +9,4 @@ __pycache__/
|
||||
node_modules/
|
||||
.turbo/
|
||||
dist/
|
||||
.npmrc
|
||||
|
||||
@@ -29,12 +29,12 @@ repos:
|
||||
- id: black-jupyter
|
||||
name: black-src
|
||||
alias: black
|
||||
exclude: ".*uv.lock"
|
||||
exclude: ".*uv.lock|examples/extract/solar_panel_e2e_comparison.ipynb"
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.0.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: ^py/tests|^py/unit_tests
|
||||
exclude: ^py/tests|^py/unit_tests|^examples
|
||||
additional_dependencies:
|
||||
[
|
||||
"types-requests",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
.DS_Store
|
||||
.cache
|
||||
.env
|
||||
.vercel
|
||||
.output
|
||||
.nitro
|
||||
/build/
|
||||
/api/
|
||||
/server/build
|
||||
/public/build# Sentry Config File
|
||||
.env.sentry-build-plugin
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.tanstack
|
||||
.vscode
|
||||
@@ -0,0 +1,4 @@
|
||||
**/build
|
||||
**/public
|
||||
pnpm-lock.yaml
|
||||
routeTree.gen.ts
|
||||
@@ -0,0 +1,88 @@
|
||||
# LlamaClassify Demo
|
||||
|
||||
A TypeScript demo application showcasing the power of **LlamaClassify** - an agentic documents classification service from [LlamaCloud](https://cloud.llamaindex.ai). This demo allows you to classify financial documents among three different types (Cash flow statement, Income Statement and Balance Sheet).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Start the Demo](#start-the-demo)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Common Issues](#common-issues)
|
||||
- [License](#license)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Features
|
||||
|
||||
- 📄 **Documemt Classification**: Classify files based on well-defined rules you can customized and play around with.
|
||||
- 🤖 **Reasoning-based Actionable Insights**: Get in-depth, reasoning based insights on the document classification, accompanied by confidence scores.
|
||||
- 🎨 **Beautiful UI**: [DaisyUI](https://daisyui.com)-based interface powered by [TanStack](https://tanstack.com)
|
||||
- ⚡ **Fast Development**: Hot reload support with development mode
|
||||
- 🛠️ **TypeScript**: Full TypeScript support with strict type checking
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (version 22 or higher)
|
||||
- pnpm package manager
|
||||
- LlamaCloud API key
|
||||
|
||||
## Installation
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/run-llama/llama_cloud_services
|
||||
cd lama_cloud_services/examples-ts/classify/
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Set up your environment variables:
|
||||
|
||||
```bash
|
||||
# Add your API key to your environment
|
||||
export LLAMA_CLOUD_API_KEY="your-llamacloud-api-key"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Start the Demo
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be up and running on http://localhost:3000
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Document Input**: Enter the path to your document when prompted
|
||||
2. **Parsing**: LlamaClassify, based on the rules you can find [here](./src/utils/classifier.ts), processes the document and classifies it
|
||||
3. **Results**: The classification outcome, as well as the reasoning behind it and the confidence score, are displayed in the UI.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Module Resolution Errors**: Ensure you're using Node.js 22+ and have all dependencies installed
|
||||
2. **API Key Issues**: Verify your LlamaCloud API key is correctly set
|
||||
3. **File Path Errors**: Use absolute paths or ensure relative paths are correct from the project root
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see the [LICENSE](../../LICENSE) file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Run `npm run format` and `npm run lint`
|
||||
5. Submit a pull request
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "tanstack-start-example-basic",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"start": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.133.22",
|
||||
"@tanstack/react-router-devtools": "^1.133.22",
|
||||
"@tanstack/react-start": "^1.133.22",
|
||||
"llama-cloud-services": "file:../../ts/llama_cloud_services",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.15",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"daisyui": "^5.3.7",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^4.1.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ErrorComponent,
|
||||
Link,
|
||||
rootRouteId,
|
||||
useMatch,
|
||||
useRouter,
|
||||
} from '@tanstack/react-router'
|
||||
import type { ErrorComponentProps } from '@tanstack/react-router'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter()
|
||||
const isRoot = useMatch({
|
||||
strict: false,
|
||||
select: (state) => state.id === rootRouteId,
|
||||
})
|
||||
|
||||
console.error('DefaultCatchBoundary Error:', error)
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 p-4 flex flex-col items-center justify-center gap-6">
|
||||
<ErrorComponent error={error} />
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.invalidate()
|
||||
}}
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded-sm text-white uppercase font-extrabold`}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
{isRoot ? (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded-sm text-white uppercase font-extrabold`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded-sm text-white uppercase font-extrabold`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.history.back()
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function NotFound({ children }: { children?: any }) {
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
{children || <p>The page you are looking for does not exist.</p>}
|
||||
</div>
|
||||
<p className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="bg-emerald-500 text-white px-2 py-1 rounded-sm uppercase font-black text-sm"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="bg-cyan-600 text-white px-2 py-1 rounded-sm uppercase font-black text-sm"
|
||||
>
|
||||
Start Over
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as UsersRouteImport } from './routes/users'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as UsersIndexRouteImport } from './routes/users.index'
|
||||
import { Route as PostsIndexRouteImport } from './routes/posts.index'
|
||||
import { Route as UsersUserIdRouteImport } from './routes/users.$userId'
|
||||
import { Route as PostsPostIdRouteImport } from './routes/posts.$postId'
|
||||
import { Route as ApiClassifyRouteImport } from './routes/api/classify'
|
||||
import { Route as PostsPostIdDeepRouteImport } from './routes/posts_.$postId.deep'
|
||||
|
||||
const UsersRoute = UsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const UsersIndexRoute = UsersIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => UsersRoute,
|
||||
} as any)
|
||||
const PostsIndexRoute = PostsIndexRouteImport.update({
|
||||
id: '/posts/',
|
||||
path: '/posts/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const UsersUserIdRoute = UsersUserIdRouteImport.update({
|
||||
id: '/$userId',
|
||||
path: '/$userId',
|
||||
getParentRoute: () => UsersRoute,
|
||||
} as any)
|
||||
const PostsPostIdRoute = PostsPostIdRouteImport.update({
|
||||
id: '/posts/$postId',
|
||||
path: '/posts/$postId',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiClassifyRoute = ApiClassifyRouteImport.update({
|
||||
id: '/api/classify',
|
||||
path: '/api/classify',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PostsPostIdDeepRoute = PostsPostIdDeepRouteImport.update({
|
||||
id: '/posts_/$postId/deep',
|
||||
path: '/posts/$postId/deep',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/users': typeof UsersRouteWithChildren
|
||||
'/api/classify': typeof ApiClassifyRoute
|
||||
'/posts/$postId': typeof PostsPostIdRoute
|
||||
'/users/$userId': typeof UsersUserIdRoute
|
||||
'/posts': typeof PostsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/api/classify': typeof ApiClassifyRoute
|
||||
'/posts/$postId': typeof PostsPostIdRoute
|
||||
'/users/$userId': typeof UsersUserIdRoute
|
||||
'/posts': typeof PostsIndexRoute
|
||||
'/users': typeof UsersIndexRoute
|
||||
'/posts/$postId/deep': typeof PostsPostIdDeepRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/users': typeof UsersRouteWithChildren
|
||||
'/api/classify': typeof ApiClassifyRoute
|
||||
'/posts/$postId': typeof PostsPostIdRoute
|
||||
'/users/$userId': typeof UsersUserIdRoute
|
||||
'/posts/': typeof PostsIndexRoute
|
||||
'/users/': typeof UsersIndexRoute
|
||||
'/posts_/$postId/deep': typeof PostsPostIdDeepRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/users'
|
||||
| '/api/classify'
|
||||
| '/posts/$postId'
|
||||
| '/users/$userId'
|
||||
| '/posts'
|
||||
| '/users/'
|
||||
| '/posts/$postId/deep'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/api/classify'
|
||||
| '/posts/$postId'
|
||||
| '/users/$userId'
|
||||
| '/posts'
|
||||
| '/users'
|
||||
| '/posts/$postId/deep'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/users'
|
||||
| '/api/classify'
|
||||
| '/posts/$postId'
|
||||
| '/users/$userId'
|
||||
| '/posts/'
|
||||
| '/users/'
|
||||
| '/posts_/$postId/deep'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
UsersRoute: typeof UsersRouteWithChildren
|
||||
ApiClassifyRoute: typeof ApiClassifyRoute
|
||||
PostsPostIdRoute: typeof PostsPostIdRoute
|
||||
PostsIndexRoute: typeof PostsIndexRoute
|
||||
PostsPostIdDeepRoute: typeof PostsPostIdDeepRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/users': {
|
||||
id: '/users'
|
||||
path: '/users'
|
||||
fullPath: '/users'
|
||||
preLoaderRoute: typeof UsersRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/users/': {
|
||||
id: '/users/'
|
||||
path: '/'
|
||||
fullPath: '/users/'
|
||||
preLoaderRoute: typeof UsersIndexRouteImport
|
||||
parentRoute: typeof UsersRoute
|
||||
}
|
||||
'/posts/': {
|
||||
id: '/posts/'
|
||||
path: '/posts'
|
||||
fullPath: '/posts'
|
||||
preLoaderRoute: typeof PostsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/users/$userId': {
|
||||
id: '/users/$userId'
|
||||
path: '/$userId'
|
||||
fullPath: '/users/$userId'
|
||||
preLoaderRoute: typeof UsersUserIdRouteImport
|
||||
parentRoute: typeof UsersRoute
|
||||
}
|
||||
'/posts/$postId': {
|
||||
id: '/posts/$postId'
|
||||
path: '/posts/$postId'
|
||||
fullPath: '/posts/$postId'
|
||||
preLoaderRoute: typeof PostsPostIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/classify': {
|
||||
id: '/api/classify'
|
||||
path: '/api/classify'
|
||||
fullPath: '/api/classify'
|
||||
preLoaderRoute: typeof ApiClassifyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/posts_/$postId/deep': {
|
||||
id: '/posts_/$postId/deep'
|
||||
path: '/posts/$postId/deep'
|
||||
fullPath: '/posts/$postId/deep'
|
||||
preLoaderRoute: typeof PostsPostIdDeepRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface UsersRouteChildren {
|
||||
UsersUserIdRoute: typeof UsersUserIdRoute
|
||||
UsersIndexRoute: typeof UsersIndexRoute
|
||||
}
|
||||
|
||||
const UsersRouteChildren: UsersRouteChildren = {
|
||||
UsersUserIdRoute: UsersUserIdRoute,
|
||||
UsersIndexRoute: UsersIndexRoute,
|
||||
}
|
||||
|
||||
const UsersRouteWithChildren = UsersRoute._addFileChildren(UsersRouteChildren)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
UsersRoute: UsersRouteWithChildren,
|
||||
ApiClassifyRoute: ApiClassifyRoute,
|
||||
PostsPostIdRoute: PostsPostIdRoute,
|
||||
PostsIndexRoute: PostsIndexRoute,
|
||||
PostsPostIdDeepRoute: PostsPostIdDeepRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
|
||||
import { NotFound } from './components/NotFound'
|
||||
|
||||
export function getRouter() {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
defaultNotFoundComponent: () => <NotFound />,
|
||||
scrollRestoration: true,
|
||||
})
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/// <reference types="vite/client" />
|
||||
import {
|
||||
HeadContent,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
} from '@tanstack/react-router'
|
||||
import * as React from 'react'
|
||||
import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary'
|
||||
import { NotFound } from '~/components/NotFound'
|
||||
import { seo } from '~/utils/seo'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
...seo({
|
||||
title:
|
||||
'Financial Documents Classification Agent',
|
||||
description: `Classify financial documents as balance sheets, income statements and cash flow statemets. `,
|
||||
}),
|
||||
],
|
||||
links: [
|
||||
{ rel: 'stylesheet', href: "https://cdn.jsdelivr.net/npm/daisyui@5" },
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
sizes: '180x180',
|
||||
href: '/apple-touch-icon.png',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
sizes: '32x32',
|
||||
href: '/favicon-32x32.png',
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
sizes: '16x16',
|
||||
href: '/favicon-16x16.png',
|
||||
},
|
||||
{ rel: 'manifest', href: '/site.webmanifest', color: '#fffff' },
|
||||
{ rel: 'icon', href: '/favicon.ico' },
|
||||
],
|
||||
scripts: [
|
||||
{
|
||||
src: '/customScript.js',
|
||||
type: 'text/javascript',
|
||||
},
|
||||
{
|
||||
src: "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
|
||||
type: "text/javascript",
|
||||
}
|
||||
],
|
||||
}),
|
||||
errorComponent: DefaultCatchBoundary,
|
||||
notFoundComponent: () => <NotFound />,
|
||||
shellComponent: RootDocument,
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
<div className="navbar bg-base-100 shadow-sm">
|
||||
<div className="navbar-start">
|
||||
<div className="dropdown">
|
||||
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="menu menu-lg dropdown-content bg-base-100 rounded-box z-1 mt-3 w-80 p-2 shadow"
|
||||
>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="https://cloud.llamaindex.ai">Get Started with LlamaCloud</a></li>
|
||||
<li><a href="https://developers.llamaindex.ai/python/cloud/llamaclassify/getting_started/">LlamaClassify Docs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="navbar-center">
|
||||
<a className="btn btn-ghost text-xl" href="/">Financial Documents Classification Agent</a>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<a href="https://github.com/run-llama/llama_cloud_services/main/blob/examples-ts/classify">
|
||||
<button className="btn btn-ghost btn-circle">
|
||||
<div className="indicator">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-10 w-10"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 640 512"
|
||||
>
|
||||
<path d="M237.9 461.4C237.9 463.4 235.6 465 232.7 465C229.4 465.3 227.1 463.7 227.1 461.4C227.1 459.4 229.4 457.8 232.3 457.8C235.3 457.5 237.9 459.1 237.9 461.4zM206.8 456.9C206.1 458.9 208.1 461.2 211.1 461.8C213.7 462.8 216.7 461.8 217.3 459.8C217.9 457.8 216 455.5 213 454.6C210.4 453.9 207.5 454.9 206.8 456.9zM251 455.2C248.1 455.9 246.1 457.8 246.4 460.1C246.7 462.1 249.3 463.4 252.3 462.7C255.2 462 257.2 460.1 256.9 458.1C256.6 456.2 253.9 454.9 251 455.2zM316.8 72C178.1 72 72 177.3 72 316C72 426.9 141.8 521.8 241.5 555.2C254.3 557.5 258.8 549.6 258.8 543.1C258.8 536.9 258.5 502.7 258.5 481.7C258.5 481.7 188.5 496.7 173.8 451.9C173.8 451.9 162.4 422.8 146 415.3C146 415.3 123.1 399.6 147.6 399.9C147.6 399.9 172.5 401.9 186.2 425.7C208.1 464.3 244.8 453.2 259.1 446.6C261.4 430.6 267.9 419.5 275.1 412.9C219.2 406.7 162.8 398.6 162.8 302.4C162.8 274.9 170.4 261.1 186.4 243.5C183.8 237 175.3 210.2 189 175.6C209.9 169.1 258 202.6 258 202.6C278 197 299.5 194.1 320.8 194.1C342.1 194.1 363.6 197 383.6 202.6C383.6 202.6 431.7 169 452.6 175.6C466.3 210.3 457.8 237 455.2 243.5C471.2 261.2 481 275 481 302.4C481 398.9 422.1 406.6 366.2 412.9C375.4 420.8 383.2 435.8 383.2 459.3C383.2 493 382.9 534.7 382.9 542.9C382.9 549.4 387.5 557.3 400.2 555C500.2 521.8 568 426.9 568 316C568 177.3 455.5 72 316.8 72zM169.2 416.9C167.9 417.9 168.2 420.2 169.9 422.1C171.5 423.7 173.8 424.4 175.1 423.1C176.4 422.1 176.1 419.8 174.4 417.9C172.8 416.3 170.5 415.6 169.2 416.9zM158.4 408.8C157.7 410.1 158.7 411.7 160.7 412.7C162.3 413.7 164.3 413.4 165 412C165.7 410.7 164.7 409.1 162.7 408.1C160.7 407.5 159.1 407.8 158.4 408.8zM190.8 444.4C189.2 445.7 189.8 448.7 192.1 450.6C194.4 452.9 197.3 453.2 198.6 451.6C199.9 450.3 199.3 447.3 197.3 445.4C195.1 443.1 192.1 442.8 190.8 444.4zM179.4 429.7C177.8 430.7 177.8 433.3 179.4 435.6C181 437.9 183.7 438.9 185 437.9C186.6 436.6 186.6 434 185 431.7C183.6 429.4 181 428.4 179.4 429.7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { classifier, classificationRules, parsingConfig } from '~/utils/classifier'
|
||||
|
||||
export const Route = createFileRoute('/api/classify')({
|
||||
component: RouteComponent,
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const body = await request.formData()
|
||||
const fl = body.get("file") as File;
|
||||
if (!fl) {
|
||||
return new Response(JSON.stringify({"result": "you need to provide a file"}))
|
||||
}
|
||||
const buff = await fl.arrayBuffer()
|
||||
const rawRes = await classifier.classify(
|
||||
classificationRules,
|
||||
parsingConfig,
|
||||
{ fileContents: [new Uint8Array(buff)] },
|
||||
)
|
||||
const results = rawRes.items
|
||||
let classification = ""
|
||||
|
||||
for (const result of results) {
|
||||
if ("result" in result && result.result) {
|
||||
classification += `
|
||||
<div class="card bg-base-100 shadow-xl p-6 mb-4">
|
||||
<div class="space-y-3">
|
||||
<p><span class="font-semibold">📄 Document:</span> ${fl.name}</p>
|
||||
<p><span class="font-semibold">🏷️ Type:</span> <span class="badge badge-primary">${result.result.type}</span></p>
|
||||
<p><span class="font-semibold">📊 Confidence:</span> ${result.result.confidence*100}%</p>
|
||||
<p><span class="font-semibold">💭 Reasoning:</span> ${result.result.reasoning}</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({"result": classification}))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const [file, setFile] = useState<null | File>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [reply, setReply] = useState<null | string>(null)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile)
|
||||
}
|
||||
}
|
||||
const handleClearFile = () => {
|
||||
if (file) {
|
||||
setFile(null)
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
if (reply) {
|
||||
setReply(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClassify = async () => {
|
||||
if (!file) return
|
||||
|
||||
if (reply) {
|
||||
setReply(null)
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch('/api/classify', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
setReply(data.result)
|
||||
} catch (error) {
|
||||
console.error('Error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center gap-y-8">
|
||||
<br />
|
||||
<h1 className="text-xl font-bold text-gray-700">AI-Powered finacial document classification</h1>
|
||||
<h2 className="text-lg font-semibold text-gray-500">Need help sorting out the financial documents jungle? Let our classification agent handle it!</h2>
|
||||
<fieldset className="fieldset bg-base-100 border-base-300 rounded-box w-200 border p-4">
|
||||
<legend className="fieldset-legend text-lg">Upload your financial document here</legend>
|
||||
<label className="label flex justify-center">
|
||||
<input type="file" className="file-input" onChange={handleFileChange} accept='application/pdf' ref={fileInputRef} />
|
||||
</label>
|
||||
</fieldset>
|
||||
{file && (
|
||||
<div className="flex flex-col justify-center items-center gap-y-8">
|
||||
<p className="text-sm text-gray-600">Selected file: {file.name}</p>
|
||||
<div className='grid grid-cols-2 gap-x-6'>
|
||||
<button
|
||||
type="button"
|
||||
className='btn bg-gray-500 text-white shadow-lg hover:bg-gray-600 hover:shadow-xl rounded'
|
||||
onClick={handleClassify}
|
||||
>
|
||||
Classify
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearFile}
|
||||
type="button"
|
||||
className="px-4 py-2 bg-red-300 text-black rounded hover:bg-red-400 hover:shadow-xl shadow-lg"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="loading loading-spinner text-primary"></span>
|
||||
)}
|
||||
{reply && (
|
||||
<div
|
||||
className="max-w-2xl w-full"
|
||||
dangerouslySetInnerHTML={{ __html: reply }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LlamaClassify, ClassifierRule, ClassifyParsingConfiguration } from "llama-cloud-services"
|
||||
|
||||
export const classifier = new LlamaClassify(process.env.LLAMA_CLOUD_API_KEY);
|
||||
|
||||
export const classificationRules: ClassifierRule[] = [
|
||||
{
|
||||
description: "Shows a company's assets, liabilities, and shareholders' equity at a specific point in time, providing a snapshot of financial position.",
|
||||
type: "balance_sheet"
|
||||
},
|
||||
{
|
||||
description: "Reports cash inflows and outflows from operating, investing, and financing activities, highlighting liquidity and cash management.",
|
||||
type: "cash_flow_statement"
|
||||
},
|
||||
{
|
||||
description: "Summarizes revenues, expenses, and profits over a period, indicating financial performance and profitability.",
|
||||
type: "income_statement"
|
||||
},
|
||||
];
|
||||
|
||||
export const parsingConfig: ClassifyParsingConfiguration = {
|
||||
lang: "en",
|
||||
max_pages: 20,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const seo = ({
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
image,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
image?: string
|
||||
keywords?: string
|
||||
}) => {
|
||||
const tags = [
|
||||
{ title },
|
||||
{ name: 'description', content: description },
|
||||
{ name: 'keywords', content: keywords },
|
||||
{ name: 'twitter:title', content: title },
|
||||
{ name: 'twitter:description', content: description },
|
||||
{ name: 'twitter:creator', content: '@tannerlinsley' },
|
||||
{ name: 'twitter:site', content: '@tannerlinsley' },
|
||||
{ name: 'og:type', content: 'website' },
|
||||
{ name: 'og:title', content: title },
|
||||
{ name: 'og:description', content: description },
|
||||
...(image
|
||||
? [
|
||||
{ name: 'twitter:image', content: image },
|
||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ name: 'og:image', content: image },
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
tanstackStart({
|
||||
srcDirectory: 'src',
|
||||
}),
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
"source": [
|
||||
"# Extraction and Analysis over a Fidelity Multi-Fund Annual Report\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services-demo/blob/main/examples/extract/asset_manager_fund_analysis.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/extract/asset_manager_fund_analysis.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"In this notebook we show you how to create an agentic document workflow over a complex document that contains annual reports for multiple funds - each fund reports financials in a standardized reporting structure, and it's all consolidated in the same document.\n",
|
||||
"\n",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"source": [
|
||||
"# Automotive Equity Research: A Multi-Step Agentic Workflow\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services-demo/blob/main/examples/extract/automotive_sector_analysis.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/extract/automotive_sector_analysis.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"This notebook demonstrates an end‑to‑end agentic workflow using LlamaExtract and the LlamaIndex event‑driven workflow framework for automotive sector analysis.\n",
|
||||
"\n",
|
||||
|
||||
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 769 KiB |
|
After Width: | Height: | Size: 942 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,508 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a7oq3cfnync",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Extracting Repeating Entities from Documents\n",
|
||||
"\n",
|
||||
"This notebook demonstrates how to use the `PER_TABLE_ROW` extraction target to extract structured data from documents containing repeating entities like tables, lists, or catalogs.\n",
|
||||
"\n",
|
||||
"## Why Use the Tabular Extraction Target?\n",
|
||||
"\n",
|
||||
"`PER_DOC` (refer to the table below for a quick overview of the different extraction targets) is the default extraction target in LlamaExtract, which looks at the entire document's context when doing an extraction. When extracting lists of entities, LLM-based extraction has a critical failure mode — it often **only extracts the first few tens of entries** from a long list. This happens because LLMs have limited attention spans for repetitive data. Document-level extraction doesn't guarantee exhaustive coverage, and long lists lead to incomplete extractions.\n",
|
||||
"\n",
|
||||
"**The Solution**: `PER_TABLE_ROW` solves this by processing each entity individually or in smaller batches, ensuring **exhaustive extraction** of all entries regardless of list length.\n",
|
||||
"\n",
|
||||
"### Entity-Level Extraction\n",
|
||||
"\n",
|
||||
"When using `extraction_target=ExtractTarget.PER_TABLE_ROW`, you define a schema for a **single entity** (e.g., one hospital, one product, one invoice line item), not the full document. LlamaExtract automatically:\n",
|
||||
"- Detects the formatting patterns that distinguish individual entities (table rows, list items, section headers, etc.)\n",
|
||||
"- Applies your schema to each identified entity\n",
|
||||
"- Returns a `list[YourSchema]` with one object per entity\n",
|
||||
"\n",
|
||||
"This approach is ideal when each entity locally contains all the information needed for your schema.\n",
|
||||
"\n",
|
||||
"### Choosing the Right Extraction Target\n",
|
||||
"\n",
|
||||
"| Extraction Target | Best For | Returns |\n",
|
||||
"|-------------------|----------|---------|\n",
|
||||
"| `PER_DOC` | Single-entity documents, summaries, or short lists | One JSON object for entire document |\n",
|
||||
"| `PER_PAGE` | Multi-page documents where each page is independent | One JSON object per page |\n",
|
||||
"| `PER_TABLE_ROW` | **Long lists, tables, catalogs with repeating entities** | List of JSON objects (one per entity) |\n",
|
||||
"\n",
|
||||
"📖 For more details, see the [Extraction Target documentation](https://developers.llamaindex.ai/python/cloud/llamaextract/features/concepts/#extraction-target)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "9427d1de",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from dotenv import load_dotenv\n",
|
||||
"from llama_cloud_services import LlamaExtract\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"# Load environment variables (put LLAMA_CLOUD_API_KEY in your .env file)\n",
|
||||
"load_dotenv(override=True)\n",
|
||||
"\n",
|
||||
"# Optionally, add your project id/organization id\n",
|
||||
"llama_extract = LlamaExtract()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "4426b360",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Table of Hospitals by County and Insurance Plans\n",
|
||||
"\n",
|
||||
"We have a PDF document with a list of hospitals by county and different insurance plans offered by Blue Shield of California. \n",
|
||||
"\n",
|
||||
"\n",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c86sjymhn1r",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"We want to extract each hospital from this table along with a list of applicable insurance plans. \n",
|
||||
"\n",
|
||||
"### Example 1: Structured Table\n",
|
||||
"\n",
|
||||
"This is an ideal use case for `PER_TABLE_ROW` extraction:\n",
|
||||
"- **Clear structure**: The document has explicit table formatting with rows and columns\n",
|
||||
"- **Repeating entities**: Each row represents one hospital with consistent attributes\n",
|
||||
"- **Local information**: All data for each hospital (county, name, plans) is contained within its row\n",
|
||||
"\n",
|
||||
"Notice that our `Hospital` schema describes a **single hospital**, not the full document. LlamaExtract will return a `list[Hospital]` with one entry per table row."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7c61a802",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pydantic import BaseModel, Field\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"class Hospital(BaseModel):\n",
|
||||
" \"\"\"List of hospitals by county available for different BSC plans\"\"\"\n",
|
||||
"\n",
|
||||
" county: str = Field(description=\"County name\")\n",
|
||||
" hospital_name: str = Field(description=\"Name of the hospital\")\n",
|
||||
" plan_names: list[str] = Field(\n",
|
||||
" description=\"List of plans available at the hospital. One of: Trio HMO, SaveNet, Access+ HMO, BlueHPN PPO, Tandem PPO, PPO\"\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "b8a69b7a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from llama_cloud_services.extract import ExtractConfig, ExtractMode, ExtractTarget\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"result = await llama_extract.aextract(\n",
|
||||
" data_schema=Hospital,\n",
|
||||
" files=\"./data/tables/BSC-Hospital-List-by-County.pdf\",\n",
|
||||
" config=ExtractConfig(\n",
|
||||
" extraction_mode=ExtractMode.PREMIUM,\n",
|
||||
" extraction_target=ExtractTarget.PER_TABLE_ROW,\n",
|
||||
" parse_model=\"anthropic-sonnet-4.5\",\n",
|
||||
" ),\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "43722cda",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Results"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "95b5aca6",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"380"
|
||||
]
|
||||
},
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"len(result.data)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "1e355770",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"[{'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Alameda Hospital',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'SaveNet',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Alta Bates Med Ctr Herrick Campus',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Alta Bates Summit Med Ctr Alta Bates Campus',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Alta Bates Summit Med Ctr Summit Campus',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Alta Bates Summit Medical Center',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'BHC Fremont Hospital',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'SaveNet',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Centre For Neuro Skills San Francisco',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'SaveNet',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Eden Medical Center',\n",
|
||||
" 'plan_names': ['Trio HMO', 'Access+ HMO', 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Fairmont Hospital',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'SaveNet',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']},\n",
|
||||
" {'county': 'Alameda',\n",
|
||||
" 'hospital_name': 'Highland Hospital',\n",
|
||||
" 'plan_names': ['Trio HMO',\n",
|
||||
" 'SaveNet',\n",
|
||||
" 'Access+ HMO',\n",
|
||||
" 'BlueHPN PPO',\n",
|
||||
" 'Tandem PPO',\n",
|
||||
" 'PPO']}]"
|
||||
]
|
||||
},
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"result.data[:10]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e28f0de8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "di156pb7s6j",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Success!** We extracted all **380 hospitals** from the multi-page PDF. Each entity was correctly parsed with its county, hospital name, and applicable insurance plans. With `PER_DOC`, we would likely have only gotten the first 20-30 entries."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "gelvl6db268",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Extracting from a Toy Catalog\n",
|
||||
"\n",
|
||||
"### Example 2: Semi-Structured List\n",
|
||||
"\n",
|
||||
"The `PER_TABLE_ROW` extraction target also works well for documents that aren't explicit tables but have similar properties:\n",
|
||||
"- **Ordered listing**: The toys are listed sequentially with visual separation (section headers, spacing)\n",
|
||||
"- **Repeating pattern**: Each toy entry has a consistent structure (code, name, specs, description)\n",
|
||||
"- **Local information**: All attributes for each toy are grouped together in its entry\n",
|
||||
"\n",
|
||||
"Even though this isn't a traditional table format, each toy entity locally contains all the information needed for our schema. LlamaExtract detects the formatting patterns that distinguish each toy and extracts them as separate entities.\n",
|
||||
"\n",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8cf0b2db",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from pydantic import BaseModel, Field\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"class ToyCatalog(BaseModel):\n",
|
||||
" \"\"\"Product information from a toy catalog.\"\"\"\n",
|
||||
"\n",
|
||||
" section_name: str = Field(\n",
|
||||
" description=\"The name of the toy section (e.g. Table Toys, Active Toys).\"\n",
|
||||
" )\n",
|
||||
" product_code: str = Field(\n",
|
||||
" description=\"The unique product code for the toy (e.g., GA457).\"\n",
|
||||
" )\n",
|
||||
" toy_name: str = Field(description=\"The name of the toy.\")\n",
|
||||
" age_range: str = Field(\n",
|
||||
" description=\"The recommended age range for the toy (e.g., 6 +, 4 +).\",\n",
|
||||
" )\n",
|
||||
" player_range: str = Field(\n",
|
||||
" description=\"The number of players the toy is designed for (e.g., 2, 2-4, 1-6).\",\n",
|
||||
" )\n",
|
||||
" material: str = Field(\n",
|
||||
" description=\"The primary material(s) the toy is made of (e.g., wood, cardboard).\",\n",
|
||||
" )\n",
|
||||
" description: str = Field(\n",
|
||||
" description=\"A brief description of the toy and its components and dimensions.\",\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "mysu1i2qo9e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Results\n",
|
||||
"\n",
|
||||
"Again, our schema represents a **single toy product**, not the entire catalog. The system will return a `list[ToyCatalog]` with one entry per toy."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "5b38b806",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"result = await llama_extract.aextract(\n",
|
||||
" data_schema=ToyCatalog,\n",
|
||||
" files=\"./data/tables/Click-BS-Toys-Catalogue-2024.pdf\",\n",
|
||||
" config=ExtractConfig(\n",
|
||||
" extraction_mode=ExtractMode.PREMIUM,\n",
|
||||
" extraction_target=ExtractTarget.PER_TABLE_ROW,\n",
|
||||
" parse_model=\"anthropic-sonnet-4.5\",\n",
|
||||
" ),\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "91aface0",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"153"
|
||||
]
|
||||
},
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"len(result.data)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "51278736",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"[{'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA457',\n",
|
||||
" 'toy_name': 'Dots and Boxes',\n",
|
||||
" 'age_range': '6+',\n",
|
||||
" 'player_range': '2',\n",
|
||||
" 'material': 'wood',\n",
|
||||
" 'description': 'base 17x17 cm\\n50 border pieces 4x1,2x0,3 cm\\n34 trees 2,6x1,4 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA456',\n",
|
||||
" 'toy_name': '3 In a Row',\n",
|
||||
" 'age_range': '8+',\n",
|
||||
" 'player_range': '2',\n",
|
||||
" 'material': 'wood, pine, cardboard',\n",
|
||||
" 'description': 'base 24x22,5x2,5 cm\\n30 cards 5,5x5 cm\\n6 chips'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA467',\n",
|
||||
" 'toy_name': 'Which Cow am i?',\n",
|
||||
" 'age_range': '6+',\n",
|
||||
" 'player_range': '2',\n",
|
||||
" 'material': 'wood, beech',\n",
|
||||
" 'description': '2 cow bases 56x4x4,5 cm\\n16 cards 4x5 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA460',\n",
|
||||
" 'toy_name': 'Balance Bunnies',\n",
|
||||
" 'age_range': '4+',\n",
|
||||
" 'player_range': '2',\n",
|
||||
" 'material': 'wood',\n",
|
||||
" 'description': '1 base 35x12x25 cm\\n7 bunnies 7 foxes\\n1 dice 3 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA462',\n",
|
||||
" 'toy_name': 'Color Combination Race',\n",
|
||||
" 'age_range': '4+',\n",
|
||||
" 'player_range': '2-4',\n",
|
||||
" 'material': 'wood, cardboard',\n",
|
||||
" 'description': 'base 6,5x6,5x15 cm, rings 5,5x5,5x0,5 mm\\ncardholder 6x6x2 cm, cards 5,5x5,5 cm\\ncolor cards Ø 15,5 cm - Ø 7 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA465',\n",
|
||||
" 'toy_name': 'Plop It',\n",
|
||||
" 'age_range': '6+',\n",
|
||||
" 'player_range': '2-4',\n",
|
||||
" 'material': 'wood, elastic, cardboard',\n",
|
||||
" 'description': 'Catch the right balls and plop them in the net!\\n* 2 ploppers 8x5 cm\\n* 2 net holders Ø 5cm, length 55 cm\\n* 6 cards 1,5x2,5 cm, 30 balls Ø 2,5 cm\\n* 1 rope 120 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA466',\n",
|
||||
" 'toy_name': 'Whack a Shape',\n",
|
||||
" 'age_range': '4+',\n",
|
||||
" 'player_range': '2-4',\n",
|
||||
" 'material': 'wood',\n",
|
||||
" 'description': '* base 38,5x15,5 cm\\n* 2 stands 36 half balls, 4 hammers\\n* 1 dice 2,5 cm\\n* 4 cards'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA458',\n",
|
||||
" 'toy_name': 'Sling Puck | Table Hockey',\n",
|
||||
" 'age_range': '6+',\n",
|
||||
" 'player_range': '2',\n",
|
||||
" 'material': 'wood',\n",
|
||||
" 'description': '* double sides base 39x21x3 cm\\n* 10 chips Ø 2,5 cm\\n* 2 pushers 4x4x3 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA039',\n",
|
||||
" 'toy_name': 'DIY Birdhouse',\n",
|
||||
" 'age_range': '3+',\n",
|
||||
" 'player_range': '1',\n",
|
||||
" 'material': 'wood',\n",
|
||||
" 'description': '* house 9x9x13 cm'},\n",
|
||||
" {'section_name': 'Table Toys',\n",
|
||||
" 'product_code': 'GA319',\n",
|
||||
" 'toy_name': 'Triangle Domino',\n",
|
||||
" 'age_range': '6+',\n",
|
||||
" 'player_range': '2-4',\n",
|
||||
" 'material': 'wood',\n",
|
||||
" 'description': '* 35 triangles 10x10 x10 cm'}]"
|
||||
]
|
||||
},
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"result.data[:10]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d1810c0a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "ezur9gnhmsb",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**Success!** Despite the semi-structured format, we extracted all **152 toy products** from the catalog (there's an extra repeated extracted toy from the Appendix section). LlamaExtract automatically detected the visual patterns separating each toy entry and applied our schema to each one."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "aeyr3io29u",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Summary\n",
|
||||
"\n",
|
||||
"The `PER_TABLE_ROW` extraction target is powerful for extracting repeating structured entities from documents. Key takeaways:\n",
|
||||
"\n",
|
||||
"1. **Schema design**: Define your schema for a single entity, not the full document. The system returns `list[YourSchema]`.\n",
|
||||
"\n",
|
||||
"2. **Works with various formats**: Not just traditional tables—any document with distinguishable repeating entities (bullets, numbering, headers, visual separation, etc.). The common requirement is that each entity should contain all the necessary data for your schema within its local context.\n",
|
||||
"\n",
|
||||
"3. **Automatic pattern detection**: LlamaExtract identifies the formatting patterns that distinguish entities and applies your schema to each one."
|
||||
]
|
||||
}
|
||||
],
|
||||
"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": 5
|
||||
}
|
||||
@@ -1035,7 +1035,7 @@
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
@@ -1052,5 +1052,5 @@
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"source": [
|
||||
"# Dynamic Section Retrieval with LlamaParse\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services-demo/blob/main/examples/parse/advanced_rag/dynamic_section_retrieval.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/parse/advanced_rag/dynamic_section_retrieval.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"This notebook showcases a concept called \"dynamic section retrieval\".\n",
|
||||
"\n",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"source": [
|
||||
"# Advanced RAG with LlamaParse\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_parse/blob/main/examples/demo_advanced.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_parse/blob/main/examples/parse/demo_advanced.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"This notebook is a complete walkthrough for using LlamaParse with advanced indexing/retrieval techniques in LlamaIndex over the Apple 10K Filing. \n",
|
||||
"\n",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"source": [
|
||||
"# RAG with Excel Spreadsheet using LlamaPrase\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/demo_excel.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/parse/demo_excel.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"This notebook shows you using LlamaParse with Excel Spreadsheet.\n",
|
||||
"\n",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"source": [
|
||||
"# Download Charts\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/demo_get_charts.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/parse/demo_get_charts.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"This notebook demonstrates how to download charts from a document using the result object.\n",
|
||||
"\n",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"source": [
|
||||
"# LlamaParse - Fast checking Insurance Contract for Coverage\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/demo_insurance.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/parse/demo_insurance.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"In this notebook we will look at how LlamaParse can be used to extract structured coverage information from an insurance policy.\n",
|
||||
"\n",
|
||||
@@ -36,7 +36,7 @@
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Download an insurance policy fron IRDAI\n",
|
||||
"## Download an insurance policy from IRDAI\n",
|
||||
"\n",
|
||||
"The Insurance Regulatory and Development Authority of India (IRDAI) maintains a great resource: https://policyholder.gov.in/web/guest/non-life-insurance-products where all insurance policies available in India are publicly available for download! Let's download a complex health insurance policy as an example."
|
||||
]
|
||||
@@ -228,11 +228,11 @@
|
||||
" result_type=\"markdown\",\n",
|
||||
" system_prompt_append=\"\"\"\n",
|
||||
"This document is an insurance policy.\n",
|
||||
"When a benefits/coverage/exlusion is describe in the document ammend to it add a text in the follwing benefits string format (where coverage could be an exclusion).\n",
|
||||
"When a benefits/coverage/exlusion is describe in the document amend to it add a text in the following benefits string format (where coverage could be an exclusion).\n",
|
||||
"\n",
|
||||
"For {nameofrisk} and in this condition {whenDoesThecoverageApply} the coverage is {coverageDescription}. \n",
|
||||
" \n",
|
||||
"If the document contain a benefits TABLE that describe coverage amounts, do not ouput it as a table, but instead as a list of benefits string.\n",
|
||||
"If the document contain a benefits TABLE that describe coverage amounts, do not output it as a table, but instead as a list of benefits string.\n",
|
||||
" \n",
|
||||
"\"\"\",\n",
|
||||
").aparse(\"./policy.pdf\")\n",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"source": [
|
||||
"# LlamaParse `JobResult` Tour\n",
|
||||
"\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/demo_json.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/parse/demo_json.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>\n",
|
||||
"\n",
|
||||
"The `JobResult` object is the main object returned by the LlamaParse API. It contains all the information about the job, including the parsed data, metadata, and any errors.\n",
|
||||
"\n",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"\n",
|
||||
"LlamaParse supports users to specify a `language` parameter before uploading documents, giving users better OCR capabilities over non-English PDFs, parsing images into more accurate representations.\n",
|
||||
"\n",
|
||||
"You can specify 80+ different languages: see this file for a full list of supported languages: https://github.com/run-llama/llama_cloud_services/blob/main/llama_parse/base.py.\n",
|
||||
"You can specify 80+ different languages: see this file for a full list of supported languages: https://github.com/run-llama/llama_cloud_services/blob/main/py/llama_cloud_services/parse/base.py.\n",
|
||||
"\n",
|
||||
"This notebook shows a demo of this in action. \n",
|
||||
"\n",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/excel/o1_excel_rag.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
|
||||
"<a href=\"https://colab.research.google.com/github/run-llama/llama_cloud_services/blob/main/examples/parse/excel/o1_excel_rag.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -740,7 +740,7 @@
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"In this example, these pages aren't going to be that different when parsed, but we can verify which pages triggered auto-made by looking at the [JSON output](https://github.com/run-llama/llama_cloud_services/blob/main/examples/demo_json_tour.ipynb) of LlamaParse:"
|
||||
"In this example, these pages aren't going to be that different when parsed, but we can verify which pages triggered auto-made by looking at the [JSON output](https://github.com/run-llama/llama_cloud_services/blob/main/examples/parse/demo_json_tour.ipynb) of LlamaParse:"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
This project uses LlamaSheets to extract data from spreadsheets for analysis.
|
||||
|
||||
## Current Project Structure
|
||||
|
||||
- `data/` - Contains extracted parquet files from LlamaSheets
|
||||
- `{name}_region_{N}.parquet` - Table data files
|
||||
- `{name}_metadata_{N}.parquet` - Cell metadata files
|
||||
- `{name}_job_metadata.json` - Extraction job information
|
||||
- `scripts/` - Analysis and helper scripts
|
||||
- `reports/` - Your generated reports and outputs
|
||||
|
||||
## Working with LlamaSheets Data
|
||||
|
||||
### Understanding the Files
|
||||
|
||||
When a spreadsheet is extracted, you'll find:
|
||||
|
||||
1. **Table parquet files** (`region_*.parquet`): The actual table data
|
||||
- Columns correspond to spreadsheet columns
|
||||
- Data types are preserved (dates, numbers, strings, booleans)
|
||||
|
||||
2. **Metadata parquet files** (`metadata_*.parquet`): Rich cell-level metadata
|
||||
- Formatting: `font_bold`, `font_italic`, `font_size`, `background_color_rgb`
|
||||
- Position: `row_number`, `column_number`, `coordinate` (e.g., "A1")
|
||||
- Type detection: `data_type`, `is_date_like`, `is_percentage`, `is_currency`
|
||||
- Layout: `is_in_first_row`, `is_merged_cell`, `horizontal_alignment`
|
||||
- Content: `cell_value`, `raw_cell_value`
|
||||
|
||||
3. **Job metadata JSON** (`job_metadata.json`): Overall extraction results
|
||||
- `regions[]`: List of extracted regions with IDs, locations, and titles/descriptions
|
||||
- `worksheet_metadata[]`: Generated titles and descriptions
|
||||
- `status`: Success/failure status
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Use metadata to understand structure**: Bold cells often indicate headers, colors indicate groupings
|
||||
2. **Validate before analysis**: Check data types, look for missing values
|
||||
3. **Preserve formatting context**: The metadata tells you what the spreadsheet author emphasized
|
||||
4. **Save intermediate results**: Store cleaned data as new parquet files
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Loading data:**
|
||||
```python
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_parquet("data/region_1_Sheet1.parquet")
|
||||
meta_df = pd.read_parquet("data/metadata_1_Sheet1.parquet")
|
||||
```
|
||||
|
||||
**Finding headers:**
|
||||
```python
|
||||
headers = meta_df[meta_df["font_bold"] == True]["cell_value"].tolist()
|
||||
```
|
||||
|
||||
**Finding date columns:**
|
||||
```python
|
||||
date_cols = meta_df[meta_df["is_date_like"] == True]["column_number"].unique()
|
||||
```
|
||||
|
||||
## Tools Available
|
||||
|
||||
- **Python 3.11+**: For data analysis
|
||||
- **pandas**: DataFrame manipulation
|
||||
- **pyarrow**: Parquet file reading
|
||||
- **matplotlib**: Visualization (optional)
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always read the job_metadata.json first to understand what was extracted
|
||||
- Check both table data and metadata before making assumptions
|
||||
- Write reusable functions for common operations
|
||||
- Document any data quality issues discovered
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Generate sample spreadsheets for LlamaSheets + Claude workflows.
|
||||
|
||||
This script creates example Excel files that demonstrate different use cases:
|
||||
1. Simple data table (for Workflow 1)
|
||||
2. Regional sales data (for Workflow 2)
|
||||
3. Complex budget with formatting (for Workflow 3)
|
||||
4. Weekly sales report (for Workflow 4)
|
||||
|
||||
Usage:
|
||||
python generate_sample_data.py
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
|
||||
def generate_workflow_1_data(output_dir: Path) -> None:
|
||||
"""Generate simple financial report for Workflow 1."""
|
||||
print("📊 Generating Workflow 1: financial_report_q1.xlsx")
|
||||
|
||||
# Create sample quarterly data
|
||||
months = ["January", "February", "March"]
|
||||
categories = ["Revenue", "Cost of Goods Sold", "Operating Expenses", "Net Income"]
|
||||
|
||||
data = []
|
||||
for category in categories:
|
||||
row: dict[str, str | int] = {"Category": category}
|
||||
for month in months:
|
||||
if category == "Revenue":
|
||||
value = random.randint(80000, 120000)
|
||||
elif category == "Cost of Goods Sold":
|
||||
value = random.randint(30000, 50000)
|
||||
elif category == "Operating Expenses":
|
||||
value = random.randint(20000, 35000)
|
||||
else: # Net Income
|
||||
value = int(
|
||||
int(row.get("January", 0))
|
||||
+ int(row.get("February", 0))
|
||||
+ int(row.get("March", 0))
|
||||
)
|
||||
value = random.randint(15000, 40000)
|
||||
row[month] = value
|
||||
data.append(row)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel
|
||||
output_file = output_dir / "financial_report_q1.xlsx"
|
||||
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
|
||||
df.to_excel(writer, sheet_name="Q1 Summary", index=False)
|
||||
|
||||
# Format it nicely
|
||||
worksheet = writer.sheets["Q1 Summary"]
|
||||
for cell in worksheet[1]: # Header row
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill(
|
||||
start_color="4F81BD", end_color="4F81BD", fill_type="solid"
|
||||
)
|
||||
cell.font = Font(color="FFFFFF", bold=True)
|
||||
|
||||
print(f" ✅ Created {output_file}")
|
||||
|
||||
|
||||
def generate_workflow_2_data(output_dir: Path) -> None:
|
||||
"""Generate regional sales data for Workflow 2."""
|
||||
print("\n📊 Generating Workflow 2: Regional sales data")
|
||||
|
||||
regions = ["northeast", "southeast", "west"]
|
||||
products = ["Widget A", "Widget B", "Widget C", "Gadget X", "Gadget Y"]
|
||||
|
||||
for region in regions:
|
||||
data = []
|
||||
start_date = datetime(2024, 1, 1)
|
||||
|
||||
# Generate 90 days of sales data
|
||||
for day in range(90):
|
||||
date = start_date + timedelta(days=day)
|
||||
# Random number of sales per day (3-8)
|
||||
for _ in range(random.randint(3, 8)):
|
||||
product = random.choice(products)
|
||||
units_sold = random.randint(1, 20)
|
||||
price_per_unit = random.randint(50, 200)
|
||||
revenue = units_sold * price_per_unit
|
||||
|
||||
data.append(
|
||||
{
|
||||
"Date": date.strftime("%Y-%m-%d"),
|
||||
"Product": product,
|
||||
"Units_Sold": units_sold,
|
||||
"Revenue": revenue,
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel
|
||||
output_file = output_dir / f"sales_{region}.xlsx"
|
||||
df.to_excel(output_file, sheet_name="Sales", index=False)
|
||||
print(f" ✅ Created {output_file} ({len(df)} rows)")
|
||||
|
||||
|
||||
def generate_workflow_3_data(output_dir: Path) -> None:
|
||||
"""Generate complex budget spreadsheet with formatting for Workflow 3."""
|
||||
print("\n📊 Generating Workflow 3: company_budget_2024.xlsx")
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Budget"
|
||||
|
||||
# Define departments with colors
|
||||
departments = {
|
||||
"Engineering": "C6E0B4",
|
||||
"Marketing": "FFD966",
|
||||
"Sales": "F4B084",
|
||||
"Operations": "B4C7E7",
|
||||
}
|
||||
|
||||
# Define categories
|
||||
categories = {
|
||||
"Personnel": ["Salaries", "Benefits", "Training"],
|
||||
"Infrastructure": ["Office Rent", "Equipment", "Software Licenses"],
|
||||
"Operations": ["Travel", "Supplies", "Miscellaneous"],
|
||||
}
|
||||
|
||||
# Styles
|
||||
header_font = Font(bold=True, size=12)
|
||||
category_font = Font(bold=True, size=11)
|
||||
|
||||
row = 1
|
||||
|
||||
# Title
|
||||
ws.merge_cells(f"A{row}:E{row}")
|
||||
ws[f"A{row}"] = "2024 Annual Budget"
|
||||
ws[f"A{row}"].font = Font(bold=True, size=14)
|
||||
ws[f"A{row}"].alignment = Alignment(horizontal="center")
|
||||
row += 2
|
||||
|
||||
# Headers
|
||||
ws[f"A{row}"] = "Category"
|
||||
ws[f"B{row}"] = "Item"
|
||||
for i, dept in enumerate(departments.keys()):
|
||||
ws.cell(row, 3 + i, dept)
|
||||
ws.cell(row, 3 + i).font = header_font
|
||||
|
||||
for cell in ws[row]:
|
||||
cell.font = header_font
|
||||
row += 1
|
||||
|
||||
# Data
|
||||
for category, items in categories.items():
|
||||
# Category header (bold)
|
||||
ws[f"A{row}"] = category
|
||||
ws[f"A{row}"].font = category_font
|
||||
row += 1
|
||||
|
||||
# Items with department budgets
|
||||
for item in items:
|
||||
ws[f"A{row}"] = ""
|
||||
ws[f"B{row}"] = item
|
||||
|
||||
# Add budget amounts for each department (with color)
|
||||
for i, (dept, color) in enumerate(departments.items()):
|
||||
amount = random.randint(5000, 50000)
|
||||
cell = ws.cell(row, 3 + i, amount)
|
||||
cell.fill = PatternFill(
|
||||
start_color=color, end_color=color, fill_type="solid"
|
||||
)
|
||||
cell.number_format = "$#,##0"
|
||||
|
||||
row += 1
|
||||
|
||||
row += 1 # Blank row between categories
|
||||
|
||||
# Adjust column widths
|
||||
ws.column_dimensions["A"].width = 20
|
||||
ws.column_dimensions["B"].width = 25
|
||||
for i in range(len(departments)):
|
||||
ws.column_dimensions[chr(67 + i)].width = 15 # C, D, E, F
|
||||
|
||||
output_file = output_dir / "company_budget_2024.xlsx"
|
||||
wb.save(output_file)
|
||||
print(f" ✅ Created {output_file}")
|
||||
print(" • Bold categories, colored departments, merged title cell")
|
||||
|
||||
|
||||
def generate_workflow_4_data(output_dir: Path) -> None:
|
||||
"""Generate weekly sales report for Workflow 4."""
|
||||
print("\n📊 Generating Workflow 4: sales_weekly.xlsx")
|
||||
|
||||
products = [
|
||||
"Product A",
|
||||
"Product B",
|
||||
"Product C",
|
||||
"Product D",
|
||||
"Product E",
|
||||
"Product F",
|
||||
"Product G",
|
||||
"Product H",
|
||||
]
|
||||
|
||||
# Generate one week of data
|
||||
data = []
|
||||
start_date = datetime(2024, 11, 4) # Monday
|
||||
|
||||
for day in range(7):
|
||||
date = start_date + timedelta(days=day)
|
||||
# Each product has 3-10 transactions per day
|
||||
for product in products:
|
||||
for _ in range(random.randint(3, 10)):
|
||||
units = random.randint(1, 15)
|
||||
price = random.randint(20, 150)
|
||||
revenue = units * price
|
||||
|
||||
data.append(
|
||||
{
|
||||
"Date": date.strftime("%Y-%m-%d"),
|
||||
"Product": product,
|
||||
"Units": units,
|
||||
"Revenue": revenue,
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel with some formatting
|
||||
output_file = output_dir / "sales_weekly.xlsx"
|
||||
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
|
||||
df.to_excel(writer, sheet_name="Weekly Sales", index=False)
|
||||
|
||||
# Format header
|
||||
worksheet = writer.sheets["Weekly Sales"]
|
||||
for cell in worksheet[1]:
|
||||
cell.font = Font(bold=True)
|
||||
|
||||
print(f" ✅ Created {output_file} ({len(df)} rows)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Generate all sample data files."""
|
||||
print("=" * 60)
|
||||
print("Generating Sample Data for LlamaSheets + Coding Agent Workflows")
|
||||
print("=" * 60)
|
||||
|
||||
# Create output directory
|
||||
output_dir = Path("input_data")
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Generate data for each workflow
|
||||
generate_workflow_1_data(output_dir)
|
||||
generate_workflow_2_data(output_dir)
|
||||
generate_workflow_3_data(output_dir)
|
||||
generate_workflow_4_data(output_dir)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ All sample data generated!")
|
||||
print("=" * 60)
|
||||
print(f"\nFiles created in {output_dir.absolute()}:")
|
||||
print("\nWorkflow 1 (Understanding a New Spreadsheet):")
|
||||
print(" • financial_report_q1.xlsx")
|
||||
print("\nWorkflow 2 (Generating Analysis Scripts):")
|
||||
print(" • sales_northeast.xlsx")
|
||||
print(" • sales_southeast.xlsx")
|
||||
print(" • sales_west.xlsx")
|
||||
print("\nWorkflow 3 (Using Cell Metadata):")
|
||||
print(" • company_budget_2024.xlsx")
|
||||
print("\nWorkflow 4 (Complete Automation):")
|
||||
print(" • sales_weekly.xlsx")
|
||||
print("\nYou can now use these files with the workflows in the documentation!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
llama-cloud-services # LlamaSheets SDK
|
||||
pandas>=2.0.0
|
||||
pyarrow>=12.0.0
|
||||
openpyxl>=3.0.0 # For Excel file support
|
||||
matplotlib>=3.7.0 # For visualizations (optional)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Helper script to extract spreadsheets using LlamaSheets."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import dotenv
|
||||
from pathlib import Path
|
||||
|
||||
from llama_cloud_services.beta.sheets import LlamaSheets
|
||||
from llama_cloud_services.beta.sheets.types import (
|
||||
SpreadsheetParsingConfig,
|
||||
SpreadsheetResultType,
|
||||
)
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
async def extract_spreadsheet(
|
||||
file_path: str, output_dir: str = "data", generate_metadata: bool = True
|
||||
) -> dict:
|
||||
"""Extract a spreadsheet using LlamaSheets."""
|
||||
|
||||
client = LlamaSheets(
|
||||
base_url="https://api.cloud.llamaindex.ai",
|
||||
api_key=os.getenv("LLAMA_CLOUD_API_KEY"),
|
||||
)
|
||||
|
||||
print(f"Extracting {file_path}...")
|
||||
|
||||
# Extract regions
|
||||
config = SpreadsheetParsingConfig(
|
||||
sheet_names=None, # Extract all sheets
|
||||
generate_additional_metadata=generate_metadata,
|
||||
)
|
||||
|
||||
job_result = await client.aextract_regions(file_path, config=config)
|
||||
|
||||
print(f"Extracted {len(job_result.regions)} region(s)")
|
||||
|
||||
# Create output directory
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get base name for files
|
||||
base_name = Path(file_path).stem
|
||||
|
||||
# Save job metadata
|
||||
job_metadata_path = output_path / f"{base_name}_job_metadata.json"
|
||||
with open(job_metadata_path, "w") as f:
|
||||
json.dump(job_result.model_dump(mode="json"), f, indent=2)
|
||||
print(f"Saved job metadata to {job_metadata_path}")
|
||||
|
||||
# Download each region
|
||||
for idx, region in enumerate(job_result.regions, 1):
|
||||
sheet_name = region.sheet_name.replace(" ", "_")
|
||||
|
||||
# Download region data
|
||||
region_bytes = await client.adownload_region_result(
|
||||
job_id=job_result.id,
|
||||
region_id=region.region_id,
|
||||
result_type=region.region_type,
|
||||
)
|
||||
|
||||
region_path = output_path / f"{base_name}_region_{idx}_{sheet_name}.parquet"
|
||||
with open(region_path, "wb") as f:
|
||||
f.write(region_bytes)
|
||||
print(f" Table {idx}: {region_path}")
|
||||
|
||||
# Download metadata
|
||||
metadata_bytes = await client.adownload_region_result(
|
||||
job_id=job_result.id,
|
||||
region_id=region.region_id,
|
||||
result_type=SpreadsheetResultType.CELL_METADATA,
|
||||
)
|
||||
|
||||
metadata_path = output_path / f"{base_name}_metadata_{idx}_{sheet_name}.parquet"
|
||||
with open(metadata_path, "wb") as f:
|
||||
f.write(metadata_bytes)
|
||||
print(f" Metadata {idx}: {metadata_path}")
|
||||
|
||||
print(f"\nAll files saved to {output_path}/")
|
||||
|
||||
return job_result.model_dump(mode="json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python scripts/extract.py <spreadsheet_file>")
|
||||
sys.exit(1)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
|
||||
if not Path(file_path).exists():
|
||||
print(f"❌ File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
result = asyncio.run(extract_spreadsheet(file_path))
|
||||
print(f"\n✅ Extraction complete! Job ID: {result['id']}")
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Generate sample spreadsheets for LlamaSheets + LlamaIndex Agent workflows.
|
||||
|
||||
This script creates example Excel files that demonstrate different use cases:
|
||||
1. Simple data table (for Workflow 1)
|
||||
2. Regional sales data (for Workflow 2)
|
||||
3. Complex budget with formatting (for Workflow 3)
|
||||
4. Weekly sales report (for Workflow 4)
|
||||
|
||||
Usage:
|
||||
python generate_sample_data.py
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
|
||||
def generate_workflow_1_data(output_dir: Path) -> None:
|
||||
"""Generate simple financial report for Workflow 1."""
|
||||
print("📊 Generating Workflow 1: financial_report_q1.xlsx")
|
||||
|
||||
# Create sample quarterly data
|
||||
months = ["January", "February", "March"]
|
||||
categories = ["Revenue", "Cost of Goods Sold", "Operating Expenses", "Net Income"]
|
||||
|
||||
data = []
|
||||
for category in categories:
|
||||
row: dict[str, str | int] = {"Category": category}
|
||||
for month in months:
|
||||
if category == "Revenue":
|
||||
value = random.randint(80000, 120000)
|
||||
elif category == "Cost of Goods Sold":
|
||||
value = random.randint(30000, 50000)
|
||||
elif category == "Operating Expenses":
|
||||
value = random.randint(20000, 35000)
|
||||
else: # Net Income
|
||||
value = int(
|
||||
int(row.get("January", 0))
|
||||
+ int(row.get("February", 0))
|
||||
+ int(row.get("March", 0))
|
||||
)
|
||||
value = random.randint(15000, 40000)
|
||||
row[month] = value
|
||||
data.append(row)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel
|
||||
output_file = output_dir / "financial_report_q1.xlsx"
|
||||
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
|
||||
df.to_excel(writer, sheet_name="Q1 Summary", index=False)
|
||||
|
||||
# Format it nicely
|
||||
worksheet = writer.sheets["Q1 Summary"]
|
||||
for cell in worksheet[1]: # Header row
|
||||
cell.font = Font(bold=True)
|
||||
cell.fill = PatternFill(
|
||||
start_color="4F81BD", end_color="4F81BD", fill_type="solid"
|
||||
)
|
||||
cell.font = Font(color="FFFFFF", bold=True)
|
||||
|
||||
print(f" ✅ Created {output_file}")
|
||||
|
||||
|
||||
def generate_workflow_2_data(output_dir: Path) -> None:
|
||||
"""Generate regional sales data for Workflow 2."""
|
||||
print("\n📊 Generating Workflow 2: Regional sales data")
|
||||
|
||||
regions = ["northeast", "southeast", "west"]
|
||||
products = ["Widget A", "Widget B", "Widget C", "Gadget X", "Gadget Y"]
|
||||
|
||||
for region in regions:
|
||||
data = []
|
||||
start_date = datetime(2024, 1, 1)
|
||||
|
||||
# Generate 90 days of sales data
|
||||
for day in range(90):
|
||||
date = start_date + timedelta(days=day)
|
||||
# Random number of sales per day (3-8)
|
||||
for _ in range(random.randint(3, 8)):
|
||||
product = random.choice(products)
|
||||
units_sold = random.randint(1, 20)
|
||||
price_per_unit = random.randint(50, 200)
|
||||
revenue = units_sold * price_per_unit
|
||||
|
||||
data.append(
|
||||
{
|
||||
"Date": date.strftime("%Y-%m-%d"),
|
||||
"Product": product,
|
||||
"Units_Sold": units_sold,
|
||||
"Revenue": revenue,
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel
|
||||
output_file = output_dir / f"sales_{region}.xlsx"
|
||||
df.to_excel(output_file, sheet_name="Sales", index=False)
|
||||
print(f" ✅ Created {output_file} ({len(df)} rows)")
|
||||
|
||||
|
||||
def generate_workflow_3_data(output_dir: Path) -> None:
|
||||
"""Generate complex budget spreadsheet with formatting for Workflow 3."""
|
||||
print("\n📊 Generating Workflow 3: company_budget_2024.xlsx")
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Budget"
|
||||
|
||||
# Define departments with colors
|
||||
departments = {
|
||||
"Engineering": "C6E0B4",
|
||||
"Marketing": "FFD966",
|
||||
"Sales": "F4B084",
|
||||
"Operations": "B4C7E7",
|
||||
}
|
||||
|
||||
# Define categories
|
||||
categories = {
|
||||
"Personnel": ["Salaries", "Benefits", "Training"],
|
||||
"Infrastructure": ["Office Rent", "Equipment", "Software Licenses"],
|
||||
"Operations": ["Travel", "Supplies", "Miscellaneous"],
|
||||
}
|
||||
|
||||
# Styles
|
||||
header_font = Font(bold=True, size=12)
|
||||
category_font = Font(bold=True, size=11)
|
||||
|
||||
row = 1
|
||||
|
||||
# Title
|
||||
ws.merge_cells(f"A{row}:E{row}")
|
||||
ws[f"A{row}"] = "2024 Annual Budget"
|
||||
ws[f"A{row}"].font = Font(bold=True, size=14)
|
||||
ws[f"A{row}"].alignment = Alignment(horizontal="center")
|
||||
row += 2
|
||||
|
||||
# Headers
|
||||
ws[f"A{row}"] = "Category"
|
||||
ws[f"B{row}"] = "Item"
|
||||
for i, dept in enumerate(departments.keys()):
|
||||
ws.cell(row, 3 + i, dept)
|
||||
ws.cell(row, 3 + i).font = header_font
|
||||
|
||||
for cell in ws[row]:
|
||||
cell.font = header_font
|
||||
row += 1
|
||||
|
||||
# Data
|
||||
for category, items in categories.items():
|
||||
# Category header (bold)
|
||||
ws[f"A{row}"] = category
|
||||
ws[f"A{row}"].font = category_font
|
||||
row += 1
|
||||
|
||||
# Items with department budgets
|
||||
for item in items:
|
||||
ws[f"A{row}"] = ""
|
||||
ws[f"B{row}"] = item
|
||||
|
||||
# Add budget amounts for each department (with color)
|
||||
for i, (dept, color) in enumerate(departments.items()):
|
||||
amount = random.randint(5000, 50000)
|
||||
cell = ws.cell(row, 3 + i, amount)
|
||||
cell.fill = PatternFill(
|
||||
start_color=color, end_color=color, fill_type="solid"
|
||||
)
|
||||
cell.number_format = "$#,##0"
|
||||
|
||||
row += 1
|
||||
|
||||
row += 1 # Blank row between categories
|
||||
|
||||
# Adjust column widths
|
||||
ws.column_dimensions["A"].width = 20
|
||||
ws.column_dimensions["B"].width = 25
|
||||
for i in range(len(departments)):
|
||||
ws.column_dimensions[chr(67 + i)].width = 15 # C, D, E, F
|
||||
|
||||
output_file = output_dir / "company_budget_2024.xlsx"
|
||||
wb.save(output_file)
|
||||
print(f" ✅ Created {output_file}")
|
||||
print(" • Bold categories, colored departments, merged title cell")
|
||||
|
||||
|
||||
def generate_workflow_4_data(output_dir: Path) -> None:
|
||||
"""Generate weekly sales report for Workflow 4."""
|
||||
print("\n📊 Generating Workflow 4: sales_weekly.xlsx")
|
||||
|
||||
products = [
|
||||
"Product A",
|
||||
"Product B",
|
||||
"Product C",
|
||||
"Product D",
|
||||
"Product E",
|
||||
"Product F",
|
||||
"Product G",
|
||||
"Product H",
|
||||
]
|
||||
|
||||
# Generate one week of data
|
||||
data = []
|
||||
start_date = datetime(2024, 11, 4) # Monday
|
||||
|
||||
for day in range(7):
|
||||
date = start_date + timedelta(days=day)
|
||||
# Each product has 3-10 transactions per day
|
||||
for product in products:
|
||||
for _ in range(random.randint(3, 10)):
|
||||
units = random.randint(1, 15)
|
||||
price = random.randint(20, 150)
|
||||
revenue = units * price
|
||||
|
||||
data.append(
|
||||
{
|
||||
"Date": date.strftime("%Y-%m-%d"),
|
||||
"Product": product,
|
||||
"Units": units,
|
||||
"Revenue": revenue,
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel with some formatting
|
||||
output_file = output_dir / "sales_weekly.xlsx"
|
||||
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
|
||||
df.to_excel(writer, sheet_name="Weekly Sales", index=False)
|
||||
|
||||
# Format header
|
||||
worksheet = writer.sheets["Weekly Sales"]
|
||||
for cell in worksheet[1]:
|
||||
cell.font = Font(bold=True)
|
||||
|
||||
print(f" ✅ Created {output_file} ({len(df)} rows)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Generate all sample data files."""
|
||||
print("=" * 60)
|
||||
print("Generating Sample Data for LlamaSheets + Coding Agent Workflows")
|
||||
print("=" * 60)
|
||||
|
||||
# Create output directory
|
||||
output_dir = Path("input_data")
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Generate data for each workflow
|
||||
generate_workflow_1_data(output_dir)
|
||||
generate_workflow_2_data(output_dir)
|
||||
generate_workflow_3_data(output_dir)
|
||||
generate_workflow_4_data(output_dir)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ All sample data generated!")
|
||||
print("=" * 60)
|
||||
print(f"\nFiles created in {output_dir.absolute()}:")
|
||||
print("\nWorkflow 1 (Understanding a New Spreadsheet):")
|
||||
print(" • financial_report_q1.xlsx")
|
||||
print("\nWorkflow 2 (Generating Analysis Scripts):")
|
||||
print(" • sales_northeast.xlsx")
|
||||
print(" • sales_southeast.xlsx")
|
||||
print(" • sales_west.xlsx")
|
||||
print("\nWorkflow 3 (Using Cell Metadata):")
|
||||
print(" • company_budget_2024.xlsx")
|
||||
print("\nWorkflow 4 (Complete Automation):")
|
||||
print(" • sales_weekly.xlsx")
|
||||
print("\nYou can now use these files with the workflows in the documentation!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
LlamaSheets Agent with LlamaIndex
|
||||
|
||||
This example shows how to build an agent that can work with spreadsheet data
|
||||
extracted by LlamaSheets using Python code execution.
|
||||
|
||||
The agent has minimal tools but maximum flexibility - it can execute arbitrary
|
||||
pandas code against the extracted data, similar to a coding agent.
|
||||
|
||||
NOTE: Code execution should be handled safely in a sandboxed environment for security.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import dotenv
|
||||
import pandas as pd
|
||||
from llama_index.core.agent import FunctionAgent, ToolCall, ToolCallResult, AgentStream
|
||||
from llama_index.llms.openai import OpenAI
|
||||
from workflows import Context
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
# Global context for loaded dataframes
|
||||
_dataframe_context: Dict[str, Any] = {}
|
||||
|
||||
|
||||
# Helper function for initial agent context
|
||||
def list_extracted_data(data_dir: str = "data") -> str:
|
||||
"""
|
||||
List all regions and metadata files extracted by LlamaSheets.
|
||||
|
||||
This helps discover what data is available to work with.
|
||||
|
||||
Args:
|
||||
data_dir: Directory containing extracted parquet files (default: "data")
|
||||
|
||||
Returns:
|
||||
JSON string with information about available files
|
||||
"""
|
||||
data_path = Path(data_dir)
|
||||
|
||||
if not data_path.exists():
|
||||
return json.dumps({"error": f"Data directory '{data_dir}' not found"})
|
||||
|
||||
# Find all parquet and metadata files
|
||||
region_files = list(data_path.glob("*_region_*.parquet"))
|
||||
job_metadata_files = list(data_path.glob("*_job_metadata.json"))
|
||||
|
||||
regions = []
|
||||
for region_file in region_files:
|
||||
# Quick peek at dimensions
|
||||
df = pd.read_parquet(region_file)
|
||||
|
||||
# Find corresponding metadata file
|
||||
base_name = region_file.stem.replace("_region_", "_metadata_")
|
||||
metadata_path = region_file.parent / f"{base_name}.parquet"
|
||||
|
||||
regions.append(
|
||||
{
|
||||
"region_file": str(region_file),
|
||||
"metadata_file": str(metadata_path) if metadata_path.exists() else None,
|
||||
"shape": {"rows": len(df), "columns": len(df.columns)},
|
||||
"columns": list(df.columns),
|
||||
}
|
||||
)
|
||||
|
||||
result = {
|
||||
"data_directory": str(data_path.absolute()),
|
||||
"num_regions": len(regions),
|
||||
"regions": regions,
|
||||
"job_metadata_files": [str(f) for f in job_metadata_files],
|
||||
}
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
# Agent tool for code execution against dataframes
|
||||
def execute_dataframe_code(
|
||||
code: str, load_files: Optional[Dict[str, str]] = None
|
||||
) -> str:
|
||||
"""
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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"}
|
||||
|
||||
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 = '''
|
||||
# Load and inspect data
|
||||
df = pd.read_parquet("data/sales_region_1.parquet")
|
||||
print(f"Loaded {len(df)} rows")
|
||||
|
||||
result = {
|
||||
"shape": df.shape,
|
||||
"columns": list(df.columns),
|
||||
"sample": df.head(3).to_dict(orient="records")
|
||||
}
|
||||
'''
|
||||
"""
|
||||
global _dataframe_context
|
||||
|
||||
# Capture stdout and stderr
|
||||
stdout_capture = io.StringIO()
|
||||
stderr_capture = io.StringIO()
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
try:
|
||||
# Redirect stdout/stderr
|
||||
sys.stdout = stdout_capture
|
||||
sys.stderr = stderr_capture
|
||||
|
||||
# Create execution context with pandas, json, and previously loaded dfs
|
||||
exec_context = {
|
||||
"pd": pd,
|
||||
"json": json,
|
||||
"Path": Path,
|
||||
**_dataframe_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)
|
||||
|
||||
# Restore stdout/stderr
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
# Collect output
|
||||
stdout_output = stdout_capture.getvalue()
|
||||
stderr_output = stderr_capture.getvalue()
|
||||
|
||||
output_parts = []
|
||||
|
||||
# Add stdout if any
|
||||
if stdout_output:
|
||||
output_parts.append(f"<stdout>{stdout_output}</stdout>")
|
||||
|
||||
# Add stderr if any
|
||||
if stderr_output:
|
||||
output_parts.append(f"<stderr>{stderr_output}</stderr>")
|
||||
|
||||
# Try to get a result (if code set a 'result' variable)
|
||||
if "result" in exec_context:
|
||||
result = exec_context["result"]
|
||||
result_str = None
|
||||
|
||||
if isinstance(result, pd.DataFrame):
|
||||
# Convert DataFrame to readable format
|
||||
result_str = result.to_string()
|
||||
elif isinstance(result, (dict, list)):
|
||||
result_str = json.dumps(result, indent=2, default=str)
|
||||
else:
|
||||
result_str = str(result)
|
||||
|
||||
if result_str:
|
||||
output_parts.append(f"<result_var>{result_str}</result_var>")
|
||||
|
||||
# Return combined output or success message
|
||||
if output_parts:
|
||||
return "\n\n".join(output_parts)
|
||||
else:
|
||||
return "Code executed successfully (no output or result)"
|
||||
|
||||
except Exception as e:
|
||||
# Restore stdout/stderr in case of error
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
# Get any partial output
|
||||
stdout_output = stdout_capture.getvalue()
|
||||
stderr_output = stderr_capture.getvalue()
|
||||
|
||||
error_parts = []
|
||||
if stdout_output:
|
||||
error_parts.append(f"=== STDOUT (before error) ===\n{stdout_output}")
|
||||
if stderr_output:
|
||||
error_parts.append(f"=== STDERR (before error) ===\n{stderr_output}")
|
||||
|
||||
error_parts.append(f"=== ERROR ===\n{str(e)}")
|
||||
error_parts.append(f"\n=== CODE ===\n{code}")
|
||||
|
||||
return "\n\n".join(error_parts)
|
||||
|
||||
|
||||
def create_llamasheets_agent(
|
||||
llm_model: str = "gpt-4.1", api_key: Optional[str] = None
|
||||
) -> FunctionAgent:
|
||||
# Initialize LLM
|
||||
llm = OpenAI(model=llm_model, api_key=api_key)
|
||||
|
||||
# Create tools - just 4 simple but powerful tools
|
||||
tools = [execute_dataframe_code]
|
||||
|
||||
# System prompt to guide the agent
|
||||
available_regions = list_extracted_data()
|
||||
system_prompt = f"""You are an AI assistant that helps analyze spreadsheet data extracted by LlamaSheets.
|
||||
|
||||
LlamaSheets extracts messy spreadsheets into clean parquet files with two types of outputs:
|
||||
1. Region files (*_region_*.parquet) - The actual data with columns and rows
|
||||
2. Metadata files (*_metadata_*.parquet) - Rich cell-level metadata including:
|
||||
- Formatting: font_bold, font_italic, font_size, background_color_rgb
|
||||
- Position: row_number, column_number, coordinate
|
||||
- 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
|
||||
|
||||
Key tips:
|
||||
- Bold cells in metadata often indicate headers
|
||||
- Background colors often indicate groupings or departments
|
||||
- Load both region and metadata files for complete analysis
|
||||
- Write clear pandas code - you have full pandas functionality available
|
||||
- Store results in variables for reuse across multiple code executions
|
||||
|
||||
Existing Processed Regions:
|
||||
{available_regions}
|
||||
"""
|
||||
|
||||
# Configure agent
|
||||
return FunctionAgent(tools=tools, llm=llm, system_prompt=system_prompt)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Example of using the LlamaSheets agent."""
|
||||
|
||||
# Create the agent
|
||||
agent = create_llamasheets_agent()
|
||||
ctx = Context(agent)
|
||||
|
||||
# Example queries the agent can handle:
|
||||
queries = [
|
||||
# Discovery
|
||||
"What spreadsheet data is available?",
|
||||
# Simple analysis
|
||||
"Load the sales data and show me the first few rows with column info",
|
||||
# Using metadata
|
||||
"Find all bold cells in the metadata - these are likely headers",
|
||||
]
|
||||
|
||||
# Example: Run a query
|
||||
for query in queries:
|
||||
print(f"\n=== Query: {query} ===")
|
||||
handler = agent.run(query, ctx=ctx)
|
||||
async for ev in handler.stream_events():
|
||||
if isinstance(ev, ToolCall):
|
||||
tool_kwargs_str = (
|
||||
str(ev.tool_kwargs)[:500] + " ..."
|
||||
if len(str(ev.tool_kwargs)) > 500
|
||||
else str(ev.tool_kwargs)
|
||||
)
|
||||
print(f"\n[Tool Call] {ev.tool_name} with args:\n{tool_kwargs_str}\n\n")
|
||||
elif isinstance(ev, ToolCallResult):
|
||||
result_str = (
|
||||
str(ev.tool_output)[:500] + " ..."
|
||||
if len(str(ev.tool_output)) > 500
|
||||
else str(ev.tool_output)
|
||||
)
|
||||
print(f"\n[Tool Result] {ev.tool_name}:\n{result_str}\n\n")
|
||||
elif isinstance(ev, AgentStream):
|
||||
print(ev.delta, end="", flush=True)
|
||||
|
||||
_ = await handler
|
||||
print("=== End Query ===\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,7 @@
|
||||
llama-cloud-services # LlamaSheets SDK
|
||||
llama-index-core
|
||||
llama-index-llms-openai
|
||||
pandas>=2.0.0
|
||||
pyarrow>=12.0.0
|
||||
openpyxl>=3.0.0 # For Excel file support
|
||||
matplotlib>=3.7.0 # For visualizations (optional)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Helper script to extract spreadsheets using LlamaSheets."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import dotenv
|
||||
from pathlib import Path
|
||||
|
||||
from llama_cloud_services.beta.sheets import LlamaSheets
|
||||
from llama_cloud_services.beta.sheets.types import (
|
||||
SpreadsheetParsingConfig,
|
||||
SpreadsheetResultType,
|
||||
)
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
async def extract_spreadsheet(
|
||||
file_path: str, output_dir: str = "data", generate_metadata: bool = True
|
||||
) -> dict:
|
||||
"""Extract a spreadsheet using LlamaSheets."""
|
||||
|
||||
client = LlamaSheets(
|
||||
base_url="https://api.cloud.llamaindex.ai",
|
||||
api_key=os.getenv("LLAMA_CLOUD_API_KEY"),
|
||||
)
|
||||
|
||||
print(f"Extracting {file_path}...")
|
||||
|
||||
# Extract regions
|
||||
config = SpreadsheetParsingConfig(
|
||||
sheet_names=None, # Extract all sheets
|
||||
generate_additional_metadata=generate_metadata,
|
||||
)
|
||||
|
||||
job_result = await client.aextract_regions(file_path, config=config)
|
||||
|
||||
print(f"Extracted {len(job_result.regions)} region(s)")
|
||||
|
||||
# Create output directory
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get base name for files
|
||||
base_name = Path(file_path).stem
|
||||
|
||||
# Save job metadata
|
||||
job_metadata_path = output_path / f"{base_name}_job_metadata.json"
|
||||
with open(job_metadata_path, "w") as f:
|
||||
json.dump(job_result.model_dump(mode="json"), f, indent=2)
|
||||
print(f"Saved job metadata to {job_metadata_path}")
|
||||
|
||||
# Download each region
|
||||
for idx, region in enumerate(job_result.regions, 1):
|
||||
sheet_name = region.sheet_name.replace(" ", "_")
|
||||
|
||||
# Download region data
|
||||
region_bytes = await client.adownload_region_result(
|
||||
job_id=job_result.id,
|
||||
region_id=region.region_id,
|
||||
result_type=region.region_type,
|
||||
)
|
||||
|
||||
region_path = output_path / f"{base_name}_region_{idx}_{sheet_name}.parquet"
|
||||
with open(region_path, "wb") as f:
|
||||
f.write(region_bytes)
|
||||
print(f" Table {idx}: {region_path}")
|
||||
|
||||
# Download metadata
|
||||
metadata_bytes = await client.adownload_region_result(
|
||||
job_id=job_result.id,
|
||||
region_id=region.region_id,
|
||||
result_type=SpreadsheetResultType.CELL_METADATA,
|
||||
)
|
||||
|
||||
metadata_path = output_path / f"{base_name}_metadata_{idx}_{sheet_name}.parquet"
|
||||
with open(metadata_path, "wb") as f:
|
||||
f.write(metadata_bytes)
|
||||
print(f" Metadata {idx}: {metadata_path}")
|
||||
|
||||
print(f"\nAll files saved to {output_path}/")
|
||||
|
||||
return job_result.model_dump(mode="json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python scripts/extract.py <spreadsheet_file>")
|
||||
sys.exit(1)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
|
||||
if not Path(file_path).exists():
|
||||
print(f"❌ File not found: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
result = asyncio.run(extract_spreadsheet(file_path))
|
||||
print(f"\n✅ Extraction complete! Job ID: {result['id']}")
|
||||
@@ -5,14 +5,21 @@
|
||||
"private": true,
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"scripts": {
|
||||
"pre-commit-version": "pnpm changeset",
|
||||
"version": "./scripts/changeset-version.py version",
|
||||
"publish": "./scripts/changeset-version.py publish --tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.6.2",
|
||||
"lint-staged": "^15.4.2"
|
||||
"lint-staged": "^15.4.2",
|
||||
"@changesets/cli": "^2.29.5",
|
||||
"changesets": "^1.0.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"ts/llama_cloud_services/src/**/*.{ts,tsx,js,jsx}": [
|
||||
"pnpm --filter llama-cloud-services exec eslint --fix",
|
||||
"pnpm --filter llama-cloud-services exec prettier --write"
|
||||
"pnpm --filter llama-cloud-services exec prettier --write src/ tests/"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.11.1+sha512.e519b9f7639869dc8d5c3c5dfef73b3f091094b0a006d7317353c72b124e80e1afd429732e28705ad6bfa1ee879c1fce46c128ccebd3192101f43dd67c667912"
|
||||
|
||||
@@ -147,7 +147,7 @@ documents = SimpleDirectoryReader(
|
||||
).load_data()
|
||||
```
|
||||
|
||||
Full documentation for `SimpleDirectoryReader` can be found on the [LlamaIndex Documentation](https://docs.llamaindex.ai/en/stable/module_guides/loading/simpledirectoryreader.html).
|
||||
Full documentation for `SimpleDirectoryReader` can be found on the [LlamaIndex Documentation](https://developers.llamaindex.ai/python/framework/module_guides/loading/simpledirectoryreader/).
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
packages:
|
||||
- "ts/**"
|
||||
- "ts/*"
|
||||
- "py"
|
||||
- "py/*"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# llama-cloud-services-py
|
||||
|
||||
## 0.6.81
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f3233de: Propagate retrieval metadata to retriever nodes
|
||||
|
||||
## 0.6.80
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0506c88: Moved ClassifyClient to LlamaClassify (backward compatible)
|
||||
|
||||
## 0.6.79
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e020e3e: Remove unneeded organization_id param from beta classifier client
|
||||
|
||||
## 0.6.78
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9f1ef4e: Fix extract
|
||||
|
||||
## 0.6.77
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 407292b: Now return partial results on job failure
|
||||
|
||||
## 0.6.76
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 4f24f53: Add aggressive_table_extraction flag in python sdk
|
||||
|
||||
## 0.6.75
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- f81532e: Safest types possible for parse
|
||||
|
||||
## 0.6.74
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1bf5223: Fix default bbox values
|
||||
- 24166dc: Now only escape single dollar signs - preserve double for latex equations
|
||||
|
||||
## 0.6.73
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e6a7939: Loosen packaging dep requirement
|
||||
|
||||
## 0.6.72
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ad6734b: Fixup and test versioning
|
||||
|
||||
## 0.6.71
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 51011b9: Escape dollar signs in jupyter notebooks
|
||||
|
||||
## 0.6.70
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d028397: Update llama-cloud api version, and integrate with agent data deletion
|
||||
@@ -1,5 +1,7 @@
|
||||
from llama_cloud_services.parse import LlamaParse
|
||||
from llama_cloud_services.extract import LlamaExtract, ExtractionAgent, SourceText
|
||||
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 (
|
||||
LlamaCloudCompositeRetriever,
|
||||
@@ -12,8 +14,10 @@ __all__ = [
|
||||
"LlamaExtract",
|
||||
"ExtractionAgent",
|
||||
"SourceText",
|
||||
"FileInput",
|
||||
"EU_BASE_URL",
|
||||
"LlamaCloudIndex",
|
||||
"LlamaCloudRetriever",
|
||||
"LlamaCloudCompositeRetriever",
|
||||
"FakeLlamaCloudServer",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import os
|
||||
from typing import Any, Dict, Generic, List, Optional, Type
|
||||
|
||||
from llama_cloud import (
|
||||
AgentData,
|
||||
PaginatedResponseAgentData,
|
||||
PaginatedResponseAggregateGroup,
|
||||
)
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
from tenacity import (
|
||||
WrappedFn,
|
||||
@@ -86,7 +91,7 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
client=llama_client,
|
||||
type=ExtractedPerson,
|
||||
collection="extracted_people",
|
||||
agent_url_id="person-extraction-agent"
|
||||
deployment_name="person-extraction-agent"
|
||||
)
|
||||
|
||||
# Create data
|
||||
@@ -109,10 +114,12 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
self,
|
||||
type: Type[AgentDataT],
|
||||
collection: str = "default",
|
||||
agent_url_id: Optional[str] = None,
|
||||
deployment_name: Optional[str] = None,
|
||||
client: Optional[AsyncLlamaCloud] = None,
|
||||
token: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
# deprecated, use deployment_name instead
|
||||
agent_url_id: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the AsyncAgentDataClient.
|
||||
@@ -123,11 +130,11 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
collection: Named collection within the agent for organizing data.
|
||||
Defaults to "default". Collections allow logical separation of
|
||||
different data types or workflows within the same agent.
|
||||
agent_url_id: Unique identifier for the agent. This normally appears in the
|
||||
url of an agent within the llama cloud platform. If not provided,
|
||||
will attempt to use the LLAMA_DEPLOY_DEPLOYMENT_NAME environment
|
||||
variable. Data can only be added to an already existing agent in the
|
||||
platform.
|
||||
deployment_name: Unique identifier for the agent deployment. This normally
|
||||
appears in the URL of an agent within the Llama Cloud platform. If not
|
||||
provided, will attempt to use the LLAMA_DEPLOY_DEPLOYMENT_NAME
|
||||
environment variable. Data can only be added to an already existing
|
||||
agent in the platform.
|
||||
client: AsyncLlamaCloud client instance for API communication. If not provided, will
|
||||
construct one from the provided api token and base url
|
||||
token: Llama Cloud API token. Reads from LLAMA_CLOUD_API_KEY if not provided
|
||||
@@ -135,15 +142,14 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
defaults to https://api.cloud.llamaindex.ai
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_url_id is not provided and the
|
||||
ValueError: If deployment_name is not provided and the
|
||||
LLAMA_DEPLOY_DEPLOYMENT_NAME environment variable is not set
|
||||
|
||||
Note:
|
||||
The client automatically applies retry logic to all API calls with
|
||||
exponential backoff for timeout, connection, and HTTP status errors.
|
||||
"""
|
||||
|
||||
self.agent_url_id = agent_url_id or get_default_agent_id()
|
||||
self.deployment_name = deployment_name or agent_url_id or get_default_agent_id()
|
||||
|
||||
self.collection = collection
|
||||
if not client:
|
||||
@@ -156,15 +162,19 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
|
||||
@agent_data_retry
|
||||
async def get_item(self, item_id: str) -> TypedAgentData[AgentDataT]:
|
||||
raw_data = await self.client.beta.get_agent_data(
|
||||
raw_data = await self.untyped_get_item(item_id)
|
||||
return TypedAgentData.from_raw(raw_data, self.type)
|
||||
|
||||
@agent_data_retry
|
||||
async def untyped_get_item(self, item_id: str) -> AgentData:
|
||||
return await self.client.beta.get_agent_data(
|
||||
item_id=item_id,
|
||||
)
|
||||
return TypedAgentData.from_raw(raw_data, validator=self.type)
|
||||
|
||||
@agent_data_retry
|
||||
async def create_item(self, data: AgentDataT) -> TypedAgentData[AgentDataT]:
|
||||
raw_data = await self.client.beta.create_agent_data(
|
||||
agent_slug=self.agent_url_id,
|
||||
deployment_name=self.deployment_name,
|
||||
collection=self.collection,
|
||||
data=data.model_dump(),
|
||||
)
|
||||
@@ -184,6 +194,21 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
async def delete_item(self, item_id: str) -> None:
|
||||
await self.client.beta.delete_agent_data(item_id=item_id)
|
||||
|
||||
@agent_data_retry
|
||||
async def delete(
|
||||
self, filter: Optional[Dict[str, Dict[ComparisonOperator, Any]]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Delete agent data by query, similar to search.
|
||||
Returns the number of deleted items.
|
||||
"""
|
||||
response = await self.client.beta.delete_agent_data_by_query_api_v_1_beta_agent_data_delete_post(
|
||||
deployment_name=self.deployment_name,
|
||||
collection=self.collection,
|
||||
filter=filter,
|
||||
)
|
||||
return response.deleted_count
|
||||
|
||||
@agent_data_retry
|
||||
async def search(
|
||||
self,
|
||||
@@ -210,9 +235,7 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
offset: Number of items to skip from the beginning. Defaults to 0.
|
||||
include_total: Whether to include the total count in the response. Defaults to False to improve performance. It's recommended to only request on the first page.
|
||||
"""
|
||||
raw = await self.client.beta.search_agent_data_api_v_1_beta_agent_data_search_post(
|
||||
agent_slug=self.agent_url_id,
|
||||
collection=self.collection,
|
||||
raw = await self.untyped_search(
|
||||
filter=filter,
|
||||
order_by=order_by,
|
||||
offset=offset,
|
||||
@@ -227,6 +250,25 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
total=raw.total_size,
|
||||
)
|
||||
|
||||
@agent_data_retry
|
||||
async def untyped_search(
|
||||
self,
|
||||
filter: Optional[Dict[str, Dict[ComparisonOperator, Any]]] = None,
|
||||
order_by: Optional[str] = None,
|
||||
offset: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
include_total: bool = False,
|
||||
) -> PaginatedResponseAgentData:
|
||||
return await self.client.beta.search_agent_data_api_v_1_beta_agent_data_search_post(
|
||||
deployment_name=self.deployment_name,
|
||||
collection=self.collection,
|
||||
filter=filter,
|
||||
order_by=order_by,
|
||||
offset=offset,
|
||||
page_size=page_size,
|
||||
include_total=include_total,
|
||||
)
|
||||
|
||||
@agent_data_retry
|
||||
async def aggregate(
|
||||
self,
|
||||
@@ -253,8 +295,38 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
offset: Number of groups to skip from the beginning. Defaults to 0.
|
||||
page_size: Maximum number of groups to return per page.
|
||||
"""
|
||||
raw = await self.client.beta.aggregate_agent_data_api_v_1_beta_agent_data_aggregate_post(
|
||||
agent_slug=self.agent_url_id,
|
||||
raw = await self.untyped_aggregate(
|
||||
filter=filter,
|
||||
group_by=group_by,
|
||||
count=count,
|
||||
first=first,
|
||||
order_by=order_by,
|
||||
offset=offset,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return TypedAggregateGroupItems(
|
||||
items=[
|
||||
TypedAggregateGroup.from_raw(grp, validator=self.type)
|
||||
for grp in raw.items
|
||||
],
|
||||
has_more=raw.next_page_token is not None,
|
||||
total=raw.total_size,
|
||||
)
|
||||
|
||||
@agent_data_retry
|
||||
async def untyped_aggregate(
|
||||
self,
|
||||
filter: Optional[Dict[str, Dict[ComparisonOperator, Any]]] = None,
|
||||
group_by: Optional[List[str]] = None,
|
||||
count: Optional[bool] = None,
|
||||
first: Optional[bool] = None,
|
||||
order_by: Optional[str] = None,
|
||||
offset: Optional[int] = None,
|
||||
page_size: Optional[int] = None,
|
||||
) -> PaginatedResponseAggregateGroup:
|
||||
return await self.client.beta.aggregate_agent_data_api_v_1_beta_agent_data_aggregate_post(
|
||||
deployment_name=self.deployment_name,
|
||||
collection=self.collection,
|
||||
page_size=page_size,
|
||||
filter=filter,
|
||||
@@ -264,11 +336,3 @@ class AsyncAgentDataClient(Generic[AgentDataT]):
|
||||
first=first,
|
||||
offset=offset,
|
||||
)
|
||||
return TypedAggregateGroupItems(
|
||||
items=[
|
||||
TypedAggregateGroup.from_raw(item, validator=self.type)
|
||||
for item in raw.items
|
||||
],
|
||||
has_more=raw.next_page_token is not None,
|
||||
total=raw.total_size,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ CRUD operations, search capabilities, filtering, and aggregation functionality
|
||||
for managing agent-generated data at scale.
|
||||
|
||||
Key Concepts:
|
||||
- Agent Slug: Unique identifier for an agent instance
|
||||
- Deployment Name: Unique identifier for an agent deployment
|
||||
- Collection: Named grouping of data within an agent (defaults to "default"). Data within a collection should be of the same type.
|
||||
- Agent Data: Individual structured data records with metadata and timestamps
|
||||
|
||||
@@ -26,7 +26,7 @@ Example Usage:
|
||||
client=async_llama_cloud,
|
||||
type=Person,
|
||||
collection="people",
|
||||
agent_url_id="my-extraction-agent-xyz"
|
||||
deployment_name="my-extraction-agent-xyz"
|
||||
)
|
||||
|
||||
# Create typed data
|
||||
@@ -56,7 +56,6 @@ from typing import (
|
||||
|
||||
# Type variable for user-defined data models
|
||||
AgentDataT = TypeVar("AgentDataT", bound=BaseModel)
|
||||
|
||||
# Type variable for extracted data (can be dict or Pydantic model)
|
||||
ExtractedT = TypeVar("ExtractedT", bound=Union[BaseModel, dict])
|
||||
|
||||
@@ -78,7 +77,7 @@ class TypedAgentData(BaseModel, Generic[AgentDataT]):
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for this data record
|
||||
agent_url_id: Identifier of the agent that created this data
|
||||
deployment_name: Identifier of the agent deployment that created this data
|
||||
collection: Named collection within the agent (used for organization)
|
||||
data: The actual structured data payload (typed as AgentDataT)
|
||||
created_at: Timestamp when the record was first created
|
||||
@@ -94,8 +93,8 @@ class TypedAgentData(BaseModel, Generic[AgentDataT]):
|
||||
"""
|
||||
|
||||
id: Optional[str] = Field(description="Unique identifier for this data record")
|
||||
agent_url_id: str = Field(
|
||||
description="Identifier of the agent that created this data"
|
||||
deployment_name: str = Field(
|
||||
description="Identifier of the agent deployment that created this data"
|
||||
)
|
||||
collection: Optional[str] = Field(
|
||||
description="Named collection within the agent for data organization"
|
||||
@@ -116,15 +115,15 @@ class TypedAgentData(BaseModel, Generic[AgentDataT]):
|
||||
Args:
|
||||
raw_data: Raw agent data from the API
|
||||
validator: Pydantic model class to validate the data field
|
||||
|
||||
Returns:
|
||||
TypedAgentData instance with validated data
|
||||
"""
|
||||
|
||||
data: AgentDataT = validator.model_validate(raw_data.data)
|
||||
|
||||
return cls(
|
||||
id=raw_data.id,
|
||||
agent_url_id=raw_data.agent_slug,
|
||||
deployment_name=raw_data.deployment_name,
|
||||
collection=raw_data.collection,
|
||||
data=data,
|
||||
created_at=raw_data.created_at,
|
||||
@@ -222,12 +221,16 @@ def parse_extracted_field_metadata(
|
||||
return {
|
||||
k: _parse_extracted_field_metadata_recursive(v)
|
||||
for k, v in field_metadata.items()
|
||||
if k not in _METADATA_FIELDS_SIBLING_TO_LEAF
|
||||
and k not in _ADDITIONAL_ROOT_METADATA_FIELDS
|
||||
if not _is_reasoning_field(k, v) and k not in _ADDITIONAL_ROOT_METADATA_FIELDS
|
||||
}
|
||||
|
||||
|
||||
_METADATA_FIELDS_SIBLING_TO_LEAF = {"reasoning"}
|
||||
def _is_reasoning_field(field_name: str, field_value: Any) -> bool:
|
||||
# There can either be a user specified reasoning field (from the schema), or a reasoning metadata field for the
|
||||
# dict of values
|
||||
return field_name == "reasoning" and isinstance(field_value, str)
|
||||
|
||||
|
||||
_ADDITIONAL_ROOT_METADATA_FIELDS = {"error"}
|
||||
|
||||
|
||||
@@ -257,14 +260,12 @@ def _parse_extracted_field_metadata_recursive(
|
||||
except ValidationError:
|
||||
pass
|
||||
additional_fields = {
|
||||
k: v
|
||||
for k, v in field_value.items()
|
||||
if k in _METADATA_FIELDS_SIBLING_TO_LEAF
|
||||
k: v for k, v in field_value.items() if _is_reasoning_field(k, v)
|
||||
}
|
||||
return {
|
||||
k: _parse_extracted_field_metadata_recursive(v, additional_fields)
|
||||
for k, v in field_value.items()
|
||||
if k not in _METADATA_FIELDS_SIBLING_TO_LEAF
|
||||
if not _is_reasoning_field(k, v)
|
||||
}
|
||||
elif isinstance(field_value, list):
|
||||
return [_parse_extracted_field_metadata_recursive(item) for item in field_value]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from llama_cloud_services.beta.classifier.client import LlamaClassify, ClassifyClient
|
||||
from llama_cloud_services.beta.classifier.types import ClassifyJobResultsWithFiles
|
||||
from llama_cloud_services.utils import SourceText, FileInput
|
||||
|
||||
__all__ = [
|
||||
"LlamaClassify",
|
||||
"ClassifyClient",
|
||||
"ClassifyJobResultsWithFiles",
|
||||
"SourceText",
|
||||
"FileInput",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
import warnings
|
||||
from typing import Optional, List, Union
|
||||
from pydantic import BaseModel
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
from llama_cloud.types import (
|
||||
@@ -14,7 +15,11 @@ from llama_cloud.types import (
|
||||
from llama_cloud.resources.classifier.client import OMIT
|
||||
from llama_cloud_services.files.client import FileClient
|
||||
from llama_cloud_services.constants import POLLING_TIMEOUT_SECONDS
|
||||
from llama_cloud_services.utils import is_terminal_status, augment_async_errors
|
||||
from llama_cloud_services.utils import (
|
||||
is_terminal_status,
|
||||
augment_async_errors,
|
||||
FileInput,
|
||||
)
|
||||
from llama_index.core.async_utils import DEFAULT_NUM_WORKERS, run_jobs
|
||||
from llama_cloud_services.beta.classifier.types import (
|
||||
ClassifyJobResultsWithFiles,
|
||||
@@ -26,7 +31,7 @@ class ClassificationOutput(BaseModel):
|
||||
classification: str
|
||||
|
||||
|
||||
class ClassifyClient:
|
||||
class LlamaClassify:
|
||||
"""
|
||||
Experimental - Client for interacting with the LlamaCloud Classifier API.
|
||||
The Classification API is currently in beta and may change in the future without notice.
|
||||
@@ -34,7 +39,6 @@ class ClassifyClient:
|
||||
Args:
|
||||
client: The LlamaCloud client to use.
|
||||
project_id: The project ID to use.
|
||||
organization_id: The organization ID to use.
|
||||
polling_interval: The interval to poll for job completion in seconds.
|
||||
polling_timeout: The timeout for the job to complete in seconds.
|
||||
"""
|
||||
@@ -43,15 +47,13 @@ class ClassifyClient:
|
||||
self,
|
||||
client: AsyncLlamaCloud,
|
||||
project_id: Optional[str] = None,
|
||||
organization_id: Optional[str] = None,
|
||||
polling_interval: float = 1.0,
|
||||
polling_timeout: float = POLLING_TIMEOUT_SECONDS,
|
||||
):
|
||||
self.client = client
|
||||
self.project_id = project_id
|
||||
self.organization_id = organization_id
|
||||
self.polling_interval = polling_interval
|
||||
self.file_client = FileClient(client, project_id, organization_id)
|
||||
self.file_client = FileClient(client, project_id)
|
||||
self.polling_timeout = polling_timeout
|
||||
|
||||
@classmethod
|
||||
@@ -59,7 +61,6 @@ class ClassifyClient:
|
||||
cls,
|
||||
api_key: str,
|
||||
project_id: Optional[str] = None,
|
||||
organization_id: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> "ClassifyClient":
|
||||
"""
|
||||
@@ -69,7 +70,6 @@ class ClassifyClient:
|
||||
return cls(
|
||||
client,
|
||||
project_id,
|
||||
organization_id,
|
||||
)
|
||||
|
||||
async def acreate_classify_job(
|
||||
@@ -96,7 +96,6 @@ class ClassifyClient:
|
||||
file_ids=file_ids,
|
||||
parsing_configuration=parsing_configuration or OMIT,
|
||||
project_id=self.project_id,
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
|
||||
def create_classify_job(
|
||||
@@ -147,7 +146,6 @@ class ClassifyClient:
|
||||
results = await self.client.classifier.get_classification_job_results(
|
||||
classify_job_with_status.id,
|
||||
project_id=self.project_id,
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -166,6 +164,98 @@ class ClassifyClient:
|
||||
)
|
||||
)
|
||||
|
||||
async def aclassify(
|
||||
self,
|
||||
rules: list[ClassifierRule],
|
||||
files: Union[FileInput, List[FileInput]],
|
||||
parsing_configuration: Optional[ClassifyParsingConfiguration] = None,
|
||||
raise_on_error: bool = True,
|
||||
workers: int = DEFAULT_NUM_WORKERS,
|
||||
show_progress: bool = False,
|
||||
) -> ClassifyJobResultsWithFiles:
|
||||
"""
|
||||
Classify one or more files from various input types.
|
||||
|
||||
Args:
|
||||
rules: The rules to use for classification.
|
||||
files: The file(s) to classify. Can be a single file or list of files. Each can be:
|
||||
- str/Path: File path
|
||||
- SourceText: Text content or file with explicit filename
|
||||
- File: Already uploaded file
|
||||
- BufferedIOBase: File-like object
|
||||
parsing_configuration: The parsing configuration to use for classification.
|
||||
raise_on_error: Whether to raise an error if the classification job fails.
|
||||
workers: Number of parallel workers for uploading files.
|
||||
show_progress: Whether to show progress bars.
|
||||
|
||||
Returns:
|
||||
The results of the classification job with file metadata.
|
||||
"""
|
||||
# Normalize to list
|
||||
if not isinstance(files, list):
|
||||
files = [files]
|
||||
|
||||
# Upload all files
|
||||
coroutines = [
|
||||
self.file_client.upload_content(file_input) for file_input in files
|
||||
]
|
||||
uploaded_files: List[File] = await run_jobs(
|
||||
coroutines,
|
||||
show_progress=show_progress,
|
||||
workers=workers,
|
||||
desc="Uploading files for classification",
|
||||
)
|
||||
|
||||
# Classify
|
||||
results = await self.aclassify_file_ids(
|
||||
rules,
|
||||
[file.id for file in uploaded_files],
|
||||
parsing_configuration,
|
||||
raise_on_error,
|
||||
)
|
||||
return ClassifyJobResultsWithFiles.from_classify_job_results(
|
||||
results, uploaded_files
|
||||
)
|
||||
|
||||
def classify(
|
||||
self,
|
||||
rules: list[ClassifierRule],
|
||||
files: Union[FileInput, List[FileInput]],
|
||||
parsing_configuration: Optional[ClassifyParsingConfiguration] = None,
|
||||
raise_on_error: bool = True,
|
||||
workers: int = DEFAULT_NUM_WORKERS,
|
||||
show_progress: bool = False,
|
||||
) -> ClassifyJobResultsWithFiles:
|
||||
"""
|
||||
Classify one or more files from various input types (synchronous version).
|
||||
|
||||
Args:
|
||||
rules: The rules to use for classification.
|
||||
files: The file(s) to classify. Can be a single file or list of files. Each can be:
|
||||
- str/Path: File path
|
||||
- SourceText: Text content or file with explicit filename
|
||||
- File: Already uploaded file
|
||||
- BufferedIOBase: File-like object
|
||||
parsing_configuration: The parsing configuration to use for classification.
|
||||
raise_on_error: Whether to raise an error if the classification job fails.
|
||||
workers: Number of parallel workers for uploading files.
|
||||
show_progress: Whether to show progress bars.
|
||||
|
||||
Returns:
|
||||
The results of the classification job with file metadata.
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(
|
||||
self.aclassify(
|
||||
rules,
|
||||
files,
|
||||
parsing_configuration,
|
||||
raise_on_error,
|
||||
workers,
|
||||
show_progress,
|
||||
)
|
||||
)
|
||||
|
||||
async def aclassify_file_path(
|
||||
self,
|
||||
rules: list[ClassifierRule],
|
||||
@@ -173,11 +263,17 @@ class ClassifyClient:
|
||||
parsing_configuration: Optional[ClassifyParsingConfiguration] = None,
|
||||
raise_on_error: bool = True,
|
||||
) -> ClassifyJobResultsWithFiles:
|
||||
file = await self.file_client.upload_file(file_input_path)
|
||||
results = await self.aclassify_file_ids(
|
||||
rules, [file.id], parsing_configuration, raise_on_error
|
||||
"""
|
||||
Deprecated: Use aclassify() instead.
|
||||
"""
|
||||
warnings.warn(
|
||||
"aclassify_file_path is deprecated, use aclassify() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return await self.aclassify(
|
||||
rules, file_input_path, parsing_configuration, raise_on_error
|
||||
)
|
||||
return ClassifyJobResultsWithFiles.from_classify_job_results(results, [file])
|
||||
|
||||
def classify_file_path(
|
||||
self,
|
||||
@@ -186,12 +282,17 @@ class ClassifyClient:
|
||||
parsing_configuration: Optional[ClassifyParsingConfiguration] = None,
|
||||
raise_on_error: bool = True,
|
||||
) -> ClassifyJobResultsWithFiles:
|
||||
with augment_async_errors():
|
||||
return asyncio.run(
|
||||
self.aclassify_file_path(
|
||||
rules, file_input_path, parsing_configuration, raise_on_error
|
||||
)
|
||||
)
|
||||
"""
|
||||
Deprecated: Use classify() instead.
|
||||
"""
|
||||
warnings.warn(
|
||||
"classify_file_path is deprecated, use classify() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.classify(
|
||||
rules, file_input_path, parsing_configuration, raise_on_error
|
||||
)
|
||||
|
||||
async def aclassify_file_paths(
|
||||
self,
|
||||
@@ -202,17 +303,22 @@ class ClassifyClient:
|
||||
workers: int = DEFAULT_NUM_WORKERS,
|
||||
show_progress: bool = False,
|
||||
) -> ClassifyJobResultsWithFiles:
|
||||
coroutines = [self.file_client.upload_file(path) for path in file_input_paths]
|
||||
files: list[File] = await run_jobs(
|
||||
coroutines,
|
||||
show_progress=show_progress,
|
||||
workers=workers,
|
||||
desc="Uploading files for classification",
|
||||
"""
|
||||
Deprecated: Use aclassify() instead.
|
||||
"""
|
||||
warnings.warn(
|
||||
"aclassify_file_paths is deprecated, use aclassify() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
results = await self.aclassify_file_ids(
|
||||
rules, [file.id for file in files], parsing_configuration, raise_on_error
|
||||
return await self.aclassify(
|
||||
rules,
|
||||
file_input_paths,
|
||||
parsing_configuration,
|
||||
raise_on_error,
|
||||
workers,
|
||||
show_progress,
|
||||
)
|
||||
return ClassifyJobResultsWithFiles.from_classify_job_results(results, files)
|
||||
|
||||
def classify_file_paths(
|
||||
self,
|
||||
@@ -221,12 +327,17 @@ class ClassifyClient:
|
||||
parsing_configuration: Optional[ClassifyParsingConfiguration] = None,
|
||||
raise_on_error: bool = True,
|
||||
) -> ClassifyJobResultsWithFiles:
|
||||
with augment_async_errors():
|
||||
return asyncio.run(
|
||||
self.aclassify_file_paths(
|
||||
rules, file_input_paths, parsing_configuration, raise_on_error
|
||||
)
|
||||
)
|
||||
"""
|
||||
Deprecated: Use classify() instead.
|
||||
"""
|
||||
warnings.warn(
|
||||
"classify_file_paths is deprecated, use classify() instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.classify(
|
||||
rules, file_input_paths, parsing_configuration, raise_on_error
|
||||
)
|
||||
|
||||
async def wait_for_job_completion(self, job_id: str) -> ClassifyJob:
|
||||
"""
|
||||
@@ -241,7 +352,7 @@ class ClassifyClient:
|
||||
The classify job with status.
|
||||
"""
|
||||
job = await self.client.classifier.get_classify_job(
|
||||
job_id, project_id=self.project_id, organization_id=self.organization_id
|
||||
job_id, project_id=self.project_id
|
||||
)
|
||||
start_time = time.time()
|
||||
while not is_terminal_status(job.status):
|
||||
@@ -252,6 +363,9 @@ class ClassifyClient:
|
||||
)
|
||||
await asyncio.sleep(self.polling_interval)
|
||||
job = await self.client.classifier.get_classify_job(
|
||||
job_id, project_id=self.project_id, organization_id=self.organization_id
|
||||
job_id, project_id=self.project_id
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
ClassifyClient = LlamaClassify
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""LlamaCloud Spreadsheet API SDK
|
||||
|
||||
This module provides a Python SDK for the LlamaCloud Spreadsheet API.
|
||||
"""
|
||||
|
||||
from llama_cloud_services.beta.sheets.client import (
|
||||
LlamaSheets,
|
||||
SpreadsheetAPIError,
|
||||
SpreadsheetJobError,
|
||||
SpreadsheetTimeoutError,
|
||||
)
|
||||
from llama_cloud_services.beta.sheets.types import (
|
||||
ExtractedRegionSummary,
|
||||
FileUploadResponse,
|
||||
JobStatus,
|
||||
PresignedUrlResponse,
|
||||
SpreadsheetJob,
|
||||
SpreadsheetJobResult,
|
||||
SpreadsheetParseResult,
|
||||
SpreadsheetParsingConfig,
|
||||
SpreadsheetResultType,
|
||||
WorksheetMetadata,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
"LlamaSheets",
|
||||
# Exceptions
|
||||
"SpreadsheetAPIError",
|
||||
"SpreadsheetJobError",
|
||||
"SpreadsheetTimeoutError",
|
||||
# Types
|
||||
"ExtractedRegionSummary",
|
||||
"FileUploadResponse",
|
||||
"JobStatus",
|
||||
"PresignedUrlResponse",
|
||||
"SpreadsheetJob",
|
||||
"SpreadsheetJobResult",
|
||||
"SpreadsheetParseResult",
|
||||
"SpreadsheetParsingConfig",
|
||||
"SpreadsheetResultType",
|
||||
"WorksheetMetadata",
|
||||
]
|
||||
@@ -0,0 +1,520 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from llama_cloud_services.beta.sheets.types import (
|
||||
FileUploadResponse,
|
||||
JobStatus,
|
||||
PresignedUrlResponse,
|
||||
SpreadsheetJob,
|
||||
SpreadsheetJobResult,
|
||||
SpreadsheetParsingConfig,
|
||||
SpreadsheetResultType,
|
||||
)
|
||||
from llama_cloud_services.constants import BASE_URL
|
||||
from llama_cloud_services.files.client import FileClient
|
||||
from llama_cloud_services.utils import (
|
||||
augment_async_errors,
|
||||
FileInput,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _should_retry_exception(exception: BaseException) -> bool:
|
||||
"""Determine if an exception should be retried."""
|
||||
if isinstance(exception, httpx.HTTPStatusError):
|
||||
return exception.response.status_code in (429, 500, 502, 503, 504)
|
||||
return False
|
||||
|
||||
|
||||
class SpreadsheetAPIError(Exception):
|
||||
"""Base exception for spreadsheet API errors"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpreadsheetJobError(SpreadsheetAPIError):
|
||||
"""Exception raised when a spreadsheet job fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpreadsheetTimeoutError(SpreadsheetAPIError):
|
||||
"""Exception raised when a job times out"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LlamaSheets:
|
||||
"""Client for the LlamaCloud Spreadsheet API"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
max_timeout: int = 300,
|
||||
poll_interval: int = 5,
|
||||
max_retries: int = 3,
|
||||
async_httpx_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
"""Initialize the LlamaSheets client.
|
||||
|
||||
Args:
|
||||
api_key: API key for authentication. If not provided, will use LLAMA_CLOUD_API_KEY env var
|
||||
base_url: Base URL for the API
|
||||
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
|
||||
async_httpx_client: Optional custom async httpx client
|
||||
"""
|
||||
self.api_key = api_key or os.environ.get("LLAMA_CLOUD_API_KEY")
|
||||
if not self.api_key:
|
||||
raise ValueError(
|
||||
"An API key must be provided either as an argument or via the LLAMA_CLOUD_API_KEY environment variable."
|
||||
)
|
||||
|
||||
base_url = base_url or os.environ.get("LLAMA_CLOUD_BASE_URL", BASE_URL)
|
||||
self.base_url = str(base_url).rstrip("/")
|
||||
|
||||
self.max_timeout = max_timeout
|
||||
self.poll_interval = poll_interval
|
||||
self.max_retries = max_retries
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
def _get_async_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create the async httpx client"""
|
||||
if self._async_client is None:
|
||||
self._async_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0),
|
||||
follow_redirects=True,
|
||||
)
|
||||
return self._async_client
|
||||
|
||||
def _get_headers(self) -> dict[str, str]:
|
||||
"""Get common headers for API requests"""
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Sync methods
|
||||
|
||||
def upload_file(
|
||||
self, file_obj: FileInput, file_name: str | None = None
|
||||
) -> FileUploadResponse:
|
||||
"""Upload a file to the Files API.
|
||||
|
||||
Args:
|
||||
file_obj: File to upload (path, bytes, or file-like object)
|
||||
file_name: Optional name for the uploaded filename
|
||||
|
||||
Returns:
|
||||
FileUploadResponse with the uploaded file ID
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(self.aupload_file(file_obj))
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
file_id: str,
|
||||
config: dict | SpreadsheetParsingConfig | None = None,
|
||||
) -> SpreadsheetJob:
|
||||
"""Create a new spreadsheet parsing job.
|
||||
|
||||
Args:
|
||||
file_id: ID of the uploaded file
|
||||
config: Parsing configuration
|
||||
|
||||
Returns:
|
||||
SpreadsheetJob with job details
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(self.acreate_job(file_id, config))
|
||||
|
||||
def get_job(
|
||||
self, job_id: str, include_results_metadata: bool = True
|
||||
) -> SpreadsheetJobResult:
|
||||
"""Get the status of a spreadsheet parsing job.
|
||||
|
||||
Args:
|
||||
job_id: ID of the job
|
||||
include_results_metadata: Whether to include results metadata in the response
|
||||
|
||||
Returns:
|
||||
SpreadsheetJobResult with job status and optionally results
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(self.aget_job(job_id, include_results_metadata))
|
||||
|
||||
def wait_for_completion(self, job_id: str) -> SpreadsheetJobResult:
|
||||
"""Wait for a job to complete by polling.
|
||||
|
||||
Args:
|
||||
job_id: ID of the job to wait for
|
||||
|
||||
Returns:
|
||||
SpreadsheetJobResult when job is complete
|
||||
|
||||
Raises:
|
||||
SpreadsheetTimeoutError: If job doesn't complete within max_timeout
|
||||
SpreadsheetJobError: If job fails
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(self.await_for_completion(job_id))
|
||||
|
||||
def download_region_result(
|
||||
self,
|
||||
job_id: str,
|
||||
region_id: str,
|
||||
result_type: SpreadsheetResultType = SpreadsheetResultType.TABLE,
|
||||
) -> bytes:
|
||||
"""Download a region result (either region data or cell metadata).
|
||||
|
||||
Args:
|
||||
job_id: ID of the job
|
||||
region_id: ID of the region
|
||||
result_type: Type of result to download (region or cell_metadata)
|
||||
|
||||
Returns:
|
||||
Raw bytes of the parquet file
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(
|
||||
self.adownload_region_result(job_id, region_id, result_type)
|
||||
)
|
||||
|
||||
def download_region_as_dataframe(
|
||||
self,
|
||||
job_id: str,
|
||||
region_id: str,
|
||||
result_type: SpreadsheetResultType = SpreadsheetResultType.TABLE,
|
||||
) -> "pd.DataFrame":
|
||||
"""Download a region result as a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
job_id: ID of the job
|
||||
region_id: ID of the region
|
||||
result_type: Type of result to download (region or cell_metadata)
|
||||
|
||||
Returns:
|
||||
pandas DataFrame
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(
|
||||
self.adownload_region_as_dataframe(job_id, region_id, result_type)
|
||||
)
|
||||
|
||||
def extract_regions(
|
||||
self,
|
||||
file_obj: FileInput,
|
||||
config: dict | SpreadsheetParsingConfig | None = None,
|
||||
) -> SpreadsheetJobResult:
|
||||
"""High-level method to parse a spreadsheet file.
|
||||
|
||||
This method handles the entire workflow:
|
||||
1. Upload the file
|
||||
2. Create a parsing job
|
||||
3. Wait for completion
|
||||
4. Return results
|
||||
|
||||
Args:
|
||||
file_obj: File to parse (path, bytes, or file-like object)
|
||||
config: Parsing configuration
|
||||
|
||||
Returns:
|
||||
SpreadsheetJobResult with parsing results
|
||||
"""
|
||||
with augment_async_errors():
|
||||
return asyncio.run(self.aextract_regions(file_obj, config))
|
||||
|
||||
# Async methods
|
||||
|
||||
async def aupload_file(
|
||||
self, file_obj: FileInput, file_name: str | None = None
|
||||
) -> FileUploadResponse:
|
||||
"""Upload a file to the Files API.
|
||||
|
||||
Args:
|
||||
file_obj: File to upload (path, bytes, or file-like object)
|
||||
file_name: Optional name for the uploaded filename
|
||||
|
||||
Returns:
|
||||
FileUploadResponse with the uploaded file ID
|
||||
"""
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=32),
|
||||
retry=retry_if_exception(_should_retry_exception),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
return await self._files_client.upload_content(
|
||||
file_obj, external_file_id=file_name
|
||||
)
|
||||
except Exception as e:
|
||||
raise SpreadsheetAPIError(f"Failed to upload file: {e}") from e
|
||||
raise RuntimeError("Tenacity did not execute")
|
||||
|
||||
async def acreate_job(
|
||||
self,
|
||||
file_id: str,
|
||||
config: dict | SpreadsheetParsingConfig | None = None,
|
||||
) -> SpreadsheetJob:
|
||||
"""Create a new spreadsheet parsing job.
|
||||
|
||||
Args:
|
||||
file_id: ID of the uploaded file
|
||||
config: Parsing configuration
|
||||
|
||||
Returns:
|
||||
SpreadsheetJob with job details
|
||||
"""
|
||||
if config is None:
|
||||
config = SpreadsheetParsingConfig()
|
||||
elif isinstance(config, dict):
|
||||
config = SpreadsheetParsingConfig.model_validate(config)
|
||||
|
||||
if not isinstance(config, SpreadsheetParsingConfig):
|
||||
raise ValueError(
|
||||
"config must be a dict or SpreadsheetParsingConfig instance"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"file_id": file_id,
|
||||
"config": config.model_dump(mode="json", exclude_none=True),
|
||||
}
|
||||
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=32),
|
||||
retry=retry_if_exception(_should_retry_exception),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
client = self._get_async_client()
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/v1/beta/sheets/jobs",
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return SpreadsheetJob.model_validate(response.json())
|
||||
except Exception as e:
|
||||
raise SpreadsheetAPIError(f"Failed to create job: {e}") from e
|
||||
raise RuntimeError("Tenacity did not execute")
|
||||
|
||||
async def aget_job(
|
||||
self, job_id: str, include_results_metadata: bool = True
|
||||
) -> SpreadsheetJobResult:
|
||||
"""Get the status of a spreadsheet parsing job.
|
||||
|
||||
Args:
|
||||
job_id: ID of the job
|
||||
include_results_metadata: Whether to include results in the response
|
||||
|
||||
Returns:
|
||||
SpreadsheetJobResult with job status and optionally results
|
||||
"""
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=32),
|
||||
retry=retry_if_exception(_should_retry_exception),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
client = self._get_async_client()
|
||||
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},
|
||||
)
|
||||
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
|
||||
raise RuntimeError("Tenacity did not execute")
|
||||
|
||||
async def await_for_completion(self, job_id: str) -> SpreadsheetJobResult:
|
||||
"""Wait for a job to complete by polling.
|
||||
|
||||
Args:
|
||||
job_id: ID of the job to wait for
|
||||
|
||||
Returns:
|
||||
SpreadsheetJobResult when job is complete
|
||||
|
||||
Raises:
|
||||
SpreadsheetTimeoutError: If job doesn't complete within max_timeout
|
||||
SpreadsheetJobError: If job fails
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while (time.time() - start_time) < self.max_timeout:
|
||||
job_result = await self.aget_job(job_id, include_results_metadata=True)
|
||||
|
||||
if job_result.status in (
|
||||
JobStatus.SUCCESS,
|
||||
JobStatus.PARTIAL_SUCCESS,
|
||||
JobStatus.ERROR,
|
||||
JobStatus.FAILURE,
|
||||
):
|
||||
if job_result.status in (JobStatus.SUCCESS, JobStatus.PARTIAL_SUCCESS):
|
||||
return job_result
|
||||
else:
|
||||
error_msg = f"Job failed with status: {job_result.status}"
|
||||
if job_result.errors:
|
||||
error_msg += f"\nErrors: {', '.join(job_result.errors)}"
|
||||
raise SpreadsheetJobError(error_msg)
|
||||
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
|
||||
raise SpreadsheetTimeoutError(
|
||||
f"Job did not complete within {self.max_timeout} seconds"
|
||||
)
|
||||
|
||||
async def adownload_region_result(
|
||||
self,
|
||||
job_id: str,
|
||||
region_id: str,
|
||||
result_type: SpreadsheetResultType = SpreadsheetResultType.TABLE,
|
||||
) -> bytes:
|
||||
"""Download a region result (either region data or cell metadata).
|
||||
|
||||
Args:
|
||||
job_id: ID of the job
|
||||
region_id: ID of the region
|
||||
result_type: Type of result to download (region or cell_metadata)
|
||||
|
||||
Returns:
|
||||
Raw bytes of the parquet file
|
||||
"""
|
||||
# Get presigned URL
|
||||
presigned_response = None
|
||||
result_type_str = str(result_type)
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=32),
|
||||
retry=retry_if_exception(_should_retry_exception),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
client = self._get_async_client()
|
||||
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(),
|
||||
)
|
||||
response.raise_for_status()
|
||||
presigned_response = PresignedUrlResponse.model_validate(
|
||||
response.json()
|
||||
)
|
||||
except Exception as e:
|
||||
raise SpreadsheetAPIError(f"Failed to get presigned URL: {e}") from e
|
||||
|
||||
# Download using presigned URL
|
||||
if presigned_response is None:
|
||||
raise SpreadsheetAPIError("Failed to obtain presigned URL.")
|
||||
|
||||
try:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=32),
|
||||
retry=retry_if_exception(_should_retry_exception),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
download_response = await client.get(presigned_response.url)
|
||||
download_response.raise_for_status()
|
||||
return download_response.content
|
||||
except Exception as e:
|
||||
raise SpreadsheetAPIError(f"Failed to download result: {e}") from e
|
||||
raise RuntimeError("Tenacity did not execute")
|
||||
|
||||
async def adownload_region_as_dataframe(
|
||||
self,
|
||||
job_id: str,
|
||||
region_id: str,
|
||||
result_type: SpreadsheetResultType = SpreadsheetResultType.TABLE,
|
||||
) -> "pd.DataFrame":
|
||||
"""Download a region result as a pandas DataFrame.
|
||||
|
||||
Args:
|
||||
job_id: ID of the job
|
||||
region_id: ID of the region
|
||||
result_type: Type of result to download (region or cell_metadata)
|
||||
|
||||
Returns:
|
||||
pandas DataFrame
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
parquet_bytes = await self.adownload_region_result(
|
||||
job_id, region_id, result_type
|
||||
)
|
||||
return pd.read_parquet(io.BytesIO(parquet_bytes))
|
||||
|
||||
async def aextract_regions(
|
||||
self,
|
||||
file_obj: FileInput,
|
||||
config: dict | SpreadsheetParsingConfig | None = None,
|
||||
) -> SpreadsheetJobResult:
|
||||
"""High-level method to parse a spreadsheet file.
|
||||
|
||||
This method handles the entire workflow:
|
||||
1. Upload the file
|
||||
2. Create a parsing job
|
||||
3. Wait for completion
|
||||
4. Return results
|
||||
|
||||
Args:
|
||||
file_obj: File to parse (path, bytes, or file-like object)
|
||||
config: Parsing configuration
|
||||
|
||||
Returns:
|
||||
SpreadsheetJobResult with parsing results
|
||||
"""
|
||||
# Upload file
|
||||
file_response = await self.aupload_file(file_obj)
|
||||
|
||||
# Create job
|
||||
job = await self.acreate_job(file_response.id, config)
|
||||
|
||||
# Wait for completion
|
||||
return await self.await_for_completion(job.id)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all HTTP clients (async)"""
|
||||
if self._async_client:
|
||||
await self._async_client.aclose()
|
||||
|
||||
async def __aenter__(self) -> "LlamaSheets":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, _exc_type, _exc_val, _exc_tb) -> None: # type: ignore
|
||||
await self.aclose()
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class SpreadsheetResultType(str, Enum):
|
||||
TABLE = "table"
|
||||
EXTRA = "extra"
|
||||
CELL_METADATA = "cell_metadata"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class ExtractedRegionSummary(BaseModel):
|
||||
"""A summary of a single extracted region from a spreadsheet"""
|
||||
|
||||
region_id: str = Field(
|
||||
...,
|
||||
description="Unique identifier for this region within the file",
|
||||
)
|
||||
sheet_name: str = Field(..., description="Worksheet name where region was found")
|
||||
location: str = Field(..., description="Location of the region in the spreadsheet")
|
||||
title: str | None = Field(None, description="Generated title for the region")
|
||||
description: str | None = Field(
|
||||
None, description="Generated description of the region"
|
||||
)
|
||||
region_type: SpreadsheetResultType = Field(
|
||||
..., description="Type of the extracted region"
|
||||
)
|
||||
|
||||
|
||||
class WorksheetMetadata(BaseModel):
|
||||
"""Metadata about a worksheet in a spreadsheet"""
|
||||
|
||||
sheet_name: str = Field(..., description="Name of the worksheet")
|
||||
title: str | None = Field(None, description="Generated title for the worksheet")
|
||||
description: str | None = Field(
|
||||
None, description="Generated description of the worksheet"
|
||||
)
|
||||
|
||||
|
||||
class SpreadsheetParseResult(BaseModel):
|
||||
"""Result of parsing a single spreadsheet file"""
|
||||
|
||||
success: bool = Field(..., description="Whether parsing was successful")
|
||||
file_name: str = Field(..., description="Original filename")
|
||||
|
||||
regions: list[ExtractedRegionSummary] = Field(
|
||||
default_factory=list, description="All successfully extracted regions"
|
||||
)
|
||||
worksheet_metadata: list[WorksheetMetadata] = Field(
|
||||
default_factory=list, description="Metadata for each processed worksheet"
|
||||
)
|
||||
|
||||
# Error information
|
||||
errors: list[str] = Field(
|
||||
default_factory=list, description="Any errors encountered during parsing"
|
||||
)
|
||||
|
||||
|
||||
class SpreadsheetParsingConfig(BaseModel):
|
||||
"""Configuration for spreadsheet parsing and region extraction"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sheet_names: list[str] | None = Field(
|
||||
default=None,
|
||||
description="The names of the sheets to extract regions from. If empty, the default sheet is extracted.",
|
||||
)
|
||||
include_hidden_cells: bool = Field(
|
||||
default=True,
|
||||
description="Whether to include hidden cells when extracting regions from the spreadsheet.",
|
||||
)
|
||||
extraction_range: str | None = Field(
|
||||
default=None,
|
||||
description="A1 notation of the range to extract a single region from. If None, the entire sheet is used.",
|
||||
)
|
||||
generate_additional_metadata: bool = Field(
|
||||
default=True,
|
||||
description="Whether to generate additional metadata (title, description) for each extracted region.",
|
||||
)
|
||||
use_experimental_processing: bool = Field(
|
||||
default=False,
|
||||
description="Enables experimental processing. Accuracy may be impacted.",
|
||||
)
|
||||
|
||||
|
||||
class SpreadsheetJob(BaseModel):
|
||||
"""A spreadsheet parsing job"""
|
||||
|
||||
id: str = Field(..., description="The ID of the job")
|
||||
user_id: str = Field(..., description="The ID of the user")
|
||||
project_id: str = Field(..., description="The ID of the project")
|
||||
file: dict = Field(..., description="The file object being parsed")
|
||||
config: SpreadsheetParsingConfig = Field(
|
||||
..., description="Configuration for the parsing job"
|
||||
)
|
||||
status: str = Field(..., description="The status of the parsing job")
|
||||
created_at: str = Field(..., description="When the job was created")
|
||||
updated_at: str = Field(..., description="When the job was last updated")
|
||||
|
||||
@field_validator("created_at", "updated_at", mode="before")
|
||||
def validate_dates(cls, v: str) -> str:
|
||||
"""Validate that the dates are in the correct format"""
|
||||
if isinstance(v, datetime):
|
||||
return v.isoformat()
|
||||
else:
|
||||
return v
|
||||
|
||||
|
||||
class SpreadsheetJobResult(SpreadsheetJob):
|
||||
"""A spreadsheet parsing job result."""
|
||||
|
||||
# Results are included when the job is complete
|
||||
success: bool | None = Field(
|
||||
None, description="Whether the job completed successfully"
|
||||
)
|
||||
regions: list[ExtractedRegionSummary] = Field(
|
||||
default_factory=list,
|
||||
description="All extracted regions (populated when job is complete)",
|
||||
)
|
||||
worksheet_metadata: list[WorksheetMetadata] = Field(
|
||||
default_factory=list,
|
||||
description="Metadata for each processed worksheet (populated when job is complete)",
|
||||
)
|
||||
errors: list[str] = Field(
|
||||
default_factory=list, description="Any errors encountered"
|
||||
)
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""Status of a spreadsheet parsing job"""
|
||||
|
||||
PENDING = "PENDING"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
SUCCESS = "SUCCESS"
|
||||
PARTIAL_SUCCESS = "PARTIAL_SUCCESS"
|
||||
ERROR = "ERROR"
|
||||
FAILURE = "FAILURE"
|
||||
|
||||
|
||||
class PresignedUrlResponse(BaseModel):
|
||||
"""Response containing a presigned URL for downloading results"""
|
||||
|
||||
url: str = Field(..., description="The presigned URL for downloading")
|
||||
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""Response from uploading a file"""
|
||||
|
||||
id: str = Field(..., description="The ID of the uploaded file")
|
||||
name: str = Field(..., description="The name of the file")
|
||||
project_id: str = Field(..., description="The project ID")
|
||||
user_id: str = Field(..., description="The user ID")
|
||||
@@ -1,2 +1,3 @@
|
||||
BASE_URL = "https://api.cloud.llamaindex.ai"
|
||||
EU_BASE_URL = "https://api.cloud.eu.llamaindex.ai"
|
||||
POLLING_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
@@ -2,15 +2,16 @@ from llama_cloud_services.extract.extract import (
|
||||
LlamaExtract,
|
||||
ExtractConfig,
|
||||
ExtractionAgent,
|
||||
SourceText,
|
||||
ExtractTarget,
|
||||
ExtractMode,
|
||||
)
|
||||
from llama_cloud_services.utils import SourceText, FileInput
|
||||
|
||||
__all__ = [
|
||||
"LlamaExtract",
|
||||
"ExtractionAgent",
|
||||
"SourceText",
|
||||
"FileInput",
|
||||
"ExtractConfig",
|
||||
"ExtractTarget",
|
||||
"ExtractMode",
|
||||
|
||||
@@ -2,10 +2,9 @@ import asyncio
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from io import BufferedIOBase, BufferedReader, BytesIO, TextIOWrapper
|
||||
from io import BufferedIOBase, TextIOWrapper
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Type, Union, Coroutine, Any, TypeVar
|
||||
import secrets
|
||||
import warnings
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
@@ -19,14 +18,12 @@ from llama_cloud import (
|
||||
ExtractAgent as CloudExtractAgent,
|
||||
ExtractConfig,
|
||||
ExtractJob,
|
||||
ExtractJobCreate,
|
||||
ExtractRun,
|
||||
File,
|
||||
FileData,
|
||||
ExtractMode,
|
||||
StatusEnum,
|
||||
ExtractTarget,
|
||||
LlamaExtractSettings,
|
||||
PaginatedExtractRunsResponse,
|
||||
)
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
@@ -35,7 +32,8 @@ from llama_cloud_services.extract.utils import (
|
||||
JSONObjectType,
|
||||
ExperimentalWarning,
|
||||
)
|
||||
from llama_cloud_services.utils import augment_async_errors
|
||||
from llama_cloud_services.utils import augment_async_errors, SourceText, FileInput
|
||||
from llama_cloud_services.files.client import FileClient
|
||||
from llama_index.core.schema import BaseComponent
|
||||
from llama_index.core.async_utils import run_jobs
|
||||
from llama_index.core.bridge.pydantic import Field, PrivateAttr
|
||||
@@ -190,46 +188,6 @@ async def _wait_for_job_result(
|
||||
)
|
||||
|
||||
|
||||
class SourceText:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
file: Union[bytes, BufferedIOBase, TextIOWrapper, str, Path, None] = None,
|
||||
text_content: Optional[str] = None,
|
||||
filename: Optional[str] = None,
|
||||
):
|
||||
self.file = file
|
||||
self.filename = filename
|
||||
self.text_content = text_content
|
||||
self._validate()
|
||||
|
||||
def _validate(self) -> None:
|
||||
"""Ensure filename is provided when needed."""
|
||||
if not ((self.file is None) ^ (self.text_content is None)):
|
||||
raise ValueError("Either file or text_content must be provided.")
|
||||
if self.text_content is not None:
|
||||
if not self.filename:
|
||||
random_hex = secrets.token_hex(4)
|
||||
self.filename = f"text_input_{random_hex}.txt"
|
||||
return
|
||||
|
||||
if isinstance(self.file, (bytes, BufferedIOBase, TextIOWrapper)):
|
||||
if not self.filename and hasattr(self.file, "name"):
|
||||
self.filename = os.path.basename(str(self.file.name))
|
||||
elif not hasattr(self.file, "name") and self.filename is None:
|
||||
raise ValueError(
|
||||
"filename must be provided when file is bytes or a file-like object without a name"
|
||||
)
|
||||
elif isinstance(self.file, (str, Path)):
|
||||
if not self.filename:
|
||||
self.filename = os.path.basename(str(self.file))
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {type(self.file)}")
|
||||
|
||||
|
||||
FileInput = Union[str, Path, BufferedIOBase, SourceText, File]
|
||||
|
||||
|
||||
def run_in_thread(
|
||||
coro: Coroutine[Any, Any, T],
|
||||
thread_pool: ThreadPoolExecutor,
|
||||
@@ -322,6 +280,7 @@ class ExtractionAgent:
|
||||
self._thread_pool = ThreadPoolExecutor(
|
||||
max_workers=min(10, (os.cpu_count() or 1) + 4)
|
||||
)
|
||||
self._file_client = FileClient(client, project_id, organization_id)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
@@ -371,65 +330,11 @@ class ExtractionAgent:
|
||||
ValueError: If filename is not provided for bytes input or for file-like objects
|
||||
without a name attribute.
|
||||
"""
|
||||
file_contents: Optional[Union[BufferedIOBase, BytesIO]] = None
|
||||
try:
|
||||
if file_input.text_content is not None:
|
||||
# Handle direct text content
|
||||
file_contents = BytesIO(file_input.text_content.encode("utf-8"))
|
||||
elif isinstance(file_input.file, TextIOWrapper):
|
||||
# Handle text-based IO objects
|
||||
file_contents = BytesIO(file_input.file.read().encode("utf-8"))
|
||||
elif isinstance(file_input.file, (str, Path)):
|
||||
# Handle file paths
|
||||
file_contents = open(file_input.file, "rb")
|
||||
elif isinstance(file_input.file, bytes):
|
||||
# Handle bytes
|
||||
file_contents = BytesIO(file_input.file)
|
||||
elif isinstance(file_input.file, BufferedIOBase):
|
||||
# Handle binary IO objects
|
||||
file_contents = file_input.file
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {type(file_input.file)}")
|
||||
|
||||
# Add name attribute to file object if needed
|
||||
if not hasattr(file_contents, "name"):
|
||||
file_contents.name = file_input.filename # type: ignore
|
||||
|
||||
return await self._client.files.upload_file(
|
||||
project_id=self._project_id, upload_file=file_contents
|
||||
)
|
||||
finally:
|
||||
if file_contents is not None and isinstance(
|
||||
file_contents, (BufferedReader, BytesIO)
|
||||
):
|
||||
file_contents.close()
|
||||
return await self._file_client.upload_content(file_input)
|
||||
|
||||
async def _upload_file(self, file_input: FileInput) -> File:
|
||||
source_text = None
|
||||
if isinstance(file_input, File):
|
||||
return file_input
|
||||
if isinstance(file_input, SourceText):
|
||||
source_text = file_input
|
||||
elif isinstance(file_input, (str, Path)):
|
||||
path = Path(file_input)
|
||||
source_text = SourceText(file=path, filename=path.name)
|
||||
else:
|
||||
# Try to get filename from the file object if not provided
|
||||
filename = None
|
||||
if hasattr(file_input, "name"):
|
||||
filename = os.path.basename(str(file_input.name))
|
||||
if filename is None:
|
||||
raise ValueError(
|
||||
"Use SourceText to provide filename when uploading bytes or file-like objects."
|
||||
)
|
||||
|
||||
warnings.warn(
|
||||
"Use SourceText instead of bytes or file-like objects",
|
||||
DeprecationWarning,
|
||||
)
|
||||
source_text = SourceText(file=file_input, filename=filename)
|
||||
|
||||
return await self.upload_file(source_text)
|
||||
"""Upload a file from various input types using FileClient."""
|
||||
return await self._file_client.upload_content(file_input)
|
||||
|
||||
async def _wait_for_job_result(self, job_id: str) -> Optional[ExtractRun]:
|
||||
"""Wait for and return the results of an extraction job."""
|
||||
@@ -463,56 +368,6 @@ class ExtractionAgent:
|
||||
)
|
||||
)
|
||||
|
||||
async def _run_extraction_test(
|
||||
self,
|
||||
files: Union[FileInput, List[FileInput]],
|
||||
extract_settings: LlamaExtractSettings,
|
||||
) -> Union[ExtractJob, List[ExtractJob]]:
|
||||
if not isinstance(files, list):
|
||||
files = [files]
|
||||
single_file = True
|
||||
else:
|
||||
single_file = False
|
||||
|
||||
upload_tasks = [self._upload_file(file) for file in files]
|
||||
with augment_async_errors():
|
||||
uploaded_files = await run_jobs(
|
||||
upload_tasks,
|
||||
workers=self.num_workers,
|
||||
desc="Uploading files",
|
||||
show_progress=self.show_progress,
|
||||
)
|
||||
|
||||
async def run_job(file: File) -> ExtractRun:
|
||||
job_queued = await self._client.llama_extract.run_job_test_user(
|
||||
job_create=ExtractJobCreate(
|
||||
extraction_agent_id=self.id,
|
||||
file_id=file.id,
|
||||
data_schema_override=self.data_schema,
|
||||
config_override=self.config,
|
||||
),
|
||||
extract_settings=extract_settings,
|
||||
)
|
||||
return await self._wait_for_job_result(job_queued.id)
|
||||
|
||||
job_tasks = [run_job(file) for file in uploaded_files]
|
||||
with augment_async_errors():
|
||||
extract_results = await run_jobs(
|
||||
job_tasks,
|
||||
workers=self.num_workers,
|
||||
desc="Running extraction jobs",
|
||||
show_progress=self.show_progress,
|
||||
)
|
||||
|
||||
if self._verbose:
|
||||
for file, job in zip(files, extract_results):
|
||||
file_repr = (
|
||||
str(file) if isinstance(file, (str, Path)) else "<bytes/buffer>"
|
||||
)
|
||||
print(f"Running extraction for file {file_repr} under job_id {job.id}")
|
||||
|
||||
return extract_results[0] if single_file else extract_results
|
||||
|
||||
async def queue_extraction(
|
||||
self,
|
||||
files: Union[FileInput, List[FileInput]],
|
||||
@@ -544,12 +399,10 @@ class ExtractionAgent:
|
||||
|
||||
job_tasks = [
|
||||
self._client.llama_extract.run_job(
|
||||
request=ExtractJobCreate(
|
||||
extraction_agent_id=self.id,
|
||||
file_id=file.id,
|
||||
data_schema_override=self.data_schema,
|
||||
config_override=self.config,
|
||||
),
|
||||
extraction_agent_id=self.id,
|
||||
file_id=file.id,
|
||||
data_schema_override=self.data_schema,
|
||||
config_override=self.config,
|
||||
)
|
||||
for file in uploaded_files
|
||||
]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from io import BytesIO
|
||||
from typing import BinaryIO
|
||||
import os
|
||||
from pathlib import Path
|
||||
from llama_cloud.client import AsyncLlamaCloud
|
||||
from llama_cloud.types import File, FileCreate
|
||||
from typing import Optional
|
||||
from llama_cloud_services.utils import SourceText, FileInput
|
||||
|
||||
|
||||
class FileClient:
|
||||
@@ -95,3 +97,83 @@ class FileClient:
|
||||
project_id=self.project_id,
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
|
||||
async def upload_content(
|
||||
self, file_input: FileInput, external_file_id: Optional[str] = None
|
||||
) -> File:
|
||||
"""
|
||||
Upload content from various input types or fetch an already-uploaded file.
|
||||
|
||||
Args:
|
||||
file_input: The content to upload. Can be:
|
||||
- File: Already uploaded file (returned as-is)
|
||||
- str/Path: Path to a file on disk
|
||||
- SourceText: Text content, file, or file_id with explicit filename
|
||||
- BufferedIOBase: File-like binary object
|
||||
external_file_id: Optional external identifier for the file
|
||||
|
||||
Returns:
|
||||
File: The uploaded (or fetched) file object
|
||||
|
||||
Raises:
|
||||
ValueError: If the input type is not supported or required info is missing
|
||||
"""
|
||||
# If already a File object, return it
|
||||
if isinstance(file_input, File):
|
||||
return file_input
|
||||
|
||||
# Handle SourceText
|
||||
if isinstance(file_input, SourceText):
|
||||
# If file_id is provided, fetch the file object
|
||||
if file_input.file_id is not None:
|
||||
return await self.get_file(file_input.file_id)
|
||||
elif file_input.text_content is not None:
|
||||
# Handle direct text content
|
||||
text_bytes = file_input.text_content.encode("utf-8")
|
||||
return await self.upload_bytes(
|
||||
text_bytes, external_file_id or file_input.filename or "file"
|
||||
)
|
||||
elif isinstance(file_input.file, (str, Path)):
|
||||
# Handle file paths using the existing upload_file method
|
||||
return await self.upload_file(
|
||||
str(file_input.file), external_file_id or file_input.filename
|
||||
)
|
||||
elif isinstance(file_input.file, bytes):
|
||||
# Handle bytes
|
||||
return await self.upload_bytes(
|
||||
file_input.file, external_file_id or file_input.filename or "file"
|
||||
)
|
||||
elif hasattr(file_input.file, "read"):
|
||||
# Handle any file-like object (TextIOWrapper, BytesIO, BufferedReader, BufferedIOBase, etc.)
|
||||
content = file_input.file.read() # type: ignore
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
return await self.upload_bytes(
|
||||
content, external_file_id or file_input.filename or "file"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {type(file_input.file)}")
|
||||
|
||||
# Handle string/Path directly
|
||||
elif isinstance(file_input, (str, Path)):
|
||||
return await self.upload_file(str(file_input), external_file_id)
|
||||
|
||||
# Handle raw file-like objects
|
||||
elif hasattr(file_input, "read"):
|
||||
if hasattr(file_input, "name"):
|
||||
filename = os.path.basename(str(file_input.name))
|
||||
else:
|
||||
filename = external_file_id or "file"
|
||||
|
||||
# Read content to determine size
|
||||
content = file_input.read()
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
|
||||
return await self.upload_bytes(content, external_file_id or filename)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported file input type: {type(file_input)}. "
|
||||
f"Supported types: str, Path, SourceText, BufferedIOBase, or File."
|
||||
)
|
||||
|
||||
@@ -258,6 +258,7 @@ def page_screenshot_nodes_to_node_with_score(
|
||||
client: LlamaCloud,
|
||||
raw_image_nodes: Optional[List[PageScreenshotNodeWithScore]],
|
||||
project_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> List[NodeWithScore]:
|
||||
if not raw_image_nodes:
|
||||
return []
|
||||
@@ -273,6 +274,7 @@ def page_screenshot_nodes_to_node_with_score(
|
||||
image_base64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
image_node_metadata: Dict[str, Any] = {
|
||||
**(raw_image_node.node.metadata or {}),
|
||||
**(metadata or {}),
|
||||
"file_id": raw_image_node.node.file_id,
|
||||
"page_index": raw_image_node.node.page_index,
|
||||
}
|
||||
@@ -289,6 +291,7 @@ def image_nodes_to_node_with_score(
|
||||
client: LlamaCloud,
|
||||
raw_image_nodes: Optional[List[PageScreenshotNodeWithScore]],
|
||||
project_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> List[NodeWithScore]:
|
||||
"""
|
||||
Legacy method to alias page_screenshot_nodes_to_node_with_score.
|
||||
@@ -297,7 +300,10 @@ def image_nodes_to_node_with_score(
|
||||
return []
|
||||
|
||||
return page_screenshot_nodes_to_node_with_score(
|
||||
client=client, raw_image_nodes=raw_image_nodes, project_id=project_id
|
||||
client=client,
|
||||
raw_image_nodes=raw_image_nodes,
|
||||
project_id=project_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -305,6 +311,7 @@ def page_figure_nodes_to_node_with_score(
|
||||
client: LlamaCloud,
|
||||
raw_figure_nodes: Optional[List[PageFigureNodeWithScore]],
|
||||
project_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> List[NodeWithScore]:
|
||||
if not raw_figure_nodes:
|
||||
return []
|
||||
@@ -321,6 +328,7 @@ def page_figure_nodes_to_node_with_score(
|
||||
figure_base64 = base64.b64encode(figure_bytes).decode("utf-8")
|
||||
figure_node_metadata: Dict[str, Any] = {
|
||||
**(raw_figure_node.node.metadata or {}),
|
||||
**(metadata or {}),
|
||||
"file_id": raw_figure_node.node.file_id,
|
||||
"page_index": raw_figure_node.node.page_index,
|
||||
"figure_name": raw_figure_node.node.figure_name,
|
||||
@@ -337,6 +345,7 @@ async def apage_screenshot_nodes_to_node_with_score(
|
||||
client: AsyncLlamaCloud,
|
||||
raw_image_nodes: Optional[List[PageScreenshotNodeWithScore]],
|
||||
project_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> List[NodeWithScore]:
|
||||
if not raw_image_nodes:
|
||||
return []
|
||||
@@ -357,6 +366,7 @@ async def apage_screenshot_nodes_to_node_with_score(
|
||||
image_base64 = base64.b64encode(image_bytes).decode("utf-8")
|
||||
image_node_metadata: Dict[str, Any] = {
|
||||
**(raw_image_node.node.metadata or {}),
|
||||
**(metadata or {}),
|
||||
"file_id": raw_image_node.node.file_id,
|
||||
"page_index": raw_image_node.node.page_index,
|
||||
}
|
||||
@@ -372,6 +382,7 @@ async def aimage_nodes_to_node_with_score(
|
||||
client: AsyncLlamaCloud,
|
||||
raw_image_nodes: Optional[List[PageScreenshotNodeWithScore]],
|
||||
project_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> List[NodeWithScore]:
|
||||
"""
|
||||
Legacy method to alias apage_screenshot_nodes_to_node_with_score.
|
||||
@@ -380,7 +391,10 @@ async def aimage_nodes_to_node_with_score(
|
||||
return []
|
||||
|
||||
return await apage_screenshot_nodes_to_node_with_score(
|
||||
client=client, raw_image_nodes=raw_image_nodes, project_id=project_id
|
||||
client=client,
|
||||
raw_image_nodes=raw_image_nodes,
|
||||
project_id=project_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@@ -388,6 +402,7 @@ async def apage_figure_nodes_to_node_with_score(
|
||||
client: AsyncLlamaCloud,
|
||||
raw_figure_nodes: Optional[List[PageFigureNodeWithScore]],
|
||||
project_id: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> List[NodeWithScore]:
|
||||
if not raw_figure_nodes:
|
||||
return []
|
||||
@@ -409,6 +424,7 @@ async def apage_figure_nodes_to_node_with_score(
|
||||
figure_base64 = base64.b64encode(figure_bytes).decode("utf-8")
|
||||
figure_node_metadata: Dict[str, Any] = {
|
||||
**(raw_figure_node.node.metadata or {}),
|
||||
**(metadata or {}),
|
||||
"file_id": raw_figure_node.node.file_id,
|
||||
"page_index": raw_figure_node.node.page_index,
|
||||
"figure_name": raw_figure_node.node.figure_name,
|
||||
|
||||
@@ -19,6 +19,7 @@ from llama_cloud import (
|
||||
PipelineCreate,
|
||||
PipelineCreateEmbeddingConfig,
|
||||
PipelineCreateTransformConfig,
|
||||
PipelineFileCreateCustomMetadataValue,
|
||||
PipelineType,
|
||||
ProjectCreate,
|
||||
ManagedIngestionStatus,
|
||||
@@ -333,7 +334,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
if file_ids:
|
||||
self._wait_for_resources(
|
||||
file_ids,
|
||||
lambda fid: self._client.pipelines.get_pipeline_file_status(
|
||||
lambda fid: self._client.pipeline_files.get_pipeline_file_status(
|
||||
pipeline_id=self.pipeline.id, file_id=fid
|
||||
),
|
||||
resource_name="file",
|
||||
@@ -420,7 +421,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
if file_ids:
|
||||
await self._await_for_resources(
|
||||
file_ids,
|
||||
lambda fid: self._aclient.pipelines.get_pipeline_file_status(
|
||||
lambda fid: self._aclient.pipeline_files.get_pipeline_file_status(
|
||||
pipeline_id=self.pipeline.id, file_id=fid
|
||||
),
|
||||
resource_name="file",
|
||||
@@ -489,6 +490,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
name: str,
|
||||
project_name: str = DEFAULT_PROJECT_NAME,
|
||||
organization_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
app_url: Optional[str] = None,
|
||||
@@ -504,15 +506,15 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
app_url = app_url or os.environ.get("LLAMA_CLOUD_APP_URL", DEFAULT_APP_URL)
|
||||
client = get_client(api_key, base_url, app_url, timeout)
|
||||
|
||||
# create project if it doesn't exist
|
||||
project = client.projects.upsert_project(
|
||||
organization_id=organization_id, request=ProjectCreate(name=project_name)
|
||||
)
|
||||
if project.id is None:
|
||||
raise ValueError(f"Failed to create/get project {project_name}")
|
||||
|
||||
if verbose:
|
||||
print(f"Created project {project.id} with name {project.name}")
|
||||
if project_id is None:
|
||||
# create project if it doesn't exist
|
||||
project = client.projects.upsert_project(
|
||||
organization_id=organization_id,
|
||||
request=ProjectCreate(name=project_name),
|
||||
)
|
||||
project_id = project.id
|
||||
if verbose:
|
||||
print(f"Created project {project_id} with name {project_name}")
|
||||
|
||||
# create pipeline
|
||||
pipeline_create = PipelineCreate(
|
||||
@@ -523,7 +525,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
llama_parse_parameters=llama_parse_parameters or LlamaParseParameters(),
|
||||
)
|
||||
pipeline = client.pipelines.upsert_pipeline(
|
||||
project_id=project.id, request=pipeline_create
|
||||
project_id=project_id, request=pipeline_create
|
||||
)
|
||||
if pipeline.id is None:
|
||||
raise ValueError(f"Failed to create/get pipeline {name}")
|
||||
@@ -532,8 +534,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
|
||||
return cls(
|
||||
name,
|
||||
project_name=project.name,
|
||||
organization_id=project.organization_id,
|
||||
project_id=project_id,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
app_url=app_url,
|
||||
@@ -606,6 +607,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
name: str,
|
||||
project_name: str = DEFAULT_PROJECT_NAME,
|
||||
organization_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
app_url: Optional[str] = None,
|
||||
@@ -631,6 +633,7 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
verbose=verbose,
|
||||
embedding_config=embedding_config,
|
||||
transform_config=transform_config,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
app_url = app_url or os.environ.get("LLAMA_CLOUD_APP_URL", DEFAULT_APP_URL)
|
||||
@@ -903,6 +906,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
def upload_file(
|
||||
self,
|
||||
file_path: str,
|
||||
custom_metadata: Optional[
|
||||
dict[str, Optional[PipelineFileCreateCustomMetadataValue]]
|
||||
] = None,
|
||||
verbose: bool = False,
|
||||
wait_for_ingestion: bool = True,
|
||||
raise_on_error: bool = False,
|
||||
@@ -916,8 +922,10 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
print(f"Uploaded file {file.id} with name {file.name}")
|
||||
|
||||
# Add file to pipeline
|
||||
pipeline_file_create = PipelineFileCreate(file_id=file.id)
|
||||
self._client.pipelines.add_files_to_pipeline_api(
|
||||
pipeline_file_create = PipelineFileCreate(
|
||||
file_id=file.id, custom_metadata=custom_metadata
|
||||
)
|
||||
self._client.pipeline_files.add_files_to_pipeline_api(
|
||||
pipeline_id=self.pipeline.id, request=[pipeline_file_create]
|
||||
)
|
||||
|
||||
@@ -930,6 +938,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
async def aupload_file(
|
||||
self,
|
||||
file_path: str,
|
||||
custom_metadata: Optional[
|
||||
dict[str, Optional[PipelineFileCreateCustomMetadataValue]]
|
||||
] = None,
|
||||
verbose: bool = False,
|
||||
wait_for_ingestion: bool = True,
|
||||
raise_on_error: bool = False,
|
||||
@@ -943,8 +954,10 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
print(f"Uploaded file {file.id} with name {file.name}")
|
||||
|
||||
# Add file to pipeline
|
||||
pipeline_file_create = PipelineFileCreate(file_id=file.id)
|
||||
await self._aclient.pipelines.add_files_to_pipeline_api(
|
||||
pipeline_file_create = PipelineFileCreate(
|
||||
file_id=file.id, custom_metadata=custom_metadata
|
||||
)
|
||||
await self._aclient.pipeline_files.add_files_to_pipeline_api(
|
||||
pipeline_id=self.pipeline.id, request=[pipeline_file_create]
|
||||
)
|
||||
|
||||
@@ -959,6 +972,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
self,
|
||||
file_name: str,
|
||||
url: str,
|
||||
custom_metadata: Optional[
|
||||
dict[str, Optional[PipelineFileCreateCustomMetadataValue]]
|
||||
] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
request_headers: Optional[Dict[str, str]] = None,
|
||||
verify_ssl: bool = True,
|
||||
@@ -981,8 +997,10 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
print(f"Uploaded file {file.id} with ID {file.id}")
|
||||
|
||||
# Add file to pipeline
|
||||
pipeline_file_create = PipelineFileCreate(file_id=file.id)
|
||||
self._client.pipelines.add_files_to_pipeline_api(
|
||||
pipeline_file_create = PipelineFileCreate(
|
||||
file_id=file.id, custom_metadata=custom_metadata
|
||||
)
|
||||
self._client.pipeline_files.add_files_to_pipeline_api(
|
||||
pipeline_id=self.pipeline.id, request=[pipeline_file_create]
|
||||
)
|
||||
|
||||
@@ -996,6 +1014,9 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
self,
|
||||
file_name: str,
|
||||
url: str,
|
||||
custom_metadata: Optional[
|
||||
dict[str, Optional[PipelineFileCreateCustomMetadataValue]]
|
||||
] = None,
|
||||
proxy_url: Optional[str] = None,
|
||||
request_headers: Optional[Dict[str, str]] = None,
|
||||
verify_ssl: bool = True,
|
||||
@@ -1018,8 +1039,10 @@ class LlamaCloudIndex(BaseManagedIndex):
|
||||
print(f"Uploaded file {file.id} with ID {file.id}")
|
||||
|
||||
# Add file to pipeline
|
||||
pipeline_file_create = PipelineFileCreate(file_id=file.id)
|
||||
await self._aclient.pipelines.add_files_to_pipeline_api(
|
||||
pipeline_file_create = PipelineFileCreate(
|
||||
file_id=file.id, custom_metadata=custom_metadata
|
||||
)
|
||||
await self._aclient.pipeline_files.add_files_to_pipeline_api(
|
||||
pipeline_id=self.pipeline.id, request=[pipeline_file_create]
|
||||
)
|
||||
|
||||
|
||||
@@ -129,11 +129,12 @@ class LlamaCloudRetriever(BaseRetriever):
|
||||
)
|
||||
|
||||
def _result_nodes_to_node_with_score(
|
||||
self, result_nodes: List[TextNodeWithScore]
|
||||
self, result_nodes: List[TextNodeWithScore], metadata: Optional[dict] = None
|
||||
) -> List[NodeWithScore]:
|
||||
nodes = []
|
||||
for res in result_nodes:
|
||||
text_node = TextNode.parse_obj(res.node.dict())
|
||||
text_node = TextNode.model_validate(res.node.dict())
|
||||
text_node.metadata.update(metadata or {})
|
||||
nodes.append(NodeWithScore(node=text_node, score=res.score))
|
||||
|
||||
return nodes
|
||||
@@ -161,17 +162,25 @@ class LlamaCloudRetriever(BaseRetriever):
|
||||
search_filters_inference_schema=search_filters_inference_schema,
|
||||
)
|
||||
|
||||
result_nodes = self._result_nodes_to_node_with_score(results.retrieval_nodes)
|
||||
result_nodes = self._result_nodes_to_node_with_score(
|
||||
results.retrieval_nodes, metadata=results.metadata
|
||||
)
|
||||
if self._retrieve_page_screenshot_nodes:
|
||||
result_nodes.extend(
|
||||
page_screenshot_nodes_to_node_with_score(
|
||||
self._client, results.image_nodes, self.project.id
|
||||
self._client,
|
||||
results.image_nodes,
|
||||
self.project.id,
|
||||
metadata=results.metadata,
|
||||
)
|
||||
)
|
||||
if self._retrieve_page_figure_nodes:
|
||||
result_nodes.extend(
|
||||
page_figure_nodes_to_node_with_score(
|
||||
self._client, results.page_figure_nodes, self.project.id
|
||||
self._client,
|
||||
results.page_figure_nodes,
|
||||
self.project.id,
|
||||
metadata=results.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -200,17 +209,25 @@ class LlamaCloudRetriever(BaseRetriever):
|
||||
search_filters_inference_schema=search_filters_inference_schema,
|
||||
)
|
||||
|
||||
result_nodes = self._result_nodes_to_node_with_score(results.retrieval_nodes)
|
||||
result_nodes = self._result_nodes_to_node_with_score(
|
||||
results.retrieval_nodes, metadata=results.metadata
|
||||
)
|
||||
if self._retrieve_page_screenshot_nodes:
|
||||
result_nodes.extend(
|
||||
await apage_screenshot_nodes_to_node_with_score(
|
||||
self._aclient, results.image_nodes, self.project.id
|
||||
self._aclient,
|
||||
results.image_nodes,
|
||||
self.project.id,
|
||||
metadata=results.metadata,
|
||||
)
|
||||
)
|
||||
if self._retrieve_page_figure_nodes:
|
||||
result_nodes.extend(
|
||||
await apage_figure_nodes_to_node_with_score(
|
||||
self._aclient, results.page_figure_nodes, self.project.id
|
||||
self._aclient,
|
||||
results.page_figure_nodes,
|
||||
self.project.id,
|
||||
metadata=results.metadata,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -188,6 +188,10 @@ class LlamaParse(BasePydanticReader):
|
||||
default=False,
|
||||
description="If set to true, LlamaParse will try to detect long table and adapt the output.",
|
||||
)
|
||||
aggressive_table_extraction: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, LlamaParse will try to extract tables aggressively, may lead to false positives.",
|
||||
)
|
||||
annotate_links: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="Annotate links found in the document to extract their URL.",
|
||||
@@ -420,6 +424,10 @@ class LlamaParse(BasePydanticReader):
|
||||
default=False,
|
||||
description="If set to true, the parser will extract sub-tables from the spreadsheet when possible (more than one table per sheet).",
|
||||
)
|
||||
spreadsheet_force_formula_computation: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will re-compute values for all spreadsheet cells containing formulas.",
|
||||
)
|
||||
specialized_chart_parsing_agentic: Optional[bool] = Field(
|
||||
default=False,
|
||||
description="If set to true, the parser will use a specialized agentic chart parsing model to extract data from charts. This model is able to understand the chart type and extract the data accordingly.",
|
||||
@@ -709,6 +717,9 @@ class LlamaParse(BasePydanticReader):
|
||||
if self.adaptive_long_table:
|
||||
data["adaptive_long_table"] = self.adaptive_long_table
|
||||
|
||||
if self.aggressive_table_extraction:
|
||||
data["aggressive_table_extraction"] = self.aggressive_table_extraction
|
||||
|
||||
if self.annotate_links:
|
||||
data["annotate_links"] = self.annotate_links
|
||||
|
||||
@@ -965,6 +976,11 @@ class LlamaParse(BasePydanticReader):
|
||||
if self.spreadsheet_extract_sub_tables:
|
||||
data["spreadsheet_extract_sub_tables"] = self.spreadsheet_extract_sub_tables
|
||||
|
||||
if self.spreadsheet_force_formula_computation:
|
||||
data[
|
||||
"spreadsheet_force_formula_computation"
|
||||
] = self.spreadsheet_force_formula_computation
|
||||
|
||||
if self.specialized_chart_parsing_agentic:
|
||||
data[
|
||||
"specialized_chart_parsing_agentic"
|
||||
@@ -1073,11 +1089,7 @@ class LlamaParse(BasePydanticReader):
|
||||
return current_interval # Default fallback
|
||||
|
||||
async def _get_job_result(
|
||||
self,
|
||||
job_id: str,
|
||||
result_type: str,
|
||||
verbose: bool = False,
|
||||
raise_job_error: bool = True,
|
||||
self, job_id: str, result_type: str, verbose: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
start = time.time()
|
||||
tries = 0
|
||||
@@ -1110,25 +1122,7 @@ class LlamaParse(BasePydanticReader):
|
||||
print(".", end="", flush=True)
|
||||
current_interval = self._calculate_backoff(current_interval)
|
||||
else:
|
||||
if raise_job_error:
|
||||
raise JobFailedException.from_result(result_json)
|
||||
else:
|
||||
error_code = result_json.get("error_code")
|
||||
error_message = result_json.get("error_message")
|
||||
|
||||
error_parts = [f"Job ID: {job_id} failed with status: {status}"]
|
||||
if error_code:
|
||||
error_parts.append(f"Error code: {error_code}")
|
||||
if error_message:
|
||||
error_parts.append(f"Error message: {error_message}")
|
||||
error_str = ", ".join(error_parts)
|
||||
|
||||
return {
|
||||
"pages": [],
|
||||
"job_metadata": {"job_pages": 0},
|
||||
"error": error_str,
|
||||
"status": status,
|
||||
}
|
||||
raise JobFailedException.from_result(result_json)
|
||||
except (
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
@@ -1152,6 +1146,25 @@ class LlamaParse(BasePydanticReader):
|
||||
)
|
||||
current_interval = self._calculate_backoff(current_interval)
|
||||
|
||||
async def _get_job_result_with_error_handling(
|
||||
self, job_id: str, result_type: str, verbose: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Get job result with error handling based on ignore_errors setting."""
|
||||
try:
|
||||
return await self._get_job_result(job_id, result_type, verbose=verbose)
|
||||
except JobFailedException as e:
|
||||
if self.ignore_errors:
|
||||
# Return error information when ignore_errors is True
|
||||
return {
|
||||
"pages": [],
|
||||
"job_metadata": {},
|
||||
"error": f"{e.status}: {e.error_message or 'No error message'}",
|
||||
"error_code": e.error_code,
|
||||
"status": e.status,
|
||||
}
|
||||
else:
|
||||
raise e
|
||||
|
||||
async def _parse_one(
|
||||
self,
|
||||
file_path: FileInput,
|
||||
@@ -1159,7 +1172,6 @@ class LlamaParse(BasePydanticReader):
|
||||
fs: Optional[AbstractFileSystem] = None,
|
||||
result_type: Optional[str] = None,
|
||||
num_workers: Optional[int] = None,
|
||||
raise_job_error: bool = True,
|
||||
) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
if self.partition_pages is None:
|
||||
job_results = [
|
||||
@@ -1168,7 +1180,6 @@ class LlamaParse(BasePydanticReader):
|
||||
extra_info=extra_info,
|
||||
fs=fs,
|
||||
result_type=result_type,
|
||||
raise_job_error=raise_job_error,
|
||||
)
|
||||
]
|
||||
else:
|
||||
@@ -1178,7 +1189,6 @@ class LlamaParse(BasePydanticReader):
|
||||
fs=fs,
|
||||
result_type=result_type,
|
||||
num_workers=num_workers,
|
||||
raise_job_error=raise_job_error,
|
||||
)
|
||||
return job_results
|
||||
|
||||
@@ -1188,7 +1198,6 @@ class LlamaParse(BasePydanticReader):
|
||||
extra_info: Optional[dict] = None,
|
||||
fs: Optional[AbstractFileSystem] = None,
|
||||
result_type: Optional[str] = None,
|
||||
raise_job_error: bool = True,
|
||||
**create_kwargs: Any,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Create one parse job and wait for the result."""
|
||||
@@ -1197,11 +1206,8 @@ class LlamaParse(BasePydanticReader):
|
||||
)
|
||||
if self.verbose:
|
||||
print("Started parsing the file under job_id %s" % job_id)
|
||||
result = await self._get_job_result(
|
||||
job_id,
|
||||
result_type or self.result_type.value,
|
||||
verbose=self.verbose,
|
||||
raise_job_error=raise_job_error,
|
||||
result = await self._get_job_result_with_error_handling(
|
||||
job_id, result_type or self.result_type.value, verbose=self.verbose
|
||||
)
|
||||
return job_id, result
|
||||
|
||||
@@ -1212,7 +1218,6 @@ class LlamaParse(BasePydanticReader):
|
||||
fs: Optional[AbstractFileSystem] = None,
|
||||
result_type: Optional[str] = None,
|
||||
num_workers: Optional[int] = None,
|
||||
raise_job_error: bool = True,
|
||||
) -> List[Tuple[str, Dict[str, Any]]]:
|
||||
"""Partition a file and run separate parse jobs per partition segment."""
|
||||
assert self.partition_pages is not None
|
||||
@@ -1227,7 +1232,6 @@ class LlamaParse(BasePydanticReader):
|
||||
extra_info=extra_info,
|
||||
fs=fs,
|
||||
result_type=result_type,
|
||||
raise_job_error=raise_job_error,
|
||||
partition_target_pages=target_pages,
|
||||
)
|
||||
for target_pages in partition_pages(
|
||||
@@ -1255,33 +1259,37 @@ class LlamaParse(BasePydanticReader):
|
||||
size = self.partition_pages
|
||||
if not size:
|
||||
break
|
||||
# Fetch JSON result type first to get accurate pagination data
|
||||
# and then fetch the user's desired result type if needed
|
||||
job_id, json_result = await self._parse_one_unpartitioned(
|
||||
file_path,
|
||||
extra_info=extra_info,
|
||||
fs=fs,
|
||||
result_type=ResultType.JSON.value,
|
||||
raise_job_error=raise_job_error,
|
||||
partition_target_pages=f"{total}-{total + size - 1}",
|
||||
)
|
||||
|
||||
if json_result.get("error"):
|
||||
if results and "NO_DATA_FOUND_IN_FILE" in json_result.get("error", ""):
|
||||
return results
|
||||
results.append((job_id, json_result))
|
||||
return results
|
||||
|
||||
result_type = result_type or self.result_type.value
|
||||
if result_type == ResultType.JSON.value:
|
||||
job_result = json_result
|
||||
else:
|
||||
job_result = await self._get_job_result(
|
||||
job_id,
|
||||
result_type,
|
||||
verbose=self.verbose,
|
||||
raise_job_error=raise_job_error,
|
||||
try:
|
||||
# Fetch JSON result type first to get accurate pagination data
|
||||
# and then fetch the user's desired result type if needed
|
||||
job_id, json_result = await self._parse_one_unpartitioned(
|
||||
file_path,
|
||||
extra_info=extra_info,
|
||||
fs=fs,
|
||||
result_type=ResultType.JSON.value,
|
||||
partition_target_pages=f"{total}-{total + size - 1}",
|
||||
)
|
||||
# Check if the result is an error result (when ignore_errors=True)
|
||||
if json_result.get("error_code") == "NO_DATA_FOUND_IN_FILE":
|
||||
raise JobFailedException(
|
||||
job_id=job_id,
|
||||
status=json_result.get("status", "ERROR"),
|
||||
error_code=json_result.get("error_code"),
|
||||
error_message=json_result.get("error"),
|
||||
)
|
||||
|
||||
result_type = result_type or self.result_type.value
|
||||
if result_type == ResultType.JSON.value:
|
||||
job_result = json_result
|
||||
else:
|
||||
job_result = await self._get_job_result(
|
||||
job_id, result_type, verbose=self.verbose
|
||||
)
|
||||
except JobFailedException as e:
|
||||
if results and e.error_code == "NO_DATA_FOUND_IN_FILE":
|
||||
# Expected when we try to read past the end of the file
|
||||
return results
|
||||
raise
|
||||
results.append((job_id, job_result))
|
||||
if len(json_result["pages"]) < size:
|
||||
break
|
||||
@@ -1395,7 +1403,6 @@ class LlamaParse(BasePydanticReader):
|
||||
extra_info: Optional[dict] = None,
|
||||
fs: Optional[AbstractFileSystem] = None,
|
||||
num_workers: Optional[int] = None,
|
||||
raise_job_error: bool = True,
|
||||
) -> List[JobResult]:
|
||||
job_results = await self._parse_one(
|
||||
file_path,
|
||||
@@ -1403,7 +1410,6 @@ class LlamaParse(BasePydanticReader):
|
||||
fs=fs,
|
||||
result_type=ResultType.JSON.value,
|
||||
num_workers=num_workers,
|
||||
raise_job_error=raise_job_error,
|
||||
)
|
||||
return [
|
||||
JobResult(
|
||||
@@ -1423,7 +1429,6 @@ class LlamaParse(BasePydanticReader):
|
||||
file_path: Union[List[FileInput], FileInput],
|
||||
extra_info: Optional[dict] = None,
|
||||
fs: Optional[AbstractFileSystem] = None,
|
||||
raise_job_error: bool = True,
|
||||
) -> Union[List["JobResult"], "JobResult"]:
|
||||
"""
|
||||
Parse the file and return a JobResult object instead of Document objects.
|
||||
@@ -1435,7 +1440,6 @@ class LlamaParse(BasePydanticReader):
|
||||
file_path: Path to the file to parse. Can be a string, path, bytes, file-like object, or a list of these.
|
||||
extra_info: Additional metadata to include in the result.
|
||||
fs: Optional filesystem to use for reading files.
|
||||
raise_job_error: If True (default), raises JobFailedException when jobs fail. If False, returns JobResult objects with error information.
|
||||
|
||||
Returns:
|
||||
JobResult object or list of JobResult objects if either multiple files were provided or file(s) were partitioned before parsing.
|
||||
@@ -1451,11 +1455,7 @@ class LlamaParse(BasePydanticReader):
|
||||
else:
|
||||
file_name = str(file_path)
|
||||
result = await self._aparse_one(
|
||||
file_path,
|
||||
file_name,
|
||||
extra_info=extra_info,
|
||||
fs=fs,
|
||||
raise_job_error=raise_job_error,
|
||||
file_path, file_name, extra_info=extra_info, fs=fs
|
||||
)
|
||||
return result[0] if len(result) == 1 else result
|
||||
|
||||
@@ -1481,7 +1481,6 @@ class LlamaParse(BasePydanticReader):
|
||||
extra_info=extra_info,
|
||||
fs=fs,
|
||||
num_workers=1,
|
||||
raise_job_error=raise_job_error,
|
||||
)
|
||||
for i, f in enumerate(file_path)
|
||||
],
|
||||
@@ -1507,7 +1506,6 @@ class LlamaParse(BasePydanticReader):
|
||||
file_path: Union[List[FileInput], FileInput],
|
||||
extra_info: Optional[dict] = None,
|
||||
fs: Optional[AbstractFileSystem] = None,
|
||||
raise_job_error: bool = True,
|
||||
) -> Union[List["JobResult"], "JobResult"]:
|
||||
"""
|
||||
Parse the file and return a JobResult object instead of Document objects.
|
||||
@@ -1519,17 +1517,12 @@ class LlamaParse(BasePydanticReader):
|
||||
file_path: Path to the file to parse. Can be a string, path, bytes, file-like object, or a list of these.
|
||||
extra_info: Additional metadata to include in the result.
|
||||
fs: Optional filesystem to use for reading files.
|
||||
raise_job_error: If True (default), raises JobFailedException when jobs fail. If False, returns JobResult objects with error information.
|
||||
|
||||
Returns:
|
||||
JobResult object or list of JobResult objects if multiple files were provided
|
||||
"""
|
||||
try:
|
||||
return asyncio_run(
|
||||
self.aparse(
|
||||
file_path, extra_info, fs=fs, raise_job_error=raise_job_error
|
||||
)
|
||||
)
|
||||
return asyncio_run(self.aparse(file_path, extra_info, fs=fs))
|
||||
except RuntimeError as e:
|
||||
if nest_asyncio_err in str(e):
|
||||
raise RuntimeError(nest_asyncio_msg)
|
||||
@@ -1796,7 +1789,7 @@ class LlamaParse(BasePydanticReader):
|
||||
return sub_docs
|
||||
|
||||
async def aget_result(
|
||||
self, job_id: Union[str, List[str]], raise_job_error: bool = True
|
||||
self, job_id: Union[str, List[str]]
|
||||
) -> Union[JobResult, List[JobResult]]:
|
||||
"""
|
||||
Return JobResult object for previously parsed job(s).
|
||||
@@ -1805,17 +1798,13 @@ class LlamaParse(BasePydanticReader):
|
||||
|
||||
Args:
|
||||
job_id: Job ID or list of multiple Job IDs to be retrieved.
|
||||
raise_job_error: If True (default), raises JobFailedException when jobs fail. If False, returns JobResult objects with error information.
|
||||
|
||||
Returns:
|
||||
JobResult object or list of JobResult objects if multiple job IDs were provided.
|
||||
"""
|
||||
if isinstance(job_id, str):
|
||||
result = await self._get_job_result(
|
||||
job_id,
|
||||
ResultType.JSON.value,
|
||||
verbose=self.verbose,
|
||||
raise_job_error=raise_job_error,
|
||||
result = await self._get_job_result_with_error_handling(
|
||||
job_id, ResultType.JSON.value, verbose=self.verbose
|
||||
)
|
||||
return JobResult(
|
||||
job_id=job_id,
|
||||
@@ -1829,11 +1818,8 @@ class LlamaParse(BasePydanticReader):
|
||||
elif isinstance(job_id, list):
|
||||
results = []
|
||||
jobs = [
|
||||
self._get_job_result(
|
||||
id_,
|
||||
ResultType.JSON.value,
|
||||
verbose=self.verbose,
|
||||
raise_job_error=raise_job_error,
|
||||
self._get_job_result_with_error_handling(
|
||||
id_, ResultType.JSON.value, verbose=self.verbose
|
||||
)
|
||||
for id_ in job_id
|
||||
]
|
||||
@@ -1859,7 +1845,7 @@ class LlamaParse(BasePydanticReader):
|
||||
raise ValueError("The input job_id must be a string or a list of strings.")
|
||||
|
||||
def get_result(
|
||||
self, job_id: Union[str, List[str]], raise_job_error: bool = True
|
||||
self, job_id: Union[str, List[str]]
|
||||
) -> Union[JobResult, List[JobResult]]:
|
||||
"""
|
||||
Return JobResult object for previously parsed job(s).
|
||||
@@ -1868,15 +1854,12 @@ class LlamaParse(BasePydanticReader):
|
||||
|
||||
Args:
|
||||
job_id: Job ID or list of multiple Job IDs to be retrieved.
|
||||
raise_job_error: If True (default), raises JobFailedException when jobs fail. If False, returns JobResult objects with error information.
|
||||
|
||||
Returns:
|
||||
JobResult object or list of JobResult objects if multiple job IDs were provided.
|
||||
"""
|
||||
try:
|
||||
return asyncio_run(
|
||||
self.aget_result(job_id, raise_job_error=raise_job_error)
|
||||
)
|
||||
return asyncio_run(self.aget_result(job_id))
|
||||
except RuntimeError as e:
|
||||
if nest_asyncio_err in str(e):
|
||||
raise RuntimeError(nest_asyncio_msg)
|
||||
|
||||
@@ -1,17 +1,87 @@
|
||||
import httpx
|
||||
import os
|
||||
import re
|
||||
from pydantic import BaseModel, Field, SerializeAsAny
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny, model_validator
|
||||
from typing import Dict, Any, List, Optional, get_origin, get_args
|
||||
|
||||
from llama_cloud_services.parse.utils import make_api_request
|
||||
from llama_cloud_services.parse.utils import (
|
||||
make_api_request,
|
||||
is_jupyter,
|
||||
)
|
||||
from llama_index.core.async_utils import asyncio_run
|
||||
from llama_index.core.schema import Document, ImageDocument, ImageNode, TextNode
|
||||
|
||||
PAGE_REGEX = r"page[-_](\d+)\.jpg$"
|
||||
|
||||
SAFE_MODEL_CONFIGS = ConfigDict(
|
||||
extra="allow",
|
||||
validate_assignment=False,
|
||||
arbitrary_types_allowed=True,
|
||||
validate_default=False,
|
||||
)
|
||||
|
||||
class JobMetadata(BaseModel):
|
||||
|
||||
class SafeBaseModel(BaseModel):
|
||||
"""Base model that gracefully handles None values from unstable backend responses."""
|
||||
|
||||
model_config = SAFE_MODEL_CONFIGS
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def coerce_none_to_defaults(cls, data: Any) -> Any:
|
||||
"""
|
||||
Replace None values with appropriate defaults based on field type annotations.
|
||||
This prevents validation errors when the backend returns None for non-optional fields.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# Process each field that has a None value
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if value is not None or key not in cls.model_fields:
|
||||
result[key] = value
|
||||
continue
|
||||
|
||||
# Value is None and field exists in model
|
||||
field_info = cls.model_fields[key]
|
||||
|
||||
# If field has a default or default_factory, let Pydantic handle it
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
if (
|
||||
field_info.default is not PydanticUndefined
|
||||
or field_info.default_factory is not None
|
||||
):
|
||||
continue
|
||||
|
||||
# Otherwise, provide a sensible default based on the type annotation
|
||||
annotation = field_info.annotation
|
||||
origin = get_origin(annotation)
|
||||
|
||||
# Handle List types
|
||||
if origin is list:
|
||||
result[key] = []
|
||||
# Handle Dict types
|
||||
elif origin is dict:
|
||||
result[key] = {}
|
||||
# Handle basic types
|
||||
elif annotation == str or (origin and str in get_args(annotation)):
|
||||
result[key] = ""
|
||||
elif annotation == int or (origin and int in get_args(annotation)):
|
||||
result[key] = 0
|
||||
elif annotation == float or (origin and float in get_args(annotation)):
|
||||
result[key] = 0.0
|
||||
elif annotation == bool or (origin and bool in get_args(annotation)):
|
||||
result[key] = False
|
||||
# If we can't determine a safe default, skip (let Pydantic try)
|
||||
else:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class JobMetadata(SafeBaseModel):
|
||||
"""Metadata about the job."""
|
||||
|
||||
job_pages: int = Field(default=0, description="The number of pages in the job.")
|
||||
@@ -24,19 +94,31 @@ class JobMetadata(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class BBox(BaseModel):
|
||||
class BBox(SafeBaseModel):
|
||||
"""A bounding box."""
|
||||
|
||||
x: float = Field(description="The x-coordinate of the bounding box.")
|
||||
y: float = Field(description="The y-coordinate of the bounding box.")
|
||||
w: float = Field(description="The width of the bounding box.")
|
||||
h: float = Field(description="The height of the bounding box.")
|
||||
x: Optional[float] = Field(
|
||||
default=None,
|
||||
description="The x-coordinate of the bounding box.",
|
||||
)
|
||||
y: Optional[float] = Field(
|
||||
default=None,
|
||||
description="The y-coordinate of the bounding box.",
|
||||
)
|
||||
w: Optional[float] = Field(
|
||||
default=None,
|
||||
description="The width of the bounding box.",
|
||||
)
|
||||
h: Optional[float] = Field(
|
||||
default=None,
|
||||
description="The height of the bounding box.",
|
||||
)
|
||||
|
||||
|
||||
class PageItem(BaseModel):
|
||||
class PageItem(SafeBaseModel):
|
||||
"""An item in a page."""
|
||||
|
||||
type: str = Field(description="The type of the item.")
|
||||
type: str = Field(default="", description="The type of the item.")
|
||||
lvl: Optional[int] = Field(
|
||||
default=None, description="The level of indentation of the item."
|
||||
)
|
||||
@@ -58,10 +140,10 @@ class PageItem(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ImageItem(BaseModel):
|
||||
class ImageItem(SafeBaseModel):
|
||||
"""An image in a page."""
|
||||
|
||||
name: str = Field(description="The name of the image.")
|
||||
name: str = Field(default="", description="The name of the image.")
|
||||
height: Optional[float] = Field(
|
||||
default=None, description="The height of the image."
|
||||
)
|
||||
@@ -81,22 +163,28 @@ class ImageItem(BaseModel):
|
||||
type: Optional[str] = Field(default=None, description="The type of the image.")
|
||||
|
||||
|
||||
class LayoutItem(BaseModel):
|
||||
class LayoutItem(SafeBaseModel):
|
||||
"""The layout of a page."""
|
||||
|
||||
image: str = Field(description="The name of the image containing the layout item")
|
||||
confidence: float = Field(description="The confidence of the layout item.")
|
||||
label: str = Field(description="The label of the layout item.")
|
||||
image: str = Field(
|
||||
default="", description="The name of the image containing the layout item"
|
||||
)
|
||||
confidence: float = Field(
|
||||
default=0.0, description="The confidence of the layout item."
|
||||
)
|
||||
label: str = Field(default="", description="The label of the layout item.")
|
||||
bbox: Optional[BBox] = Field(
|
||||
default=None, description="The bounding box of the layout item."
|
||||
)
|
||||
isLikelyNoise: bool = Field(description="Whether the layout item is likely noise.")
|
||||
isLikelyNoise: bool = Field(
|
||||
default=False, description="Whether the layout item is likely noise."
|
||||
)
|
||||
|
||||
|
||||
class ChartItem(BaseModel):
|
||||
class ChartItem(SafeBaseModel):
|
||||
"""A chart in a page."""
|
||||
|
||||
name: str = Field(description="The name of the chart.")
|
||||
name: str = Field(default="", description="The name of the chart.")
|
||||
x: Optional[float] = Field(
|
||||
default=None, description="The x-coordinate of the chart."
|
||||
)
|
||||
@@ -109,7 +197,7 @@ class ChartItem(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Page(BaseModel):
|
||||
class Page(SafeBaseModel):
|
||||
"""A page of the document."""
|
||||
|
||||
page: int = Field(default=0, description="The page number.")
|
||||
@@ -164,7 +252,7 @@ class Page(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class JobResult(BaseModel):
|
||||
class JobResult(SafeBaseModel):
|
||||
"""The raw JSON result from the LlamaParse API."""
|
||||
|
||||
pages: List[Page] = Field(
|
||||
@@ -181,6 +269,13 @@ class JobResult(BaseModel):
|
||||
error: Optional[str] = Field(
|
||||
default=None, description="The error message if the job failed."
|
||||
)
|
||||
error_code: Optional[str] = Field(
|
||||
default=None, description="The error code if the job failed."
|
||||
)
|
||||
status: Optional[str] = Field(
|
||||
default=None,
|
||||
description="The job status (e.g., PENDING, SUCCESS, ERROR, CANCELED).",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -258,6 +353,29 @@ class JobResult(BaseModel):
|
||||
documents = await self.aget_text_documents(split_by_page)
|
||||
return [TextNode(text=doc.text, metadata=doc.metadata) for doc in documents]
|
||||
|
||||
def _format_markdown_for_notebook(self, text: Optional[str]) -> Optional[str]:
|
||||
"""Format markdown text for Jupyter notebook display by escaping dollar signs."""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
def escape_single_dollar_signs(text: str) -> str:
|
||||
"""Escape single dollar signs in text to prevent Jupyter from interpreting them as LaTeX.
|
||||
|
||||
Preserves all strings of dollar signs greater than length 1,
|
||||
especially preserving double dollar signs ($$) which denote LaTeX equations.
|
||||
|
||||
Args:
|
||||
text: The text to escape
|
||||
|
||||
Returns:
|
||||
Text with single dollar signs escaped
|
||||
"""
|
||||
# Replace single $ with \$, but preserve $$
|
||||
# Use negative lookahead and lookbehind to match $ not preceded or followed by $
|
||||
return re.sub(r"(?<!\$)\$(?!\$)", r"\$", text)
|
||||
|
||||
return escape_single_dollar_signs(text)
|
||||
|
||||
def get_markdown_documents(self, split_by_page: bool = False) -> List[Document]:
|
||||
"""
|
||||
Get the markdown documents from the job.
|
||||
@@ -268,17 +386,22 @@ class JobResult(BaseModel):
|
||||
if split_by_page:
|
||||
return [
|
||||
Document(
|
||||
text=page.md,
|
||||
text=self._format_markdown_for_notebook(page.md)
|
||||
if is_jupyter()
|
||||
else page.md,
|
||||
metadata={"page_number": page.page, "file_name": self.file_name},
|
||||
)
|
||||
for page in self.pages
|
||||
]
|
||||
else:
|
||||
text = self._page_separator.join(
|
||||
[page.md if page.md is not None else "" for page in self.pages]
|
||||
)
|
||||
return [
|
||||
Document(
|
||||
text=self._page_separator.join(
|
||||
[page.md if page.md is not None else "" for page in self.pages]
|
||||
),
|
||||
text=self._format_markdown_for_notebook(text)
|
||||
if is_jupyter()
|
||||
else text,
|
||||
metadata={"file_name": self.file_name},
|
||||
)
|
||||
]
|
||||
@@ -328,7 +451,10 @@ class JobResult(BaseModel):
|
||||
"""
|
||||
url = f"{self._base_url}/api/v1/parsing/job/{self.job_id}/result/raw/markdown"
|
||||
response = await make_api_request(self._client, "GET", url)
|
||||
return response.content.decode("utf-8")
|
||||
markdown = response.content.decode("utf-8")
|
||||
return (
|
||||
self._format_markdown_for_notebook(markdown) if is_jupyter() else markdown
|
||||
)
|
||||
|
||||
def get_text(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import functools
|
||||
import httpx
|
||||
import itertools
|
||||
import logging
|
||||
@@ -356,6 +357,17 @@ def partition_pages(
|
||||
return
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def is_jupyter() -> bool:
|
||||
"""Check if we're running in a Jupyter environment."""
|
||||
try:
|
||||
from IPython import get_ipython
|
||||
|
||||
return get_ipython().__class__.__name__ == "ZMQInteractiveShell"
|
||||
except (ImportError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def extract_tables_from_json_results(
|
||||
json_results: List[dict], download_path: str
|
||||
) -> List[str]:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from .matchers import FileMatcher, RequestMatcher, SchemaMatcher
|
||||
from .server import FakeLlamaCloudServer
|
||||
|
||||
__all__ = [
|
||||
"FakeLlamaCloudServer",
|
||||
"FileMatcher",
|
||||
"SchemaMatcher",
|
||||
"RequestMatcher",
|
||||
]
|
||||
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
@@ -0,0 +1,335 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
)
|
||||
@@ -0,0 +1,673 @@
|
||||
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
|
||||
@@ -0,0 +1,324 @@
|
||||
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
|
||||
@@ -0,0 +1,100 @@
|
||||
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)
|
||||
@@ -0,0 +1,153 @@
|
||||
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
|
||||