You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
194 lines
5.5 KiB
Python
194 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Annotated
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
|
|
from pydantic import BaseModel
|
|
|
|
from app.calendar_agent import CalendarAvailabilityAgent
|
|
from app.config import get_settings
|
|
from app.gmail_agent import GmailTriageAgent
|
|
from app.google_clients import build_calendar_service, build_gmail_service
|
|
|
|
settings = get_settings()
|
|
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
|
logger = logging.getLogger("personal-agent")
|
|
|
|
app = FastAPI(title="Personal Agent", version="0.1.0")
|
|
scheduler: AsyncIOScheduler | None = None
|
|
scan_lock: asyncio.Lock | None = None
|
|
|
|
|
|
class ScanResponse(BaseModel):
|
|
scanned: int
|
|
linkedin: int
|
|
advertising: int
|
|
skipped: int
|
|
failed: int
|
|
|
|
|
|
class AvailabilityRequest(BaseModel):
|
|
start: str
|
|
end: str
|
|
calendar_ids: list[str] | None = None
|
|
|
|
|
|
class BusySlot(BaseModel):
|
|
calendar_id: str
|
|
start: str
|
|
end: str
|
|
|
|
|
|
class AvailabilityResponse(BaseModel):
|
|
start: str
|
|
end: str
|
|
available: bool
|
|
busy_slots: list[BusySlot]
|
|
checked_calendars: list[str]
|
|
|
|
|
|
def verify_api_key(
|
|
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
|
authorization: Annotated[str | None, Header()] = None,
|
|
) -> None:
|
|
expected = settings.agent_api_key
|
|
if not expected:
|
|
return
|
|
|
|
provided = x_api_key
|
|
if not provided and authorization:
|
|
parts = authorization.split(" ", 1)
|
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
provided = parts[1]
|
|
|
|
if provided != expected:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key."
|
|
)
|
|
|
|
|
|
def _run_scan_once(max_results: int) -> ScanResponse:
|
|
gmail_service = build_gmail_service(settings)
|
|
gmail_agent = GmailTriageAgent(gmail_service=gmail_service, query=settings.gmail_query)
|
|
result = gmail_agent.scan_and_route_messages(max_results=max_results)
|
|
return ScanResponse(
|
|
scanned=result.scanned,
|
|
linkedin=result.linkedin,
|
|
advertising=result.advertising,
|
|
skipped=result.skipped,
|
|
failed=result.failed,
|
|
)
|
|
|
|
|
|
def _get_scan_lock() -> asyncio.Lock:
|
|
global scan_lock
|
|
if scan_lock is None:
|
|
scan_lock = asyncio.Lock()
|
|
return scan_lock
|
|
|
|
|
|
async def _scheduled_scan() -> None:
|
|
lock = _get_scan_lock()
|
|
if lock.locked():
|
|
logger.info("Previous scan still running, skipping this tick.")
|
|
return
|
|
|
|
async with lock:
|
|
try:
|
|
result = await asyncio.to_thread(_run_scan_once, 100)
|
|
logger.info("Scheduled scan complete: %s", result.model_dump())
|
|
except Exception:
|
|
logger.exception("Scheduled scan failed")
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def startup_event() -> None:
|
|
global scheduler
|
|
_get_scan_lock()
|
|
scheduler = AsyncIOScheduler()
|
|
scheduler.add_job(
|
|
_scheduled_scan,
|
|
"interval",
|
|
minutes=settings.gmail_scan_interval_minutes,
|
|
next_run_time=datetime.now(),
|
|
)
|
|
scheduler.start()
|
|
logger.info(
|
|
"Scheduler started (interval=%s min)", settings.gmail_scan_interval_minutes
|
|
)
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown_event() -> None:
|
|
if scheduler:
|
|
scheduler.shutdown(wait=False)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, object]:
|
|
return {
|
|
"status": "ok",
|
|
"scan_interval_minutes": settings.gmail_scan_interval_minutes,
|
|
}
|
|
|
|
|
|
@app.post(
|
|
"/scan",
|
|
response_model=ScanResponse,
|
|
dependencies=[Depends(verify_api_key)],
|
|
)
|
|
async def scan_now(max_results: int = Query(100, ge=1, le=500)) -> ScanResponse:
|
|
async with _get_scan_lock():
|
|
try:
|
|
return await asyncio.to_thread(_run_scan_once, max_results)
|
|
except FileNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Gmail scan failed: {exc}",
|
|
) from exc
|
|
|
|
|
|
@app.post(
|
|
"/availability",
|
|
response_model=AvailabilityResponse,
|
|
dependencies=[Depends(verify_api_key)],
|
|
)
|
|
async def availability(request: AvailabilityRequest) -> AvailabilityResponse:
|
|
try:
|
|
calendar_service = build_calendar_service(settings)
|
|
availability_agent = CalendarAvailabilityAgent(calendar_service=calendar_service)
|
|
result = await asyncio.to_thread(
|
|
availability_agent.get_availability,
|
|
request.start,
|
|
request.end,
|
|
request.calendar_ids,
|
|
)
|
|
return AvailabilityResponse(
|
|
start=result.start,
|
|
end=result.end,
|
|
available=result.available,
|
|
busy_slots=result.busy_slots,
|
|
checked_calendars=result.checked_calendars,
|
|
)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
except FileNotFoundError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=str(exc),
|
|
) from exc
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Availability lookup failed: {exc}",
|
|
) from exc
|