* fix: improve async batch retain with large payloads * fix: improve async batch retain with large payloads * api * api * api * api * api * Clean up perf benchmark: keep only Python files - Remove README.md and PERFORMANCE_FINDINGS.md - Remove results/ JSON files (gitignored) - Remove test_data/ directory - Keep only __init__.py and retain_perf.py * docs: explain automatic batch optimization for async retain - Add section explaining Hindsight automatically handles batch sizing - Users don't need to manually tune batch sizes with async mode - Hindsight splits large batches (>10k tokens) into optimized sub-batches - Include example showing best practices * docs: remove emojis and code example from performance page * fix: correct OperationDetails type to match API response - Change optional fields to use | null instead of ? - Fixes TypeScript compilation error in control plane build * fix: use discriminated union for OperationDetails type - Support both success and error states properly - Fixes TypeScript error when setting error state * fix: use unique document_ids in batch retain examples - Each item in a batch must have unique document_id - Update both Python and JavaScript examples - Fixes test-doc-examples CI failure * chore: trigger CI * fix: test mocking and duplicate document_ids in examples - Mock _get_pool() in test_async_retain_tags.py to avoid _initialized error - Set _initialized = True on mocked MemoryEngine instances - Fix duplicate document_ids in retain.py and retain.mjs examples * fix: properly mock async pool/connection and fix more duplicate document_ids - Use AsyncMock for pool.acquire() to fix 'can't be used in await' error - Fix duplicate document_ids in retain-async examples (retain.py and retain.mjs) - Remove batch-level document_id parameter that caused duplicates * ci: collect all doc example failures and show summary - Run all Python/Node.js/CLI examples regardless of individual failures - Collect failure list and display summary at the end - Show pass/fail count and list of failed files - Exit with failure only after running all examples * refactor: extract doc example testing to standalone script - Create scripts/test-doc-examples.sh to run all examples - Collects logs of failed examples separately - Shows full error logs only for failures at the end - Clean summary with pass/fail counts - Proper exit codes - Replaces inline bash in CI workflow * fix: doc examples - duplicate document_ids and error handling - retain.py: move document_id to item level to avoid duplicates - documents.mjs: add error handling for getDocument to show clear error message * fix: update tests for duplicate document_id validation - test_async_retain_tags: verify operation structure instead of exact UUID - test_delete_bank: use unique document_ids (team-doc-1, team-doc-2)
456 lines
15 KiB
Python
456 lines
15 KiB
Python
"""
|
|
Retain operation performance benchmark.
|
|
|
|
Measures retain operation performance by:
|
|
1. Loading a document from a file or directory
|
|
2. Sending it to the retain endpoint via HTTP (batched for directories)
|
|
3. Measuring time taken and token usage
|
|
4. Reporting performance metrics
|
|
|
|
Usage:
|
|
# Single file
|
|
uv run python hindsight-dev/benchmarks/perf/retain_perf.py --document <file_path> [options]
|
|
|
|
# Directory (batches all files)
|
|
uv run python hindsight-dev/benchmarks/perf/retain_perf.py --document <dir_path> [options]
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
console = Console()
|
|
|
|
|
|
async def retain_via_memory_engine(
|
|
bank_id: str,
|
|
items: list[dict[str, Any]],
|
|
) -> tuple[float, dict[str, Any]]:
|
|
"""
|
|
Send retain request directly to MemoryEngine (in-memory, no HTTP).
|
|
|
|
Args:
|
|
bank_id: Bank ID to retain into
|
|
items: List of items to retain
|
|
|
|
Returns:
|
|
Tuple of (duration_seconds, response_data)
|
|
"""
|
|
from hindsight_api import MemoryEngine
|
|
from hindsight_api.models import RequestContext
|
|
|
|
# Initialize memory engine
|
|
memory = MemoryEngine(
|
|
db_url=os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0"),
|
|
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
|
|
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
|
|
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-20b"),
|
|
memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None,
|
|
)
|
|
await memory.initialize()
|
|
|
|
# Measure time
|
|
start_time = time.time()
|
|
|
|
try:
|
|
# Call retain_batch_async directly
|
|
result, usage = await memory.retain_batch_async(
|
|
bank_id=bank_id,
|
|
contents=items,
|
|
request_context=RequestContext(),
|
|
return_usage=True,
|
|
)
|
|
|
|
duration = time.time() - start_time
|
|
|
|
# Format response to match HTTP response structure
|
|
response_data = {
|
|
"success": True,
|
|
"bank_id": bank_id,
|
|
"items_count": len(items),
|
|
"async": False,
|
|
"usage": usage.model_dump() if usage else None,
|
|
}
|
|
|
|
return duration, response_data
|
|
finally:
|
|
# Close memory engine connections
|
|
pool = await memory._get_pool()
|
|
await pool.close()
|
|
|
|
|
|
async def retain_via_http(
|
|
base_url: str,
|
|
bank_id: str,
|
|
items: list[dict[str, Any]],
|
|
timeout: float = 300.0,
|
|
) -> tuple[float, dict[str, Any]]:
|
|
"""
|
|
Send retain request via HTTP and measure performance.
|
|
|
|
Args:
|
|
base_url: API base URL (e.g., http://localhost:8000)
|
|
bank_id: Bank ID to retain into
|
|
items: List of items to retain (each with 'content' and optional 'context', 'metadata')
|
|
timeout: Request timeout in seconds
|
|
|
|
Returns:
|
|
Tuple of (duration_seconds, response_data)
|
|
"""
|
|
url = f"{base_url}/v1/default/banks/{bank_id}/memories"
|
|
|
|
payload = {"items": items}
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
# Measure time
|
|
start_time = time.time()
|
|
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.post(url, json=payload, headers=headers)
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
|
|
duration = time.time() - start_time
|
|
|
|
return duration, result
|
|
|
|
|
|
def load_documents(path: str) -> tuple[list[dict[str, Any]], int]:
|
|
"""
|
|
Load document(s) from file or directory.
|
|
|
|
For directories: loads all .json, .txt, and .md files
|
|
For JSON files with 'content' field: extracts content
|
|
For other files: reads entire file as content
|
|
|
|
Returns:
|
|
Tuple of (items_list, total_content_length)
|
|
items_list: List of dicts with 'content' and optional 'metadata'/'context'
|
|
total_content_length: Total character count across all documents
|
|
"""
|
|
file_path = Path(path)
|
|
if not file_path.exists():
|
|
raise FileNotFoundError(f"Path not found: {path}")
|
|
|
|
items = []
|
|
total_length = 0
|
|
|
|
if file_path.is_file():
|
|
# Single file
|
|
content, metadata = _load_single_file(file_path)
|
|
total_length = len(content)
|
|
item = {"content": content}
|
|
if metadata:
|
|
item["metadata"] = metadata
|
|
items.append(item)
|
|
else:
|
|
# Directory - load all supported files
|
|
supported_extensions = {".json", ".txt", ".md"}
|
|
files = [f for f in file_path.rglob("*") if f.is_file() and f.suffix in supported_extensions]
|
|
|
|
if not files:
|
|
raise ValueError(f"No supported files (.json, .txt, .md) found in directory: {path}")
|
|
|
|
console.print(f"Found {len(files)} files in directory")
|
|
|
|
for file in sorted(files):
|
|
try:
|
|
content, metadata = _load_single_file(file)
|
|
total_length += len(content)
|
|
item = {"content": content}
|
|
if metadata:
|
|
item["metadata"] = metadata
|
|
# Add filename as context for batch processing
|
|
item["context"] = f"Source: {file.name}"
|
|
items.append(item)
|
|
except Exception as e:
|
|
console.print(f"[yellow]Warning: Failed to load {file.name}: {e}[/yellow]")
|
|
continue
|
|
|
|
return items, total_length
|
|
|
|
|
|
def _load_single_file(file_path: Path) -> tuple[str, dict[str, Any] | None]:
|
|
"""
|
|
Load a single file and extract content.
|
|
|
|
Returns:
|
|
Tuple of (content, metadata)
|
|
"""
|
|
if file_path.suffix == ".json":
|
|
# Try to parse as JSON and extract 'content' field
|
|
try:
|
|
data = json.loads(file_path.read_text())
|
|
if isinstance(data, dict) and "content" in data:
|
|
# Extract metadata if present
|
|
metadata = data.get("metadata", {})
|
|
# Add doc_id to metadata if present
|
|
if "doc_id" in data:
|
|
metadata["doc_id"] = data["doc_id"]
|
|
return data["content"], metadata if metadata else None
|
|
else:
|
|
# Fallback: use entire JSON as string
|
|
return file_path.read_text(), None
|
|
except json.JSONDecodeError:
|
|
# Not valid JSON, read as text
|
|
return file_path.read_text(), None
|
|
else:
|
|
# Read as plain text
|
|
return file_path.read_text(), None
|
|
|
|
|
|
def display_results(
|
|
duration: float,
|
|
usage: dict[str, int] | None,
|
|
content_length: int,
|
|
bank_id: str,
|
|
num_documents: int,
|
|
) -> None:
|
|
"""Display benchmark results in a formatted table."""
|
|
table = Table(title="Retain Performance Benchmark Results")
|
|
table.add_column("Metric", style="cyan")
|
|
table.add_column("Value", style="green")
|
|
|
|
table.add_row("Bank ID", bank_id)
|
|
table.add_row("Documents", f"{num_documents:,}")
|
|
table.add_row("Total Content Length", f"{content_length:,} chars")
|
|
if num_documents > 1:
|
|
table.add_row("Avg Content/Doc", f"{content_length / num_documents:,.0f} chars")
|
|
table.add_row("", "") # Separator
|
|
table.add_row("Duration", f"{duration:.3f}s")
|
|
table.add_row("Throughput", f"{content_length / duration:,.0f} chars/sec")
|
|
if num_documents > 1:
|
|
table.add_row("Docs/Second", f"{num_documents / duration:.2f}")
|
|
|
|
if usage:
|
|
table.add_row("", "") # Separator
|
|
table.add_row("Input Tokens", f"{usage.get('input_tokens', 0):,}")
|
|
table.add_row("Output Tokens", f"{usage.get('output_tokens', 0):,}")
|
|
table.add_row("Total Tokens", f"{usage.get('total_tokens', 0):,}")
|
|
table.add_row("Tokens/Second", f"{usage.get('total_tokens', 0) / duration:,.1f}")
|
|
if num_documents > 1:
|
|
table.add_row("Avg Tokens/Doc", f"{usage.get('total_tokens', 0) / num_documents:,.0f}")
|
|
else:
|
|
table.add_row("", "") # Separator
|
|
table.add_row("Token Usage", "Not available (async mode or error)")
|
|
|
|
console.print("\n")
|
|
console.print(table)
|
|
|
|
|
|
def save_results(
|
|
output_path: Path,
|
|
duration: float,
|
|
usage: dict[str, int] | None,
|
|
content_length: int,
|
|
bank_id: str,
|
|
document_path: str,
|
|
num_documents: int,
|
|
) -> None:
|
|
"""Save results to JSON file."""
|
|
results = {
|
|
"bank_id": bank_id,
|
|
"document_path": document_path,
|
|
"num_documents": num_documents,
|
|
"content_length": content_length,
|
|
"avg_content_per_doc": content_length / num_documents if num_documents > 0 else 0,
|
|
"duration_seconds": duration,
|
|
"chars_per_second": content_length / duration,
|
|
"docs_per_second": num_documents / duration if num_documents > 0 else 0,
|
|
"usage": usage,
|
|
}
|
|
|
|
if usage:
|
|
results["tokens_per_second"] = usage.get("total_tokens", 0) / duration
|
|
results["avg_tokens_per_doc"] = usage.get("total_tokens", 0) / num_documents if num_documents > 0 else 0
|
|
|
|
with open(output_path, "w") as f:
|
|
json.dump(results, f, indent=2)
|
|
|
|
console.print(f"\n[green]✓[/green] Results saved to {output_path}")
|
|
|
|
|
|
async def main():
|
|
"""Run the retain performance benchmark."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Benchmark retain operation performance",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Benchmark with a single document file
|
|
uv run python hindsight-dev/benchmarks/perf/retain_perf.py \\
|
|
--document ./test_data/large_doc.txt \\
|
|
--bank-id perf-test-001
|
|
|
|
# Benchmark with a directory (batches all files)
|
|
uv run python hindsight-dev/benchmarks/perf/retain_perf.py \\
|
|
--document ~/Documents/my-docs/ \\
|
|
--bank-id perf-test-batch \\
|
|
--output results/batch_perf.json
|
|
|
|
# With custom API URL and save results
|
|
uv run python hindsight-dev/benchmarks/perf/retain_perf.py \\
|
|
--document ./test_data/ \\
|
|
--bank-id perf-test-001 \\
|
|
--api-url http://localhost:8000 \\
|
|
--output results/retain_perf_001.json
|
|
""",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--document",
|
|
required=True,
|
|
help="Path to document file or directory (for directories, batches all .json/.txt/.md files)",
|
|
)
|
|
parser.add_argument(
|
|
"--bank-id",
|
|
default="perf-test",
|
|
help="Bank ID to use (default: perf-test)",
|
|
)
|
|
parser.add_argument(
|
|
"--context",
|
|
help="Optional context for the retain operation (only used for single file mode)",
|
|
)
|
|
parser.add_argument(
|
|
"--api-url",
|
|
default="http://localhost:8000",
|
|
help="API base URL (default: http://localhost:8000)",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=float,
|
|
default=300.0,
|
|
help="Request timeout in seconds (default: 300)",
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
type=Path,
|
|
help="Path to save results JSON (optional)",
|
|
)
|
|
parser.add_argument(
|
|
"--in-memory",
|
|
action="store_true",
|
|
help="Use in-memory MemoryEngine instead of HTTP (bypasses API server, useful for isolating performance)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
console.print("\n[bold cyan]Retain Performance Benchmark[/bold cyan]")
|
|
console.print("=" * 80)
|
|
|
|
# Check mode
|
|
if args.in_memory:
|
|
console.print("\n[cyan]Mode: IN-MEMORY (direct MemoryEngine, no HTTP)[/cyan]")
|
|
else:
|
|
console.print(f"\n[cyan]Mode: HTTP (via {args.api_url})[/cyan]")
|
|
|
|
# Check if server is running (skip for in-memory mode)
|
|
if not args.in_memory:
|
|
console.print(f"\n[1] Checking API server at {args.api_url}...")
|
|
try:
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(f"{args.api_url}/health", timeout=5.0)
|
|
response.raise_for_status()
|
|
console.print(" [green]✓[/green] API server is running")
|
|
except Exception as e:
|
|
console.print(f" [red]✗[/red] API server is not accessible: {e}")
|
|
console.print("\n[yellow]Please ensure the API server is running:[/yellow]")
|
|
console.print(" ./scripts/dev/start-api.sh")
|
|
sys.exit(1)
|
|
|
|
# Load document(s)
|
|
doc_path = Path(args.document)
|
|
if doc_path.is_dir():
|
|
console.print(f"\n[2] Loading documents from directory {args.document}...")
|
|
else:
|
|
console.print(f"\n[2] Loading document from {args.document}...")
|
|
|
|
try:
|
|
items, total_content_length = load_documents(args.document)
|
|
num_docs = len(items)
|
|
|
|
# Add context to single file if provided
|
|
if num_docs == 1 and args.context:
|
|
items[0]["context"] = args.context
|
|
|
|
console.print(
|
|
f" [green]✓[/green] Loaded {num_docs:,} document{'s' if num_docs > 1 else ''} ({total_content_length:,} characters)"
|
|
)
|
|
if num_docs > 1:
|
|
console.print(
|
|
f" [cyan]Average content per document: {total_content_length / num_docs:,.0f} chars[/cyan]"
|
|
)
|
|
except Exception as e:
|
|
console.print(f" [red]✗[/red] Failed to load documents: {e}")
|
|
sys.exit(1)
|
|
|
|
# Run benchmark
|
|
console.print(f"\n[3] {'Processing' if args.in_memory else 'Sending retain request to'} bank '{args.bank_id}'...")
|
|
console.print(f" [cyan]Retaining {num_docs:,} document{'s' if num_docs > 1 else ''} in batch...[/cyan]")
|
|
try:
|
|
if args.in_memory:
|
|
# In-memory mode: call MemoryEngine directly
|
|
duration, result = await retain_via_memory_engine(
|
|
bank_id=args.bank_id,
|
|
items=items,
|
|
)
|
|
else:
|
|
# HTTP mode: call API endpoint
|
|
duration, result = await retain_via_http(
|
|
base_url=args.api_url,
|
|
bank_id=args.bank_id,
|
|
items=items,
|
|
timeout=args.timeout,
|
|
)
|
|
console.print(f" [green]✓[/green] Retain completed in {duration:.3f}s")
|
|
|
|
# Extract usage
|
|
usage = result.get("usage")
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
console.print(f" [red]✗[/red] HTTP error: {e.response.status_code}")
|
|
console.print(f" Response: {e.response.text}")
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
console.print(f" [red]✗[/red] Request failed: {e}")
|
|
sys.exit(1)
|
|
|
|
# Display results
|
|
console.print("\n[4] Results:")
|
|
display_results(
|
|
duration=duration,
|
|
usage=usage,
|
|
content_length=total_content_length,
|
|
bank_id=args.bank_id,
|
|
num_documents=num_docs,
|
|
)
|
|
|
|
# Save results if requested
|
|
if args.output:
|
|
console.print("\n[5] Saving results...")
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
save_results(
|
|
output_path=args.output,
|
|
duration=duration,
|
|
usage=usage,
|
|
content_length=total_content_length,
|
|
bank_id=args.bank_id,
|
|
document_path=args.document,
|
|
num_documents=num_docs,
|
|
)
|
|
|
|
console.print("\n[bold green]✓ Benchmark Complete![/bold green]\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|