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 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", ) -> bool: """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 # 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 # 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 # 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 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 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 except httpx.RequestError as e: console.print(f"[bold red]Failed to process {server_file_path}: {e}[/bold red]") return False async def worker( semaphore: asyncio.Semaphore, client: httpx.AsyncClient, base_url: str, db_id: str, file_path: pathlib.Path, file_hash: str, progress: Progress, upload_task_id: int, process_task_id: int, enable_ocr: str = "paddlex_ocr", chunk_size: int = 1000, chunk_overlap: int = 200, use_qa_split: bool = False, qa_separator: str = "\n\n\n", ): """A worker task that uploads and then processes a single file.""" async with semaphore: # 1. Upload file server_file_path = await upload_file(client, base_url, db_id, file_path) progress.update(upload_task_id, advance=1, postfix=f"Uploaded {file_path.name}") if not server_file_path: progress.update(process_task_id, advance=1) # Mark as processed to not hang the progress bar return file_path, file_hash, "upload_failed" # 2. Process file success = await process_document( client, base_url, db_id, server_file_path, enable_ocr=enable_ocr, chunk_size=chunk_size, chunk_overlap=chunk_overlap, use_qa_split=use_qa_split, qa_separator=qa_separator, ) progress.update(process_task_id, advance=1, postfix=f"Processed {file_path.name}") return file_path, file_hash, "success" if success else "processing_failed" 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]") async def convert_to_markdown( client: httpx.AsyncClient, base_url: str, db_id: str, server_file_path: str, ) -> str | None: """Calls the file-to-markdown conversion endpoint.""" try: response = await client.post( f"{base_url}/knowledge/files/markdown", json={"db_id": db_id, "file_path": server_file_path}, timeout=600, # 10 minutes timeout for conversion ) response.raise_for_status() result = response.json() if result.get("status") == "success": return result.get("markdown_content") else: console.print(f"[bold red]Failed to convert {server_file_path}: {result.get('message')}[/bold red]") return None except httpx.HTTPStatusError as e: console.print( f"[bold red]Failed to convert {server_file_path}: {e.response.status_code} - {e.response.text}[/bold red]" ) return None except httpx.RequestError as e: console.print(f"[bold red]Request failed for {server_file_path}: {e}[/bold red]") return None async def trans_worker( semaphore: asyncio.Semaphore, client: httpx.AsyncClient, base_url: str, db_id: str, file_path: pathlib.Path, output_dir: pathlib.Path, progress: Progress, task_id: int, ): """A worker task that uploads a file and converts it to markdown.""" async with semaphore: # 1. Upload file server_file_path = await upload_file(client, base_url, db_id, file_path) if not server_file_path: progress.update(task_id, advance=1, postfix=f"[red]Upload failed for {file_path.name}[/red]") return file_path, "upload_failed" # 2. Convert file to markdown markdown_content = await convert_to_markdown(client, base_url, db_id, server_file_path) if not markdown_content: progress.update(task_id, advance=1, postfix=f"[yellow]Conversion failed for {file_path.name}[/yellow]") return file_path, "conversion_failed" # 3. Save markdown content to output directory try: output_path = output_dir / file_path.with_suffix(".md").name output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(markdown_content) progress.update(task_id, advance=1, postfix=f"[green]Converted {file_path.name}[/green]") return file_path, "success" except OSError as e: console.print(f"[bold red]Error saving markdown for {file_path.name}: {e}[/bold red]") progress.update(task_id, advance=1, postfix=f"[red]Save failed for {file_path.name}[/red]") return file_path, "save_failed" @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: str = typer.Option("*.md", help="The glob pattern for files to upload (e.g., '*.pdf', '**/*.txt')."), 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."), concurrency: int = typer.Option(1, help="The number of concurrent upload/process tasks."), 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 (paddlex_ocr, mineru_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 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 glob_method = directory.rglob if recursive else directory.glob all_files = list(glob_method(pattern)) if not all_files: console.print(f"[bold yellow]No files found in '{directory}' matching '{pattern}'. 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}"} # Setup concurrency and tasks semaphore = asyncio.Semaphore(concurrency) tasks = [] 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("[bold blue]Uploading...", total=len(files_to_upload), postfix="") process_task_id = progress.add_task("[bold cyan]Processing...", total=len(files_to_upload), postfix="") for file_path, file_hash in files_to_upload: task = asyncio.create_task( worker( semaphore, client, base_url, db_id, file_path, file_hash, progress, upload_task_id, process_task_id, enable_ocr=enable_ocr, chunk_size=chunk_size, chunk_overlap=chunk_overlap, use_qa_split=use_qa_split, qa_separator=qa_separator, ) ) tasks.append(task) results = await asyncio.gather(*tasks) # Summarize results and update processed files record successful_files = [] upload_failures = [] processing_failures = [] newly_processed_hashes = set() for file_path, file_hash, status in results: if status == "success": successful_files.append(file_path) newly_processed_hashes.add(file_hash) elif status == "upload_failed": upload_failures.append(file_path) elif status == "processing_failed": processing_failures.append(file_path) # Don't add to processed files if processing failed # Save newly processed files to record if newly_processed_hashes: all_processed_files = processed_files | newly_processed_hashes save_processed_files(record_file, all_processed_files) console.print( f"[bold green]Updated processed files record with " f"{len(newly_processed_hashes)} new entries.[/bold green]" ) console.print("[bold green]Batch operation complete.[/bold green]") console.print(f" - [green]Successful:[/green] {len(successful_files)}") console.print(f" - [red]Upload Failed:[/red] {len(upload_failures)}") if upload_failures: for f in upload_failures: console.print(f" - {f}") console.print(f" - [yellow]Processing Failed:[/yellow] {len(processing_failures)}") if processing_failures: for f in processing_failures: console.print(f" - {f}") asyncio.run(run()) @app.command() def trans( db_id: str = typer.Option(..., help="The ID of the knowledge base (for temporary file upload)."), directory: pathlib.Path = typer.Option( ..., help="The directory containing files to convert.", exists=True, file_okay=False ), output_dir: pathlib.Path = typer.Option("output_markdown", help="The directory to save converted markdown files."), pattern: str = typer.Option("*.docx", help="The glob pattern for files to convert (e.g., '*.pdf', '*.docx')."), 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."), concurrency: int = typer.Option(4, help="The number of concurrent conversion tasks."), recursive: bool = typer.Option(False, "--recursive", "-r", help="Search for files recursively in subdirectories."), ): """ Batch convert files to Markdown format. """ console.print(f"[bold green]Starting batch conversion for files in: {directory}[/bold green]") output_dir.mkdir(parents=True, exist_ok=True) # Discover files glob_method = directory.rglob if recursive else directory.glob files_to_convert = list(glob_method(pattern)) if not files_to_convert: console.print(f"[bold yellow]No files found in '{directory}' matching '{pattern}'. Aborting.[/bold yellow]") raise typer.Exit() # 过滤掉macos的隐藏文件 files_to_convert = [f for f in files_to_convert if not f.name.startswith("._")] console.print(f"Found {len(files_to_convert)} files to convert.") 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}"} # Setup concurrency and tasks semaphore = asyncio.Semaphore(concurrency) tasks = [] 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: task_id = progress.add_task("[bold blue]Converting...", total=len(files_to_convert), postfix="") for file_path in files_to_convert: task = asyncio.create_task( trans_worker(semaphore, client, base_url, db_id, file_path, output_dir, progress, task_id) ) tasks.append(task) results = await asyncio.gather(*tasks) # Summarize results successful_files = [] failed_files = [] for file_path, status in results: if status == "success": successful_files.append(file_path) else: failed_files.append((file_path, status)) console.print("[bold green]Batch conversion complete.[/bold green]") console.print(f" - [green]Successful:[/green] {len(successful_files)}") console.print(f" - [red]Failed:[/red] {len(failed_files)}") if failed_files: for f, status in failed_files: console.print(f" - {f} (Reason: {status})") 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" \ --base-url http://127.0.0.1:5050/api \ --username your_username \ --password your_password \ --concurrency 4 \ --recursive \ --record-file scripts/tmp/batch_processed_files.txt # Example for trans uv run scripts/batch_upload.py trans \ --db-id your_kb_id \ --directory path/to/your/data \ --output-dir path/to/output_markdown \ --pattern "*.docx" \ --base-url http://127.0.0.1:5050/api \ --username your_username \ --password your_password \ --concurrency 4 \ --recursive """ if __name__ == "__main__": app()