299 lines
11 KiB
Python
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())
|