首页 / 文章

JS开发者必备的Python:基于FastAPI与LangGraph的Gmail及日历自动化工具

通过构建一个仅用于草稿的个人助手来学习 Python:FastAPI、用于 Gmail 和日历的 Google OAuth、LangGraph 工具、本地 Ollama 模型以及 SSE 流式传输。

发布者:REACTAPP.TOP阅读约 10 分钟2243 词

来源: Learn Python by Building an AI Agent That Reads Emails and Calendar

对于那些日常工作中主要使用 Node.js、React、Express 和 Next 的开发者来说,Python 似乎是一片陌生的领域,其规范和生态系统都需要从零开始学习。一个不错的切入点是为自己构建一些真正有用的事物,而不是简单的待办清单应用或计算器。这个项目就是一个个人助手智能体:它能够连接 Gmail 和 Google 日历,理解通俗的英文提问,并自行决定该执行什么操作。

可以询问它本周收到了哪些邮件,它会读取收件箱内容。告诉它明早安排一次站会,相应的日历事件就会生成。若需要为求职被拒的情况撰写回复,它会先生成草稿,但绝不会直接发送,始终由人工先进行审核。

从 RAG 到智能体

检索增强生成技术能让聊天机器人根据用户自身的文档来回答问题,而非凭空编造内容。构建此类系统有助于了解大语言模型的行为机制、嵌入向量以及ChromaDB等向量数据库的运作原理,以及如何通过服务器发送事件来传输响应。不过,RAG属于被动式技术:它接收问题、搜索文档并给出答案,现实中不会产生其他任何变化。

而智能体则不仅限于回答问题,还会主动采取行动。两者的区别通过对比最容易看清楚:

RAG: Question → Search docs → Answer
Agent: Question → Think → Pick a tool → Use it → Think again → Answer
 ↑___________________________________|
 (loops until the task is done)

智能体通过循环进行推理,并拥有一组工具,即它可以调用的函数,它会自行判断哪个函数适合当前请求。如果是关于会议的问题,它会调用日历工具;如果是要求给经理起草便条的请求,它会调用Gmail的草稿工具;如果需要同时使用两种功能,它会按顺序依次调用它们。第一次看到这种效果时,确实会感觉有点像魔法。

在开发阶段,整个系统都在离线状态下运行,使用本地模型,而通过一个环境变量可以在生产环境中用Claude替代原有模型。

环境设置

在安装或运行任何程序之前,务必先激活虚拟环境,因为忘记这一点是最常见的混淆来源。

python3 -m venv venv
source venv/bin/activate  # Mac/Linux
# you'll see (venv) in your terminal — that means it's active
pip install fastapi uvicorn python-dotenv

最小的 FastAPI 服务器

FastAPI 扮演着 JavaScript 世界中 Express 的角色。一个“Hello World”示例如下:

from fastapi import FastAPI
from dotenv import load_dotenv

load_dotenv()  # reads your .env file

app = FastAPI()

@app.get("/")
def root():
    return {"message": "Agent is running!"}

@app.post("/ask")
def ask(body: dict):
    question = body.get("question")
    return {"answer": f"You asked: {question}"}

先用 uvicorn 启动它:

uvicorn main:app --reload

--reload 参数是 nodemon 的对应选项,会在文件发生变化时重新启动服务器。通过一个快速请求即可测试该接口:

curl -X POST http://localhost:8000/ask \
  -H "Content-Type: application/json" \
  -d '{"question": "hello"}'

连接 Gmail

现在进入实际操作阶段,因为该代理程序会与真实的 Gmail 账户进行交互。首先处理 Google Cloud 端的操作:

  1. 打开 Google Cloud 控制台并创建一个新项目。
  2. 启用 Gmail API 和 Google Calendar API。
  3. 创建类型为“桌面应用”的 OAuth 凭证。
  4. 下载 JSON 文件并将其保存为 credentials.json。
  5. 在 OAuth 同意界面中,于“受众”部分添加自己的 Gmail 地址作为测试用户。

接着安装 Google 客户端库:

pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client

Gmail工具仅能创建草稿,而不会实际发送邮件。未经审核流程,客服人员不应拥有代您发送邮件的权限。

