Gaykar commited on
Commit
c6421b9
·
1 Parent(s): 6fd495a
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.pydanticschema import ResumeExtract,JobDescriptionExtract,SkillGapAnalysis
3
  from app.core.config import settings
4
- from app.tools.tools import roadmap_planner_agent_tools
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.graphnodes import *
3
  from langgraph.prebuilt import ToolNode ,tools_condition
4
- from app.agents.agents import
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.pydanticschema import *
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]