ForcePilot/scripts/batch_upload.py

526 lines
20 KiB
Python
Raw Normal View History

import asyncio
import hashlib
import json
import pathlib
import httpx
import typer
from rich.console import Console
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
app = typer.Typer()
console = Console()
async def login(client: httpx.AsyncClient, base_url: str, username: str, password: str) -> str | None:
"""Logs in to the API and returns the access token."""
try:
response = await client.post(
f"{base_url}/auth/token",
data={"username": username, "password": password},
)
response.raise_for_status()
return response.json().get("access_token")
except httpx.HTTPStatusError as e:
console.print(f"[bold red]Login failed: {e.response.status_code} - {e.response.text}[/bold red]")
return None
except httpx.RequestError as e:
console.print(f"[bold red]Login request failed: {e}[/bold red]")
return None
async def check_task_status(client: httpx.AsyncClient, base_url: str, task_id: str) -> str | None:
"""Check the status of a task. Returns status string or None if failed."""
try:
response = await client.get(f"{base_url}/tasks/{task_id}")
response.raise_for_status()
task_data = response.json().get("task", {})
return task_data.get("status")
except httpx.HTTPStatusError as e:
console.print(f"[bold yellow]Warning: Failed to check task {task_id}: {e.response.status_code}[/bold yellow]")
return None
except httpx.RequestError as e:
console.print(f"[bold yellow]Warning: Failed to check task {task_id}: {e}[/bold yellow]")
return None
async def upload_file(
client: httpx.AsyncClient,
base_url: str,
db_id: str,
file_path: pathlib.Path,
) -> str | None:
"""Uploads a single file and returns its server-side path."""
try:
with open(file_path, "rb") as f:
files = {"file": (file_path.name, f, "application/octet-stream")}
response = await client.post(
f"{base_url}/knowledge/files/upload",
params={"db_id": db_id},
files=files,
timeout=300, # 5 minutes timeout for large files
)
response.raise_for_status()
return response.json().get("file_path")
except httpx.HTTPStatusError as e:
console.print(
f"[bold red]Failed to upload {file_path.name}: {e.response.status_code} - {e.response.text}[/bold red]"
)
return None
except httpx.RequestError as e:
console.print(f"[bold red]Failed to upload {file_path.name}: {e}[/bold red]")
return None
async def process_document(
client: httpx.AsyncClient,
base_url: str,
db_id: str,
server_file_path: str,
enable_ocr: str = "paddlex_ocr",
chunk_size: int = 1000,
chunk_overlap: int = 200,
use_qa_split: bool = False,
qa_separator: str = "\n\n\n",
) -> tuple[bool, str | None]:
"""Triggers the processing of an uploaded file in the knowledge base."""
# Prepare processing parameters
params = {
"chunk_size": chunk_size,
"chunk_overlap": chunk_overlap,
"enable_ocr": enable_ocr,
"use_qa_split": use_qa_split,
"qa_separator": qa_separator,
"content_type": "file",
}
try:
response = await client.post(
f"{base_url}/knowledge/databases/{db_id}/documents",
json={"items": [server_file_path], "params": params},
timeout=600, # 10 minutes timeout for processing
)
response.raise_for_status()
result = response.json()
# Handle asynchronous ingest response
overall_status = result.get("status")
if overall_status == "queued":
task_id = result.get("task_id")
extra = f" (task id: {task_id})" if task_id else ""
console.print(
f"[bold cyan]Ingestion queued for {server_file_path}{extra}. "
"Track progress in the task center.[/bold cyan]"
)
return True, task_id
# Check if the overall request was successful for synchronous responses
if overall_status != "success":
console.print(
f"[bold yellow]Processing warning for {server_file_path}: {result.get('message')}[/bold yellow]"
)
return False, None
# Check the specific file's processing status in the items array
items = result.get("items", [])
if not items:
console.print(f"[bold red]No processing result for {server_file_path}[/bold red]")
return False, None
# Since we only sent one file, check the first item
item = items[0]
# Check for both 'success' and 'done' status (different APIs might use different status values)
if item.get("status") in ["success", "done"]:
return True, None
else:
# Get more detailed error information
error_msg = item.get("message", "")
error_detail = item.get("detail", "")
error_reason = item.get("reason", "")
# Combine all available error information
error_info = []
if error_msg:
error_info.append(error_msg)
if error_detail:
error_info.append(error_detail)
if error_reason:
error_info.append(error_reason)
if not error_info:
error_info = ["Unknown error"]
full_error = " | ".join(error_info)
console.print(f"[bold red]Failed to process {server_file_path}: {full_error}[/bold red]")
# Also log the full item for debugging
console.print(f"[dim]Debug - Full item response: {item}[/dim]")
return False, None
except httpx.HTTPStatusError as e:
console.print(
f"[bold red]Failed to process {server_file_path}: {e.response.status_code} - {e.response.text}[/bold red]"
)
return False, None
except httpx.RequestError as e:
console.print(f"[bold red]Failed to process {server_file_path}: {e}[/bold red]")
return False, None
async def upload_single_file(
client: httpx.AsyncClient,
base_url: str,
db_id: str,
file_path: pathlib.Path,
progress: Progress,
task_id: int,
) -> str | None:
"""Upload a single file and return server file path."""
server_file_path = await upload_file(client, base_url, db_id, file_path)
if server_file_path:
progress.update(task_id, advance=1, postfix=f"Uploaded {file_path.name}")
else:
progress.update(task_id, advance=1, postfix=f"Failed: {file_path.name}")
return server_file_path
async def add_batch_to_knowledge_base(
client: httpx.AsyncClient,
base_url: str,
db_id: str,
server_file_paths: list[str],
enable_ocr: str = "paddlex_ocr",
chunk_size: int = 1000,
chunk_overlap: int = 200,
use_qa_split: bool = False,
qa_separator: str = "\n\n\n",
) -> tuple[bool, str | None]:
"""Add a batch of files to knowledge base and return task_id."""
if not server_file_paths:
return True, None
# Prepare processing parameters
params = {
"chunk_size": chunk_size,
"chunk_overlap": chunk_overlap,
"enable_ocr": enable_ocr,
"use_qa_split": use_qa_split,
"qa_separator": qa_separator,
"content_type": "file",
}
try:
response = await client.post(
f"{base_url}/knowledge/databases/{db_id}/documents",
json={"items": server_file_paths, "params": params},
timeout=600, # 10 minutes timeout for processing
)
response.raise_for_status()
result = response.json()
overall_status = result.get("status")
if overall_status == "queued":
task_id = result.get("task_id")
extra = f" (task id: {task_id})" if task_id else ""
console.print(
f"[bold cyan]Batch of {len(server_file_paths)} files queued for processing{extra}. "
"Track progress in the task center.[/bold cyan]"
)
return True, task_id
elif overall_status == "success":
console.print(f"[bold green]Batch of {len(server_file_paths)} files processed successfully[/bold green]")
return True, None
else:
console.print(f"[bold yellow]Batch processing warning: {result.get('message')}[/bold yellow]")
return False, None
except httpx.HTTPStatusError as e:
console.print(f"[bold red]Failed to process batch: {e.response.status_code} - {e.response.text}[/bold red]")
return False, None
except httpx.RequestError as e:
console.print(f"[bold red]Failed to process batch: {e}[/bold red]")
return False, None
async def wait_for_tasks_completion(
client: httpx.AsyncClient,
base_url: str,
task_ids: list[str],
poll_interval: int = 5,
) -> dict[str, str]:
"""Wait for all tasks to complete and return their final statuses."""
if not task_ids:
return {}
console.print(f"[bold cyan]Waiting for {len(task_ids)} tasks to complete...[/bold cyan]")
pending_tasks = task_ids.copy()
completed_tasks = {}
while pending_tasks:
for task_id in pending_tasks.copy():
status = await check_task_status(client, base_url, task_id)
if status:
if status in ["success", "failed", "cancelled"]:
completed_tasks[task_id] = status
pending_tasks.remove(task_id)
console.print(f"[dim]Task {task_id} completed with status: {status}[/dim]")
if pending_tasks:
await asyncio.sleep(poll_interval)
console.print(f"[bold green]All {len(task_ids)} tasks completed[/bold green]")
return completed_tasks
def get_file_hash(file_path: pathlib.Path) -> str:
"""Calculate SHA256 hash of a file."""
hash_sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_sha256.update(chunk)
return hash_sha256.hexdigest()
def load_processed_files(record_file: pathlib.Path) -> set[str]:
"""Load the set of processed file hashes from the record file."""
if not record_file.exists():
return set()
try:
with open(record_file) as f:
data = json.load(f)
return set(data.get("processed_files", []))
except (OSError, json.JSONDecodeError) as e:
console.print(f"[bold yellow]Warning: Could not load processed files record: {e}[/bold yellow]")
return set()
def save_processed_files(record_file: pathlib.Path, processed_files: set[str]):
"""Save the set of processed file hashes to the record file."""
# Ensure the directory exists
record_file.parent.mkdir(parents=True, exist_ok=True)
try:
with open(record_file, "w") as f:
json.dump({"processed_files": list(processed_files)}, f, indent=2)
except OSError as e:
console.print(f"[bold red]Error: Could not save processed files record: {e}[/bold red]")
@app.command()
def upload(
db_id: str = typer.Option(..., help="The ID of the knowledge base."),
directory: pathlib.Path = typer.Option(
..., help="The directory containing files to upload.", exists=True, file_okay=False
),
pattern: list[str] = typer.Option(
["*.md"],
help="The glob patterns for files to upload (e.g., '*.pdf', '**/*.txt'). Can be specified multiple times.",
),
base_url: str = typer.Option("http://127.0.0.1:5050/api", help="The base URL of the API server."),
username: str = typer.Option(..., help="Admin username for login."),
password: str = typer.Option(..., help="Admin password for login."),
recursive: bool = typer.Option(False, "--recursive", "-r", help="Search for files recursively in subdirectories."),
record_file: pathlib.Path = typer.Option(
"scripts/tmp/batch_processed_files.txt", help="File to store processed files record."
),
chunk_size: int = typer.Option(1000, help="Chunk size for document processing."),
chunk_overlap: int = typer.Option(200, help="Chunk overlap for document processing."),
enable_ocr: str = typer.Option(
"paddlex_ocr", help="OCR engine to use (onnx_rapid_ocr, mineru_ocr, mineru_official, paddlex_ocr, disable)."
),
use_qa_split: bool = typer.Option(False, help="Whether to use QA splitting."),
qa_separator: str = typer.Option("\n\n\n", help="Separator for QA splitting."),
batch_size: int = typer.Option(20, help="Number of files to process in each batch."),
wait_for_completion: bool = typer.Option(True, help="Whether to wait for tasks to complete before next batch."),
poll_interval: int = typer.Option(5, help="Polling interval in seconds for checking task status."),
):
"""
Batch upload and process files into a Yuxi-Know knowledge base.
"""
console.print(f"[bold green]Starting batch upload for knowledge base: {db_id}[/bold green]")
# Load previously processed files
processed_files = load_processed_files(record_file)
console.print(f"Loaded {len(processed_files)} previously processed files from record.")
# Discover files from multiple patterns
glob_method = directory.rglob if recursive else directory.glob
all_files = []
for pat in pattern:
files_for_pat = list(glob_method(pat))
all_files.extend(files_for_pat)
# Remove duplicates
all_files = list(set(all_files))
if not all_files:
patterns_str = "', '".join(pattern)
console.print(
f"[bold yellow]No files found in '{directory}' matching patterns: '{patterns_str}'. Aborting.[/bold yellow]"
)
raise typer.Exit()
# 过滤掉macos的隐藏文件
all_files = [f for f in all_files if not f.name.startswith("._")]
# Filter out already processed files
files_to_upload = []
skipped_files = []
for file_path in all_files:
file_hash = get_file_hash(file_path)
if file_hash in processed_files:
skipped_files.append(file_path)
else:
files_to_upload.append((file_path, file_hash))
if not files_to_upload:
console.print(
f"[bold green]All {len(all_files)} files have already been processed. Nothing to do.[/bold green]"
)
raise typer.Exit()
console.print(f"Found {len(all_files)} total files:")
console.print(f" - [green]New files to process:[/green] {len(files_to_upload)}")
console.print(f" - [blue]Already processed (skipped):[/blue] {len(skipped_files)}")
async def run():
async with httpx.AsyncClient() as client:
# Login
token = await login(client, base_url, username, password)
if not token:
raise typer.Exit(code=1)
client.headers = {"Authorization": f"Bearer {token}"}
# Process files in batches: upload 20 -> process 20 -> wait -> repeat
total_processed_files = []
total_upload_failures = []
total_processing_failures = []
all_successful_hashes = set()
# Split all files into batches
for batch_num in range(0, len(files_to_upload), batch_size):
batch_files = files_to_upload[batch_num : batch_num + batch_size]
batch_start = batch_num + 1
batch_end = min(batch_num + batch_size, len(files_to_upload))
console.print(
f"\n[bold yellow]=== Batch {batch_start}-{batch_end} of {len(files_to_upload)} ===[/bold yellow]"
)
# Step 1: Upload this batch of files sequentially
console.print(f"[blue]Step 1: Uploading {len(batch_files)} files...[/blue]")
successful_uploads = []
batch_upload_failures = []
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
TimeElapsedColumn(),
TextColumn("{task.fields[postfix]}"),
console=console,
transient=True,
) as progress:
upload_task_id = progress.add_task(
f"Uploading batch {batch_start}-{batch_end}...", total=len(batch_files), postfix=""
)
for file_path, file_hash in batch_files:
server_file_path = await upload_single_file(
client, base_url, db_id, file_path, progress, upload_task_id
)
if server_file_path:
successful_uploads.append((file_path, file_hash, server_file_path))
all_successful_hashes.add(file_hash)
else:
batch_upload_failures.append(file_path)
# Step 2: Process this batch if uploads succeeded
if successful_uploads:
console.print(f"[green]Step 2: Processing {len(successful_uploads)} uploaded files...[/green]")
# Extract server file paths
server_file_paths = [item[2] for item in successful_uploads]
# Submit batch to knowledge base
success, task_id = await add_batch_to_knowledge_base(
client,
base_url,
db_id,
server_file_paths,
enable_ocr=enable_ocr,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
use_qa_split=use_qa_split,
qa_separator=qa_separator,
)
if success:
total_processed_files.extend([item[0] for item in successful_uploads])
# Step 3: Wait for this batch to complete
if wait_for_completion and task_id:
console.print(
f"[cyan]Step 3: Waiting for batch {batch_start}-{batch_end} to complete...[/cyan]"
)
await wait_for_tasks_completion(client, base_url, [task_id], poll_interval)
console.print(f"[green]Batch {batch_start}-{batch_end} completed![/green]")
else:
console.print(f"[green]Batch {batch_start}-{batch_end} submitted successfully![/green]")
else:
total_processing_failures.extend([item[0] for item in successful_uploads])
console.print(f"[red]Batch {batch_start}-{batch_end} processing failed[/red]")
# Record batch failures
total_upload_failures.extend(batch_upload_failures)
# Update processed files record after each batch
if all_successful_hashes:
all_processed_files = processed_files | all_successful_hashes
save_processed_files(record_file, all_processed_files)
# Small delay between batches
if batch_end < len(files_to_upload):
console.print("[dim]Waiting 2 seconds before next batch...[/dim]")
await asyncio.sleep(2)
# Final summary
console.print("\n[bold green]=== All Batches Complete ===[/bold green]")
console.print(f" - [green]Files successfully processed:[/green] {len(total_processed_files)}")
console.print(f" - [red]Upload failures:[/red] {len(total_upload_failures)}")
if total_upload_failures:
for f in total_upload_failures:
console.print(f" - {f}")
console.print(f" - [yellow]Processing failures:[/yellow] {len(total_processing_failures)}")
if total_processing_failures:
for f in total_processing_failures:
console.print(f" - {f}")
asyncio.run(run())
"""
# Example for upload
uv run scripts/batch_upload.py upload \
--db-id your_kb_id \
--directory path/to/your/data \
--pattern "*.docx" --pattern "*.pdf" --pattern "*.html" \
--base-url http://127.0.0.1:5050/api \
--username your_username \
--password your_password \
--batch-size 20 \
--wait-for-completion \
--poll-interval 5 \
--recursive \
--record-file scripts/tmp/batch_processed_files.txt
"""
if __name__ == "__main__":
app()