import os
import base64
import re
from email.mime.text import MIMEText
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build

SCOPES = [
    'https://www.googleapis.com/auth/gmail.readonly',
    'https://www.googleapis.com/auth/gmail.compose',
]

def get_gmail_service():
    creds = None
    if os.path.exists('token.json'):
        creds = Credentials.from_authorized_user_file('token.json', SCOPES)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
        with open('token.json', 'w') as f:
            f.write(creds.to_json())
    return build('gmail', 'v1', credentials=creds)


def read_emails(max_results=5):
    service = get_gmail_service()
    results = service.users().messages().list(
        userId='me', maxResults=max_results, labelIds=['INBOX']
    ).execute()

    emails = []
    for msg in results.get('messages', []):
        message = service.users().messages().get(
            userId='me', id=msg['id'], format='full'
        ).execute()
        headers = message['payload']['headers']
        emails.append({
            'subject': next((h['value'] for h in headers if h['name'] == 'Subject'), ''),
            'from':    next((h['value'] for h in headers if h['name'] == 'From'), ''),
            'date':    next((h['value'] for h in headers if h['name'] == 'Date'), ''),
        })
    return emails


def create_draft(to: str, subject: str, body: str):
    # Extract clean email address from "Name <email>" format
    match = re.search(r'[\w\.-]+@[\w\.-]+\.\w+', to)
    if match:
        to = match.group(0)

    service = get_gmail_service()
    message = MIMEText(body)
    message['to'] = to
    message['subject'] = subject
    raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
    draft = service.users().drafts().create(
        userId='me', body={'message': {'raw': raw}}
    ).execute()
    return f"Draft created for {to} — subject: '{subject}'"

首次运行时,浏览器窗口会要求你使用 Google 登录,并提示该应用尚未通过验证。请选择“高级”选项,然后进入该应用。对于仍在开发中的软件来说,这是正常现象。之后凭证会被保存到 token.json 中,这样只需登录一次即可。

有一条安全规则必须明确强调:绝不要将 token.json 或 credentials.json 提交到 git 中。GitHub 的机密信息扫描通常会阻止此类提交,但最好还是不要依赖这一点。应立即将这些文件添加到 .gitignore 中:

token.json
credentials.json
.env
venv/
__pycache__/

连接 Google 日历

流程与 Gmail 相同:先进行身份验证,然后再调用 API。

from datetime import datetime, timedelta
from googleapiclient.discovery import build
# ... (same auth setup as gmail_tool.py)

def get_upcoming_events(days=7, max_results=10):
    service = get_calendar_service()
    now = datetime.utcnow()
    future = now + timedelta(days=days)

    events = service.events().list(
        calendarId='primary',
        timeMin=now.isoformat() + 'Z',
        timeMax=future.isoformat() + 'Z',
        maxResults=max_results,
        singleEvents=True,
        orderBy='startTime'
    ).execute().get('items', [])

    return [{
        'title': e.get('summary', 'No title'),
        'start': e['start'].get('dateTime', e['start'].get('date')),
        'end':   e['end'].get('dateTime', e['end'].get('date')),
        'location': e.get('location', ''),
        'attendees': [a['email'] for a in e.get('attendees', [])],
    } for e in events]


def create_event(title, date, start_time, end_time, description='', attendees=[]):
    service = get_calendar_service()
    event = {
        'summary': title,
        'description': description,
        'start': {'dateTime': f'{date}T{start_time}:00', 'timeZone': 'America/Los_Angeles'},
        'end':   {'dateTime': f'{date}T{end_time}:00',   'timeZone': 'America/Los_Angeles'},
    }
    if attendees:
        event['attendees'] = [{'email': e} for e in attendees]

    created = service.events().insert(
        calendarId='primary', body=event, sendUpdates='none'
    ).execute()
    return f"Event '{title}' created on {date} from {start_time} to {end_time}"

代理本身

LangGraph负责将各组件连接起来。请在模型集成中一并安装它:

pip install langgraph langchain-anthropic langchain-core langchain-ollama

该代理拥有四种工具,这些工具都是普通的 Python 函数,只要加上工具装饰器,LangGraph 就能自动调用它们:

