# backend/app/worker/sync_jobs.py — phase-13 주기 sync 잡 + 수동 트리거 진입점 # 프로토타입은 수동 트리거(/api/connectors/sync-all)도 제공(데모 결정성). from sqlmodel import Session, select from ..config import get_settings from ..connectors.registry import ConnectorRegistry from ..db import engine from ..models import ConnState, Email, ExternalLink def recover_stuck_syncing(session: Session | None = None) -> int: """기동 시 'syncing' 으로 멈춰 있는 계정을 'connected' 로 되돌린다. sync() 는 시작할 때 state=syncing 을 즉시 커밋하고 끝에서 connected/error 로 바꾸는데, 그 사이에 프로세스가 죽거나(uvicorn --reload, 크래시) 재시작되면 syncing 이 DB 에 영구히 남는다. run_periodic_sync 는 connected/error 만 sync 하므로 멈춘 syncing 계정은 영영 동기화에서 빠진다. 기동 직후엔 실제 진행 중인 sync 가 없으므로 syncing 은 모두 '깨진 상태' → connected 로 복구.""" def _do(s: Session) -> int: stuck = [a for a in ConnectorRegistry.accounts(s) if a.state == ConnState.syncing] for a in stuck: a.state = ConnState.connected s.add(a) if stuck: s.commit() return len(stuck) if session is not None: return _do(session) with Session(engine) as s: return _do(s) def purge_spam(session: Session | None = None) -> int: """이미 저장된 스팸(provider 가 스팸 분류)을 일괄 삭제 — 더는 스팸을 들고 있지 않는다. 이후 sync 는 스팸을 저장하지 않으므로(fetch 제외 + base.sync 안전망) 재유입 없음.""" def _do(s: Session) -> int: spam = s.exec(select(Email).where(Email.folder == "spam")).all() ids = [e.id for e in spam] if not ids: return 0 links = s.exec( select(ExternalLink).where( ExternalLink.entity_type == "email", ExternalLink.entity_id.in_(ids), ) ).all() for lk in links: s.delete(lk) for e in spam: s.delete(e) s.commit() return len(ids) if session is not None: return _do(session) with Session(engine) as s: return _do(s) def run_periodic_sync(session: Session | None = None) -> list[dict]: """worker 스케줄러가 sync_interval_minutes 마다 호출. real/연결된 계정만 sync. mock 계정은 데이터가 변하지 않으므로 주기 sync 대상이 아니다.""" def _do(s: Session) -> list[dict]: out: list[dict] = [] for a in ConnectorRegistry.accounts(s): mode = ConnectorRegistry.effective_mode(a) if a.state in (ConnState.connected, ConnState.error) and mode != "mock": res = ConnectorRegistry.get(s, a).sync(s) out.append(res.__dict__) return out if session is not None: return _do(session) with Session(engine) as s: return _do(s) def run_auto_triage(limit: int = 3) -> int: """새 메일 자동 정리: 기준 시각 이후 도착한 미분석 '집중' 메일을 LLM 으로 백그라운드 분석. auto-sync 루프가 sync 직후 호출 — 사용자가 '정리 맡기기'를 누르지 않아도 카드가 미리 채워진다. 한 사이클에 limit 통만 처리(부하 제한). LLM 미가용/오류 시 즉시 중단(다음 주기 재시도).""" # 라우터 헬퍼(폴더 판정·기준 시각·정렬)를 재사용. 순환 임포트 방지로 함수 안에서 지연 임포트. from ..llm.provider import get_provider from ..models import TriageEvent from ..routers.mail import ( _ai_actionable, _email_in_folder, _email_sort_key, _ensure_triage_since, _is_new_mail, ) from ..services.mail_ai import analyze_email with Session(engine) as s: since = _ensure_triage_since(s) todo = [ e for e in s.exec(select(Email)).all() if not e.ai_json and _email_in_folder(e, "smart") and _is_new_mail(e, since) ] if not todo: return 0 todo.sort(key=_email_sort_key, reverse=True) provider = get_provider() analyzed, cards, err = 0, 0, None for e in todo[:limit]: try: ai = analyze_email(s, e, provider) analyzed += 1 if _ai_actionable(ai): cards += 1 except Exception as ex: # LLM 미가용/파싱 실패 — 이번 사이클 중단, 다음 주기에 재시도 err = (str(ex) or ex.__class__.__name__)[:200] break # '오늘 한 일' 로그: 실제로 읽었거나 에러가 났을 때만 기록(무활동은 남기지 않음). if analyzed or err: s.add(TriageEvent(analyzed=analyzed, cards=cards, error=err)) s.commit() return analyzed def register(scheduler) -> None: """phase-7 worker.main(APScheduler)에서 호출(있을 때). 없으면 수동 트리거로 동작.""" mins = get_settings().sync_interval_minutes scheduler.add_job( run_periodic_sync, "interval", minutes=mins, id="connector_sync", replace_existing=True )