diff --git a/config.py b/config.py index f9183e7..85ff431 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.369" +SYSTEM_VERSION = "V10.370" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 3cf4318..832cbcd 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-21:瀏覽器測試守門與 PChome 熱路徑優化 +- **V10.370 Gemini runtime sentinel**: AI automation smoke 新增 `Gemini 出站費用 sentinel`,每天檢查近 24h `ai_calls.provider='gemini'` 的 calls/tokens/cost/top callers;若 `GEMINI_API_HARD_DISABLED=true` 仍有 Gemini 記錄,smoke 直接升為 critical。scheduler 09:10 摘要推播前會先執行一次只讀 smoke,讓 Gemini 費用異常不再依賴人工打開 `/ai_automation_smoke` 才被發現。 - **V10.369 Gemini 防復發測試與極端價差同款放行**: 新增靜態測試禁止 production code 在 `services.gemini_guard` / `config.py` 之外直接讀 `GEMINI_API_KEY`,並要求所有 Gemini SDK/REST 出站點必須經 `get_gemini_api_key()`;比價 matcher 針對「同品牌 + 明確 identity anchor + 規格完全一致」但競品價格極端偏低的原生露/眉筆案例抑制價格懲罰,避免真同款因價格差被錯降級,同時補回既有 hard-veto 安全斷言。 - **V10.368 比價搜尋錨點強化**: marketplace matcher 補 LUDEYA 蜂王玫瑰外泌微臻霜、雅詩蘭黛微分子肌底原生露、Za / PERIPERA 眉筆眉彩等低信心邊界品牌的 identity anchor,並把「兩入組 / 任選色號 / 多色可選 / 櫻花輕盈版」歸為搜尋噪音,讓 MOMO → PChome 搜尋詞更聚焦於同款身份與規格,不被包裝組合或色號選項帶偏。 - **V10.367 Gemini hard egress kill switch**: 新增 `GEMINI_API_HARD_DISABLED=true` 預設硬封鎖,中央 `services.gemini_guard` 會在 hard switch 未解鎖時拒絕 `GEMINI_API_KEY`,即使 `GEMINI_FALLBACK_ENABLED=true` 也不會初始化 SDK 或 REST 出站。Code Review/OpenClaw/MCP/通用 AI fallback 保留 emergency path,但必須同時設 `GEMINI_API_HARD_DISABLED=false` 與 `GEMINI_FALLBACK_ENABLED=true`,必要時再用 `GEMINI_ALLOWED_CONTEXTS` 限定 caller。 diff --git a/scheduler.py b/scheduler.py index 7daec97..c64e6b0 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2857,16 +2857,22 @@ def run_ppt_auto_generation_task(schedule_kind=None): def run_ai_smoke_daily_summary_task(): - """每日 AI 自動化 Smoke trend 摘要推播(只讀 history,不重新執行 smoke)。""" + """每日 AI 自動化 Smoke 摘要推播;先執行一次只讀 smoke 以刷新 sentinel。""" try: - from services.ai_automation_smoke_service import send_smoke_daily_summary + from services.ai_automation_smoke_service import collect_ai_automation_smoke, send_smoke_daily_summary + smoke_result = collect_ai_automation_smoke(record_history=True, history_limit=20) result = send_smoke_daily_summary() logging.info( - "[Scheduler] [AISmokeSummary] 完成 | status=%s sent=%s failed=%s", + "[Scheduler] [AISmokeSummary] 完成 | smoke=%s send_status=%s sent=%s failed=%s", + smoke_result.get("status"), result.get("status"), result.get("telegram", {}).get("sent", 0), result.get("telegram", {}).get("failed", 0), ) + result["smoke"] = { + "status": smoke_result.get("status"), + "summary": smoke_result.get("summary"), + } _save_stats('ai_smoke_daily_summary', result) except Exception as e: import traceback as _tb diff --git a/services/ai_automation_smoke_service.py b/services/ai_automation_smoke_service.py index 44a51e9..c205adb 100644 --- a/services/ai_automation_smoke_service.py +++ b/services/ai_automation_smoke_service.py @@ -9,7 +9,7 @@ from __future__ import annotations import os import json import threading -from datetime import datetime +from datetime import datetime, timedelta, timezone from html import escape from typing import Any, Dict, List @@ -187,6 +187,19 @@ def build_smoke_daily_summary_message(history_limit: int = 200) -> str: else: lines += ["", "🗓️ 尚無 smoke history;請先開啟 /ai_automation_smoke 執行快檢。"] + problem_checks = [ + item for item in latest.get("checks", []) + if item.get("status") in {"warning", "critical"} + ] + if problem_checks: + lines += ["", "🚩 最近異常檢查"] + for item in problem_checks[:5]: + lines.append( + f"{escape(str(item.get('status', '')).upper())}|" + f"{escape(str(item.get('name', 'unknown')))}:" + f"{escape(str(item.get('summary', '')))}" + ) + lines += [ "━━━━━━━━━━━━━━━━━━━━", "入口:/ai_automation_smoke", @@ -234,6 +247,110 @@ def _event_router_check() -> Dict[str, Any]: return _check("EventRouter 通知鏈", "critical", f"EventRouter smoke 失敗:{exc}") +def _row_mapping(row: Any) -> Dict[str, Any]: + if row is None: + return {} + if isinstance(row, dict): + return row + mapping = getattr(row, "_mapping", None) + if mapping is not None: + return dict(mapping) + return {} + + +def _gemini_egress_check(window_hours: int = 24) -> Dict[str, Any]: + """Read-only runtime sentinel for unexpected Gemini spend.""" + session = None + try: + from services.gemini_guard import gemini_disabled_message, is_gemini_hard_disabled + + session = get_session() + since_at = datetime.now(timezone.utc) - timedelta(hours=int(window_hours)) + summary_row = session.execute( + text(""" + SELECT + COUNT(*) AS calls, + COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0) AS tokens, + COALESCE(SUM(COALESCE(cost_usd, 0)), 0) AS cost_usd, + MAX(called_at) AS last_called + FROM ai_calls + WHERE lower(provider) = 'gemini' + AND called_at >= :since_at + """), + {"since_at": since_at}, + ).fetchone() + summary = _row_mapping(summary_row) + calls = int(summary.get("calls") or 0) + tokens = int(summary.get("tokens") or 0) + cost_usd = float(summary.get("cost_usd") or 0.0) + hard_disabled = is_gemini_hard_disabled() + + top_rows = [] + if calls: + top_rows = [ + _row_mapping(row) for row in session.execute( + text(""" + SELECT + caller, + model, + COUNT(*) AS calls, + COALESCE(SUM(COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)), 0) AS tokens, + COALESCE(SUM(COALESCE(cost_usd, 0)), 0) AS cost_usd, + MAX(called_at) AS last_called + FROM ai_calls + WHERE lower(provider) = 'gemini' + AND called_at >= :since_at + GROUP BY caller, model + ORDER BY calls DESC, last_called DESC + LIMIT 5 + """), + {"since_at": since_at}, + ).fetchall() + ] + + details = { + "window_hours": int(window_hours), + "since_at": since_at.isoformat(timespec="seconds"), + "hard_disabled": hard_disabled, + "calls": calls, + "tokens": tokens, + "cost_usd": round(cost_usd, 6), + "last_called": str(summary.get("last_called") or ""), + "top_callers": top_rows, + "guard_reason": gemini_disabled_message("ai_smoke_gemini_egress"), + } + + if calls == 0: + return _check( + "Gemini 出站費用 sentinel", + "ok", + f"最近 {window_hours}h Gemini 出站 0 次,費用 $0", + details, + ) + if hard_disabled: + return _check( + "Gemini 出站費用 sentinel", + "critical", + f"Hard-disabled 狀態仍偵測到 Gemini {calls} 次 / ${cost_usd:.6f}", + details, + ) + return _check( + "Gemini 出站費用 sentinel", + "warning", + f"Gemini emergency fallback 近 {window_hours}h 使用 {calls} 次 / ${cost_usd:.6f}", + details, + ) + except Exception as exc: + return _check( + "Gemini 出站費用 sentinel", + "warning", + f"Gemini 出站紀錄無法讀取:{exc}", + ) + finally: + if session is not None: + session.close() + + def _autoheal_check() -> Dict[str, Any]: try: import services.auto_heal_service as autoheal @@ -360,6 +477,7 @@ def _elephant_hitl_check() -> Dict[str, Any]: def collect_ai_automation_smoke(*, record_history: bool = True, history_limit: int = 20) -> Dict[str, Any]: checks: List[Dict[str, Any]] = [ _event_router_check(), + _gemini_egress_check(), _autoheal_check(), _nemotron_check(), _embedding_queue_check(), diff --git a/tests/test_ai_automation_smoke_service.py b/tests/test_ai_automation_smoke_service.py index dd1d203..d79c219 100644 --- a/tests/test_ai_automation_smoke_service.py +++ b/tests/test_ai_automation_smoke_service.py @@ -27,6 +27,7 @@ def test_collect_ai_automation_smoke_uses_worst_status(monkeypatch): from services import ai_automation_smoke_service as smoke monkeypatch.setattr(smoke, "_event_router_check", lambda: smoke._check("event", "ok", "ok")) + monkeypatch.setattr(smoke, "_gemini_egress_check", lambda: smoke._check("gemini", "ok", "ok")) monkeypatch.setattr(smoke, "_autoheal_check", lambda: smoke._check("autoheal", "warning", "warn")) monkeypatch.setattr(smoke, "_nemotron_check", lambda: smoke._check("nemotron", "ok", "ok")) monkeypatch.setattr(smoke, "_embedding_queue_check", lambda: smoke._check("embedding", "critical", "boom")) @@ -35,7 +36,7 @@ def test_collect_ai_automation_smoke_uses_worst_status(monkeypatch): result = smoke.collect_ai_automation_smoke(record_history=False) assert result["status"] == "critical" - assert result["summary"] == {"ok": 3, "warning": 1, "critical": 1, "total": 5} + assert result["summary"] == {"ok": 4, "warning": 1, "critical": 1, "total": 6} def test_collect_ai_automation_smoke_persists_recent_history(tmp_path, monkeypatch): @@ -45,6 +46,7 @@ def test_collect_ai_automation_smoke_persists_recent_history(tmp_path, monkeypat monkeypatch.setattr(smoke, "_HISTORY_PATH", str(history_path)) monkeypatch.setattr(smoke, "_HISTORY_LIMIT", 2) monkeypatch.setattr(smoke, "_event_router_check", lambda: smoke._check("event", "ok", "ok")) + monkeypatch.setattr(smoke, "_gemini_egress_check", lambda: smoke._check("gemini", "ok", "ok")) monkeypatch.setattr(smoke, "_autoheal_check", lambda: smoke._check("autoheal", "ok", "ok")) monkeypatch.setattr(smoke, "_nemotron_check", lambda: smoke._check("nemotron", "ok", "ok")) monkeypatch.setattr(smoke, "_embedding_queue_check", lambda: smoke._check("embedding", "ok", "ok")) @@ -95,6 +97,82 @@ def test_smoke_history_daily_summary(): ] +def test_gemini_egress_check_ok_when_no_calls(monkeypatch): + from services import ai_automation_smoke_service as smoke + + class FakeResult: + def fetchone(self): + return {"calls": 0, "tokens": 0, "cost_usd": 0, "last_called": None} + + def fetchall(self): + return [] + + class FakeSession: + closed = False + + def execute(self, *_args, **_kwargs): + return FakeResult() + + def close(self): + self.closed = True + + fake_session = FakeSession() + monkeypatch.setattr(smoke, "get_session", lambda: fake_session) + monkeypatch.delenv("GEMINI_API_HARD_DISABLED", raising=False) + + result = smoke._gemini_egress_check() + + assert result["status"] == "ok" + assert result["details"]["calls"] == 0 + assert result["details"]["hard_disabled"] is True + assert fake_session.closed is True + + +def test_gemini_egress_check_critical_when_hard_disabled_has_calls(monkeypatch): + from services import ai_automation_smoke_service as smoke + + class FakeSummaryResult: + def fetchone(self): + return { + "calls": 2, + "tokens": 1234, + "cost_usd": 0.012345, + "last_called": "2026-05-21 07:00:00+00", + } + + class FakeTopResult: + def fetchall(self): + return [{ + "caller": "code_review_openclaw_gemini", + "model": "gemini-2.5-flash", + "calls": 2, + "tokens": 1234, + "cost_usd": 0.012345, + "last_called": "2026-05-21 07:00:00+00", + }] + + class FakeSession: + def __init__(self): + self.calls = 0 + + def execute(self, *_args, **_kwargs): + self.calls += 1 + return FakeSummaryResult() if self.calls == 1 else FakeTopResult() + + def close(self): + pass + + monkeypatch.setattr(smoke, "get_session", lambda: FakeSession()) + monkeypatch.delenv("GEMINI_API_HARD_DISABLED", raising=False) + + result = smoke._gemini_egress_check() + + assert result["status"] == "critical" + assert "Hard-disabled" in result["summary"] + assert result["details"]["calls"] == 2 + assert result["details"]["top_callers"][0]["caller"] == "code_review_openclaw_gemini" + + def test_build_smoke_daily_summary_message_escapes_history(tmp_path, monkeypatch): from services import ai_automation_smoke_service as smoke @@ -111,6 +189,7 @@ def test_build_smoke_daily_summary_message_escapes_history(tmp_path, monkeypatch assert "AI 自動化 Smoke 每日摘要" in message assert "WARNING" in message + assert "最近異常檢查" in message assert "