vikramvasudevan commited on
Commit
63d1774
Β·
verified Β·
1 Parent(s): 911afbf

Upload folder using huggingface_hub

Browse files
app.py CHANGED
@@ -149,7 +149,7 @@ async def chat_streaming(debug_mode: bool, message, history, thread_id):
149
  start_time = time.time()
150
  streamed_response = ""
151
  final_response = ""
152
- final_node = "validator"
153
 
154
  MAX_CONTENT = 500
155
 
@@ -167,7 +167,7 @@ async def chat_streaming(debug_mode: bool, message, history, thread_id):
167
  node_icon = "🧠"
168
  else:
169
  node_icon = "βš™οΈ"
170
- node_label = f"node:{node}"
171
  tool_label = f"{name or ''}"
172
  if tool_label:
173
  node_label = node_label + f":{tool_label}"
@@ -186,10 +186,6 @@ async def chat_streaming(debug_mode: bool, message, history, thread_id):
186
  def generate_processing_message():
187
  return (
188
  f"<div class='thinking-bubble'><em>πŸ€”{random.choice(thinking_verbs)} ...</em></div>"
189
- # f"<div style='opacity: 0.1' title='{full}'>"
190
- # f"<span>{node}:{name or ''}:</span>"
191
- # f"<strong>Looking for : [{message}]</strong> {truncated or '...'}"
192
- # f"</div>"
193
  )
194
 
