#!/usr/bin/env python3 """ run_scheduler.py — momo-scheduler 容器入口點 排程任務清單(對齊 app.py init_scheduler + scheduler.py 全任務): 每 30 分鐘:auto_import、whitepage_check 每 1 小時:momo、edm、festival 每 4 小時:competitor_price_feeder、icaim_analysis 每 6 小時:quality_rescore 每 12 小時:dedup_batch 每 1 天 :db_backup(03:00)、cleanup_agent_context(03:30)、backup_monitor(04:00)、daily_report(09:00)、ai_smoke_summary(09:10)、pchome_match_backfill(10:30)、openclaw_meta_analysis(12:00, Phase 4 降頻)、daily_token_report(23:55) 每 1 週 :weekly_strategy(週一 06:00) 每 1 月 :monthly_report(每月1日 07:00) """ import asyncio import logging import threading import time import schedule # 匯入全部排程任務函式 from scheduler import ( run_momo_task, run_edm_task, run_festival_task, run_promo_event_task, run_auto_import_task, run_whitepage_check, run_competitor_price_feeder_task, run_pchome_match_backfill_task, run_icaim_analysis_task, run_weekly_strategy_task, run_db_backup_task, run_backup_monitor_task, run_openclaw_meta_analysis_task, run_dedup_batch_task, run_quality_rescore_task, run_daily_report_task, run_ai_smoke_daily_summary_task, run_monthly_report_task, ) logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', ) logger = logging.getLogger(__name__) def _register_schedules(): schedule.every(30).minutes.do(run_auto_import_task) logger.info("📅 每 30 分鐘:auto_import") schedule.every(30).minutes.do(run_whitepage_check) logger.info("📅 每 30 分鐘:whitepage_check") schedule.every(1).hours.do(run_momo_task) logger.info("📅 每 1 小時:momo_task") schedule.every(1).hours.do(run_edm_task) logger.info("📅 每 1 小時:edm_task") schedule.every(1).hours.do(run_festival_task) logger.info("📅 每 1 小時:festival_task") # 動態註冊促銷活動爬蟲(根據配置) from services.crawler_config_loader import get_enabled_crawlers enabled_crawlers = get_enabled_crawlers() promo_event_configs = { 'mothers_day_2026': {'lpn': '', 'page_type': 'mothers_day', 'name': '母親節超值限時購'}, 'valentine_520_2026': {'lpn': '', 'page_type': 'valentine_520', 'name': '520情人節限定購物'}, 'labor_day_2026': {'lpn': '', 'page_type': 'labor_day', 'name': '勞動節購物優惠'} } for crawler_key, config in enabled_crawlers.items(): if crawler_key in promo_event_configs: event_config = promo_event_configs[crawler_key] lpn_code = config.get('lpn_code', '') if lpn_code: schedule_hours = config.get('schedule_hours', 4) schedule.every(schedule_hours).hours.do( lambda lpn=lpn_code, pt=event_config['page_type'], an=event_config['name']: run_promo_event_task(lpn, pt, an) ) logger.info(f"📅 每 {schedule_hours} 小時:{event_config['name']} ({event_config['page_type']})") else: logger.warning(f"⚠️ {event_config['name']} 未配置 LPN 代碼,跳過排程") schedule.every(4).hours.do(run_competitor_price_feeder_task) logger.info("📅 每 4 小時:competitor_price_feeder") schedule.every(4).hours.do(run_icaim_analysis_task) logger.info("📅 每 4 小時:icaim_analysis") # Operation Ollama-First v5.0 Phase 4:Meta 自審降頻 6h → 每日 12:00(月省 ~1.875M Gemini tokens) # icaim_analysis 內原本 line 2233/2253 的額外觸發已同步移除(避免重複呼叫) schedule.every().day.at("12:00").do(run_openclaw_meta_analysis_task) logger.info("📅 每日 12:00:openclaw_meta_analysis(Phase 4 降頻:原 6h)") schedule.every(6).hours.do(run_quality_rescore_task) logger.info("📅 每 6 小時:quality_rescore") schedule.every(12).hours.do(run_dedup_batch_task) logger.info("📅 每 12 小時:dedup_batch") # Operation Ollama-First v5.0 Phase 11+ — RAG 學習迴圈 worker(Phase 12 收尾) # 預設 RAG_ENABLED=false 時,learning_episodes 不會有資料,worker 跑空 loop(無害) schedule.every(5).minutes.do(run_promotion_gate_worker) logger.info("📅 每 5 分鐘:promotion_gate_worker(pending → promote/reject/await)") schedule.every(30).minutes.do(run_awaiting_review_push) logger.info("📅 每 30 分鐘:awaiting_review_push(推 Telegram 等 👍/👎)") schedule.every(4).hours.do(run_expire_stale_reviews) logger.info("📅 每 4 小時:expire_stale_reviews(24h 無回應降權 0.5)") schedule.every().day.at("03:00").do(run_db_backup_task) logger.info("📅 每日 03:00:db_backup") schedule.every().day.at("03:30").do(run_cleanup_agent_context) logger.info("📅 每日 03:30:cleanup_agent_context") schedule.every().day.at("04:00").do(run_backup_monitor_task) logger.info("📅 每日 04:00:backup_monitor") schedule.every().monday.at("06:00").do(run_weekly_strategy_task) logger.info("📅 每週一 06:00:weekly_strategy") schedule.every().day.at("09:00").do(run_daily_report_task) logger.info("📅 每日 09:00:daily_report") schedule.every().day.at("09:10").do(run_ai_smoke_daily_summary_task) logger.info("📅 每日 09:10:ai_smoke_daily_summary") schedule.every().day.at("10:30").do(run_pchome_match_backfill_task) logger.info("📅 每日 10:30:pchome_match_backfill") # Operation Ollama-First v5.0 — Phase 1 收尾:每日 23:55 LLM Token 日報 schedule.every().day.at("23:55").do(run_daily_token_report_task) logger.info("📅 每日 23:55:daily_token_report") # 每月1日 07:00 月報(schedule 不支援 every().month,用每日 07:00 + 日期判斷) def _monthly_report_gate(): from datetime import datetime as _dt if _dt.now().day == 1: run_monthly_report_task() schedule.every().day.at("07:00").do(_monthly_report_gate) logger.info("📅 每月1日 07:00:monthly_report") def run_daily_token_report_task(): """每日 23:55 — Operation Ollama-First v5.0 Phase 1 收尾:LLM Token 日報。 任務: 1. 查 ai_calls 過去 24h 統計(總覽 / 供應商 / TOP caller / 成本 / 趨勢 / 告警) 2. 推 Telegram + 寫 ai_insights(type='daily_token_report') 紀律: - 失敗安全:DB 查不到資料 → 推「⚠️ 報表生成失敗」訊息但不爆 scheduler - 不影響其他排程:例外完全吞掉,僅 log error """ try: from services.token_report_service import send_daily_report result = send_daily_report() logger.info( "[TokenReport] sent=%s failed=%s chars=%s ok=%s", result.get('sent'), result.get('failed'), result.get('chars'), result.get('ok'), ) except Exception as e: logger.error(f"[TokenReport] task failed: {e}", exc_info=True) # ───────────────────────────────────────────────────────────────────────────── # Operation Ollama-First v5.0 Phase 11+ — RAG 學習迴圈 worker(Phase 12 收尾) # ───────────────────────────────────────────────────────────────────────────── def run_promotion_gate_worker(): """每 5 分鐘 — 批次處理 learning_episodes pending → can_promote → promote/reject/await。 依 ADR-032 PromotionGate 4 階段,不主動跑 LLM(Distiller 純規則引擎)。 RAG_ENABLED=false 時 learning_episodes 為空,worker 跑空 loop(無害)。 """ try: from services.learning_pipeline import process_pending_episodes stats = process_pending_episodes() if stats.get('pending_seen', 0) > 0: logger.info( "[PromotionWorker] pending=%d promoted=%d rejected=%d awaiting=%d errors=%d", stats['pending_seen'], stats['promoted'], stats['rejected'], stats['awaiting'], stats['errors'], ) except Exception as e: logger.error(f"[PromotionWorker] task failed: {e}", exc_info=True) def run_awaiting_review_push(): """每 30 分鐘 — 推 awaiting_review episode 到 Telegram 等 👍/👎。 限制:TELEGRAM_ADMIN_CHAT_ID 未設則跳過(fail-safe)。 """ try: from services.learning_pipeline import push_awaiting_reviews_to_telegram pushed = push_awaiting_reviews_to_telegram() if pushed > 0: logger.info("[AwaitingReviewPush] pushed=%d episodes", pushed) except Exception as e: logger.error(f"[AwaitingReviewPush] task failed: {e}", exc_info=True) def run_expire_stale_reviews(): """每 4 小時 — 24h 無回應 awaiting_review → expired(weight=0.5)。 依 ADR-033 護欄 #1 Stage 4 規則。 """ try: from services.learning_pipeline import expire_stale_reviews n = expire_stale_reviews() if n > 0: logger.info("[ExpireStale] expired %d awaiting_review episodes (24h timeout)", n) except Exception as e: logger.error(f"[ExpireStale] task failed: {e}", exc_info=True) def run_cleanup_agent_context(): """每日 03:30 — 清理 agent_context 表中已過期的 TTL 記錄(migration 018 定義)""" from database.manager import get_session from sqlalchemy import text session = get_session() try: session.execute(text("SELECT cleanup_expired_agent_context()")) session.commit() logger.info("[Cleanup] agent_context TTL 清理完成") except Exception as e: logger.error(f"[Cleanup] agent_context 清理失敗: {e}") try: from services.event_router import notify_failure notify_failure( task_name="run_cleanup_agent_context", error=e, source="Scheduler.Cleanup", event_type="agent_context_cleanup_failure", priority="P2", title="agent_context TTL 清理失敗", dedup_ttl_sec=3600, ) except Exception as _router_e: logger.error(f"[Cleanup] event_router 失敗: {_router_e}") finally: session.close() def _run_elephant_alpha_engine(): """Daemon thread: ElephantAlpha 自主監控引擎(獨立 asyncio loop)""" loop = None try: from services.elephant_alpha_autonomous_engine import autonomous_engine loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) logger.info("🐘 [ElephantAlpha] Autonomous engine thread started") loop.run_until_complete(autonomous_engine.start_autonomous_monitoring()) except Exception as e: logger.error(f"🐘 [ElephantAlpha] Engine crashed: {e}") finally: if loop is not None: loop.close() if __name__ == "__main__": logger.info("🚀 momo-scheduler 啟動中...") _register_schedules() logger.info("✅ 全部排程任務已註冊") _ea_thread = threading.Thread( target=_run_elephant_alpha_engine, daemon=True, name="elephant-alpha-engine", ) _ea_thread.start() logger.info("🐘 [ElephantAlpha] Autonomous engine thread launched") logger.info("⏰ 排程主迴圈啟動,等待任務觸發...") _ea_watchdog_counter = 0 # 每 60 秒(60 次 sleep(1))做一次存活檢查 while True: try: schedule.run_pending() time.sleep(1) # 每 60 秒檢查 ElephantAlpha 執行緒是否還活著 _ea_watchdog_counter += 1 if _ea_watchdog_counter >= 60: _ea_watchdog_counter = 0 if not _ea_thread.is_alive(): logger.error("[ElephantAlpha] 監控執行緒已死亡,嘗試重啟") try: from services.event_router import dispatch_sync as _dispatch_sync _dispatch_sync({ "source": "Scheduler.ElephantAlpha", "event_type": "thread_crashed", "severity": "alert", "title": "ElephantAlpha 執行緒死亡", "status": "自動重啟中", "impact": "P2 - 自主監控引擎暫停", "summary": "ElephantAlphaEngine daemon thread 意外終止,排程主迴圈已偵測並觸發重啟", }) except Exception as _alert_err: logger.error(f"[ElephantAlpha] 無法發送告警: {_alert_err}") _ea_thread = threading.Thread( target=_run_elephant_alpha_engine, daemon=True, name="ElephantAlphaEngine", ) _ea_thread.start() logger.info("[ElephantAlpha] 執行緒已重啟") except KeyboardInterrupt: logger.info("⛔ Scheduler stopped.") break except Exception as e: logger.error(f"Scheduler error: {e}") time.sleep(5)