refactor(core): extract protocol-agnostic service layer

master
oabrivard 6 days ago
parent 42189e972d
commit d25d429519

@ -0,0 +1,25 @@
from app.core.models import (
CoreAvailabilityResult,
CoreBusySlot,
CoreMailingListCandidate,
CoreMethodExecution,
CoreScanResult,
CoreUnsubscribeCandidatesResult,
CoreUnsubscribeDigestResult,
CoreUnsubscribeExecutionResult,
CoreUnsubscribeMethod,
)
from app.core.service import CoreAgentService
__all__ = [
"CoreAgentService",
"CoreScanResult",
"CoreAvailabilityResult",
"CoreBusySlot",
"CoreUnsubscribeDigestResult",
"CoreUnsubscribeMethod",
"CoreMailingListCandidate",
"CoreUnsubscribeCandidatesResult",
"CoreMethodExecution",
"CoreUnsubscribeExecutionResult",
]

@ -0,0 +1,86 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class CoreScanResult:
scanned: int
linkedin: int
advertising: int
veille_techno: int
skipped: int
failed: int
@dataclass(frozen=True)
class CoreBusySlot:
calendar_id: str
start: str
end: str
@dataclass(frozen=True)
class CoreAvailabilityResult:
start: str
end: str
available: bool
busy_slots: list[CoreBusySlot]
checked_calendars: list[str]
@dataclass(frozen=True)
class CoreUnsubscribeDigestResult:
scanned_messages: int
extracted_unique_links: int
new_links: int
sent_to: str | None
email_sent: bool
@dataclass(frozen=True)
class CoreUnsubscribeMethod:
method_id: str
method_type: str
value: str
@dataclass(frozen=True)
class CoreMailingListCandidate:
candidate_id: str
list_name: str
sender_domain: str
message_count: int
sample_senders: list[str]
sample_subjects: list[str]
methods: list[CoreUnsubscribeMethod]
approved: bool
@dataclass(frozen=True)
class CoreUnsubscribeCandidatesResult:
scanned_messages: int
candidates: list[CoreMailingListCandidate]
@dataclass(frozen=True)
class CoreMethodExecution:
candidate_id: str
list_name: str
method_id: str
method_type: str
value: str
success: bool
detail: str
@dataclass(frozen=True)
class CoreUnsubscribeExecutionResult:
scanned_messages: int
candidates_considered: int
selected_candidates: int
executed_methods: int
skipped_already_executed: int
failed_methods: int
updated_approved_count: int
results: list[CoreMethodExecution]

