Spaces:
Sleeping
Sleeping
feat: Add interrupt handling and YesNoDecision class for user queries
Browse files- Implemented `handle_interrupt` function to manage user approval for interrupted searches in `app.py`.
- Introduced `YesNoDecision` class with a method to parse string inputs into decision instances in `nodes.py`.
- Updated the `search_help` function to utilize the new decision structure for user permissions.
- app.py +69 -7
- pstuts_rag/pstuts_rag/nodes.py +22 -4
app.py
CHANGED
|
@@ -11,13 +11,20 @@ import chainlit as cl
|
|
| 11 |
import httpx
|
| 12 |
import nest_asyncio
|
| 13 |
from dotenv import load_dotenv
|
|
|
|
| 14 |
from langchain_core.documents import Document
|
| 15 |
from langchain_core.runnables import Runnable
|
| 16 |
from langgraph.checkpoint.memory import MemorySaver
|
|
|
|
| 17 |
|
| 18 |
from pstuts_rag.configuration import Configuration
|
| 19 |
from pstuts_rag.datastore import Datastore
|
| 20 |
-
from pstuts_rag.nodes import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
from pstuts_rag.utils import get_unique
|
| 22 |
|
| 23 |
# Track the single active session
|
|
@@ -203,9 +210,6 @@ async def format_url_reference(url_ref):
|
|
| 203 |
)
|
| 204 |
|
| 205 |
|
| 206 |
-
from langchain.callbacks.base import BaseCallbackHandler
|
| 207 |
-
|
| 208 |
-
|
| 209 |
class ChainlitCallbackHandler(BaseCallbackHandler):
|
| 210 |
"""
|
| 211 |
Custom callback handler for Chainlit to visualize the execution of LangChain chains/graphs.
|
|
@@ -293,6 +297,42 @@ class ChainlitCallbackHandler(BaseCallbackHandler):
|
|
| 293 |
# TODO Add buttons with pregenerated queries
|
| 294 |
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
@cl.on_message
|
| 297 |
async def message_handler(input_message: cl.Message):
|
| 298 |
"""
|
|
@@ -329,11 +369,25 @@ async def message_handler(input_message: cl.Message):
|
|
| 329 |
config = configuration.to_runnable_config()
|
| 330 |
config["callbacks"] = [ChainlitCallbackHandler()]
|
| 331 |
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
await ai_graph.ainvoke({"query": input_message.content}, config),
|
| 335 |
)
|
| 336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
for msg in response["messages"]:
|
| 338 |
if isinstance(msg, FinalAnswer):
|
| 339 |
# Stream the final answer token-by-token for a typing effect
|
|
@@ -346,10 +400,18 @@ async def message_handler(input_message: cl.Message):
|
|
| 346 |
if final_msg:
|
| 347 |
await final_msg.update()
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
# Send all unique video references as separate messages
|
| 350 |
for v in get_unique(response["video_references"]):
|
| 351 |
await format_video_reference(v).send()
|
| 352 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
# Send all unique URL references as separate messages (with screenshots if available)
|
| 354 |
url_reference_tasks = [
|
| 355 |
format_url_reference(u) for u in get_unique(response["url_references"])
|
|
|
|
| 11 |
import httpx
|
| 12 |
import nest_asyncio
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
+
from langchain.callbacks.base import BaseCallbackHandler
|
| 15 |
from langchain_core.documents import Document
|
| 16 |
from langchain_core.runnables import Runnable
|
| 17 |
from langgraph.checkpoint.memory import MemorySaver
|
| 18 |
+
from langgraph.types import Command
|
| 19 |
|
| 20 |
from pstuts_rag.configuration import Configuration
|
| 21 |
from pstuts_rag.datastore import Datastore
|
| 22 |
+
from pstuts_rag.nodes import (
|
| 23 |
+
FinalAnswer,
|
| 24 |
+
TutorialState,
|
| 25 |
+
initialize,
|
| 26 |
+
YesNoDecision,
|
| 27 |
+
)
|
| 28 |
from pstuts_rag.utils import get_unique
|
| 29 |
|
| 30 |
# Track the single active session
|
|
|
|
| 210 |
)
|
| 211 |
|
| 212 |
|
|
|
|
|
|
|
|
|
|
| 213 |
class ChainlitCallbackHandler(BaseCallbackHandler):
|
| 214 |
"""
|
| 215 |
Custom callback handler for Chainlit to visualize the execution of LangChain chains/graphs.
|
|
|
|
| 297 |
# TODO Add buttons with pregenerated queries
|
| 298 |
|
| 299 |
|
| 300 |
+
async def handle_interrupt(query: str) -> YesNoDecision:
|
| 301 |
+
|
| 302 |
+
try:
|
| 303 |
+
user_input = await cl.AskActionMessage(
|
| 304 |
+
content="Search has been interrupted. Do you approve query: '%s' to be sent to Adobe Help?"
|
| 305 |
+
% query,
|
| 306 |
+
timeout=30,
|
| 307 |
+
raise_on_timeout=True,
|
| 308 |
+
actions=[
|
| 309 |
+
cl.Action(
|
| 310 |
+
name="approve",
|
| 311 |
+
payload={"value": "yes"},
|
| 312 |
+
label="✅ Approve",
|
| 313 |
+
),
|
| 314 |
+
cl.Action(
|
| 315 |
+
name="cancel",
|
| 316 |
+
payload={"value": "cancel"},
|
| 317 |
+
label="❌ Cancel web search",
|
| 318 |
+
),
|
| 319 |
+
],
|
| 320 |
+
).send()
|
| 321 |
+
if user_input and user_input.get("payload").get("value") == "yes":
|
| 322 |
+
return YesNoDecision(decision="yes")
|
| 323 |
+
else:
|
| 324 |
+
return YesNoDecision(decision="no")
|
| 325 |
+
|
| 326 |
+
except TimeoutError:
|
| 327 |
+
await cl.Message(
|
| 328 |
+
"Timeout: No response from user. Canceling search."
|
| 329 |
+
).send()
|
| 330 |
+
return YesNoDecision(decision="no")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
from pstuts_rag.nodes import YesNoDecision
|
| 334 |
+
|
| 335 |
+
|
| 336 |
@cl.on_message
|
| 337 |
async def message_handler(input_message: cl.Message):
|
| 338 |
"""
|
|
|
|
| 369 |
config = configuration.to_runnable_config()
|
| 370 |
config["callbacks"] = [ChainlitCallbackHandler()]
|
| 371 |
|
| 372 |
+
raw_response = await ai_graph.ainvoke(
|
| 373 |
+
{"query": input_message.content}, config
|
|
|
|
| 374 |
)
|
| 375 |
|
| 376 |
+
if "__interrupt__" in raw_response:
|
| 377 |
+
logging.warning("*** INTERRUPT ***")
|
| 378 |
+
|
| 379 |
+
logging.info(raw_response["__interrupt__"])
|
| 380 |
+
|
| 381 |
+
answer: YesNoDecision = await handle_interrupt(
|
| 382 |
+
raw_response["__interrupt__"][-1].value["query"]
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
raw_response = await ai_graph.ainvoke(
|
| 386 |
+
Command(resume=answer.decision), config
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
response = cast(TutorialState, raw_response)
|
| 390 |
+
|
| 391 |
for msg in response["messages"]:
|
| 392 |
if isinstance(msg, FinalAnswer):
|
| 393 |
# Stream the final answer token-by-token for a typing effect
|
|
|
|
| 400 |
if final_msg:
|
| 401 |
await final_msg.update()
|
| 402 |
|
| 403 |
+
await cl.Message(
|
| 404 |
+
content=f"Formatting {len(response['video_references'])} video references."
|
| 405 |
+
).send()
|
| 406 |
+
|
| 407 |
# Send all unique video references as separate messages
|
| 408 |
for v in get_unique(response["video_references"]):
|
| 409 |
await format_video_reference(v).send()
|
| 410 |
|
| 411 |
+
await cl.Message(
|
| 412 |
+
content=f"Formatting {len(response['url_references'])} website references."
|
| 413 |
+
).send()
|
| 414 |
+
|
| 415 |
# Send all unique URL references as separate messages (with screenshots if available)
|
| 416 |
url_reference_tasks = [
|
| 417 |
format_url_reference(u) for u in get_unique(response["url_references"])
|
pstuts_rag/pstuts_rag/nodes.py
CHANGED
|
@@ -185,10 +185,10 @@ async def search_help(state: TutorialState, config: RunnableConfig):
|
|
| 185 |
logging.info("search_help: asking permission")
|
| 186 |
|
| 187 |
response = interrupt(
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
"
|
| 191 |
-
|
| 192 |
)
|
| 193 |
|
| 194 |
logging.info(f"Permission response '{response}'")
|
|
@@ -306,6 +306,24 @@ class YesNoDecision(BaseModel):
|
|
| 306 |
|
| 307 |
decision: Literal["yes", "no"] = Field(description="Yes or no decision.")
|
| 308 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
class URLReference(BaseModel):
|
| 311 |
"""Model for URL reference with summary.
|
|
|
|
| 185 |
logging.info("search_help: asking permission")
|
| 186 |
|
| 187 |
response = interrupt(
|
| 188 |
+
{
|
| 189 |
+
"message": "Do you allow Internet search for this query?",
|
| 190 |
+
"query": query,
|
| 191 |
+
}
|
| 192 |
)
|
| 193 |
|
| 194 |
logging.info(f"Permission response '{response}'")
|
|
|
|
| 306 |
|
| 307 |
decision: Literal["yes", "no"] = Field(description="Yes or no decision.")
|
| 308 |
|
| 309 |
+
@classmethod
|
| 310 |
+
def from_string(cls, value: str) -> "YesNoDecision":
|
| 311 |
+
"""Parse a string and return a YesNoDecision instance, mapping common affirmatives to 'yes', others to 'no'."""
|
| 312 |
+
affirmatives = {
|
| 313 |
+
"yes",
|
| 314 |
+
"y",
|
| 315 |
+
"true",
|
| 316 |
+
"ok",
|
| 317 |
+
"okay",
|
| 318 |
+
"sure",
|
| 319 |
+
"1",
|
| 320 |
+
"fine",
|
| 321 |
+
"alright",
|
| 322 |
+
}
|
| 323 |
+
if value.strip().lower() in affirmatives:
|
| 324 |
+
return cls(decision="yes")
|
| 325 |
+
return cls(decision="no")
|
| 326 |
+
|
| 327 |
|
| 328 |
class URLReference(BaseModel):
|
| 329 |
"""Model for URL reference with summary.
|