533 lines
14 KiB
Python
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()
|