@ -0,0 +1,202 @@
from __future__ import annotations
import logging
from app.calendar_agent import CalendarAvailabilityAgent
from app.config import Settings
from app.gmail_agent import GmailTriageAgent
from app.google_clients import build_calendar_service, build_gmail_service
from app.strands_classifier import StrandsEmailClassifier
from app.unsubscribe_agent import UnsubscribeDigestAgent
from app.unsubscribe_hil_agent import (
CandidateSnapshot,
UnsubscribeExecutionResult,
UnsubscribeHumanLoopAgent,
)
from app.core.models import (
CoreAvailabilityResult,
CoreBusySlot,
CoreMailingListCandidate,
CoreMethodExecution,
CoreScanResult,
CoreUnsubscribeCandidatesResult,
CoreUnsubscribeDigestResult,
CoreUnsubscribeExecutionResult,
CoreUnsubscribeMethod,
)
class CoreAgentService:
def __init__(self, settings: Settings, *, logger: logging.Logger | None = None) -> None:
self.settings = settings
self.logger = logger or logging.getLogger("personal-agent.core")
self._strands_key_warning_logged = False
def scan_mailbox(self, max_results: int) -> CoreScanResult:
gmail_service = build_gmail_service(self.settings)
gmail_agent = GmailTriageAgent(
gmail_service=gmail_service,
query=self.settings.gmail_query,
classifier=self._build_strands_classifier(),
fallback_to_rules=self.settings.llm_fallback_to_rules,
)
result = gmail_agent.scan_and_route_messages(max_results=max_results)
return CoreScanResult(
scanned=result.scanned,
linkedin=result.linkedin,
advertising=result.advertising,
veille_techno=result.veille_techno,
skipped=result.skipped,
failed=result.failed,
)
def check_availability(
self, start: str, end: str, calendar_ids: list[str] | None
) -> CoreAvailabilityResult:
calendar_service = build_calendar_service(self.settings)
availability_agent = CalendarAvailabilityAgent(calendar_service=calendar_service)
result = availability_agent.get_availability(start, end, calendar_ids)
return CoreAvailabilityResult(
start=result.start,
end=result.end,
available=result.available,
busy_slots=[
CoreBusySlot(
calendar_id=slot["calendar_id"],
start=slot["start"],
end=slot["end"],
)
for slot in result.busy_slots
],
checked_calendars=result.checked_calendars,
)
def scan_unsubscribe_digest(self, max_results: int) -> CoreUnsubscribeDigestResult:
bounded_max_results = max(1, min(max_results, 500))
gmail_service = build_gmail_service(self.settings)
unsubscribe_agent = UnsubscribeDigestAgent(
gmail_service=gmail_service,
query=self.settings.unsubscribe_query,
state_file=self.settings.unsubscribe_state_file,
recipient_email=self.settings.unsubscribe_digest_recipient,
send_empty_digest=self.settings.unsubscribe_send_empty_digest,
)
result = unsubscribe_agent.scan_and_send_digest(max_results=bounded_max_results)
return CoreUnsubscribeDigestResult(
scanned_messages=result.scanned_messages,
extracted_unique_links=result.extracted_unique_links,
new_links=result.new_links,
sent_to=result.sent_to,
email_sent=result.email_sent,
)
def list_unsubscribe_candidates(self, max_results: int) -> CoreUnsubscribeCandidatesResult:
snapshot = self._build_unsubscribe_hil_agent().discover_candidates(max_results=max_results)
return self._snapshot_to_core(snapshot)
def execute_unsubscribe_selected(
self,
selected_candidate_ids: list[str],
max_results: int,
remember_selection: bool,
) -> CoreUnsubscribeExecutionResult:
result = self._build_unsubscribe_hil_agent().execute_selected(
selected_candidate_ids=selected_candidate_ids,
max_results=max_results,
remember_selection=remember_selection,
)
return self._execution_to_core(result)
def run_unsubscribe_auto(self, max_results: int) -> CoreUnsubscribeExecutionResult:
result = self._build_unsubscribe_hil_agent().execute_for_approved(max_results=max_results)
return self._execution_to_core(result)
def _build_strands_classifier(self) -> StrandsEmailClassifier | None:
if not self.settings.strands_api_key:
if self.settings.llm_fallback_to_rules:
if not self._strands_key_warning_logged:
self.logger.warning(
"Strands API key not set. Falling back to rules-based classification."
)
self._strands_key_warning_logged = True
return None
raise RuntimeError(
"STRANDS_OPENAI_API_KEY (or LLM_API_KEY) is required when LLM_FALLBACK_TO_RULES is disabled."
)
try:
return StrandsEmailClassifier(
api_key=self.settings.strands_api_key,
model_id=self.settings.strands_model_id,
base_url=self.settings.strands_base_url,
timeout_seconds=self.settings.strands_timeout_seconds,
temperature=self.settings.strands_temperature,
)
except Exception:
if self.settings.llm_fallback_to_rules:
self.logger.exception(
"Could not initialize Strands classifier; using rules fallback."
)
return None
raise
def _build_unsubscribe_hil_agent(self) -> UnsubscribeHumanLoopAgent:
gmail_service = build_gmail_service(self.settings)
return UnsubscribeHumanLoopAgent(
gmail_service=gmail_service,
query=self.settings.unsubscribe_hil_query,
state_file=self.settings.unsubscribe_hil_state_file,
http_timeout_seconds=self.settings.unsubscribe_http_timeout_seconds,
user_agent=self.settings.unsubscribe_user_agent,
)
def _snapshot_to_core(self, snapshot: CandidateSnapshot) -> CoreUnsubscribeCandidatesResult:
return CoreUnsubscribeCandidatesResult(
scanned_messages=snapshot.scanned_messages,
candidates=[
CoreMailingListCandidate(
candidate_id=candidate.candidate_id,
list_name=candidate.list_name,
sender_domain=candidate.sender_domain,
message_count=candidate.message_count,
sample_senders=candidate.sample_senders,
sample_subjects=candidate.sample_subjects,
methods=[
CoreUnsubscribeMethod(
method_id=method.method_id,
method_type=method.method_type,
value=method.value,
)
for method in candidate.methods
],
approved=candidate.approved,
)
for candidate in snapshot.candidates
],
)
def _execution_to_core(
self, result: UnsubscribeExecutionResult
) -> CoreUnsubscribeExecutionResult:
return CoreUnsubscribeExecutionResult(
scanned_messages=result.scanned_messages,
candidates_considered=result.candidates_considered,
selected_candidates=result.selected_candidates,
executed_methods=result.executed_methods,
skipped_already_executed=result.skipped_already_executed,
failed_methods=result.failed_methods,
updated_approved_count=result.updated_approved_count,
results=[
CoreMethodExecution(
candidate_id=item.candidate_id,
list_name=item.list_name,
method_id=item.method_id,
method_type=item.method_type,
value=item.value,
success=item.success,
detail=item.detail,
)
for item in result.results
],
)

