|
23 | 23 | from langchain_anthropic import ChatAnthropic |
24 | 24 | from langchain_chroma import Chroma |
25 | 25 | from langchain_community.embeddings import FastEmbedEmbeddings |
26 | | -from langchain_core.messages import HumanMessage, SystemMessage |
| 26 | +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage |
27 | 27 | from langgraph.graph import END, StateGraph |
28 | 28 |
|
29 | 29 | logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
@@ -406,6 +406,7 @@ def _format_response(state: AgentState) -> dict: |
406 | 406 | retry_history = state.get("retry_history", []) |
407 | 407 | return { |
408 | 408 | "code": state.get("code", ""), |
| 409 | + "rag_context": state.get("rag_context", ""), |
409 | 410 | "result": { |
410 | 411 | "status": "success" if result.get("ok") else "error", |
411 | 412 | "output": result.get("std_out", "").strip(), |
@@ -542,6 +543,199 @@ def generate_code_endpoint(): |
542 | 543 | }), 500 |
543 | 544 |
|
544 | 545 |
|
| 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 | + |
545 | 739 | @app.route("/api/metrics", methods=["GET"]) |
546 | 740 | def metrics_endpoint(): |
547 | 741 | """Return aggregated observability stats and a dashboard PNG.""" |
|
0 commit comments