Pawan Mane commited on
Commit
8986591
Β·
1 Parent(s): 370d216

Initial Changes

Browse files
.dockerignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+
5
+ # Local env β€” secrets never go into the image
6
+ .env
7
+ .env.*
8
+
9
+ # Python cache
10
+ __pycache__
11
+ *.pyc
12
+ *.pyo
13
+ *.pyd
14
+ .Python
15
+ *.egg-info
16
+ dist/
17
+ build/
18
+
19
+ # Tests
20
+ tests/
21
+
22
+ # Docker files (not needed inside image)
23
+ Dockerfile
24
+ Dockerfile.space
25
+ docker-compose.yml
26
+
27
+ # Local dev notes
28
+ *.md
29
+ !README.md
.env.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ GROQ_API_KEY=your_groq_api_key_here
2
+ HUGGINGFACEHUB_API_TOKEN=your_groq_api_key_here
3
+ HF_TOKEN=your_groq_api_key_here
4
+ WEATHER_API_KEY=your_weatherstack_api_key_here
5
+ LLM_MODEL=llama-3.3-70b-versatile
6
+ LLM_TEMPERATURE=0
7
+ MAX_RETRIES=3
8
+ EVAL_THRESHOLD=0.6
9
+ HITL_ENABLED=true
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingFace Spaces β€” Dockerfile
2
+ # Docs: https://huggingface.co/docs/hub/spaces-sdks-docker
3
+ #
4
+ # Rules for HF Spaces:
5
+ # - Must expose port 7860
6
+ # - Must run as non-root user (uid 1000)
7
+ # - No BuildKit cache mounts (HF builder doesn't support --mount)
8
+ # - Secrets injected via Space Settings β†’ Variables, not .env file
9
+
10
+ FROM python:3.10-slim
11
+
12
+ ENV PYTHONDONTWRITEBYTECODE=1 \
13
+ PYTHONUNBUFFERED=1 \
14
+ PIP_NO_CACHE_DIR=1 \
15
+ PIP_ROOT_USER_ACTION=ignore \
16
+ PYTHONPATH=/app \
17
+ GRADIO_MODE=true \
18
+ GRADIO_SERVER_NAME=0.0.0.0 \
19
+ GRADIO_SERVER_PORT=7860
20
+
21
+ WORKDIR /app
22
+
23
+ # System deps
24
+ RUN apt-get update && apt-get install -y --no-install-recommends \
25
+ build-essential \
26
+ git \
27
+ && rm -rf /var/lib/apt/lists/*
28
+
29
+ # Install heavy ML packages first (longest layer)
30
+ RUN pip install --upgrade pip && \
31
+ pip install \
32
+ --extra-index-url https://download.pytorch.org/whl/cpu \
33
+ torch \
34
+ sentence-transformers \
35
+ transformers \
36
+ faiss-cpu
37
+
38
+ # Install remaining dependencies
39
+ COPY requirements.txt .
40
+ RUN pip install -r requirements.txt
41
+
42
+ # Copy source
43
+ COPY . .
44
+
45
+ # HuggingFace Spaces requires non-root user uid=1000
46
+ RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
47
+ USER appuser
48
+
49
+ EXPOSE 7860
50
+
51
+ CMD ["python", "app/frontend/gradio_app_hf.py"]
README.md CHANGED
@@ -8,3 +8,89 @@ pinned: false
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
11
+
12
+ # LangGraph Agent β€” Modular Structure
13
+
14
+ A production-ready LangGraph application with 8 agentic checkpoints,
15
+ modular architecture, and Docker support.
16
+
17
+ ## Project Structure
18
+
19
+ ```
20
+ langgraph_agent/
21
+ β”œβ”€β”€ app/
22
+ β”‚ β”œβ”€β”€ config.py # All settings (env-driven)
23
+ β”‚ β”œβ”€β”€ state.py # AgentState TypedDict
24
+ β”‚ β”œβ”€β”€ nodes/
25
+ β”‚ β”‚ β”œβ”€β”€ router.py # βœ… Checkpoint 3 β€” Conditional routing
26
+ β”‚ β”‚ β”œβ”€β”€ rag.py # βœ… Checkpoint 2 β€” RAG retrieval
27
+ β”‚ β”‚ β”œβ”€β”€ llm_node.py # βœ… Checkpoint 4 β€” Retries
28
+ β”‚ β”‚ β”œβ”€β”€ tool_executor.py # βœ… Checkpoint 1 β€” Tool execution
29
+ β”‚ β”‚ β”œβ”€β”€ memory.py # βœ… Checkpoint 5 β€” Memory
30
+ β”‚ β”‚ β”œβ”€β”€ hitl.py # βœ… Checkpoint 6 β€” Human-in-the-Loop
31
+ β”‚ β”‚ β”œβ”€β”€ evaluation.py # βœ… Checkpoint 7 β€” Evaluation
32
+ β”‚ β”‚ β”œβ”€β”€ guardrails.py # βœ… Checkpoint 8 β€” Guardrails
33
+ β”‚ β”‚ └── output.py # Final output node
34
+ β”‚ β”œβ”€β”€ tools/
35
+ β”‚ β”‚ β”œβ”€β”€ calculator.py # Math expression tool
36
+ β”‚ β”‚ └── weather.py # Weatherstack API tool
37
+ β”‚ β”œβ”€β”€ rag/
38
+ β”‚ β”‚ └── store.py # FAISS vector store + retrieval
39
+ β”‚ β”œβ”€β”€ graph/
40
+ β”‚ β”‚ └── builder.py # Graph topology assembly
41
+ β”‚ └── utils/
42
+ β”‚ └── llm.py # LLM singleton factory
43
+ β”œβ”€β”€ tests/
44
+ β”‚ └── test_nodes.py # Unit tests (no API key needed)
45
+ β”œβ”€β”€ main.py # CLI entry point
46
+ β”œβ”€β”€ requirements.txt
47
+ β”œβ”€β”€ Dockerfile
48
+ β”œβ”€β”€ docker-compose.yml
49
+ └── .env.example
50
+ ```
51
+
52
+ ## Quickstart
53
+
54
+ ### Local
55
+
56
+ ```bash
57
+ cp .env.example .env
58
+ # Fill in GROQ_API_KEY and WEATHER_API_KEY
59
+
60
+ pip install -r requirements.txt
61
+ python main.py
62
+ ```
63
+
64
+ ### Docker
65
+
66
+ ```bash
67
+ cp .env.example .env
68
+ # Fill in your API keys in .env
69
+
70
+ docker compose up --build
71
+ ```
72
+
73
+ ### Run tests (no API keys needed)
74
+
75
+ ```bash
76
+ pip install pytest
77
+ pytest tests/
78
+ ```
79
+
80
+ ## Adding a new tool
81
+
82
+ 1. Create `app/tools/my_tool.py` with a `@tool` function
83
+ 2. Import it in `app/tools/__init__.py` and add to `ALL_TOOLS`
84
+ 3. Done β€” the router and LLM binding pick it up automatically
85
+
86
+ ## Environment Variables
87
+
88
+ | Variable | Description | Default |
89
+ |------------------|------------------------------------|-----------------------------|
90
+ | GROQ_API_KEY | Groq API key | required |
91
+ | WEATHER_API_KEY | Weatherstack API key | required for weather tool |
92
+ | LLM_MODEL | Groq model name | llama-3.3-70b-versatile |
93
+ | LLM_TEMPERATURE | LLM temperature | 0 |
94
+ | MAX_RETRIES | Max LLM retry attempts | 3 |
95
+ | EVAL_THRESHOLD | Min quality score before retry | 0.6 |
96
+ | HITL_ENABLED | Enable human approval gate | true |
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/config.py
3
+ ─────────────
4
+ Central configuration β€” all env-driven settings live here.
5
+ """
6
+
7
+ import os
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+
12
+
13
+ class Config:
14
+ # ── LLM ───────────────────────────────────────────────────────────────
15
+ GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
16
+ LLM_MODEL: str = os.getenv("LLM_MODEL", "llama-3.3-70b-versatile")
17
+ LLM_TEMPERATURE: float = float(os.getenv("LLM_TEMPERATURE", "0"))
18
+
19
+ # ── External APIs ─────────────────────────────────────────────────────
20
+ WEATHER_API_KEY: str = os.getenv("WEATHER_API_KEY", "")
21
+
22
+ # ── Agent behaviour ───────────────────────────────────────────────────
23
+ MAX_RETRIES: int = int(os.getenv("MAX_RETRIES", "3"))
24
+ EVAL_THRESHOLD: float = float(os.getenv("EVAL_THRESHOLD", "0.6"))
25
+ HITL_ENABLED: bool = os.getenv("HITL_ENABLED", "true").lower() == "true"
26
+
27
+ # ── UI mode ───────────────────────────────────────────────────────────
28
+ # Set to true when running under Gradio β€” switches HITL from input()
29
+ # to the exception-based pause/resume mechanism
30
+ GRADIO_MODE: bool = os.getenv("GRADIO_MODE", "false").lower() == "true"
31
+
32
+ # ── RAG ───────────────────────────────────────────────────────────────
33
+ EMBEDDING_MODEL: str = "sentence-transformers/all-MiniLM-L6-v2"
34
+ RAG_TOP_K: int = 2
35
+
36
+ # ── Guardrails ────────────────────────────────────────────────────────
37
+ BLOCKED_PHRASES: list = ["harm", "illegal", "violence", "hate"]
38
+
39
+
40
+ settings = Config()
app/frontend/css.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CSS = """
2
+ /* Claude's exact font stack */
3
+ *, *::before, *::after {
4
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
5
+ Helvetica, Arial, sans-serif !important;
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ footer { display: none !important; }
10
+
11
+ /* Full-page warm dark background */
12
+ .gradio-container {
13
+ max-width: 100% !important;
14
+ width: 100% !important;
15
+ padding: 12px !important;
16
+ margin: 0 !important;
17
+ min-height: 100vh;
18
+ background: #1c1917 !important;
19
+ }
20
+
21
+ /* Gradio theme token overrides β€” warm stone palette */
22
+ .gradio-container, .wrap, .prose {
23
+ --body-background-fill: #1c1917 !important;
24
+ --background-fill-primary: #28211e !important;
25
+ --background-fill-secondary: #1c1917 !important;
26
+ --border-color-primary: #44403c !important;
27
+ --color-accent: #a78bfa !important;
28
+ --button-primary-background-fill: #7c3aed !important;
29
+ --button-primary-background-fill-hover: #6d28d9 !important;
30
+ --button-primary-text-color: #ffffff !important;
31
+ --input-background-fill: #28211e !important;
32
+ --block-background-fill: #28211e !important;
33
+ --block-border-color: #44403c !important;
34
+ --body-text-color: #e7e5e4 !important;
35
+ --body-text-color-subdued: #a8a29e !important;
36
+ }
37
+
38
+ /* ── Bordered section boxes ── */
39
+ /* Every top-level gr.Group or gr.Column block */
40
+ .section-box {
41
+ border: 1px solid #44403c !important;
42
+ border-radius: 12px !important;
43
+ background: #211e1b !important;
44
+ padding: 16px !important;
45
+ margin-bottom: 10px !important;
46
+ }
47
+
48
+ /* Override Gradio's own block borders to match our style */
49
+ .block {
50
+ border-radius: 12px !important;
51
+ border: 1px solid #44403c !important;
52
+ background: #211e1b !important;
53
+ }
54
+
55
+ /* Don't double-border inner elements */
56
+ .block .block { border: none !important; background: transparent !important; }
57
+
58
+ /* Chatbot window itself */
59
+ .chatbot-block { border: 1px solid #44403c !important; border-radius: 12px !important; overflow: hidden !important; }
60
+
61
+ /* Chat bubbles */
62
+ .message.user {
63
+ background: #3b1f6b !important;
64
+ border: 1px solid #5b21b6 !important;
65
+ color: #ede9fe !important;
66
+ border-radius: 18px 18px 4px 18px !important;
67
+ font-size: 15px !important;
68
+ line-height: 1.65 !important;
69
+ }
70
+ .message.bot, .message.assistant {
71
+ background: #2a2420 !important;
72
+ border: 1px solid #44403c !important;
73
+ color: #e7e5e4 !important;
74
+ border-radius: 4px 18px 18px 18px !important;
75
+ font-size: 15px !important;
76
+ line-height: 1.65 !important;
77
+ }
78
+ .avatar-container { display: none !important; }
79
+
80
+ /* Input textarea */
81
+ textarea {
82
+ font-size: 15px !important;
83
+ line-height: 1.5 !important;
84
+ }
85
+
86
+ /* HITL warning box */
87
+ .hitl-box {
88
+ border: 1px solid #92400e !important;
89
+ border-radius: 12px !important;
90
+ background: #1c1007 !important;
91
+ padding: 14px 16px !important;
92
+ }
93
+ """
app/frontend/gradio_app.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/frontend/gradio_app.py β€” Full page warm gray UI
3
+ """
4
+ import os
5
+ import gradio as gr
6
+ from langchain_core.messages import HumanMessage
7
+
8
+ os.environ["GRADIO_MODE"] = "true"
9
+ os.environ["HITL_ENABLED"] = os.getenv("HITL_ENABLED", "true")
10
+
11
+ from app.graph.builder import build_graph
12
+ from app.state import AgentState
13
+ from app.nodes.hitl import HITLPauseException
14
+
15
+ _graph = build_graph()
16
+ _thread_config = {"configurable": {"thread_id": "gradio-session-001"}}
17
+ _conversation_history = []
18
+ _pending_hitl_state: AgentState | None = None
19
+
20
+
21
+ def run_graph(query: str) -> AgentState:
22
+ global _conversation_history
23
+ _conversation_history.append(HumanMessage(content=query))
24
+ initial_state: AgentState = {
25
+ "messages": _conversation_history.copy(), "query": query,
26
+ "route": "", "rag_context": "", "tool_calls": [], "tool_results": [],
27
+ "response": "", "retry_count": 0, "hitl_approved": False,
28
+ "evaluation_score": 0.0, "guardrail_passed": True,
29
+ "memory_summary": "", "node_log": [],
30
+ }
31
+ return _graph.invoke(initial_state, config=_thread_config)
32
+
33
+
34
+ def resume_graph_after_hitl(state: AgentState, approved: bool) -> AgentState:
35
+ global _conversation_history
36
+ from app.nodes.evaluation import evaluation_node, eval_route
37
+ from app.nodes.guardrails import guardrails_node
38
+ from app.nodes.output import output_node
39
+ if not approved:
40
+ return {**state, "response": "🚫 Response rejected by human reviewer."}
41
+ s = evaluation_node({**state, "hitl_approved": True})
42
+ if eval_route(s) == "retry":
43
+ from app.nodes.llm_node import llm_node
44
+ s = llm_node(s)
45
+ s = guardrails_node(s)
46
+ s = output_node(s)
47
+ _conversation_history = s["messages"]
48
+ return s
49
+
50
+
51
+ def format_trace(node_log: list) -> str:
52
+ if not node_log:
53
+ return "*Waiting for a query...*"
54
+ lines = []
55
+ for node in node_log:
56
+ if any(x in node for x in ["βœ…", "auto-pass", "approved", "output", "passed"]):
57
+ icon = "βœ…"
58
+ elif any(x in node for x in ["BLOCKED", "rejected", "FAILED", "ERROR"]):
59
+ icon = "❌"
60
+ elif any(x in node for x in ["retry", "⏳", "⏸"]):
61
+ icon = "πŸ”„"
62
+ else:
63
+ icon = "β–Έ"
64
+ lines.append(f"{icon} `{node}`")
65
+ return "\n\n".join(lines)
66
+
67
+
68
+ def user_msg(t): return {"role": "user", "content": t}
69
+ def bot_msg(t): return {"role": "assistant", "content": t}
70
+
71
+
72
+ def handle_submit(user_message, chat_history):
73
+ global _pending_hitl_state
74
+ if not user_message.strip():
75
+ return chat_history, "", "*Waiting for a query...*", "", gr.update(visible=False), gr.update(value="")
76
+
77
+ chat_history = chat_history + [user_msg(user_message)]
78
+ try:
79
+ fs = run_graph(user_message)
80
+ route = fs.get("route", "")
81
+ score = fs.get("evaluation_score", 0.0)
82
+ g_ok = fs.get("guardrail_passed", True)
83
+
84
+ # Guardrail blocked β€” remove this exchange from history so it
85
+ # doesn't poison the memory summary for future innocent queries
86
+ if not g_ok:
87
+ global _conversation_history
88
+ if _conversation_history:
89
+ _conversation_history.pop()
90
+
91
+ chat_history = chat_history + [bot_msg(fs.get("response", ""))]
92
+ meta = f"**Route:** {route.upper() or 'β€”'} Β· **Eval:** {score:.2f} Β· **Guardrail:** {'βœ… Passed' if g_ok else '🚫 Blocked'}"
93
+ return (chat_history, "", format_trace(fs.get("node_log", [])),
94
+ meta, gr.update(visible=False), gr.update(value=""))
95
+
96
+ except HITLPauseException as e:
97
+ _pending_hitl_state = e.state
98
+ log = e.state.get("node_log", []) + ["⏸ hitl β€” awaiting approval"]
99
+ chat_history = chat_history + [bot_msg("⏳ *Awaiting human approval...*")]
100
+ meta = f"**Route:** {e.state.get('route','').upper() or 'β€”'} Β· **Status:** ⏸ Pending HITL"
101
+ return (chat_history, "", format_trace(log),
102
+ meta, gr.update(visible=True),
103
+ gr.update(value=f"**Pending response:**\n\n{e.pending_response}"))
104
+
105
+ except Exception as e:
106
+ chat_history = chat_history + [bot_msg(f"❌ Error: {e}")]
107
+ return (chat_history, "", f"❌ `{e}`", "", gr.update(visible=False), gr.update(value=""))
108
+
109
+
110
+ def handle_approve(chat_history):
111
+ global _pending_hitl_state
112
+ if not _pending_hitl_state:
113
+ return chat_history, "*No trace.*", "", gr.update(visible=False)
114
+ fs = resume_graph_after_hitl(_pending_hitl_state, True)
115
+ _pending_hitl_state = None
116
+ if chat_history and chat_history[-1]["role"] == "assistant":
117
+ chat_history = chat_history[:-1] + [bot_msg(fs.get("response", ""))]
118
+ score = fs.get("evaluation_score", 0.0)
119
+ g_ok = fs.get("guardrail_passed", True)
120
+ meta = f"**Route:** {fs.get('route','').upper() or 'β€”'} Β· **Eval:** {score:.2f} Β· **Guardrail:** {'βœ… Passed' if g_ok else '🚫 Blocked'}"
121
+ return chat_history, format_trace(fs.get("node_log", []) + ["βœ… hitl approved β†’ output"]), meta, gr.update(visible=False)
122
+
123
+
124
+ def handle_reject(chat_history):
125
+ global _pending_hitl_state
126
+ _pending_hitl_state = None
127
+ if chat_history and chat_history[-1]["role"] == "assistant":
128
+ chat_history = chat_history[:-1] + [bot_msg("🚫 Rejected by reviewer.")]
129
+ return chat_history, "❌ `hitl rejected β†’ END`", "", gr.update(visible=False)
130
+
131
+
132
+ def handle_clear():
133
+ global _conversation_history, _pending_hitl_state
134
+ _conversation_history, _pending_hitl_state = [], None
135
+ return [], "", "*Waiting for a query...*", "", gr.update(visible=False)
136
+
137
+
138
+ from app.frontend.css import CSS
139
+
140
+
141
+ def build_ui():
142
+ with gr.Blocks(title="LangGraph Agent", css=CSS, theme=gr.themes.Soft()) as demo:
143
+
144
+ # ── Header ───────────────────────────────────────────────────
145
+ gr.Markdown("## πŸ€– LangGraph Agent")
146
+
147
+ with gr.Row(equal_height=True):
148
+
149
+ # ══ Main chat column ══════════════════════════════════════
150
+ with gr.Column(scale=4):
151
+
152
+ # Chat box
153
+ with gr.Group(elem_classes="section-box"):
154
+ chatbot = gr.Chatbot(
155
+ type="messages",
156
+ show_label=False,
157
+ height=500,
158
+ container=False,
159
+ placeholder="Send a message to get started.",
160
+ elem_classes="chatbot-block",
161
+ )
162
+
163
+ # HITL box
164
+ with gr.Group(visible=False, elem_classes="hitl-box") as hitl_panel:
165
+ hitl_content = gr.Markdown()
166
+ gr.Markdown("πŸ” **Human review required** β€” approve or reject before the response is sent.")
167
+ with gr.Row():
168
+ approve_btn = gr.Button("βœ… Approve", variant="primary")
169
+ reject_btn = gr.Button("❌ Reject", variant="stop")
170
+
171
+ # Input box
172
+ with gr.Group(elem_classes="section-box"):
173
+ with gr.Row():
174
+ user_input = gr.Textbox(
175
+ placeholder="Message LangGraph Agent...",
176
+ show_label=False, scale=7, lines=1, container=False,
177
+ )
178
+ send_btn = gr.Button("Send", variant="primary", scale=1)
179
+ clear_btn = gr.Button("πŸ—‘", variant="secondary", scale=0, min_width=44)
180
+ meta_display = gr.Markdown("")
181
+
182
+ # Examples box
183
+ with gr.Group(elem_classes="section-box"):
184
+ gr.Examples(
185
+ examples=[
186
+ ["What is RAG?"], ["What is LangGraph?"],
187
+ ["Calculate 25 * 48"], ["Weather in Mumbai?"],
188
+ ["Tell me a joke"], ["Explain HITL"],
189
+ ],
190
+ inputs=user_input,
191
+ label="Examples",
192
+ )
193
+
194
+ # ══ Right sidebar ══════════════════════════════════════════
195
+ with gr.Column(scale=1):
196
+
197
+ # Trace box
198
+ with gr.Group(elem_classes="section-box"):
199
+ gr.Markdown("**⚑ Execution Trace**")
200
+ trace_display = gr.Markdown("*Waiting for a query...*")
201
+
202
+ # Topology box
203
+ with gr.Group(elem_classes="section-box"):
204
+ gr.Markdown("""**πŸ—Ί Graph Topology**
205
+ ```
206
+ START β†’ router
207
+ β”œβ”€ rag β†’ llm
208
+ └─ tool/general β†’ llm
209
+ β”œβ”€ tool_executor
210
+ └─ memory β†’ hitl
211
+ β”œβ”€ evaluation
212
+ β”‚ β”œβ”€ retry β†’ llm
213
+ β”‚ └─ guardrails β†’ output
214
+ └─ END
215
+ ```""")
216
+
217
+ # ── Events ───────────────────────────────────────────────────
218
+ submit_outs = [chatbot, user_input, trace_display, meta_display, hitl_panel, hitl_content]
219
+ send_btn.click(fn=handle_submit, inputs=[user_input, chatbot], outputs=submit_outs)
220
+ user_input.submit(fn=handle_submit, inputs=[user_input, chatbot], outputs=submit_outs)
221
+
222
+ hitl_outs = [chatbot, trace_display, meta_display, hitl_panel]
223
+ approve_btn.click(fn=handle_approve, inputs=[chatbot], outputs=hitl_outs)
224
+ reject_btn.click(fn=handle_reject, inputs=[chatbot], outputs=hitl_outs)
225
+ clear_btn.click(fn=handle_clear, outputs=[chatbot, user_input, trace_display, meta_display, hitl_panel])
226
+
227
+ return demo
228
+
229
+
230
+ if __name__ == "__main__":
231
+ demo = build_ui()
232
+ demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
app/frontend/gradio_app_hf.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/frontend/gradio_app_hf.py
3
+ ──────────────────────────────
4
+ HuggingFace Spaces entry point.
5
+
6
+ Key differences from local gradio_app.py:
7
+ - Reads all config from environment variables (HF injects secrets as env vars)
8
+ - No .env file available on HF Spaces β€” dotenv is silenced gracefully
9
+ - Runs on port 7860 (HF Spaces requirement)
10
+ - PYTHONPATH=/app must be set in Dockerfile so `from app.*` imports resolve
11
+ """
12
+
13
+ import os
14
+
15
+ # ── Set env flags before any app imports ──────────────────────────────────
16
+ os.environ["GRADIO_MODE"] = "true"
17
+ os.environ["PYTHONPATH"] = "/app"
18
+
19
+ # HITL defaults to false on public spaces β€” override via HF Space Variables
20
+ # All other secrets (GROQ_API_KEY, WEATHER_API_KEY, LLM_MODEL etc.)
21
+ # are set in HuggingFace Space β†’ Settings β†’ Variables and Secrets
22
+
23
+ # ── Silence dotenv β€” no .env file exists on HF Spaces ─────────────────────
24
+ # app/config.py calls load_dotenv() which would print a warning if .env
25
+ # is missing. We patch it to a no-op before config is imported.
26
+ import sys
27
+ from unittest.mock import MagicMock
28
+ if "dotenv" not in sys.modules:
29
+ sys.modules["dotenv"] = MagicMock()
30
+
31
+ # ── Import the full app (config, graph, nodes all load here) ───────────────
32
+ import gradio as gr
33
+ from langchain_core.messages import HumanMessage
34
+
35
+ from app.graph.builder import build_graph
36
+ from app.state import AgentState
37
+ from app.nodes.hitl import HITLPauseException
38
+ from app.frontend.css import CSS
39
+
40
+
41
+ # ── Graph singleton ────────────────────────────────────────────────────────
42
+ _graph = build_graph()
43
+ _thread_config = {"configurable": {"thread_id": "hf-session-001"}}
44
+ _conversation_history = []
45
+ _pending_hitl_state: AgentState | None = None
46
+
47
+
48
+ # ── Core runner ────────────────────────────────────────────────────────────
49
+
50
+ def run_graph(query: str) -> AgentState:
51
+ global _conversation_history
52
+ _conversation_history.append(HumanMessage(content=query))
53
+ initial_state: AgentState = {
54
+ "messages": _conversation_history.copy(),
55
+ "query": query,
56
+ "route": "",
57
+ "rag_context": "",
58
+ "tool_calls": [],
59
+ "tool_results": [],
60
+ "response": "",
61
+ "retry_count": 0,
62
+ "hitl_approved": False,
63
+ "evaluation_score": 0.0,
64
+ "guardrail_passed": True,
65
+ "memory_summary": "",
66
+ "node_log": [],
67
+ }
68
+ return _graph.invoke(initial_state, config=_thread_config)
69
+
70
+
71
+ def resume_graph_after_hitl(state: AgentState, approved: bool) -> AgentState:
72
+ global _conversation_history
73
+ from app.nodes.evaluation import evaluation_node, eval_route
74
+ from app.nodes.guardrails import guardrails_node
75
+ from app.nodes.output import output_node
76
+ if not approved:
77
+ return {**state, "response": "🚫 Response rejected by human reviewer."}
78
+ s = evaluation_node({**state, "hitl_approved": True})
79
+ if eval_route(s) == "retry":
80
+ from app.nodes.llm_node import llm_node
81
+ s = llm_node(s)
82
+ s = guardrails_node(s)
83
+ s = output_node(s)
84
+ _conversation_history = s["messages"]
85
+ return s
86
+
87
+
88
+ # ── Helpers ────────────────────────────────────────────────────────────────
89
+
90
+ def format_trace(node_log: list) -> str:
91
+ if not node_log:
92
+ return "*Waiting for a query...*"
93
+ lines = []
94
+ for node in node_log:
95
+ if any(x in node for x in ["βœ…", "auto-pass", "approved", "output", "passed"]):
96
+ icon = "βœ…"
97
+ elif any(x in node for x in ["BLOCKED", "rejected", "FAILED", "ERROR"]):
98
+ icon = "❌"
99
+ elif any(x in node for x in ["retry", "⏳", "⏸"]):
100
+ icon = "πŸ”„"
101
+ else:
102
+ icon = "β–Έ"
103
+ lines.append(f"{icon} `{node}`")
104
+ return "\n\n".join(lines)
105
+
106
+
107
+ def user_msg(t): return {"role": "user", "content": t}
108
+ def bot_msg(t): return {"role": "assistant", "content": t}
109
+
110
+
111
+ # ── Event handlers ─────────────────────────────────────────────────────────
112
+
113
+ def handle_submit(user_message, chat_history):
114
+ global _pending_hitl_state, _conversation_history
115
+ if not user_message.strip():
116
+ return chat_history, "", "*Waiting for a query...*", "", gr.update(visible=False), gr.update(value="")
117
+
118
+ chat_history = chat_history + [user_msg(user_message)]
119
+ try:
120
+ fs = run_graph(user_message)
121
+ route = fs.get("route", "")
122
+ score = fs.get("evaluation_score", 0.0)
123
+ g_ok = fs.get("guardrail_passed", True)
124
+
125
+ # Drop blocked exchange from history to prevent memory poisoning
126
+ if not g_ok and _conversation_history:
127
+ _conversation_history.pop()
128
+
129
+ chat_history = chat_history + [bot_msg(fs.get("response", ""))]
130
+ meta = f"**Route:** {route.upper() or 'β€”'} Β· **Eval:** {score:.2f} Β· **Guardrail:** {'βœ… Passed' if g_ok else '🚫 Blocked'}"
131
+ return (chat_history, "", format_trace(fs.get("node_log", [])),
132
+ meta, gr.update(visible=False), gr.update(value=""))
133
+
134
+ except HITLPauseException as e:
135
+ _pending_hitl_state = e.state
136
+ log = e.state.get("node_log", []) + ["⏸ hitl β€” awaiting approval"]
137
+ chat_history = chat_history + [bot_msg("⏳ *Awaiting human approval...*")]
138
+ meta = f"**Route:** {e.state.get('route','').upper() or 'β€”'} Β· **Status:** ⏸ Pending HITL"
139
+ return (chat_history, "", format_trace(log),
140
+ meta, gr.update(visible=True),
141
+ gr.update(value=f"**Pending response:**\n\n{e.pending_response}"))
142
+
143
+ except Exception as e:
144
+ chat_history = chat_history + [bot_msg(f"❌ Error: {e}")]
145
+ return (chat_history, "", f"❌ `{e}`", "", gr.update(visible=False), gr.update(value=""))
146
+
147
+
148
+ def handle_approve(chat_history):
149
+ global _pending_hitl_state
150
+ if not _pending_hitl_state:
151
+ return chat_history, "*No trace.*", "", gr.update(visible=False)
152
+ fs = resume_graph_after_hitl(_pending_hitl_state, True)
153
+ _pending_hitl_state = None
154
+ if chat_history and chat_history[-1]["role"] == "assistant":
155
+ chat_history = chat_history[:-1] + [bot_msg(fs.get("response", ""))]
156
+ score = fs.get("evaluation_score", 0.0)
157
+ g_ok = fs.get("guardrail_passed", True)
158
+ meta = f"**Route:** {fs.get('route','').upper() or 'β€”'} Β· **Eval:** {score:.2f} Β· **Guardrail:** {'βœ… Passed' if g_ok else '🚫 Blocked'}"
159
+ return chat_history, format_trace(fs.get("node_log", []) + ["βœ… hitl approved β†’ output"]), meta, gr.update(visible=False)
160
+
161
+
162
+ def handle_reject(chat_history):
163
+ global _pending_hitl_state
164
+ _pending_hitl_state = None
165
+ if chat_history and chat_history[-1]["role"] == "assistant":
166
+ chat_history = chat_history[:-1] + [bot_msg("🚫 Rejected by reviewer.")]
167
+ return chat_history, "❌ `hitl rejected β†’ END`", "", gr.update(visible=False)
168
+
169
+
170
+ def handle_clear():
171
+ global _conversation_history, _pending_hitl_state
172
+ _conversation_history, _pending_hitl_state = [], None
173
+ return [], "", "*Waiting for a query...*", "", gr.update(visible=False)
174
+
175
+
176
+ # ── UI ─────────────────────────────────────────────────────────────────────
177
+
178
+ def build_ui():
179
+ with gr.Blocks(title="LangGraph Agent", css=CSS, theme=gr.themes.Soft()) as demo:
180
+
181
+ gr.Markdown("## πŸ€– LangGraph Agent")
182
+
183
+ with gr.Row(equal_height=True):
184
+
185
+ # ══ Main chat ═════════════════════════════════════════════
186
+ with gr.Column(scale=4):
187
+
188
+ with gr.Group(elem_classes="section-box"):
189
+ chatbot = gr.Chatbot(
190
+ type="messages", show_label=False, height=500,
191
+ container=False,
192
+ placeholder="Send a message to get started.",
193
+ elem_classes="chatbot-block",
194
+ )
195
+
196
+ with gr.Group(visible=False, elem_classes="hitl-box") as hitl_panel:
197
+ hitl_content = gr.Markdown()
198
+ gr.Markdown("πŸ” **Human review required** β€” approve or reject before the response is sent.")
199
+ with gr.Row():
200
+ approve_btn = gr.Button("βœ… Approve", variant="primary")
201
+ reject_btn = gr.Button("❌ Reject", variant="stop")
202
+
203
+ with gr.Group(elem_classes="section-box"):
204
+ with gr.Row():
205
+ user_input = gr.Textbox(
206
+ placeholder="Message LangGraph Agent...",
207
+ show_label=False, scale=7, lines=1, container=False,
208
+ )
209
+ send_btn = gr.Button("Send", variant="primary", scale=1)
210
+ clear_btn = gr.Button("πŸ—‘", variant="secondary", scale=0, min_width=44)
211
+ meta_display = gr.Markdown("")
212
+
213
+ with gr.Group(elem_classes="section-box"):
214
+ gr.Examples(
215
+ examples=[
216
+ ["What is RAG?"], ["What is LangGraph?"],
217
+ ["Calculate 25 * 48"], ["Weather in Mumbai?"],
218
+ ["Tell me a joke"], ["Explain HITL"],
219
+ ],
220
+ inputs=user_input,
221
+ label="Examples",
222
+ )
223
+
224
+ # ══ Right sidebar ══════════════════════════════════════════
225
+ with gr.Column(scale=1):
226
+
227
+ with gr.Group(elem_classes="section-box"):
228
+ gr.Markdown("**⚑ Execution Trace**")
229
+ trace_display = gr.Markdown("*Waiting for a query...*")
230
+
231
+ with gr.Group(elem_classes="section-box"):
232
+ gr.Markdown("""**πŸ—Ί Graph Topology**
233
+ ```
234
+ START β†’ router
235
+ β”œβ”€ rag β†’ llm
236
+ └─ tool/general β†’ llm
237
+ β”œβ”€ tool_executor
238
+ └─ memory β†’ hitl
239
+ β”œβ”€ evaluation
240
+ β”‚ β”œβ”€ retry β†’ llm
241
+ β”‚ └─ guardrails β†’ output
242
+ └─ END
243
+ ```""")
244
+
245
+ submit_outs = [chatbot, user_input, trace_display, meta_display, hitl_panel, hitl_content]
246
+ send_btn.click(fn=handle_submit, inputs=[user_input, chatbot], outputs=submit_outs)
247
+ user_input.submit(fn=handle_submit, inputs=[user_input, chatbot], outputs=submit_outs)
248
+
249
+ hitl_outs = [chatbot, trace_display, meta_display, hitl_panel]
250
+ approve_btn.click(fn=handle_approve, inputs=[chatbot], outputs=hitl_outs)
251
+ reject_btn.click(fn=handle_reject, inputs=[chatbot], outputs=hitl_outs)
252
+ clear_btn.click(fn=handle_clear, outputs=[chatbot, user_input, trace_display, meta_display, hitl_panel])
253
+
254
+ return demo
255
+
256
+
257
+ # ── Launch ─────────────────────────────────────────────────────────────────
258
+
259
+ if __name__ == "__main__":
260
+ demo = build_ui()
261
+ demo.launch(
262
+ server_name="0.0.0.0",
263
+ server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
264
+ show_error=True,
265
+ )
app/graph/__init__.py ADDED
File without changes
app/graph/builder.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/graph/builder.py
3
+ ─────────────────────
4
+ Assembles the LangGraph StateGraph from all nodes and edges.
5
+ This is the only file that knows about graph topology.
6
+
7
+ Graph topology:
8
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
9
+ β”Œβ”€β”€β”€β”€β–Ίβ”‚ rag │────┐
10
+ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
11
+ [START] ─► router β–Ό
12
+ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
13
+ └────►│ llm (tool / general) β”‚
14
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
15
+ β”‚ β”‚
16
+ tool_calls? none
17
+ β”‚ β”‚
18
+ tool_executor β”‚
19
+ β”‚ β”‚
20
+ β–Ό β–Ό
21
+ memory β—„β”€β”€β”€β”€β”˜
22
+ β”‚
23
+ hitl ──(rejected)──► END
24
+ β”‚
25
+ evaluation ──(retry)──► llm
26
+ β”‚
27
+ guardrails
28
+ β”‚
29
+ output
30
+ β”‚
31
+ END
32
+ """
33
+
34
+ from langgraph.graph import StateGraph, END
35
+ from langgraph.checkpoint.memory import MemorySaver
36
+ from app.state import AgentState
37
+ from app.nodes import (
38
+ router_node, route_selector,
39
+ rag_node,
40
+ llm_node,
41
+ tool_executor_node,
42
+ memory_node,
43
+ hitl_node, hitl_route,
44
+ evaluation_node, eval_route,
45
+ guardrails_node,
46
+ output_node,
47
+ )
48
+
49
+
50
+ def build_graph():
51
+ """Compile and return the full LangGraph agent."""
52
+ builder = StateGraph(AgentState)
53
+
54
+ # ── Register nodes ────────────────────────────────────────────────────
55
+ builder.add_node("router", router_node)
56
+ builder.add_node("rag", rag_node)
57
+ builder.add_node("llm", llm_node)
58
+ builder.add_node("tool_executor", tool_executor_node)
59
+ builder.add_node("memory", memory_node)
60
+ builder.add_node("hitl", hitl_node)
61
+ builder.add_node("evaluation", evaluation_node)
62
+ builder.add_node("guardrails", guardrails_node)
63
+ builder.add_node("output", output_node)
64
+
65
+ # ── Entry point ───────────────────────────────────────────────────────
66
+ builder.set_entry_point("router")
67
+
68
+ # ── Conditional routing (CHECKPOINT 3) ────────────────────────────────
69
+ builder.add_conditional_edges(
70
+ "router",
71
+ route_selector,
72
+ {
73
+ "rag": "rag", # Knowledge query β†’ retrieve then answer
74
+ "tool": "llm", # Tool query β†’ LLM decides which tool to call
75
+ "general": "llm", # Chat query β†’ straight to LLM
76
+ },
77
+ )
78
+
79
+ # RAG retrieval feeds into the LLM node
80
+ builder.add_edge("rag", "llm")
81
+
82
+ # After LLM: execute tools if requested, else go straight to memory
83
+ builder.add_conditional_edges(
84
+ "llm",
85
+ lambda s: "tool_executor" if s.get("tool_calls") else "memory",
86
+ {"tool_executor": "tool_executor", "memory": "memory"},
87
+ )
88
+
89
+ builder.add_edge("tool_executor", "memory")
90
+
91
+ # Memory β†’ HITL review (CHECKPOINT 6)
92
+ builder.add_edge("memory", "hitl")
93
+
94
+ # HITL approval gate
95
+ builder.add_conditional_edges(
96
+ "hitl",
97
+ hitl_route,
98
+ {"evaluation": "evaluation", "end": END},
99
+ )
100
+
101
+ # Evaluation quality gate β€” may loop back to LLM (CHECKPOINT 7)
102
+ builder.add_conditional_edges(
103
+ "evaluation",
104
+ eval_route,
105
+ {"retry": "llm", "guardrails": "guardrails"},
106
+ )
107
+
108
+ # Safety filter β†’ final output
109
+ builder.add_edge("guardrails", "output")
110
+ builder.add_edge("output", END)
111
+
112
+ # MemorySaver persists state across invocations (CHECKPOINT 5)
113
+ checkpointer = MemorySaver()
114
+ return builder.compile(checkpointer=checkpointer)
app/nodes/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """Re-export every node for clean imports in graph/builder.py."""
2
+ from app.nodes.router import router_node, route_selector
3
+ from app.nodes.rag import rag_node
4
+ from app.nodes.llm_node import llm_node
5
+ from app.nodes.tool_executor import tool_executor_node
6
+ from app.nodes.memory import memory_node
7
+ from app.nodes.hitl import hitl_node, hitl_route
8
+ from app.nodes.evaluation import evaluation_node, eval_route
9
+ from app.nodes.guardrails import guardrails_node
10
+ from app.nodes.output import output_node
app/nodes/evaluation.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/evaluation.py β€” CHECKPOINT 7: Evaluation
3
+
4
+ Extra fix: detect LLM refusal responses and auto-pass them
5
+ so we don't waste retries on intentional refusals.
6
+ """
7
+ from langchain_core.messages import HumanMessage
8
+ from app.state import AgentState
9
+ from app.utils.llm import llm
10
+ from app.config import settings
11
+
12
+ # Phrases that indicate the LLM intentionally refused β€” don't retry these
13
+ REFUSAL_PHRASES = [
14
+ "sensitive", "harmful", "hate", "threat", "negative", "i can't help with that."
15
+ "i cannot provide information on",
16
+ "i can't help", "i cannot help", "i'm unable", "i am unable",
17
+ "i won't", "i will not", "not able to assist", "can't assist",
18
+ "i'm sorry, i can't", "i'm not able", "as an ai, i cannot",
19
+ "i must decline", "i'm going to have to decline",
20
+ ]
21
+
22
+
23
+ def evaluation_node(state: AgentState) -> AgentState:
24
+ log = state.get("node_log", [])
25
+ response_lower = state.get("response", "").lower()
26
+
27
+ # Tool responses are always valid β€” skip LLM scoring
28
+ if state.get("route") == "tool" or state.get("tool_results"):
29
+ print("[EVAL] Tool response β€” auto-passed.")
30
+ return {**state, "evaluation_score": 1.0, "node_log": log + ["evaluation (tool auto-pass βœ…)"]}
31
+
32
+ # Refusal responses are intentional β€” don't retry, let guardrails handle
33
+ if any(phrase in response_lower for phrase in REFUSAL_PHRASES):
34
+ print("[EVAL] LLM refusal detected β€” auto-passed to guardrails.")
35
+ return {
36
+ **state,
37
+ "evaluation_score": 1.0,
38
+ "node_log": log + ["evaluation (refusal auto-pass β†’ guardrails)"],
39
+ }
40
+
41
+ eval_prompt = f"""Rate the following AI response on a scale of 0.0 to 1.0
42
+ for relevance and quality relative to the query.
43
+ Return ONLY a float number between 0.0 and 1.0 β€” no other text.
44
+
45
+ Query: {state['query']}
46
+ Response: {state['response']}
47
+
48
+ Score:"""
49
+
50
+ try:
51
+ raw = llm.invoke([HumanMessage(content=eval_prompt)]).content.strip()
52
+ score = max(0.0, min(1.0, float(raw)))
53
+ except Exception:
54
+ score = 0.8
55
+
56
+ current_retries = state.get("retry_count", 0)
57
+ below_threshold = score < settings.EVAL_THRESHOLD
58
+ new_retry_count = (current_retries + 1) if below_threshold else current_retries
59
+
60
+ print(f"[EVAL] Score: {score:.2f} (threshold: {settings.EVAL_THRESHOLD}, retries: {current_retries})")
61
+ return {
62
+ **state,
63
+ "evaluation_score": score,
64
+ "retry_count": new_retry_count,
65
+ "node_log": log + [f"evaluation (score={score:.2f}, retry={new_retry_count})"],
66
+ }
67
+
68
+
69
+ def eval_route(state: AgentState) -> str:
70
+ score = state["evaluation_score"]
71
+ retry_count = state.get("retry_count", 0)
72
+
73
+ if score < settings.EVAL_THRESHOLD and retry_count <= settings.MAX_RETRIES:
74
+ print(f"[EVAL] Score {score:.2f} below threshold β€” retry {retry_count}/{settings.MAX_RETRIES}")
75
+ return "retry"
76
+
77
+ if score < settings.EVAL_THRESHOLD:
78
+ print(f"[EVAL] Max retries ({settings.MAX_RETRIES}) reached β€” proceeding anyway.")
79
+
80
+ return "guardrails"
app/nodes/guardrails.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/nodes/guardrails.py β€” CHECKPOINT 8: Guardrails"""
2
+ from app.state import AgentState
3
+ from app.config import settings
4
+
5
+ SAFE_FALLBACK = "I'm sorry, I can't help with that request."
6
+
7
+
8
+ def guardrails_node(state: AgentState) -> AgentState:
9
+ response_lower = state.get("response", "").lower()
10
+ triggered = [p for p in settings.BLOCKED_PHRASES if p in response_lower]
11
+ log = state.get("node_log", [])
12
+ if triggered:
13
+ print(f"[GUARDRAILS] ⚠️ Blocked β€” matched phrases: {triggered}")
14
+ log = log + [f"guardrails (BLOCKED: {triggered})"]
15
+ return {**state, "guardrail_passed": False, "response": SAFE_FALLBACK, "node_log": log}
16
+ print("[GUARDRAILS] βœ… Passed.")
17
+ log = log + ["guardrails βœ…"]
18
+ return {**state, "guardrail_passed": True, "node_log": log}
app/nodes/hitl.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/hitl.py
3
+ ──────────────────
4
+ CHECKPOINT 6 β€” HUMAN-IN-THE-LOOP (HITL)
5
+
6
+ Two modes:
7
+ β€’ CLI mode (HITL_ENABLED=true, GRADIO_MODE=false) β†’ uses input()
8
+ β€’ Gradio mode (GRADIO_MODE=true) β†’ stores pending response
9
+ in state and raises HITLPauseException so Gradio can show
10
+ Approve / Reject buttons and resume the graph after user clicks.
11
+ """
12
+
13
+ from app.state import AgentState
14
+ from app.config import settings
15
+
16
+
17
+ class HITLPauseException(Exception):
18
+ """
19
+ Raised by hitl_node when running under Gradio.
20
+ Carries the pending response so the UI can display it for approval.
21
+ """
22
+ def __init__(self, pending_response: str, state: AgentState):
23
+ self.pending_response = pending_response
24
+ self.state = state
25
+ super().__init__("HITL approval required")
26
+
27
+
28
+ def hitl_node(state: AgentState) -> AgentState:
29
+ """Show the pending response to a human and record their approval."""
30
+
31
+ # Auto-approve when HITL is disabled (CI / tests)
32
+ if not settings.HITL_ENABLED:
33
+ print("[HITL] Auto-approved (HITL_ENABLED=false).")
34
+ return {**state, "hitl_approved": True}
35
+
36
+ # Gradio mode β€” pause graph execution and let the UI handle approval
37
+ if settings.GRADIO_MODE:
38
+ raise HITLPauseException(
39
+ pending_response=state.get("response", ""),
40
+ state=state,
41
+ )
42
+
43
+ # CLI mode β€” blocking console prompt
44
+ print("\n" + "=" * 55)
45
+ print("[HITL] Agent wants to send this response:")
46
+ print("-" * 55)
47
+ print(state.get("response", "(no response yet)"))
48
+ print("=" * 55)
49
+ raw = input("[HITL] Approve? (yes/no): ").strip().lower()
50
+ approved = raw in ("yes", "y")
51
+ if not approved:
52
+ print("[HITL] Response rejected β€” stopping this turn.")
53
+ return {**state, "hitl_approved": approved}
54
+
55
+
56
+ def hitl_route(state: AgentState) -> str:
57
+ """Conditional edge: approved β†’ evaluation, rejected β†’ END."""
58
+ return "evaluation" if state["hitl_approved"] else "end"
app/nodes/llm_node.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/llm_node.py β€” CHECKPOINT 4: RETRIES
3
+
4
+ Fix: For tool routes, only send the current query to the LLM.
5
+ Full history causes the LLM to re-fire tools from previous turns.
6
+ For rag/general routes, full clean history is fine for context.
7
+ """
8
+ import time
9
+ from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage
10
+ from app.state import AgentState
11
+ from app.tools import ALL_TOOLS
12
+ from app.utils.llm import get_llm_with_tools, llm
13
+ from app.config import settings
14
+
15
+ _llm_with_tools = get_llm_with_tools(ALL_TOOLS)
16
+
17
+
18
+ def llm_node(state: AgentState) -> AgentState:
19
+ for attempt in range(1, settings.MAX_RETRIES + 1):
20
+ try:
21
+ system_parts = ["You are a helpful AI assistant."]
22
+ if state.get("rag_context"):
23
+ system_parts.append(f"\nUse the following context to answer:\n{state['rag_context']}")
24
+ if state.get("memory_summary"):
25
+ system_parts.append(f"\nPrevious conversation summary:\n{state['memory_summary']}")
26
+
27
+ system_msg = SystemMessage(content="\n".join(system_parts))
28
+
29
+ if state["route"] == "tool":
30
+ # Tool route: only send current query β€” never include history.
31
+ # History contains previous HumanMessages which confuse the LLM
32
+ # into calling tools for old queries alongside the new one.
33
+ messages = [system_msg, HumanMessage(content=state["query"])]
34
+ ai_msg = _llm_with_tools.invoke(messages)
35
+ else:
36
+ # RAG / general: full clean history gives the LLM good context
37
+ clean_messages = [
38
+ m for m in state["messages"]
39
+ if not isinstance(m, ToolMessage)
40
+ and not (isinstance(m, AIMessage) and getattr(m, "tool_calls", []))
41
+ ]
42
+ messages = [system_msg] + clean_messages
43
+ ai_msg = llm.invoke(messages)
44
+
45
+ tool_calls = getattr(ai_msg, "tool_calls", []) or []
46
+ response_text = ai_msg.content or ""
47
+
48
+ print(f"[LLM] Attempt {attempt} succeeded. Tool calls: {len(tool_calls)}")
49
+ print(f"[LLM] Generated Output for Usery Query ({state['query']}) : {response_text[0:200]}")
50
+ log = state.get("node_log", []) + [f"llm (attempt={attempt}, route={state['route']})"]
51
+
52
+ return {
53
+ **state,
54
+ "tool_calls": tool_calls,
55
+ "tool_results": [],
56
+ "response": response_text,
57
+ "node_log": log,
58
+ }
59
+
60
+ except Exception as e:
61
+ print(f"[LLM] Attempt {attempt}/{settings.MAX_RETRIES} failed: {e}")
62
+ if attempt == settings.MAX_RETRIES:
63
+ log = state.get("node_log", []) + [f"llm (FAILED after {attempt} attempts)"]
64
+ return {**state, "response": "Sorry, I encountered an error.", "node_log": log}
65
+ time.sleep(2 ** attempt)
66
+
67
+ return state
app/nodes/memory.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/memory.py β€” CHECKPOINT 5: Memory
3
+
4
+ Fix: Sanitize memory summary β€” if the summary contains refusal/harmful
5
+ context from a previous blocked query, reset it so it doesn't poison
6
+ future innocent queries.
7
+ """
8
+ from langchain_core.messages import HumanMessage, AIMessage
9
+ from app.state import AgentState
10
+ from app.utils.llm import llm
11
+
12
+ SUMMARY_THRESHOLD = 6 # increased so memory kicks in less aggressively
13
+
14
+ # If the summary contains these, it's tainted β€” reset it
15
+ TAINTED_PHRASES = [
16
+ "illegal", "harmful", "violence", "harm", "cannot help",
17
+ "can't help", "i'm unable", "i cannot provide",
18
+ ]
19
+
20
+
21
+ def _is_tainted(summary: str) -> bool:
22
+ low = summary.lower()
23
+ return any(p in low for p in TAINTED_PHRASES)
24
+
25
+
26
+ def memory_node(state: AgentState) -> AgentState:
27
+ log = state.get("node_log", []) + ["memory"]
28
+
29
+ # Reset tainted memory so it doesn't bleed into future turns
30
+ existing_summary = state.get("memory_summary", "")
31
+ if existing_summary and _is_tainted(existing_summary):
32
+ print("[MEMORY] Tainted summary detected β€” resetting.")
33
+ return {**state, "memory_summary": "", "node_log": log}
34
+
35
+ # Only summarise clean human/assistant turns β€” no tool messages
36
+ clean = [
37
+ m for m in state["messages"]
38
+ if isinstance(m, HumanMessage)
39
+ or (isinstance(m, AIMessage) and not getattr(m, "tool_calls", []))
40
+ ]
41
+
42
+ if len(clean) < SUMMARY_THRESHOLD:
43
+ return {**state, "node_log": log}
44
+
45
+ recent_text = "\n".join(
46
+ f"{'User' if isinstance(m, HumanMessage) else 'Assistant'}: {m.content}"
47
+ for m in clean[-SUMMARY_THRESHOLD:]
48
+ )
49
+ prompt = (
50
+ "Summarise the following conversation in 2-3 sentences, "
51
+ "focusing only on the topics discussed and useful context. "
52
+ "Do NOT include any harmful, violent, or illegal content in the summary.\n\n"
53
+ + recent_text
54
+ )
55
+ try:
56
+ summary = llm.invoke([HumanMessage(content=prompt)]).content
57
+
58
+ # Double-check the generated summary is not tainted
59
+ if _is_tainted(summary):
60
+ print("[MEMORY] Generated summary was tainted β€” discarding.")
61
+ return {**state, "memory_summary": "", "node_log": log}
62
+
63
+ print("[MEMORY] Summary updated.")
64
+ return {**state, "memory_summary": summary, "node_log": log}
65
+ except Exception as e:
66
+ print(f"[MEMORY] Summarisation failed: {e}")
67
+ return {**state, "node_log": log}
app/nodes/output.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """app/nodes/output.py β€” Final output node"""
2
+ from langchain_core.messages import AIMessage
3
+ from app.state import AgentState
4
+
5
+
6
+ def output_node(state: AgentState) -> AgentState:
7
+ ai_message = AIMessage(content=state["response"])
8
+ updated_messages = state["messages"] + [ai_message]
9
+ log = state.get("node_log", []) + ["output"]
10
+ print(f"\nπŸ€– {state['response']}\n")
11
+ return {**state, "messages": updated_messages, "node_log": log}
app/nodes/rag.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/rag.py
3
+ ─────────────────
4
+ CHECKPOINT 2 β€” RAG node
5
+
6
+ Retrieves relevant document chunks and stores them in state so the
7
+ LLM node can inject them into its prompt.
8
+ """
9
+
10
+ from app.state import AgentState
11
+ from app.rag.store import retrieve_context
12
+
13
+
14
+ def rag_node(state: AgentState) -> AgentState:
15
+ context = retrieve_context(state["query"])
16
+ print(f"[RAG] Retrieved {len(context.splitlines())} chunk(s).")
17
+ log = state.get("node_log", []) + ["rag"]
18
+ return {**state, "rag_context": context, "node_log": log}
app/nodes/router.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/router.py β€” CHECKPOINT 3: CONDITIONAL ROUTING
3
+ LLM-based semantic router that classifies query into rag / tool / general.
4
+ """
5
+
6
+ import json
7
+ from langchain_core.messages import HumanMessage
8
+ from app.state import AgentState
9
+ from app.tools import ALL_TOOLS
10
+ from app.utils.llm import llm
11
+
12
+
13
+ def router_node(state: AgentState) -> AgentState:
14
+ tool_descriptions = "\n".join(
15
+ f'- "{t.name}": {t.description}' for t in ALL_TOOLS
16
+ )
17
+ router_prompt = f"""You are a query router for an AI assistant.
18
+
19
+ Available tools:
20
+ {tool_descriptions}
21
+
22
+ Knowledge base topics (for RAG):
23
+ - LangGraph, RAG, Guardrails, HITL, Memory in AI agents, Tool calling, Conditional routing
24
+
25
+ Given the user query below, decide the best route:
26
+ β€’ "tool" β€” if any available tool can directly answer or act on this query
27
+ β€’ "rag" β€” if the query asks for information that exists in the knowledge base
28
+ β€’ "general" β€” for everything else (chit-chat, opinions, open-ended questions)
29
+
30
+ Respond ONLY with valid JSON β€” no explanation, no markdown fences:
31
+ {{"route": "<tool|rag|general>", "reason": "<one sentence why>"}}
32
+
33
+ User query: {state["query"]}"""
34
+
35
+ try:
36
+ response = llm.invoke([HumanMessage(content=router_prompt)])
37
+ raw = response.content.strip().removeprefix("```json").removesuffix("```").strip()
38
+ parsed = json.loads(raw)
39
+ route = parsed.get("route", "general")
40
+ reason = parsed.get("reason", "")
41
+ if route not in ("rag", "tool", "general"):
42
+ route = "general"
43
+ print(f"[ROUTER] β†’ '{route}' | {reason}")
44
+ except Exception as e:
45
+ print(f"[ROUTER] Failed ({e}), defaulting to 'general'.")
46
+ route = "general"
47
+
48
+ log = state.get("node_log", []) + [f"router β†’ {route}"]
49
+ return {**state, "route": route, "node_log": log}
50
+
51
+
52
+ def route_selector(state: AgentState) -> str:
53
+ return state["route"]
app/nodes/tool_executor.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/nodes/tool_executor.py β€” CHECKPOINT 1: Tool execution
3
+
4
+ Fix: Format tool results as natural language instead of raw key:value dump.
5
+ """
6
+ from app.state import AgentState
7
+ from app.tools import TOOL_MAP
8
+
9
+
10
+ def tool_executor_node(state: AgentState) -> AgentState:
11
+ results = []
12
+
13
+ for tc in state.get("tool_calls", []):
14
+ tool_name = tc["name"]
15
+ tool_args = tc.get("args", {})
16
+ if tool_name in TOOL_MAP:
17
+ result = TOOL_MAP[tool_name].invoke(tool_args)
18
+ print(f"[TOOL] {tool_name}({tool_args}) β†’ {result}")
19
+ results.append({"tool": tool_name, "result": result})
20
+ else:
21
+ results.append({"tool": tool_name, "result": "Tool not found."})
22
+
23
+ # Format as readable natural language instead of raw "tool: result" dump
24
+ if results:
25
+ if len(results) == 1:
26
+ response = str(results[0]["result"])
27
+ else:
28
+ lines = [f"- **{r['tool']}**: {r['result']}" for r in results]
29
+ response = "\n".join(lines)
30
+
31
+ log = state.get("node_log", []) + [f"tool_executor ({', '.join(r['tool'] for r in results)})"]
32
+ return {**state, "tool_results": results, "response": response, "node_log": log}
33
+
34
+ return state
app/rag/__init__.py ADDED
File without changes
app/rag/store.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/rag/store.py
3
+ ─────────────────
4
+ CHECKPOINT 2 β€” RAG (Retrieval-Augmented Generation)
5
+
6
+ Builds a FAISS vector store from sample documents and exposes a single
7
+ `retrieve_context(query)` function used by the RAG graph node.
8
+
9
+ How RAG works:
10
+ 1. Documents are split into chunks and embedded into vectors.
11
+ 2. At query time the query is also embedded.
12
+ 3. FAISS finds the chunks whose vectors are closest to the query vector.
13
+ 4. Those chunks are injected into the LLM prompt as "context".
14
+ """
15
+
16
+ from langchain_community.vectorstores import FAISS
17
+ from langchain_huggingface import HuggingFaceEmbeddings
18
+ from langchain_core.documents import Document
19
+ from app.config import settings
20
+
21
+
22
+ # ── Sample knowledge base ─────────────────────────────────────────────────
23
+ # Replace or extend this list with real documents / a document loader.
24
+ SAMPLE_DOCS = [
25
+ Document(page_content="LangGraph is a library for building stateful, multi-actor LLM applications using graphs."),
26
+ Document(page_content="RAG stands for Retrieval-Augmented Generation. It combines a retriever with an LLM."),
27
+ Document(page_content="Guardrails are safety checks that prevent harmful or off-topic responses from AI systems."),
28
+ Document(page_content="Human-in-the-Loop (HITL) pauses automation so a human can review or approve an action."),
29
+ Document(page_content="Memory in AI agents allows them to remember past interactions within or across sessions."),
30
+ Document(page_content="Tool calling allows LLMs to invoke external functions like calculators or APIs."),
31
+ Document(page_content="Conditional routing directs a query to the most appropriate processing path."),
32
+ ]
33
+
34
+
35
+ def build_vector_store(docs: list[Document] | None = None) -> FAISS:
36
+ """
37
+ Embed documents and load them into an in-memory FAISS index.
38
+ Pass custom `docs` to override the default knowledge base.
39
+ """
40
+ embeddings = HuggingFaceEmbeddings(model_name=settings.EMBEDDING_MODEL)
41
+ return FAISS.from_documents(docs or SAMPLE_DOCS, embeddings)
42
+
43
+
44
+ # Build once at import time β€” reused across all requests
45
+ _vector_store: FAISS = build_vector_store()
46
+
47
+
48
+ def retrieve_context(query: str, k: int | None = None) -> str:
49
+ """
50
+ Return the top-k most relevant document chunks for `query` as plain text.
51
+ Each chunk is separated by a newline.
52
+ """
53
+ top_k = k or settings.RAG_TOP_K
54
+ results = _vector_store.similarity_search(query, k=top_k)
55
+ return "\n".join(doc.page_content for doc in results)
app/state.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/state.py
3
+ ────────────
4
+ AgentState is the single source of truth that flows through every graph node.
5
+ Added `node_log` so the Gradio UI can display which nodes were visited.
6
+ """
7
+
8
+ from typing import TypedDict, List
9
+
10
+
11
+ class AgentState(TypedDict):
12
+ messages: List # Full conversation history (HumanMessage / AIMessage)
13
+ query: str # Current user query (raw string)
14
+ route: str # Router decision: "rag" | "tool" | "general"
15
+ rag_context: str # Retrieved document chunks (injected into LLM prompt)
16
+ tool_calls: list # Tool-call objects returned by the LLM
17
+ tool_results: list # Executed tool results {"tool": ..., "result": ...}
18
+ response: str # Final text response to send to the user
19
+ retry_count: int # How many LLM retries have happened this turn
20
+ hitl_approved: bool # Did a human approve the response?
21
+ evaluation_score: float # LLM self-evaluation score 0.0 – 1.0
22
+ guardrail_passed: bool # Did the safety filter pass?
23
+ memory_summary: str # Compressed summary of older conversation turns
24
+ node_log: List[str] # Ordered list of nodes visited β€” shown in Gradio UI
app/tools/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/tools/__init__.py
3
+ ──────────────────────
4
+ Aggregates all tools into one list.
5
+ Add new tools here β€” the router and LLM binding pick them up automatically.
6
+ """
7
+
8
+ from app.tools.calculator import calculator
9
+ from app.tools.weather import get_weather_data
10
+
11
+ # Master tool registry β€” every node that needs tools imports this list
12
+ ALL_TOOLS = [calculator, get_weather_data]
13
+
14
+ # Convenience map for the tool-executor node
15
+ TOOL_MAP = {t.name: t for t in ALL_TOOLS}
app/tools/calculator.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/tools/calculator.py
3
+ ────────────────────────
4
+ CHECKPOINT 1 β€” TOOL CALLS (calculator)
5
+
6
+ A @tool is a plain Python function decorated so the LLM can call it.
7
+ The docstring becomes the tool description the LLM reads to decide when to use it.
8
+ """
9
+
10
+ from langchain_core.tools import tool
11
+
12
+
13
+ @tool
14
+ def calculator(expression: str) -> str:
15
+ """
16
+ Evaluate a safe arithmetic expression and return the result as a string.
17
+ Examples: '2 + 2', '10 * 5', '(3 + 4) ** 2'
18
+ """
19
+ try:
20
+ # eval() with empty builtins prevents code injection
21
+ result = eval(expression, {"__builtins__": {}})
22
+ return str(result)
23
+ except Exception as e:
24
+ return f"Error evaluating expression: {e}"
app/tools/weather.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/tools/weather.py
3
+ ─────────────────────
4
+ CHECKPOINT 1 β€” TOOL CALLS (weather)
5
+
6
+ Calls the Weatherstack API and returns current conditions for a city.
7
+ The API key is read from settings so it never lives in code.
8
+ """
9
+
10
+ import requests
11
+ from langchain_core.tools import tool
12
+ from app.config import settings
13
+
14
+
15
+ @tool
16
+ def get_weather_data(city: str) -> str:
17
+ """
18
+ Fetch current weather for a given city name (e.g. 'Pune', 'London').
19
+ Returns a summary string with temperature and conditions.
20
+ """
21
+ url = (
22
+ f"https://api.weatherstack.com/current"
23
+ f"?access_key={settings.WEATHER_API_KEY}&query={city}"
24
+ )
25
+ try:
26
+ data = requests.get(url, timeout=10).json()
27
+ if "error" in data:
28
+ return f"Weather API error: {data['error'].get('info', 'unknown error')}"
29
+ current = data.get("current", {})
30
+ location = data.get("location", {})
31
+ return (
32
+ f"{location.get('name')}, {location.get('country')} β€” "
33
+ f"{current.get('temperature')}Β°C, {', '.join(current.get('weather_descriptions', []))}"
34
+ )
35
+ except Exception as e:
36
+ return f"Failed to fetch weather: {e}"
app/utils/__init__.py ADDED
File without changes
app/utils/llm.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/utils/llm.py
3
+ ────────────────
4
+ LLM singleton factory.
5
+ Import `llm` and `llm_with_tools` from here β€” never instantiate ChatGroq elsewhere.
6
+ """
7
+
8
+ from langchain_groq import ChatGroq
9
+ from app.config import settings
10
+
11
+
12
+ def _build_llm() -> ChatGroq:
13
+ return ChatGroq(
14
+ model=settings.LLM_MODEL,
15
+ temperature=settings.LLM_TEMPERATURE,
16
+ api_key=settings.GROQ_API_KEY,
17
+ )
18
+
19
+
20
+ # Plain LLM β€” used by router, evaluator, memory summariser
21
+ llm = _build_llm()
22
+
23
+ # Lazy-bound version with tools (tools are registered after this module loads)
24
+ # Call get_llm_with_tools() after tools are imported.
25
+ def get_llm_with_tools(tools: list) -> ChatGroq:
26
+ """Return an LLM instance with the given tools bound."""
27
+ return llm.bind_tools(tools)
docker-compose.yml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+
3
+ # ── CLI mode ──────────────────────────────────────────────────────────
4
+ agent:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ image: langgraph-agent:local
9
+ container_name: langgraph_agent
10
+ env_file: .env
11
+ environment:
12
+ - PYTHONPATH=/app
13
+ - GRADIO_MODE=false
14
+ stdin_open: true
15
+ tty: true
16
+ volumes:
17
+ - .:/app
18
+ - huggingface_cache:/root/.cache/huggingface
19
+ command: python main.py
20
+ profiles: ["cli"]
21
+
22
+ # ── Gradio UI mode ────────────────────────────────────────────────────
23
+ gradio:
24
+ build:
25
+ context: .
26
+ dockerfile: Dockerfile
27
+ image: langgraph-agent:local
28
+ container_name: langgraph_gradio
29
+ env_file: .env
30
+ environment:
31
+ - PYTHONPATH=/app
32
+ - GRADIO_MODE=true
33
+ ports:
34
+ - "7860:7860"
35
+ volumes:
36
+ - .:/app
37
+ - huggingface_cache:/root/.cache/huggingface
38
+ command: python app/frontend/gradio_app.py
39
+
40
+ volumes:
41
+ huggingface_cache:
git ADDED
File without changes
main.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ main.py
3
+ ────────
4
+ Entry point β€” runs the CLI chat loop.
5
+ Gradio frontend will replace this file in the next phase.
6
+ """
7
+
8
+ from langchain_core.messages import HumanMessage
9
+ from app.graph.builder import build_graph
10
+ from app.state import AgentState
11
+
12
+
13
+ def main():
14
+ graph = build_graph()
15
+ thread_config = {"configurable": {"thread_id": "session-001"}}
16
+ conversation_history = []
17
+
18
+ print("\nπŸš€ LangGraph Agent ready. Type 'quit' to exit.")
19
+ print("─" * 50)
20
+ print("Try:")
21
+ print(" β€’ 'What is RAG?' β†’ RAG route")
22
+ print(" β€’ 'Calculate 15 * 8' β†’ Tool route")
23
+ print(" β€’ 'Weather in Pune' β†’ Tool route")
24
+ print(" β€’ 'Tell me a joke' β†’ General route")
25
+ print("─" * 50 + "\n")
26
+
27
+ while True:
28
+ user_input = input("You: ").strip()
29
+ if not user_input:
30
+ continue
31
+ if user_input.lower() in ("quit", "exit", "q"):
32
+ print("Goodbye! πŸ‘‹")
33
+ break
34
+
35
+ conversation_history.append(HumanMessage(content=user_input))
36
+
37
+ initial_state: AgentState = {
38
+ "messages": conversation_history.copy(),
39
+ "query": user_input,
40
+ "route": "",
41
+ "rag_context": "",
42
+ "tool_calls": [],
43
+ "tool_results": [],
44
+ "response": "",
45
+ "retry_count": 0,
46
+ "hitl_approved": False,
47
+ "evaluation_score": 0.0,
48
+ "guardrail_passed": True,
49
+ "memory_summary": "",
50
+ }
51
+
52
+ final_state = graph.invoke(initial_state, config=thread_config)
53
+ conversation_history = final_state["messages"]
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ langgraph>=0.2.0
2
+ langchain>=0.3.0
3
+ langchain-core>=0.3.0
4
+ langchain-groq>=0.2.0
5
+ langchain-community>=0.3.0
6
+ langchain-huggingface>=0.1.0
7
+ faiss-cpu>=1.7.4
8
+ sentence-transformers>=3.0.0
9
+ requests>=2.31.0
10
+ python-dotenv>=1.0.0
11
+ gradio==5.23.0
12
+ # CPU-only torch
13
+ --extra-index-url https://download.pytorch.org/whl/cpu
14
+ torch
tests/__init__.py ADDED
File without changes
tests/test_nodes.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/test_nodes.py
3
+ ────────────────────
4
+ Unit tests for individual nodes using a mock LLM so no API key is needed.
5
+ Run with: pytest tests/
6
+ """
7
+
8
+ import pytest
9
+ from unittest.mock import patch, MagicMock
10
+ from langchain_core.messages import HumanMessage, AIMessage
11
+
12
+ from app.state import AgentState
13
+ from app.nodes.guardrails import guardrails_node
14
+ from app.nodes.output import output_node
15
+ from app.tools.calculator import calculator
16
+
17
+
18
+ # ── Helpers ───────────────────────────────────────────────────────────────
19
+
20
+ def make_state(**overrides) -> AgentState:
21
+ base: AgentState = {
22
+ "messages": [],
23
+ "query": "test query",
24
+ "route": "general",
25
+ "rag_context": "",
26
+ "tool_calls": [],
27
+ "tool_results": [],
28
+ "response": "Hello!",
29
+ "retry_count": 0,
30
+ "hitl_approved": True,
31
+ "evaluation_score": 0.8,
32
+ "guardrail_passed": True,
33
+ "memory_summary": "",
34
+ }
35
+ return {**base, **overrides}
36
+
37
+
38
+ # ── Calculator tool ───────────────────────────────────────────────────────
39
+
40
+ def test_calculator_basic():
41
+ assert calculator.invoke({"expression": "2 + 2"}) == "4"
42
+
43
+ def test_calculator_complex():
44
+ assert calculator.invoke({"expression": "10 * 5 - 3"}) == "47"
45
+
46
+ def test_calculator_bad_input():
47
+ result = calculator.invoke({"expression": "import os"})
48
+ assert "Error" in result
49
+
50
+
51
+ # ── Guardrails node ───────────────────────────────────────────────────────
52
+
53
+ def test_guardrails_passes_clean_response():
54
+ state = make_state(response="The weather in Pune is sunny today.")
55
+ result = guardrails_node(state)
56
+ assert result["guardrail_passed"] is True
57
+ assert result["response"] == "The weather in Pune is sunny today."
58
+
59
+ def test_guardrails_blocks_harmful_response():
60
+ state = make_state(response="Here is how to cause harm to someone...")
61
+ result = guardrails_node(state)
62
+ assert result["guardrail_passed"] is False
63
+ assert "can't help" in result["response"]
64
+
65
+
66
+ # ── Output node ───────────────────────────────────────────────────────────
67
+
68
+ def test_output_node_appends_message():
69
+ state = make_state(messages=[HumanMessage(content="Hi")], response="Hello!")
70
+ result = output_node(state)
71
+ assert len(result["messages"]) == 2
72
+ assert isinstance(result["messages"][-1], AIMessage)
73
+ assert result["messages"][-1].content == "Hello!"