Merge pull request 'improve: README, LICENSE, pyproject, tests, sub-question traversal fix' (#21) from improve/v1 into main
Reviewed-on: https://git.example.com/jarianc/MasterMind/pulls/21
This commit is contained in:
commit
18978e6e95
16
.gitignore
vendored
16
.gitignore
vendored
@ -1,15 +1,21 @@
|
|||||||
# Created by venv; see https://docs.python.org/3/library/venv.html
|
# Python
|
||||||
*/output
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Virtual environment config (contains personal paths)
|
||||||
|
pyvenv.cfg
|
||||||
/lib
|
/lib
|
||||||
/include
|
/include
|
||||||
/bin
|
/bin
|
||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
# Virtual environment config (contains personal paths)
|
|
||||||
pyvenv.cfg
|
|
||||||
|
|
||||||
# Large binaries (PDFs should not be tracked in git)
|
# Large binaries (PDFs should not be tracked in git)
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
# Output directories
|
# Output directories
|
||||||
output/
|
output/
|
||||||
|
*/output
|
||||||
|
|||||||
@ -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?
|
* What technologies did that project try to use?
|
||||||
* How many people are using this project?
|
* How many people are using this project?
|
||||||
* How relevant is 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?
|
* Is it open source with a license that is friendly to my project?
|
||||||
* What technology is the best for this project?
|
* What technology is the best for this project?
|
||||||
* What frameworks am i considering using and are they commercially friendly?
|
* 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
|
### Questions to answer
|
||||||
* Draw the architectural schema for all the classes I plan to create to implement this project
|
* 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
|
* Describe each of the classes in detail with a list of functions with return values that we plan to create
|
||||||
* What techonologies are required to build it?
|
* What technologies are required to build it?
|
||||||
* What will be the cost of running this given the hardware for the project
|
* What will be the cost of running this given the hardware for the project
|
||||||
* How many users are using this system
|
* How many users are using this system
|
||||||
|
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
||||||
@ -1,5 +1,5 @@
|
|||||||
## Brief Description
|
## 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)
|
- Downloaded Youtube videos (Jellyfin Urls)
|
||||||
- MKV Backups of Blurays (jellyfin urls)
|
- MKV Backups of Blurays (jellyfin urls)
|
||||||
|
|||||||
108
README.md
Normal file
108
README.md
Normal file
@ -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).
|
||||||
6
conftest.py
Normal file
6
conftest.py
Normal file
@ -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"))
|
||||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@ -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"]
|
||||||
@ -37,6 +37,7 @@ import os
|
|||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.request
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
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
|
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:
|
def read_file(path: pathlib.Path) -> str:
|
||||||
"""Return the contents of a text file."""
|
"""Return the contents of a text file."""
|
||||||
try:
|
try:
|
||||||
@ -142,11 +160,11 @@ def fetch_response(
|
|||||||
if api_key:
|
if api_key:
|
||||||
headers["Authorization"] = f"Bearer {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"
|
ollama_url, data=payload, headers=headers, method="POST"
|
||||||
)
|
)
|
||||||
try:
|
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")
|
data = resp.read().decode("utf-8")
|
||||||
result = json.loads(data)
|
result = json.loads(data)
|
||||||
return result.get("response", "").strip()
|
return result.get("response", "").strip()
|
||||||
@ -285,17 +303,13 @@ def main() -> None:
|
|||||||
|
|
||||||
for phase in questions:
|
for phase in questions:
|
||||||
phase_name = phase.get("phase", "Unnamed Phase")
|
phase_name = phase.get("phase", "Unnamed Phase")
|
||||||
phase_qs = phase.get("questions", [])
|
phase_texts = iter_question_texts(phase.get("questions", []))
|
||||||
logging.info("Phase: %s – %d questions", phase_name, len(phase_qs))
|
logging.info("Phase: %s – %d questions", phase_name, len(phase_texts))
|
||||||
|
|
||||||
# Sliding window: only keep last N summaries to bound context growth
|
# Sliding window: only keep last N summaries to bound context growth
|
||||||
phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:]
|
phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:]
|
||||||
|
|
||||||
for q in phase_qs:
|
for question_text in phase_texts:
|
||||||
question_text = q.get("text", "")
|
|
||||||
if not question_text:
|
|
||||||
continue # skip empty
|
|
||||||
|
|
||||||
logging.info("Q%d: %s", counter, question_text)
|
logging.info("Q%d: %s", counter, question_text)
|
||||||
response = fetch_response(
|
response = fetch_response(
|
||||||
full_system_prompt,
|
full_system_prompt,
|
||||||
@ -318,12 +332,10 @@ def main() -> None:
|
|||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
# After finishing the phase, write a short summary file for this phase
|
# After finishing the phase, write a short summary file for this phase
|
||||||
phase_summary_file = (
|
phase_summary_file = project_dir / f"{sanitize_phase_name(phase_name)}_summary.txt"
|
||||||
project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
summary = fetch_response(
|
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),
|
"\n\n".join(accumulated_summaries),
|
||||||
[],
|
[],
|
||||||
args.model,
|
args.model,
|
||||||
|
|||||||
195
tests/test_mastermind_cli.py
Normal file
195
tests/test_mastermind_cli.py
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user