diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100644 index 00000000..3b4e8781 --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Seed a disposable demo workspace and launch the local dashboard. + + python -m scripts.demo + python -m scripts.demo --no-open --port 9000 + +The engraphis-demo console entry point is a separate packaging change. +The demo uses a fresh SQLite database inside a temporary directory. It never +reads or writes the database selected by ENGRAPHIS_DB_PATH. The offline +embedder is used for both the seed and the dashboard. +""" + +from __future__ import annotations + +import argparse +import os +import tempfile +from pathlib import Path + +from scripts.start_dashboard import _port, _port_is_available + +DEMO_FACTS = ( + { + "content": "Checkout API uses JWT access tokens.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Legacy checkout token format", + "subject_key": "auth.token_format", + "claim_kind": "decision", + "importance": 0.7, + }, + { + "content": "Checkout API uses PASETO access tokens.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout token format", + "subject_key": "auth.token_format", + "claim_kind": "decision", + "importance": 0.9, + }, + { + "content": "The November auth migration replaced JWT with PASETO to retire the legacy signer.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Auth migration reason", + "subject_key": "auth.migration_reason", + "claim_kind": "decision", + "importance": 0.8, + }, + { + "content": "Checkout frontend packages use pnpm. The lockfile is pnpm-lock.yaml; do not add a package-lock.json.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout package manager", + "subject_key": "repo.package_manager", + "claim_kind": "convention", + "importance": 0.8, + }, + { + "content": "Run `pnpm test --filter checkout` before merging changes that touch the checkout package.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout test command", + "subject_key": "repo.test_command", + "claim_kind": "convention", + "importance": 0.7, + }, + { + "content": "The checkout retry loop must send the same idempotency key on every attempt. A missing key caused duplicate charges in the October incident.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout retry idempotency", + "subject_key": "checkout.idempotency", + "claim_kind": "incident", + "importance": 0.9, + }, + { + "content": "Order events are written to the outbox table in the same transaction as the order. The publisher worker drains the outbox.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Checkout event delivery", + "subject_key": "checkout.event_delivery", + "claim_kind": "architecture", + "importance": 0.8, + }, + { + "content": "Schema migrations require a verified backup and a rollback note before the migration runs.", + "workspace": "acme-api", + "repo": "checkout", + "title": "Migration policy", + "subject_key": "db.migration_policy", + "claim_kind": "convention", + "importance": 0.8, + }, + { + "content": "Stripe webhook handlers must be idempotent by event id. Store the processed event id before applying the change.", + "workspace": "acme-api", + "repo": "billing", + "title": "Billing webhook idempotency", + "subject_key": "billing.webhook_idempotency", + "claim_kind": "convention", + "importance": 0.9, + }, + { + "content": "The invoice reconciliation job double-counted prorations when a subscription changed mid-cycle. The fix reads the proration line items once and keys the result by invoice id.", + "workspace": "acme-api", + "repo": "billing", + "title": "Proration reconciliation bug", + "subject_key": "billing.proration_reconciliation", + "claim_kind": "incident", + "importance": 0.8, + }, + { + "content": "Dashboard components are server components by default. Add `use client` only at interactive leaves.", + "workspace": "acme-web", + "repo": "dashboard", + "title": "Dashboard component default", + "subject_key": "web.component_default", + "claim_kind": "convention", + "importance": 0.8, + }, + { + "content": "Dashboard feature flags are read at request time, not baked in at build time.", + "workspace": "acme-web", + "repo": "dashboard", + "title": "Dashboard feature flags", + "subject_key": "web.feature_flags", + "claim_kind": "decision", + "importance": 0.7, + }, + { + "content": "Use the design tokens in `styles/tokens.css`. Do not hardcode hex colors in dashboard components.", + "workspace": "acme-web", + "repo": "dashboard", + "title": "Dashboard styling tokens", + "subject_key": "web.styling", + "claim_kind": "convention", + "importance": 0.7, + }, +) + + +def seed_demo(service) -> list[dict]: + """Write the demo facts through the public MemoryService API.""" + return [service.remember(**fact) for fact in DEMO_FACTS] + + +def create_demo_database(db_path: str | Path) -> tuple[list[dict], dict]: + """Seed a new demo database and return its write results and sample recall.""" + path = Path(db_path).expanduser().resolve() + if path.exists() and path.stat().st_size > 0: + raise SystemExit( + f"Refusing to overwrite the existing database at {path}. Choose a new demo path." + ) + for suffix in ("-wal", "-shm"): + companion = Path(str(path) + suffix) + if companion.exists(): + raise SystemExit(f"Refusing to use {path}: the companion file {companion} exists.") + path.parent.mkdir(parents=True, exist_ok=True) + + previous_embed = os.environ.get("ENGRAPHIS_EMBED_MODEL") + os.environ["ENGRAPHIS_EMBED_MODEL"] = "" + + service = None + try: + from engraphis.service import MemoryService + + service = MemoryService.create( + str(path), + embed_model="", + extractor="none", + graph_extractor="regex", + retention_supervisor="none", + ) + results = seed_demo(service) + sample = service.recall( + "What token format does the checkout API use?", + workspace="acme-api", + repo="checkout", + k=3, + ) + finally: + if service is not None: + service.close() + if previous_embed is None: + os.environ.pop("ENGRAPHIS_EMBED_MODEL", None) + else: + os.environ["ENGRAPHIS_EMBED_MODEL"] = previous_embed + return results, sample + + +def main(argv=None) -> None: + ap = argparse.ArgumentParser( + description="Seed a disposable Engraphis demo workspace and start the dashboard.", + ) + ap.add_argument( + "--host", + default=os.environ.get("ENGRAPHIS_HOST", "127.0.0.1"), + help="Bind host (default: $ENGRAPHIS_HOST, else 127.0.0.1).", + ) + ap.add_argument( + "--port", + type=_port, + default=(os.environ.get("PORT") or os.environ.get("ENGRAPHIS_PORT", "8700")), + help="Bind port (default: $PORT, else $ENGRAPHIS_PORT, else 8700).", + ) + ap.add_argument("--no-open", action="store_true", help="Do not open the browser.") + args = ap.parse_args(argv) + + if not _port_is_available(args.host, args.port): + ap.exit( + 1, + f"Port {args.port} is already in use. Choose another with --port; " + "the demo never reuses an existing dashboard.\n", + ) + + with tempfile.TemporaryDirectory(prefix="engraphis-demo-") as demo_dir: + db_path = Path(demo_dir) / "demo.db" + print( + "Creating a disposable demo database. Your existing Engraphis database is not modified." + ) + print( + "If an empty-database warning appears, it refers to the new demo file, not your real database." + ) + results, sample = create_demo_database(db_path) + + os.environ["ENGRAPHIS_DB_PATH"] = str(db_path) + os.environ["ENGRAPHIS_EMBED_MODEL"] = "" + from engraphis import config as _config + + _config.settings.db_path = str(db_path) + _config.settings.embed_model = "" + if Path(_config.settings.db_path).resolve() != db_path: + raise SystemExit( + "Refusing to start: the dashboard settings did not bind to the demo database." + ) + + superseded = sum(len(result.get("superseded") or []) for result in results) + print("Engraphis demo workspace") + print(f" Database : {db_path}") + print(f" Memories : {len(results)}") + print(f" Superseded : {superseded}") + print(" Sample recall:") + for line in str(sample.get("context") or "").splitlines(): + print(f" {line}") + print() + + from scripts.start_dashboard import main as dashboard_main + + forwarded = ["--host", args.host, "--port", str(args.port)] + if args.no_open: + forwarded.append("--no-open") + dashboard_main(forwarded) + + +if __name__ == "__main__": + main() diff --git a/tests/test_demo_seed.py b/tests/test_demo_seed.py new file mode 100644 index 00000000..d76b299e --- /dev/null +++ b/tests/test_demo_seed.py @@ -0,0 +1,109 @@ +"""The demo seed must show a live supersession, a cited recall, and safe paths.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from engraphis.service import MemoryService +from scripts.demo import DEMO_FACTS, create_demo_database, main, seed_demo + + +def test_seed_demo_supersedes_the_old_auth_fact_and_recalls_the_new_one(): + service = MemoryService.create( + ":memory:", + embed_model="", + extractor="none", + graph_extractor="regex", + retention_supervisor="none", + ) + try: + results = seed_demo(service) + assert len(results) == len(DEMO_FACTS) + + old = next( + result + for result, fact in zip(results, DEMO_FACTS) + if fact["content"] == "Checkout API uses JWT access tokens." + ) + new = next( + result + for result, fact in zip(results, DEMO_FACTS) + if fact["content"] == "Checkout API uses PASETO access tokens." + ) + assert old["id"] in new["superseded"] + + recall = service.recall( + "What token format does the checkout API use?", + workspace="acme-api", + repo="checkout", + k=5, + ) + context = recall["context"] + assert "PASETO" in context + assert "Checkout API uses JWT access tokens." not in context + + package_manager = service.recall( + "Which package manager should the checkout frontend use?", + workspace="acme-api", + repo="checkout", + k=5, + ) + assert "pnpm" in package_manager["context"] + finally: + service.close() + + +def test_create_demo_database_seeds_a_file_backed_store(tmp_path): + db_path = tmp_path / "demo.db" + results, sample = create_demo_database(db_path) + + assert db_path.is_file() + assert len(results) == len(DEMO_FACTS) + assert sum(len(result.get("superseded") or []) for result in results) == 1 + assert "PASETO" in sample["context"] + + +def test_create_demo_database_refuses_an_existing_database(tmp_path): + db_path = tmp_path / "demo.db" + db_path.write_bytes(b"existing database") + + with pytest.raises(SystemExit, match="Refusing to overwrite"): + create_demo_database(db_path) + + +def test_create_demo_database_refuses_a_wal_companion(tmp_path): + db_path = tmp_path / "demo.db" + Path(str(db_path) + "-wal").write_bytes(b"wal") + + with pytest.raises(SystemExit, match="companion"): + create_demo_database(db_path) + + +def test_create_demo_database_restores_environment(tmp_path, monkeypatch): + monkeypatch.setenv("ENGRAPHIS_EMBED_MODEL", "keep-me") + + create_demo_database(tmp_path / "demo.db") + + assert os.environ["ENGRAPHIS_EMBED_MODEL"] == "keep-me" + + +def test_main_help_exits_zero_without_creating_a_database(monkeypatch): + for name in ("ENGRAPHIS_EMBED_MODEL", "ENGRAPHIS_DB_PATH"): + monkeypatch.delenv(name, raising=False) + + with pytest.raises(SystemExit) as exc: + main(["--help"]) + + assert exc.value.code == 0 + assert "ENGRAPHIS_DB_PATH" not in os.environ + + +def test_demo_facts_stay_in_the_two_demo_workspaces(): + workspaces = {fact["workspace"] for fact in DEMO_FACTS} + repos = {fact["repo"] for fact in DEMO_FACTS} + assert workspaces == {"acme-api", "acme-web"} + assert repos == {"checkout", "billing", "dashboard"} + assert len({fact["subject_key"] for fact in DEMO_FACTS}) == len(DEMO_FACTS) - 1