feat(assistant): add optional code writing flow to learn endpoint and UI

This commit is contained in:
Carl Niklas Rydberg 2026-02-14 22:07:04 +01:00
parent b55824f49c
commit 773aff7c10
3 changed files with 201 additions and 1 deletions

143
app.py
View file

@ -274,6 +274,12 @@ class AssistantLearnPayload(BaseModel):
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
release_name: Optional[str] = None release_name: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict) metadata: Dict[str, Any] = Field(default_factory=dict)
write_code: bool = False
code_objective: Optional[str] = None
code_files: List[str] = Field(default_factory=list)
code_dry_run: bool = True
code_commit: bool = False
code_branch: Optional[str] = None
class AssistantChatMessage(BaseModel): class AssistantChatMessage(BaseModel):
@ -1325,6 +1331,134 @@ def _extract_json_object_from_text(text: str) -> Dict[str, Any]:
return obj return obj
def _extract_code_text(text: str) -> str:
raw = (text or "").strip()
if raw.startswith("```"):
lines = raw.splitlines()
if lines and lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
return "\n".join(lines).rstrip() + "\n"
return raw
def build_learn_code_prompt(
objective: str,
note_title: str,
note_text: str,
file_path: str,
current_content: str,
) -> str:
return (
"You are editing one source file in a real codebase.\n"
"Make minimal, correct changes to satisfy the objective.\n"
"Do not add explanations.\n"
"Return ONLY the full updated file content.\n\n"
f"Objective:\n{objective}\n\n"
f"Learn note title:\n{note_title}\n\n"
f"Learn note text:\n{note_text[:4000]}\n\n"
f"File path:\n{file_path}\n\n"
"Current file content:\n"
f"{current_content[:120000]}\n"
)
async def _assistant_write_code_from_learn(
payload: AssistantLearnPayload,
note_title: str,
note_text: str,
) -> Dict[str, Any]:
repo_dir = Path(__file__).resolve().parent
objective = (payload.code_objective or "").strip() or f"Implement changes from learn note: {note_title}"
requested_files = [str(x).strip() for x in (payload.code_files or []) if str(x).strip()]
if not requested_files:
raise HTTPException(status_code=400, detail="code_files is required when write_code=true")
branch_name = (payload.code_branch or "").strip()
if not branch_name:
branch_name = f"assistant/learn-{_slugify(note_title)}-{uuid.uuid4().hex[:6]}"
chk = await _run_local_cmd(["git", "rev-parse", "--is-inside-work-tree"], repo_dir)
if chk["code"] != 0:
raise HTTPException(status_code=500, detail={"message": "Not a git repository", "stderr": chk["stderr"]})
if not payload.code_dry_run:
co = await _run_local_cmd(["git", "checkout", "-B", branch_name], repo_dir)
if co["code"] != 0:
raise HTTPException(
status_code=500,
detail={"message": "Failed to create/switch branch", "stderr": co["stderr"]},
)
changed_files: List[str] = []
skipped_files: List[Dict[str, str]] = []
file_results: List[Dict[str, Any]] = []
for rel in requested_files[:20]:
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
skipped_files.append({"file": rel, "reason": "Only workspace-relative paths are allowed."})
continue
abs_path = (repo_dir / rel_path).resolve()
try:
abs_path.relative_to(repo_dir)
except ValueError:
skipped_files.append({"file": rel, "reason": "Path escapes repository root."})
continue
if not abs_path.exists() or not abs_path.is_file():
skipped_files.append({"file": rel, "reason": "File does not exist."})
continue
current = abs_path.read_text(encoding="utf-8", errors="replace")
prompt = build_learn_code_prompt(
objective=objective,
note_title=note_title,
note_text=note_text,
file_path=rel,
current_content=current,
)
raw = await ollama_generate(prompt)
updated = _extract_code_text(raw)
if not updated.strip():
skipped_files.append({"file": rel, "reason": "Model returned empty content."})
continue
changed = updated != current
file_results.append({"file": rel, "changed": changed})
if not changed:
continue
changed_files.append(rel)
if not payload.code_dry_run:
abs_path.write_text(updated, encoding="utf-8")
commit_hash: Optional[str] = None
commit_detail: Optional[str] = None
if changed_files and not payload.code_dry_run and payload.code_commit:
add = await _run_local_cmd(["git", "add", *changed_files], repo_dir)
if add["code"] != 0:
raise HTTPException(status_code=500, detail={"message": "Failed to add changed files", "stderr": add["stderr"]})
msg = f"feat(assistant): apply learn code update {note_title[:50]}"
cm = await _run_local_cmd(["git", "commit", "-m", msg], repo_dir)
commit_detail = (cm["stdout"] or cm["stderr"] or "").strip()
if cm["code"] == 0:
head = await _run_local_cmd(["git", "rev-parse", "HEAD"], repo_dir)
if head["code"] == 0:
commit_hash = head["stdout"].strip()
return {
"attempted": True,
"objective": objective,
"dry_run": payload.code_dry_run,
"branch_name": branch_name,
"requested_files": requested_files,
"changed_files": changed_files,
"skipped_files": skipped_files,
"file_results": file_results,
"commit": commit_hash,
"commit_detail": commit_detail,
}
def build_assistant_plan_prompt(payload: AssistantPlanPayload, source_docs: List[Dict[str, Any]]) -> str: def build_assistant_plan_prompt(payload: AssistantPlanPayload, source_docs: List[Dict[str, Any]]) -> str:
constraints = payload.constraints or [] constraints = payload.constraints or []
constraint_lines = "\n".join(f"- {c}" for c in constraints) if constraints else "- None" constraint_lines = "\n".join(f"- {c}" for c in constraints) if constraints else "- None"
@ -2485,13 +2619,20 @@ async def assistant_learn(payload: AssistantLearnPayload, x_admin_api_key: Optio
"fingerprint": make_fingerprint(title, "note", {}), "fingerprint": make_fingerprint(title, "note", {}),
} }
await es_index(doc) await es_index(doc)
return { response: Dict[str, Any] = {
"stored": True, "stored": True,
"concept_id": doc["concept_id"], "concept_id": doc["concept_id"],
"release_name": payload.release_name, "release_name": payload.release_name,
"title": title, "title": title,
"tags": payload.tags, "tags": payload.tags,
} }
if payload.write_code:
response["code_write"] = await _assistant_write_code_from_learn(
payload=payload,
note_title=title,
note_text=payload.text,
)
return response
@app.post("/assistant/chat", response_model=AssistantChatResponse) @app.post("/assistant/chat", response_model=AssistantChatResponse)

