feat: Implement async AI analysis for email threads
- Added `get_latest_email_date()` function in `database.py` to retrieve the most recent email date for a given account and folder. - Enhanced `fetch_folder_emails()` in `zoho_client.py` to intelligently determine the start date for fetching emails based on the latest email date in the database. - Introduced `analyze_and_update_threads_async()` for asynchronous analysis of email threads, allowing concurrent processing. - Created a synchronous wrapper `analyze_and_update_threads()` for easier integration. - Updated `fetch_emails()` to support database session and account email parameters. - Added comprehensive documentation in `AI_ANALYSIS_GUIDE.md` detailing the new AI analysis functionality. - Implemented tests for the new features, including `test_fetch_with_db.py`, `test_ai_analysis.py`, and `test_single_analysis.py`. - Added error handling and logging improvements throughout the codebase.
This commit is contained in:
+128
-30
@@ -1,10 +1,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import suppress
|
||||
from typing import List
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi import BackgroundTasks, Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
@@ -21,6 +22,9 @@ from src.database import (
|
||||
ingest_emails,
|
||||
)
|
||||
from src.zoho_client import ZohoClient
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def get_db():
|
||||
@@ -31,6 +35,9 @@ def get_db():
|
||||
db.close()
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, filename="email_alerts.log")
|
||||
|
||||
|
||||
create_db_tables()
|
||||
app = FastAPI(title="Email Alerts UI")
|
||||
|
||||
@@ -256,25 +263,9 @@ def _sync_emails_once(cfg: dict) -> int:
|
||||
"""Fetch INBOX and Sent from Zoho and ingest into DB. Returns threads requiring reply count."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Mark status
|
||||
cfg["sync_in_progress"] = True
|
||||
cfg["last_sync_status"] = "running"
|
||||
cfg["last_sync_error"] = None
|
||||
save_config(cfg)
|
||||
|
||||
account_email = cfg.get("email_address") or cfg.get("zoho_email") or ""
|
||||
if not account_email:
|
||||
cfg.update(
|
||||
{
|
||||
"last_sync_status": "error",
|
||||
"last_sync_error": "Configure email_address or zoho_email in /config",
|
||||
"sync_in_progress": False,
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Configure email_address or zoho_email in /config"
|
||||
)
|
||||
raise ValueError("Configure email_address or zoho_email in /config")
|
||||
|
||||
# Incremental lookback by last_sync_at
|
||||
days_back = int(cfg.get("email_days_back", 7) or 7)
|
||||
@@ -292,17 +283,26 @@ def _sync_emails_once(cfg: dict) -> int:
|
||||
email=cfg.get("zoho_email") or account_email,
|
||||
app_password=cfg.get("zoho_app_password"),
|
||||
)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
inbox = client.fetch_folder_emails(
|
||||
folder="INBOX", max_results=max_results, days_back=days_back
|
||||
folder="INBOX",
|
||||
max_results=max_results,
|
||||
days_back=days_back,
|
||||
db_session=db,
|
||||
account_email=account_email,
|
||||
)
|
||||
sent = client.fetch_folder_emails(
|
||||
folder="Sent", max_results=max_results, days_back=days_back
|
||||
folder="Sent",
|
||||
max_results=max_results,
|
||||
days_back=days_back,
|
||||
db_session=db,
|
||||
account_email=account_email,
|
||||
)
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ingest_emails(
|
||||
db, account_email=account_email, emails=inbox, default_folder="INBOX"
|
||||
@@ -321,23 +321,54 @@ def _sync_emails_once(cfg: dict) -> int:
|
||||
db.close()
|
||||
|
||||
|
||||
def _sync_emails_background_task():
|
||||
"""Background task to sync emails and update config status."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
cfg = load_config()
|
||||
try:
|
||||
count = _sync_emails_once(cfg)
|
||||
# Update success status
|
||||
cfg = load_config() # Reload in case it was modified
|
||||
cfg.update(
|
||||
{
|
||||
"sync_in_progress": False,
|
||||
"last_sync_status": "success",
|
||||
"last_sync_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_sync_count": count,
|
||||
"last_sync_error": None,
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
except Exception as e:
|
||||
# Update error status
|
||||
cfg = load_config() # Reload in case it was modified
|
||||
cfg.update(
|
||||
{
|
||||
"sync_in_progress": False,
|
||||
"last_sync_status": "error",
|
||||
"last_sync_error": str(e),
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
logging.error(f"Email sync failed: {e}")
|
||||
|
||||
|
||||
@app.post("/sync_emails")
|
||||
async def sync_emails():
|
||||
async def sync_emails(background_tasks: BackgroundTasks):
|
||||
cfg = load_config()
|
||||
if cfg.get("sync_in_progress"):
|
||||
return RedirectResponse(url="/?sync=busy", status_code=303)
|
||||
|
||||
async def _task():
|
||||
try:
|
||||
_sync_emails_once(load_config())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Mark sync as starting
|
||||
cfg["sync_in_progress"] = True
|
||||
cfg["last_sync_status"] = "running"
|
||||
cfg["last_sync_error"] = None
|
||||
save_config(cfg)
|
||||
asyncio.create_task(_task())
|
||||
|
||||
# Add the background task
|
||||
background_tasks.add_task(_sync_emails_background_task)
|
||||
|
||||
return RedirectResponse(url="/?sync=started", status_code=303)
|
||||
|
||||
|
||||
@@ -358,7 +389,40 @@ async def _auto_runner():
|
||||
# Sync emails then run alerts
|
||||
try:
|
||||
if not cfg.get("sync_in_progress"):
|
||||
_sync_emails_once(cfg)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Mark sync as starting
|
||||
cfg["sync_in_progress"] = True
|
||||
cfg["last_sync_status"] = "running"
|
||||
cfg["last_sync_error"] = None
|
||||
save_config(cfg)
|
||||
|
||||
try:
|
||||
count = _sync_emails_once(cfg)
|
||||
# Update success status
|
||||
cfg = load_config() # Reload in case it was modified
|
||||
cfg.update(
|
||||
{
|
||||
"sync_in_progress": False,
|
||||
"last_sync_status": "success",
|
||||
"last_sync_at": datetime.now(timezone.utc).isoformat(),
|
||||
"last_sync_count": count,
|
||||
"last_sync_error": None,
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
except Exception as e:
|
||||
# Update error status
|
||||
cfg = load_config() # Reload in case it was modified
|
||||
cfg.update(
|
||||
{
|
||||
"sync_in_progress": False,
|
||||
"last_sync_status": "error",
|
||||
"last_sync_error": str(e),
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
logging.error(f"Auto email sync failed: {e}")
|
||||
except Exception:
|
||||
# keep loop alive
|
||||
pass
|
||||
@@ -377,6 +441,23 @@ async def _auto_runner():
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
# Reset any stale sync status from previous session
|
||||
try:
|
||||
cfg = load_config()
|
||||
if cfg.get("sync_in_progress"):
|
||||
cfg.update(
|
||||
{
|
||||
"sync_in_progress": False,
|
||||
"last_sync_status": "interrupted",
|
||||
"last_sync_error": "Previous session ended while sync was in progress",
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
logging.info("Reset stale sync status from previous session")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to reset stale sync status on startup: {e}")
|
||||
|
||||
# Start the auto-processing task
|
||||
global _auto_task
|
||||
_stop_event.clear()
|
||||
_auto_task = asyncio.create_task(_auto_runner())
|
||||
@@ -384,6 +465,23 @@ async def on_startup():
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
# Reset sync status if it's currently in progress
|
||||
try:
|
||||
cfg = load_config()
|
||||
if cfg.get("sync_in_progress"):
|
||||
cfg.update(
|
||||
{
|
||||
"sync_in_progress": False,
|
||||
"last_sync_status": "interrupted",
|
||||
"last_sync_error": "Server shutdown while sync was in progress",
|
||||
}
|
||||
)
|
||||
save_config(cfg)
|
||||
logging.info("Reset sync status due to server shutdown")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to reset sync status on shutdown: {e}")
|
||||
|
||||
# Stop the auto-processing task
|
||||
if _auto_task:
|
||||
_stop_event.set()
|
||||
with suppress(Exception):
|
||||
|
||||
Reference in New Issue
Block a user