# backend/app/worker/jobs/proactive.py — 능동 감지 잡 → ProactiveCard (phase-14) import json from sqlmodel import Session, select from ...automation.event_bus import bus from ...config import get_settings from ...models import CalEvent, FinanceCategory, ProactiveCard, ProactiveKind def _dumps(d: dict) -> str: return json.dumps(d, ensure_ascii=False) def _tomorrow() -> int: return get_settings().risk_today + 1 def run_proactive(session: Session) -> list[ProactiveCard]: """작업·일정·금융을 훑어 능동 카드를 만든다(임계값 기반 → 결정적). 같은 시드에서 항상 같은 카드(id 고정 upsert).""" cards: list[ProactiveCard] = [] cards += _detect_meeting_streak(session) cards += _detect_pricey_bill(session) out: list[ProactiveCard] = [] for c in cards: existing = session.get(ProactiveCard, c.id) if existing: if existing.status == "dismissed": continue out.append(existing) continue session.add(c) out.append(c) bus.publish("proactive.detected", {"id": c.id, "kind": c.kind}) session.commit() return out def _detect_meeting_streak(s: Session) -> list[ProactiveCard]: """내일 회의가 3건 이상이면 점심 보호 카드.""" day = _tomorrow() events = s.exec(select(CalEvent).where(CalEvent.day == day)).all() if len(events) >= 3: return [ ProactiveCard( id="pc-streak", kind=ProactiveKind.schedule.value, icon="cal", tone="violet", title="내일 미팅 3연속 — 점심 비워뒀어요", why="연속 일정 사이 12시 공백을 보호 블록으로 잡았어요", cta="그대로 둘게요", action_kind="focus_block", action_payload=_dumps( { "day": day, "start": "12:00", "end": "13:00", "type": "light", "title": "점심·재충전 보호", } ), detected_at="방금", ) ] return [] def _detect_pricey_bill(s: Session) -> list[ProactiveCard]: """예산 초과 카테고리가 있으면 사유 확인 카드.""" out: list[ProactiveCard] = [] for cat in s.exec(select(FinanceCategory).where(FinanceCategory.over.is_(True))).all(): out.append( ProactiveCard( id=f"pc-bill-{cat.id}", kind=ProactiveKind.finance.value, icon="wallet", tone="amber", title=f"{cat.name} 지출이 평소보다 많아요", why=f"이번 달 {cat.amt:,}원 · 예산을 넘었어요 — 사유 확인 권장", cta="자세히", action_kind="task", action_payload=_dumps({"title": f"{cat.name} 지출 사유 확인", "project_id": "me"}), detected_at="방금", ) ) return out