View file

@ -180,6 +180,57 @@ async function saveLearn() {
} }
} }
async function saveLearnAndWriteCode() {
const cfg = getConfig();
const title = document.getElementById("learnTitle").value.trim();
const tags = document.getElementById("learnTags").value
.split(",")
.map((x) => x.trim())
.filter(Boolean);
const text = document.getElementById("learnText").value.trim();
const codeObjective = document.getElementById("learnCodeObjective").value.trim();
const codeFiles = document.getElementById("learnCodeFiles").value
.split(",")
.map((x) => x.trim())
.filter(Boolean);
const codeDryRun = document.getElementById("learnCodeDryRun").checked;
const codeCommit = document.getElementById("learnCodeCommit").checked;
const out = document.getElementById("learnOutput");
if (!text) {
out.textContent = "Provide note text first.";
return;
}
if (codeFiles.length === 0) {
out.textContent = "Provide at least one code file path.";
return;
}
out.textContent = "Saving note and writing code...";
try {
const data = await apiPost("/assistant/learn", {
text,
title: title || null,
tags,
release_name: cfg.releaseName || null,
write_code: true,
code_objective: codeObjective || null,
code_files: codeFiles,
code_dry_run: codeDryRun,
code_commit: codeCommit,
});
const cw = data.code_write || {};
out.textContent =
`saved=${data.stored}\nconcept_id=${data.concept_id}\ntitle=${data.title}\n\n` +
`write_code.attempted=${cw.attempted}\n` +
`dry_run=${cw.dry_run}\n` +
`branch=${cw.branch_name || "-"}\n` +
`changed_files=${(cw.changed_files || []).join(", ") || "-"}\n` +
`skipped_files=${(cw.skipped_files || []).map((x) => `${x.file}:${x.reason}`).join(" | ") || "-"}\n` +
`commit=${cw.commit || "-"}`;
} catch (e) {
out.textContent = `Error: ${String(e)}`;
}
}
function appendChat(role, text, meta) { function appendChat(role, text, meta) {
const target = document.getElementById("chatTranscript"); const target = document.getElementById("chatTranscript");
const el = document.createElement("div"); const el = document.createElement("div");
@ -334,6 +385,7 @@ document.getElementById("loadInbox").addEventListener("click", loadInbox);
document.getElementById("loadTasks").addEventListener("click", loadTasks); document.getElementById("loadTasks").addEventListener("click", loadTasks);
document.getElementById("makeDraft").addEventListener("click", makeDraft); document.getElementById("makeDraft").addEventListener("click", makeDraft);
document.getElementById("saveLearn").addEventListener("click", saveLearn); document.getElementById("saveLearn").addEventListener("click", saveLearn);
document.getElementById("saveLearnWrite").addEventListener("click", saveLearnAndWriteCode);
document.getElementById("sendChat").addEventListener("click", sendChat); document.getElementById("sendChat").addEventListener("click", sendChat);
document.getElementById("runImprove").addEventListener("click", runSelfImprove); document.getElementById("runImprove").addEventListener("click", runSelfImprove);
document.getElementById("loadImproveHistory").addEventListener("click", loadImproveHistory); document.getElementById("loadImproveHistory").addEventListener("click", loadImproveHistory);

View file

@ -60,8 +60,15 @@
<input id="learnTitle" type="text" placeholder="Title (optional)" /> <input id="learnTitle" type="text" placeholder="Title (optional)" />
<input id="learnTags" type="text" placeholder="tags comma-separated (optional)" /> <input id="learnTags" type="text" placeholder="tags comma-separated (optional)" />
<button id="saveLearn">Save Note</button> <button id="saveLearn">Save Note</button>
<button id="saveLearnWrite">Save + Write Code</button>
</div> </div>
</div> </div>
<div class="controls" style="margin-bottom:8px">
<input id="learnCodeObjective" type="text" placeholder="Code objective (optional)" />
<input id="learnCodeFiles" type="text" placeholder="code files (comma-separated)" />
<label><input id="learnCodeDryRun" type="checkbox" checked /> Dry-run</label>
<label><input id="learnCodeCommit" type="checkbox" /> Commit</label>
</div>
<textarea id="learnText" rows="3" placeholder="Knowledge note you want the assistant to remember"></textarea> <textarea id="learnText" rows="3" placeholder="Knowledge note you want the assistant to remember"></textarea>
<pre id="learnOutput" class="output"></pre> <pre id="learnOutput" class="output"></pre>
</section> </section>