feat(p11+): RAG worker cron — promotion_gate / awaiting_review / expire
All checks were successful
CD Pipeline / deploy (push) Successful in 2m53s

Operation Ollama-First v5.0 / Phase 11+ 收尾(ADR-032/033 落地)

services/learning_pipeline.py 新增 2 個 worker 函數:
- process_pending_episodes(batch=50) — 批次處理 pending → can_promote → promote/reject/await
  純規則引擎,不跑 LLM(Distiller 純 Hermes 規則)
- push_awaiting_reviews_to_telegram(batch=5) — 推 Stage 4 awaiting_review 到 Telegram
  TELEGRAM_ADMIN_CHAT_ID 未設則跳過(fail-safe)
  訊息含 episode_id + weight + quality + 600 字截斷文,附 promotion_review_keyboard 👍/👎

run_scheduler.py 加 3 個 cron + 對應 task wrapper:
- 每 5 分鐘  → run_promotion_gate_worker
- 每 30 分鐘 → run_awaiting_review_push
- 每 4 小時  → run_expire_stale_reviews(24h 無回應 → weight=0.5)

設計安全保證:
- RAG_ENABLED=false 時 learning_episodes 為空,3 個 worker 跑空 loop(無害)
- 所有 worker 例外完全吞掉,僅 log error,不影響其他排程
- promote 成功才回 stats['promoted']++,DB 失敗計 errors

完整 RAG 自主學習迴圈閉環:
  LLM 結果 → Distiller → learning_episodes (pending)
    ↓ 每 5 分鐘 worker
  PromotionGate 4 階段
    ↓ approved → 寫 ai_insights → RAG 可檢索
    ↓ awaiting_review → 每 30 分鐘推 Telegram
        ↓ 24h 無回應 → 每 4h expire → weight=0.5
        ↓ 👍 callback → promote → ai_insights
        ↓ 👎 callback → rejected_human → 永不晉升

仍待 Phase 12+ 完成:
- learning_episodes.embedding 寫入路徑(Stage 3 dedup 解鎖)
- RAG_ENABLED=true 灰度啟用條件(需 100+ episodes + ANTHROPIC_API_KEY)

regression: 70 unit tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-04 09:11:27 +08:00
parent 4e82acc0f5
commit c2124dce00
2 changed files with 219 additions and 2 deletions

View File

