Merge pull request 'Fix all open issues - code, security, ops' (#17) from fix/core-issues into main
Reviewed-on: https://git.example.com/jarianc/MasterMind/pulls/17
This commit is contained in:
commit
0f83032cd6
9
.gitignore
vendored
9
.gitignore
vendored
@ -4,3 +4,12 @@
|
|||||||
/include
|
/include
|
||||||
/bin
|
/bin
|
||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
|
# Virtual environment config (contains personal paths)
|
||||||
|
pyvenv.cfg
|
||||||
|
|
||||||
|
# Large binaries (PDFs should not be tracked in git)
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
# Output directories
|
||||||
|
output/
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -1,5 +0,0 @@
|
|||||||
home = /opt/homebrew/opt/python@3.13/bin
|
|
||||||
include-system-site-packages = false
|
|
||||||
version = 3.13.5
|
|
||||||
executable = /opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/bin/python3.13
|
|
||||||
command = /opt/homebrew/opt/python@3.13/bin/python3.13 -m venv /Users/user/Projects/Master Mind
|
|
||||||
@ -5,23 +5,27 @@ MasterMind CLI
|
|||||||
This script will:
|
This script will:
|
||||||
|
|
||||||
1. Load the list of questions from `Master Mind/questions.json`.
|
1. Load the list of questions from `Master Mind/questions.json`.
|
||||||
2. Load the system prompt from a user‑provided file (default: `system_prompt.txt`).
|
2. Load the system prompt from a user-provided file (default: `system_prompt.txt`).
|
||||||
3. Load the proposal and IDIOT method documents.
|
3. Load the proposal and IDIOT method documents.
|
||||||
4. For each question:
|
4. For each question:
|
||||||
- Send a request to a local AI model.
|
- Send a request to a local AI model.
|
||||||
- Store the response in a file inside a project‑named folder.
|
- Store the response in a file inside a project-named folder.
|
||||||
- Log the activity to the console.
|
- Log the activity to the console.
|
||||||
|
|
||||||
A very small helper is used to talk to a local model; it can be replaced with a real
|
A very small helper is used to talk to a local model; it can be replaced with a real
|
||||||
LLM client (Llama.cpp, Ollama, FastLLM, etc.) by editing the ``fetch_response`` function.
|
LLM client (Llama.cpp, Ollama, FastLLM, etc.) by editing the ``fetch_response`` function.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mastermind_cli.py \
|
mastermind_cli.py \\
|
||||||
--project ProjectName \
|
--project ProjectName \\
|
||||||
--proposal path/to/ProjectProposal.txt \
|
--proposal path/to/ProjectProposal.txt \\
|
||||||
--idiot path/to/IDIOTMethod.txt \
|
--idiot path/to/IDIOTMethod.txt \\
|
||||||
[--prompt-file path/to/system_prompt.txt] \
|
[--prompt-file path/to/system_prompt.txt] \\
|
||||||
[--model-magic "llama3"] # optional flag for the local model name
|
[--model "llama3"] # optional flag for the local model name
|
||||||
|
[--ollama-url "http://..."] # optional Ollama endpoint (default: OLLAMA_URL env)
|
||||||
|
[--questions-file path.json] # optional questions file (default: questions.json)
|
||||||
|
[--output-dir path] # optional output directory
|
||||||
|
[--api-key "secret"] # optional API key for Ollama auth
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -29,19 +33,50 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Sliding window size for accumulated context (issue #8)
|
||||||
|
MAX_CONTEXT_WINDOW = 5
|
||||||
|
|
||||||
|
|
||||||
|
def validate_project_name(name: str) -> str:
|
||||||
|
"""Validate project name contains only safe characters. Prevents path traversal."""
|
||||||
|
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||||
|
logging.error(
|
||||||
|
"Invalid project name '%s'. Only alphanumeric, hyphens, and underscores allowed.",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_no_traversal(path: pathlib.Path, label: str) -> pathlib.Path:
|
||||||
|
"""Ensure resolved path does not escape its intended base directory."""
|
||||||
|
resolved = path.resolve()
|
||||||
|
base = pathlib.Path(".").resolve()
|
||||||
|
try:
|
||||||
|
resolved.relative_to(base)
|
||||||
|
except ValueError:
|
||||||
|
logging.error(
|
||||||
|
"Invalid %s '%s': path escapes the allowed base directory.",
|
||||||
|
label,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
return path.read_text(encoding="utf-8")
|
return path.read_text(encoding="utf-8")
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc:
|
||||||
logging.error("Could not read %s: %s", path, exc)
|
logging.error("Could not read %s: %s", path, exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@ -51,7 +86,7 @@ def load_questions(json_path: pathlib.Path) -> List[dict]:
|
|||||||
text = read_file(json_path)
|
text = read_file(json_path)
|
||||||
try:
|
try:
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except json.JSONDecodeError as exc: # pragma: no cover
|
except json.JSONDecodeError as exc:
|
||||||
logging.error("Invalid JSON in %s: %s", json_path, exc)
|
logging.error("Invalid JSON in %s: %s", json_path, exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@ -61,6 +96,9 @@ def fetch_response(
|
|||||||
user_prompt: str,
|
user_prompt: str,
|
||||||
context: List[str],
|
context: List[str],
|
||||||
model: str = "gpt-oss:20b",
|
model: str = "gpt-oss:20b",
|
||||||
|
ollama_url: str = "http://localhost:11434/api/generate",
|
||||||
|
api_key: str | None = None,
|
||||||
|
timeout: int = 120,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a request to a local AI model and return the response.
|
Send a request to a local AI model and return the response.
|
||||||
@ -71,40 +109,50 @@ def fetch_response(
|
|||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
system_prompt : str
|
system_prompt : str
|
||||||
The system‑level instruction to the AI.
|
The system-level instruction to the AI.
|
||||||
user_prompt : str
|
user_prompt : str
|
||||||
The question or user message.
|
The question or user message.
|
||||||
context : List[str]
|
context : List[str]
|
||||||
Optional additional context to prepend to the request.
|
Optional additional context to prepend to the request.
|
||||||
|
model : str
|
||||||
|
The model name to use.
|
||||||
|
ollama_url : str
|
||||||
|
The Ollama API endpoint URL.
|
||||||
|
api_key : str | None
|
||||||
|
Optional API key for authentication.
|
||||||
|
timeout : int
|
||||||
|
Request timeout in seconds.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
str
|
str
|
||||||
The AI’s reply.
|
The AI's reply.
|
||||||
"""
|
"""
|
||||||
# Combine system prompt, context, and user prompt.
|
|
||||||
prompt_parts = [system_prompt]
|
prompt_parts = [system_prompt]
|
||||||
prompt_parts.extend(f"{ctx}" for ctx in context)
|
prompt_parts.extend(f"{ctx}" for ctx in context)
|
||||||
prompt_parts.append(f"Question: {user_prompt}")
|
prompt_parts.append(f"Question: {user_prompt}")
|
||||||
prompt = "\n\n".join(prompt_parts) + "\n"
|
prompt = "\n\n".join(prompt_parts) + "\n"
|
||||||
|
|
||||||
# Call Ollama API
|
|
||||||
url = "http://192.168.8.112:11434/api/generate"
|
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
"model": model,
|
"model": model,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"stream": False
|
"stream": False,
|
||||||
}).encode("utf-8")
|
}).encode("utf-8")
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
req = __import__("urllib.request").request.Request(
|
||||||
|
ollama_url, data=payload, headers=headers, method="POST"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with __import__("urllib.request").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()
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc:
|
||||||
logging.error("Ollama request failed: %s", exc)
|
logging.error("Ollama request failed: %s", exc)
|
||||||
return f"Simulated response to: {user_prompt}"
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@ -147,21 +195,60 @@ def main() -> None:
|
|||||||
default="",
|
default="",
|
||||||
help="Custom output directory for the project folder (default: current dir).",
|
help="Custom output directory for the project folder (default: current dir).",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ollama-url",
|
||||||
|
default=None,
|
||||||
|
help="Ollama API endpoint URL (default: $OLLAMA_URL or http://localhost:11434/api/generate).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
default=None,
|
||||||
|
help="API key for Ollama authentication (default: $OLLAMA_API_KEY).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--questions-file",
|
||||||
|
default=None,
|
||||||
|
help="Path to questions JSON file (default: questions.json next to script).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=120,
|
||||||
|
help="Request timeout in seconds for Ollama calls (default: 120).",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Resolve Ollama URL: CLI arg > env var > default
|
||||||
|
ollama_url = args.ollama_url or os.environ.get(
|
||||||
|
"OLLAMA_URL", "http://localhost:11434/api/generate"
|
||||||
|
)
|
||||||
|
api_key = args.api_key or os.environ.get("OLLAMA_API_KEY")
|
||||||
|
|
||||||
|
# Warn if using HTTP (cleartext)
|
||||||
|
if ollama_url.startswith("http://"):
|
||||||
|
logging.warning(
|
||||||
|
"Using unencrypted HTTP for Ollama endpoint. "
|
||||||
|
"Sensitive project data will be transmitted in cleartext. "
|
||||||
|
"Use https:// or ensure network isolation."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate project name against path traversal
|
||||||
|
validate_project_name(args.project)
|
||||||
|
|
||||||
base_dir = pathlib.Path.cwd()
|
base_dir = pathlib.Path.cwd()
|
||||||
|
|
||||||
# Resolve project output directory
|
# Resolve project output directory with traversal protection
|
||||||
if args.output_dir:
|
if args.output_dir:
|
||||||
out_base = pathlib.Path(args.output_dir).expanduser().resolve()
|
out_base = pathlib.Path(args.output_dir).expanduser().resolve()
|
||||||
|
validate_no_traversal(out_base, "--output-dir")
|
||||||
else:
|
else:
|
||||||
out_base = base_dir
|
out_base = base_dir
|
||||||
project_dir = out_base / args.project
|
project_dir = out_base / args.project
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_dir.mkdir(parents=True, exist_ok=True)
|
project_dir.mkdir(parents=True, exist_ok=True)
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc:
|
||||||
logging.error("Could not create project directory %s: %s", project_dir, exc)
|
logging.error("Could not create project directory %s: %s", project_dir, exc)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
@ -174,7 +261,12 @@ def main() -> None:
|
|||||||
proposal_text = read_file(proposal_path)
|
proposal_text = read_file(proposal_path)
|
||||||
idiot_text = read_file(idiot_path)
|
idiot_text = read_file(idiot_path)
|
||||||
|
|
||||||
questions_path = base_dir.parent / "questions.json"
|
# Resolve questions file: CLI arg > script-relative > project root
|
||||||
|
if args.questions_file:
|
||||||
|
questions_path = pathlib.Path(args.questions_file)
|
||||||
|
else:
|
||||||
|
script_dir = pathlib.Path(__file__).resolve().parent
|
||||||
|
questions_path = script_dir.parent / "questions.json"
|
||||||
questions = load_questions(questions_path)
|
questions = load_questions(questions_path)
|
||||||
|
|
||||||
# Merge context into system prompt for easier reuse
|
# Merge context into system prompt for easier reuse
|
||||||
@ -196,8 +288,8 @@ def main() -> None:
|
|||||||
phase_qs = phase.get("questions", [])
|
phase_qs = phase.get("questions", [])
|
||||||
logging.info("Phase: %s – %d questions", phase_name, len(phase_qs))
|
logging.info("Phase: %s – %d questions", phase_name, len(phase_qs))
|
||||||
|
|
||||||
# Context from previous phases
|
# Sliding window: only keep last N summaries to bound context growth
|
||||||
phase_context: List[str] = accumulated_summaries.copy()
|
phase_context: List[str] = accumulated_summaries[-MAX_CONTEXT_WINDOW:]
|
||||||
|
|
||||||
for q in phase_qs:
|
for q in phase_qs:
|
||||||
question_text = q.get("text", "")
|
question_text = q.get("text", "")
|
||||||
@ -206,12 +298,18 @@ def main() -> None:
|
|||||||
|
|
||||||
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, question_text, phase_context, args.model
|
full_system_prompt,
|
||||||
|
question_text,
|
||||||
|
phase_context,
|
||||||
|
args.model,
|
||||||
|
ollama_url,
|
||||||
|
api_key,
|
||||||
|
args.timeout,
|
||||||
)
|
)
|
||||||
output_file = project_dir / f"question_{counter:03d}.txt"
|
output_file = project_dir / f"question_{counter:03d}.txt"
|
||||||
try:
|
try:
|
||||||
output_file.write_text(response, encoding="utf-8")
|
output_file.write_text(response, encoding="utf-8")
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc:
|
||||||
logging.error("Could not write %s: %s", output_file, exc)
|
logging.error("Could not write %s: %s", output_file, exc)
|
||||||
continue
|
continue
|
||||||
logging.info("Saved response to %s", output_file)
|
logging.info("Saved response to %s", output_file)
|
||||||
@ -224,17 +322,24 @@ def main() -> None:
|
|||||||
project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt"
|
project_dir / f"{phase_name.replace(' ', '_').lower()}_summary.txt"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
summary = fetch_response("Make a clear and concise summary that's no more than 500 tokens on the following input",
|
summary = fetch_response(
|
||||||
|
"Make a clear and concise summary that's no more than 500 tokens on the following input",
|
||||||
"\n\n".join(accumulated_summaries),
|
"\n\n".join(accumulated_summaries),
|
||||||
[])
|
[],
|
||||||
|
args.model,
|
||||||
phase_summary_file.write_text(
|
ollama_url,
|
||||||
summary, encoding="utf-8"
|
api_key,
|
||||||
|
args.timeout,
|
||||||
)
|
)
|
||||||
logging.info("Saved per‑phase summary to %s", phase_summary_file)
|
|
||||||
except Exception as exc: # pragma: no cover
|
phase_summary_file.write_text(summary, encoding="utf-8")
|
||||||
|
logging.info("Saved per-phase summary to %s", phase_summary_file)
|
||||||
|
except SystemExit:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
logging.error("Could not write %s: %s", phase_summary_file, exc)
|
logging.error("Could not write %s: %s", phase_summary_file, exc)
|
||||||
|
|
||||||
|
# Record result for every phase (was incorrectly inside except block)
|
||||||
experiment_results.append({
|
experiment_results.append({
|
||||||
"phase": phase_name,
|
"phase": phase_name,
|
||||||
"summary_file": str(phase_summary_file),
|
"summary_file": str(phase_summary_file),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user