reducto-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reducto_cli/INSTRUCTIONS.md +152 -0
- reducto_cli/__init__.py +10 -0
- reducto_cli/__main__.py +5 -0
- reducto_cli/auth.py +173 -0
- reducto_cli/cli.py +81 -0
- reducto_cli/config.py +67 -0
- reducto_cli/editor.py +60 -0
- reducto_cli/extractor.py +89 -0
- reducto_cli/files.py +52 -0
- reducto_cli/help_text.py +24 -0
- reducto_cli/parser.py +89 -0
- reducto_cli/schema.py +26 -0
- reducto_cli-0.1.0.dist-info/METADATA +175 -0
- reducto_cli-0.1.0.dist-info/RECORD +17 -0
- reducto_cli-0.1.0.dist-info/WHEEL +5 -0
- reducto_cli-0.1.0.dist-info/entry_points.txt +2 -0
- reducto_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Reducto CLI
|
|
2
|
+
|
|
3
|
+
Welcome to the Reducto CLI. This tool lets you parse documents, extract structured data, and modify documents using Reducto’s platform.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
- Both commands accept a single file or a directory. Directories are scanned recursively and only supported files (below) are processed.
|
|
8
|
+
|
|
9
|
+
### Examples
|
|
10
|
+
|
|
11
|
+
- Parse a single file: `reducto parse path/to/document.pdf`
|
|
12
|
+
- Parse an entire folder: `reducto parse ./docs`
|
|
13
|
+
- Extract with a schema (path or inline JSON): `reducto extract ./docs/invoice.pdf -s schemas/invoice.json`
|
|
14
|
+
|
|
15
|
+
Parsed outputs are written as `<filename>.parse.md` with YAML front matter. Extraction reuses existing parses when possible and saves `<filename>.extract.json` containing only the payload.
|
|
16
|
+
|
|
17
|
+
## Supported File Types
|
|
18
|
+
|
|
19
|
+
• PDF: `.pdf`
|
|
20
|
+
• Images: `.png`, `.jpg`, `.jpeg`
|
|
21
|
+
• Office documents: `.doc`, `.docx`, `.ppt`, `.pptx`
|
|
22
|
+
• Spreadsheets: `.xls`, `.xlsx`
|
|
23
|
+
|
|
24
|
+
Commands accept either a file or a directory. Directories are scanned recursively, and only the supported file types listed above are processed.
|
|
25
|
+
|
|
26
|
+
## Parse Command Options
|
|
27
|
+
|
|
28
|
+
The `parse` command supports several flags to customize parsing behavior:
|
|
29
|
+
|
|
30
|
+
### Flags
|
|
31
|
+
|
|
32
|
+
| Flag | Description |
|
|
33
|
+
|------|-------------|
|
|
34
|
+
| `--agentic` | Enables all agentic options for tables, text, and figures. Increases accuracy but also increases latency. Use when document quality or complex layouts require enhanced processing. |
|
|
35
|
+
| `--change-tracking` | Enables change tracking during parsing. Returns `<s>` tags around strikethrough text, `<u>` tags around underlined text, and `<change>` tags around colored adjacent strikethrough and underlined text. Useful for documents with revision history. |
|
|
36
|
+
| `--highlights` | Include highlighted text in the parsed output. |
|
|
37
|
+
| `--hyperlinks` | Include embedded hyperlinks in the parsed output. |
|
|
38
|
+
| `--comments` | Include document comments in the parsed output. |
|
|
39
|
+
|
|
40
|
+
### Examples
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Basic parse
|
|
44
|
+
reducto parse document.pdf
|
|
45
|
+
|
|
46
|
+
# Parse with maximum accuracy (slower)
|
|
47
|
+
reducto parse document.pdf --agentic
|
|
48
|
+
|
|
49
|
+
# Parse a contract with change tracking
|
|
50
|
+
reducto parse contract.pdf --change-tracking
|
|
51
|
+
|
|
52
|
+
# Parse with all metadata
|
|
53
|
+
reducto parse document.pdf --hyperlinks --comments --highlights
|
|
54
|
+
|
|
55
|
+
# Combine flags as needed
|
|
56
|
+
reducto parse legal_doc.pdf --agentic --change-tracking --comments
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Extract Command Overview
|
|
60
|
+
|
|
61
|
+
The `extract` command enables you to pull specific, structured data from your documents according to a schema you provide (using JSON Schema). It is designed to automate information extraction by mapping complex or unstructured documents—such as invoices, receipts, reports, forms, contracts, financial statements, or tables—into machine-readable JSON.
|
|
62
|
+
|
|
63
|
+
Common use cases include:
|
|
64
|
+
- Extracting line items, totals, vendor/customer info from invoices and receipts
|
|
65
|
+
- Pulling key fields, tables, or sections from contracts or legal documents
|
|
66
|
+
- Capturing form field values from scanned forms or applications
|
|
67
|
+
- Summarizing structured results from reports, statements, or medical records
|
|
68
|
+
|
|
69
|
+
By providing a schema, you ensure consistency and determinism, so the extracted JSON conforms exactly to your business requirements. This is especially valuable for automating downstream processing pipelines, integrating with databases, or feeding data to other tools.
|
|
70
|
+
|
|
71
|
+
You can perform extraction on individual files or batches (folders), and extracted payloads are saved as `<filename>.extract.json`.
|
|
72
|
+
|
|
73
|
+
## Schema Guidelines for `reducto extract`
|
|
74
|
+
|
|
75
|
+
• Schemas must be valid JSON Schema documents.
|
|
76
|
+
• The top-level schema **must** be an object (`{"type": "object", ...}`) — inline strings or arrays are not permitted.
|
|
77
|
+
• Provide explicit property definitions so the extractor can map fields deterministically.
|
|
78
|
+
• Schemas may be supplied as file paths or inline JSON strings.
|
|
79
|
+
|
|
80
|
+
### Example Schema
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"items": {
|
|
87
|
+
"type": "array",
|
|
88
|
+
"items": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"article_number": {"type": "string"},
|
|
92
|
+
"description": {"type": "string"},
|
|
93
|
+
"quantity": {"type": "number"},
|
|
94
|
+
"unit_price": {"type": "number"},
|
|
95
|
+
"total_price": {"type": "number"}
|
|
96
|
+
},
|
|
97
|
+
"required": [
|
|
98
|
+
"article_number",
|
|
99
|
+
"description",
|
|
100
|
+
"quantity",
|
|
101
|
+
"unit_price",
|
|
102
|
+
"total_price"
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"required": ["items"]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can reuse parses across multiple extractions: the CLI automatically detects existing `.parse.md` files, rehydrates the recorded job ID, and uses `jobid://<id>` references to accelerate extraction jobs.
|
|
112
|
+
|
|
113
|
+
## Editing Documents with `reducto edit`
|
|
114
|
+
|
|
115
|
+
The `edit` command allows you to modify documents using natural language instructions. It uploads the document, applies the specified edits, and downloads the resulting file.
|
|
116
|
+
|
|
117
|
+
### Usage
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
reducto edit path/to/document.pdf --instructions "Your editing instructions here"
|
|
121
|
+
reducto edit path/to/document.pdf -i "Your editing instructions here"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Parameters
|
|
125
|
+
|
|
126
|
+
| Parameter | Required | Description |
|
|
127
|
+
|-----------|----------|-------------|
|
|
128
|
+
| `path` | Yes | Path to a file or directory. Directories are scanned recursively for supported file types. |
|
|
129
|
+
| `--instructions`, `-i` | Yes | Natural language instructions describing the edits to apply. |
|
|
130
|
+
|
|
131
|
+
### Output
|
|
132
|
+
|
|
133
|
+
Edited files are saved alongside the original with the naming pattern `<filename>.edited.<extension>`. For example:
|
|
134
|
+
- `invoice.pdf` → `invoice.edited.pdf`
|
|
135
|
+
- `report.docx` → `report.edited.docx`
|
|
136
|
+
|
|
137
|
+
### Examples
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
reducto edit contract.pdf -i "Fill in the client name as 'Acme Corporation' and set the contract date to January 15, 2024"
|
|
141
|
+
|
|
142
|
+
reducto edit document.pdf -i "Fill out the form with: Name: John Doe, Email: john@example.com, Select 'Yes' for newsletter subscription"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Effective Instructions
|
|
146
|
+
|
|
147
|
+
For best results with the `--instructions` flag:
|
|
148
|
+
- Be specific about what content to modify and how
|
|
149
|
+
- Reference specific elements (headers, footers, tables, specific text)
|
|
150
|
+
- Describe the desired outcome clearly
|
|
151
|
+
- For bulk operations on directories, ensure instructions apply uniformly to all file types
|
|
152
|
+
|
reducto_cli/__init__.py
ADDED
reducto_cli/__main__.py
ADDED
reducto_cli/auth.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
from .config import _write_api_key, _read_saved_api_key
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthSpinner:
|
|
15
|
+
"""Simple loading spinner for authentication polling."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
19
|
+
self._running = False
|
|
20
|
+
self._task = None
|
|
21
|
+
|
|
22
|
+
async def start(self, message: str = "Waiting for authentication"):
|
|
23
|
+
"""Start the spinner."""
|
|
24
|
+
self._running = True
|
|
25
|
+
self._task = asyncio.create_task(self._spin(message))
|
|
26
|
+
|
|
27
|
+
async def stop(self):
|
|
28
|
+
"""Stop the spinner."""
|
|
29
|
+
self._running = False
|
|
30
|
+
if self._task:
|
|
31
|
+
self._task.cancel()
|
|
32
|
+
try:
|
|
33
|
+
await self._task
|
|
34
|
+
except asyncio.CancelledError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
async def _spin(self, message: str):
|
|
38
|
+
"""Spinner animation loop."""
|
|
39
|
+
i = 0
|
|
40
|
+
while self._running:
|
|
41
|
+
char = self._spinner_chars[i % len(self._spinner_chars)]
|
|
42
|
+
print(f"\r{char} {message}...", end="", flush=True)
|
|
43
|
+
i += 1
|
|
44
|
+
await asyncio.sleep(0.1)
|
|
45
|
+
print("\r" + " " * (len(message) + 10), end="", flush=True) # Clear line
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def authenticate_with_device_code() -> None:
|
|
49
|
+
"""Authenticate with Reducto via device code flow."""
|
|
50
|
+
|
|
51
|
+
# Check if API key already exists
|
|
52
|
+
existing_key = _read_saved_api_key()
|
|
53
|
+
if existing_key:
|
|
54
|
+
if not typer.confirm("An API key is already set. Do you want to replace it?"):
|
|
55
|
+
typer.echo("Authentication cancelled.")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
client_info = {
|
|
59
|
+
"hostname": platform.node(),
|
|
60
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
61
|
+
"cli_version": "1.0.0",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async with httpx.AsyncClient() as client:
|
|
65
|
+
studio_api_url = os.getenv(
|
|
66
|
+
"REDUCTO_STUDIO_API_URL",
|
|
67
|
+
"https://mild-moose-423.convex.site",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# Request device code
|
|
72
|
+
response = await client.post(
|
|
73
|
+
f"{studio_api_url}/deviceAuth/deviceCode",
|
|
74
|
+
json={"clientInfo": client_info},
|
|
75
|
+
timeout=10.0,
|
|
76
|
+
)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
auth_data = response.json()
|
|
79
|
+
|
|
80
|
+
device_code = auth_data["device_code"]
|
|
81
|
+
user_code = auth_data["user_code"]
|
|
82
|
+
verification_uri = auth_data["verification_uri_complete"]
|
|
83
|
+
interval = auth_data["interval"]
|
|
84
|
+
expires_in = auth_data["expires_in"]
|
|
85
|
+
|
|
86
|
+
# Show instructions to user
|
|
87
|
+
typer.echo("\nReducto CLI Authentication")
|
|
88
|
+
typer.echo(f"Your code: {typer.style(user_code, fg='cyan', bold=True)}")
|
|
89
|
+
typer.echo(f"Visit: {typer.style(verification_uri, fg='blue')}")
|
|
90
|
+
typer.echo(f"Code expires in {expires_in} seconds")
|
|
91
|
+
|
|
92
|
+
# Try to open browser automatically
|
|
93
|
+
try:
|
|
94
|
+
webbrowser.open(verification_uri)
|
|
95
|
+
typer.echo("Browser opened automatically.", err=True)
|
|
96
|
+
except Exception:
|
|
97
|
+
typer.echo("Could not open browser automatically.", err=True)
|
|
98
|
+
|
|
99
|
+
# Start spinner and poll for approval
|
|
100
|
+
spinner = AuthSpinner()
|
|
101
|
+
await spinner.start()
|
|
102
|
+
|
|
103
|
+
start_time = time.time()
|
|
104
|
+
try:
|
|
105
|
+
while time.time() - start_time < expires_in:
|
|
106
|
+
await asyncio.sleep(interval)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
poll_response = await client.post(
|
|
110
|
+
f"{studio_api_url}/deviceAuth/poll",
|
|
111
|
+
json={"device_code": device_code},
|
|
112
|
+
timeout=10.0,
|
|
113
|
+
)
|
|
114
|
+
poll_response.raise_for_status()
|
|
115
|
+
poll_data = poll_response.json()
|
|
116
|
+
|
|
117
|
+
if poll_data["status"] == "approved":
|
|
118
|
+
api_key = poll_data["api_key"]
|
|
119
|
+
_write_api_key(api_key)
|
|
120
|
+
await spinner.stop()
|
|
121
|
+
typer.echo(
|
|
122
|
+
f"{typer.style('✓ Authentication successful!', fg='green', bold=True)}"
|
|
123
|
+
)
|
|
124
|
+
typer.echo("API key saved to ~/.reducto/config.yaml")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
elif poll_data["status"] == "denied":
|
|
128
|
+
await spinner.stop()
|
|
129
|
+
typer.echo(
|
|
130
|
+
f"{typer.style('✗ Authentication denied.', fg='red', bold=True)}"
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
elif poll_data["status"] == "expired":
|
|
135
|
+
await spinner.stop()
|
|
136
|
+
typer.echo(
|
|
137
|
+
f"{typer.style('✗ Authentication expired.', fg='red', bold=True)}"
|
|
138
|
+
)
|
|
139
|
+
typer.echo("Please try again.")
|
|
140
|
+
raise typer.Exit(1)
|
|
141
|
+
|
|
142
|
+
# Still pending, continue polling
|
|
143
|
+
|
|
144
|
+
except httpx.HTTPStatusError as e:
|
|
145
|
+
if e.response.status_code == 400:
|
|
146
|
+
error_data = e.response.json()
|
|
147
|
+
if error_data.get("error") == "slow_down":
|
|
148
|
+
await asyncio.sleep(interval)
|
|
149
|
+
continue
|
|
150
|
+
await spinner.stop()
|
|
151
|
+
typer.echo(f"{typer.style('✗ Polling failed:', fg='red')} {e}")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
# If we get here, the session expired
|
|
155
|
+
await spinner.stop()
|
|
156
|
+
typer.echo(
|
|
157
|
+
f"{typer.style('✗ Authentication timed out.', fg='red', bold=True)}"
|
|
158
|
+
)
|
|
159
|
+
typer.echo("Please try again.")
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
except Exception:
|
|
163
|
+
await spinner.stop()
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
except httpx.HTTPStatusError as e:
|
|
167
|
+
typer.echo(f"{typer.style('✗ Authentication failed:', fg='red')} {e}")
|
|
168
|
+
if e.response.status_code == 500:
|
|
169
|
+
typer.echo("Server error. Please try again later.")
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
typer.echo(f"{typer.style('✗ Authentication failed:', fg='red')} {e}")
|
|
173
|
+
raise typer.Exit(1)
|
reducto_cli/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from async_typer import AsyncTyper, Option
|
|
7
|
+
from reducto import AsyncReducto
|
|
8
|
+
|
|
9
|
+
from .config import get_api_key
|
|
10
|
+
from .extractor import extract_files
|
|
11
|
+
from .editor import edit_files
|
|
12
|
+
from .files import collect_files
|
|
13
|
+
from .help_text import load_help_text
|
|
14
|
+
from .parser import parse_files
|
|
15
|
+
from .schema import load_schema
|
|
16
|
+
from .auth import authenticate_with_device_code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app = AsyncTyper(help=load_help_text())
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.async_command()
|
|
23
|
+
async def login() -> None:
|
|
24
|
+
"""Authenticate with Reducto via device code flow."""
|
|
25
|
+
await authenticate_with_device_code()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.async_command()
|
|
29
|
+
async def parse(
|
|
30
|
+
path: Path,
|
|
31
|
+
agentic: bool = Option(False, "--agentic", help="Enables all agentic options. Increases latency and accuracy"),
|
|
32
|
+
change_tracking: bool = Option(False, "--change-tracking", help="Enables change tracking during parsing. Returns <s> tags around strikethrough text, <u> tags around underlined text, and <change> tags around colored adjacent strikethough and underlined text"),
|
|
33
|
+
highlights: bool = Option(False, "--highlights", help="Highlight text in the parsed document"),
|
|
34
|
+
hyperlinks: bool = Option(False, "--hyperlinks", help="Include embedded hyperlinks"),
|
|
35
|
+
comments: bool = Option(False, "--comments", help="Include comments"),
|
|
36
|
+
) -> None:
|
|
37
|
+
files = collect_files(path)
|
|
38
|
+
if not files:
|
|
39
|
+
typer.echo("No files found to parse.", err=True)
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
include = [
|
|
43
|
+
option for flag, option in [
|
|
44
|
+
(change_tracking, "change_tracking"),
|
|
45
|
+
(highlights, "highlight"),
|
|
46
|
+
(hyperlinks, "hyperlinks"),
|
|
47
|
+
(comments, "comments"),
|
|
48
|
+
] if flag
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
api_key = get_api_key()
|
|
52
|
+
async with AsyncReducto(api_key=api_key) as client:
|
|
53
|
+
await parse_files(client, files, agentic, include)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.async_command()
|
|
57
|
+
async def extract(path: Path, schema: str = Option(..., "--schema", "-s")) -> None:
|
|
58
|
+
files = collect_files(path)
|
|
59
|
+
if not files:
|
|
60
|
+
typer.echo("No files found to extract.", err=True)
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
|
|
63
|
+
schema_value = load_schema(schema)
|
|
64
|
+
api_key = get_api_key()
|
|
65
|
+
async with AsyncReducto(api_key=api_key) as client:
|
|
66
|
+
await extract_files(client, files, schema_value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.async_command()
|
|
70
|
+
async def edit(path: Path, instructions: str = Option(..., "--instructions", "-i")) -> None:
|
|
71
|
+
files = collect_files(path)
|
|
72
|
+
if not files:
|
|
73
|
+
typer.echo("No files found to edit.", err=True)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
api_key = get_api_key()
|
|
77
|
+
async with AsyncReducto(api_key=api_key) as client:
|
|
78
|
+
await edit_files(client, files, instructions)
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
app()
|
reducto_cli/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONFIG_PATH = Path.home() / ".reducto" / "config.yaml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_saved_api_key() -> str | None:
|
|
13
|
+
try:
|
|
14
|
+
content = CONFIG_PATH.read_text(encoding="utf-8")
|
|
15
|
+
except FileNotFoundError:
|
|
16
|
+
return None
|
|
17
|
+
except OSError:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
for line in content.splitlines():
|
|
21
|
+
if ":" not in line:
|
|
22
|
+
continue
|
|
23
|
+
key, value = line.split(":", 1)
|
|
24
|
+
if key.strip().lower() == "api_key":
|
|
25
|
+
return value.strip().strip("\"'")
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_api_key(value: str) -> None:
|
|
30
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
CONFIG_PATH.write_text(f"api_key: {value.strip()}\n", encoding="utf-8")
|
|
32
|
+
try:
|
|
33
|
+
os.chmod(CONFIG_PATH, 0o600)
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_api_key() -> str:
|
|
39
|
+
env_value = os.getenv("REDUCTO_API_KEY")
|
|
40
|
+
if env_value:
|
|
41
|
+
return env_value.strip()
|
|
42
|
+
|
|
43
|
+
saved_value = _read_saved_api_key()
|
|
44
|
+
if saved_value:
|
|
45
|
+
os.environ["REDUCTO_API_KEY"] = saved_value
|
|
46
|
+
return saved_value
|
|
47
|
+
|
|
48
|
+
# Suggest running login command first
|
|
49
|
+
typer.echo("\nNo API key found. You can authenticate by running:")
|
|
50
|
+
typer.echo(" " + typer.style("reducto login", fg="cyan", bold=True))
|
|
51
|
+
typer.echo("\nThis will open a browser to authenticate your CLI.")
|
|
52
|
+
|
|
53
|
+
# Still allow manual entry as fallback
|
|
54
|
+
if typer.confirm("\nOr enter an API key manually?"):
|
|
55
|
+
entered_value = typer.prompt(
|
|
56
|
+
"Enter your Reducto API key", hide_input=True
|
|
57
|
+
).strip()
|
|
58
|
+
if not entered_value:
|
|
59
|
+
typer.echo("An API key is required to continue.", err=True)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
_write_api_key(entered_value)
|
|
63
|
+
os.environ["REDUCTO_API_KEY"] = entered_value
|
|
64
|
+
return entered_value
|
|
65
|
+
else:
|
|
66
|
+
typer.echo("\nPlease run 'reducto login' to authenticate.", err=True)
|
|
67
|
+
raise typer.Exit(1)
|
reducto_cli/editor.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import typer
|
|
9
|
+
from reducto import AsyncReducto, ReductoError
|
|
10
|
+
|
|
11
|
+
from .files import edit_output_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def edit_files(client: AsyncReducto, files: Iterable[Path], instructions: str) -> None:
|
|
15
|
+
tasks = [asyncio.create_task(edit_file(client, file_path, instructions)) for file_path in files]
|
|
16
|
+
for task in asyncio.as_completed(tasks):
|
|
17
|
+
destination = await task
|
|
18
|
+
if destination is not None:
|
|
19
|
+
typer.echo(f"Saved {destination}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def edit_file(client: AsyncReducto, file_path: Path, instructions: str) -> Path | None:
|
|
23
|
+
try:
|
|
24
|
+
upload = await client.upload(file=file_path)
|
|
25
|
+
except ReductoError as exc:
|
|
26
|
+
typer.echo(f"Failed to upload {file_path}: {exc}", err=True)
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
response = await client.edit.run(
|
|
31
|
+
document_url=upload.file_id,
|
|
32
|
+
edit_instructions=instructions,
|
|
33
|
+
)
|
|
34
|
+
except ReductoError as exc:
|
|
35
|
+
typer.echo(f"Failed to edit {file_path}: {exc}", err=True)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
document_url = getattr(response, "document_url", None)
|
|
39
|
+
if not document_url:
|
|
40
|
+
typer.echo(f"No document URL in response for {file_path}", err=True)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
destination = edit_output_path(file_path)
|
|
44
|
+
try:
|
|
45
|
+
await _download_file(document_url, destination)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
typer.echo(f"Failed to download edited document for {file_path}: {exc}", err=True)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return destination
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def _download_file(url: str, destination: Path) -> None:
|
|
54
|
+
async with httpx.AsyncClient() as http_client:
|
|
55
|
+
async with http_client.stream("GET", url) as response:
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
with destination.open("wb") as f:
|
|
59
|
+
async for chunk in response.aiter_bytes():
|
|
60
|
+
f.write(chunk)
|
reducto_cli/extractor.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Iterable
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from reducto import AsyncReducto, ReductoError
|
|
10
|
+
|
|
11
|
+
from .files import extract_output_path, parse_output_path
|
|
12
|
+
from .parser import ParseInfo, parse_file, read_parse_metadata
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def extract_files(client: AsyncReducto, files: Iterable[Path], schema: object) -> None:
|
|
16
|
+
tasks = [asyncio.create_task(_extract_file(client, file_path, schema)) for file_path in files]
|
|
17
|
+
for task in asyncio.as_completed(tasks):
|
|
18
|
+
destination = await task
|
|
19
|
+
if destination is not None:
|
|
20
|
+
typer.echo(f"Saved {destination}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _extract_file(client: AsyncReducto, file_path: Path, schema: object) -> Path | None:
|
|
24
|
+
job_id = _existing_job_id(file_path)
|
|
25
|
+
parse_info: ParseInfo | None = None
|
|
26
|
+
|
|
27
|
+
if job_id is None:
|
|
28
|
+
parse_info = await parse_file(client, file_path)
|
|
29
|
+
if parse_info is None:
|
|
30
|
+
return None
|
|
31
|
+
job_id = parse_info.job_id
|
|
32
|
+
|
|
33
|
+
input_payload: object
|
|
34
|
+
if job_id and job_id != "unknown":
|
|
35
|
+
input_payload = f"jobid://{job_id}"
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
upload = await client.upload(file=file_path)
|
|
39
|
+
except ReductoError as exc:
|
|
40
|
+
typer.echo(f"Failed to upload {file_path}: {exc}", err=True)
|
|
41
|
+
return None
|
|
42
|
+
input_payload = {"file_id": upload.file_id}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
response = await client.extract.run(
|
|
46
|
+
input=input_payload,
|
|
47
|
+
instructions={"schema": schema},
|
|
48
|
+
)
|
|
49
|
+
except ReductoError as exc:
|
|
50
|
+
typer.echo(f"Failed to extract {file_path}: {exc}", err=True)
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
content = _response_to_text(response)
|
|
54
|
+
|
|
55
|
+
destination = extract_output_path(file_path)
|
|
56
|
+
destination.write_text(json.dumps(content, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
57
|
+
return destination
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _existing_job_id(source: Path) -> str | None:
|
|
61
|
+
metadata = read_parse_metadata(source)
|
|
62
|
+
job_id = metadata.get("job_id")
|
|
63
|
+
return job_id if job_id else None
|
|
64
|
+
def _response_to_text(response: object) -> Any:
|
|
65
|
+
if hasattr(response, "model_dump"):
|
|
66
|
+
data = response.model_dump()
|
|
67
|
+
cleaned = _strip_metadata(data)
|
|
68
|
+
return _unwrap_single_result(cleaned)
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _strip_metadata(data: Any) -> Any:
|
|
73
|
+
if isinstance(data, dict):
|
|
74
|
+
return {
|
|
75
|
+
key: _strip_metadata(value)
|
|
76
|
+
for key, value in data.items()
|
|
77
|
+
if key not in {"usage", "job_id", "studio_link", "citations"}
|
|
78
|
+
}
|
|
79
|
+
if isinstance(data, list):
|
|
80
|
+
return [_strip_metadata(item) for item in data]
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _unwrap_single_result(data: Any) -> Any:
|
|
85
|
+
if isinstance(data, dict) and "result" in data and len(data) == 1:
|
|
86
|
+
return _unwrap_single_result(data["result"])
|
|
87
|
+
if isinstance(data, list) and len(data) == 1:
|
|
88
|
+
return _unwrap_single_result(data[0])
|
|
89
|
+
return data
|
reducto_cli/files.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PARSE_SUFFIX = ".parse.md"
|
|
10
|
+
EXTRACT_SUFFIX = ".extract.json"
|
|
11
|
+
EDIT_SUFFIX = ".edited"
|
|
12
|
+
GENERATED_SUFFIXES = (PARSE_SUFFIX, EXTRACT_SUFFIX)
|
|
13
|
+
SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def collect_files(target: Path) -> list[Path]:
|
|
17
|
+
path = target.expanduser()
|
|
18
|
+
if not path.exists():
|
|
19
|
+
raise typer.BadParameter(f"{path} does not exist")
|
|
20
|
+
|
|
21
|
+
if path.is_file():
|
|
22
|
+
return [path] if _should_process(path) else []
|
|
23
|
+
|
|
24
|
+
return sorted(p for p in _iter_files(path) if _should_process(p))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iter_files(directory: Path) -> Iterable[Path]:
|
|
28
|
+
for item in directory.rglob("*"):
|
|
29
|
+
if item.is_file():
|
|
30
|
+
yield item
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_generated(path: Path) -> bool:
|
|
34
|
+
return any(path.name.endswith(suffix) for suffix in GENERATED_SUFFIXES)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _should_process(path: Path) -> bool:
|
|
38
|
+
if is_generated(path):
|
|
39
|
+
return False
|
|
40
|
+
return path.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_output_path(source: Path) -> Path:
|
|
44
|
+
return source.with_name(f"{source.name}{PARSE_SUFFIX}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_output_path(source: Path) -> Path:
|
|
48
|
+
return source.with_name(f"{source.name}{EXTRACT_SUFFIX}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def edit_output_path(source: Path) -> Path:
|
|
52
|
+
return source.with_name(f"{source.stem}{EDIT_SUFFIX}{source.suffix}")
|
reducto_cli/help_text.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_help_text() -> str:
|
|
8
|
+
try:
|
|
9
|
+
package_path = resources.files("reducto_cli").joinpath("INSTRUCTIONS.md")
|
|
10
|
+
content = package_path.read_text(encoding="utf-8").strip()
|
|
11
|
+
if content:
|
|
12
|
+
return content
|
|
13
|
+
except (FileNotFoundError, OSError, AttributeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
fallback_path = Path(__file__).resolve().parent / "INSTRUCTIONS.md"
|
|
17
|
+
try:
|
|
18
|
+
content = fallback_path.read_text(encoding="utf-8").strip()
|
|
19
|
+
if content:
|
|
20
|
+
return content
|
|
21
|
+
except OSError:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
return "Reducto CLI"
|
reducto_cli/parser.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from reducto import AsyncReducto, ReductoError
|
|
10
|
+
from reducto.types.shared.parse_response import ResultFullResult
|
|
11
|
+
|
|
12
|
+
from .files import parse_output_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ParseInfo:
|
|
17
|
+
job_id: str
|
|
18
|
+
duration: Optional[float]
|
|
19
|
+
output_path: Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def parse_files(client: AsyncReducto, files: Iterable[Path], agentic: bool, formatting_include: list[str]) -> None:
|
|
23
|
+
tasks = [asyncio.create_task(parse_file(client, file_path, agentic, formatting_include)) for file_path in files]
|
|
24
|
+
for task in asyncio.as_completed(tasks):
|
|
25
|
+
info = await task
|
|
26
|
+
if info is not None:
|
|
27
|
+
typer.echo(f"Saved {info.output_path}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def parse_file(client: AsyncReducto, file_path: Path, agentic: bool, formatting_include: list[str]) -> ParseInfo | None:
|
|
31
|
+
try:
|
|
32
|
+
upload = await client.upload(file=file_path)
|
|
33
|
+
kwargs = {"input": {"file_id": upload.file_id}, "formatting": {"include": formatting_include}}
|
|
34
|
+
if agentic:
|
|
35
|
+
kwargs["enhance"] = {"agentic": [{"scope": "table"}, {"scope": "text"}, {"scope": "figure"}]}
|
|
36
|
+
response = await client.parse.run(**kwargs)
|
|
37
|
+
except ReductoError as exc:
|
|
38
|
+
typer.echo(f"Failed to parse {file_path}: {exc}", err=True)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
text = _extract_text(response.result) if hasattr(response, "result") else None
|
|
42
|
+
if text is None:
|
|
43
|
+
typer.echo(f"No textual content available for {file_path}", err=True)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
job_id = getattr(response, "job_id", "unknown") or "unknown"
|
|
47
|
+
duration = getattr(response, "duration", None)
|
|
48
|
+
front_matter = [
|
|
49
|
+
"---",
|
|
50
|
+
f"job_id: {job_id}",
|
|
51
|
+
f"duration: {duration if duration is not None else 'null'}",
|
|
52
|
+
"---",
|
|
53
|
+
"",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
destination = parse_output_path(file_path)
|
|
57
|
+
destination.write_text("\n".join(front_matter + [text]), encoding="utf-8")
|
|
58
|
+
return ParseInfo(job_id=job_id, duration=duration, output_path=destination)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_text(result: object) -> Optional[str]:
|
|
62
|
+
if isinstance(result, ResultFullResult) and result.chunks:
|
|
63
|
+
return result.chunks[0].content
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_parse_metadata(source: Path) -> dict[str, str]:
|
|
68
|
+
path = parse_output_path(source)
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return {}
|
|
71
|
+
try:
|
|
72
|
+
text = path.read_text(encoding="utf-8")
|
|
73
|
+
except OSError:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
lines = text.splitlines()
|
|
77
|
+
if not lines or lines[0].strip() != "---":
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
data: dict[str, str] = {}
|
|
81
|
+
for line in lines[1:]:
|
|
82
|
+
stripped = line.strip()
|
|
83
|
+
if stripped == "---":
|
|
84
|
+
break
|
|
85
|
+
if ":" not in line:
|
|
86
|
+
continue
|
|
87
|
+
key, value = line.split(":", 1)
|
|
88
|
+
data[key.strip()] = value.strip()
|
|
89
|
+
return data
|
reducto_cli/schema.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_schema(value: str) -> object:
|
|
10
|
+
candidate = Path(value).expanduser()
|
|
11
|
+
if candidate.exists():
|
|
12
|
+
try:
|
|
13
|
+
raw = candidate.read_text(encoding="utf-8")
|
|
14
|
+
except OSError as exc:
|
|
15
|
+
raise typer.BadParameter(f"Unable to read schema file: {exc}") from exc
|
|
16
|
+
else:
|
|
17
|
+
raw = value
|
|
18
|
+
|
|
19
|
+
raw = raw.strip()
|
|
20
|
+
if not raw:
|
|
21
|
+
raise typer.BadParameter("Schema cannot be empty")
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(raw)
|
|
25
|
+
except json.JSONDecodeError:
|
|
26
|
+
return raw
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reducto-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for Reducto document processing
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: async-typer>=0.1.10
|
|
8
|
+
Requires-Dist: reductoai>=0.13.0
|
|
9
|
+
Requires-Dist: typer>=0.20.0
|
|
10
|
+
|
|
11
|
+
# Reducto CLI
|
|
12
|
+
|
|
13
|
+
Welcome to the Reducto CLI. This tool lets you parse documents, extract structured data, and modify documents using Reducto’s platform.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Install the Reducto CLI using pip:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install reducto-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Authentication
|
|
24
|
+
|
|
25
|
+
Before using the CLI, authenticate with your Reducto API key:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
reducto login
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Examples
|
|
32
|
+
|
|
33
|
+
- Parse a single file: `reducto parse path/to/document.pdf`
|
|
34
|
+
- Parse an entire folder: `reducto parse ./docs`
|
|
35
|
+
- Extract with a schema (path or inline JSON): `reducto extract ./docs/invoice.pdf -s schemas/invoice.json`
|
|
36
|
+
- Edit a single file: `reducto edit path/to/document.pdf --instructions "Your editing instructions here"`
|
|
37
|
+
|
|
38
|
+
Parsed outputs are written as `<filename>.parse.md`. Extraction reuses existing parses when possible and saves `<filename>.extract.json` containing only the payload.
|
|
39
|
+
|
|
40
|
+
## Supported File Types
|
|
41
|
+
|
|
42
|
+
• PDF: `.pdf`
|
|
43
|
+
• Images: `.png`, `.jpg`, `.jpeg`
|
|
44
|
+
• Office documents: `.doc`, `.docx`, `.ppt`, `.pptx`
|
|
45
|
+
• Spreadsheets: `.xls`, `.xlsx`
|
|
46
|
+
|
|
47
|
+
Commands accept either a file or a directory. Directories are scanned recursively, and only the supported file types listed above are processed.
|
|
48
|
+
|
|
49
|
+
## Parse Command Options
|
|
50
|
+
|
|
51
|
+
The `parse` command supports several flags to customize parsing behavior:
|
|
52
|
+
|
|
53
|
+
### Flags
|
|
54
|
+
|
|
55
|
+
| Flag | Description |
|
|
56
|
+
|------|-------------|
|
|
57
|
+
| `--agentic` | Enables all agentic options for tables, text, and figures. Increases accuracy but also increases latency. Use when document quality or complex layouts require enhanced processing. |
|
|
58
|
+
| `--change-tracking` | Enables change tracking during parsing. Returns `<s>` tags around strikethrough text, `<u>` tags around underlined text, and `<change>` tags around colored adjacent strikethrough and underlined text. Useful for documents with revision history. |
|
|
59
|
+
| `--highlights` | Include highlighted text in the parsed output. |
|
|
60
|
+
| `--hyperlinks` | Include embedded hyperlinks in the parsed output. |
|
|
61
|
+
| `--comments` | Include document comments in the parsed output. |
|
|
62
|
+
|
|
63
|
+
### Examples
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Basic parse
|
|
67
|
+
reducto parse document.pdf
|
|
68
|
+
|
|
69
|
+
# Parse with maximum accuracy (slower)
|
|
70
|
+
reducto parse document.pdf --agentic
|
|
71
|
+
|
|
72
|
+
# Parse a contract with change tracking
|
|
73
|
+
reducto parse contract.pdf --change-tracking
|
|
74
|
+
|
|
75
|
+
# Parse with all metadata
|
|
76
|
+
reducto parse document.pdf --hyperlinks --comments --highlights
|
|
77
|
+
|
|
78
|
+
# Combine flags as needed
|
|
79
|
+
reducto parse legal_doc.pdf --agentic --change-tracking --comments
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Extract Command Overview
|
|
83
|
+
|
|
84
|
+
The `extract` command enables you to pull specific, structured data from your documents according to a schema you provide (using JSON Schema). It is designed to automate information extraction by mapping complex or unstructured documents—such as invoices, receipts, reports, forms, contracts, financial statements, or tables—into machine-readable JSON.
|
|
85
|
+
|
|
86
|
+
Common use cases include:
|
|
87
|
+
- Extracting line items, totals, vendor/customer info from invoices and receipts
|
|
88
|
+
- Pulling key fields, tables, or sections from contracts or legal documents
|
|
89
|
+
- Capturing form field values from scanned forms or applications
|
|
90
|
+
- Summarizing structured results from reports, statements, or medical records
|
|
91
|
+
|
|
92
|
+
By providing a schema, you ensure consistency and determinism, so the extracted JSON conforms exactly to your business requirements. This is especially valuable for automating downstream processing pipelines, integrating with databases, or feeding data to other tools.
|
|
93
|
+
|
|
94
|
+
You can perform extraction on individual files or batches (folders), and extracted payloads are saved as `<filename>.extract.json`.
|
|
95
|
+
|
|
96
|
+
## Schema Guidelines for `reducto extract`
|
|
97
|
+
|
|
98
|
+
• Schemas must be valid JSON Schema documents.
|
|
99
|
+
• The top-level schema **must** be an object (`{"type": "object", ...}`) — inline strings or arrays are not permitted.
|
|
100
|
+
• Provide explicit property definitions so the extractor can map fields deterministically.
|
|
101
|
+
• Schemas may be supplied as file paths or inline JSON strings.
|
|
102
|
+
|
|
103
|
+
### Example Schema
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {
|
|
109
|
+
"items": {
|
|
110
|
+
"type": "array",
|
|
111
|
+
"items": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"article_number": {"type": "string"},
|
|
115
|
+
"description": {"type": "string"},
|
|
116
|
+
"quantity": {"type": "number"},
|
|
117
|
+
"unit_price": {"type": "number"},
|
|
118
|
+
"total_price": {"type": "number"}
|
|
119
|
+
},
|
|
120
|
+
"required": [
|
|
121
|
+
"article_number",
|
|
122
|
+
"description",
|
|
123
|
+
"quantity",
|
|
124
|
+
"unit_price",
|
|
125
|
+
"total_price"
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"required": ["items"]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
You can reuse parses across multiple extractions: the CLI automatically detects existing `.parse.md` files, rehydrates the recorded job ID, and uses `jobid://<id>` references to accelerate extraction jobs.
|
|
135
|
+
|
|
136
|
+
## Editing Documents with `reducto edit`
|
|
137
|
+
|
|
138
|
+
The `edit` command allows you to modify documents using natural language instructions. It uploads the document, applies the specified edits, and downloads the resulting file.
|
|
139
|
+
|
|
140
|
+
### Usage
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
reducto edit path/to/document.pdf --instructions "Your editing instructions here"
|
|
144
|
+
reducto edit path/to/document.pdf -i "Your editing instructions here"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Parameters
|
|
148
|
+
|
|
149
|
+
| Parameter | Required | Description |
|
|
150
|
+
|-----------|----------|-------------|
|
|
151
|
+
| `path` | Yes | Path to a file or directory. Directories are scanned recursively for supported file types. |
|
|
152
|
+
| `--instructions`, `-i` | Yes | Natural language instructions describing the edits to apply. |
|
|
153
|
+
|
|
154
|
+
### Output
|
|
155
|
+
|
|
156
|
+
Edited files are saved alongside the original with the naming pattern `<filename>.edited.<extension>`. For example:
|
|
157
|
+
- `invoice.pdf` → `invoice.edited.pdf`
|
|
158
|
+
- `report.docx` → `report.edited.docx`
|
|
159
|
+
|
|
160
|
+
### Examples
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
reducto edit contract.pdf -i "Fill in the client name as 'Acme Corporation' and set the contract date to January 15, 2024"
|
|
164
|
+
|
|
165
|
+
reducto edit document.pdf -i "Fill out the form with: Name: John Doe, Email: john@example.com, Select 'Yes' for newsletter subscription"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Effective Instructions
|
|
169
|
+
|
|
170
|
+
For best results with the `--instructions` flag:
|
|
171
|
+
- Be specific about what content to modify and how
|
|
172
|
+
- Reference specific elements (headers, footers, tables, specific text)
|
|
173
|
+
- Describe the desired outcome clearly
|
|
174
|
+
- For bulk operations on directories, ensure instructions apply uniformly to all file types
|
|
175
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
reducto_cli/INSTRUCTIONS.md,sha256=GqtwwxmdHJrSLiZtv7FbLZgsH4zqTVf4eBFBAlRIrzI,6154
|
|
2
|
+
reducto_cli/__init__.py,sha256=_5mutx4EYCl4_jqZovpr8yIPn1BdTC-dwT9WfRvLRJM,141
|
|
3
|
+
reducto_cli/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
|
|
4
|
+
reducto_cli/auth.py,sha256=cTVN7swoh9YacM1gt_0eNJqNUJvN8B-puECUpBNGKWg,6542
|
|
5
|
+
reducto_cli/cli.py,sha256=YX6FL_egqFNY3wiZkmKJiN4ZIOt0WyB_I2z39iP43CA,2706
|
|
6
|
+
reducto_cli/config.py,sha256=lZL5kz9cACLNfO6vdSohJegNyHGDqfW-ea5UuYK4JZA,1942
|
|
7
|
+
reducto_cli/editor.py,sha256=-Pf0eynMatWhii8OFzrcROFAHAMCk99LX5iEdMvTZq0,2010
|
|
8
|
+
reducto_cli/extractor.py,sha256=fO2zSJ9ecLsTk-VFkdTes0jFCAPOs_i6lVKCnmlrcrM,2903
|
|
9
|
+
reducto_cli/files.py,sha256=DAz9WUBV_YoyelLQS-k8Dowyr99tZVtOkadS4FB5I70,1406
|
|
10
|
+
reducto_cli/help_text.py,sha256=80uhM6NzgGdbuxgEv4Lx2XMe6b3I82l0hE-CgHqMWe0,655
|
|
11
|
+
reducto_cli/parser.py,sha256=it2LO7RznuVJ0TvVey9VcUIGjhfJVPkHl8-g__YqwrI,2878
|
|
12
|
+
reducto_cli/schema.py,sha256=Ttcx3IcyKVDrnJGmLxGeJhGH1QdIsvr-csnpwmDaxzA,601
|
|
13
|
+
reducto_cli-0.1.0.dist-info/METADATA,sha256=_ICs2adq_SnTvj8jW_b_GRs00HLzDk1_Auengwg-3Jc,6550
|
|
14
|
+
reducto_cli-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
15
|
+
reducto_cli-0.1.0.dist-info/entry_points.txt,sha256=dRLbGtD1LhU-eGfITuetnpvq6q-_ELrXJy4zzRnWz0w,48
|
|
16
|
+
reducto_cli-0.1.0.dist-info/top_level.txt,sha256=PkGOBDvo-HEUItp3EV_P0PMXKLudo6CJEtB9oofR_wE,12
|
|
17
|
+
reducto_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
reducto_cli
|