@@ -105,6 +105,17 @@ def _register_schedules():
schedule.every(12).hours.do(run_dedup_batch_task) schedule.every(12).hours.do(run_dedup_batch_task)
logger.info("📅 每 12 小時dedup_batch") logger.info("📅 每 12 小時dedup_batch")
# Operation Ollama-First v5.0 Phase 11+ — RAG 學習迴圈 workerPhase 12 收尾)
# 預設 RAG_ENABLED=false 時learning_episodes 不會有資料worker 跑空 loop無害
schedule.every(5).minutes.do(run_promotion_gate_worker)
logger.info("📅 每 5 分鐘promotion_gate_workerpending → 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_reviews24h 無回應降權 0.5")
schedule.every().day.at("03:00").do(run_db_backup_task) schedule.every().day.at("03:00").do(run_db_backup_task)
logger.info("📅 每日 03:00db_backup") logger.info("📅 每日 03:00db_backup")
@@ -161,8 +172,57 @@ def run_daily_token_report_task():
) )
except Exception as e: except Exception as e:
logger.error(f"[TokenReport] task failed: {e}", exc_info=True) logger.error(f"[TokenReport] task failed: {e}", exc_info=True)
# 不再嘗試 event_router避免循環依賴純 log 即可
# 統帥可從 scheduler logs 觀察失敗
# ─────────────────────────────────────────────────────────────────────────────
# Operation Ollama-First v5.0 Phase 11+ — RAG 學習迴圈 workerPhase 12 收尾)
# ─────────────────────────────────────────────────────────────────────────────
def run_promotion_gate_worker():
"""每 5 分鐘 — 批次處理 learning_episodes pending → can_promote → promote/reject/await。
依 ADR-032 PromotionGate 4 階段,不主動跑 LLMDistiller 純規則引擎)。
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 → expiredweight=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(): def run_cleanup_agent_context():

View File

@@ -26,6 +26,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import logging import logging
import os
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@@ -724,6 +725,160 @@ def hash_human_approver(username: str) -> str:
return hashlib.sha1(username.encode('utf-8')).hexdigest()[:8] return hashlib.sha1(username.encode('utf-8')).hexdigest()[:8]
# ─────────────────────────────────────────────────────────────────────────────
# Worker 函數(給 run_scheduler.py 排程用)— Phase 11+ 收尾
# ─────────────────────────────────────────────────────────────────────────────
# 預設批次大小:每次處理 N 筆 pending避免 worker 一次跑太久阻塞排程
PENDING_BATCH_SIZE = int(os.environ.get('PROMOTION_PENDING_BATCH_SIZE', '50'))
AWAITING_REVIEW_PUSH_BATCH = int(os.environ.get('AWAITING_REVIEW_PUSH_BATCH', '5'))
def process_pending_episodes(batch_size: int = PENDING_BATCH_SIZE) -> Dict[str, int]:
"""批次處理 learning_episodes pending → can_promote → promote/reject/await_review。
給 run_scheduler.py 每 5 分鐘跑一次。
依 ADR-032 PromotionGate 4 階段,每筆走完整檢查。
Returns:
{'pending_seen': N, 'promoted': X, 'rejected': Y, 'awaiting': Z, 'errors': E}
"""
stats = {'pending_seen': 0, 'promoted': 0, 'rejected': 0, 'awaiting': 0, 'errors': 0}
try:
from sqlalchemy import text as sa_text
from database.manager import get_session
except Exception as exc:
logger.warning('[PromotionWorker] DB import failed: %s', exc)
return stats
session = get_session()
try:
rows = session.execute(
sa_text("""
SELECT id FROM learning_episodes
WHERE promotion_status = 'pending'
ORDER BY created_at ASC
LIMIT :n
"""),
{'n': batch_size},
).fetchall()
episode_ids = [int(r[0]) for r in rows]
except Exception as exc:
logger.error('[PromotionWorker] SELECT pending failed: %s', exc)
session.close()
return stats
finally:
session.close()
stats['pending_seen'] = len(episode_ids)
if not episode_ids:
return stats
for ep_id in episode_ids:
try:
decision = promotion_gate.can_promote(ep_id)
if decision.reason == 'approved':
if promotion_gate.promote(ep_id):
stats['promoted'] += 1
else:
stats['errors'] += 1
elif decision.reason == 'awaiting_review':
if promotion_gate.mark_awaiting_review(ep_id):
stats['awaiting'] += 1
else:
stats['errors'] += 1
elif decision.reason.startswith('rejected_'):
if promotion_gate.reject(ep_id, decision.reason, decision.detail):
stats['rejected'] += 1
else:
stats['errors'] += 1
except Exception as exc:
logger.warning('[PromotionWorker] episode_id=%s failed: %s', ep_id, exc)
stats['errors'] += 1
logger.info(
'[PromotionWorker] batch done: pending=%d promoted=%d rejected=%d awaiting=%d errors=%d',
stats['pending_seen'], stats['promoted'], stats['rejected'],
stats['awaiting'], stats['errors']
)
return stats
def push_awaiting_reviews_to_telegram(batch: int = AWAITING_REVIEW_PUSH_BATCH,
chat_id: Optional[str] = None) -> int:
"""找 awaiting_review 但尚未推送的 episode → 推 Telegram 帶 👍/👎 keyboard。
給 run_scheduler.py 每 30 分鐘跑(與 expire_stale_reviews 配合 24h timeout
判斷「未推送」reviewed_at IS NULLmark_awaiting_review 時不設 reviewed_at
24h expired / human approve/reject 時才寫 reviewed_at
"""
pushed = 0
try:
from sqlalchemy import text as sa_text
from database.manager import get_session
except Exception as exc:
logger.warning('[AwaitingReviewPush] DB import failed: %s', exc)
return 0
# 取 chat_id預設 admin
if chat_id is None:
chat_id = os.environ.get('TELEGRAM_ADMIN_CHAT_ID', '').strip() or None
if not chat_id:
logger.info('[AwaitingReviewPush] TELEGRAM_ADMIN_CHAT_ID 未設,跳過推送')
return 0
session = get_session()
try:
rows = session.execute(
sa_text("""
SELECT id, distilled_text, weight, quality_score
FROM learning_episodes
WHERE promotion_status = 'awaiting_review'
AND reviewed_at IS NULL
ORDER BY created_at ASC
LIMIT :n
"""),
{'n': batch},
).fetchall()
except Exception as exc:
logger.error('[AwaitingReviewPush] SELECT failed: %s', exc)
session.close()
return 0
finally:
session.close()
if not rows:
return 0
# 推送
try:
from services.telegram_templates import promotion_review_keyboard, _send_telegram_raw
except Exception as exc:
logger.warning('[AwaitingReviewPush] template import failed: %s', exc)
return 0
for r in rows:
ep_id, text_, weight, quality = r[0], r[1], float(r[2] or 0), float(r[3] or 0)
msg = (
f"🧠 <b>RAG 學習晉升審核</b>\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"📋 episode #{ep_id} (weight={weight:.2f} quality={quality:.2f})\n\n"
f"{(text_ or '')[:600]}\n\n"
f"審核:通過 → 寫入 ai_insights 供 RAG 檢索;拒絕 → 永不晉升"
)
try:
_send_telegram_raw(msg, chat_id=chat_id, reply_markup=promotion_review_keyboard(ep_id))
pushed += 1
except Exception as exc:
logger.warning('[AwaitingReviewPush] episode_id=%s push failed: %s', ep_id, exc)
logger.info('[AwaitingReviewPush] pushed %d awaiting_review episodes to chat=%s',
pushed, chat_id)
return pushed
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 全域單例 # 全域單例
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -742,6 +897,8 @@ __all__ = [
'learning_pipeline', 'learning_pipeline',
'promotion_gate', 'promotion_gate',
'expire_stale_reviews', 'expire_stale_reviews',
'process_pending_episodes',
'push_awaiting_reviews_to_telegram',
'hash_human_approver', 'hash_human_approver',
'STAGE_1_AUTO_QUALITY', 'STAGE_1_AUTO_QUALITY',
'STAGE_3_DEDUP_THRESHOLD', 'STAGE_3_DEDUP_THRESHOLD',