haiku.rag/src/haiku/rag/cli.py
2025-11-04 16:02:58 +02:00

547 lines
15 KiB
Python

import asyncio
import json
import warnings
from importlib.metadata import version
from pathlib import Path
from typing import Any
import typer
# Load environment variables from .env file before importing Config
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
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",
),
filter: str | None = typer.Option(
None,
"--filter",
"-f",
help="SQL WHERE clause to filter documents (e.g., \"uri LIKE '%arxiv%'\")",
),
):
from haiku.rag.app import HaikuRAGApp
app = HaikuRAGApp(db_path=db)
asyncio.run(app.list_documents(filter=filter))
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()