iamfaham commited on
Commit
89e4c6f
Β·
verified Β·
1 Parent(s): 17de045

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +316 -70
  2. rag_pipeline.py +695 -258
  3. requirements.txt +4 -1
app.py CHANGED
@@ -1,20 +1,145 @@
1
  import os
2
  import gradio as gr
3
- from rag_pipeline import rag_chain # reuse from Step 3 in rag_pipeline.py
 
 
 
4
 
5
  # Check if running on Hugging Face Spaces
6
  IS_HF_SPACES = os.getenv("SPACE_ID") is not None
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  def chat_with_rag(message, history):
 
 
 
10
  if not message.strip():
11
  return history, ""
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  try:
 
 
14
  response = rag_chain.invoke(message)
15
 
16
  # Check if response is too long and truncate if necessary
17
- max_display_length = 8000 # Reasonable limit for Gradio display
18
  if len(response) > max_display_length:
19
  truncated_response = (
20
  response[:max_display_length]
@@ -39,6 +164,36 @@ def clear_chat():
39
  return [], ""
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  with gr.Blocks(
43
  theme=gr.themes.Soft(),
44
  css="""
@@ -88,92 +243,183 @@ with gr.Blocks(
88
  .clear-button:hover {
89
  background-color: #c82333 !important;
90
  }
91
- .input-container {
92
- display: flex !important;
93
- gap: 10px !important;
94
- align-items: flex-end !important;
 
 
 
 
 
 
 
95
  }
96
- .textbox-container {
97
- flex: 1 !important;
 
 
 
 
 
 
 
 
98
  }
99
  """,
100
  ) as demo:
101
- gr.Markdown("# πŸ€– React Docs Assistant")
102
- gr.Markdown(
103
- "Ask questions about React documentation and get comprehensive answers."
104
- )
105
 
106
- # Chat history
107
- chatbot = gr.Chatbot(
108
- label="Chat History",
109
- height=500, # Slightly reduced to make room for input area
110
- show_label=True,
111
- type="messages", # Use the new messages format
112
- )
 
 
 
 
 
 
 
 
 
113
 
114
- # Input area with send button
115
- with gr.Row():
116
- with gr.Column(scale=4):
117
- textbox = gr.Textbox(
118
- placeholder="Ask a question about React... (Press Enter or click Send)",
119
- lines=2, # Allow multiple lines for longer questions
120
- max_lines=5,
121
- label="Your Question",
122
- show_label=True,
 
 
 
 
123
  )
124
- with gr.Column(scale=1):
125
- send_button = gr.Button(
126
- "πŸš€ Send", variant="primary", size="lg", elem_classes=["send-button"]
 
 
 
 
127
  )
128
 
129
- # Control buttons
130
- with gr.Row():
131
- clear_button = gr.Button(
132
- "πŸ—‘οΈ Clear Chat", variant="secondary", elem_classes=["clear-button"]
 
 
 
 
 
 
133
  )
134
 
135
- # Example questions
136
- with gr.Accordion("Example Questions", open=False):
137
- gr.Markdown(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  """
139
- Try these example questions:
140
- - **What is React?**
141
- - **How do I use useState hook?**
142
- - **Explain React components**
143
- - **What are props in React?**
144
- - **How does React rendering work?**
145
- - **What are React Hooks?**
146
- - **How to handle events in React?**
147
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  )
149
 
150
- # Event handlers
151
- def send_message(message, history):
152
- return chat_with_rag(message, history)
 
 
 
 
153
 
154
- # Connect the send button
155
- send_button.click(
156
- fn=send_message,
157
- inputs=[textbox, chatbot],
158
- outputs=[chatbot, textbox],
159
- api_name="send",
160
- )
161
 
162
- # Connect Enter key in textbox
163
- textbox.submit(
164
- fn=send_message,
165
- inputs=[textbox, chatbot],
166
- outputs=[chatbot, textbox],
167
- api_name="send_enter",
168
- )
169
 
170
- # Connect clear button
171
- clear_button.click(
172
- fn=clear_chat, inputs=[], outputs=[chatbot, textbox], api_name="clear"
173
- )
 
 
 
 
 
 
174
 
175
  if __name__ == "__main__":
176
  demo.launch(
177
- debug=False, # Disable debug mode for production
178
- show_error=True, # Keep error display for users
179
  )
 
1
  import os
2
  import gradio as gr
3
+ from rag_pipeline import create_rag_chain
4
+ import time
5
+ import logging
6
+ from appwrite_service import appwrite_service
7
 
8
  # Check if running on Hugging Face Spaces
9
  IS_HF_SPACES = os.getenv("SPACE_ID") is not None
10
 
11
+ # Configure logging
12
+ logging.basicConfig(level=logging.INFO)
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # Predefined documentation sets
17
+ PREDEFINED_DOCS = {
18
+ "React": {
19
+ "name": "React Documentation",
20
+ "url": "https://react.dev/learn",
21
+ "description": "Official React documentation including hooks, components, and best practices",
22
+ "category": "Frontend Framework",
23
+ },
24
+ "Go": {
25
+ "name": "Go Documentation",
26
+ "url": "https://go.dev/doc/",
27
+ "description": "Official Go documentation including language features, standard library, and tutorials",
28
+ "category": "Programming Language",
29
+ },
30
+ "Python": {
31
+ "name": "Python Documentation",
32
+ "url": "https://docs.python.org/3/",
33
+ "description": "Official Python documentation covering language features, standard library, and tutorials",
34
+ "category": "Programming Language",
35
+ },
36
+ "Node.js": {
37
+ "name": "Node.js Documentation",
38
+ "url": "https://nodejs.org/en/docs/",
39
+ "description": "Node.js runtime documentation including APIs, modules, and development guides",
40
+ "category": "Runtime Environment",
41
+ },
42
+ "Vue.js": {
43
+ "name": "Vue.js Documentation",
44
+ "url": "https://vuejs.org/guide/",
45
+ "description": "Vue.js framework documentation with composition API, components, and routing",
46
+ "category": "Frontend Framework",
47
+ },
48
+ "Django": {
49
+ "name": "Django Documentation",
50
+ "url": "https://docs.djangoproject.com/en/stable/",
51
+ "description": "Django web framework documentation including models, views, and deployment",
52
+ "category": "Backend Framework",
53
+ },
54
+ "FastAPI": {
55
+ "name": "FastAPI Documentation",
56
+ "url": "https://fastapi.tiangolo.com/",
57
+ "description": "FastAPI framework documentation with automatic API documentation and validation",
58
+ "category": "Backend Framework",
59
+ },
60
+ "Docker": {
61
+ "name": "Docker Documentation",
62
+ "url": "https://docs.docker.com/",
63
+ "description": "Docker containerization platform documentation including images, containers, and orchestration",
64
+ "category": "DevOps",
65
+ },
66
+ "Kubernetes": {
67
+ "name": "Kubernetes Documentation",
68
+ "url": "https://kubernetes.io/docs/",
69
+ "description": "Kubernetes orchestration platform documentation including pods, services, and deployment",
70
+ "category": "DevOps",
71
+ },
72
+ "MongoDB": {
73
+ "name": "MongoDB Documentation",
74
+ "url": "https://docs.mongodb.com/",
75
+ "description": "MongoDB NoSQL database documentation including CRUD operations and aggregation",
76
+ "category": "Database",
77
+ },
78
+ "PostgreSQL": {
79
+ "name": "PostgreSQL Documentation",
80
+ "url": "https://www.postgresql.org/docs/",
81
+ "description": "PostgreSQL relational database documentation including SQL features and administration",
82
+ "category": "Database",
83
+ },
84
+ }
85
+
86
+ # Global variable to track selected documentation
87
+ selected_docs = {"key": None, "name": None, "url": None}
88
+
89
+
90
+ def select_documentation(doc_key):
91
+ """Select a predefined documentation set"""
92
+ global selected_docs
93
+
94
+ if doc_key not in PREDEFINED_DOCS:
95
+ return "❌ Invalid documentation selection"
96
+
97
+ doc_info = PREDEFINED_DOCS[doc_key]
98
+ selected_docs["key"] = doc_key
99
+ selected_docs["name"] = doc_info["name"]
100
+ selected_docs["url"] = doc_info["url"]
101
+
102
+ # Check detailed status
103
+ status = get_detailed_status(doc_info["url"])
104
+
105
+ if "βœ… Available" in status:
106
+ return f"βœ… {doc_info['name']} is ready! You can now ask questions about it."
107
+ elif "⚠️" in status:
108
+ return f"⚠️ {doc_info['name']} selected but not fully available. Contact administrator."
109
+ else:
110
+ return f"❌ {doc_info['name']} is not available. Contact administrator."
111
+
112
 
113
  def chat_with_rag(message, history):
114
+ """Chat with RAG system"""
115
+ global selected_docs
116
+
117
  if not message.strip():
118
  return history, ""
119
 
120
+ # Check if documentation is selected and processed
121
+ if not selected_docs["key"]:
122
+ error_msg = "❌ Please select a documentation set first. Go to the 'Select Documentation' tab."
123
+ history.append({"role": "user", "content": message})
124
+ history.append({"role": "assistant", "content": error_msg})
125
+ return history, ""
126
+
127
+ # Check if documentation is fully processed and available for chat
128
+ is_fully_processed = appwrite_service.is_fully_processed(selected_docs["url"])
129
+
130
+ if not is_fully_processed:
131
+ error_msg = f"❌ {selected_docs['name']} is not available for chat. Please contact the administrator to make this documentation available."
132
+ history.append({"role": "user", "content": message})
133
+ history.append({"role": "assistant", "content": error_msg})
134
+ return history, ""
135
+
136
  try:
137
+ # Create RAG chain for the selected documentation
138
+ rag_chain = create_rag_chain(selected_docs["url"])
139
  response = rag_chain.invoke(message)
140
 
141
  # Check if response is too long and truncate if necessary
142
+ max_display_length = 8000
143
  if len(response) > max_display_length:
144
  truncated_response = (
145
  response[:max_display_length]
 
164
  return [], ""
165
 
166
 
167
+ def get_detailed_status(url):
168
+ """Get detailed status of documentation availability"""
169
+ if not url:
170
+ return "❌ No URL provided"
171
+
172
+ try:
173
+ # Check if fully processed (has completion status)
174
+ is_fully_processed = appwrite_service.is_fully_processed(url)
175
+
176
+ if is_fully_processed:
177
+ return "βœ… Available for Chat"
178
+ else:
179
+ return "❌ Not Available - Contact Admin"
180
+ except Exception as e:
181
+ return f"❌ Error checking status: {str(e)}"
182
+
183
+
184
+ def get_current_selection():
185
+ """Get current documentation selection info with detailed status"""
186
+ global selected_docs
187
+
188
+ if selected_docs["key"]:
189
+ doc_info = PREDEFINED_DOCS[selected_docs["key"]]
190
+ status = get_detailed_status(selected_docs["url"])
191
+ return f"πŸ“š {doc_info['name']}\nπŸ“– {doc_info['description']}\nπŸ”— {doc_info['url']}\n\nStatus: {status}"
192
+ else:
193
+ return "❌ No documentation selected. Please select a documentation set from the list above."
194
+
195
+
196
+ # Create the Gradio interface
197
  with gr.Blocks(
198
  theme=gr.themes.Soft(),
199
  css="""
 
243
  .clear-button:hover {
244
  background-color: #c82333 !important;
245
  }
246
+ .select-button {
247
+ background-color: #17a2b8 !important;
248
+ color: white !important;
249
+ border: none !important;
250
+ border-radius: 8px !important;
251
+ padding: 8px 16px !important;
252
+ font-weight: bold !important;
253
+ transition: background-color 0.3s !important;
254
+ }
255
+ .select-button:hover {
256
+ background-color: #138496 !important;
257
  }
258
+ .doc-selector {
259
+ background-color: #f8f9fa !important;
260
+ border: 1px solid #ddd !important;
261
+ border-radius: 8px !important;
262
+ padding: 15px !important;
263
+ margin-bottom: 20px !important;
264
+ }
265
+ .doc-selector:hover {
266
+ border-color: #007acc !important;
267
+ background-color: #e6f3ff !important;
268
  }
269
  """,
270
  ) as demo:
271
+ gr.Markdown("# πŸ€– Documentation Assistant")
272
+ gr.Markdown("Select documentation and start chatting!")
 
 
273
 
274
+ # Documentation Selection Section (Small section at top)
275
+ with gr.Group(elem_classes=["doc-selector"]):
276
+ gr.Markdown("### πŸ“š Select Documentation")
277
+
278
+ # Get available documentation from database
279
+ def get_available_docs():
280
+ """Get only documentation that is available in the database"""
281
+ available_docs = {}
282
+ available_options = []
283
+
284
+ for key, doc_info in PREDEFINED_DOCS.items():
285
+ if appwrite_service.is_fully_processed(doc_info["url"]):
286
+ available_docs[key] = doc_info
287
+ available_options.append(f"{doc_info['name']} - {doc_info['url']}")
288
+
289
+ return available_docs, available_options
290
 
291
+ # Get available documentation
292
+ available_docs, doc_options = get_available_docs()
293
+ doc_keys = list(available_docs.keys())
294
+
295
+ if not available_docs:
296
+ gr.Markdown("❌ **No documentation is currently available.**")
297
+ gr.Markdown("Please contact the administrator to process documentation.")
298
+ else:
299
+ doc_dropdown = gr.Dropdown(
300
+ choices=doc_options,
301
+ label="Choose Documentation",
302
+ value=None,
303
+ interactive=True,
304
  )
305
+
306
+ # Current selection display
307
+ current_selection = gr.Textbox(
308
+ label="Selected Documentation",
309
+ interactive=False,
310
+ value="No documentation selected",
311
+ lines=2,
312
  )
313
 
314
+ # Chat Interface (Main section)
315
+ if available_docs:
316
+ gr.Markdown("### πŸ’¬ Chat with Documentation")
317
+
318
+ # Chat history
319
+ chatbot = gr.Chatbot(
320
+ label="Chat History",
321
+ height=500,
322
+ show_label=True,
323
+ type="messages",
324
  )
325
 
326
+ # Input area with send button
327
+ with gr.Row():
328
+ with gr.Column(scale=4):
329
+ textbox = gr.Textbox(
330
+ placeholder="Ask a question about the documentation... (Press Enter or click Send)",
331
+ lines=2,
332
+ max_lines=5,
333
+ label="Your Question",
334
+ show_label=True,
335
+ )
336
+ with gr.Column(scale=1):
337
+ send_button = gr.Button(
338
+ "πŸš€ Send",
339
+ variant="primary",
340
+ size="lg",
341
+ elem_classes=["send-button"],
342
+ )
343
+
344
+ # Control buttons
345
+ with gr.Row():
346
+ clear_button = gr.Button(
347
+ "πŸ—‘οΈ Clear Chat", variant="secondary", elem_classes=["clear-button"]
348
+ )
349
+
350
+ # Example questions
351
+ with gr.Accordion("Example Questions", open=False):
352
+ gr.Markdown(
353
+ """
354
+ Try these example questions after selecting documentation:
355
+ - **What is the main concept?**
356
+ - **How do I get started?**
357
+ - **What are the key features?**
358
+ - **Show me an example**
359
+ - **What are the best practices?**
360
  """
361
+ )
362
+
363
+ # Event handlers
364
+ def select_doc_from_dropdown(choice):
365
+ """Handle documentation selection from dropdown"""
366
+ if not choice:
367
+ return "No documentation selected"
368
+
369
+ # Find the key for the selected option
370
+ selected_index = doc_options.index(choice)
371
+ selected_key = doc_keys[selected_index]
372
+
373
+ # Call the existing select_documentation function
374
+ return select_documentation(selected_key)
375
+
376
+ def send_message(message, history):
377
+ return chat_with_rag(message, history)
378
+
379
+ def update_selection():
380
+ return get_current_selection()
381
+
382
+ # Connect the dropdown
383
+ doc_dropdown.change(
384
+ fn=select_doc_from_dropdown,
385
+ inputs=[doc_dropdown],
386
+ outputs=[current_selection],
387
  )
388
 
389
+ # Connect the send button
390
+ send_button.click(
391
+ fn=send_message,
392
+ inputs=[textbox, chatbot],
393
+ outputs=[chatbot, textbox],
394
+ api_name="send",
395
+ )
396
 
397
+ # Connect Enter key in textbox
398
+ textbox.submit(
399
+ fn=send_message,
400
+ inputs=[textbox, chatbot],
401
+ outputs=[chatbot, textbox],
402
+ api_name="send_enter",
403
+ )
404
 
405
+ # Connect clear button
406
+ clear_button.click(
407
+ fn=clear_chat, inputs=[], outputs=[chatbot, textbox], api_name="clear"
408
+ )
 
 
 
409
 
410
+ # Update selection info on load
411
+ demo.load(
412
+ fn=update_selection,
413
+ inputs=[],
414
+ outputs=[current_selection],
415
+ )
416
+ else:
417
+ gr.Markdown("### πŸ’¬ Chat Interface")
418
+ gr.Markdown("**No documentation is available for chat.**")
419
+ gr.Markdown("Please contact the administrator to process documentation first.")
420
 
421
  if __name__ == "__main__":
422
  demo.launch(
423
+ debug=False,
424
+ show_error=True,
425
  )
rag_pipeline.py CHANGED
@@ -1,258 +1,695 @@
1
- import os
2
- from dotenv import load_dotenv
3
- from langchain_pinecone import Pinecone as LangchainPinecone
4
- from langchain_huggingface import HuggingFaceEmbeddings
5
- from langchain_core.prompts import PromptTemplate
6
- from langchain_core.runnables import RunnableLambda
7
- from langchain_openai import ChatOpenAI
8
- import json
9
- from rank_bm25 import BM25Okapi
10
- from transformers import AutoTokenizer, AutoModelForSequenceClassification
11
- import torch
12
- import logging
13
- import re
14
-
15
- load_dotenv()
16
-
17
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
18
-
19
- # Initialize Pinecone vectorstore
20
- embedder = HuggingFaceEmbeddings(
21
- model_name="intfloat/e5-large-v2",
22
- model_kwargs={"device": "cpu"},
23
- encode_kwargs={"normalize_embeddings": True},
24
- )
25
-
26
- index_name = os.getenv("PINECONE_INDEX")
27
- vectorstore = LangchainPinecone.from_existing_index(
28
- index_name=index_name,
29
- embedding=embedder,
30
- )
31
-
32
- # Retriever
33
- retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
34
-
35
- # LLM setup
36
- llm = ChatOpenAI(
37
- model=os.getenv("OPENROUTER_MODEL"),
38
- api_key=os.getenv("OPENROUTER_API_KEY"),
39
- base_url="https://openrouter.ai/api/v1",
40
- max_tokens=2000, # Limit response length to prevent extremely long outputs
41
- temperature=0.7, # Add some creativity while keeping responses focused
42
- )
43
-
44
- # Question decomposition prompt template
45
- decomposition_template = """Break down the following question into exactly 4 sub-questions that would help provide a comprehensive answer.
46
- Each sub-question should focus on a different aspect of the main question.
47
-
48
- Original Question: {question}
49
-
50
- Please provide exactly 4 sub-questions, one per line, starting with numbers 1-4:
51
-
52
- 1. [First sub-question]
53
- 2. [Second sub-question]
54
- 3. [Third sub-question]
55
- 4. [Fourth sub-question]
56
-
57
- Make sure each sub-question is specific and focused on a different aspect of the original question."""
58
-
59
- decomposition_prompt = PromptTemplate(
60
- input_variables=["question"],
61
- template=decomposition_template,
62
- )
63
-
64
- # Answer synthesis prompt template
65
- synthesis_template = """You are a helpful assistant. Based on the answers to the sub-questions below, provide a comprehensive but concise answer to the original question.
66
-
67
- Original Question: {original_question}
68
-
69
- Sub-questions and their answers:
70
- {sub_answers}
71
-
72
- Please synthesize these answers into a clear, well-structured response that directly addresses the original question.
73
- Keep the response focused and avoid unnecessary repetition. If any sub-question couldn't be answered with the available context, mention that briefly.
74
- Include relevant code examples where applicable, but keep them concise."""
75
-
76
- synthesis_prompt = PromptTemplate(
77
- input_variables=["original_question", "sub_answers"],
78
- template=synthesis_template,
79
- )
80
-
81
- # Individual answer prompt template
82
- template = """You are a helpful assistant. Answer the question using ONLY the context below. Also add a code example if applicable.
83
- If the answer is not in the context, say "I don't know."
84
-
85
- Context:
86
- {context}
87
-
88
- Question:
89
- {question}
90
-
91
- Helpful Answer:"""
92
-
93
- prompt = PromptTemplate(
94
- input_variables=["context", "question"],
95
- template=template,
96
- )
97
-
98
- # Load docs for BM25
99
- with open("react_docs_chunks.json", "r", encoding="utf-8") as f:
100
- docs_json = json.load(f)
101
-
102
- bm25_corpus = [doc["content"] for doc in docs_json]
103
- bm25_titles = [doc.get("title", "") for doc in docs_json]
104
- bm25 = BM25Okapi([doc.split() for doc in bm25_corpus])
105
-
106
- # Cross-encoder for re-ranking
107
- cross_encoder_model = "cross-encoder/ms-marco-MiniLM-L-6-v2"
108
- cross_tokenizer = AutoTokenizer.from_pretrained(cross_encoder_model)
109
- cross_model = AutoModelForSequenceClassification.from_pretrained(cross_encoder_model)
110
-
111
-
112
- # Hybrid retrieval function
113
- def hybrid_retrieve(query, dense_k=5, bm25_k=5, rerank_k=5):
114
- logging.info(f"Hybrid retrieval for query: {query}")
115
- # Dense retrieval
116
- dense_docs = retriever.get_relevant_documents(query)
117
- logging.info(f"Dense docs retrieved: {len(dense_docs)}")
118
- dense_set = set((d.metadata["title"], d.page_content) for d in dense_docs)
119
-
120
- # BM25 retrieval
121
- bm25_scores = bm25.get_scores(query.split())
122
- bm25_indices = sorted(
123
- range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True
124
- )[:bm25_k]
125
- bm25_docs = [
126
- type(
127
- "Doc",
128
- (),
129
- {"metadata": {"title": bm25_titles[i]}, "page_content": bm25_corpus[i]},
130
- )
131
- for i in bm25_indices
132
- ]
133
- logging.info(f"BM25 docs retrieved: {len(bm25_docs)}")
134
- bm25_set = set((d.metadata["title"], d.page_content) for d in bm25_docs)
135
-
136
- # Merge and deduplicate
137
- all_docs = list(
138
- {(d[0], d[1]): d for d in list(dense_set) + list(bm25_set)}.values()
139
- )
140
- all_doc_objs = [
141
- type("Doc", (), {"metadata": {"title": t}, "page_content": c})
142
- for t, c in all_docs
143
- ]
144
- logging.info(f"Total unique docs before re-ranking: {len(all_doc_objs)}")
145
-
146
- # Re-rank with cross-encoder
147
- pairs = [(query, doc.page_content) for doc in all_doc_objs]
148
- inputs = cross_tokenizer.batch_encode_plus(
149
- pairs, padding=True, truncation=True, return_tensors="pt", max_length=512
150
- )
151
- with torch.no_grad():
152
- scores = cross_model(**inputs).logits.squeeze().cpu().numpy()
153
- ranked = sorted(zip(all_doc_objs, scores), key=lambda x: x[1], reverse=True)[
154
- :rerank_k
155
- ]
156
- logging.info(f"Docs after re-ranking: {len(ranked)}")
157
- return [doc for doc, _ in ranked]
158
-
159
-
160
- # Question decomposition function
161
- def decompose_question(question):
162
- try:
163
- logging.info(f"Decomposing question: {question}")
164
- decomposition_response = llm.invoke(
165
- decomposition_prompt.format(question=question)
166
- )
167
- logging.info(
168
- f"Decomposition response: {decomposition_response.content[:200]}..."
169
- )
170
-
171
- # Extract sub-questions from the response
172
- content = decomposition_response.content
173
- sub_questions = []
174
-
175
- # Use regex to extract numbered questions
176
- pattern = r"\d+\.\s*(.+)"
177
- matches = re.findall(pattern, content, re.MULTILINE)
178
- logging.info(f"Regex matches: {matches}")
179
-
180
- for match in matches[:4]: # Take first 4 matches
181
- sub_question = match.strip()
182
- if sub_question:
183
- sub_questions.append(sub_question)
184
-
185
- # If we don't get exactly 4 questions, create variations
186
- while len(sub_questions) < 4:
187
- sub_questions.append(f"Additional aspect of: {question}")
188
-
189
- logging.info(f"Decomposed into {len(sub_questions)} sub-questions")
190
- return sub_questions[:4]
191
- except Exception as e:
192
- logging.error(f"Error in decompose_question: {str(e)}")
193
- # Fallback to simple variations
194
- return [
195
- f"What is {question}?",
196
- f"How does {question} work?",
197
- f"When to use {question}?",
198
- f"Examples of {question}",
199
- ]
200
-
201
-
202
- # RAG chain
203
- def format_docs(docs):
204
- logging.info(f"Formatting {len(docs)} docs for LLM context.")
205
- return "\n\n".join(f"{doc.metadata['title']}:\n{doc.page_content}" for doc in docs)
206
-
207
-
208
- def process_question_with_decomposition(original_question):
209
- try:
210
- logging.info(f"Processing question with decomposition: {original_question}")
211
-
212
- # Step 1: Decompose the question
213
- sub_questions = decompose_question(original_question)
214
- logging.info(f"Sub-questions: {sub_questions}")
215
-
216
- # Step 2: Get answers for each sub-question
217
- sub_answers = []
218
- for i, sub_q in enumerate(sub_questions, 1):
219
- logging.info(f"Processing sub-question {i}: {sub_q}")
220
-
221
- # Retrieve context for this sub-question
222
- context = format_docs(hybrid_retrieve(sub_q))
223
- logging.info(f"Context length for sub-question {i}: {len(context)}")
224
-
225
- # Get answer for this sub-question
226
- sub_answer = llm.invoke(prompt.format(context=context, question=sub_q))
227
- logging.info(f"Sub-answer {i}: {sub_answer.content[:100]}...")
228
- sub_answers.append(f"{i}. {sub_q}\nAnswer: {sub_answer.content}")
229
-
230
- # Step 3: Synthesize the final answer
231
- sub_answers_text = "\n\n".join(sub_answers)
232
- logging.info(f"Sub-answers text length: {len(sub_answers_text)}")
233
-
234
- final_answer = llm.invoke(
235
- synthesis_prompt.format(
236
- original_question=original_question, sub_answers=sub_answers_text
237
- )
238
- )
239
-
240
- logging.info(f"Final answer: {final_answer.content[:100]}...")
241
- return final_answer.content
242
-
243
- except Exception as e:
244
- logging.error(f"Error in process_question_with_decomposition: {str(e)}")
245
- return f"Error processing question: {str(e)}"
246
-
247
-
248
- # Enhanced RAG chain with decomposition
249
- rag_chain = RunnableLambda(process_question_with_decomposition)
250
-
251
- # Run it for local testing
252
- if __name__ == "__main__":
253
- while True:
254
- query = input("\n Ask a question about React: ")
255
- if query.lower() in ["exit", "quit"]:
256
- break
257
- response = rag_chain.invoke(query)
258
- print("\nπŸ€– Answer:\n", response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+ from langchain_pinecone import Pinecone as LangchainPinecone
4
+ from langchain_huggingface import HuggingFaceEmbeddings
5
+ from langchain_core.prompts import PromptTemplate
6
+ from langchain_core.runnables import RunnableLambda
7
+ from langchain_openai import ChatOpenAI
8
+ from langchain_core.documents import Document
9
+ import json
10
+ from rank_bm25 import BM25Okapi
11
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification
12
+ import torch
13
+ import logging
14
+ import re
15
+ from appwrite_service import appwrite_service
16
+
17
+ load_dotenv()
18
+
19
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
20
+
21
+
22
+ def detect_device():
23
+ """Detect the best available device for computation"""
24
+ if torch.cuda.is_available():
25
+ device = "cuda"
26
+ logging.info(f"πŸš€ GPU detected: {torch.cuda.get_device_name(0)}")
27
+ logging.info(
28
+ f"πŸ’Ύ GPU memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB"
29
+ )
30
+ else:
31
+ device = "cpu"
32
+ logging.info("πŸ’» Using CPU for computation")
33
+ return device
34
+
35
+
36
+ # Initialize device
37
+ device = detect_device()
38
+
39
+ # Initialize Pinecone vectorstore with GPU support
40
+ logging.info(f"🧠 Initializing embeddings model on {device.upper()}")
41
+ embedder = HuggingFaceEmbeddings(
42
+ model_name="intfloat/e5-large-v2",
43
+ model_kwargs={"device": device},
44
+ encode_kwargs={"normalize_embeddings": True},
45
+ )
46
+
47
+ index_name = os.getenv("PINECONE_INDEX")
48
+ vectorstore = LangchainPinecone.from_existing_index(
49
+ index_name=index_name,
50
+ embedding=embedder,
51
+ )
52
+
53
+ # Retriever
54
+ retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
55
+
56
+ # LLM setup
57
+ llm = ChatOpenAI(
58
+ model=os.getenv("OPENROUTER_MODEL"),
59
+ api_key=os.getenv("OPENROUTER_API_KEY"),
60
+ base_url="https://openrouter.ai/api/v1",
61
+ max_tokens=2000,
62
+ temperature=0.7,
63
+ )
64
+
65
+ # Relevance check prompt template
66
+ relevance_template = """You are a helpful assistant that determines if a question is related to the available documentation.
67
+
68
+ Available Documentation Context:
69
+ {context}
70
+
71
+ Question: {question}
72
+
73
+ Instructions:
74
+ - Answer "YES" if the question is related to ANY topic, concept, feature, or technology mentioned in the documentation context above
75
+ - Answer "YES" if the question asks about general concepts that would be covered in this type of documentation
76
+ - Answer "NO" only if the question is clearly about a completely different technology, domain, or unrelated topic
77
+ - Be generous in your interpretation - if there's any reasonable chance the documentation could help answer the question, answer "YES"
78
+
79
+ Examples:
80
+ - For React documentation: Questions about hooks, components, JSX, state, props, lifecycle, etc. should be "YES"
81
+ - For Python documentation: Questions about syntax, libraries, functions, data types, etc. should be "YES"
82
+ - For any documentation: Questions about basic concepts of that technology should be "YES"
83
+
84
+ Answer with ONLY "YES" or "NO":"""
85
+
86
+ relevance_prompt = PromptTemplate(
87
+ input_variables=["context", "question"],
88
+ template=relevance_template,
89
+ )
90
+
91
+ # Question decomposition prompt template
92
+ decomposition_template = """Break down the following question into exactly 4 sub-questions that would help provide a comprehensive answer.
93
+ Each sub-question should focus on a different aspect of the main question.
94
+
95
+ Original Question: {question}
96
+
97
+ Please provide exactly 4 sub-questions, one per line, starting with numbers 1-4:
98
+
99
+ 1. [First sub-question]
100
+ 2. [Second sub-question]
101
+ 3. [Third sub-question]
102
+ 4. [Fourth sub-question]
103
+
104
+ Make sure each sub-question is specific and focused on a different aspect of the original question."""
105
+
106
+ decomposition_prompt = PromptTemplate(
107
+ input_variables=["question"],
108
+ template=decomposition_template,
109
+ )
110
+
111
+ # Answer synthesis prompt template
112
+ synthesis_template = """You are a helpful assistant. Based on the answers to the sub-questions below, provide a comprehensive but concise answer to the original question.
113
+
114
+ Original Question: {original_question}
115
+
116
+ Sub-questions and their answers:
117
+ {sub_answers}
118
+
119
+ Please synthesize these answers into a clear, well-structured response that directly addresses the original question.
120
+ Keep the response focused and avoid unnecessary repetition. If any sub-question couldn't be answered with the available context, mention that briefly.
121
+ Include relevant code examples where applicable, but keep them concise."""
122
+
123
+ synthesis_prompt = PromptTemplate(
124
+ input_variables=["original_question", "sub_answers"],
125
+ template=synthesis_template,
126
+ )
127
+
128
+ # Individual answer prompt template
129
+ template = """You are a helpful assistant. Answer the question using ONLY the context below. Also add a code example if applicable.
130
+ If the answer is not in the context, say "I don't know."
131
+
132
+ Context:
133
+ {context}
134
+
135
+ Question:
136
+ {question}
137
+
138
+ Helpful Answer:"""
139
+
140
+ prompt = PromptTemplate(
141
+ input_variables=["context", "question"],
142
+ template=template,
143
+ )
144
+
145
+
146
+ # Load docs for BM25 from Appwrite instead of local JSON
147
+ def load_docs_from_appwrite(selected_url=None):
148
+ """Load document chunks from Appwrite database for specific documentation"""
149
+ try:
150
+ logging.info(f"Loading document chunks from Appwrite for URL: {selected_url}")
151
+ docs_json = appwrite_service.get_all_chunks(selected_url)
152
+
153
+ if not docs_json:
154
+ logging.warning(
155
+ f"No chunks found in Appwrite database for URL: {selected_url}. This is normal if no documentation has been processed yet."
156
+ )
157
+ # Return empty list instead of raising error
158
+ return []
159
+
160
+ logging.info(
161
+ f"Loaded {len(docs_json)} chunks from Appwrite for URL: {selected_url}"
162
+ )
163
+ return docs_json
164
+ except Exception as e:
165
+ logging.error(f"Error loading docs from Appwrite: {str(e)}")
166
+ # Return empty list on error instead of raising
167
+ return []
168
+
169
+
170
+ # Global variables for BM25
171
+ docs_json = None
172
+ bm25_corpus = None
173
+ bm25_titles = None
174
+ bm25 = None
175
+ current_url = None # Track current URL to detect changes
176
+
177
+
178
+ def reset_bm25_data():
179
+ """Reset BM25 data to force reinitialization"""
180
+ global docs_json, bm25_corpus, bm25_titles, bm25, current_url
181
+ docs_json = None
182
+ bm25_corpus = None
183
+ bm25_titles = None
184
+ bm25 = None
185
+ current_url = None
186
+ logging.info("BM25 data reset")
187
+
188
+
189
+ def initialize_bm25(selected_url=None):
190
+ """Initialize BM25 with document chunks from Appwrite for specific documentation"""
191
+ global docs_json, bm25_corpus, bm25_titles, bm25, current_url
192
+
193
+ # Reset if URL has changed
194
+ if current_url != selected_url:
195
+ logging.info(
196
+ f"URL changed from {current_url} to {selected_url}, resetting BM25 data"
197
+ )
198
+ reset_bm25_data()
199
+ current_url = selected_url
200
+
201
+ if docs_json is None:
202
+ docs_json = load_docs_from_appwrite(selected_url)
203
+
204
+ if not docs_json:
205
+ # If no chunks available, create empty BM25
206
+ bm25_corpus = []
207
+ bm25_titles = []
208
+ bm25 = None # Don't initialize BM25 with empty corpus
209
+ logging.warning(
210
+ f"BM25 initialized with no chunks for URL: {selected_url} - no documentation processed yet"
211
+ )
212
+ else:
213
+ bm25_corpus = [doc["content"] for doc in docs_json]
214
+ bm25_titles = [doc.get("title", "") for doc in docs_json]
215
+ bm25 = BM25Okapi([doc.split() for doc in bm25_corpus])
216
+ logging.info(
217
+ f"BM25 initialized with {len(docs_json)} chunks for URL: {selected_url}"
218
+ )
219
+
220
+
221
+ # Cross-encoder for re-ranking (kept on CPU as requested - no GPU acceleration for re-ranking)
222
+ cross_encoder_model = "cross-encoder/ms-marco-MiniLM-L-6-v2"
223
+ cross_tokenizer = AutoTokenizer.from_pretrained(cross_encoder_model)
224
+ cross_model = AutoModelForSequenceClassification.from_pretrained(cross_encoder_model)
225
+ logging.info(
226
+ "πŸ”„ Cross-encoder model initialized on CPU (re-ranking excluded from GPU acceleration)"
227
+ )
228
+
229
+
230
+ # Create context summary for relevance checking
231
+ def create_context_summary(selected_url=None):
232
+ """Create a comprehensive summary of available context for relevance checking"""
233
+ try:
234
+ # Initialize BM25 if not already done
235
+ initialize_bm25(selected_url)
236
+
237
+ # Get unique titles from the corpus
238
+ if bm25_titles:
239
+ unique_titles = list(set(bm25_titles))
240
+
241
+ # Create a more comprehensive context summary
242
+ # Include more titles and also extract key topics from content
243
+ context_parts = []
244
+
245
+ # Add document titles (increase from 20 to 50 for better coverage)
246
+ context_parts.append("Document titles:")
247
+ context_parts.extend(unique_titles[:50])
248
+
249
+ # Add key topics extracted from content
250
+ if bm25_corpus:
251
+ # Extract key terms from the first few documents
252
+ key_terms = set()
253
+ for doc_content in bm25_corpus[:100]: # Check first 100 docs
254
+ # Extract important terms (simple approach)
255
+ words = doc_content.lower().split()
256
+ # Look for React-specific terms
257
+ react_terms = [
258
+ word
259
+ for word in words
260
+ if any(
261
+ term in word
262
+ for term in [
263
+ "hook",
264
+ "component",
265
+ "jsx",
266
+ "prop",
267
+ "state",
268
+ "effect",
269
+ "context",
270
+ "reducer",
271
+ "ref",
272
+ "memo",
273
+ "callback",
274
+ "usememo",
275
+ "usestate",
276
+ "useeffect",
277
+ "usecontext",
278
+ "usereducer",
279
+ "useref",
280
+ "usecallback",
281
+ "react",
282
+ "render",
283
+ "virtual",
284
+ "dom",
285
+ "lifecycle",
286
+ ]
287
+ )
288
+ ]
289
+ key_terms.update(react_terms[:10]) # Limit per document
290
+
291
+ if key_terms:
292
+ context_parts.append("\nKey topics found:")
293
+ context_parts.extend(list(key_terms)[:30]) # Top 30 key terms
294
+
295
+ # Add URL information for context
296
+ if selected_url:
297
+ context_parts.append(f"\nDocumentation source: {selected_url}")
298
+ if "react" in selected_url.lower():
299
+ context_parts.append(
300
+ "This is React documentation covering components, hooks, JSX, state management, and React concepts."
301
+ )
302
+ elif "python" in selected_url.lower():
303
+ context_parts.append(
304
+ "This is Python documentation covering language features, standard library, and Python concepts."
305
+ )
306
+ elif "vue" in selected_url.lower():
307
+ context_parts.append(
308
+ "This is Vue.js documentation covering components, directives, and Vue concepts."
309
+ )
310
+ # Add more URL-specific context as needed
311
+
312
+ context_summary = "\n".join(context_parts)
313
+ else:
314
+ context_summary = "No documentation available yet"
315
+
316
+ logging.info(f"Context summary created with {len(context_summary)} characters")
317
+ return context_summary
318
+ except Exception as e:
319
+ logging.error(f"Error creating context summary: {str(e)}")
320
+ return "Documentation topics"
321
+
322
+
323
+ # Hybrid retrieval function
324
+ def hybrid_retrieve(query, selected_url=None, dense_k=5, bm25_k=5, rerank_k=5):
325
+ logging.info(f"Hybrid retrieval for query: {query} with URL: {selected_url}")
326
+
327
+ # Initialize BM25 if not already done
328
+ initialize_bm25(selected_url)
329
+
330
+ # Dense retrieval
331
+ dense_docs = retriever.get_relevant_documents(query)
332
+ logging.info(f"Dense docs retrieved: {len(dense_docs)}")
333
+ dense_set = set((d.metadata["title"], d.page_content) for d in dense_docs)
334
+
335
+ # BM25 retrieval
336
+ if (
337
+ bm25_corpus and bm25 is not None
338
+ ): # Only if we have chunks and BM25 is initialized
339
+ bm25_scores = bm25.get_scores(query.split())
340
+ bm25_indices = sorted(
341
+ range(len(bm25_scores)), key=lambda i: bm25_scores[i], reverse=True
342
+ )[:bm25_k]
343
+ bm25_docs = [
344
+ Document(
345
+ page_content=bm25_corpus[i],
346
+ metadata={"title": bm25_titles[i]},
347
+ )
348
+ for i in bm25_indices
349
+ ]
350
+ logging.info(f"BM25 docs retrieved: {len(bm25_docs)}")
351
+ bm25_set = set((d.metadata["title"], d.page_content) for d in bm25_docs)
352
+ else:
353
+ bm25_docs = []
354
+ bm25_set = set()
355
+ logging.info("No BM25 docs retrieved - no chunks available")
356
+
357
+ # Merge and deduplicate
358
+ all_docs = list(
359
+ {(d[0], d[1]): d for d in list(dense_set) + list(bm25_set)}.values()
360
+ )
361
+ all_doc_objs = [
362
+ Document(
363
+ page_content=c,
364
+ metadata={"title": t},
365
+ )
366
+ for t, c in all_docs
367
+ ]
368
+ logging.info(f"Total unique docs before re-ranking: {len(all_doc_objs)}")
369
+
370
+ # Re-rank with cross-encoder
371
+ pairs = [(query, doc.page_content) for doc in all_doc_objs]
372
+ inputs = cross_tokenizer.batch_encode_plus(
373
+ pairs, padding=True, truncation=True, return_tensors="pt", max_length=512
374
+ )
375
+ with torch.no_grad():
376
+ scores = cross_model(**inputs).logits.squeeze().cpu().numpy()
377
+ ranked = sorted(zip(all_doc_objs, scores), key=lambda x: x[1], reverse=True)[
378
+ :rerank_k
379
+ ]
380
+ logging.info(f"Docs after re-ranking: {len(ranked)}")
381
+ return [doc for doc, _ in ranked]
382
+
383
+
384
+ # Relevance check function
385
+ def check_relevance(question, selected_url=None):
386
+ """Check if the question is relevant to the available documentation"""
387
+ try:
388
+ logging.info(
389
+ f"Checking relevance for question: {question} with URL: {selected_url}"
390
+ )
391
+
392
+ # First, check for obvious relevant keywords based on the URL
393
+ question_lower = question.lower()
394
+ if selected_url:
395
+ url_lower = selected_url.lower()
396
+
397
+ # Define technology-specific keywords
398
+ tech_keywords = {
399
+ "react": [
400
+ "hook",
401
+ "component",
402
+ "jsx",
403
+ "prop",
404
+ "state",
405
+ "effect",
406
+ "context",
407
+ "reducer",
408
+ "ref",
409
+ "memo",
410
+ "callback",
411
+ "render",
412
+ "virtual",
413
+ "dom",
414
+ "lifecycle",
415
+ "react",
416
+ ],
417
+ "python": [
418
+ "python",
419
+ "function",
420
+ "class",
421
+ "module",
422
+ "import",
423
+ "variable",
424
+ "list",
425
+ "dict",
426
+ "string",
427
+ "integer",
428
+ "loop",
429
+ "condition",
430
+ "exception",
431
+ "library",
432
+ ],
433
+ "vue": [
434
+ "vue",
435
+ "component",
436
+ "directive",
437
+ "template",
438
+ "computed",
439
+ "watch",
440
+ "method",
441
+ "prop",
442
+ "emit",
443
+ "slot",
444
+ "router",
445
+ "vuex",
446
+ ],
447
+ "node": [
448
+ "node",
449
+ "npm",
450
+ "express",
451
+ "server",
452
+ "module",
453
+ "require",
454
+ "async",
455
+ "callback",
456
+ "promise",
457
+ "stream",
458
+ ],
459
+ "django": [
460
+ "django",
461
+ "model",
462
+ "view",
463
+ "template",
464
+ "form",
465
+ "admin",
466
+ "url",
467
+ "middleware",
468
+ "orm",
469
+ "queryset",
470
+ ],
471
+ "docker": [
472
+ "docker",
473
+ "container",
474
+ "image",
475
+ "dockerfile",
476
+ "compose",
477
+ "volume",
478
+ "network",
479
+ "registry",
480
+ ],
481
+ "kubernetes": [
482
+ "kubernetes",
483
+ "pod",
484
+ "service",
485
+ "deployment",
486
+ "namespace",
487
+ "ingress",
488
+ "configmap",
489
+ "secret",
490
+ ],
491
+ }
492
+
493
+ # Check if question contains relevant keywords for the current documentation
494
+ for tech, keywords in tech_keywords.items():
495
+ if tech in url_lower:
496
+ if any(keyword in question_lower for keyword in keywords):
497
+ logging.info(
498
+ f"Question contains relevant {tech} keywords - bypassing LLM relevance check"
499
+ )
500
+ return True
501
+
502
+ # Create context summary
503
+ context_summary = create_context_summary(selected_url)
504
+
505
+ # Log the context summary for debugging
506
+ logging.info(f"Context summary for relevance check: {context_summary[:500]}...")
507
+
508
+ # Check relevance using LLM
509
+ relevance_response = llm.invoke(
510
+ relevance_prompt.format(context=context_summary, question=question)
511
+ )
512
+
513
+ # Parse the response
514
+ response_text = relevance_response.content.strip().upper()
515
+ is_relevant = "YES" in response_text
516
+
517
+ logging.info(
518
+ f"Relevance check result: {response_text} (Relevant: {is_relevant})"
519
+ )
520
+
521
+ # If LLM says NO but we have keyword matches, override to YES
522
+ if not is_relevant and selected_url:
523
+ url_lower = selected_url.lower()
524
+ if "react" in url_lower and any(
525
+ term in question_lower
526
+ for term in ["hook", "component", "jsx", "state", "prop", "react"]
527
+ ):
528
+ logging.info(
529
+ "Overriding LLM relevance check - question contains React-specific terms"
530
+ )
531
+ return True
532
+ elif "python" in url_lower and any(
533
+ term in question_lower
534
+ for term in ["python", "function", "class", "module"]
535
+ ):
536
+ logging.info(
537
+ "Overriding LLM relevance check - question contains Python-specific terms"
538
+ )
539
+ return True
540
+
541
+ return is_relevant
542
+
543
+ except Exception as e:
544
+ logging.error(f"Error in relevance check: {str(e)}")
545
+ # Default to relevant if check fails
546
+ logging.info("Defaulting to relevant due to error")
547
+ return True
548
+
549
+
550
+ # Question decomposition function
551
+ def decompose_question(question):
552
+ try:
553
+ logging.info(f"Decomposing question: {question}")
554
+ decomposition_response = llm.invoke(
555
+ decomposition_prompt.format(question=question)
556
+ )
557
+ logging.info(
558
+ f"Decomposition response: {decomposition_response.content[:200]}..."
559
+ )
560
+
561
+ # Extract sub-questions from the response
562
+ content = decomposition_response.content
563
+ sub_questions = []
564
+
565
+ # Use regex to extract numbered questions
566
+ pattern = r"\d+\.\s*(.+)"
567
+ matches = re.findall(pattern, content, re.MULTILINE)
568
+ logging.info(f"Regex matches: {matches}")
569
+
570
+ for match in matches[:4]: # Take first 4 matches
571
+ sub_question = match.strip()
572
+ if sub_question:
573
+ sub_questions.append(sub_question)
574
+
575
+ # If we don't get exactly 4 questions, create variations
576
+ while len(sub_questions) < 4:
577
+ sub_questions.append(f"Additional aspect of: {question}")
578
+
579
+ logging.info(f"Decomposed into {len(sub_questions)} sub-questions")
580
+ return sub_questions[:4]
581
+ except Exception as e:
582
+ logging.error(f"Error in decompose_question: {str(e)}")
583
+ # Fallback to simple variations
584
+ return [
585
+ f"What is {question}?",
586
+ f"How does {question} work?",
587
+ f"When to use {question}?",
588
+ f"Examples of {question}",
589
+ ]
590
+
591
+
592
+ # RAG chain
593
+ def format_docs(docs):
594
+ logging.info(f"Formatting {len(docs)} docs for LLM context.")
595
+ return "\n\n".join(f"{doc.metadata['title']}:\n{doc.page_content}" for doc in docs)
596
+
597
+
598
+ def process_question_with_relevance_check(
599
+ original_question, selected_url=None, debug=False
600
+ ):
601
+ try:
602
+ logging.info(
603
+ f"Processing question with relevance check: {original_question} for URL: {selected_url}"
604
+ )
605
+
606
+ # Step 1: Check if the question is relevant to the documentation
607
+ is_relevant = check_relevance(original_question, selected_url)
608
+
609
+ if debug:
610
+ print(f"πŸ” DEBUG: Question: {original_question}")
611
+ print(f"πŸ” DEBUG: URL: {selected_url}")
612
+ print(f"πŸ” DEBUG: Relevance check result: {is_relevant}")
613
+
614
+ if not is_relevant:
615
+ logging.info(
616
+ f"Question not relevant to available documentation: {original_question}"
617
+ )
618
+ error_msg = f'No context provided for "{original_question}". This question doesn\'t appear to be related to the documentation that has been processed. Please ask a question about the documentation topics that are available.'
619
+
620
+ if debug:
621
+ print(f"πŸ” DEBUG: Returning relevance error: {error_msg}")
622
+ # Also show the context that was used for relevance check
623
+ context = create_context_summary(selected_url)
624
+ print(f"πŸ” DEBUG: Context used for relevance check: {context[:500]}...")
625
+
626
+ return error_msg
627
+
628
+ # Step 2: If relevant, proceed with decomposition
629
+ sub_questions = decompose_question(original_question)
630
+ logging.info(f"Sub-questions: {sub_questions}")
631
+
632
+ if debug:
633
+ print(f"πŸ” DEBUG: Sub-questions: {sub_questions}")
634
+
635
+ # Step 3: Get answers for each sub-question
636
+ sub_answers = []
637
+ for i, sub_q in enumerate(sub_questions, 1):
638
+ logging.info(f"Processing sub-question {i}: {sub_q}")
639
+
640
+ # Retrieve context for this sub-question
641
+ context = format_docs(hybrid_retrieve(sub_q, selected_url))
642
+ logging.info(f"Context length for sub-question {i}: {len(context)}")
643
+
644
+ if debug:
645
+ print(f"πŸ” DEBUG: Sub-question {i}: {sub_q}")
646
+ print(f"πŸ” DEBUG: Context length: {len(context)}")
647
+
648
+ # Get answer for this sub-question
649
+ sub_answer = llm.invoke(prompt.format(context=context, question=sub_q))
650
+ logging.info(f"Sub-answer {i}: {sub_answer.content[:100]}...")
651
+ sub_answers.append(f"{i}. {sub_q}\nAnswer: {sub_answer.content}")
652
+
653
+ # Step 4: Synthesize the final answer
654
+ sub_answers_text = "\n\n".join(sub_answers)
655
+ logging.info(f"Sub-answers text length: {len(sub_answers_text)}")
656
+
657
+ final_answer = llm.invoke(
658
+ synthesis_prompt.format(
659
+ original_question=original_question, sub_answers=sub_answers_text
660
+ )
661
+ )
662
+
663
+ logging.info(f"Final answer: {final_answer.content[:100]}...")
664
+
665
+ if debug:
666
+ print(f"πŸ” DEBUG: Final answer length: {len(final_answer.content)}")
667
+
668
+ return final_answer.content
669
+
670
+ except Exception as e:
671
+ logging.error(f"Error in process_question_with_relevance_check: {str(e)}")
672
+ return f"Error processing question: {str(e)}"
673
+
674
+
675
+ # Enhanced RAG chain with relevance check
676
+ def create_rag_chain(selected_url=None, debug=False):
677
+ """Create a RAG chain for the selected documentation"""
678
+
679
+ def process_with_url(question):
680
+ return process_question_with_relevance_check(question, selected_url, debug)
681
+
682
+ return RunnableLambda(process_with_url)
683
+
684
+
685
+ # Default RAG chain (for backward compatibility)
686
+ rag_chain = create_rag_chain()
687
+
688
+ # Run it for local testing
689
+ if __name__ == "__main__":
690
+ while True:
691
+ query = input("\n Ask a question about the documentation: ")
692
+ if query.lower() in ["exit", "quit"]:
693
+ break
694
+ response = rag_chain.invoke(query)
695
+ print("\nπŸ€– Answer:\n", response)
requirements.txt CHANGED
@@ -13,4 +13,7 @@ transformers
13
  sentence-transformers
14
  torch
15
  numpy
16
- scikit-learn
 
 
 
 
13
  sentence-transformers
14
  torch
15
  numpy
16
+ scikit-learn
17
+ appwrite
18
+ aiohttp
19
+ pinecone-client