Spaces:
Restarting
on
CPU Upgrade
Restarting
on
CPU Upgrade
Upload folder using huggingface_hub
Browse files- app.py +16 -26
- config.py +13 -1
- db.py +2 -2
- graph_helper.py +17 -265
- modules/__init__.py +0 -0
- modules/nodes/chat.py +73 -0
- modules/nodes/conditions.py +25 -0
- modules/nodes/dedup.py +43 -0
- modules/nodes/init.py +61 -0
- modules/nodes/state.py +11 -0
- modules/nodes/tool_calls.py +6 -0
- modules/nodes/validator.py +146 -0
- sanatan_assistant.py +17 -7
- tools.py +13 -4
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"
|
| 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 |
-
|
| 231 |
-
|
| 232 |
-
else:
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 279 |
-
final_response
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
| 283 |
yield buffer
|
| 284 |
-
|
| 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 |
-
|
| 62 |
-
[
|
| 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
|
| 10 |
-
from
|
| 11 |
-
from
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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 |
-
|
|
|
|
| 282 |
graph.add_conditional_edges(
|
| 283 |
"llm",
|
| 284 |
branching_condition,
|
| 285 |
{"tools": "tools", "validator": "validator", "__end__": END},
|
| 286 |
)
|
| 287 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|