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:
bolade
2025-08-11 23:20:20 +01:00
parent d553d6f31e
commit 75a0a3fde7
14 changed files with 1358 additions and 476 deletions
+128 -30
View File
@@ -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):