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.
130 lines
5.2 KiB
Python
130 lines
5.2 KiB
Python
# 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
|
|
)
|