diff --git a/.gitignore b/.gitignore index 7186383..72c51d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,21 @@ -# Created by venv; see https://docs.python.org/3/library/venv.html -*/output +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +build/ +dist/ + +# Virtual environment config (contains personal paths) +pyvenv.cfg /lib /include /bin .ropeproject -# Virtual environment config (contains personal paths) -pyvenv.cfg - # Large binaries (PDFs should not be tracked in git) *.pdf # Output directories output/ +*/output diff --git a/IDIOTMethod.txt b/IDIOTMethod.txt index 4076c3f..1d2f13b 100644 --- a/IDIOTMethod.txt +++ b/IDIOTMethod.txt @@ -20,7 +20,7 @@ It's important to always start every project in this order. We should write the * What technologies did that project try to use? * How many people are using this project? * How relevant is this project? - * Based on my learnings, can I use code from on of those project to help me? + * Based on my learnings, can I use code from one of those projects to help me? * Is it open source with a license that is friendly to my project? * What technology is the best for this project? * What frameworks am i considering using and are they commercially friendly? @@ -32,8 +32,8 @@ It's important to always start every project in this order. We should write the ### Questions to answer * Draw the architectural schema for all the classes I plan to create to implement this project - * Describe each of the classes in details with a list of functions with return values that we plan to create -* What techonologies are required to build it? + * Describe each of the classes in detail with a list of functions with return values that we plan to create +* What technologies are required to build it? * What will be the cost of running this given the hardware for the project * How many users are using this system diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eba8c1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jarian Cottingham + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ProjectProposals/Meilisearch UI/proposal.txt b/ProjectProposals/Meilisearch UI/proposal.txt index 571097e..f2ffb25 100644 --- a/ProjectProposals/Meilisearch UI/proposal.txt +++ b/ProjectProposals/Meilisearch UI/proposal.txt @@ -1,5 +1,5 @@ ## Brief Description -I have a local deployment of Mellisearch going. It's job is going to be to provide an alternative to Google for a very wide amount of resources on a local internet. The current resources the internet has are the following. +I have a local deployment of Meilisearch going. It's job is going to be to provide an alternative to Google for a very wide amount of resources on a local internet. The current resources the internet has are the following. - Downloaded Youtube videos (Jellyfin Urls) - MKV Backups of Blurays (jellyfin urls) diff --git a/README.md b/README.md new file mode 100644 index 0000000..b83f8e4 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# MasterMind + +AI-driven project planning. MasterMind feeds a project proposal and a structured +question set through a local LLM (Ollama) in fixed execution phases, producing a +written plan plus per-phase summaries you can review before writing any code. + +It is built around the **IDIOT method** — a project execution framework that breaks +any project into five ordered phases: + +1. **Investigation** — audience, goals, sub-problems, prior art, tech choice +2. **Design** — architecture, required tech, cost, scale +3. **Implementation** — build the design piece by piece +4. **Optimization** — improve without sacrificing quality +5. **Testing** — user-proposed tests, derived test cases, unit/E2E tests +6. **Follow-up** — suggested next steps + +`questions.json` encodes the full question tree for each phase (including nested +sub-questions); `IDIOTMethod.txt` is the human-readable framework document. + +## How it works + +``` +proposal + IDIOT method + system prompt + │ + ▼ +┌──────────────────────────────┐ +│ Investigation (N questions) │ ──► question_001.txt … question_NNN.txt +├──────────────────────────────┤ +│ Design (N questions) │ ──► design_summary.txt +├──────────────────────────────┤ +│ Implementation … Follow-up │ ──► … _summary.txt +└──────────────────────────────┘ + │ + ▼ +experiment_results.json (per-phase index of summary files) +``` + +- Each question is sent to the model with the project proposal, the IDIOT method, + and a **sliding context window** (the last 5 accumulated answers) so context + stays bounded on long runs. +- After every phase, a second model call writes a concise per-phase summary. +- All outputs land in a folder named after your project. + +## Requirements + +- Python 3.10+ +- [Ollama](https://ollama.com) running locally (default: `http://localhost:11434`) +- A model pulled locally, e.g.: + +```bash +ollama pull gpt-oss:20b +``` + +## Quickstart + +```bash +python3 scripts/mastermind_cli.py \ + --project "Meilisearch UI" \ + --proposal "ProjectProposals/Meilisearch UI/proposal.txt" \ + --idiot IDIOTMethod.txt \ + --prompt-file "ProjectProposals/Meilisearch UI/system-prompt.txt" \ + --model gpt-oss:20b +``` + +Outputs appear in `./Meilisearch UI/`. + +## Options + +| Flag | Default | Description | +| --- | --- | --- | +| `--project NAME` | required | Project name; a folder with this name stores the outputs (alphanumeric, `-`, `_` only) | +| `--proposal PATH` | required | Project proposal document (text) | +| `--idiot PATH` | required | IDIOT method document (text) | +| `--prompt-file PATH` | `system_prompt.txt` | System prompt file | +| `--model NAME` | `gpt-oss:20b` | One of `gpt-oss:20b`, `qwen3:30b`, `devstral:24b`, `llama3.3:70b` | +| `--questions-file PATH` | `questions.json` (repo root) | Question tree JSON | +| `--output-dir PATH` | current directory | Where the project folder is created (must be under the current directory) | +| `--ollama-url URL` | `$OLLAMA_URL` or `http://localhost:11434/api/generate` | Ollama endpoint | +| `--api-key KEY` | `$OLLAMA_API_KEY` | Optional bearer token for Ollama | +| `--timeout SECONDS` | `120` | Per-request timeout | + +The question tree supports nested `subQuestions`, which are asked in depth-first +order. Empty questions are skipped. + +## Project layout + +``` +. +├── IDIOTMethod.txt # The IDIOT execution framework +├── questions.json # Phase → question tree +├── ProjectProposals/ +│ └── Meilisearch UI/ # Example proposal + system prompt +├── scripts/ +│ └── mastermind_cli.py # The CLI +└── tests/ + └── test_mastermind_cli.py +``` + +## Testing + +```bash +pip install -e ".[dev]" +pytest tests/ -v +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..d3904de --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +"""Make scripts/ importable for the test suite without installation.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "scripts")) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..80ce3a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "mastermind" +version = "1.0.0" +description = "AI-driven project planning: run a proposal through the IDIOT method with a local LLM (Ollama)" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Jarian Cottingham" }] +keywords = ["ollama", "llm", "planning", "cli"] +classifiers = [ + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.4"] + +[project.scripts] +mastermind = "mastermind_cli:main" + +[tool.setuptools] +package-dir = { "" = "scripts" } +py-modules = ["mastermind_cli"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] + +[tool.ruff.lint.isort] +known-first-party = ["mastermind_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/scripts/mastermind_cli.py b/scripts/mastermind_cli.py index d07e3ff..8fd8eca 100644 --- a/scripts/mastermind_cli.py +++ b/scripts/mastermind_cli.py @@ -37,6 +37,7 @@ import os import pathlib import re import sys +import urllib.request from typing import List logging.basicConfig(level=logging.INFO, format="%(message)s") @@ -72,6 +73,23 @@ def validate_no_traversal(path: pathlib.Path, label: str) -> pathlib.Path: return resolved +def sanitize_phase_name(name: str) -> str: + """Reduce a phase name to a safe filename slug. Prevents path traversal.""" + slug = re.sub(r"[^a-z0-9_-]+", "_", name.strip().lower()).strip("_") + return slug or "phase" + + +def iter_question_texts(questions: List[dict]) -> List[str]: + """Collect question texts recursively, including nested subQuestions.""" + texts: List[str] = [] + for q in questions or []: + text = (q.get("text") or "").strip() + if text: + texts.append(text) + texts.extend(iter_question_texts(q.get("subQuestions") or [])) + return texts + + def read_file(path: pathlib.Path) -> str: """Return the contents of a text file.""" try: @@ -142,11 +160,11 @@ def fetch_response( if api_key: headers["Authorization"] = f"Bearer {api_key}" - req = __import__("urllib.request").request.Request( + req = urllib.request.Request( ollama_url, data=payload, headers=headers, method="POST" ) try: - with __import__("urllib.request").request.urlopen(req, timeout=timeout) as resp: + with urllib.request.urlopen(req, timeout=timeout) as resp: data = resp.read().decode("utf-8") result = json.loads(data) return result.get("response", "").strip() @@ -285,17 +303,13 @@ def main() -> None: for phase in questions: phase_name = phase.get("phase", "Unnamed Phase") - phase_qs = phase.get("questions", []) - logging.info("Phase: %s – %d questions", phase_name, len(phase_qs)) + phase_texts = iter_question_texts(phase.get("questions", [])) + logging.info("Phase: %s – %d questions", phase_name, len(phase_texts)) # Sliding window: only keep last N summaries to bound context growth phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:] - for q in phase_qs: - question_text = q.get("text", "") - if not question_text: - continue # skip empty - + for question_text in phase_texts: logging.info("Q%d: %s", counter, question_text) response = fetch_response( full_system_prompt, @@ -318,12 +332,10 @@ def main() -> None: counter += 1 # After finishing the phase, write a short summary file for this phase - phase_summary_file = ( - project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt" - ) + phase_summary_file = project_dir / f"{sanitize_phase_name(phase_name)}_summary.txt" try: summary = fetch_response( - "Make a clear and concise summary that's no more than 500 tokens on the following input", + "Summarize the following input concisely, in at most 500 tokens", "\n\n".join(accumulated_summaries), [], args.model, diff --git a/tests/test_mastermind_cli.py b/tests/test_mastermind_cli.py new file mode 100644 index 0000000..5fbb032 --- /dev/null +++ b/tests/test_mastermind_cli.py @@ -0,0 +1,195 @@ +"""Tests for mastermind_cli.""" + +import contextlib +import io +import json +import sys +from pathlib import Path + +import pytest + +import mastermind_cli as mm + +# --- validation ------------------------------------------------------------- + + +def test_validate_project_name_valid(): + assert mm.validate_project_name("MyProject") == "MyProject" + assert mm.validate_project_name("abc-123_x") == "abc-123_x" + + +@pytest.mark.parametrize("bad", ["", "a b", "a/b", "../evil", "a.b", "p'x", "caf\u00e9"]) +def test_validate_project_name_invalid(bad): + with pytest.raises(SystemExit): + mm.validate_project_name(bad) + + +def test_validate_no_traversal_inside(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert mm.validate_no_traversal(Path("sub/dir"), "x") == tmp_path / "sub" / "dir" + + +def test_validate_no_traversal_escape(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(SystemExit): + mm.validate_no_traversal(Path("../../outside"), "x") + + +# --- slug + question tree ---------------------------------------------------- + + +def test_sanitize_phase_name(): + assert mm.sanitize_phase_name("Investigation") == "investigation" + assert mm.sanitize_phase_name("Follow up") == "follow_up" + assert mm.sanitize_phase_name("../../evil") == "evil" + assert mm.sanitize_phase_name(" ") == "phase" + assert mm.sanitize_phase_name("a b/c") == "a_b_c" + + +def test_iter_question_texts_nested(): + tree = [ + {"text": "Q1"}, + { + "text": "Q2", + "subQuestions": [ + {"text": "Q2a"}, + {"text": "", "subQuestions": [{"text": "Q2a-1"}]}, + ], + }, + {"text": ""}, + ] + assert mm.iter_question_texts(tree) == ["Q1", "Q2", "Q2a", "Q2a-1"] + + +def test_iter_question_texts_empty(): + assert mm.iter_question_texts(None) == [] + assert mm.iter_question_texts([]) == [] + + +# --- loading ----------------------------------------------------------------- + + +def test_load_questions_ok(tmp_path): + p = tmp_path / "q.json" + p.write_text('[{"phase": "A", "questions": [{"text": "x"}]}]', encoding="utf-8") + assert mm.load_questions(p) == [{"phase": "A", "questions": [{"text": "x"}]}] + + +def test_load_questions_invalid(tmp_path): + p = tmp_path / "q.json" + p.write_text("not json", encoding="utf-8") + with pytest.raises(SystemExit): + mm.load_questions(p) + + +# --- fetch_response ----------------------------------------------------------- + + +class _FakeResp: + def __init__(self, payload: dict): + self.payload = payload + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def _fake_urlopen(exc=None): + captured = {} + + def _urlopen(req, timeout=None): + captured["headers"] = {k.lower(): v for k, v in req.headers.items()} + captured["payload"] = json.loads(req.data.decode("utf-8")) + if exc is not None: + raise exc + return _FakeResp({"response": "hello world"}) + + return _urlopen, captured + + +def test_fetch_response_success(monkeypatch): + _urlopen, captured = _fake_urlopen() + monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen) + out = mm.fetch_response("SYS", "What?", ["CTX"]) + assert out == "hello world" + prompt = captured["payload"]["prompt"] + assert "SYS" in prompt and "What?" in prompt and "CTX" in prompt + assert captured["headers"]["content-type"] == "application/json" + assert "authorization" not in captured["headers"] + + +def test_fetch_response_api_key(monkeypatch): + _urlopen, captured = _fake_urlopen() + monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen) + mm.fetch_response("S", "Q", [], api_key="sekret") + assert captured["headers"]["authorization"] == "Bearer sekret" + + +def test_fetch_response_error_exits(monkeypatch): + _urlopen, _ = _fake_urlopen(exc=OSError("boom")) + monkeypatch.setattr(mm.urllib.request, "urlopen", _urlopen) + with pytest.raises(SystemExit): + mm.fetch_response("S", "Q", []) + + +# --- end to end --------------------------------------------------------------- + +QUESTIONS = [ + { + "phase": "Alpha", + "questions": [ + {"text": "Q1"}, + {"text": "Q2", "subQuestions": [{"text": "Q2a"}]}, + ], + }, + {"phase": "Beta", "questions": [{"text": "Q3"}]}, +] + + +def test_main_end_to_end(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + (tmp_path / "prompt.txt").write_text("SYSTEM", encoding="utf-8") + (tmp_path / "proposal.txt").write_text("PROPOSAL", encoding="utf-8") + (tmp_path / "idiot.txt").write_text("IDIOT", encoding="utf-8") + qfile = tmp_path / "qs.json" + qfile.write_text(json.dumps(QUESTIONS), encoding="utf-8") + + calls = [] + + def fake_fetch(system_prompt, user_prompt, context, *args, **kwargs): + calls.append((user_prompt, context)) + return f"ANSWER-{user_prompt}" + + monkeypatch.setattr(mm, "fetch_response", fake_fetch) + + argv = [ + "mastermind_cli.py", + "--project", "Proj", + "--proposal", "proposal.txt", + "--idiot", "idiot.txt", + "--prompt-file", "prompt.txt", + "--questions-file", str(qfile), + "--output-dir", "out", + ] + monkeypatch.setattr(sys, "argv", argv) + + with contextlib.redirect_stderr(io.StringIO()): + mm.main() + + proj = tmp_path / "out" / "Proj" + assert (proj / "question_001.txt").read_text(encoding="utf-8") == "ANSWER-Q1" + assert (proj / "question_002.txt").read_text(encoding="utf-8") == "ANSWER-Q2" + assert (proj / "question_003.txt").read_text(encoding="utf-8") == "ANSWER-Q2a" + assert (proj / "question_004.txt").read_text(encoding="utf-8") == "ANSWER-Q3" + assert (proj / "alpha_summary.txt").exists() + assert (proj / "beta_summary.txt").exists() + results = json.loads((proj / "experiment_results.json").read_text(encoding="utf-8")) + assert [r["phase"] for r in results] == ["Alpha", "Beta"] + # 4 questions + 2 phase summaries + assert len(calls) == 6