Thicket/thicket/cli.py

299 lines
11 KiB
Python

"""Command-line interface for Thicket.
Three modes:
- ``--version`` — print the package version and exit.
- ``--dry-run`` — probe the environment (Python modules,
Qdrant service, Ollama service), print a readiness report, exit.
Does NOT launch the GUI and ingests nothing.
- ``--ingest DIR --vault DIR`` — headless batch ingest using the same
core pipeline modules the GUI drives. No Qt is imported on this
path, so it works over SSH / cron.
With no flags, ``main()`` defers to ``ui_window.launch_gui()``.
Heavy imports (``ui_window``, pipeline modules) are deferred into the
branches that need them so that ``--version`` / ``--dry-run`` start
instantly and never touch PySide6.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
"""Build the CLI argument parser."""
parser = argparse.ArgumentParser(
prog="thicket",
description="Super-ingest + RAG console: documents -> Obsidian + Qdrant + LightRAG",
)
parser.add_argument(
"--version", action="store_true",
help="Print version and exit",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Probe environment (modules, Qdrant, Ollama) and print a "
"readiness report — do NOT launch GUI or ingest anything",
)
parser.add_argument(
"--ingest", metavar="DIR",
help="Headless mode: batch-ingest this directory (no GUI)",
)
parser.add_argument(
"--vault", metavar="DIR",
help="Obsidian vault root (required with --ingest)",
)
from .vector_stores import TARGETS as _VECTOR_TARGETS
parser.add_argument(
"--target", default="qdrant",
choices=["obsidian"] + list(_VECTOR_TARGETS),
help="Ingest destination: 'obsidian' = notes only; any vector "
"store key = notes + that store (qdrant default; most run "
"embedded under <vault>/.thicket/; pgvector/mariadb use "
"PG* / MARIADB_* env)",
)
parser.add_argument(
"--host", default="localhost",
help="Service host for service-backed targets (qdrant default "
"6333, weaviate 8080; qdrant target only)",
)
parser.add_argument(
"--port", type=int, default=6333,
help="Service port for service-backed targets (qdrant 6333, "
"weaviate 8080)",
)
parser.add_argument(
"--collection", default="second_brain",
help="Qdrant collection name (default: second_brain)",
)
from .embedder import DEFAULT_EMBED_MODEL
parser.add_argument(
"--embed-model", default=DEFAULT_EMBED_MODEL,
help=f"FastEmbed embedding model (default: {DEFAULT_EMBED_MODEL} — "
"code/config-strong; see thicket.embedder catalog)",
)
parser.add_argument(
"--chunk-size", type=int, default=400,
help="Chunk size in words (default: 400)",
)
parser.add_argument(
"--overlap", type=int, default=50,
help="Chunk overlap in words (default: 50)",
)
parser.add_argument(
"--no-vault", action="store_true",
help="Headless mode: skip the Obsidian vault-note stage "
"(vectors/graph only)",
)
parser.add_argument(
"--no-qdrant", action="store_true",
help="Headless mode: skip the Qdrant vector stage (vault notes only)",
)
parser.add_argument(
"--lightrag", action="store_true",
help="Headless mode: also run the knowledge-graph stage",
)
parser.add_argument(
"--graph-engine", default="lightrag", choices=["lightrag", "graphify"],
help="Knowledge-graph engine (default: lightrag; graphify builds "
"graph.json + HTML report via one local Ollama pass)",
)
parser.add_argument(
"--notes-dir", default="Ingested_Brain",
help="Vault subdirectory for generated notes (default: Ingested_Brain)",
)
parser.add_argument(
"--skip-unchanged", action="store_true",
help="Skip files whose content hash matches the last ingest "
"(manifest under <vault>/.thicket/)",
)
parser.add_argument(
"--max-mb", type=int, default=0,
help="Skip files larger than this many megabytes (default: 0 = no limit)",
)
parser.add_argument(
"--archive", action="store_true",
help="After verified ingest, move each source to the archive dir "
"(default: <input>/../ingested-archive) and bzip2 it — "
"sources are never deleted",
)
parser.add_argument(
"--archive-dir", metavar="DIR",
help="Override the ingested-archive directory (default: "
"sibling of the input dir)",
)
parser.add_argument(
"--minio", action="store_true",
help="Headless mode: archive source documents to MinIO "
"(env: MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET)",
)
parser.add_argument(
"--ask", metavar="QUESTION",
help="Ask a natural-language question over the SQL corpus of the "
"selected target (pgvector / mariadb; Vanna 2 + Ollama) — "
"no GUI, no ingest",
)
parser.add_argument(
"--ollama-llm", default="llama3",
help="Ollama LLM for LightRAG entity extraction (default: llama3)",
)
parser.add_argument(
"--ollama-embed", default="nomic-embed-text",
help="Ollama embedding model for LightRAG (default: nomic-embed-text)",
)
return parser
def run_dry_run(qdrant_host: str, qdrant_port: int) -> int:
"""Probe the environment and print a readiness report."""
from .env_probe import PROBED_MODULES, probe_environment
env = probe_environment(qdrant_host=qdrant_host, qdrant_port=qdrant_port)
def _module_line(name: str) -> str:
tag = "OK" if env.modules.get(name) else "MISSING"
note = " (optional — graph stage)" if name == "lightrag" else ""
return f" {name:14s} {tag}{note}"
def _service_line(name: str, up: bool, detail: str, error: str | None) -> str:
state = f"UP — {detail}" if up else f"DOWN ({error or 'no response'})"
return f"{name}: {state}"
def _ollama_detail() -> str:
shown = env.ollama_models[:12]
extra = len(env.ollama_models) - len(shown)
return ", ".join(shown) + (f" … (+{extra} more)" if extra > 0 else "") \
or "(no models pulled)"
print("=== Thicket environment probe ===")
print(f"Python: {env.python_version}")
print("Modules:")
print("\n".join(_module_line(name) for name in PROBED_MODULES))
print(_service_line("Qdrant", env.qdrant_up,
", ".join(env.qdrant_collections), env.qdrant_error))
print(_service_line("Postgres", env.pg_up, "reachable", env.pg_error))
print(_service_line("MinIO", env.minio_up, "reachable", env.minio_error))
print(_service_line("Ollama", env.ollama_up,
_ollama_detail(), env.ollama_error))
print()
if env.qdrant_ready:
print("READY: vault + Qdrant stages available.")
else:
print("PARTIAL: vault stage available; install '.[ingest]' extras and/or")
print("start Qdrant (docker run -p 6333:6333 qdrant/qdrant) for vectors.")
return 0
def run_headless(args: argparse.Namespace) -> int:
"""Drive the core pipeline without Qt — the GUI's worker logic,
reduced to sequential prints."""
from .pipeline_core import headless_ingest
input_dir = Path(args.ingest).expanduser()
vault_dir = Path(args.vault).expanduser() if args.vault else None
if not input_dir.is_dir():
print(f"Error: input directory '{input_dir}' does not exist.", file=sys.stderr)
return 1
if vault_dir is None:
print("Error: --vault is required with --ingest.", file=sys.stderr)
return 1
if not vault_dir.is_dir():
print(f"Error: vault directory '{vault_dir}' does not exist.", file=sys.stderr)
return 1
try:
ok, fail = headless_ingest(
input_dir=input_dir,
vault_dir=vault_dir,
qdrant_host=args.host,
qdrant_port=args.port,
target=args.target if args.target != "obsidian" else "qdrant",
collection=args.collection,
embed_model=args.embed_model,
chunk_size=args.chunk_size,
overlap=args.overlap,
use_vault=not args.no_vault,
use_qdrant=(args.target != "obsidian") and not args.no_qdrant,
use_lightrag=args.lightrag,
use_minio=args.minio,
graph_engine=args.graph_engine,
notes_dir=args.notes_dir,
skip_unchanged=args.skip_unchanged,
use_fs_archive=args.archive,
archive_dir=Path(args.archive_dir).expanduser()
if args.archive_dir else None,
max_mb=args.max_mb,
ollama_llm=args.ollama_llm,
ollama_embed=args.ollama_embed,
)
except KeyboardInterrupt:
return 130 # POSIX: 128 + SIGINT
print(f"\nDone — {ok} succeeded, {fail} failed.")
return 0 if fail == 0 else 2
def run_ask(args: argparse.Namespace) -> int:
"""Natural-language SQL over the corpus via Vanna + Ollama."""
from .ask_vanna import ask
try:
answer = ask(args.ask, target=args.target, collection=args.collection,
llm_model=args.ollama_llm,
log=lambda msg: print(f"> {msg}"))
except Exception as e: # noqa: BLE001 — surface every failure readably
print(f"ASK ERROR: {e}", file=sys.stderr)
return 1
print(f"\n{answer}")
return 0
def main(argv: list[str] | None = None) -> int:
"""Entry point — backend profiles first, then arg dispatch."""
from .layout import apply_backend_profiles
apply_backend_profiles()
# Step-down by mode: --version, --dry-run, and --ingest each run
# Qt-free and exit; only the no-flag default loads the GUI.
parser = build_parser()
args = parser.parse_args(argv)
if args.version:
from . import __version__
print(f"thicket {__version__}")
return 0
if args.dry_run:
return run_dry_run(args.host, args.port)
if args.ingest:
return run_headless(args)
if args.ask:
return run_ask(args)
# No flag — launch the GUI.
try:
from .ui_window import launch_gui
except ImportError as e:
print(
f"GUI unavailable: {e}\n"
f"Run the console with its environment:\n"
f" thicket (~/.local/bin command)\n"
f" /mnt/AI/runtime/thicket-venv/bin/python thicket.py\n"
f"Or bootstrap it: scripts/bootstrap_sources.sh",
file=sys.stderr,
)
return 1
return launch_gui(argv)
if __name__ == "__main__":
sys.exit(main())