Vineetiitg commited on
Commit
0eca2a3
Β·
1 Parent(s): b313397

feat(docs+tests): add architecture diagram to README and mocked workflow unit tests

Browse files
Files changed (3) hide show
  1. README.md +84 -0
  2. requirements.txt +1 -0
  3. tests/test_workflow.py +289 -0
README.md CHANGED
@@ -14,6 +14,90 @@ license: mit
14
 
15
  A lightweight, production-ready advanced RAG support copilot using OpenRouter free LLM APIs (`google/gemma-4-31b-it:free`), Qdrant dense retrieval, FastEmbed CPU-only embeddings, LangGraph Self-RAG, Guardrails AI, Ragas evaluation, FastAPI, and Streamlit.
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  ## 🌟 Why Scenario B? (Lightweight & Cloud-Ready)
18
  This project has been optimized to remove all heavy GPU and PyTorch/Ollama dependencies:
19
  - **No Multi-GB Downloads:** Uses OpenRouter API for LLM inference, removing the need for local Ollama weights.
 
14
 
15
  A lightweight, production-ready advanced RAG support copilot using OpenRouter free LLM APIs (`google/gemma-4-31b-it:free`), Qdrant dense retrieval, FastEmbed CPU-only embeddings, LangGraph Self-RAG, Guardrails AI, Ragas evaluation, FastAPI, and Streamlit.
16
 
