From 2bf8145b75a4d22a385e623f316923565fcd8516 Mon Sep 17 00:00:00 2001 From: John McChesney TenEyck Jr Date: Sat, 23 May 2026 16:58:05 +0100 Subject: [PATCH] Improve fixture-mode first run flow --- README.md | 4 + docs/quickstart.md | 128 ++++++++++++++++++++++++++ src/mailplus_intelligence/__init__.py | 8 ++ src/mailplus_intelligence/cli.py | 120 +++++++++++++++++++++--- src/mailplus_intelligence/doctor.py | 31 ++++++- tests/test_cli.py | 55 +++++++++++ 6 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 docs/quickstart.md diff --git a/README.md b/README.md index de5de9d..236f269 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,10 @@ This repo is targeting the **medium** architecture first: This gives high value without memory bloat or premature overbuilding. +Start with the [fixture-mode quickstart](docs/quickstart.md) to seed a local +database, search fixture metadata, review extraction candidates, and run a +dry-run export without live MailPlus credentials. + ## Runtime baseline M0 uses a Python 3.12 package with SQLite-friendly local foundations. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..73d023d --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,128 @@ +# Quickstart + +This walkthrough uses the synthetic fixture corpus only. It does not connect to +live MailPlus, IMAP, wiki, memory, or reminder surfaces. + +## Setup + +```bash +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +``` + +Confirm the local fixture-mode environment: + +```bash +mpi doctor +``` + +Expected shape: + +```text +MailPlus Intelligence fixture doctor +- ok: runtime: python 3.12; expected >=3.12 +- ok: storage: selected storage engine: sqlite +- ok: manifest: project.bootstrap.yaml present +- ok: fixtures: loaded metadata fixture corpus with 8 messages +- ok: schema: metadata schema user_version=1 +- gated: live-mailplus: live MailPlus credentials intentionally unavailable in fixture mode +result: ok +``` + +For machine-readable output, run: + +```bash +mpi doctor --json +``` + +The JSON response includes `ok` and a `checks` array. Each check has `name`, +`status`, `message`, and an optional `next_step`. + +## Seed A Local Database + +Use a file-backed database so queue decisions and sync state persist: + +```bash +mpi --db ./mpi.db seed --from-fixtures fixtures/mailplus_metadata +``` + +Expected shape: + +```text +Seeded fixture corpus: inserted=8, skipped=0, queued=4, queue_skipped=0. +``` + +Re-running the command is safe; indexed messages and deterministic queue items +are skipped when they already exist. + +## Search And Inspect + +```bash +mpi --db ./mpi.db search --keyword Atlas +``` + +Expected shape: + +```text +2026-01-05T15:02:00Z Re: Project Atlas kickoff + locator: fixture-export-003 / uid=1002 +``` + +Inspect the reconstructed thread: + +```bash +mpi --db ./mpi.db thread thread-a +``` + +Expected shape: + +```text +Thread: thread-a (3 messages) + 2026-01-05T15:02:00Z Re: Project Atlas kickoff +``` + +## Review Queue + +List candidates: + +```bash +mpi --db ./mpi.db queue list +``` + +Expected shape: + +```text +[candidate] thread_summary thread-a +``` + +Inspect one artifact: + +```bash +mpi --db ./mpi.db queue inspect +``` + +Approve one artifact: + +```bash +mpi --db ./mpi.db queue approve --notes "Looks correct from fixture metadata" +``` + +## Dry-Run Export + +Export approved or corrected candidates into inspectable files: + +```bash +mpi --db ./mpi.db export --output ./out +``` + +Expected shape: + +```text +Dry-run export: 1 artifact(s) -> out + memory/thread-summaries/.md +``` + +Production writes to wiki, `memory/`, and reminders are not enabled in v0.1. +Review the generated files and `out/export-manifest.json` before any future live +promotion work. diff --git a/src/mailplus_intelligence/__init__.py b/src/mailplus_intelligence/__init__.py index bd802f3..afa74fe 100644 --- a/src/mailplus_intelligence/__init__.py +++ b/src/mailplus_intelligence/__init__.py @@ -1,16 +1,24 @@ """MailPlus Intelligence runtime foundations.""" +from importlib.metadata import PackageNotFoundError, version + from .fixtures import MetadataFixtureCorpus, load_metadata_fixture_corpus from .runtime import RuntimeProfile, default_runtime_profile from .schema import apply_all_migrations, apply_schema_v0, current_schema_version from .sqlite import connect_sqlite from .suppression import SUPPRESSION_FAMILIES, SuppressionDecision, classify_noise_suppression +try: + __version__ = version("mailplus-intelligence") +except PackageNotFoundError: + __version__ = "0.1.0+source" + __all__ = [ "MetadataFixtureCorpus", "RuntimeProfile", "SUPPRESSION_FAMILIES", "SuppressionDecision", + "__version__", "apply_all_migrations", "apply_schema_v0", "classify_noise_suppression", diff --git a/src/mailplus_intelligence/cli.py b/src/mailplus_intelligence/cli.py index e625827..6f94873 100644 --- a/src/mailplus_intelligence/cli.py +++ b/src/mailplus_intelligence/cli.py @@ -4,6 +4,7 @@ import argparse import json +import sqlite3 import sys from pathlib import Path @@ -21,6 +22,35 @@ def _setup_db(db_path: str): return conn +def _warn_if_ephemeral_db(args: argparse.Namespace) -> None: + if getattr(args, "db", ":memory:") != ":memory:": + return + if getattr(args, "command", None) in {"search", "thread", "queue", "export", "sync", "seed"}: + print( + "warning: --db :memory: does not persist approvals, queue decisions, or sync state; use --db ./mpi.db.", + file=sys.stderr, + ) + + +def _runtime_configuration_errors() -> tuple[type[BaseException], ...]: + errors: list[type[BaseException]] = [] + try: + from .live_adapter import LiveAdapterNotConfigured + + errors.append(LiveAdapterNotConfigured) + except ImportError: + pass + + try: + from .llm_extractor import LLMNotAvailable # type: ignore[attr-defined] + + errors.append(LLMNotAvailable) + except (ImportError, AttributeError): + pass + + return tuple(errors) + + # ── search ──────────────────────────────────────────────────────────────────── def cmd_search(args: argparse.Namespace) -> int: @@ -156,6 +186,41 @@ def cmd_export(args: argparse.Namespace) -> int: return 0 +# ── seed ────────────────────────────────────────────────────────────────────── + +def cmd_seed(args: argparse.Namespace) -> int: + from .extractor import extract_from_corpus + from .fixtures import load_metadata_fixture_corpus + from .queue import enqueue_candidate + from .sync import sync_from_fixture_corpus + from .threading import reconstruct_fixture_threads + + conn = _setup_db(args.db) + try: + result = sync_from_fixture_corpus(conn, args.from_fixtures) + corpus = load_metadata_fixture_corpus(args.from_fixtures) + threads = reconstruct_fixture_threads(corpus.messages) + candidates = extract_from_corpus(threads, corpus.messages) + + queued = 0 + skipped = 0 + for candidate in candidates: + try: + enqueue_candidate(conn, candidate.__dict__) + queued += 1 + except sqlite3.IntegrityError: + skipped += 1 + finally: + conn.close() + + print( + "Seeded fixture corpus: " + f"inserted={result.inserted}, skipped={result.skipped}, " + f"queued={queued}, queue_skipped={skipped}." + ) + return 0 if result.success else 1 + + # ── sync ───────────────────────────────────────────────────────────────────── @@ -225,12 +290,15 @@ def cmd_doctor(args: argparse.Namespace) -> int: # ── parser ──────────────────────────────────────────────────────────────────── def build_parser() -> argparse.ArgumentParser: + from . import __version__ + parser = argparse.ArgumentParser( prog="mpi", description="MailPlus Intelligence operator CLI", ) parser.add_argument("--db", default=":memory:", help="Path to SQLite database (default: :memory:)") parser.add_argument("--json", action="store_true", help="Output as JSON") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") sub = parser.add_subparsers(dest="command") # search @@ -273,6 +341,15 @@ def build_parser() -> argparse.ArgumentParser: ep = sub.add_parser("export", help="Dry-run export of approved candidates") ep.add_argument("--output", default="./export-artifacts", help="Output directory") + # seed + seedp = sub.add_parser("seed", help="Seed a local DB from fixture metadata") + seedp.add_argument( + "--from-fixtures", + dest="from_fixtures", + default="fixtures/mailplus_metadata", + help="Fixture corpus directory", + ) + # sync syp = sub.add_parser("sync", help="Sync job status and checkpoint inspection") sya = syp.add_subparsers(dest="sync_action") @@ -291,22 +368,35 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) + _warn_if_ephemeral_db(args) - if args.command == "search": - return cmd_search(args) - elif args.command == "thread": - return cmd_thread(args) - elif args.command == "queue": - return cmd_queue(args) - elif args.command == "export": - return cmd_export(args) - elif args.command == "doctor": - return cmd_doctor(args) - elif args.command == "sync": - return cmd_sync(args) - else: - parser.print_help() - return 1 + try: + if args.command == "search": + return cmd_search(args) + elif args.command == "thread": + return cmd_thread(args) + elif args.command == "queue": + return cmd_queue(args) + elif args.command == "export": + return cmd_export(args) + elif args.command == "seed": + return cmd_seed(args) + elif args.command == "doctor": + return cmd_doctor(args) + elif args.command == "sync": + return cmd_sync(args) + else: + parser.print_help() + return 1 + except FileNotFoundError as exc: + print(f"error: file not found: {exc}. Check the fixture path or database parent directory.", file=sys.stderr) + return 2 + except sqlite3.OperationalError as exc: + print(f"error: sqlite operation failed: {exc}. Check that the database parent directory exists.", file=sys.stderr) + return 2 + except _runtime_configuration_errors() as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 if __name__ == "__main__": diff --git a/src/mailplus_intelligence/doctor.py b/src/mailplus_intelligence/doctor.py index 4e27ad5..768cdbb 100644 --- a/src/mailplus_intelligence/doctor.py +++ b/src/mailplus_intelligence/doctor.py @@ -20,6 +20,7 @@ class DoctorCheck: name: str status: str message: str + next_step: str | None = None @dataclass(frozen=True) @@ -45,6 +46,7 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: "runtime", "ok" if sys.version_info >= (3, 12) else "fail", f"python {sys.version_info.major}.{sys.version_info.minor}; expected >=3.12", + None if sys.version_info >= (3, 12) else "Install Python 3.12 or newer.", ) ) checks.append( @@ -52,6 +54,7 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: "storage", "ok" if profile.storage_engine == "sqlite" else "fail", f"selected storage engine: {profile.storage_engine}", + None if profile.storage_engine == "sqlite" else "Use the default SQLite runtime profile.", ) ) @@ -61,6 +64,7 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: "manifest", "ok" if manifest.exists() else "fail", "project.bootstrap.yaml present" if manifest.exists() else "missing project.bootstrap.yaml", + None if manifest.exists() else "Run doctor from the repository root.", ) ) @@ -75,7 +79,14 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: ) ) except Exception as exc: - checks.append(DoctorCheck("fixtures", "fail", f"fixture corpus unavailable: {exc}")) + checks.append( + DoctorCheck( + "fixtures", + "fail", + f"fixture corpus unavailable: {exc}", + "Confirm fixtures/mailplus_metadata exists or restore the fixture corpus.", + ) + ) try: connection = connect_sqlite() @@ -91,9 +102,16 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: finally: connection.close() except Exception as exc: - checks.append(DoctorCheck("schema", "fail", f"schema bootstrap failed: {exc}")) + checks.append( + DoctorCheck( + "schema", + "fail", + f"schema bootstrap failed: {exc}", + "Check SQLite availability and repository migrations.", + ) + ) - live_keys = ("MAILPLUS_URL", "MAILPLUS_USERNAME", "MAILPLUS_PASSWORD") + live_keys = ("MAILPLUS_HOST", "MAILPLUS_USER", "MAILPLUS_TOKEN") missing_live_keys = [key for key in live_keys if not os.environ.get(key)] checks.append( DoctorCheck( @@ -104,6 +122,11 @@ def run_fixture_doctor(project_root: str | Path = ".") -> DoctorReport: if missing_live_keys else "live MailPlus credential environment is present" ), + ( + "Set MAILPLUS_HOST, MAILPLUS_USER, and MAILPLUS_TOKEN only when testing live access." + if missing_live_keys + else None + ), ) ) @@ -116,6 +139,8 @@ def format_doctor_report(report: DoctorReport) -> str: lines = ["MailPlus Intelligence fixture doctor"] for check in report.checks: lines.append(f"- {check.status}: {check.name}: {check.message}") + if check.next_step: + lines.append(f" next: {check.next_step}") lines.append(f"result: {'ok' if report.ok else 'failed'}") return "\n".join(lines) diff --git a/tests/test_cli.py b/tests/test_cli.py index bf00a77..eb67852 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,8 @@ from __future__ import annotations +import contextlib +import io import json import tempfile import unittest @@ -42,6 +44,21 @@ def test_no_subcommand_returns_nonzero(self): rc = main([]) self.assertEqual(rc, 1) + def test_version_option_prints_package_version(self): + parser = build_parser() + buf = io.StringIO() + with contextlib.redirect_stdout(buf), self.assertRaises(SystemExit) as raised: + parser.parse_args(["--version"]) + self.assertEqual(raised.exception.code, 0) + self.assertIn("mpi ", buf.getvalue()) + + def test_memory_db_warning_is_actionable(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["search", "--keyword", "Atlas"]) + self.assertEqual(rc, 0) + self.assertIn("--db :memory: does not persist", err.getvalue()) + class CLISearchTests(unittest.TestCase): def setUp(self): @@ -107,5 +124,43 @@ def test_export_no_approved_candidates(self): self.assertEqual(rc, 0) +class CLISeedTests(unittest.TestCase): + def test_seed_populates_search_and_queue(self): + with tempfile.TemporaryDirectory() as tmp: + db_path = str(Path(tmp) / "mpi.db") + rc = main(["--db", db_path, "seed", "--from-fixtures", "fixtures/mailplus_metadata"]) + self.assertEqual(rc, 0) + + out = io.StringIO() + with contextlib.redirect_stdout(out): + search_rc = main(["--db", db_path, "--json", "search", "--keyword", "Atlas"]) + self.assertEqual(search_rc, 0) + self.assertGreater(len(json.loads(out.getvalue())), 0) + + queue_out = io.StringIO() + with contextlib.redirect_stdout(queue_out): + queue_rc = main(["--db", db_path, "queue", "list"]) + self.assertEqual(queue_rc, 0) + self.assertIn("[candidate]", queue_out.getvalue()) + + def test_seed_missing_fixture_path_prints_friendly_error(self): + with tempfile.TemporaryDirectory() as tmp: + db_path = str(Path(tmp) / "mpi.db") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["--db", db_path, "seed", "--from-fixtures", "missing-fixtures"]) + self.assertEqual(rc, 2) + self.assertIn("file not found", err.getvalue()) + + def test_missing_database_parent_prints_friendly_error(self): + with tempfile.TemporaryDirectory() as tmp: + db_path = str(Path(tmp) / "missing-parent" / "mpi.db") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["--db", db_path, "search", "--keyword", "Atlas"]) + self.assertEqual(rc, 2) + self.assertIn("database parent directory", err.getvalue()) + + if __name__ == "__main__": unittest.main()