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

# 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
)