haiku.rag/src/haiku/rag/cli.py
2025-10-30 13:04:25 +02:00

533 lines
14 KiB
Python

import asyncio
import json
import warnings
from importlib.metadata import version
from pathlib import Path
from typing import Any
import typer
from haiku.rag.config import Config
from haiku.rag.logging import configure_cli_logging
from haiku.rag.utils import is_up_to_date
cli = typer.Typer(
context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
)
async def check_version():
"""Check if haiku.rag is up to date and show warning if not."""
up_to_date, current_version, latest_version = await is_up_to_date()
if not up_to_date:
typer.echo(
f"Warning: haiku.rag is outdated. Current: {current_version}, Latest: {latest_version}",
)
typer.echo("Please update.")
def version_callback(value: bool):
if value:
v = version("haiku.rag")
typer.echo(f"haiku.rag version {v}")
raise typer.Exit()
@cli.callback()
def main(
_version: bool = typer.Option(
False,
"-v",
"--version",
callback=version_callback,
help="Show version and exit",
),
config: Path | None = typer.Option(
None,
"--config",
help="Path to YAML configuration file",
),
):
"""haiku.rag CLI - Vector database RAG system"""
# Store config path in environment for config loader to use
if config:
import os
os.environ["HAIKU_RAG_CONFIG_PATH"] = str(config.absolute())
# Configure logging minimally for CLI context
if Config.environment == "development":
# Lazy import logfire only in development
try:
import logfire # type: ignore
logfire.configure(send_to_logfire="if-token-present")
logfire.instrument_pydantic_ai()
except Exception:
pass
else:
configure_cli_logging()
warnings.filterwarnings("ignore")
# Run version check before any command
try:
asyncio.run(check_version())
except Exception:
# Do not block CLI on version check issues
pass
@cli.command("list", help="List all stored documents")
def list_documents(
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.list_documents())
def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]:
"""Parse repeated --meta KEY=VALUE options into a dictionary.
Raises a Typer error if any entry is malformed.
"""
result: dict[str, Any] = {}
if not meta:
return result
for item in meta:
if "=" not in item:
raise typer.BadParameter("--meta must be in KEY=VALUE format")
key, value = item.split("=", 1)
if not key:
raise typer.BadParameter("--meta key cannot be empty")
# Best-effort JSON coercion: numbers, booleans, null, arrays/objects
try:
parsed = json.loads(value)
result[key] = parsed
except Exception:
# Leave as string if not valid JSON literal
result[key] = value
return result
@cli.command("add", help="Add a document from text input")
def add_document_text(
text: str = typer.Argument(
help="The text content of the document to add",
),
meta: list[str] | None = typer.Option(
None,
"--meta",
help="Metadata entries as KEY=VALUE (repeatable)",
metavar="KEY=VALUE",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
metadata = _parse_meta_options(meta)
asyncio.run(app.add_document_from_text(text=text, metadata=metadata or None))
@cli.command("add-src", help="Add a document from a file path, directory, or URL")
def add_document_src(
source: str = typer.Argument(
help="The file path, directory, or URL of the document(s) to add",
),
title: str | None = typer.Option(
None,
"--title",
help="Optional human-readable title to store with the document",
),
meta: list[str] | None = typer.Option(
None,
"--meta",
help="Metadata entries as KEY=VALUE (repeatable)",
metavar="KEY=VALUE",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
metadata = _parse_meta_options(meta)
asyncio.run(
app.add_document_from_source(
source=source, title=title, metadata=metadata or None
)
)
@cli.command("get", help="Get and display a document by its ID")
def get_document(
doc_id: str = typer.Argument(
help="The ID of the document to get",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.get_document(doc_id=doc_id))
@cli.command("delete", help="Delete a document by its ID")
def delete_document(
doc_id: str = typer.Argument(
help="The ID of the document to delete",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.delete_document(doc_id=doc_id))
# Add alias `rm` for delete
cli.command("rm", help="Alias for delete: remove a document by its ID")(delete_document)
@cli.command("search", help="Search for documents by a query")
def search(
query: str = typer.Argument(
help="The search query to use",
),
limit: int = typer.Option(
5,
"--limit",
"-l",
help="Maximum number of results to return",
),
filter: str | None = typer.Option(
None,
"--filter",
"-f",
help="SQL WHERE clause to filter documents (e.g., \"uri LIKE '%arxiv%'\")",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.search(query=query, limit=limit, filter=filter))
@cli.command("ask", help="Ask a question using the QA agent")
def ask(
question: str = typer.Argument(
help="The question to ask",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
cite: bool = typer.Option(
False,
"--cite",
help="Include citations in the response",
),
deep: bool = typer.Option(
False,
"--deep",
help="Use deep multi-agent QA for complex questions",
),
verbose: bool = typer.Option(
False,
"--verbose",
help="Show verbose progress output (only with --deep)",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.ask(question=question, cite=cite, deep=deep, verbose=verbose))
@cli.command("research", help="Run multi-agent research and output a concise report")
def research(
question: str = typer.Argument(
help="The research question to investigate",
),
max_iterations: int = typer.Option(
3,
"--max-iterations",
"-n",
help="Maximum search/analyze iterations",
),
confidence_threshold: float = typer.Option(
0.8,
"--confidence-threshold",
help="Minimum confidence (0-1) to stop",
),
max_concurrency: int = typer.Option(
1,
"--max-concurrency",
help="Max concurrent searches per iteration (planned)",
),
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
verbose: bool = typer.Option(
False,
"--verbose",
help="Show verbose progress output",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(
app.research(
question=question,
max_iterations=max_iterations,
confidence_threshold=confidence_threshold,
max_concurrency=max_concurrency,
verbose=verbose,
)
)
@cli.command("settings", help="Display current configuration settings")
def settings():
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=Path()) # Don't need actual DB for settings
app.show_settings()
@cli.command("init-config", help="Generate a YAML configuration file")
def init_config(
output: Path = typer.Argument(
Path("haiku.rag.yaml"),
help="Output path for the config file",
),
from_env: bool = typer.Option(
False,
"--from-env",
help="Migrate settings from .env file",
),
):
"""Generate a YAML configuration file with defaults or from .env."""
import yaml
from haiku.rag.config.loader import generate_default_config, load_config_from_env
if output.exists():
typer.echo(
f"Error: {output} already exists. Remove it first or choose a different path."
)
raise typer.Exit(1)
if from_env:
# Load from environment variables (including .env if present)
from dotenv import load_dotenv
load_dotenv()
config_data = load_config_from_env()
if not config_data:
typer.echo("Warning: No environment variables found to migrate.")
typer.echo("Generating default configuration instead.")
config_data = generate_default_config()
else:
config_data = generate_default_config()
# Write YAML with comments
with open(output, "w") as f:
f.write("# haiku.rag configuration file\n")
f.write(
"# See https://ggozad.github.io/haiku.rag/configuration/ for details\n\n"
)
yaml.dump(config_data, f, default_flow_style=False, sort_keys=False)
typer.echo(f"Configuration file created: {output}")
typer.echo("Edit the file to customize your settings.")
@cli.command(
"rebuild",
help="Rebuild the database by deleting all chunks and re-indexing all documents",
)
def rebuild(
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.rebuild())
@cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage")
def vacuum(
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.vacuum())
@cli.command("info", help="Show read-only database info (no upgrades or writes)")
def info(
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.info())
@cli.command("download-models", help="Download Docling and Ollama models per config")
def download_models_cmd():
from haiku.rag.utils import prefetch_models
try:
prefetch_models()
typer.echo("Models downloaded successfully.")
except Exception as e:
typer.echo(f"Error downloading models: {e}")
raise typer.Exit(1)
@cli.command(
"serve",
help="Start haiku.rag server. Use --monitor, --mcp, and/or --a2a to enable services.",
)
def serve(
db: Path = typer.Option(
Config.storage.data_dir / "haiku.rag.lancedb",
"--db",
help="Path to the LanceDB database file",
),
monitor: bool = typer.Option(
False,
"--monitor",
help="Enable file monitoring",
),
mcp: bool = typer.Option(
False,
"--mcp",
help="Enable MCP server",
),
stdio: bool = typer.Option(
False,
"--stdio",
help="Run MCP server on stdio Transport (requires --mcp)",
),
mcp_port: int = typer.Option(
8001,
"--mcp-port",
help="Port to bind MCP server to (ignored with --stdio)",
),
a2a: bool = typer.Option(
False,
"--a2a",
help="Enable A2A (Agent-to-Agent) server",
),
a2a_host: str = typer.Option(
"127.0.0.1",
"--a2a-host",
help="Host to bind A2A server to",
),
a2a_port: int = typer.Option(
8000,
"--a2a-port",
help="Port to bind A2A server to",
),
) -> None:
"""Start the server with selected services."""
# Require at least one service flag
if not (monitor or mcp or a2a):
typer.echo(
"Error: At least one service flag (--monitor, --mcp, or --a2a) must be specified"
)
raise typer.Exit(1)
if stdio and not mcp:
typer.echo("Error: --stdio requires --mcp")
raise typer.Exit(1)
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
transport = "stdio" if stdio else None
asyncio.run(
app.serve(
enable_monitor=monitor,
enable_mcp=mcp,
mcp_transport=transport,
mcp_port=mcp_port,
enable_a2a=a2a,
a2a_host=a2a_host,
a2a_port=a2a_port,
)
)
@cli.command(
"a2aclient", help="Run interactive client to chat with haiku.rag's A2A server"
)
def a2aclient(
url: str = typer.Option(
"http://localhost:8000",
"--url",
help="Base URL of the A2A server",
),
):
try:
from haiku.rag.a2a.client import run_interactive_client
except ImportError:
typer.echo(
"Error: A2A support requires the 'a2a' extra. "
"Install with: uv pip install 'haiku.rag[a2a]'"
)
raise typer.Exit(1)
asyncio.run(run_interactive_client(url=url))
if __name__ == "__main__":
cli()