Skip to content

Commit 38062c2

Browse files
committed
Slackbot AI follow up + tests
1 parent c52733c commit 38062c2

4 files changed

Lines changed: 1084 additions & 2 deletions

File tree

server/installer/sandbox/code_generator.py

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from langchain_anthropic import ChatAnthropic
2424
from langchain_chroma import Chroma
2525
from langchain_community.embeddings import FastEmbedEmbeddings
26-
from langchain_core.messages import HumanMessage, SystemMessage
26+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
2727
from langgraph.graph import END, StateGraph
2828

2929
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -406,6 +406,7 @@ def _format_response(state: AgentState) -> dict:
406406
retry_history = state.get("retry_history", [])
407407
return {
408408
"code": state.get("code", ""),
409+
"rag_context": state.get("rag_context", ""),
409410
"result": {
410411
"status": "success" if result.get("ok") else "error",
411412
"output": result.get("std_out", "").strip(),
@@ -542,6 +543,199 @@ def generate_code_endpoint():
542543
}), 500
543544

544545

546+
@app.route("/api/generate-code-followup", methods=["POST"])
547+
def generate_code_followup_endpoint():
548+
"""Continue an AI conversation with prior context.
549+
550+
Body:
551+
prompt: str — the user's follow-up instruction
552+
history: list[dict] — prior turns, each with:
553+
role: "user" | "assistant"
554+
prompt: str — the instruction or description
555+
code: str — generated code (assistant turns)
556+
output: str — execution stdout (assistant turns)
557+
error: str — execution error (assistant turns)
558+
rag_context: str — RAG context from that turn (optional)
559+
"""
560+
data = request.get_json() or {}
561+
user_prompt = data.get("prompt", "").strip()
562+
history = data.get("history", [])
563+
564+
if not user_prompt:
565+
return jsonify({"error": "prompt is required"}), 400
566+
567+
try:
568+
# ── Step 1: RAG retrieval on the new prompt ────────────────────────
569+
index_data_dir()
570+
rag_parts: list[str] = []
571+
572+
try:
573+
sensor_docs = sensors_store.similarity_search(user_prompt, k=RAG_SENSOR_K)
574+
if sensor_docs:
575+
names = [d.page_content for d in sensor_docs]
576+
rag_parts.append("RELEVANT SENSORS:\n" + "\n".join(f" - {n}" for n in names))
577+
except Exception:
578+
logger.warning("Followup: sensor RAG failed", exc_info=True)
579+
580+
try:
581+
run_docs = runs_store.similarity_search(user_prompt, k=RAG_RUN_K)
582+
if run_docs:
583+
descs = [d.page_content for d in run_docs]
584+
rag_parts.append("RELEVANT RUNS:\n" + "\n".join(f" - {d}" for d in descs))
585+
except Exception:
586+
logger.warning("Followup: runs RAG failed", exc_info=True)
587+
588+
try:
589+
solution_docs = solutions_store.similarity_search(user_prompt, k=RAG_SOLUTION_K)
590+
if solution_docs:
591+
summaries = [d.page_content for d in solution_docs]
592+
rag_parts.append("SUCCESSFUL EXAMPLES:\n" + "\n".join(f" - {s}" for s in summaries))
593+
except Exception:
594+
logger.warning("Followup: solutions RAG failed", exc_info=True)
595+
596+
new_rag_context = "\n\n".join(rag_parts)
597+
598+
# ── Step 2: Build multi-turn message list ──────────────────────────
599+
guide = _load_guide()
600+
system_content = guide
601+
# Merge RAG context: combine history's RAG with freshly retrieved
602+
all_rag_parts: list[str] = []
603+
for turn in history:
604+
rc = turn.get("rag_context", "")
605+
if rc:
606+
all_rag_parts.append(rc)
607+
if new_rag_context:
608+
all_rag_parts.append(new_rag_context)
609+
combined_rag = "\n\n".join(dict.fromkeys(all_rag_parts)) # deduplicate, preserve order
610+
if combined_rag:
611+
system_content += f"\n\n--- RETRIEVED CONTEXT ---\n{combined_rag}"
612+
613+
messages = [SystemMessage(content=system_content)]
614+
615+
for turn in history:
616+
role = turn.get("role", "user")
617+
if role == "user":
618+
messages.append(HumanMessage(content=turn.get("prompt", "")))
619+
elif role == "assistant":
620+
code = turn.get("code", "")
621+
output = turn.get("output", "")
622+
error = turn.get("error", "")
623+
parts = []
624+
if code:
625+
parts.append(f"```python\n{code}\n```")
626+
if output:
627+
parts.append(f"Output:\n{output}")
628+
if error:
629+
parts.append(f"Error:\n{error}")
630+
messages.append(AIMessage(content="\n\n".join(parts) or "(no output)"))
631+
632+
# The new follow-up instruction
633+
messages.append(HumanMessage(content=user_prompt))
634+
635+
logger.info(
636+
"Followup: %d history turns, %d total messages, prompt=%s",
637+
len(history), len(messages), user_prompt[:80],
638+
)
639+
640+
# ── Step 3: LLM call ───────────────────────────────────────────────
641+
response = llm.invoke(messages)
642+
code = _extract_python(response.content)
643+
644+
# ── Step 4: Execute in sandbox ─────────────────────────────────────
645+
retry_history: list[dict] = []
646+
retries = 0
647+
final_result = {}
648+
sandbox_ms = 0.0
649+
650+
while True:
651+
try:
652+
t0 = _time.perf_counter()
653+
resp = requests.post(SANDBOX_URL, json={"code": code}, timeout=120)
654+
sandbox_ms = (_time.perf_counter() - t0) * 1000
655+
resp.raise_for_status()
656+
final_result = resp.json()
657+
except Exception as e:
658+
err = str(e)
659+
retries += 1
660+
retry_history.append({"attempt": retries, "error": err})
661+
if retries <= MAX_RETRIES:
662+
# Retry: ask LLM to fix
663+
messages.append(AIMessage(content=f"```python\n{code}\n```"))
664+
messages.append(HumanMessage(
665+
content=f"ATTEMPT {retries} FAILED:\n{err}\n\nFix the error above."
666+
))
667+
response = llm.invoke(messages)
668+
code = _extract_python(response.content)
669+
continue
670+
final_result = {}
671+
break
672+
673+
if not final_result.get("ok"):
674+
parts_err: list[str] = []
675+
rc = final_result.get("return_code")
676+
if rc is not None and rc != 0:
677+
parts_err.append(f"Exit code: {rc}")
678+
if final_result.get("std_err"):
679+
parts_err.append(f"STDERR:\n{final_result['std_err'].strip()}")
680+
if final_result.get("std_out"):
681+
parts_err.append(f"STDOUT:\n{final_result['std_out'].strip()}")
682+
err = "\n".join(parts_err)
683+
retries += 1
684+
retry_history.append({"attempt": retries, "error": err})
685+
if retries <= MAX_RETRIES:
686+
messages.append(AIMessage(content=f"```python\n{code}\n```"))
687+
messages.append(HumanMessage(
688+
content=f"ATTEMPT {retries} FAILED:\n{err}\n\nFix the error above."
689+
))
690+
response = llm.invoke(messages)
691+
code = _extract_python(response.content)
692+
continue
693+
break
694+
else:
695+
break
696+
697+
# ── Step 5: Format response ────────────────────────────────────────
698+
state: AgentState = {
699+
"prompt": user_prompt,
700+
"rag_context": new_rag_context,
701+
"code": code,
702+
"sandbox_result": final_result,
703+
"error": retry_history[-1]["error"] if retry_history and not final_result.get("ok") else "",
704+
"retries": retries,
705+
"retry_history": retry_history,
706+
"llm_cache_hit": False,
707+
"exec_cache_hit": False,
708+
}
709+
formatted = _format_response(state)
710+
711+
# Record metrics
712+
ok = formatted["result"]["status"] == "success"
713+
creator = data.get("creator", "") or ""
714+
prompt_key = "followup:" + hashlib.sha256(user_prompt.encode()).hexdigest()
715+
try:
716+
record_metrics(
717+
prompt_hash=prompt_key,
718+
llm_cache_hit=False,
719+
exec_cache_hit=False,
720+
retry_count=retries,
721+
sandbox_ms=sandbox_ms,
722+
success=ok,
723+
creator=creator,
724+
)
725+
except Exception:
726+
logger.exception("Failed to record followup metrics")
727+
728+
return jsonify(formatted)
729+
730+
except Exception as e:
731+
logger.exception("Followup generation failed for prompt: %s", user_prompt[:80])
732+
return jsonify({
733+
"error": str(e),
734+
"rag_context": "",
735+
"result": {"status": "error", "output": "", "error": str(e), "files": []},
736+
}), 500
737+
738+
545739
@app.route("/api/metrics", methods=["GET"])
546740
def metrics_endpoint():
547741
"""Return aggregated observability stats and a dashboard PNG."""

