Gaykar commited on
Commit
33ffec6
·
1 Parent(s): bd1c641
README.md CHANGED
@@ -9,4 +9,71 @@ license: apache-2.0
9
  short_description: Email ai agent project with memory.
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  short_description: Email ai agent project with memory.
10
  ---
11
 
12
+ # 📧 AI-Driven Email Agent 🧠
13
+
14
+ A production-grade, multi-agent system built with LangGraph and FastAPI that automates email triage, context retrieval, and drafting. This project demonstrates advanced implementation of **Long-term Memory**, **State Persistence**, and **Human-in-the-Loop Interrupts** using LangGraph's Functional API Command pattern.
15
+
16
+ ---
17
+
18
+ ## 🚀 Key Features
19
+
20
+ - **Advanced Learning Implementation**
21
+ Implemented **Semantic Memory**, **Checkpointer Persistence**, and **Functional Interrupts**, enabling the agent to maintain state and handle user feedback reliably.
22
+
23
+ - **Multi-Agent Workflow**
24
+ Specialized agents for:
25
+ - Triage
26
+ - Context Synthesis
27
+ - Email Drafting
28
+
29
+ - **Intelligent Triage**
30
+ Automatically classifies emails, assigns priority, and determines if a reply is required.
31
+
32
+ - **Semantic Memory**
33
+ Uses `langmem` and `PostgresStore` to retrieve past interactions, allowing the agent to remember previous project details.
34
+
35
+ - **Resource Management**
36
+ A dedicated **Token Count Node** ensures large emails (e.g., deployment logs) are summarized before processing to optimize costs.
37
+
38
+ - **Human-in-the-Loop**
39
+ The graph pauses using `interrupt()` to allow users to:
40
+ - Review drafts
41
+ - Approve responses
42
+ - Provide feedback via `Command(resume=...)`
43
+
44
+ - **Scalable Architecture**
45
+ Built with **FastAPI**, **Docker**, and a modular structure for enterprise-grade deployment.
46
+
47
+ ---
48
+
49
+ ## 🛠️ Tech Stack
50
+
51
+ - **Orchestration:** `langgraph` (Functional API), `langchain`
52
+ - **LLM Interface:** `langchain-groq`
53
+ - **Memory & Persistence:**
54
+ - `langmem`
55
+ - `PostgresCheckpoint`
56
+ - `PostgresStore` (Neon/PostgreSQL)
57
+ - **Database ORM:** SQLAlchemy 2.0
58
+ - **Embeddings:** `langchain_huggingface` (DistilBERT)
59
+ - **Backend:** FastAPI + Uvicorn
60
+ - **Configuration:** `pydantic-settings` (.env management)
61
+ - **Authentication:** `google-auth` (Gmail API Integration)
62
+
63
+ ---
64
+
65
+ ## 📂 Project Structure
66
+
67
+ ```bash
68
+ app/
69
+ ├── agents/ # Brains: Specialized LLM logic (Triage, Writer, Context)
70
+ ├── database/ # Data: SQLAlchemy models and Connection Pooling
71
+ ├── nodes/ # Workflow: Functional steps of the graph (Safety, Tokens)
72
+ ├── persistance/ # Persistence: Postgres Checkpointer & Memory Store config
73
+ ├── state/ # Schema: Pydantic & TypedDict state definitions
74
+ ├── utils/ # Toolbox: Token counters, Embeddings, and Auth helpers
75
+ ├── graph.py # Logic: StateGraph construction and compilation
76
+ └── main.py # Entry: FastAPI app and Controller logic
77
+
78
+
79
+
app/core/auth.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta, timezone
2
+ from jose import JWTError, jwt
3
+ from passlib.context import CryptContext
4
+ from fastapi import HTTPException, Depends, status
5
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
6
+ from sqlalchemy.orm import Session
7
+ import hashlib
8
+ import base64
9
+ from app.core.config import settings
10
+ from app.database.connection import get_session
11
+ from app.database.models import User
12
+
13
+
14
+ # CONFIG
15
+
16
+
17
+ SECRET_KEY = settings.SECRET_KEY
18
+ ALGORITHM = settings.ALGORITHM
19
+ ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
20
+
21
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
22
+
23
+ # auto_error=False → allows us to return 401 instead of FastAPI's 403
24
+ security = HTTPBearer(auto_error=False)
25
+
26
+
27
+ # TOKEN LOGIC
28
+
29
+
30
+ def create_access_token(data: dict) -> str:
31
+ """
32
+ Create a JWT access token with UTC-aware expiration
33
+ """
34
+ to_encode = data.copy()
35
+
36
+ expire = datetime.now(timezone.utc) + timedelta(
37
+ minutes=ACCESS_TOKEN_EXPIRE_MINUTES
38
+ )
39
+
40
+ to_encode.update({
41
+ "exp": expire,
42
+ "sub": str(data.get("id")) # JWT best practice
43
+ })
44
+
45
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
46
+
47
+
48
+ def verify_token(
49
+ credentials: HTTPAuthorizationCredentials = Depends(security)
50
+ ) -> dict:
51
+ """
52
+ Verify JWT token and return payload
53
+ """
54
+ if credentials is None:
55
+ raise HTTPException(
56
+ status_code=status.HTTP_401_UNAUTHORIZED,
57
+ detail="Not authenticated"
58
+ )
59
+
60
+ try:
61
+ token = credentials.credentials
62
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
63
+
64
+ user_id = payload.get("id")
65
+ if user_id is None:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_401_UNAUTHORIZED,
68
+ detail="Invalid token payload"
69
+ )
70
+
71
+ return payload
72
+
73
+ except JWTError:
74
+ raise HTTPException(
75
+ status_code=status.HTTP_401_UNAUTHORIZED,
76
+ detail="Invalid or expired token"
77
+ )
78
+
79
+ # CURRENT USER DEPENDENCY
80
+
81
+
82
+ async def get_current_user(
83
+ token_data: dict = Depends(verify_token),
84
+ db: Session = Depends(get_session)
85
+ ) -> User:
86
+ """
87
+ Fetch the logged-in user from DB using JWT payload
88
+ """
89
+ user_id = token_data.get("id")
90
+
91
+ user = (
92
+ db.query(User)
93
+ .filter(User.id == user_id)
94
+ .first()
95
+ )
96
+
97
+ if not user:
98
+ raise HTTPException(
99
+ status_code=status.HTTP_401_UNAUTHORIZED,
100
+ detail="User not found"
101
+ )
102
+
103
+ return user
104
+
105
+ # PASSWORD LOGIC (bcrypt-safe, unlimited length)
app/core/config.py CHANGED
@@ -11,6 +11,11 @@ class Settings(BaseSettings):
11
  GMAIL_CREDENTIALS_PATH: str = "credentials.json"
12
  GMAIL_TOKEN_PATH: str = "token.json"
13
 
 
 
 
 
 
14
  DB_URL_FOR_SQL_AL:str
15
 
16
  model_config = SettingsConfigDict(
 
11
  GMAIL_CREDENTIALS_PATH: str = "credentials.json"
12
  GMAIL_TOKEN_PATH: str = "token.json"
13
 
14
+ SECRET_KEY: str
15
+ ALGORITHM: str
16
+
17
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
18
+
19
  DB_URL_FOR_SQL_AL:str
20
 
21
  model_config = SettingsConfigDict(
app/gmail_auth.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.config import settings
2
+ from langchain_google_community.gmail.utils import (
3
+ build_resource_service,
4
+ get_gmail_credentials,
5
+ )
6
+
7
+ from langchain_google_community import GmailToolkit
8
+ credentials = get_gmail_credentials(
9
+ token_file=settings.GMAIL_TOKEN_PATH,
10
+ scopes=["https://mail.google.com/"],
11
+ client_sercret_file=settings.GMAIL_CREDENTIALS_PATH,
12
+ )
13
+
14
+ api_resource = build_resource_service(
15
+ credentials=credentials
16
+ )
17
+
18
+ gmail_toolkit = GmailToolkit(
19
+ api_resource=api_resource
20
+ )
app/main.py CHANGED
@@ -4,7 +4,6 @@ from typing import Optional, Dict, Any, TypedDict, Annotated, Sequence
4
  from langchain_core.messages import BaseMessage, HumanMessage
5
  from langgraph.graph import add_messages
6
  from langgraph.types import Command
7
- import uuid
8
  import logging
9
  from app.graph import graph
10
  from app.state.state import EmailAgentState
@@ -12,6 +11,24 @@ from app.database.connection import get_session
12
  from app.database.utils import get_or_create_user
13
  from sqlalchemy.orm import Session
14
  from app.database.connection import SessionLocal
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
 
17
  logger = logging.getLogger(__name__)
@@ -29,7 +46,6 @@ app = FastAPI(title="AI Email Agent API")
29
 
30
  class EmailProcessRequest(BaseModel):
31
  thread_id: str
32
- user_email: EmailStr
33
  sender_email_id: EmailStr
34
  sender_subject: str
35
  sender_email_body: str
@@ -37,13 +53,12 @@ class EmailProcessRequest(BaseModel):
37
 
38
  class ReviewActionRequest(BaseModel):
39
  thread_id: str
40
- user_id: str
41
  status: str # "approved" or "rejected"
42
  feedback: Optional[str] = None
43
 
44
  class SendEmailRequest(BaseModel):
45
  thread_id: str
46
- user_id: str
47
  human_message: str
48
  # --- Helper Functions ---
49
 
@@ -67,24 +82,38 @@ def parse_interrupt(final_state: Dict[str, Any]) -> Optional[Dict[str, Any]]:
67
 
68
  # --- Endpoints ---
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @app.post("/process-email")
71
- def process_email(request: EmailProcessRequest, db: Session = Depends(get_session)) -> Dict[str, Any]:
72
  """Process email through the graph pipeline."""
73
 
74
  try:
75
- user = get_or_create_user(db, request.user_email)
76
 
77
  thread_id = request.thread_id
78
  config = {
79
  "configurable": {
80
  "thread_id": thread_id,
81
- "user_id": str(user.id)
82
  }
83
  }
84
 
85
  input_data = {
86
- "user_email_id": request.user_email,
87
- "user_id": user.id,
88
  "user_name": "Atharva",
89
  "sender_email_id": request.sender_email_id,
90
  "sender_subject": request.sender_subject,
@@ -123,14 +152,14 @@ def process_email(request: EmailProcessRequest, db: Session = Depends(get_sessio
123
 
124
 
125
  @app.post("/review-action")
126
- def review_action(request: ReviewActionRequest) -> Dict[str, Any]:
127
  """Resume graph execution based on user review."""
128
 
129
  try:
130
  config = {
131
  "configurable": {
132
  "thread_id": request.thread_id,
133
- "user_id": request.user_id
134
  }
135
  }
136
 
@@ -182,12 +211,12 @@ def review_action(request: ReviewActionRequest) -> Dict[str, Any]:
182
 
183
 
184
  @app.post("/send_email")
185
- def send_email(request: SendEmailRequest) -> Dict[str, Any]:
186
 
187
  config = {
188
  "configurable": {
189
  "thread_id": request.thread_id,
190
- "user_id": request.user_id
191
  }
192
  }
193
 
@@ -195,7 +224,7 @@ def send_email(request: SendEmailRequest) -> Dict[str, Any]:
195
  config,
196
  {"messages": [HumanMessage(content=request.human_message)]},
197
  as_node="prepare_context_node"
198
- )
199
  final_state = graph.invoke(None, config=config)
200
 
201
  return {
@@ -210,4 +239,4 @@ def send_email(request: SendEmailRequest) -> Dict[str, Any]:
210
 
211
  if __name__ == "__main__":
212
  import uvicorn
213
- uvicorn.run(app, host="127.0.0.1", port=8000)
 
4
  from langchain_core.messages import BaseMessage, HumanMessage
5
  from langgraph.graph import add_messages
6
  from langgraph.types import Command
 
7
  import logging
8
  from app.graph import graph
9
  from app.state.state import EmailAgentState
 
11
  from app.database.utils import get_or_create_user
12
  from sqlalchemy.orm import Session
13
  from app.database.connection import SessionLocal
14
+ from app.core.config import settings
15
+ from fastapi import Request
16
+ import os
17
+ from app.database.models import User
18
+ from app.core.auth import create_access_token,get_current_user
19
+
20
+ # CREATE GMAIL AUTH FILES FROM HF SECRETS
21
+
22
+
23
+ if not os.path.exists(settings.GMAIL_CREDENTIALS_PATH):
24
+
25
+ with open(settings.GMAIL_CREDENTIALS_PATH, "w") as f:
26
+ f.write(os.environ["GOOGLE_CREDENTIALS"])
27
+
28
+ if not os.path.exists(settings.GMAIL_TOKEN_PATH):
29
+
30
+ with open(settings.GMAIL_TOKEN_PATH, "w") as f:
31
+ f.write(os.environ["GOOGLE_TOKEN"])
32
 
33
 
34
  logger = logging.getLogger(__name__)
 
46
 
47
  class EmailProcessRequest(BaseModel):
48
  thread_id: str
 
49
  sender_email_id: EmailStr
50
  sender_subject: str
51
  sender_email_body: str
 
53
 
54
  class ReviewActionRequest(BaseModel):
55
  thread_id: str
56
+
57
  status: str # "approved" or "rejected"
58
  feedback: Optional[str] = None
59
 
60
  class SendEmailRequest(BaseModel):
61
  thread_id: str
 
62
  human_message: str
63
  # --- Helper Functions ---
64
 
 
82
 
83
  # --- Endpoints ---
84
 
85
+
86
+ @app.post("/get-user-data")
87
+ def get_user_data(user_email: EmailStr, db: Session = Depends(get_session)):
88
+ """Get user data by email."""
89
+ user = get_or_create_user(db, user_email)
90
+
91
+ token = create_access_token({
92
+ "id": user.id,
93
+ "email": user_email
94
+ })
95
+ return {"user_id": str(user.id), "email": user.email, "token": token}
96
+
97
+
98
+
99
  @app.post("/process-email")
100
+ def process_email(request: EmailProcessRequest, db: Session = Depends(get_session), current_user: User = Depends(get_current_user)) -> Dict[str, Any]:
101
  """Process email through the graph pipeline."""
102
 
103
  try:
104
+
105
 
106
  thread_id = request.thread_id
107
  config = {
108
  "configurable": {
109
  "thread_id": thread_id,
110
+ "user_id": str(current_user.id)
111
  }
112
  }
113
 
114
  input_data = {
115
+ "user_email_id": current_user.email,
116
+ "user_id": current_user.id,
117
  "user_name": "Atharva",
118
  "sender_email_id": request.sender_email_id,
119
  "sender_subject": request.sender_subject,
 
152
 
153
 
154
  @app.post("/review-action")
155
+ def review_action(request: ReviewActionRequest,db: Session = Depends(get_session), current_user: User = Depends(get_current_user)) -> Dict[str, Any]:
156
  """Resume graph execution based on user review."""
157
 
158
  try:
159
  config = {
160
  "configurable": {
161
  "thread_id": request.thread_id,
162
+ "user_id": current_user.id
163
  }
164
  }
165
 
 
211
 
212
 
213
  @app.post("/send_email")
214
+ def send_email(request: SendEmailRequest,db: Session = Depends(get_session),current_user: User = Depends(get_current_user)) -> Dict[str, Any]:
215
 
216
  config = {
217
  "configurable": {
218
  "thread_id": request.thread_id,
219
+ "user_id": current_user.id
220
  }
221
  }
222
 
 
224
  config,
225
  {"messages": [HumanMessage(content=request.human_message)]},
226
  as_node="prepare_context_node"
227
+ )
228
  final_state = graph.invoke(None, config=config)
229
 
230
  return {
 
239
 
240
  if __name__ == "__main__":
241
  import uvicorn
242
+ uvicorn.run(app, host="127.0.0.1", port=8080)
app/tools/email_writing_agent_tools.py CHANGED
@@ -7,9 +7,10 @@ from typing import Annotated, Union
7
  from langchain_core.tools import InjectedToolCallId, tool
8
  from langchain.tools import ToolRuntime
9
  from langgraph.types import Command
10
- from langchain_core.messages import SystemMessage, HumanMessage,ToolMessage,AIMessage,BaseMessage
11
  from langgraph.graph import END
 
12
 
 
13
  @tool(args_schema=CreateDraftSchema)
14
  def create_gmail_draft(
15
  to: Union[str, list],
@@ -31,8 +32,8 @@ def create_gmail_draft(
31
  "data": {"to": to, "subject": subject, "body": body}
32
  })
33
 
34
- toolkit = GmailToolkit()
35
- draft_tool = [t for t in toolkit.get_tools() if t.name == "create_gmail_draft"][0]
36
 
37
  # 2. Handle Logic
38
  if response.get("status") == "approved":
@@ -61,14 +62,15 @@ def create_gmail_draft(
61
  #---------------------------------------------------------------------------
62
 
63
 
 
64
  def send_draft(
65
  tool_call_id: Annotated[str, InjectedToolCallId],runtime: ToolRuntime # Injected ID
66
  ):
67
  """Sends a finalized Gmail draft by its ID."""
68
 
69
  try:
70
- toolkit = GmailToolkit()
71
- result = toolkit.api_resource.users().drafts().send(
72
  userId="me", body={"id": runtime.state["draft_id"]}
73
  ).execute()
74
 
 
7
  from langchain_core.tools import InjectedToolCallId, tool
8
  from langchain.tools import ToolRuntime
9
  from langgraph.types import Command
 
10
  from langgraph.graph import END
11
+ from langchain_core.messages import ToolMessage,SystemMessage, HumanMessage
12
 
13
+ from app.gmail_auth import gmail_toolkit ,api_resource
14
  @tool(args_schema=CreateDraftSchema)
15
  def create_gmail_draft(
16
  to: Union[str, list],
 
32
  "data": {"to": to, "subject": subject, "body": body}
33
  })
34
 
35
+
36
+ draft_tool = [t for t in gmail_toolkit.get_tools() if t.name == "create_gmail_draft"][0]
37
 
38
  # 2. Handle Logic
39
  if response.get("status") == "approved":
 
62
  #---------------------------------------------------------------------------
63
 
64
 
65
+ @tool
66
  def send_draft(
67
  tool_call_id: Annotated[str, InjectedToolCallId],runtime: ToolRuntime # Injected ID
68
  ):
69
  """Sends a finalized Gmail draft by its ID."""
70
 
71
  try:
72
+
73
+ result = gmail_toolkit.api_resource.users().drafts().send(
74
  userId="me", body={"id": runtime.state["draft_id"]}
75
  ).execute()
76
 
authenticate_gmail.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from google_auth_oauthlib.flow import InstalledAppFlow
3
+
4
+ # Gmail API scope
5
+ SCOPES = [
6
+ "https://mail.google.com/"
7
+ ]
8
+
9
+ def run_authentication():
10
+ print("Starting Google OAuth Authentication Flow...")
11
+
12
+ if not os.path.exists("credentials.json"):
13
+ print("ERROR: Please place your 'credentials.json' file in this folder first!")
14
+ return
15
+
16
+ flow = InstalledAppFlow.from_client_secrets_file(
17
+ "credentials.json",
18
+ SCOPES
19
+ )
20
+
21
+ creds = flow.run_local_server(port=8080)
22
+
23
+ with open("token.json", "w") as token_file:
24
+ token_file.write(creds.to_json())
25
+
26
+ print("\nSUCCESS: 'token.json' has been created successfully!")
27
+
28
+ if __name__ == "__main__":
29
+ run_authentication()
requirements.txt CHANGED
@@ -14,3 +14,6 @@ psycopg-pool
14
  transformers
15
  pydantic-settings
16
  email-validator
 
 
 
 
14
  transformers
15
  pydantic-settings
16
  email-validator
17
+ itsdangerous
18
+ python-jose[cryptography]
19
+ passlib[bcrypt]