feat: add create mode for new files
Auto-detects create vs edit based on whether target_path exists. Edit jobs use unified diff/patch (existing behaviour). Create jobs ask the model to output the complete new file, validated by file type (_validate_output handles .py compile, .html tag check, generic non-empty check). - _resolve_target: handles /opt/sites/ and /opt/stacks/ paths - _CREATOR_SYSTEM + _build_create_prompt + _ask_creator: full-file output - _validate_output: replaces direct _syntax_check calls in the loop - run_coding_loop: branches on mode, stores mode on job dict - approve: mkdir parents for new paths, skip backup if no original - submit_task: initialise mode field as None Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,12 @@ JOBS_FILE = STACKS_DIR / "qyburn-coder" / "jobs.json"
|
|||||||
MAX_ATTEMPTS = 3
|
MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_target(target_path: str) -> Path:
|
||||||
|
if target_path.startswith("/opt/"):
|
||||||
|
return Path(target_path)
|
||||||
|
return STACKS_DIR / target_path
|
||||||
|
|
||||||
|
|
||||||
def _save_jobs():
|
def _save_jobs():
|
||||||
try:
|
try:
|
||||||
JOBS_FILE.write_text(json.dumps(jobs, indent=2))
|
JOBS_FILE.write_text(json.dumps(jobs, indent=2))
|
||||||
@@ -110,12 +116,43 @@ def _syntax_check(code: str, filename: str) -> str | None:
|
|||||||
Path(tmp).unlink(missing_ok=True)
|
Path(tmp).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_output(content: str, filename: str) -> str | None:
|
||||||
|
"""Returns error string or None if valid. Handles .py, .html, and generic files."""
|
||||||
|
if not content.strip():
|
||||||
|
return "model returned empty output"
|
||||||
|
ext = Path(filename).suffix.lower()
|
||||||
|
if ext == ".py":
|
||||||
|
return _syntax_check(content, filename)
|
||||||
|
if ext in (".html", ".htm"):
|
||||||
|
low = content.lower()
|
||||||
|
if "<html" not in low and "<!doctype" not in low:
|
||||||
|
return "output does not appear to be valid HTML (missing <html> or <!DOCTYPE>)"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _make_diff(original: str, modified: str, filename: str) -> str:
|
def _make_diff(original: str, modified: str, filename: str) -> str:
|
||||||
a = original.splitlines(keepends=True)
|
a = original.splitlines(keepends=True)
|
||||||
b = modified.splitlines(keepends=True)
|
b = modified.splitlines(keepends=True)
|
||||||
return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)"
|
return "".join(difflib.unified_diff(a, b, fromfile=f"a/{filename}", tofile=f"b/{filename}")) or "(no changes)"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_create_prompt(job: dict, attempt: int, last_error: str | None) -> str:
|
||||||
|
lines = [f"Task: {job['description']}"]
|
||||||
|
if job.get("constraints"):
|
||||||
|
lines.append(f"Constraints: {job['constraints']}")
|
||||||
|
if attempt > 1 and last_error:
|
||||||
|
lines.append(f"\nPrevious attempt failed:\n{last_error}")
|
||||||
|
lines.append("Fix the issue in the output.")
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"Create the file: {job['target_path']}",
|
||||||
|
"",
|
||||||
|
"Output the complete file content:",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str:
|
def _build_prompt(job: dict, original: str, attempt: int, last_error: str | None) -> str:
|
||||||
lines = [f"Task: {job['description']}"]
|
lines = [f"Task: {job['description']}"]
|
||||||
if job.get("constraints"):
|
if job.get("constraints"):
|
||||||
@@ -167,6 +204,28 @@ _CODER_SYSTEM = (
|
|||||||
"+ added lines, - removed lines."
|
"+ added lines, - removed lines."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_CREATOR_SYSTEM = (
|
||||||
|
"You are a code and file generator. "
|
||||||
|
"When given a task, output ONLY the complete new file as raw text. "
|
||||||
|
"No markdown fences, no explanations, no preamble. "
|
||||||
|
"Output only the file content itself, starting with the very first character of the file."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ask_creator(task_prompt: str) -> str:
|
||||||
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
|
r = await client.post(f"{OLLAMA_URL}/api/chat", json={
|
||||||
|
"model": CODER_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": _CREATOR_SYSTEM},
|
||||||
|
{"role": "user", "content": task_prompt},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.1, "num_predict": 8192},
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["message"]["content"]
|
||||||
|
|
||||||
|
|
||||||
async def _ask_coder(task_prompt: str) -> str:
|
async def _ask_coder(task_prompt: str) -> str:
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
@@ -227,17 +286,18 @@ async def run_coding_loop(job_id: str):
|
|||||||
job["status"] = "running"
|
job["status"] = "running"
|
||||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
target = STACKS_DIR / job["target_path"]
|
target = _resolve_target(job["target_path"])
|
||||||
if not target.exists():
|
mode = "edit" if target.exists() else "create"
|
||||||
job["status"] = "failed"
|
job["mode"] = mode
|
||||||
job["error"] = f"target file not found: {job['target_path']}"
|
|
||||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
logger.error(f"[{job_id[:8]}] {job['error']}")
|
|
||||||
return
|
if mode == "edit":
|
||||||
|
original = target.read_text()
|
||||||
|
else:
|
||||||
|
original = ""
|
||||||
|
logger.info(f"[{job_id[:8]}] target does not exist — create mode")
|
||||||
|
|
||||||
await _warm_coder_model()
|
await _warm_coder_model()
|
||||||
|
|
||||||
original = target.read_text()
|
|
||||||
sandbox_dir = SANDBOX_DIR / job_id
|
sandbox_dir = SANDBOX_DIR / job_id
|
||||||
sandbox_dir.mkdir(parents=True, exist_ok=True)
|
sandbox_dir.mkdir(parents=True, exist_ok=True)
|
||||||
sandbox_file = sandbox_dir / Path(job["target_path"]).name
|
sandbox_file = sandbox_dir / Path(job["target_path"]).name
|
||||||
@@ -258,37 +318,43 @@ async def run_coding_loop(job_id: str):
|
|||||||
logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}")
|
logger.info(f"[{job_id[:8]}] attempt {attempt}/{MAX_ATTEMPTS}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
if mode == "edit":
|
||||||
response = await _ask_coder(_build_prompt(job, original, attempt, last_error))
|
response = await _ask_coder(_build_prompt(job, original, attempt, last_error))
|
||||||
|
else:
|
||||||
|
response = await _ask_creator(_build_create_prompt(job, attempt, last_error))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = f"Ollama error: {e}"
|
last_error = f"Ollama error: {e}"
|
||||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}")
|
logger.warning(f"[{job_id[:8]}] attempt {attempt} — LLM error: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4
|
job["tokens_estimated"] = job.get("tokens_estimated", 0) + len(response) // 4
|
||||||
diff_text = _extract_diff(response)
|
raw = _extract_diff(response)
|
||||||
last_diff = diff_text
|
last_diff = raw
|
||||||
|
|
||||||
patched, patch_error = _apply_diff(original, diff_text)
|
if mode == "edit":
|
||||||
|
content, patch_error = _apply_diff(original, raw)
|
||||||
if patch_error:
|
if patch_error:
|
||||||
last_error = patch_error
|
last_error = patch_error
|
||||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
|
logger.warning(f"[{job_id[:8]}] attempt {attempt} patch error: {patch_error[:120]}")
|
||||||
continue
|
continue
|
||||||
|
else:
|
||||||
|
content = raw
|
||||||
|
|
||||||
syntax_error = _syntax_check(patched, Path(job["target_path"]).name)
|
error = _validate_output(content, Path(job["target_path"]).name)
|
||||||
if syntax_error is None:
|
if error is None:
|
||||||
sandbox_file.write_text(patched)
|
sandbox_file.write_text(content)
|
||||||
job["status"] = "pending_review"
|
job["status"] = "pending_review"
|
||||||
job["diff"] = diff_text
|
job["diff"] = raw if mode == "edit" else f"[NEW FILE — {Path(job['target_path']).name}]\n\n{content}"
|
||||||
job["sandbox_file"] = str(sandbox_file)
|
job["sandbox_file"] = str(sandbox_file)
|
||||||
job["original"] = original
|
job["original"] = original
|
||||||
job["error"] = None
|
job["error"] = None
|
||||||
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
job["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
logger.info(f"[{job_id[:8]}] patch OK on attempt {attempt} — pending review")
|
logger.info(f"[{job_id[:8]}] {mode} OK on attempt {attempt} — pending review")
|
||||||
_write_last_run()
|
_write_last_run()
|
||||||
return
|
return
|
||||||
|
|
||||||
last_error = syntax_error
|
last_error = error
|
||||||
logger.warning(f"[{job_id[:8]}] attempt {attempt} syntax error: {syntax_error[:120]}")
|
logger.warning(f"[{job_id[:8]}] attempt {attempt} error: {error[:120]}")
|
||||||
|
|
||||||
_write_handoff(job, original, last_diff)
|
_write_handoff(job, original, last_diff)
|
||||||
job["status"] = "escalated"
|
job["status"] = "escalated"
|
||||||
@@ -463,7 +529,7 @@ async def submit_task(req: TaskRequest, background_tasks: BackgroundTasks):
|
|||||||
"constraints": req.constraints, "attempts": 0,
|
"constraints": req.constraints, "attempts": 0,
|
||||||
"created_at": now, "updated_at": now,
|
"created_at": now, "updated_at": now,
|
||||||
"cancelled": False, "error": None, "diff": None,
|
"cancelled": False, "error": None, "diff": None,
|
||||||
"sandbox_file": None, "original": None,
|
"mode": None, "sandbox_file": None, "original": None,
|
||||||
"tokens_estimated": 0,
|
"tokens_estimated": 0,
|
||||||
}
|
}
|
||||||
background_tasks.add_task(run_coding_loop, job_id)
|
background_tasks.add_task(run_coding_loop, job_id)
|
||||||
@@ -521,7 +587,9 @@ async def approve_job(job_id: str, request: Request):
|
|||||||
sandbox_file = Path(job["sandbox_file"])
|
sandbox_file = Path(job["sandbox_file"])
|
||||||
if not sandbox_file.exists():
|
if not sandbox_file.exists():
|
||||||
raise HTTPException(status_code=500, detail="sandbox file missing")
|
raise HTTPException(status_code=500, detail="sandbox file missing")
|
||||||
target = STACKS_DIR / job["target_path"]
|
target = _resolve_target(job["target_path"])
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if job.get("original"):
|
||||||
backup = target.with_suffix(target.suffix + ".qyburn-bak")
|
backup = target.with_suffix(target.suffix + ".qyburn-bak")
|
||||||
backup.write_text(job["original"])
|
backup.write_text(job["original"])
|
||||||
target.write_text(sandbox_file.read_text())
|
target.write_text(sandbox_file.read_text())
|
||||||
|
|||||||
Reference in New Issue
Block a user