Spaces:
Runtime error
Runtime error
added ag
Browse files- app/agents/context_agent.py +22 -0
- app/agents/email_writing_agent.py +8 -0
- app/agents/memory_manager_agent.py +56 -0
- app/agents/{agents.py → triage_agent.py} +19 -3
- app/graph.py +2 -2
- app/memory_store/embeddings.py +30 -0
- app/memory_store/memory_store.py +14 -0
- app/nodes/archive_node.py +17 -0
- app/nodes/context_node.py +43 -0
- app/nodes/email_writing_node.py +58 -0
- app/nodes/graphnodes.py +0 -13
- app/nodes/parse_node.py +40 -0
- app/nodes/safety_classifier_node.py +41 -0
- app/nodes/store_memory_data_node.py +45 -0
- app/nodes/triage_node.py +31 -0
- app/nodes/unsafe_email_nodes.py +14 -0
- app/prompts/email_writing_agent_prompt.py +42 -0
- app/prompts/memory_manager_agent_prompt.py +20 -0
- app/schemas/email_writing_agent_tools_schema.py +10 -0
- app/schemas/triage_agent_schema.py +49 -0
- app/state/state.py +49 -6
- app/tools/context_agent_tools.py +22 -0
- app/tools/email_writing_agent_tools.py +54 -0
- app/tools/tools.py +0 -0
- requirements.txt +3 -0
app/agents/context_agent.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain.agents import create_agent
|
| 2 |
+
from langchain.agents.middleware import ToolCallLimitMiddleware
|
| 3 |
+
from langchain_groq import ChatGroq
|
| 4 |
+
from app.prompts.context_agent_prompt import context_agent_template
|
| 5 |
+
from app.tools.context_agent_tools import context_agent_tools
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
context_agent = create_agent(
|
| 9 |
+
model=ChatGroq(
|
| 10 |
+
model="openai/gpt-oss-20b",
|
| 11 |
+
temperature=0.1,
|
| 12 |
+
),
|
| 13 |
+
tools=context_agent_tools,
|
| 14 |
+
store=p_store,
|
| 15 |
+
middleware=[
|
| 16 |
+
ToolCallLimitMiddleware[Any,None](
|
| 17 |
+
tool_name="search_memory",
|
| 18 |
+
run_limit=7,
|
| 19 |
+
thread_limit=10,
|
| 20 |
+
)
|
| 21 |
+
] ,
|
| 22 |
+
)
|
app/agents/email_writing_agent.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_groq import ChatGroq
|
| 2 |
+
from app.tools.email_writing_agent_tools import create_gmail_draft, send_draft_by_id
|
| 3 |
+
base_llm = ChatGroq(
|
| 4 |
+
model="qwen/qwen3-32b",
|
| 5 |
+
temperature=0.1,
|
| 6 |
+
)
|
| 7 |
+
tools = [create_gmail_draft, send_draft_by_id]
|
| 8 |
+
email_agent = base_llm.bind_tools(tools)
|
app/agents/memory_manager_agent.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import types
|
| 2 |
+
from langchain_groq import ChatGroq
|
| 3 |
+
from langmem import create_memory_store_manager
|
| 4 |
+
from app.schemas.memory_agent_schema import EmailMemory
|
| 5 |
+
import os
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
|
| 8 |
+
if "GROQ_API_KEY" not in os.environ:
|
| 9 |
+
os.environ["GROQ_API_KEY"] = settings.GROQ_API_KEY
|
| 10 |
+
|
| 11 |
+
def patch_groq_for_extractions(model: ChatGroq):
|
| 12 |
+
# Capture the original method
|
| 13 |
+
original_bind_tools = model.bind_tools
|
| 14 |
+
|
| 15 |
+
def fixed_bind_tools(self, tools, **kwargs):
|
| 16 |
+
fixed_tools = []
|
| 17 |
+
for tool in tools:
|
| 18 |
+
# 1. Handle dictionary-style tools (JSON Schema)
|
| 19 |
+
if isinstance(tool, dict):
|
| 20 |
+
# Ensure nested description is present
|
| 21 |
+
if "function" in tool and not tool["function"].get("description"):
|
| 22 |
+
tool["function"]["description"] = "Extract information based on the schema."
|
| 23 |
+
elif not tool.get("description"):
|
| 24 |
+
tool["description"] = "Extract information based on the schema."
|
| 25 |
+
fixed_tools.append(tool)
|
| 26 |
+
# 2. Handle Class-style tools (Pydantic)
|
| 27 |
+
else:
|
| 28 |
+
if not getattr(tool, "__doc__", None):
|
| 29 |
+
# This fixes the internal 'Done' class from langmem/trustcall
|
| 30 |
+
tool.__doc__ = "Signal that extraction is finished."
|
| 31 |
+
fixed_tools.append(tool)
|
| 32 |
+
|
| 33 |
+
return original_bind_tools(fixed_tools, **kwargs)
|
| 34 |
+
|
| 35 |
+
object.__setattr__(
|
| 36 |
+
model,
|
| 37 |
+
"bind_tools",
|
| 38 |
+
types.MethodType(fixed_bind_tools, model)
|
| 39 |
+
)
|
| 40 |
+
return model
|
| 41 |
+
|
| 42 |
+
# Apply the patch
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
model=ChatGroq(model="openai/gpt-oss-20b", temperature=0.2)
|
| 46 |
+
model = patch_groq_for_extractions(model)
|
| 47 |
+
|
| 48 |
+
memory_manager_agent = create_memory_store_manager(
|
| 49 |
+
model,
|
| 50 |
+
schemas=[EmailMemory],
|
| 51 |
+
namespace=namespace,
|
| 52 |
+
store=p_store,
|
| 53 |
+
instructions="Extract required info from incoming mail and its reply .",
|
| 54 |
+
enable_inserts=True,
|
| 55 |
+
enable_deletes=True,
|
| 56 |
+
)
|
app/agents/{agents.py → triage_agent.py}
RENAMED
|
@@ -1,13 +1,29 @@
|
|
| 1 |
from langchain_groq import ChatGroq
|
| 2 |
-
from app.schemas.
|
| 3 |
from app.core.config import settings
|
| 4 |
-
from app.
|
| 5 |
-
from app.prompts.roadmap_planner_agent_prompt import roadmap_planner_agent_prompt
|
| 6 |
from typing import Any
|
| 7 |
from langchain.agents import create_agent
|
| 8 |
from langchain.agents.middleware import ToolCallLimitMiddleware
|
| 9 |
import os
|
| 10 |
|
|
|
|
| 11 |
if "GROQ_API_KEY" not in os.environ:
|
| 12 |
os.environ["GROQ_API_KEY"] = settings.GROQ_API_KEY
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from langchain_groq import ChatGroq
|
| 2 |
+
from app.schemas.triage_agent_schema import TriageOutput
|
| 3 |
from app.core.config import settings
|
| 4 |
+
from app.prompts.triage_agent_prompt import triage_agent_template
|
|
|
|
| 5 |
from typing import Any
|
| 6 |
from langchain.agents import create_agent
|
| 7 |
from langchain.agents.middleware import ToolCallLimitMiddleware
|
| 8 |
import os
|
| 9 |
|
| 10 |
+
|
| 11 |
if "GROQ_API_KEY" not in os.environ:
|
| 12 |
os.environ["GROQ_API_KEY"] = settings.GROQ_API_KEY
|
| 13 |
|
| 14 |
+
|
| 15 |
+
triage_agent=ChatGroq(
|
| 16 |
+
model="meta-llama/llama-4-scout-17b-16e-instruct",
|
| 17 |
+
temperature=0.1,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
triage_agent=triage_agent.with_structured_output(
|
| 22 |
+
|
| 23 |
+
TriageOutput,
|
| 24 |
+
method="json_schema",
|
| 25 |
+
include_raw=True,
|
| 26 |
+
strict=True
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
app/graph.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from app.state.state import
|
| 2 |
-
from app.nodes.
|
| 3 |
from langgraph.prebuilt import ToolNode ,tools_condition
|
| 4 |
-
from app.agents.
|
| 5 |
from langgraph.graph import StateGraph,END,START
|
| 6 |
|
| 7 |
|
|
|
|
| 1 |
from app.state.state import
|
| 2 |
+
from app.nodes.triage_node import *
|
| 3 |
from langgraph.prebuilt import ToolNode ,tools_condition
|
| 4 |
+
from app.agents.triage_agent import
|
| 5 |
from langgraph.graph import StateGraph,END,START
|
| 6 |
|
| 7 |
|
app/memory_store/embeddings.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from typing import List
|
| 3 |
+
from langchain_core.embeddings import Embeddings
|
| 4 |
+
|
| 5 |
+
class RemoteAPIEmbeddings(Embeddings):
|
| 6 |
+
def __init__(self, base_url: str):
|
| 7 |
+
self.base_url = base_url.rstrip("/")
|
| 8 |
+
|
| 9 |
+
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
| 10 |
+
"""Call the /embed_docs endpoint."""
|
| 11 |
+
response = requests.post(
|
| 12 |
+
f"{self.base_url}/embed_docs",
|
| 13 |
+
json={"texts": texts}
|
| 14 |
+
)
|
| 15 |
+
response.raise_for_status()
|
| 16 |
+
return response.json()["embeddings"]
|
| 17 |
+
|
| 18 |
+
def embed_query(self, text: str) -> List[float]:
|
| 19 |
+
"""Call the /embed_query endpoint."""
|
| 20 |
+
response = requests.post(
|
| 21 |
+
f"{self.base_url}/embed_query",
|
| 22 |
+
json={"text": text}
|
| 23 |
+
)
|
| 24 |
+
response.raise_for_status()
|
| 25 |
+
return response.json()["embedding"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
API_BASE_URL = "https://gaykar-generalembeddings.hf.space"
|
| 29 |
+
|
| 30 |
+
remote_embeddings = RemoteAPIEmbeddings(base_url=API_BASE_URL)
|
app/memory_store/memory_store.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from psycopg_pool import ConnectionPool
|
| 2 |
+
from langgraph.store.postgres import PostgresStore
|
| 3 |
+
from langgraph.checkpoint.postgres import PostgresSaver
|
| 4 |
+
from app.memory_store.embeddings import remote_embeddings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
pool = ConnectionPool(
|
| 8 |
+
conninfo=DB_URI,
|
| 9 |
+
min_size=1,
|
| 10 |
+
max_size=10,
|
| 11 |
+
kwargs={"autocommit": True}
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
memory_store = PostgresStore(pool, index={"dims": 384, "embed": remote_embeddings})
|
app/nodes/archive_node.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.state.state import EmailAgentState
|
| 2 |
+
from langchain_core.runnables.config import RunnableConfig
|
| 3 |
+
|
| 4 |
+
def archive_node(state: EmailAgentState,config: RunnableConfig) -> dict:
|
| 5 |
+
|
| 6 |
+
print(f"[ARCHIVE] {state['triage_label']} — {state['sender_subject']}")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
session=get_session()
|
| 10 |
+
|
| 11 |
+
user_id=state['user_id']
|
| 12 |
+
|
| 13 |
+
thread_id = config.get("configurable", {}).get("thread_id")
|
| 14 |
+
|
| 15 |
+
save_received_email(session, user_id, thread_id,state)
|
| 16 |
+
|
| 17 |
+
return {}
|
app/nodes/context_node.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.messages import AIMessage
|
| 2 |
+
from app.agents.context_agent import context_agent
|
| 3 |
+
from app.prompts.context_agent_prompt import context_agent_template
|
| 4 |
+
from app.state.state import EmailAgentState
|
| 5 |
+
from langgraph.types import RunnableConfig
|
| 6 |
+
|
| 7 |
+
def prepare_context_node(state: EmailAgentState,config: RunnableConfig):
|
| 8 |
+
|
| 9 |
+
"""This node retrieves past context and prepares the prompt for the drafting agent."""
|
| 10 |
+
print("DEBUG: Executing prepare_context_node...")
|
| 11 |
+
|
| 12 |
+
prompt_value = context_agent_template.invoke({
|
| 13 |
+
"user_name": state["user_name"],
|
| 14 |
+
"user_email_id": state["user_email_id"],
|
| 15 |
+
"senders_email": state["sender_email_id"],
|
| 16 |
+
"subject": state["sender_subject"],
|
| 17 |
+
"body": state["sender_email_body"],
|
| 18 |
+
"triage_label": state["triage_label"],
|
| 19 |
+
"priority_score":state["priority_score"],
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
prompt = prompt_value.to_messages()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
context_agent_response = context_agent.invoke({"messages": prompt},config=config)
|
| 26 |
+
|
| 27 |
+
draft_context = ""
|
| 28 |
+
|
| 29 |
+
for msg in context_agent_response["messages"]:
|
| 30 |
+
|
| 31 |
+
if msg.type == "tool" and msg.name == "give_previous_context":
|
| 32 |
+
draft_context = msg.content
|
| 33 |
+
break
|
| 34 |
+
|
| 35 |
+
elif isinstance(msg, AIMessage) and not msg.tool_calls and not draft_context:
|
| 36 |
+
draft_context = msg.content
|
| 37 |
+
|
| 38 |
+
print(f"DEBUG: draft_context = {draft_context[:100] if draft_context else 'NOT FOUND'}")
|
| 39 |
+
|
| 40 |
+
return {
|
| 41 |
+
"context_agent_messages": context_agent_response["messages"],
|
| 42 |
+
"draft_context": draft_context,
|
| 43 |
+
}
|
app/nodes/email_writing_node.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from langchain_core.runnables import RunnableConfig
|
| 3 |
+
from app.state.state import EmailAgentState
|
| 4 |
+
from app.agents.email_writing_agent import email_agent
|
| 5 |
+
from app.prompts.email_writing_agent_prompt import email_agent_template
|
| 6 |
+
from langchain_core.messages import ToolMessage
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def email_writing_agent_node(state: EmailAgentState) -> dict:
|
| 10 |
+
print("--- DEBUG: ENTERING EMAIL_NODE ---")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
messages= state["messages"]
|
| 14 |
+
|
| 15 |
+
if len(messages) <= 1:
|
| 16 |
+
|
| 17 |
+
final_prompt = email_agent_template.invoke({
|
| 18 |
+
"user_name": state.get("user_name"),
|
| 19 |
+
"sender_email_id": state.get("sender_email_id"),
|
| 20 |
+
"sender_subject": state.get("sender_subject"),
|
| 21 |
+
"sender_email_body": state.get("sender_email_body"),
|
| 22 |
+
"draft_context": state.get("draft_context") or "No relevant past context found.",
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
final_prompt = final_prompt.to_messages()
|
| 26 |
+
|
| 27 |
+
else:
|
| 28 |
+
|
| 29 |
+
final_prompt = messages
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
response = email_agent.invoke(final_prompt)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
return {"messages": [response]}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def route_after_tools(state: EmailAgentState):
|
| 40 |
+
# Iterate backwards to find the latest ToolMessage
|
| 41 |
+
# This handles cases where the LLM might have sent a text follow-up
|
| 42 |
+
last_tool_msg = next((m for m in reversed(state["messages"])
|
| 43 |
+
if isinstance(m, ToolMessage)), None)
|
| 44 |
+
|
| 45 |
+
if not last_tool_msg:
|
| 46 |
+
return "email_writing_agent"
|
| 47 |
+
|
| 48 |
+
content_upper = last_tool_msg.content.upper()
|
| 49 |
+
|
| 50 |
+
# logic 1: If we just successfully SENT the email, go to Parser -> Memory
|
| 51 |
+
if last_tool_msg.name == "send_draft_by_id" and "SUCCESS" in content_upper:
|
| 52 |
+
print("--- ROUTER: Send successful. Moving to Parse/Memory. ---")
|
| 53 |
+
return "parse_node"
|
| 54 |
+
|
| 55 |
+
# logic 2: If we just created a DRAFT (or the send failed)
|
| 56 |
+
# Go back to agent to talk to the user
|
| 57 |
+
print("--- ROUTER: Draft created or Tool failed. Returning to Agent. ---")
|
| 58 |
+
return "email_writing_agent"
|
app/nodes/graphnodes.py
DELETED
|
@@ -1,13 +0,0 @@
|
|
| 1 |
-
from app.state.state import OnboardingState
|
| 2 |
-
from langchain_core.messages import SystemMessage, HumanMessage,ToolMessage,AIMessage
|
| 3 |
-
from app.prompts.
|
| 4 |
-
from app.prompts.
|
| 5 |
-
from app.prompts.
|
| 6 |
-
from app.agents.agents import
|
| 7 |
-
from app.prompts.gap_analysis_agent_prompt import gap_analysis_agent_prompt
|
| 8 |
-
from app.schemas.pydanticschema import ResumeExtract,JobDescriptionExtract,SkillGapAnalysis
|
| 9 |
-
import json
|
| 10 |
-
from app.tools.tools import *
|
| 11 |
-
from langchain_community.document_loaders import PyMuPDFLoader
|
| 12 |
-
from langgraph.prebuilt import ToolNode ,tools_condition
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/nodes/parse_node.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from langchain_core.messages import ToolMessage
|
| 3 |
+
from app.state.state import EmailAgentState
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def parse_response_node(state: EmailAgentState) -> dict:
|
| 7 |
+
messages = state["messages"]
|
| 8 |
+
updates = {}
|
| 9 |
+
|
| 10 |
+
# 1. Find the successful SEND message to get the ID
|
| 11 |
+
# We scan backwards for the first ToolMessage from 'send_draft_by_id'
|
| 12 |
+
send_tool_msg = next((m for m in reversed(messages)
|
| 13 |
+
if isinstance(m, ToolMessage) and m.name == "send_draft_by_id"), None)
|
| 14 |
+
|
| 15 |
+
if send_tool_msg and "SUCCESS" in send_tool_msg.content.upper():
|
| 16 |
+
id_match = re.search(r'<id>(.*?)</id>', send_tool_msg.content)
|
| 17 |
+
if id_match:
|
| 18 |
+
updates["sent_message_id"] = id_match.group(1)
|
| 19 |
+
|
| 20 |
+
# 2. Find the most recent successful DRAFT creation
|
| 21 |
+
# This is the "lookback" that ignores all the rejections and rewrites
|
| 22 |
+
draft_tool_msg = next((m for m in reversed(messages)
|
| 23 |
+
if isinstance(m, ToolMessage) and m.name == "create_gmail_draft"
|
| 24 |
+
and "Successfully" in m.content), None)
|
| 25 |
+
|
| 26 |
+
if draft_tool_msg:
|
| 27 |
+
# Extract the content from the tags you defined in the tool
|
| 28 |
+
subject_match = re.search(r'<subject>(.*?)</subject>', draft_tool_msg.content)
|
| 29 |
+
body_match = re.search(r'<body>(.*?)</body>', draft_tool_msg.content, re.DOTALL)
|
| 30 |
+
|
| 31 |
+
if subject_match:
|
| 32 |
+
updates["reply_subject"] = subject_match.group(1)
|
| 33 |
+
if body_match:
|
| 34 |
+
updates["reply_email_body"] = body_match.group(1)
|
| 35 |
+
|
| 36 |
+
# 3. Final Safety: If we still don't have a body, don't return 'None'
|
| 37 |
+
if not updates.get("reply_email_body"):
|
| 38 |
+
updates["reply_email_body"] = "Content could not be retrieved from history."
|
| 39 |
+
|
| 40 |
+
return updates
|
app/nodes/safety_classifier_node.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from typing import Any
|
| 3 |
+
from app.state.state import EmailAgentState
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
SAFETY_API_URL = "https://gaykar-classifyemail.hf.space/predict"
|
| 7 |
+
|
| 8 |
+
def safety_classifier_node(state: EmailAgentState) -> dict:
|
| 9 |
+
payload = {
|
| 10 |
+
"subject": state["sender_subject"],
|
| 11 |
+
"body": state["sender_email_body"],
|
| 12 |
+
}
|
| 13 |
+
try:
|
| 14 |
+
with httpx.Client(timeout=10.0) as client:
|
| 15 |
+
response = client.post(SAFETY_API_URL, json=payload)
|
| 16 |
+
response.raise_for_status()
|
| 17 |
+
result = response.json()
|
| 18 |
+
|
| 19 |
+
prediction = result.get("prediction", "UNSAFE").upper()
|
| 20 |
+
confidence = result.get("confidence", "0.00%")
|
| 21 |
+
url_count = result.get("url_count", 0)
|
| 22 |
+
is_safe = prediction == "SAFE"
|
| 23 |
+
safety_reason = None
|
| 24 |
+
if not is_safe:
|
| 25 |
+
safety_reason = (
|
| 26 |
+
f"Classified as {prediction} "
|
| 27 |
+
f"(confidence: {confidence}, urls found: {url_count})"
|
| 28 |
+
)
|
| 29 |
+
return {"is_safe": is_safe, "safety_reason": safety_reason}
|
| 30 |
+
|
| 31 |
+
except httpx.TimeoutException:
|
| 32 |
+
return {"is_safe": False, "safety_reason": "Safety API timed out"}
|
| 33 |
+
except httpx.HTTPStatusError as e:
|
| 34 |
+
return {"is_safe": False, "safety_reason": f"API error {e.response.status_code}"}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def after_safety(state: EmailAgentState) -> str:
|
| 39 |
+
if not state["is_safe"]:
|
| 40 |
+
return "unsafe_emails_node"
|
| 41 |
+
return "triage_node"
|
app/nodes/store_memory_data_node.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.messages import AIMessage
|
| 2 |
+
from langchain_core.runnables.config import RunnableConfig
|
| 3 |
+
from app.agents.memory_manager_agent import memory_manager_agent
|
| 4 |
+
from app.prompts.memory_manager_agent_prompt import memory_agent_template
|
| 5 |
+
from app.state.state import EmailAgentState
|
| 6 |
+
from app.agents.memory_manager_agent import memory_manager_agent
|
| 7 |
+
|
| 8 |
+
def store_memory_and_data_node(state: EmailAgentState, config: RunnableConfig):
|
| 9 |
+
"""
|
| 10 |
+
Synchronous LangGraph node to persist email interaction with robust session handling.
|
| 11 |
+
"""
|
| 12 |
+
print("--- Memory Node: Persisting interaction to DB ---")
|
| 13 |
+
|
| 14 |
+
# 1. Prepare the memory summary
|
| 15 |
+
prompt = memory_agent_template.invoke({
|
| 16 |
+
"user_name": state["user_name"],
|
| 17 |
+
"senders_email_id": state["sender_email_id"],
|
| 18 |
+
"user_email_id": state["user_email_id"],
|
| 19 |
+
"sent_email_body": state.get("reply_email_body", "No response found"), # Use .get() for safety
|
| 20 |
+
"incoming_email_body": state["sender_email_body"],
|
| 21 |
+
})
|
| 22 |
+
|
| 23 |
+
# 2. Invoke memory agent logic
|
| 24 |
+
memory_manager_agent.invoke(
|
| 25 |
+
{"messages": prompt.to_messages()},
|
| 26 |
+
config=config
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# 3. Robust Database Operations
|
| 30 |
+
sender_id = state['user_id']
|
| 31 |
+
thread_id = config.get("configurable", {}).get("thread_id")
|
| 32 |
+
|
| 33 |
+
# Using 'with' handles opening/closing even if an error occurs
|
| 34 |
+
with get_session() as session:
|
| 35 |
+
try:
|
| 36 |
+
save_sent_email(session, sender_id, thread_id, state)
|
| 37 |
+
save_received_email(session, sender_id, thread_id, state)
|
| 38 |
+
session.commit()
|
| 39 |
+
print("--- Memory Node: DB Save Successful ---")
|
| 40 |
+
except Exception as e:
|
| 41 |
+
session.rollback()
|
| 42 |
+
print(f"--- Memory Node Error: {e} ---")
|
| 43 |
+
raise e
|
| 44 |
+
|
| 45 |
+
return {"memory_agent_messages": [AIMessage(content="Memory successfully persisted.")]}
|
app/nodes/triage_node.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.agents.triage_agent import triage_agent
|
| 2 |
+
from app.prompts.triage_agent_prompt import triage_agent_template
|
| 3 |
+
from app.schemas.triage_agent_schema import TriageOutput
|
| 4 |
+
from app.state.state import EmailAgentState
|
| 5 |
+
def triage_node(state: dict) -> dict:
|
| 6 |
+
|
| 7 |
+
triage_agent_prompt =triage_agent_template.invoke(
|
| 8 |
+
{
|
| 9 |
+
"sender_subject": state["sender_subject"],
|
| 10 |
+
"sender_body": state["sender_email_body"],
|
| 11 |
+
"user_name": state["user_name"],
|
| 12 |
+
}
|
| 13 |
+
)
|
| 14 |
+
result = triage_agent.invoke(triage_agent_prompt.to_messages())
|
| 15 |
+
parsed: TriageOutput = result["parsed"]
|
| 16 |
+
return {
|
| 17 |
+
"triage_label": parsed.triage_label,
|
| 18 |
+
"requires_reply": parsed.requires_reply,
|
| 19 |
+
"triage_notes": parsed.triage_notes,
|
| 20 |
+
"priority_score": parsed.priority_score,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def route_after_triage(state: EmailAgentState):
|
| 25 |
+
# This is a ROUTER, so it returns a STRING name of the next node
|
| 26 |
+
label = state["triage_label"]
|
| 27 |
+
# Go to the node that formats the prompt
|
| 28 |
+
if label == "FOLLOW_UP_REQUIRED":
|
| 29 |
+
return "prepare_context_node"
|
| 30 |
+
|
| 31 |
+
return "archive_node"
|
app/nodes/unsafe_email_nodes.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.state.state import EmailAgentState
|
| 2 |
+
from langchain_core.runnables.config import RunnableConfig
|
| 3 |
+
|
| 4 |
+
def unsafe_emails_node(state: EmailAgentState,config: RunnableConfig) -> dict:
|
| 5 |
+
print(f"[QUARANTINE] {state['sender_subject']} — reason: {state['safety_reason']}")
|
| 6 |
+
|
| 7 |
+
session=get_session()
|
| 8 |
+
|
| 9 |
+
user_id=state['user_id']
|
| 10 |
+
|
| 11 |
+
thread_id = config.get("configurable", {}).get("thread_id")
|
| 12 |
+
|
| 13 |
+
save_received_email(session, user_id, thread_id,state)
|
| 14 |
+
return {}
|
app/prompts/email_writing_agent_prompt.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 2 |
+
|
| 3 |
+
email_agent_template = ChatPromptTemplate([
|
| 4 |
+
("system", """
|
| 5 |
+
<role>
|
| 6 |
+
You are {user_name}'s email assistant. MUST use tools for every action.
|
| 7 |
+
</role>
|
| 8 |
+
|
| 9 |
+
<memory_context>
|
| 10 |
+
{draft_context}
|
| 11 |
+
</memory_context>
|
| 12 |
+
|
| 13 |
+
<one_shot_example>
|
| 14 |
+
User: "Reply to client."
|
| 15 |
+
Agent Tool: create_gmail_draft_tool(...)
|
| 16 |
+
Output: "Success! ID: draft_999"
|
| 17 |
+
User: "Send it."
|
| 18 |
+
Agent Tool: send_draft_by_id_tool(draft_id="draft_999")
|
| 19 |
+
Output: "SUCCESS: Sent! Message ID: msg_123"
|
| 20 |
+
Output: "Stored."
|
| 21 |
+
</one_shot_example>
|
| 22 |
+
|
| 23 |
+
<rules>
|
| 24 |
+
1. NEW DRAFT: Call `create_gmail_draft_tool` first.
|
| 25 |
+
2. REJECTION: If "DRAFT REJECTED", rewrite and call `create_gmail_draft_tool` again.
|
| 26 |
+
3. SENDING: Only call `send_draft_by_id_tool` when user explicitly orders.
|
| 27 |
+
4. ARCHIVING: After `send_draft_by_id_tool` returns a Message ID.
|
| 28 |
+
important:You are not allowed to send until the user explicitly orders to send.
|
| 29 |
+
5.Use **{sender_email_id}** as the recipient not the name.
|
| 30 |
+
</rules>
|
| 31 |
+
|
| 32 |
+
"""),
|
| 33 |
+
("human", """
|
| 34 |
+
<incoming_email>
|
| 35 |
+
Subject : {sender_subject}
|
| 36 |
+
Sender : {sender_email_id}
|
| 37 |
+
Body : {sender_email_body}
|
| 38 |
+
</incoming_email>
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
""")
|
| 42 |
+
])
|
app/prompts/memory_manager_agent_prompt.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 2 |
+
|
| 3 |
+
memory_agent_template = ChatPromptTemplate([
|
| 4 |
+
("system", """<username>{user_name}</username>.<user_email_id>{user_email_id}</user_email_id>
|
| 5 |
+
|
| 6 |
+
"""),
|
| 7 |
+
|
| 8 |
+
("human", """
|
| 9 |
+
<incoming_email>
|
| 10 |
+
From: {senders_email_id}
|
| 11 |
+
To:User
|
| 12 |
+
Body: {incoming_email_body}
|
| 13 |
+
</incoming_email>
|
| 14 |
+
<reply_email>
|
| 15 |
+
From:User
|
| 16 |
+
To: {senders_email_id}
|
| 17 |
+
Body: {sent_email_body}
|
| 18 |
+
</reply_email>
|
| 19 |
+
"""),
|
| 20 |
+
])
|
app/schemas/email_writing_agent_tools_schema.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
class CreateDraftSchema(BaseModel):
|
| 4 |
+
to: str = Field(description="Recipient email address,must be a plain string email, NOT a list/array.")
|
| 5 |
+
subject: str = Field(description="Email subject.")
|
| 6 |
+
body: str = Field(description="Email body content.")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class SendDraftSchema(BaseModel):
|
| 10 |
+
draft_id: str = Field(description="The ID of the draft to send.")
|
app/schemas/triage_agent_schema.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
from pydantic import BaseModel, Field
|
| 3 |
+
from typing import Literal, Optional
|
| 4 |
+
|
| 5 |
+
TriageLabel = Literal[
|
| 6 |
+
"FOLLOW_UP_REQUIRED",
|
| 7 |
+
"READ_LATER",
|
| 8 |
+
"CHECK_LATER",
|
| 9 |
+
"PROMOTIONAL",
|
| 10 |
+
"FYI_NOTIFICATION",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
class TriageOutput(BaseModel):
|
| 14 |
+
"""
|
| 15 |
+
Structured output from the triage agent.
|
| 16 |
+
Classifies an incoming email into a productivity label.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
triage_label: TriageLabel = Field(
|
| 20 |
+
description=(
|
| 21 |
+
"Productivity label assigned to the email. "
|
| 22 |
+
"FOLLOW_UP_REQUIRED = Action or reply needed (High Urgency & Standard Follow-ups), "
|
| 23 |
+
"READ_LATER = Informational only, no action required, "
|
| 24 |
+
"CHECK_LATER = Unknown sender or cold outreach, "
|
| 25 |
+
"PROMOTIONAL = Marketing or sales emails, "
|
| 26 |
+
"FYI_NOTIFICATION = Automated alerts or confirmations."
|
| 27 |
+
)
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
requires_reply: bool = Field(
|
| 31 |
+
description="True ONLY when triage_label is FOLLOW_UP_REQUIRED. False for all others."
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
triage_notes: Optional[str] = Field(
|
| 35 |
+
default=None,
|
| 36 |
+
description="One concise sentence explaining why this label and priority were chosen."
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
priority_score: int = Field(
|
| 40 |
+
ge=1,
|
| 41 |
+
le=5,
|
| 42 |
+
description=(
|
| 43 |
+
"Urgency score: 5 = Critical/Urgent (Reply NOW), "
|
| 44 |
+
"4 = High priority (Reply today), "
|
| 45 |
+
"3 = Standard follow-up (this week), "
|
| 46 |
+
"2 = Low priority read, "
|
| 47 |
+
"1 = Ignore/Archive."
|
| 48 |
+
)
|
| 49 |
+
)
|
app/state/state.py
CHANGED
|
@@ -1,22 +1,65 @@
|
|
| 1 |
from typing import Any, Dict, List, Optional, Tuple,TypedDict,Literal
|
| 2 |
from typing import Annotated, Sequence
|
| 3 |
-
import os
|
| 4 |
-
from langchain_core.messages import SystemMessage, HumanMessage,ToolMessage,AIMessage
|
| 5 |
-
from langchain_core.tools import Tool
|
| 6 |
from langgraph.graph import StateGraph,END,START
|
| 7 |
from langgraph.types import interrupt
|
| 8 |
from langchain_core.prompts import ChatPromptTemplate,MessagesPlaceholder
|
| 9 |
-
from langchain_community.document_loaders import PyMuPDFLoader
|
| 10 |
from pydantic import BaseModel, Field
|
| 11 |
from typing import List, Optional
|
| 12 |
-
from pprint import pprint
|
| 13 |
from langchain_core.messages import BaseMessage
|
| 14 |
from langgraph.graph import add_messages
|
| 15 |
-
from app.schemas.
|
| 16 |
|
| 17 |
|
| 18 |
|
|
|
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
|
|
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Any, Dict, List, Optional, Tuple,TypedDict,Literal
|
| 2 |
from typing import Annotated, Sequence
|
|
|
|
|
|
|
|
|
|
| 3 |
from langgraph.graph import StateGraph,END,START
|
| 4 |
from langgraph.types import interrupt
|
| 5 |
from langchain_core.prompts import ChatPromptTemplate,MessagesPlaceholder
|
|
|
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
from typing import List, Optional
|
|
|
|
| 8 |
from langchain_core.messages import BaseMessage
|
| 9 |
from langgraph.graph import add_messages
|
| 10 |
+
from app.schemas.triage_agent_schema import TriageLabel
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
+
class EmailAgentState(TypedDict):
|
| 15 |
|
| 16 |
|
| 17 |
+
user_email_id: str
|
| 18 |
+
user_id: int
|
| 19 |
+
|
| 20 |
+
sender_email_body: str
|
| 21 |
|
| 22 |
+
sender_email_id: str
|
| 23 |
|
| 24 |
+
sender_subject: str
|
| 25 |
+
|
| 26 |
+
user_name: str
|
| 27 |
+
|
| 28 |
+
# Safety node output
|
| 29 |
+
is_safe: Optional[bool]
|
| 30 |
+
safety_reason: Optional[str]
|
| 31 |
+
|
| 32 |
+
# Triage node output
|
| 33 |
+
triage_label: Optional[TriageLabel]
|
| 34 |
+
|
| 35 |
+
requires_reply: Optional[bool]
|
| 36 |
+
|
| 37 |
+
triage_notes: Optional[str]
|
| 38 |
+
|
| 39 |
+
priority_score: Optional[int]
|
| 40 |
+
|
| 41 |
+
draft_id: Optional[str]
|
| 42 |
+
|
| 43 |
+
sent_message_id: Optional[str]
|
| 44 |
+
|
| 45 |
+
draft_context:Optional[str]
|
| 46 |
+
|
| 47 |
+
memory_agent_messages:Annotated[Sequence[BaseMessage],add_messages]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
reply_subject: Optional[str]
|
| 51 |
+
|
| 52 |
+
draft_email: Optional[str]
|
| 53 |
+
|
| 54 |
+
draft_reason: Optional[str]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
context_agent_messages:Annotated[Sequence[BaseMessage],add_messages]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
human_approved: Optional[bool]
|
| 61 |
+
reply_email_body:Optional[str]
|
| 62 |
+
|
| 63 |
+
messages:Annotated[Sequence[BaseMessage],add_messages]
|
| 64 |
+
|
| 65 |
+
|
app/tools/context_agent_tools.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langmem import create_search_memory_tool
|
| 2 |
+
from langchain.tools import tool
|
| 3 |
+
|
| 4 |
+
search_memory_tool = create_search_memory_tool(
|
| 5 |
+
namespace=(
|
| 6 |
+
"email",
|
| 7 |
+
"{langgraph_user_id}",
|
| 8 |
+
"collection"
|
| 9 |
+
)
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
@tool
|
| 13 |
+
def give_previous_context(memory_summary: str) -> str:
|
| 14 |
+
"""
|
| 15 |
+
Args:
|
| 16 |
+
memory_summary: Structured summary containing sender identity,
|
| 17 |
+
past context, new facts stored, and suggested tone.
|
| 18 |
+
|
| 19 |
+
"""
|
| 20 |
+
return memory_summary
|
| 21 |
+
|
| 22 |
+
context_agent_tools=[search_memory_tool,give_previous_context]
|
app/tools/email_writing_agent_tools.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langgraph.types import interrupt
|
| 2 |
+
from googleapiclient.errors import HttpError
|
| 3 |
+
from app.schemas.email_writing_agent_tools_schema import CreateDraftSchema, SendDraftSchema
|
| 4 |
+
from langchain.tools import tool
|
| 5 |
+
from langchain_google_community import GmailToolkit
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@tool(args_schema=CreateDraftSchema)
|
| 9 |
+
def create_gmail_draft(to: str, subject: str, body: str):
|
| 10 |
+
"""Creates a new Gmail draft after human approval."""
|
| 11 |
+
|
| 12 |
+
# 1. Pause and ask for review
|
| 13 |
+
response = interrupt({
|
| 14 |
+
"action": "review_draft",
|
| 15 |
+
"data": {"to": to, "subject": subject, "body": body}
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
toolkit = GmailToolkit()
|
| 19 |
+
draft_tool = [t for t in toolkit.get_tools() if t.name == "create_gmail_draft"][0]
|
| 20 |
+
|
| 21 |
+
# 2. Handle the response
|
| 22 |
+
if response.get("status") == "approved":
|
| 23 |
+
reply = draft_tool.invoke({
|
| 24 |
+
"message": body,
|
| 25 |
+
"to": [to],
|
| 26 |
+
"subject": subject
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
draft_id=reply.split(":")[1].strip()
|
| 30 |
+
return f"Successfully created draft : <id>{draft_id}</id> <subject>{subject}</subject> <body>{body}</body>"
|
| 31 |
+
|
| 32 |
+
else:
|
| 33 |
+
# Get the feedback from the user response
|
| 34 |
+
feedback = response.get("feedback", "User rejected without specific notes.")
|
| 35 |
+
|
| 36 |
+
# We return this to the AGENT so it can read it and rewrite the draft
|
| 37 |
+
return f"DRAFT REJECTED BY USER. Feedback: {feedback}. Please rewrite the draft based on this feedback and try again."
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@tool(args_schema=SendDraftSchema)
|
| 43 |
+
def send_draft_by_id(draft_id: str):
|
| 44 |
+
"""Sends a finalized Gmail draft by its ID."""
|
| 45 |
+
try:
|
| 46 |
+
toolkit = GmailToolkit()
|
| 47 |
+
result = toolkit.api_resource.users().drafts().send(
|
| 48 |
+
userId="me", body={"id": draft_id}
|
| 49 |
+
).execute()
|
| 50 |
+
return f"SUCCESS: Sent! a Gmail with ID: <id>{result['id']}</id>"
|
| 51 |
+
except HttpError as error:
|
| 52 |
+
if error.resp.status == 404:
|
| 53 |
+
return f"ERROR: Draft ID {draft_id} was not found. Please verify the ID or check if it was already sent."
|
| 54 |
+
return f"ERROR: An unexpected error occurred: {error}"
|
app/tools/tools.py
DELETED
|
File without changes
|
requirements.txt
CHANGED
|
@@ -6,3 +6,6 @@ fastapi==0.118.1
|
|
| 6 |
uvicorn
|
| 7 |
langmem
|
| 8 |
SQLAlchemy==2.0.41
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
uvicorn
|
| 7 |
langmem
|
| 8 |
SQLAlchemy==2.0.41
|
| 9 |
+
google-auth==2.49.1
|
| 10 |
+
langgraph-checkpoint-postgres
|
| 11 |
+
psycopg[binary,pool]
|