195
  if (
@@ -203,11 +199,6 @@ async def chat_streaming(debug_mode: bool, message, history, thread_id):
203
 
204
  html = (
205
  f"<div class='thinking-bubble'><em>πŸ€” {msg.name} tool: {random.choice(thinking_verbs)} ...</em></div>"
206
- # f"<div style='opacity: 0.5'>"
207
- # f"<strong>Looking for : [{message}]</strong><br>"
208
- # f"<strong>Tool Args:</strong> {tooltip or '(no args)'}<br>"
209
- # f"{truncated or '...'}"
210
- # f"</div>"
211
  )
212
  yield f"### { ' β†’ '.join(node_tree)}\n{html}"
213
  elif isinstance(msg, AIMessageChunk):
@@ -226,12 +217,12 @@ async def chat_streaming(debug_mode: bool, message, history, thread_id):
226
  yield f"### { " β†’ ".join(node_tree)}\n{generate_processing_message()}\n<div class='intermediate-output'>{escape(truncate_middle(streamed_response))}</div>"
227
  else:
228
  # Stream intermediate messages with transparent style
229
- if node != final_node:
230
- streamed_response += msg.content
231
- yield f"### { ' β†’ '.join(node_tree) }\n<div class='intermediate-output'>{escape(truncate_middle(streamed_response))}</div>"
232
- else:
233
- # Buffer the final validated response instead of yielding
234
- final_response += msg.content
235
 
236
  if msg.tool_call_chunks:
237
  for tool_call_chunk in msg.tool_call_chunks:
@@ -275,16 +266,15 @@ async def chat_streaming(debug_mode: bool, message, history, thread_id):
275
  node_tree[-1] = "βœ…"
276
  end_time = time.time()
277
  duration = end_time - start_time
278
- if final_response:
279
- final_response = (
280
- f"\n{final_response}" f"\n\n⏱️ Processed in {duration:.2f} seconds"
281
- )
282
- buffer = f"### {' β†’ '.join(node_tree)}\n"
 
 
283
  yield buffer
284
- for c in final_response:
285
- buffer += c
286
- yield buffer
287
- await asyncio.sleep(0.0005)
288
 
289
  logger.debug("************************************")
290
  # Now, you can process the complete tool calls from the buffer
 
149
  start_time = time.time()
150
  streamed_response = ""
151
  final_response = ""
152
+ # final_node = "validator"
153
 
154
  MAX_CONTENT = 500
155
 
 
167
  node_icon = "🧠"
168
  else:
169
  node_icon = "βš™οΈ"
170
+ node_label = f"{node}"
171
  tool_label = f"{name or ''}"
172
  if tool_label:
173
  node_label = node_label + f":{tool_label}"
 
186
  def generate_processing_message():
187
  return (
188
  f"<div class='thinking-bubble'><em>πŸ€”{random.choice(thinking_verbs)} ...</em></div>"
 
 
 
 
189
  )
190
 
191
  if (
 
199
 
200
  html = (
201
  f"<div class='thinking-bubble'><em>πŸ€” {msg.name} tool: {random.choice(thinking_verbs)} ...</em></div>"
 
 
 
 
 
202
  )
203
  yield f"### { ' β†’ '.join(node_tree)}\n{html}"
204
  elif isinstance(msg, AIMessageChunk):
 
217
  yield f"### { " β†’ ".join(node_tree)}\n{generate_processing_message()}\n<div class='intermediate-output'>{escape(truncate_middle(streamed_response))}</div>"
218
  else:
219
  # Stream intermediate messages with transparent style
220
+ # if node != final_node:
221
+ streamed_response += msg.content
222
+ yield f"### { ' β†’ '.join(node_tree) }\n<div class='intermediate-output'>{escape(truncate_middle(streamed_response))}</div>"
223
+ # else:
224
+ # Buffer the final validated response instead of yielding
225
+ final_response += msg.content
226
 
227
  if msg.tool_call_chunks:
228
  for tool_call_chunk in msg.tool_call_chunks:
 
266
  node_tree[-1] = "βœ…"
267
  end_time = time.time()
268
  duration = end_time - start_time
269
+ final_response = (
270
+ f"\n{final_response}" f"\n\n⏱️ Processed in {duration:.2f} seconds"
271
+ )
272
+ buffer = f"### {' β†’ '.join(node_tree)}\n"
273
+ yield buffer
274
+ for c in final_response:
275
+ buffer += c
276
  yield buffer
277
+ await asyncio.sleep(0.0005)
 
 
 
278
 
279
  logger.debug("************************************")
280
  # Now, you can process the complete tool calls from the buffer
config.py CHANGED
@@ -1,5 +1,5 @@
1
  from metadata import MetadataWhereClause
2
-
3
 
4
  class SanatanConfig:
5
  # shuklaYajurVedamPdfPath: str = "./data/shukla-yajur-veda.pdf"
@@ -497,3 +497,15 @@ class SanatanConfig:
497
  if "collection_embedding_fn" in scripture:
498
  embedding_fn = scripture["collection_embedding_fn"] # overridden in config
499
  return embedding_fn
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from metadata import MetadataWhereClause
2
+ from typing import List, Dict
3
 
4
  class SanatanConfig:
5
  # shuklaYajurVedamPdfPath: str = "./data/shukla-yajur-veda.pdf"
 
497
  if "collection_embedding_fn" in scripture:
498
  embedding_fn = scripture["collection_embedding_fn"] # overridden in config
499
  return embedding_fn
500
+
501
+ def filter_scriptures_fields(
502
+ self,
503
+ fields_to_keep: List[str]
504
+ ) -> List[Dict]:
505
+ """
506
+ Return a list of scripture dicts containing only the specified fields.
507
+ """
508
+ filtered = []
509
+ for s in self.scriptures:
510
+ filtered.append({k: s[k] for k in fields_to_keep if k in s})
511
+ return filtered
db.py CHANGED
@@ -58,8 +58,8 @@ class SanatanDatabase:
58
 
59
  # 1. Try native contains
60
  response = collection.query(
61
- query_texts=get_embedding(
62
- [""], SanatanConfig().get_embedding_for_collection(collection_name)
63
  ),
64
  where_document={"$contains": literal_to_search_for},
65
  n_results=n_results,
 
58
 
59
  # 1. Try native contains
60
  response = collection.query(
61
+ query_embeddings=get_embedding(
62
+ [literal_to_search_for], SanatanConfig().get_embedding_for_collection(collection_name)
63
  ),
64
  where_document={"$contains": literal_to_search_for},
65
  n_results=n_results,
graph_helper.py CHANGED
@@ -1,289 +1,41 @@
1
- import json
2
- from typing import Annotated, TypedDict
3
- from httpx import Timeout
4
  from langgraph.graph import StateGraph, START, END
5
  from langgraph.checkpoint.memory import MemorySaver
6
- from langgraph.graph.message import add_messages
7
- from langchain_openai import ChatOpenAI
8
  from langgraph.graph.state import CompiledStateGraph
9
- from langchain_core.messages import AIMessage
10
- from config import SanatanConfig
11
- from tools import (
12
- tool_format_scripture_answer,
13
- tool_get_standardized_prabandham_names,
14
- tool_search_db,
15
- tool_search_web,
16
- tool_push,
17
- tool_get_standardized_azhwar_names,
18
- tool_search_db_by_metadata,
19
- tool_get_standardized_divya_desam_names,
20
- tool_search_db_for_literal,
21
- )
22
- from langgraph.prebuilt import ToolNode, tools_condition
23
- from langchain_core.messages import SystemMessage, ToolMessage, HumanMessage
24
  import logging
25
 
26
  logger = logging.getLogger(__name__)
27
  logger.setLevel(logging.INFO)
28
 
29
-
30
- class ChatState(TypedDict):
31
- debug_mode: bool = True
32
- messages: Annotated[list[str], add_messages]
33
-
34
-
35
- def check_debug_condition(state: ChatState) -> str:
36
- if state["debug_mode"]:
37
- return "validator"
38
- else:
39
- return "__end__"
40
-
41
-
42
- def branching_condition(state: ChatState) -> str:
43
- last_message = state["messages"][-1]
44
- if hasattr(last_message, "tool_calls") and last_message.tool_calls:
45
- return "tools"
46
- else:
47
- return check_debug_condition(state)
48
-
49
-
50
- from typing import List, Dict
51
-
52
-
53
- def truncate_messages_for_token_limit(messages, max_tokens=50000):
54
- """
55
- messages: list of dicts or LangChain messages
56
- """
57
- total_tokens = 0
58
- result = []
59
-
60
- # iterate from newest to oldest
61
- for msg in reversed(messages):
62
- content = getattr(msg, "content", "")
63
- tokens = len(content) // 4
64
-
65
- # handle tool calls
66
- group = [msg]
67
- tool_calls = getattr(msg, "additional_kwargs", {}).get("tool_calls", [])
68
- for call in tool_calls:
69
- # include corresponding tool messages
70
- for m in messages:
71
- if (
72
- getattr(m, "additional_kwargs", {}).get("tool_call_id")
73
- == call["id"]
74
- ):
75
- group.append(m)
76
-
77
- group_tokens = sum(len(getattr(m, "content", "")) // 4 for m in group)
78
-
79
- if total_tokens + group_tokens > max_tokens:
80
- break
81
-
82
- total_tokens += group_tokens
83
- result = group + result
84
-
85
- return result
86
-
87
-
88
  def generate_graph() -> CompiledStateGraph:
89
  memory = MemorySaver()
90
- tools = [
91
- tool_search_web,
92
- tool_push,
93
- tool_search_db,
94
- tool_format_scripture_answer,
95
- tool_get_standardized_azhwar_names,
96
- tool_get_standardized_prabandham_names,
97
- tool_get_standardized_divya_desam_names,
98
- tool_search_db_by_metadata,
99
- tool_search_db_for_literal,
100
- ]
101
- llm = ChatOpenAI(
102
- model="gpt-4o-mini", temperature=0.2, max_retries=0, timeout=Timeout(60.0)
103
- ).bind_tools(tools)
104
- llm_without_tools = ChatOpenAI(
105
- model="gpt-4o-mini", temperature=0.1, max_retries=0, timeout=Timeout(60.0)
106
- )
107
-
108
- def chatNode(state: ChatState) -> ChatState:
109
- # logger.info("messages before LLM: %s", str(state["messages"]))
110
- state["messages"] = truncate_messages_for_token_limit(
111
- messages=state["messages"]
112
- )
113
- response = llm.invoke(state["messages"])
114
- # return {"messages": [response]}
115
- return {"messages": state["messages"] + [response]}
116
-
117
- def validatorNode(state: ChatState) -> ChatState:
118
- messages = state["messages"] or []
119
-
120
- # Step 1: Separate out last message
121
- last_message = messages[-1]
122
- trimmed_messages = messages[:-1]
123
-
124
- # Step 2: Ensure last message is from LLM
125
- if not isinstance(last_message, AIMessage):
126
- return {"messages": messages} # no-op if nothing to validate
127
-
128
- # Step 3: Build validation input
129
- validation_prompt = trimmed_messages + [
130
- SystemMessage(
131
- content=(
132
- """
133
- You are a strict validator for LLM responses to scripture queries. DO NOT USE any tools for this.
134
-
135
- Your tasks:
136
- 0. Treat your input as `original_llm_response`.
137
- 1. Compare the original user query to the LLM’s answer.
138
- 2. Identify the scripture context (e.g., Divya Prabandham, Bhagavad Gita, Upanishads, Ramayana, etc.).
139
- 3. Based on the scripture context, dynamically choose the appropriate entity columns for validation:
140
- - **Divya Prabandham** β†’ azhwar, prabandham, location/deity
141
- - **Bhagavad Gita** β†’ chapter, verse number(s), speaker, listener
142
- - **Upanishads** β†’ section, mantra number, rishi, deity
143
- - **Ramayana/Mahabharata** β†’ book/kanda, section/sarga, character(s), location
144
- - **Other** β†’ pick the 3–4 most relevant contextual entities from the scripture’s metadata.
145
- 4. Verify (from `original_llm_response`):
146
- - Correct verse number(s)
147
- - Keyword/context match
148
- - All scripture-specific entity fields
149
- - Native verse text quality
150
- - Relevance of the response with respect to the question asked by the user.
151
- 5. **Repair any garbled Tamil/Sanskrit characters** in the verse:
152
- - Restore correct letters, diacritics, and punctuation.
153
- - Replace broken Unicode with proper characters.
154
- - Correct vowel signs, consonants, and pulli markers.
155
- - Preserve original spacing and line breaks.
156
- The repaired version is `fixed_llm_response`.
157
-
158
- 6. Evaluate your `Confidence` as an integer between 0 to 100(no percentage sign). Confidence-based display rule:
159
- - If `Confidence` < 75:
160
- - Show this message upfront:
161
- #### Confidence score: {{Confidence}}%
162
- 7. Formatting rules for output:
163
-
164
- <!-- **Step 1 – Repaired LLM Response in Markdown:** -->
165
- <!-- BEGIN_MARKDOWN -->
166
- {{fixed_llm_response}}
167
- <!-- END_MARKDOWN -->
168
-
169
- <!-- **Step 2 – Validation Table:** -->
170
- <div style="font-size: small; opacity: 0.6;">
171
- <hr>
172
- <b>Original user query:</b> {{original_user_query}}
173
-
174
- <table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
175
- <tr>
176
- <th>Parameter</th>
177
- <th>Expected</th>
178
- <th>Found</th>
179
- <th>Match?</th>
180
- </tr>
181
- <tr>
182
- <td>verse number(s)</td>
183
- <td>{{requested_verse_numbers}}</td>
184
- <td>{{found_verse_numbers}}</td>
185
- <td>{{match_status_for_verse}}</td>
186
- </tr>
187
- <tr>
188
- <td>keyword/context</td>
189
- <td>{{requested_keywords}}</td>
190
- <td>{{found_keywords}}</td>
191
- <td>{{match_status_for_keyword}}</td>
192
- </tr>
193
- {{dynamic_entity_rows}}
194
- <tr>
195
- <td>native verse text</td>
196
- <td style="white-space: normal; word-break: break-word; word-wrap: break-word;">{{original_native_text_100_characters}}</td>
197
- <td style="white-space: normal; word-break: break-word; word-wrap: break-word;">{{cleaned_native_text_100_characters}}</td>
198
- <td>{{garbled_fix_status}}</td>
199
- </tr>
200
- </table>
201
-
202
- <p><b>Verdict:</b> {{Verdict}}<br>
203
- <b>Confidence score:</b> {{Confidence}}% – {{Justification}}<br>
204
- <span style="background-color:{{badge_color_code}}; color:white; padding:2px 6px; border-radius:4px;">{{badge_emoji}}</span></p>
205
- </div>
206
-
207
- ---
208
-
209
- Where:
210
- - `{{dynamic_entity_rows}}` is context-specific entity rows.
211
- - `{{cleaned_native_text}}` must be from the repaired `fixed_llm_response` (if Confidence β‰₯ 75).
212
- - βœ…, ❌, ⚠️ remain for matches.
213
- - Hidden markers (`<!-- BEGIN_MARKDOWN -->`) prevent them from rendering as visible text.
214
- - Always wrap verse text so it doesn’t overflow horizontally.
215
-
216
- """
217
- )
218
- ),
219
- HumanMessage(
220
- content=last_message.content
221
- ), # 🟒 convert AI output to Human input
222
- ]
223
-
224
- # Step 4: Invoke LLM
225
- response = llm_without_tools.invoke(validation_prompt)
226
-
227
- # Step 5: Replace old AI message with validated one
228
- return {"messages": trimmed_messages + [response]}
229
-
230
- def init_system_prompt_node(state: ChatState) -> ChatState:
231
- messages = state["messages"] or []
232
-
233
- # Check if system prompts were already added
234
- already_has_prompt = any(
235
- isinstance(m, SystemMessage) and "format_scripture_answer" in m.content
236
- for m in messages
237
- )
238
-
239
- if not already_has_prompt:
240
- messages += [
241
- SystemMessage(
242
- content=f"Here is the list of all scriptures along with their metadata configurations:\n{json.dumps(SanatanConfig.scriptures, indent=1)}\n"
243
- ),
244
- SystemMessage(
245
- content="⚠️ Do NOT summarize or compress the output from the `query` tool. It will be passed directly to `format_scripture_answer` tool that formats the answer **AS IS**. DO NOT REMOVE SANSKRIT/TAMIL TEXTS"
246
- ),
247
- SystemMessage(
248
- content="You MUST call the `format_scripture_answer` tool if the user question is about scripture content and the `query` tool has returned a result."
249
- ),
250
- SystemMessage(
251
- content="For general scripture queries, always prefer semantic search (tool_search_db). Use metadata or literal search only if the user specifies an exact verse number, azhwar, divya desam or phrase."
252
- ),
253
- SystemMessage(
254
- content="you must ALWAYS call one of the standardization tools available to get the correct entity name before using the `tool_search_db_by_metadata` tool."
255
- ),
256
- SystemMessage(
257
- content="""
258
- When using tools, you may call the same tool multiple times in a single task ONLY if:
259
- 1. Each call has materially different arguments or targets a different piece of missing information.
260
- 2. You have a clear reason for another call that is explicitly based on the new results you just received.
261
- 3. You must stop calling the tool if:
262
- - The results do not change meaningfully from the previous call, OR
263
- - You have already resolved enough information to produce the required output.
264
-
265
- NEVER recall the tool if it fails execution.
266
- Before each new call to the same tool, compare the planned arguments with your previous call(s).
267
- If they are essentially the same, do NOT call it again β€” instead, proceed to generate the final validated output.
268
- """
269
- ),
270
- ]
271
-
272
- return {"messages": messages}
273
-
274
  graph = StateGraph(ChatState)
275
  graph.add_node("init", init_system_prompt_node)
276
  graph.add_node("llm", chatNode)
 
277
  graph.add_node("tools", ToolNode(tools))
 
278
  graph.add_node("validator", validatorNode)
 
279
  graph.add_edge(START, "init")
280
  graph.add_edge("init", "llm")
281
- # graph.add_conditional_edges("llm", tools_condition, "tools")
 
282
  graph.add_conditional_edges(
283
  "llm",
284
  branching_condition,
285
  {"tools": "tools", "validator": "validator", "__end__": END},
286
  )
287
- graph.add_edge("tools", "llm")
 
 
288
  graph.add_edge("validator", END)
 
289
  return graph.compile(checkpointer=memory)
 
 
 
 
1
  from langgraph.graph import StateGraph, START, END
2
  from langgraph.checkpoint.memory import MemorySaver
 
 
3
  from langgraph.graph.state import CompiledStateGraph
4
+ from modules.nodes.chat import chatNode, tools
5
+ from modules.nodes.conditions import branching_condition
6
+ from modules.nodes.dedup import dedup_tool_call
7
+ from modules.nodes.init import init_system_prompt_node
8
+ from modules.nodes.state import ChatState
9
+ from modules.nodes.tool_calls import increment_tool_calls
10
+ from modules.nodes.validator import validatorNode
11
+ from langgraph.prebuilt import ToolNode
 
 
 
 
 
 
 
12
  import logging
13
 
14
  logger = logging.getLogger(__name__)
15
  logger.setLevel(logging.INFO)
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def generate_graph() -> CompiledStateGraph:
18
  memory = MemorySaver()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  graph = StateGraph(ChatState)
20
  graph.add_node("init", init_system_prompt_node)
21
  graph.add_node("llm", chatNode)
22
+ graph.add_node("dedup", dedup_tool_call)
23
  graph.add_node("tools", ToolNode(tools))
24
+ graph.add_node("count_tools", increment_tool_calls)
25
  graph.add_node("validator", validatorNode)
26
+
27
  graph.add_edge(START, "init")
28
  graph.add_edge("init", "llm")
29
+
30
+ # branching happens *after* dedup
31
  graph.add_conditional_edges(
32
  "llm",
33
  branching_condition,
34
  {"tools": "tools", "validator": "validator", "__end__": END},
35
  )
36
+
37
+ graph.add_edge("tools", "count_tools")
38
+ graph.add_edge("count_tools", "llm")
39
  graph.add_edge("validator", END)
40
+
41
  return graph.compile(checkpointer=memory)
modules/__init__.py ADDED
File without changes
modules/nodes/chat.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from httpx import Timeout
2
+ from langchain_openai import ChatOpenAI
3
+ from modules.nodes.state import ChatState
4
+ from tools import (
5
+ tool_format_scripture_answer,
6
+ tool_get_standardized_prabandham_names,
7
+ tool_search_db,
8
+ tool_search_web,
9
+ tool_push,
10
+ tool_get_standardized_azhwar_names,
11
+ tool_search_db_by_metadata,
12
+ tool_get_standardized_divya_desam_names,
13
+ tool_search_db_for_literal,
14
+ )
15
+
16
+ tools = [
17
+ tool_search_db_by_metadata,
18
+ tool_search_db,
19
+ tool_search_db_for_literal,
20
+ tool_get_standardized_azhwar_names,
21
+ tool_get_standardized_prabandham_names,
22
+ tool_get_standardized_divya_desam_names,
23
+ tool_format_scripture_answer,
24
+ tool_search_web,
25
+ tool_push,
26
+ ]
27
+ llm = ChatOpenAI(
28
+ model="gpt-4o-mini", temperature=0.2, max_retries=0, timeout=Timeout(60.0)
29
+ ).bind_tools(tools)
30
+
31
+ def _truncate_messages_for_token_limit(messages, max_tokens=50000):
32
+ """
33
+ Truncate messages to stay under token limit while preserving assistant-tool_call integrity.
34
+ """
35
+ return messages
36
+ total_tokens = 0
37
+ result = []
38
+
39
+ # iterate oldest β†’ newest to preserve conversation
40
+ for msg in messages:
41
+ content = getattr(msg, "content", "")
42
+ msg_tokens = len(content) // 4
43
+
44
+ # gather tool responses if any
45
+ tool_calls = getattr(msg, "additional_kwargs", {}).get("tool_calls", [])
46
+ group = [msg]
47
+ for call in tool_calls:
48
+ for m in messages:
49
+ if (
50
+ getattr(m, "additional_kwargs", {}).get("tool_call_id")
51
+ == call["id"]
52
+ ):
53
+ group.append(m)
54
+
55
+ group_tokens = sum(len(getattr(m, "content", "")) // 4 for m in group)
56
+
57
+ # if this whole group would exceed the limit, stop
58
+ if total_tokens + group_tokens > max_tokens:
59
+ break
60
+
61
+ total_tokens += group_tokens
62
+ result.extend(group)
63
+
64
+ return result
65
+
66
+ def chatNode(state: ChatState) -> ChatState:
67
+ # logger.info("messages before LLM: %s", str(state["messages"]))
68
+ state["messages"] = _truncate_messages_for_token_limit(
69
+ messages=state["messages"]
70
+ )
71
+ response = llm.invoke(state["messages"])
72
+ state["messages"] = state["messages"] + [response]
73
+ return state
modules/nodes/conditions.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from modules.nodes.state import ChatState
2
+
3
+ def _check_debug_condition(state: ChatState) -> str:
4
+ if state["debug_mode"]:
5
+ return "validator"
6
+ else:
7
+ return "__end__"
8
+
9
+ MAX_TOOL_CALLS = 3
10
+
11
+ def branching_condition(state: ChatState) -> str:
12
+ # hard stop after MAX_TOOL_CALLS
13
+ if state.get("tool_calls", 0) >= MAX_TOOL_CALLS:
14
+ return _check_debug_condition(state)
15
+
16
+ last_msg = state["messages"][-1]
17
+
18
+ if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
19
+ if state.get("skip_tool", False):
20
+ # remove tool_calls from the last assistant message
21
+ last_msg.tool_calls = []
22
+ return _check_debug_condition(state)
23
+ return "tools"
24
+ else:
25
+ return _check_debug_condition(state)
modules/nodes/dedup.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import json
3
+
4
+ from langchain_core.messages import ToolMessage
5
+ from modules.nodes.state import ChatState
6
+
7
+ def dedup_tool_call(state: ChatState):
8
+ # ensure seen_tool_calls exists
9
+ if state.get("seen_tool_calls") is None:
10
+ state["seen_tool_calls"] = set()
11
+ state["skip_tool"] = False # reset every time
12
+
13
+ if not state.get("messages"):
14
+ return state
15
+
16
+ last_msg = state["messages"][-1]
17
+
18
+ # only process messages that have tool_calls
19
+ if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
20
+ call = last_msg.tool_calls[0]
21
+ tool_name = call["name"]
22
+ raw_args = call.get("arguments") or {}
23
+ tool_args = json.dumps(raw_args, sort_keys=True)
24
+ sig = (tool_name, hashlib.md5(tool_args.encode()).hexdigest())
25
+
26
+ if sig in state["seen_tool_calls"]:
27
+ # Duplicate detected β†’ append a proper ToolMessage instead of a system message
28
+ state["messages"].append(
29
+ ToolMessage(
30
+ content=f"Duplicate tool call skipped: {tool_name}({tool_args})",
31
+ tool_call_id=call["id"],
32
+ name=tool_name,
33
+ additional_kwargs={},
34
+ )
35
+ )
36
+ state["skip_tool"] = True
37
+ # remove the tool_calls from the last assistant message to prevent validation error
38
+ last_msg.tool_calls = []
39
+ else:
40
+ state["seen_tool_calls"].add(sig)
41
+ state["skip_tool"] = False
42
+
43
+ return state
modules/nodes/init.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from langchain_core.messages import SystemMessage
3
+ from config import SanatanConfig
4
+ from modules.nodes.state import ChatState
5
+
6
+
7
+ def init_system_prompt_node(state: ChatState) -> ChatState:
8
+ messages = state["messages"] or []
9
+
10
+ # Check if system prompts were already added
11
+ already_has_prompt = any(
12
+ isinstance(m, SystemMessage) and "format_scripture_answer" in m.content
13
+ for m in messages
14
+ )
15
+
16
+ if not already_has_prompt:
17
+ scriptures = SanatanConfig().filter_scriptures_fields(
18
+ fields_to_keep=[
19
+ "name",
20
+ "title",
21
+ "collection_name",
22
+ "metadata_fields",
23
+ "llm_hints",
24
+ ]
25
+ )
26
+ messages += [
27
+ SystemMessage(
28
+ content=f"Here is the list of all scriptures along with their metadata configurations:\n{json.dumps(scriptures, indent=1)}\n"
29
+ ),
30
+ SystemMessage(
31
+ content="⚠️ Do NOT summarize or compress the output from the `query` tool. It will be passed directly to `format_scripture_answer` tool that formats the answer **AS IS**. DO NOT REMOVE SANSKRIT/TAMIL TEXTS"
32
+ ),
33
+ SystemMessage(
34
+ content="You MUST call the `format_scripture_answer` tool if the user question is about scripture content and the `query` tool has returned a result."
35
+ ),
36
+ SystemMessage(
37
+ content="For general scripture queries, always prefer semantic search (tool_search_db). Use metadata or literal search only if the user specifies an exact verse number, azhwar, divya desam or phrase."
38
+ ),
39
+ SystemMessage(
40
+ content="you must ALWAYS call one of the standardization tools available to get the correct entity name before using the `tool_search_db_by_metadata` tool."
41
+ ),
42
+ SystemMessage(
43
+ content="""
44
+ When using tools, you may call the same tool multiple times in a single task ONLY if:
45
+ 1. Each call has materially different arguments or targets a different piece of missing information.
46
+ 2. You have a clear reason for another call that is explicitly based on the new results you just received.
47
+ 3. You must stop calling the tool if:
48
+ - The results do not change meaningfully from the previous call, OR
49
+ - You have already resolved enough information to produce the required output.
50
+
51
+ NEVER recall the tool if it fails execution.
52
+ Before each new call to the same tool, compare the planned arguments with your previous call(s).
53
+ If they are essentially the same, do NOT call it again β€” instead, proceed to generate the final validated output.
54
+ """
55
+ ),
56
+ ]
57
+ state["tool_calls"] = 0
58
+ state["seen_tool_calls"] = set()
59
+ state["skip_tool"] = False
60
+
61
+ return state
modules/nodes/state.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Annotated, TypedDict
2
+
3
+ from langgraph.graph.message import add_messages
4
+
5
+
6
+ class ChatState(TypedDict):
7
+ debug_mode: bool = True
8
+ messages: Annotated[list[str], add_messages]
9
+ tool_calls: int
10
+ seen_tool_calls: set[tuple[str, str]] # (tool_name, params_hash)
11
+ skip_tool: bool
modules/nodes/tool_calls.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from modules.nodes.state import ChatState
2
+
3
+
4
+ def increment_tool_calls(state: ChatState):
5
+ state["tool_calls"] = state.get("tool_calls", 0) + 1
6
+ return state
modules/nodes/validator.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from httpx import Timeout
2
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
3
+ from langchain_openai import ChatOpenAI
4
+ from modules.nodes.state import ChatState
5
+
6
+
7
+ _llm_without_tools = ChatOpenAI(
8
+ model="gpt-4o-mini", temperature=0.1, max_retries=0, timeout=Timeout(60.0)
9
+ )
10
+
11
+ _validator_prompt_text = """
12
+ You are a strict validator for LLM responses to scripture queries. DO NOT USE any tools for this.
13
+
14
+ Your tasks:
15
+ 0. Treat your input as `original_llm_response`.
16
+ 1. Compare the original user query to the LLM’s answer.
17
+ 2. Identify the scripture context (e.g., Divya Prabandham, Bhagavad Gita, Upanishads, Ramayana, etc.).
18
+ 3. Based on the scripture context, dynamically choose the appropriate entity columns for validation:
19
+ - **Divya Prabandham** β†’ azhwar, prabandham, location/deity
20
+ - **Bhagavad Gita** β†’ chapter, verse number(s), speaker, listener
21
+ - **Upanishads** β†’ section, mantra number, rishi, deity
22
+ - **Ramayana/Mahabharata** β†’ book/kanda, section/sarga, character(s), location
23
+ - **Other** β†’ pick the 3–4 most relevant contextual entities from the scripture’s metadata.
24
+ 4. Verify (from `original_llm_response`):
25
+ - Correct verse number(s)
26
+ - Keyword/context match
27
+ - All scripture-specific entity fields
28
+ - Native verse text quality
29
+ - Relevance of the response with respect to the question asked by the user.
30
+ 5. **Repair any garbled Tamil/Sanskrit characters** in the verse:
31
+ - Restore correct letters, diacritics, and punctuation.
32
+ - Replace broken Unicode with proper characters.
33
+ - Correct vowel signs, consonants, and pulli markers.
34
+ - Preserve original spacing and line breaks.
35
+ The repaired version is `fixed_llm_response`.
36
+
37
+ 6. Evaluate your `Confidence` as an integer between 0 to 100(no percentage sign). Confidence-based display rule:
38
+ - If `Confidence` < 75:
39
+ - Show this message upfront:
40
+ #### Confidence score: {{Confidence}}%
41
+ 7. Formatting rules for output:
42
+
43
+ <!-- **Step 1 – Repaired LLM Response in Markdown:** -->
44
+ <!-- BEGIN_MARKDOWN -->
45
+ {{fixed_llm_response}}
46
+ <!-- END_MARKDOWN -->
47
+
48
+ <!-- **Step 2 – Validation Table:** -->
49
+ <div style="font-size: small; opacity: 0.6;">
50
+ <hr>
51
+ <b>Original user query:</b> {{original_user_query}}
52
+
53
+ <table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
54
+ <tr>
55
+ <th>Parameter</th>
56
+ <th>Expected</th>
57
+ <th>Found</th>
58
+ <th>Match?</th>
59
+ </tr>
60
+ <tr>
61
+ <td>verse number(s)</td>
62
+ <td>{{requested_verse_numbers}}</td>
63
+ <td>{{found_verse_numbers}}</td>
64
+ <td>{{match_status_for_verse}}</td>
65
+ </tr>
66
+ <tr>
67
+ <td>keyword/context</td>
68
+ <td>{{requested_keywords}}</td>
69
+ <td>{{found_keywords}}</td>
70
+ <td>{{match_status_for_keyword}}</td>
71
+ </tr>
72
+ {{dynamic_entity_rows}}
73
+ <tr>
74
+ <td>native verse text</td>
75
+ <td style="white-space: normal; word-break: break-word; word-wrap: break-word;">{{original_native_text_100_characters}}</td>
76
+ <td style="white-space: normal; word-break: break-word; word-wrap: break-word;">{{cleaned_native_text_100_characters}}</td>
77
+ <td>{{garbled_fix_status}}</td>
78
+ </tr>
79
+ </table>
80
+
81
+ <p><b>Verdict:</b> {{Verdict}}<br>
82
+ <b>Confidence score:</b> {{Confidence}}% – {{Justification}}<br>
83
+ <span style="background-color:{{badge_color_code}}; color:white; padding:2px 6px; border-radius:4px;">{{badge_emoji}}</span></p>
84
+ </div>
85
+
86
+ ---
87
+ Where:
88
+ - `{{dynamic_entity_rows}}` is context-specific entity rows.
89
+ - `{{cleaned_native_text}}` must be from the repaired `fixed_llm_response` (if Confidence β‰₯ 75).
90
+ - βœ…, ❌, ⚠️ remain for matches.
91
+ - Hidden markers (`<!-- BEGIN_MARKDOWN -->`) prevent them from rendering as visible text.
92
+ - Always wrap verse text so it doesn’t overflow horizontally.
93
+ """
94
+
95
+
96
+ def _get_ai_message_text(ai_msg):
97
+ # Try different common attributes where LangChain might store text
98
+ if hasattr(ai_msg, "content") and ai_msg.content:
99
+ return ai_msg.content
100
+ if hasattr(ai_msg, "message") and getattr(ai_msg, "message"):
101
+ return ai_msg.message
102
+ # fallback to additional_kwargs
103
+ return ai_msg.additional_kwargs.get("content", "")
104
+
105
+
106
+ def validatorNode(state: ChatState) -> ChatState:
107
+ # Dummy: just pass through, but create a final AIMessage for streaming
108
+ # state["messages"] = state.get("messages", [])
109
+ # last_ai_msg = state["messages"][-1] if state["messages"] else AIMessage(content="")
110
+ # # tag it so chat_streaming sees it as final_node
111
+ # last_ai_msg.langgraph_node = "validator"
112
+ return state
113
+
114
+ messages = state.get("messages", [])
115
+ if not messages:
116
+ print("No messages. Returning state as-is")
117
+ return state
118
+
119
+ # Step 1: Last LLM message content
120
+ last_message = messages[-1]
121
+ if not isinstance(last_message, AIMessage):
122
+ print("Last message was not AI message. Returning state as-is")
123
+ return state
124
+
125
+ llm_text = _get_ai_message_text(last_message)
126
+
127
+ # Step 2: Find the original user query content
128
+ original_user_message = next(
129
+ (m for m in reversed(messages[:-1]) if isinstance(m, HumanMessage)), None
130
+ )
131
+ user_text = original_user_message.content if original_user_message else ""
132
+
133
+ # Step 3: Build validation prompt (only SystemMessage + pure text)
134
+ validation_prompt = [
135
+ SystemMessage(content=_validator_prompt_text),
136
+ HumanMessage(
137
+ content=f"Original user query:\n{user_text}\n\nLLM response:\n{llm_text}"
138
+ ),
139
+ ]
140
+
141
+ # Step 4: Invoke LLM without tools
142
+ response = _llm_without_tools.invoke(validation_prompt)
143
+
144
+ # Step 5: Replace old AI message with validated one
145
+ state["messages"] = messages[:-1] + [response]
146
+ return state
sanatan_assistant.py CHANGED
@@ -32,12 +32,10 @@ You must answer the question using **only** the content from *{collection_name}*
32
  - Do **not** bring in information from **any other scripture or source**, or from prior knowledge, even if the answer seems obvious or well-known.
33
  - Do **not** quote any Sanskrit/Tamil verses unless they appear **explicitly** in the provided context.
34
  - Do **not** use verse numbers or line references unless clearly mentioned in the context.
35
- - If the answer cannot be found in the context, clearly say:
36
- **"I do not have enough information from the {collection_name} to answer this."**
37
 
38
  If the answer is not directly stated in the verses but is present in explanatory notes within the context, you may interpret β€” but **explicitly mention that it is an interpretation**.
39
 
40
- If the user query is not small talk, use the following response format (in Markdown):
41
 
42
  ### 🧾 Answer
43
  - Present a brief summary of your response in concise **English**.
@@ -54,10 +52,22 @@ If the user query is not small talk, use the following response format (in Markd
54
  ### πŸ”— Reference Link(s)
55
  - Provide reference link(s) (`html_url`) if one is available in the context.
56
 
57
- ### πŸ“œ Native Verse(s)
58
- - Quote the **original** native verse(s) from the context without any **translation, transliteration**, or **interpretation**.
59
- - Do **not** include **any English text** in this section. Only show the Sanskrit/Tamil verses as-is from the context.
60
- - Do **not repeat these verses** in the translation section β€” just align the relevant transliteration and translation in the following sections.
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  ### πŸ“œ English Transliteration
63
  - For each verse above, provide the **matching English transliteration**.
 
32
  - Do **not** bring in information from **any other scripture or source**, or from prior knowledge, even if the answer seems obvious or well-known.
33
  - Do **not** quote any Sanskrit/Tamil verses unless they appear **explicitly** in the provided context.
34
  - Do **not** use verse numbers or line references unless clearly mentioned in the context.
 
 
35
 
36
  If the answer is not directly stated in the verses but is present in explanatory notes within the context, you may interpret β€” but **explicitly mention that it is an interpretation**.
37
 
38
+ If the answer WAS indeed found in the context, use the following response format (in Markdown) othereise clearly state **"I do not have enough information from the {collection_name} to answer this."**
39
 
40
  ### 🧾 Answer
41
  - Present a brief summary of your response in concise **English**.
 
52
  ### πŸ”— Reference Link(s)
53
  - Provide reference link(s) (`html_url`) if one is available in the context.
54
 
55
+ ### πŸ“œ Native Verse(s) - Original
56
+ - Include the original native verses as-is
57
+
58
+ ### πŸ“œ Native Verse(s) - Sanitized
59
+
60
+ - Task: Sanitize the native verses **without adding, removing, or inventing text**. Only fix obvious encoding or typographical errors.
61
+ - Sanitization rules:
62
+ 1. Correct garbled Unicode characters.
63
+ 2. Fix broken diacritics, pulli markers, vowel signs, and punctuation.
64
+ 3. Preserve **original spacing, line breaks, and character order**.
65
+ - Do not translate, transliterate, or interpret.
66
+ - Do not hallucinate or generate new verses.
67
+ - Output should only be the **cleaned, original verses**.
68
+ - The output in this section **MUST** be in native script not english or transliterated english.
69
+ > If you are unsure about a character, leave it as it is rather than guessing.
70
+
71
 
72
  ### πŸ“œ English Transliteration
73
  - For each verse above, provide the **matching English transliteration**.
tools.py CHANGED
@@ -25,11 +25,9 @@ allowed_collections = [s["collection_name"] for s in SanatanConfig.scriptures]
25
  tool_search_db = StructuredTool.from_function(
26
  query,
27
  description=(
28
- f"Do **NOT** use this tool if the user asks for specific verse numbers or pasuram numbers."
29
- "This is the **PRIMARY** tool to use for most user queries about scripture."
30
  " Use this when the user asks **about themes, stories, ideas, emotions, or meanings** in the scriptures."
31
  " This tool uses semantic vector search and can understand context and meaning beyond keywords."
32
- f" Only use other tools like metadata or literal search if the user explicitly asks for them."
33
  f" The collection_name must be one of: {', '.join(allowed_collections)}."
34
  ),
35
  )
@@ -70,7 +68,18 @@ tool_search_web = Tool(
70
  name="search_web", description="Search the web for information", func=search_web
71
  )
72
 
73
- tool_format_scripture_answer = StructuredTool.from_function(format_scripture_answer)
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  tool_get_standardized_azhwar_names = StructuredTool.from_function(
76
  get_standardized_azhwar_names,
 
25
  tool_search_db = StructuredTool.from_function(
26
  query,
27
  description=(
28
+ f"Do **NOT** use this tool if the user asks for specific verse numbers or pasuram numbers."
 
29
  " Use this when the user asks **about themes, stories, ideas, emotions, or meanings** in the scriptures."
30
  " This tool uses semantic vector search and can understand context and meaning beyond keywords."
 
31
  f" The collection_name must be one of: {', '.join(allowed_collections)}."
32
  ),
33
  )
 
68
  name="search_web", description="Search the web for information", func=search_web
69
  )
70
 
71
+ tool_format_scripture_answer = StructuredTool.from_function(
72
+ format_scripture_answer,
73
+ name="tool_format_scripture_answer",
74
+ description=(
75
+ """
76
+ Use this tool to generate a custom system prompt based on the scripture title, question, and query_tool_output.
77
+ This is especially useful when the user has asked a question about a scripture, and the relevant context has been fetched using the `query` tool.
78
+ The generated prompt will guide the assistant to respond using only that scripture’s content, with a clear format including Sanskrit/Tamil verses, English explanations, and source chapters.
79
+ Include a santized version of the original native text. Do ensure you dont lose any words from the native text when sanitizing.
80
+ """
81
+ ),
82
+ )
83
 
84
  tool_get_standardized_azhwar_names = StructuredTool.from_function(
85
  get_standardized_azhwar_names,