First commit, working quite ok!
This commit is contained in:
+181
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
AutoDev - Planner
|
||||
Reads description + manuals, queries LLM to produce a structured development plan.
|
||||
Approaches the task like a principal engineer — thorough, skeptical, complete.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from .llm import LLM
|
||||
from .logger import Logger
|
||||
from .context import ContextManager
|
||||
from . import config
|
||||
|
||||
SYSTEM_PROMPT = config.EXPERT_IDENTITY + """
|
||||
|
||||
You are now in PLANNING mode. You have been given a project description and optional reference manuals.
|
||||
Your job is to produce a complete, actionable development plan that another engineer could follow blindly.
|
||||
|
||||
Think through this carefully:
|
||||
1. What EXACTLY is being asked for? Read the description multiple times. Identify every requirement.
|
||||
2. What language, frameworks, and tools are needed?
|
||||
3. What is the correct project structure? Think about separation of concerns.
|
||||
4. What are the build steps? What compiler flags, linker flags, dependencies?
|
||||
5. What could go wrong? Plan for error handling and edge cases.
|
||||
6. What is the correct order of implementation? Dependencies between files matter.
|
||||
|
||||
Output ONLY valid JSON with this structure:
|
||||
{
|
||||
"project_name": "string",
|
||||
"language": "primary language",
|
||||
"summary": "2-3 sentence summary of what we're building and why",
|
||||
"dependencies": ["every tool, compiler, library needed"],
|
||||
"structure": ["every file and directory to create, in order"],
|
||||
"steps": [
|
||||
{
|
||||
"id": 1,
|
||||
"phase": "setup|implement|test|debug|finalize",
|
||||
"description": "detailed description of what to do and WHY",
|
||||
"files": ["files this step creates or modifies"],
|
||||
"commands": ["exact shell commands to run, if any"],
|
||||
"acceptance": "how to verify this step succeeded"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Every file that needs to exist MUST have a step that creates it.
|
||||
- Implementation steps MUST be ordered so dependencies are created before dependents.
|
||||
- Include a setup step for directory structure and dependency checks.
|
||||
- Include test/compile steps after implementation.
|
||||
- Include a finalize step that does a clean build and verification.
|
||||
- The "acceptance" field is critical — it defines what success looks like for each step.
|
||||
- Be specific in commands. Not "compile the code" but "gcc -Wall -Wextra -o main main.c util.c -lm".
|
||||
"""
|
||||
|
||||
|
||||
class Planner:
|
||||
def __init__(self, llm: LLM, logger: Logger, ctx: ContextManager, workdir: str):
|
||||
self.llm = llm
|
||||
self.logger = logger
|
||||
self.ctx = ctx
|
||||
self.workdir = workdir
|
||||
self.plan_path = os.path.join(workdir, config.PLAN_FILE)
|
||||
|
||||
def read_description(self) -> str | None:
|
||||
path = os.path.join(self.workdir, config.DESCRIPTION_FILE)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
with open(path, "r") as f:
|
||||
return f.read().strip()
|
||||
|
||||
def read_manuals(self) -> dict[str, str]:
|
||||
manuals = {}
|
||||
mdir = os.path.join(self.workdir, config.MANUALS_DIR)
|
||||
if not os.path.isdir(mdir):
|
||||
return manuals
|
||||
for fname in sorted(os.listdir(mdir)):
|
||||
fpath = os.path.join(mdir, fname)
|
||||
if os.path.isfile(fpath):
|
||||
try:
|
||||
with open(fpath, "r") as f:
|
||||
manuals[fname] = f.read()
|
||||
except (IOError, UnicodeDecodeError):
|
||||
self.logger.log("manual_skip", f"Could not read {fname}", "warn")
|
||||
return manuals
|
||||
|
||||
def create_plan(self, description: str, manuals: dict[str, str],
|
||||
existing_work: str = "") -> dict:
|
||||
prompt_parts = [
|
||||
"## Project Description\n"
|
||||
"Read this VERY carefully. Every sentence is a requirement.\n\n"
|
||||
f"{description}"
|
||||
]
|
||||
for name, content in manuals.items():
|
||||
prompt_parts.append(f"## Reference Manual: {name}\n{content[:6000]}")
|
||||
|
||||
if existing_work:
|
||||
prompt_parts.append(
|
||||
"## Existing Work\n"
|
||||
"The following files already exist from a previous iteration. "
|
||||
"Your plan should BUILD ON what exists — only add steps for NEW or CHANGED requirements. "
|
||||
"Do NOT recreate files that already satisfy the requirements.\n\n"
|
||||
f"{existing_work}"
|
||||
)
|
||||
|
||||
prompt = "\n\n".join(prompt_parts)
|
||||
self.ctx.add("user", prompt, priority=9)
|
||||
self.logger.log("planning", "Querying LLM for development plan")
|
||||
|
||||
response = self.llm.query(prompt, system=SYSTEM_PROMPT, temperature=0.3)
|
||||
self.ctx.add("assistant", response, priority=8)
|
||||
|
||||
plan = self._parse_plan(response)
|
||||
|
||||
# Validate plan has required fields
|
||||
if not plan.get("steps"):
|
||||
# Retry once with more explicit instruction
|
||||
self.logger.log("plan_retry", "First plan had no steps, retrying", "warn")
|
||||
retry_prompt = (
|
||||
prompt + "\n\nIMPORTANT: Your previous response could not be parsed. "
|
||||
"You MUST output ONLY a JSON object. No text before or after. "
|
||||
"Start your response with { and end with }."
|
||||
)
|
||||
response = self.llm.query(retry_prompt, system=SYSTEM_PROMPT, temperature=0.1)
|
||||
plan = self._parse_plan(response)
|
||||
|
||||
self._save_plan(plan)
|
||||
self.logger.log("plan_created", f"{len(plan.get('steps', []))} steps")
|
||||
return plan
|
||||
|
||||
def load_existing_plan(self) -> dict | None:
|
||||
if os.path.exists(self.plan_path):
|
||||
try:
|
||||
with open(self.plan_path, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _parse_plan(self, response: str) -> dict:
|
||||
text = response.strip()
|
||||
# Try progressively more aggressive extraction
|
||||
for extracted in self._extract_json_candidates(text):
|
||||
try:
|
||||
plan = json.loads(extracted)
|
||||
if isinstance(plan, dict):
|
||||
return plan
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self.logger.log("plan_parse_fail", "Could not parse plan JSON", "error")
|
||||
return {"steps": [], "error": "Failed to parse plan", "raw": response[:1000]}
|
||||
|
||||
def _extract_json_candidates(self, text: str) -> list[str]:
|
||||
"""Yield JSON candidates from most to least likely."""
|
||||
candidates = []
|
||||
# 1. Markdown fenced JSON
|
||||
if "```json" in text:
|
||||
block = text.split("```json", 1)[1].split("```", 1)[0].strip()
|
||||
candidates.append(block)
|
||||
elif "```" in text:
|
||||
block = text.split("```", 1)[1].split("```", 1)[0].strip()
|
||||
candidates.append(block)
|
||||
# 2. Raw text (entire response)
|
||||
candidates.append(text)
|
||||
# 3. Find outermost { ... } with brace matching
|
||||
start = text.find("{")
|
||||
if start >= 0:
|
||||
depth = 0
|
||||
for i in range(start, len(text)):
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
candidates.append(text[start:i + 1])
|
||||
break
|
||||
return candidates
|
||||
|
||||
def _save_plan(self, plan: dict):
|
||||
with open(self.plan_path, "w") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
Reference in New Issue
Block a user