import os
from dotenv import load_dotenv
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.prebuilt import create_react_agent
from gmail_tool import read_emails, create_draft
from calendar_tool import get_upcoming_events, create_event

load_dotenv()

# ── Dev/prod switch ───────────────────────────────────────────────
ENV = os.getenv("APP_ENV", "development")

if ENV == "production":
    from langchain_anthropic import ChatAnthropic
    llm = ChatAnthropic(model="claude-sonnet-4-6", api_key=os.getenv("ANTHROPIC_API_KEY"))
    print("🤖 Using Claude (production)")
else:
    from langchain_ollama import ChatOllama
    llm = ChatOllama(model="llama3.1", temperature=0)
    print("🤖 Using Ollama llama3.1 (development)")

# ── Tools (functions the agent can call) ─────────────────────────
@tool
def tool_read_emails(max_results: int = 5) -> str:
    """Read latest emails from Gmail."""
    emails = read_emails(max_results)
    if not emails:
        return "No emails found."
    return "\n---\n".join(
        f"From: {e['from']}\nSubject: {e['subject']}\nDate: {e['date']}"
        for e in emails
    )

@tool
def tool_create_draft(to: str, subject: str, body: str) -> str:
    """Create a Gmail draft. Args: to=email address, subject=subject line, body=email text."""
    return create_draft(to, subject, body)

@tool
def tool_get_events(days: int = 7) -> str:
    """Get upcoming Google Calendar events."""
    events = get_upcoming_events(days)
    if not events:
        return f"No events in the next {days} days."
    return "\n---\n".join(
        f"📅 {e['title']}\n   {e['start']} → {e['end']}"
        + (f"\n   📍 {e['location']}" if e['location'] else "")
        for e in events
    )

@tool
def tool_create_event(title: str, date: str, start_time: str, end_time: str, description: str = "", attendees: str = "") -> str:
    """Create a Google Calendar event. Date: YYYY-MM-DD. Time: HH:MM."""
    attendee_list = [a.strip() for a in attendees.split(",")] if attendees else []
    return create_event(title, date, start_time, end_time, description, attendee_list)

# ── Agent ─────────────────────────────────────────────────────────
agent = create_react_agent(
    model=llm,
    tools=[tool_read_emails, tool_create_draft, tool_get_events, tool_create_event],
    prompt="""You are a personal assistant. Use tools to help with emails and calendar.

Rules:
- ALWAYS use a tool to answer. Never guess.
- For emails: use tool_read_emails
- For drafts: use tool_create_draft
- For calendar: use tool_get_events
- For new events: use tool_create_event"""
)

def ask_agent(question: str) -> str:
    result = agent.invoke({"messages": [HumanMessage(content=question)]})
    return result['messages'][-1].content

选择 llama3.1 而非 llama3.2

工具调用要求模型输出 LangGraph 能解析的结构化 JSON,而较小规模的模型难以做到这一点。30 亿参数的 llama3.2 总是返回原始 JSON,未能真正调用工具,而 80 亿参数的 llama3.1 则能稳定地完成该任务。对于智能体而言,模型规模的重要性远超普通聊天场景。

实时流式展示智能体的推理过程

LangGraph 最出色的功能之一就是能够逐步查看智能体处理任务的过程:它选择了哪个工具、得到了什么结果,以及是如何确定最终答案的。服务器发送的事件会实时将这一流程推送到前端。相关代码位于 main.py 中:

# main.py
import json
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from langchain_core.messages import HumanMessage
from agent import agent

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])

@app.get("/api/ask")
async def ask(q: str):
    def stream():
        for chunk in agent.stream(
            {"messages": [HumanMessage(content=q)]},
            stream_mode="updates"
        ):
            # Agent deciding which tool to use
            if "agent" in chunk:
                for msg in chunk["agent"].get("messages", []):
                    if hasattr(msg, "tool_calls") and msg.tool_calls:
                        for tc in msg.tool_calls:
                            yield f"data: {json.dumps({'type': 'thinking', 'text': f'Using tool: {tc[\"name\"]}...'})}\n\n"
                    elif hasattr(msg, "content") and msg.content:
                        yield f"data: {json.dumps({'type': 'answer', 'text': msg.content})}\n\n"

            # Tool result coming back
            if "tools" in chunk:
                for msg in chunk["tools"].get("messages", []):
                    if hasattr(msg, "content") and msg.content:
                        yield f"data: {json.dumps({'type': 'tool_result', 'text': str(msg.content)[:300]})}\n\n"

        yield f"data: {json.dumps({'type': 'done'})}\n\n"

    return StreamingResponse(stream(), media_type="text/event-stream")

