Головна / Статті

Python для розробника JS: агент для Gmail та календаря з FastAPI та LangGraph

Навчіться Python, створюючи персонального асистента лише у проектній версії: FastAPI, Google OAuth для Gmail та Календаря, інструменти LangGraph, локальні моделі Ollama та стрімінг SSE.

Видавець: REACTAPP.TOP10 хв читання2243 слів

Джерела: Learn Python by Building an AI Agent That Reads Emails and Calendar

Для розробника, який щодня працює з Node.js, React, Express та Next, Python може здатися чужою територією з іншими конвенціями та екосистемою, яку потрібно вивчати з нуля. Хорошим способом є створення чогось реального та корисного, а не просто додатка для списку завдань чи калькулятора. Цей проект — це персональний асистент-агент: ШІ, який підключається до Gmail та Google Calendar, розуміє запитання простою англійською мовою та сам вирішує, що робити.

Запитайте його, які листи надійшли цього тижня, і він прочитає поштову скриньку. Попросіть організувати нараду завтра вранці, і з’явиться подія в календарі. Запитайте про проєкт відповідь на відмову у працевлаштуванні, і він напише чернетку, яку ніколи не надсилає безпосередньо, щоб людина завжди могла її спочатку переглянути.

Від RAG до агентів

Технологія Retrieval-augmented generation дозволяє чат-боту відповідати на запитання з ваших власних документів, замість того щоб вигадувати відповіді. Розробка такого бота допомагає зрозуміти, як поводяться ШІ, як працюють ембеддинги та векторні бази даних, такі як 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 виконує ту саму роль, що й Express у світі JavaScript. Приклад „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. Увімкніть як API Gmail, так і API Google Calendar.
  3. Створіть облікові дані OAuth типу Desktop App.
  4. Завантажте файл JSON та збережіть його як credentials.json.
  5. У розділі Audience екрана згоди 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

Для виклику інструменту модель має генерувати структурований JSON, який може обробити LangGraph, проте менші моделі мають з цим труднощі. 3B-модель llama3.2 постійно повертала сирий JSON замість того, щоб справді викликати інструмент, тоді як 8B-модель 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, її рішенням майже завжди є знову активувати venv.
  • Python простіший, ніж здається розробнику JavaScript. Немає крапок з комою, немає дужок, а структура визначається відступами. Після приблизно тижня щоденного використання мова відходить на другий план, а проблеми стають головними.
  • Запуск готового проекту

    Наведені нижче команди клонують готовий проект та запускають його:

    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.

    Агенти — це не магія. Це ШІ з функціями, які їм дозволено викликати, і як тільки це спрацює, відкривається весь спектр можливостей. Саме під час створення чогось відбувається навчання.