@ -10,26 +10,17 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.calendar_agent import CalendarAvailabilityAgent
from app.config import get_settings from app.config import get_settings
from app.gmail_agent import GmailTriageAgent from app.core.service import CoreAgentService
from app.google_clients import build_calendar_service, build_gmail_service
from app.strands_classifier import StrandsEmailClassifier
from app.unsubscribe_agent import UnsubscribeDigestAgent
from app.unsubscribe_hil_agent import (
UnsubscribeHumanLoopAgent,
CandidateSnapshot,
UnsubscribeExecutionResult,
)
settings = get_settings() settings = get_settings()
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO)) logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
logger = logging.getLogger("personal-agent") logger = logging.getLogger("personal-agent")
core_service = CoreAgentService(settings=settings, logger=logger)
scheduler: AsyncIOScheduler | None = None scheduler: AsyncIOScheduler | None = None
scan_lock: asyncio.Lock | None = None scan_lock: asyncio.Lock | None = None
unsubscribe_lock: asyncio.Lock | None = None unsubscribe_lock: asyncio.Lock | None = None
strands_key_warning_logged = False
class ScanResponse(BaseModel): class ScanResponse(BaseModel):
@ -143,14 +134,7 @@ def verify_api_key(
def _run_scan_once(max_results: int) -> ScanResponse: def _run_scan_once(max_results: int) -> ScanResponse:
gmail_service = build_gmail_service(settings) result = core_service.scan_mailbox(max_results=max_results)
gmail_agent = GmailTriageAgent(
gmail_service=gmail_service,
query=settings.gmail_query,
classifier=_build_strands_classifier(),
fallback_to_rules=settings.llm_fallback_to_rules,
)
result = gmail_agent.scan_and_route_messages(max_results=max_results)
return ScanResponse( return ScanResponse(
scanned=result.scanned, scanned=result.scanned,
linkedin=result.linkedin, linkedin=result.linkedin,
@ -162,16 +146,7 @@ def _run_scan_once(max_results: int) -> ScanResponse:
def _run_unsubscribe_digest_once(max_results: int) -> UnsubscribeDigestResponse: def _run_unsubscribe_digest_once(max_results: int) -> UnsubscribeDigestResponse:
bounded_max_results = max(1, min(max_results, 500)) result = core_service.scan_unsubscribe_digest(max_results=max_results)
gmail_service = build_gmail_service(settings)
unsubscribe_agent = UnsubscribeDigestAgent(
gmail_service=gmail_service,
query=settings.unsubscribe_query,
state_file=settings.unsubscribe_state_file,
recipient_email=settings.unsubscribe_digest_recipient,
send_empty_digest=settings.unsubscribe_send_empty_digest,
)
result = unsubscribe_agent.scan_and_send_digest(max_results=bounded_max_results)
return UnsubscribeDigestResponse( return UnsubscribeDigestResponse(
scanned_messages=result.scanned_messages, scanned_messages=result.scanned_messages,
extracted_unique_links=result.extracted_unique_links, extracted_unique_links=result.extracted_unique_links,
@ -182,34 +157,9 @@ def _run_unsubscribe_digest_once(max_results: int) -> UnsubscribeDigestResponse:
def _run_unsubscribe_candidates_once(max_results: int) -> UnsubscribeCandidatesResponse: def _run_unsubscribe_candidates_once(max_results: int) -> UnsubscribeCandidatesResponse:
agent = _build_unsubscribe_hil_agent() result = core_service.list_unsubscribe_candidates(max_results=max_results)
snapshot = agent.discover_candidates(max_results=max_results)
return _snapshot_to_response(snapshot)
def _run_unsubscribe_execute_selected(
selected_candidate_ids: list[str],
max_results: int,
remember_selection: bool,
) -> UnsubscribeExecutionResponse:
agent = _build_unsubscribe_hil_agent()
result = agent.execute_selected(
selected_candidate_ids=selected_candidate_ids,
max_results=max_results,
remember_selection=remember_selection,
)
return _execution_to_response(result)
def _run_unsubscribe_auto_once(max_results: int) -> UnsubscribeExecutionResponse:
agent = _build_unsubscribe_hil_agent()
result = agent.execute_for_approved(max_results=max_results)
return _execution_to_response(result)
def _snapshot_to_response(snapshot: CandidateSnapshot) -> UnsubscribeCandidatesResponse:
return UnsubscribeCandidatesResponse( return UnsubscribeCandidatesResponse(
scanned_messages=snapshot.scanned_messages, scanned_messages=result.scanned_messages,
candidates=[ candidates=[
MailingListCandidateResponse( MailingListCandidateResponse(
candidate_id=candidate.candidate_id, candidate_id=candidate.candidate_id,
@ -228,12 +178,21 @@ def _snapshot_to_response(snapshot: CandidateSnapshot) -> UnsubscribeCandidatesR
], ],
approved=candidate.approved, approved=candidate.approved,
) )
for candidate in snapshot.candidates for candidate in result.candidates
], ],
) )
def _execution_to_response(result: UnsubscribeExecutionResult) -> UnsubscribeExecutionResponse: def _run_unsubscribe_execute_selected(
selected_candidate_ids: list[str],
max_results: int,
remember_selection: bool,
) -> UnsubscribeExecutionResponse:
result = core_service.execute_unsubscribe_selected(
selected_candidate_ids=selected_candidate_ids,
max_results=max_results,
remember_selection=remember_selection,
)
return UnsubscribeExecutionResponse( return UnsubscribeExecutionResponse(
scanned_messages=result.scanned_messages, scanned_messages=result.scanned_messages,
candidates_considered=result.candidates_considered, candidates_considered=result.candidates_considered,
@ -257,44 +216,28 @@ def _execution_to_response(result: UnsubscribeExecutionResult) -> UnsubscribeExe
) )
def _build_strands_classifier() -> StrandsEmailClassifier | None: def _run_unsubscribe_auto_once(max_results: int) -> UnsubscribeExecutionResponse:
global strands_key_warning_logged result = core_service.run_unsubscribe_auto(max_results=max_results)
return UnsubscribeExecutionResponse(
if not settings.strands_api_key: scanned_messages=result.scanned_messages,
if settings.llm_fallback_to_rules: candidates_considered=result.candidates_considered,
if not strands_key_warning_logged: selected_candidates=result.selected_candidates,
logger.warning( executed_methods=result.executed_methods,
"Strands API key not set. Falling back to rules-based classification." skipped_already_executed=result.skipped_already_executed,
) failed_methods=result.failed_methods,
strands_key_warning_logged = True updated_approved_count=result.updated_approved_count,
return None results=[
raise RuntimeError( MethodExecutionResponse(
"STRANDS_OPENAI_API_KEY (or LLM_API_KEY) is required when LLM_FALLBACK_TO_RULES is disabled." candidate_id=item.candidate_id,
) list_name=item.list_name,
method_id=item.method_id,
try: method_type=item.method_type,
return StrandsEmailClassifier( value=item.value,
api_key=settings.strands_api_key, success=item.success,
model_id=settings.strands_model_id, detail=item.detail,
base_url=settings.strands_base_url, )
timeout_seconds=settings.strands_timeout_seconds, for item in result.results
temperature=settings.strands_temperature, ],
)
except Exception:
if settings.llm_fallback_to_rules:
logger.exception("Could not initialize Strands classifier; using rules fallback.")
return None
raise
def _build_unsubscribe_hil_agent() -> UnsubscribeHumanLoopAgent:
gmail_service = build_gmail_service(settings)
return UnsubscribeHumanLoopAgent(
gmail_service=gmail_service,
query=settings.unsubscribe_hil_query,
state_file=settings.unsubscribe_hil_state_file,
http_timeout_seconds=settings.unsubscribe_http_timeout_seconds,
user_agent=settings.unsubscribe_user_agent,
) )
@ -444,10 +387,8 @@ async def scan_now(max_results: int = Query(100, ge=1, le=500)) -> ScanResponse:
) )
async def availability(request: AvailabilityRequest) -> AvailabilityResponse: async def availability(request: AvailabilityRequest) -> AvailabilityResponse:
try: try:
calendar_service = build_calendar_service(settings)
availability_agent = CalendarAvailabilityAgent(calendar_service=calendar_service)
result = await asyncio.to_thread( result = await asyncio.to_thread(
availability_agent.get_availability, core_service.check_availability,
request.start, request.start,
request.end, request.end,
request.calendar_ids, request.calendar_ids,

Loading…
Cancel
Save