在界面中,用户会看到如下序列:

🧠 Using tool: tool_read_emails...
📦 Tool result ▼  (click to expand raw data)
💬 Here are your 5 latest emails...

看到代理程序大声思考的过程本身就是一种有说服力的演示。针对本地模型运行的测试脚本会输出完整的执行轨迹:

python3 test_agent.py

🤖 Using Ollama llama3.1 (development)
🤖 Testing agent...

Test 1: Read emails
Here are your latest 3 emails:
1. From Bilt <notifications@members.bilt.com> — Your ALL Accor status match unlocks August 1st
2. From Vanguard <vanguard@...> — Your Vanguard site is getting an upgrade
3. From Kaggle <no-reply@kaggle.com> — Thank You for Making the 5-Day AI Agents Intensive a Success!

Test 2: Check calendar
You have a meeting scheduled for July 31st, 1pm-2pm.

Test 3: Create draft email
✅ Draft created for test@example.com — subject: 'Hello'

Test 4: Create calendar event
✅ Event 'Agent Test Meeting' created on 2026-07-31 from 14:00 to 15:00

该系统使用真实的电子邮件和日历事件,智能体无需协助即可自行选择合适的工具,且所有操作都在无网络连接的笔记本电脑上完成。

值得保留的经验

  • 对智能体而言,模型规模的重要性超出预期。RAG技术能够应对小型模型,因为它只需读取上下文并进行总结。而调用工具则需要有效的结构化JSON数据,小型模型往往会在格式处理上出现错误,因此8B模型与3B模型之间的差距在此处十分明显。
  • 提示语应简短且明确。冗长复杂的指令大多会被小型本地模型忽略,而用项目符号列出规则的六行左右的内容效果要好得多。
  • 默认应仅允许草稿功能。将create_draft设为唯一的邮件发送操作,这样就能由人类掌控全局。若允许智能体自主发送邮件,将会带来风险,因此必须在内容发送前由人类进行审核。
  • 虚拟环境会惩罚人的疏忽。每当出现 ModuleNotFoundError 时,解决办法几乎总是重新激活该虚拟环境。
  • 对JavaScript开发者而言,Python其实比看上去要简单。没有分号,没有大括号,代码结构完全依靠缩进来体现。经过大约一周的每日使用后,这种语言特性就会逐渐被忽视,而实际使用中的问题反而变得愈发突出。
  • 运行已完成的项目

    以下命令可用于克隆已完成的项目并启动它:

    git clone https://github.com/TpPrachi/personal_assistant_agent.git
    cd personal_assistant_agent
    
    python3 -m venv venv
    source venv/bin/activate
    pip install -r requirements.txt
    
    # Pull the model (8B, better tool calling)
    ollama pull llama3.1
    ollama serve
    
    # Set up Google credentials (see README for full steps)
    # Then authenticate
    python3 test_gmail.py
    
    # Run the agent
    python3 test_agent.py
    

    下一步该去哪里

    • 一个网络搜索工具,这样智能体就可以在网上查找信息并加以总结。
    • 对话历史记录,因为当前每个问题都是无状态的,而会话记忆功能可以让对话更自然地延续。
    • 更多工具,如笔记、提醒、Slack 消息或 GitHub 问题。方法始终不变:编写一个函数,用 @tool 标记它,然后将其添加到智能体中。
    • 生产环境部署,使用 Docker、Claude 作为模型替代本地模型,并选择 Railway 或 Render 之类的托管服务。

    智能体并非魔法。它们只是拥有可调用函数的大型语言模型,一旦这些功能得以运用,整个领域就会迎刃而解。真正的学习发生在构建实物的过程中。