server/installer/sandbox/test_code_generator.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,155 @@ def test_feedback_endpoint():
158158
return feedback_resp.status_code == 200
159159

160160

161+
def test_rag_context_in_response():
162+
"""Test that /api/generate-code now returns rag_context."""
163+
print("Testing rag_context in generate-code response...")
164+
165+
prompt = "Print the numbers 1 through 5"
166+
response = requests.post(
167+
f"{CODE_GENERATOR_URL}/api/generate-code",
168+
json={"prompt": prompt},
169+
timeout=120,
170+
)
171+
172+
if response.status_code != 200:
173+
print(f"⚠️ Generate-code returned {response.status_code}")
174+
return False
175+
176+
result = response.json()
177+
has_rag_context = "rag_context" in result
178+
print(f" rag_context present: {has_rag_context}")
179+
print(f" rag_context type: {type(result.get('rag_context'))}")
180+
if has_rag_context:
181+
ctx = result["rag_context"]
182+
print(f" rag_context length: {len(ctx)} chars")
183+
if ctx:
184+
print(f" rag_context preview: {ctx[:200]}...")
185+
print()
186+
return has_rag_context
187+
188+
189+
def test_followup_conversation():
190+
"""Test multi-turn follow-up conversation via /api/generate-code-followup."""
191+
print("Testing follow-up conversation...")
192+
193+
# Step 1: Generate initial code
194+
initial_prompt = "Print the numbers 1 through 5, one per line"
195+
print(f" Step 1 — Initial prompt: {initial_prompt}")
196+
197+
gen_resp = requests.post(
198+
f"{CODE_GENERATOR_URL}/api/generate-code",
199+
json={"prompt": initial_prompt},
200+
timeout=120,
201+
)
202+
if gen_resp.status_code != 200:
203+
print(f" ⚠️ Initial generate-code failed ({gen_resp.status_code})")
204+
return False
205+
206+
gen_result = gen_resp.json()
207+
exec_result = gen_result.get("result", {})
208+
if exec_result.get("status") != "success":
209+
print(f" ⚠️ Initial code execution failed: {exec_result.get('error', 'unknown')}")
210+
return False
211+
212+
initial_code = gen_result.get("code", "")
213+
initial_output = exec_result.get("output", "")
214+
initial_rag = gen_result.get("rag_context", "")
215+
216+
print(f" ✓ Initial code generated ({len(initial_code)} chars)")
217+
print(f" ✓ Initial output: {initial_output[:100]}")
218+
219+
# Step 2: Send a follow-up
220+
history = [
221+
{"role": "user", "prompt": initial_prompt, "code": "", "output": "", "error": "", "rag_context": ""},
222+
{"role": "assistant", "prompt": "", "code": initial_code, "output": initial_output, "error": "", "rag_context": initial_rag},
223+
]
224+
225+
followup_prompt = "Now print them in reverse order (5 to 1)"
226+
print(f" Step 2 — Follow-up prompt: {followup_prompt}")
227+
228+
followup_resp = requests.post(
229+
f"{CODE_GENERATOR_URL}/api/generate-code-followup",
230+
json={"prompt": followup_prompt, "history": history},
231+
timeout=120,
232+
)
233+
234+
if followup_resp.status_code != 200:
235+
print(f" ⚠️ Follow-up failed ({followup_resp.status_code})")
236+
return False
237+
238+
followup_result = followup_resp.json()
239+
followup_exec = followup_result.get("result", {})
240+
241+
print(f" Follow-up status: {followup_exec.get('status')}")
242+
print(f" Follow-up code:\n{'=' * 40}")
243+
print(followup_result.get("code", "No code"))
244+
print("=" * 40)
245+
print(f" Follow-up output: {followup_exec.get('output', 'no output')[:200]}")
246+
print(f" Follow-up rag_context present: {'rag_context' in followup_result}")
247+
248+
if followup_exec.get("error"):
249+
print(f" Follow-up error: {followup_exec['error'][:200]}")
250+
251+
retries = followup_result.get("retries", [])
252+
if retries:
253+
print(f" Retries: {len(retries)}")
254+
255+
success = followup_exec.get("status") == "success"
256+
257+
# Step 3: Send a second follow-up (3-turn conversation)
258+
if success:
259+
history.append({"role": "user", "prompt": followup_prompt, "code": "", "output": "", "error": "", "rag_context": ""})
260+
history.append({
261+
"role": "assistant", "prompt": "",
262+
"code": followup_result.get("code", ""),
263+
"output": followup_exec.get("output", ""),
264+
"error": "",
265+
"rag_context": followup_result.get("rag_context", ""),
266+
})
267+
268+
second_followup = "Now print them with their squares, like '1: 1', '2: 4', '3: 9' etc."
269+
print(f" Step 3 — Second follow-up: {second_followup}")
270+
271+
resp3 = requests.post(
272+
f"{CODE_GENERATOR_URL}/api/generate-code-followup",
273+
json={"prompt": second_followup, "history": history},
274+
timeout=120,
275+
)
276+
277+
if resp3.status_code == 200:
278+
r3 = resp3.json()
279+
status3 = r3.get("result", {}).get("status")
280+
print(f" 3rd turn status: {status3}")
281+
print(f" 3rd turn output: {r3.get('result', {}).get('output', '')[:200]}")
282+
if status3 == "success":
283+
print(" ✓ 3-turn conversation succeeded!")
284+
else:
285+
print(f" ⚠️ 3rd turn failed: {r3.get('result', {}).get('error', '')[:200]}")
286+
else:
287+
print(f" ⚠️ 3rd turn HTTP error: {resp3.status_code}")
288+
289+
print()
290+
return success
291+
292+
293+
def test_followup_empty_prompt():
294+
"""Test that followup endpoint rejects empty prompts."""
295+
print("Testing followup with empty prompt...")
296+
297+
resp = requests.post(
298+
f"{CODE_GENERATOR_URL}/api/generate-code-followup",
299+
json={"prompt": "", "history": []},
300+
timeout=10,
301+
)
302+
303+
success = resp.status_code == 400
304+
print(f" Status: {resp.status_code} (expected 400)")
305+
print(f" Response: {resp.json()}")
306+
print()
307+
return success
308+
309+
161310
def main():
162311
"""Run all tests."""
163312
print("=" * 60)
@@ -169,6 +318,9 @@ def main():
169318
("Simple Code Generation", test_simple_code_generation),
170319
("Error Handling", test_error_with_retry),
171320
("Feedback / Verified Solutions", test_feedback_endpoint),
321+
("RAG Context in Response", test_rag_context_in_response),
322+
("Follow-up Conversation", test_followup_conversation),
323+
("Follow-up Empty Prompt", test_followup_empty_prompt),
172324
]
173325

174326
results = []

0 commit comments

Comments
 (0)