17
+ ## 🧩 Tech Stack
18
+
19
+ ![Python](https://img.shields.io/badge/Python-3.11-blue?logo=python)
20
+ ![FastAPI](https://img.shields.io/badge/FastAPI-0.110-009688?logo=fastapi)
21
+ ![LangGraph](https://img.shields.io/badge/LangGraph-Self--RAG-orange?logo=langchain)
22
+ ![Qdrant](https://img.shields.io/badge/Qdrant-Vector%20DB-red?logo=qdrant)
23
+ ![Streamlit](https://img.shields.io/badge/Streamlit-1.32-FF4B4B?logo=streamlit)
24
+ ![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?logo=docker)
25
+ ![Guardrails](https://img.shields.io/badge/Guardrails%20AI-Security-green)
26
+ ![RAGAS](https://img.shields.io/badge/RAGAS-Evaluation-purple)
27
+
28
+ ---
29
+
30
+ ## πŸ—οΈ System Architecture
31
+
32
+ ```mermaid
33
+ flowchart TB
34
+ subgraph Client["πŸ–₯️ Client Layer"]
35
+ UI["Streamlit Chat UI<br/>Port 8501"]
36
+ end
37
+
38
+ subgraph API["⚑ API Layer (FastAPI)"]
39
+ Auth["JWT Auth + RBAC"]
40
+ Guard["Input Guardrails<br/>Prompt Injection Β· Rate Limit"]
41
+ Chat["/chat Endpoint"]
42
+ Stream["/chat/stream Endpoint"]
43
+ Admin["Admin Endpoints<br/>Ingest Β· Upload Β· Reset Β· Eval"]
44
+ end
45
+
46
+ subgraph Agent["🧠 LangGraph Self-RAG Agent"]
47
+ direction TB
48
+ Retrieve["1. Retrieve<br/>Qdrant Hybrid Search"]
49
+ Grade["2. Grade Documents<br/>LLM Relevance Scoring"]
50
+ Generate["3. Generate Answer<br/>Context-Grounded LLM"]
51
+ Evaluate["4. Evaluate Answer<br/>Hallucination Detection"]
52
+ end
53
+
54
+ subgraph Storage["πŸ—„οΈ Data Layer"]
55
+ Qdrant["Qdrant Vector DB<br/>Dense + Sparse Vectors"]
56
+ Embed["FastEmbed ONNX<br/>CPU-Only Embeddings"]
57
+ end
58
+
59
+ subgraph Safety["πŸ›‘οΈ Output Safety"]
60
+ Redact["PII Redaction<br/>SSN Β· CC Β· Email Β· Phone"]
61
+ end
62
+
63
+ subgraph Observe["πŸ“Š Observability"]
64
+ LangSmith["LangSmith Tracing"]
65
+ Metrics["Latency Metrics"]
66
+ RAGAS["RAGAS Benchmarks<br/>Faithfulness Β· Relevancy"]
67
+ end
68
+
69
+ UI -->|HTTP + Streaming| Auth
70
+ Auth --> Guard
71
+ Guard --> Chat & Stream
72
+ Chat & Stream --> Retrieve
73
+ Retrieve -->|Query| Qdrant
74
+ Qdrant -->|Chunks| Retrieve
75
+ Embed -.->|Embeddings| Qdrant
76
+ Retrieve --> Grade
77
+ Grade -->|Relevant| Generate
78
+ Grade -->|"All Irrelevant"| UI
79
+ Generate --> Evaluate
80
+ Evaluate -->|"Grounded βœ…"| Redact
81
+ Evaluate -->|"Hallucinated πŸ”„"| Generate
82
+ Redact --> UI
83
+ Admin -->|Ingest Docs| Embed
84
+ Chat & Stream -.-> LangSmith & Metrics
85
+ Admin -.-> RAGAS
86
+ ```
87
+
88
+ ### Self-RAG Workflow (Cyclic Decision Graph)
89
+
90
+ ```mermaid
91
+ stateDiagram-v2
92
+ [*] --> Retrieve: User Query
93
+ Retrieve --> GradeDocuments: Retrieved Chunks
94
+ GradeDocuments --> Generate: Relevant Docs Found
95
+ GradeDocuments --> [*]: All Docs Irrelevant
96
+ Generate --> EvaluateAnswer: Generated Response
97
+ EvaluateAnswer --> [*]: Grounded (confidence β‰₯ threshold)
98
+ EvaluateAnswer --> Generate: Hallucination Detected (max 3 retries)
99
+ ```
100
+
101
  ## 🌟 Why Scenario B? (Lightweight & Cloud-Ready)
102
  This project has been optimized to remove all heavy GPU and PyTorch/Ollama dependencies:
103
  - **No Multi-GB Downloads:** Uses OpenRouter API for LLM inference, removing the need for local Ollama weights.
requirements.txt CHANGED
@@ -23,6 +23,7 @@ pypdf==4.3.1
23
  python-docx==1.1.2
24
  beautifulsoup4==4.12.3
25
  pytest==8.2.2
 
26
  PyJWT==2.8.0
27
  passlib==1.7.4
28
  bcrypt==3.2.2
 
23
  python-docx==1.1.2
24
  beautifulsoup4==4.12.3
25
  pytest==8.2.2
26
+ pytest-asyncio==0.23.7
27
  PyJWT==2.8.0
28
  passlib==1.7.4
29
  bcrypt==3.2.2
tests/test_workflow.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for the LangGraph Self-RAG workflow.
3
+
4
+ These tests mock all LLM calls and external retrieval so they run
5
+ instantly without network access, API keys, or a running Qdrant instance.
6
+
7
+ Strategy: patch the individual async node functions (retrieve, grade_documents,
8
+ generate, evaluate_answer) rather than the raw LLM objects, because LangGraph
9
+ compiles the graph at import time and the pipe operator is hard to intercept.
10
+ """
11
+
12
+ import pytest
13
+ from unittest.mock import AsyncMock, patch
14
+ from langchain_core.documents import Document
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Helpers – sample data
19
+ # ---------------------------------------------------------------------------
20
+
21
+ SAMPLE_DOCS = [
22
+ Document(
23
+ page_content="To reset your password, go to Settings > Security > Reset Password.",
24
+ metadata={"source": "faq.md", "doc_id": "faq-001", "chunk_id": "c1"},
25
+ ),
26
+ Document(
27
+ page_content="Our support team is available 24/7 via the Help Center.",
28
+ metadata={"source": "contact.md", "doc_id": "contact-001", "chunk_id": "c2"},
29
+ ),
30
+ ]
31
+
32
+ SAMPLE_SOURCES = [
33
+ {"source": "faq.md", "page": None, "chunk_id": "c1", "doc_id": "faq-001",
34
+ "snippet": "To reset your password, go to Settings > Security > Reset Password."},
35
+ ]
36
+
37
+
38
+ def _base_state(**overrides):
39
+ """Build a minimal valid input state for the workflow."""
40
+ state = {"question": "How do I reset my password?", "chat_history": [], "run_count": 0}
41
+ state.update(overrides)
42
+ return state
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Fake node return values
47
+ # ---------------------------------------------------------------------------
48
+
49
+ async def _fake_retrieve(state):
50
+ return {
51
+ "documents": SAMPLE_DOCS,
52
+ "sources": SAMPLE_SOURCES,
53
+ "question": state["question"],
54
+ "run_count": state.get("run_count", 0),
55
+ }
56
+
57
+
58
+ async def _fake_retrieve_empty(state):
59
+ return {
60
+ "documents": [],
61
+ "sources": [],
62
+ "question": state["question"],
63
+ "run_count": state.get("run_count", 0),
64
+ }
65
+
66
+
67
+ async def _fake_grade_all_relevant(state):
68
+ return {"documents": state.get("documents", [])}
69
+
70
+
71
+ async def _fake_grade_all_irrelevant(state):
72
+ return {"documents": []}
73
+
74
+
75
+ async def _fake_generate(state):
76
+ run_count = state.get("run_count", 0) + 1
77
+ return {
78
+ "generation": "Reset your password in Settings > Security.",
79
+ "sources": SAMPLE_SOURCES,
80
+ "run_count": run_count,
81
+ }
82
+
83
+
84
+ async def _fake_evaluate_grounded(state):
85
+ return {"grounded": "yes", "confidence_score": 0.95}
86
+
87
+
88
+ async def _fake_evaluate_hallucinated(state):
89
+ return {"grounded": "no", "confidence_score": 0.2}
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Tests – Happy Path
94
+ # ---------------------------------------------------------------------------
95
+
96
+ @pytest.mark.asyncio
97
+ async def test_workflow_happy_path_returns_grounded_answer():
98
+ """Full graph: retrieve β†’ grade(yes) β†’ generate β†’ evaluate(grounded) β†’ END."""
99
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
100
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \
101
+ patch("app.graph.workflow.generate", new=_fake_generate), \
102
+ patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded):
103
+
104
+ from app.graph.workflow import compile_workflow
105
+ agent = compile_workflow()
106
+
107
+ state = await agent.ainvoke(_base_state())
108
+
109
+ assert state["generation"] == "Reset your password in Settings > Security."
110
+ assert state["confidence_score"] == 0.95
111
+ assert state["grounded"] == "yes"
112
+ assert state["run_count"] == 1
113
+ assert len(state["sources"]) > 0
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Tests – No Documents Retrieved
118
+ # ---------------------------------------------------------------------------
119
+
120
+ @pytest.mark.asyncio
121
+ async def test_workflow_ends_when_no_documents_retrieved():
122
+ """When retriever returns nothing, grade filters to empty β†’ graph ends."""
123
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve_empty), \
124
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_irrelevant):
125
+
126
+ from app.graph.workflow import compile_workflow
127
+ agent = compile_workflow()
128
+
129
+ state = await agent.ainvoke(_base_state(question="What is the meaning of life?"))
130
+
131
+ # No docs β†’ no generation
132
+ assert "generation" not in state or state.get("generation") is None
133
+ assert state["documents"] == []
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Tests – All Documents Graded Irrelevant
138
+ # ---------------------------------------------------------------------------
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_workflow_ends_when_all_docs_graded_irrelevant():
142
+ """When grader rejects all docs, graph ends without calling generate."""
143
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
144
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_irrelevant):
145
+
146
+ from app.graph.workflow import compile_workflow
147
+ agent = compile_workflow()
148
+
149
+ state = await agent.ainvoke(_base_state(question="Tell me about quantum physics"))
150
+
151
+ assert "generation" not in state or state.get("generation") is None
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Tests – Hallucination Retry Loop Caps at 3
156
+ # ---------------------------------------------------------------------------
157
+
158
+ @pytest.mark.asyncio
159
+ async def test_workflow_retries_on_hallucination_and_caps_at_max():
160
+ """
161
+ Evaluator always says 'hallucinated', so the graph loops back to
162
+ generate. After run_count reaches 3, check_hallucinations routes to END.
163
+ """
164
+ generate_call_count = 0
165
+
166
+ async def _counting_generate(state):
167
+ nonlocal generate_call_count
168
+ generate_call_count += 1
169
+ run_count = state.get("run_count", 0) + 1
170
+ return {
171
+ "generation": f"Attempt {generate_call_count}",
172
+ "sources": SAMPLE_SOURCES,
173
+ "run_count": run_count,
174
+ }
175
+
176
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
177
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \
178
+ patch("app.graph.workflow.generate", new=_counting_generate), \
179
+ patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_hallucinated):
180
+
181
+ from app.graph.workflow import compile_workflow
182
+ agent = compile_workflow()
183
+
184
+ state = await agent.ainvoke(_base_state())
185
+
186
+ # Must stop at or before 3 retries
187
+ assert state["run_count"] <= 3, f"Expected max 3 retries, got {state['run_count']}"
188
+ assert generate_call_count <= 3, f"Generate called {generate_call_count} times, expected ≀ 3"
189
+ assert state["generation"], "Should return the last attempt's answer"
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Tests – Single Retry Then Grounded
194
+ # ---------------------------------------------------------------------------
195
+
196
+ @pytest.mark.asyncio
197
+ async def test_workflow_retries_once_then_succeeds():
198
+ """First evaluation says hallucinated, second says grounded β†’ stops at run_count=2."""
199
+ eval_call_count = 0
200
+
201
+ async def _eval_fail_then_pass(state):
202
+ nonlocal eval_call_count
203
+ eval_call_count += 1
204
+ if eval_call_count == 1:
205
+ return {"grounded": "no", "confidence_score": 0.3}
206
+ return {"grounded": "yes", "confidence_score": 0.9}
207
+
208
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
209
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \
210
+ patch("app.graph.workflow.generate", new=_fake_generate), \
211
+ patch("app.graph.workflow.evaluate_answer", new=_eval_fail_then_pass):
212
+
213
+ from app.graph.workflow import compile_workflow
214
+ agent = compile_workflow()
215
+
216
+ state = await agent.ainvoke(_base_state())
217
+
218
+ assert state["grounded"] == "yes"
219
+ assert state["confidence_score"] == 0.9
220
+ assert eval_call_count == 2
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Tests – Output State Structure
225
+ # ---------------------------------------------------------------------------
226
+
227
+ @pytest.mark.asyncio
228
+ async def test_workflow_output_state_has_required_keys():
229
+ """Verify the final state dict contains all expected keys after a full run."""
230
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
231
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \
232
+ patch("app.graph.workflow.generate", new=_fake_generate), \
233
+ patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded):
234
+
235
+ from app.graph.workflow import compile_workflow
236
+ agent = compile_workflow()
237
+
238
+ state = await agent.ainvoke(_base_state())
239
+
240
+ required_keys = {"question", "documents", "generation", "sources", "run_count",
241
+ "confidence_score", "grounded"}
242
+ assert required_keys.issubset(state.keys()), f"Missing keys: {required_keys - state.keys()}"
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Tests – Chat History Preservation
247
+ # ---------------------------------------------------------------------------
248
+
249
+ @pytest.mark.asyncio
250
+ async def test_workflow_preserves_chat_history():
251
+ """Ensure chat_history is passed through the graph without corruption."""
252
+ history = [
253
+ {"role": "user", "content": "Hello"},
254
+ {"role": "assistant", "content": "Hi! How can I help?"},
255
+ ]
256
+
257
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
258
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \
259
+ patch("app.graph.workflow.generate", new=_fake_generate), \
260
+ patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded):
261
+
262
+ from app.graph.workflow import compile_workflow
263
+ agent = compile_workflow()
264
+
265
+ state = await agent.ainvoke(_base_state(chat_history=history))
266
+
267
+ assert state["chat_history"] == history, "Chat history should be preserved"
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Tests – Question Passthrough
272
+ # ---------------------------------------------------------------------------
273
+
274
+ @pytest.mark.asyncio
275
+ async def test_workflow_returns_original_question():
276
+ """The original question must be present in the final state unchanged."""
277
+ question = "How do I contact support?"
278
+
279
+ with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \
280
+ patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \
281
+ patch("app.graph.workflow.generate", new=_fake_generate), \
282
+ patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded):
283
+
284
+ from app.graph.workflow import compile_workflow
285
+ agent = compile_workflow()
286
+
287
+ state = await agent.ainvoke(_base_state(question=question))
288
+
289
+ assert state["question"] == question