diff --git a/config.py b/config.py
index 3f926c6..94b83d4 100644
--- a/config.py
+++ b/config.py
@@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
-SYSTEM_VERSION = "V10.424"
+SYSTEM_VERSION = "V10.425"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示
diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md
index 1fb32dd..c023d44 100644
--- a/docs/AI_INTELLIGENCE_MODULE_SOT.md
+++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md
@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-24 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉
-> **適用版本**: V10.424
+> **適用版本**: V10.425
---
@@ -31,6 +31,7 @@
- Gemini 不可被任何狀態面板或 router 推薦為主提供者:`AIProviderService._get_recommended_provider()` 不得回傳 `gemini`,只能顯示為 fallback 狀態;`llm_model_router` 的 `ea_engine` 若收到 `gemini-*` default 必須改回 `hermes3:latest`,需要深推理時才升本地 `deepseek-r1:14b`。
- ElephantAlpha prompt / agent registry 不得再把 OpenClaw 描述為 Gemini 主模型;OpenClaw 是 `qwen2.5-coder:7b` / `qwen3:14b` Ollama-first 策略師,Gemini 僅能在 guard 顯式解鎖後作 emergency fallback。
- 111 `192.168.0.111` 只是最後一道 Mac fallback,不承接 7B+、vision、long-context 模型長駐;`OllamaService.generate()` 落到 111 時會將 `qwen3`、`deepseek-r1`、`hermes3`、`qwen2.5*`、`gemma3`、`llava`、`minicpm-v` 與 7B+ 模型依 `OLLAMA_111_MODEL_DOWNGRADE_PATTERNS` 降級到 `OLLAMA_111_MODEL_FALLBACK=llama3.2:latest`,並以 `OLLAMA_111_KEEP_ALIVE=5m`、`OLLAMA_111_MAX_TIMEOUT=20`、`OLLAMA_111_NUM_CTX=4096`、`OLLAMA_111_NUM_PREDICT=512` 封頂。OpenClaw 報告型路徑的業務 keep-alive 預設 `5m`;Code Review 以 `CODE_REVIEW_ALLOW_111_FALLBACK=false`、Hermes 以 `HERMES_ALLOW_111_FALLBACK=false` 預設跳過 111,避免 16GB RAM 主機與 GCP-B 被長駐 runner、長輸出與 24h keep-alive 壓到高 load。
+- Scheduler 每 15 分鐘執行 `run_ollama_111_usage_guard_check()`,只讀 `ai_calls` 統計最近視窗的 GCP-A / GCP-B / 111 呼叫量;預設 60 分鐘內 Ollama 呼叫至少 20 次、111 至少 3 次且占比 >= 5% 才推 Telegram。這是觀測護欄,不改路由、不寫 DB、不自動重啟服務。
- 111 的 LAN 入口必須經 `scripts/ops/ollama111_allow_proxy.py` allowlist proxy:真實 Ollama 綁 `127.0.0.1:11434`,proxy 綁 `192.168.0.111:11434`,預設只允許 111 本機與 188 生產宿主;110 / 121 / 其他 LAN client 不能直接打 111,避免跨專案 CI 或 VM 繞過 momo-pro router 載入 7B+ runner。111 上以 `scripts/ops/install_ollama111_allow_proxy.sh` 安裝 user LaunchAgent,安裝器會把 proxy script 複製到 `~/.local/share/momo-pro-system/ollama111_allow_proxy.py`,讓 LaunchAgent 不依賴 iCloud repo 掛載路徑,並讓 proxy 與 `OLLAMA_HOST=127.0.0.1:11434` 在登入/重啟後自動恢復。
- ElephantAlpha 的 `price_drop_alert` / `market_opportunity` Telegram HITL 告警必須把同款證據獨立呈現,至少包含 `match_type`、`price_basis`、`alert_tier` 與 `match_score`;沒有高信心同款與總價可比證據時,不得把 PChome/MOMO 價差寫成可直接跟價建議。
diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md
index 80c3171..8e5014f 100644
--- a/docs/memory/history_logs.md
+++ b/docs/memory/history_logs.md
@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-05-24:PChome 近門檻身份回收第二輪
+- **V10.425 111 fallback 使用率護欄**: Scheduler 每 15 分鐘只讀 `ai_calls` 檢查 111 Ollama fallback 使用率,預設 60 分鐘內 Ollama 呼叫 >=20、111 呼叫 >=3 且占比 >=5% 才推 Telegram,並列出 111 caller Top 5;此護欄只觀測與告警,不改路由、不寫 DB、不重啟服務,讓 111 被異常承接高負載時可即早發現。
- **V10.424 111 proxy LaunchAgent 安裝路徑穩定化**: `install_ollama111_allow_proxy.sh` 會把 proxy script 複製到 `~/.local/share/momo-pro-system/ollama111_allow_proxy.py` 後再寫入 LaunchAgent,避免 111 重啟或 iCloud repo 路徑未掛載時代理失效;同時清空舊 stderr log,讓安裝後狀態更容易判讀。
- **V10.423 12 Agent 決策信封**: `triaged_alert()` 支援 `decision_envelope` 結構化區塊,讓 Hermes / NemoTron / OpenClaw / ElephantAlpha 與後續 12 角色決策統一輸出 `severity`、`evidence`、`recommended_action`、`expected_impact`、`confidence`、`guardrails` 與 `trace`;缺證據時必須明確標記資料品質與 HITL 邊界,避免再出現空泛效益預測或不可追溯告警。
- **V10.422 111 proxy LaunchAgent 持久化**: 新增 `scripts/ops/install_ollama111_allow_proxy.sh`,在 111 以 user LaunchAgent 安裝 `com.momo.ollama111-allow-proxy`,啟動時設定 `OLLAMA_HOST=127.0.0.1:11434`、重啟 Ollama、載入 allowlist proxy,避免重開機或重新登入後 111 又回到 LAN 全開狀態。
diff --git a/run_scheduler.py b/run_scheduler.py
index 8915102..7e861e0 100644
--- a/run_scheduler.py
+++ b/run_scheduler.py
@@ -53,6 +53,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
_AI_CALLS_ERROR_SPIKE_LAST_PUSH_TS = 0.0
+_OLLAMA_111_USAGE_LAST_PUSH_TS = 0.0
def _env_flag(name: str, default: bool = False) -> bool:
@@ -204,6 +205,10 @@ def _register_schedules():
schedule.every(30).minutes.do(run_ai_calls_error_spike_check)
logger.info("📅 每 30 分鐘:ai_calls_error_spike_check(錯誤率 ≥ 30% 推 Telegram)")
+ # Phase 57: 111 Ollama 使用率護欄,避免 final fallback 默默承接高負載
+ schedule.every(15).minutes.do(run_ollama_111_usage_guard_check)
+ logger.info("📅 每 15 分鐘:ollama_111_usage_guard_check(111 fallback 使用率告警)")
+
# Phase 44: 觀測台每日 09:30 健康摘要推送
schedule.every().day.at("09:30").do(run_observability_daily_summary)
logger.info("📅 每日 09:30:observability_daily_summary(早晨報三主機/AI/Cost/PPT)")
@@ -724,6 +729,126 @@ def run_ai_calls_error_spike_check():
)
+def run_ollama_111_usage_guard_check():
+ """Phase 57 — final fallback 111 使用率告警。
+
+ 111 是最後防線;這個 guard 只觀測 ai_calls,不改路由。
+ 預設條件:最近 60 分鐘 Ollama 呼叫 >= 20、111 呼叫 >= 3、111 占比 >= 5%。
+ """
+ if not _env_flag("OLLAMA_111_USAGE_ALERT_ENABLED", True):
+ return
+
+ try:
+ from sqlalchemy import text as _sa
+ from database.manager import DatabaseManager
+
+ window_minutes = int(os.getenv("OLLAMA_111_USAGE_ALERT_WINDOW_MINUTES", "60"))
+ threshold_pct = float(os.getenv("OLLAMA_111_USAGE_ALERT_PCT", "5"))
+ min_total = int(os.getenv("OLLAMA_111_USAGE_ALERT_MIN_TOTAL", "20"))
+ min_111 = int(os.getenv("OLLAMA_111_USAGE_ALERT_MIN_111", "3"))
+ dedup_sec = int(os.getenv("OLLAMA_111_USAGE_ALERT_DEDUP_SEC", "3600"))
+
+ session = DatabaseManager().get_session()
+ try:
+ row = session.execute(
+ _sa("""
+ SELECT
+ COUNT(*) FILTER (
+ WHERE provider IN ('gcp_ollama','ollama_secondary','ollama_111')
+ ) AS total_ollama,
+ COUNT(*) FILTER (WHERE provider = 'gcp_ollama') AS gcp_a,
+ COUNT(*) FILTER (WHERE provider = 'ollama_secondary') AS gcp_b,
+ COUNT(*) FILTER (WHERE provider = 'ollama_111') AS host_111
+ FROM ai_calls
+ WHERE called_at >= NOW() - (:window_minutes || ' minutes')::interval
+ """),
+ {"window_minutes": window_minutes},
+ ).fetchone()
+
+ total_ollama = int(row[0] or 0)
+ gcp_a = int(row[1] or 0)
+ gcp_b = int(row[2] or 0)
+ host_111 = int(row[3] or 0)
+
+ if total_ollama < min_total or host_111 < min_111:
+ return
+
+ rate_pct = (host_111 / total_ollama * 100.0) if total_ollama else 0.0
+ if rate_pct < threshold_pct:
+ return
+
+ top_callers = session.execute(
+ _sa("""
+ SELECT caller,
+ COALESCE(model, '') AS model,
+ COUNT(*) AS calls,
+ COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens,
+ COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS errors
+ FROM ai_calls
+ WHERE called_at >= NOW() - (:window_minutes || ' minutes')::interval
+ AND provider = 'ollama_111'
+ GROUP BY caller, model
+ ORDER BY calls DESC, tokens DESC
+ LIMIT 5
+ """),
+ {"window_minutes": window_minutes},
+ ).fetchall()
+ finally:
+ session.close()
+
+ global _OLLAMA_111_USAGE_LAST_PUSH_TS
+ now_ts = time.time()
+ if now_ts - _OLLAMA_111_USAGE_LAST_PUSH_TS < dedup_sec:
+ logger.info("[Ollama111Guard] skip duplicate alert within %ss window", dedup_sec)
+ return
+
+ from services.telegram_templates import send_telegram_with_result
+
+ lines = [
+ "⚠️ 111 Ollama 使用率偏高",
+ "",
+ f"過去 {window_minutes} 分鐘 Ollama 呼叫:{total_ollama} 次",
+ f"111 fallback:{host_111} 次({rate_pct:.1f}%)",
+ f"GCP-A:{gcp_a} 次 · GCP-B:{gcp_b} 次",
+ "",
+ ]
+ if top_callers:
+ lines.append("111 caller Top 5:")
+ for caller, model, calls, tokens, errors in top_callers:
+ model_part = f" / {model}" if model else ""
+ err_part = f" · err {errors}" if int(errors or 0) else ""
+ lines.append(
+ f"• {caller}{model_part}:{calls} 次 · {int(tokens or 0):,} tokens{err_part}"
+ )
+ lines.append("")
+ lines.extend([
+ "建議先看 GCP-A/GCP-B health probe 與近期 unhealthy mark;",
+ "若 GCP 正常,檢查是否有 fallback flag 或重任務意外打到 111。",
+ ])
+
+ reply_markup = {
+ "inline_keyboard": [
+ [{"text": "🏥 主機健康", "callback_data": "cmd:obs_health"},
+ {"text": "📊 AI 呼叫", "callback_data": "cmd:obs_ai_calls"}],
+ ],
+ }
+ send_telegram_with_result("\n".join(lines), reply_markup=reply_markup, parse_mode="HTML")
+ _OLLAMA_111_USAGE_LAST_PUSH_TS = now_ts
+ logger.warning(
+ "[Ollama111Guard] alert pushed: total=%s gcp_a=%s gcp_b=%s host_111=%s rate=%.1f%%",
+ total_ollama, gcp_a, gcp_b, host_111, rate_pct,
+ )
+ except Exception as e:
+ logger.error(f"[Ollama111Guard] failed: {e}", exc_info=True)
+ _notify_scheduler_failure(
+ "run_ollama_111_usage_guard_check",
+ e,
+ source="Scheduler.Ollama111Guard",
+ event_type="ollama_111_usage_guard_failure",
+ title="111 Ollama 使用率護欄失敗",
+ )
+
+
def run_observability_daily_summary():
"""Phase 44 — 每日 09:30 推送觀測台健康摘要(早晨報)。
diff --git a/scripts/ops/install_ollama111_allow_proxy.sh b/scripts/ops/install_ollama111_allow_proxy.sh
index f5192fe..2726638 100755
--- a/scripts/ops/install_ollama111_allow_proxy.sh
+++ b/scripts/ops/install_ollama111_allow_proxy.sh
@@ -16,7 +16,7 @@ INSTALL_SCRIPT_PATH="${INSTALL_DIR}/ollama111_allow_proxy.py"
PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}"
OLLAMA_APP="${OLLAMA_APP:-/Applications/Ollama.app}"
OLLAMA_HOST_VALUE="${OLLAMA_HOST_VALUE:-127.0.0.1:11434}"
-ALLOWED_CIDRS="${OLLAMA111_PROXY_ALLOWED_CIDRS:-127.0.0.1/32,192.168.0.80/32,192.168.0.111/32,192.168.0.188/32}"
+ALLOWED_CIDRS="${OLLAMA111_PROXY_ALLOWED_CIDRS:-127.0.0.1/32,192.168.0.111/32,192.168.0.188/32}"
GUI_DOMAIN="gui/$(id -u)"
if [[ ! -f "${PROJECT_DIR}/scripts/ops/ollama111_allow_proxy.py" ]]; then
diff --git a/scripts/ops/ollama111_allow_proxy.py b/scripts/ops/ollama111_allow_proxy.py
index 62d6b1c..9fa4f32 100755
--- a/scripts/ops/ollama111_allow_proxy.py
+++ b/scripts/ops/ollama111_allow_proxy.py
@@ -15,7 +15,7 @@ import ipaddress
import logging
import os
import signal
-from typing import Iterable
+import sys
LISTEN_HOST = os.getenv("OLLAMA111_PROXY_LISTEN_HOST", "192.168.0.111")
@@ -26,7 +26,7 @@ ALLOWED_CIDRS = tuple(
item.strip()
for item in os.getenv(
"OLLAMA111_PROXY_ALLOWED_CIDRS",
- "127.0.0.1/32,192.168.0.80/32,192.168.0.111/32,192.168.0.188/32",
+ "127.0.0.1/32,192.168.0.111/32,192.168.0.188/32",
).split(",")
if item.strip()
)
@@ -93,6 +93,7 @@ async def _main() -> None:
logging.basicConfig(
level=os.getenv("OLLAMA111_PROXY_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(message)s",
+ stream=sys.stdout,
)
server = await asyncio.start_server(_handle_client, LISTEN_HOST, LISTEN_PORT)
sockets = ", ".join(str(sock.getsockname()) for sock in (server.sockets or []))
diff --git a/tests/test_ollama111_proxy_contract.py b/tests/test_ollama111_proxy_contract.py
new file mode 100644
index 0000000..effc970
--- /dev/null
+++ b/tests/test_ollama111_proxy_contract.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_ollama111_proxy_default_allowlist_stays_production_only():
+ proxy_source = (ROOT / "scripts/ops/ollama111_allow_proxy.py").read_text()
+ installer_source = (ROOT / "scripts/ops/install_ollama111_allow_proxy.sh").read_text()
+
+ assert "192.168.0.188/32" in proxy_source
+ assert "192.168.0.188/32" in installer_source
+ assert "192.168.0.111/32" in proxy_source
+ assert "192.168.0.111/32" in installer_source
+ assert "192.168.0.80/32" not in proxy_source
+ assert "192.168.0.80/32" not in installer_source
+
+
+def test_ollama111_proxy_logs_to_stdout_for_launchagent_collection():
+ proxy_source = (ROOT / "scripts/ops/ollama111_allow_proxy.py").read_text()
+
+ assert "import sys" in proxy_source
+ assert "stream=sys.stdout" in proxy_source
diff --git a/tests/test_run_scheduler_embed_consistency.py b/tests/test_run_scheduler_embed_consistency.py
index 0d1e9e1..9e67216 100644
--- a/tests/test_run_scheduler_embed_consistency.py
+++ b/tests/test_run_scheduler_embed_consistency.py
@@ -146,6 +146,7 @@ def test_v2_cron_blind_spot_list_has_failure_notifications(monkeypatch):
"run_cost_throttle_reset_if_new_month",
"run_ppt_vision_audit",
"run_embed_consistency_check",
+ "run_ollama_111_usage_guard_check",
]:
source = inspect.getsource(getattr(run_scheduler, fn_name))
assert "_notify_scheduler_failure(" in source
@@ -161,6 +162,20 @@ def test_roi_ai_smoke_and_daily_report_schedules_stay_staggered():
assert 'schedule.every().day.at("09:05").do(run_roi_monthly_report_if_new_month)' in source
assert 'schedule.every().day.at("09:10").do(run_ai_smoke_daily_summary_task)' in source
assert "schedule.every(6).hours.do(run_action_plan_hygiene_task)" in source
+ assert "schedule.every(15).minutes.do(run_ollama_111_usage_guard_check)" in source
+
+
+def test_ollama_111_usage_guard_stays_observational(monkeypatch):
+ run_scheduler = _load_run_scheduler(monkeypatch)
+ source = inspect.getsource(run_scheduler.run_ollama_111_usage_guard_check)
+
+ assert "OLLAMA_111_USAGE_ALERT_ENABLED" in source
+ assert "provider = 'ollama_111'" in source
+ assert "send_telegram_with_result" in source
+ assert "_notify_scheduler_failure(" in source
+ assert "只觀測 ai_calls,不改路由" in source
+ assert "UPDATE" not in source
+ assert "DELETE" not in source
def test_legacy_edm_and_seasonal_promo_schedules_are_opt_in(monkeypatch):