Phase 1 A4 — 13 個呼叫點接 ai_call_logger(覆蓋率 11.8% → 預估 50%+)
- TOP-1 nemoton_dispatcher: nemotron_dispatch caller (NIM 配額追蹤)
- TOP-2 openclaw_strategist: 4 reports (daily/weekly/monthly/meta) + qa caller
- TOP-3 hermes_analyst: hermes_analyst + hermes_intent (順修 commit 00591c5 殘留 bug)
- TOP-4 code_review_pipeline: code_review_hermes/openclaw/elephant 三鏈 (request_id 串)
- TOP-5 openclaw_bot_routes: openclaw_bot_main/gemini/nim 三層 fallback
Phase 3 A7 — OpenClaw Q&A → qwen3:14b(feature flag OFF)
- OPENCLAW_QA_OLLAMA_FIRST 灰度開關
- 繁中強制 system prompt + Gemini fallback chain
- _is_low_quality_response 品質守門(簡體字檢測 + 拒答訊號 + 結構分數)
- 黃金集 A/B 對照測試框架(10 樣本去 PII)
Phase 3 A8 — OpenClaw 日報 → Hermes 模板(feature flag OFF)
- OPENCLAW_DAILY_HERMES_TEMPLATE 灰度開關
- _compute_daily_kpi 純 SQL + Hermes 規則引擎
- _compute_gemini_insight 精簡 200 字洞察 prompt
- templates/daily_report_v2.j2 + _SafeUndefined 缺欄位優雅降級
- scripts/compare_daily_report_versions.py 雙版本盲測
Phase 3 A9 — Nemotron NIM → qwen3:14b(feature flag OFF)
- NEMOTRON_OLLAMA_FIRST 灰度開關(A2 紅燈:deepseek-r1 假支援,改 qwen3)
- _call_qwen3_dispatch + 既有 NIM tool_calls 解析共用
- 保留 ADR-004「🟡 [降級模式]」Hermes 規則引擎兜底
H6 PII fix — chat_id 進 ai_calls.meta 改 SHA1[:8](4 處 Bot Q&A)
Code Review pipeline — N3 動態 provider tag(gcp/secondary/111)+ A4 logger 三鏈
37 unit tests 全綠(routing 15 + golden 5 + qwen3 8 + daily template 8 + nemotron 1)
Operation Ollama-First v5.0 / Phase 1 A4 + Phase 3 A7+A8+A9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
213 lines
8.9 KiB
Python
213 lines
8.9 KiB
Python
"""
|
||
tests/test_openclaw_daily_template.py
|
||
─────────────────────────────────────────────────────────────────
|
||
Operation Ollama-First v5.0 / Phase 3 / A8 — 日報模板路由測試
|
||
|
||
驗證面:
|
||
T1. flag=false(預設)→ 走 _legacy_full_gemini_daily_report(regression)
|
||
T2. flag=true → 走 _generate_daily_report_hermes_template
|
||
T3. _compute_daily_kpi 各 KPI 函數可獨立 mock 測(DB 失敗回安全預設)
|
||
T4. _render_daily_template_v2 缺欄位優雅降級(_SafeUndefined 不 raise)
|
||
T5. _SafeUndefined 對 'X.Y.Z' 巢狀存取不爆
|
||
|
||
紀律:
|
||
- 不打真實 DB / Gemini API
|
||
- 不寫 ai_insights
|
||
- 不發 Telegram
|
||
"""
|
||
|
||
import os
|
||
from datetime import date, datetime
|
||
from unittest.mock import patch, MagicMock
|
||
|
||
import pytest
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# Fixtures
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_flag(monkeypatch):
|
||
"""每個 test 前清環境變數,避免互相污染"""
|
||
monkeypatch.delenv('OPENCLAW_DAILY_HERMES_TEMPLATE', raising=False)
|
||
yield
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T1+T2 — Routing
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestRouting:
|
||
|
||
def test_flag_false_routes_to_legacy(self, monkeypatch):
|
||
"""flag=false → _legacy_full_gemini_daily_report 被呼叫"""
|
||
monkeypatch.setenv('OPENCLAW_DAILY_HERMES_TEMPLATE', 'false')
|
||
import importlib
|
||
import services.openclaw_strategist_service as svc
|
||
importlib.reload(svc)
|
||
|
||
legacy_called = {'v': False}
|
||
hermes_called = {'v': False}
|
||
|
||
def mock_legacy():
|
||
legacy_called['v'] = True
|
||
return {'status': 'ok', 'mode': 'legacy'}
|
||
|
||
def mock_hermes():
|
||
hermes_called['v'] = True
|
||
return {'status': 'ok', 'mode': 'hermes_template'}
|
||
|
||
monkeypatch.setattr(svc, '_legacy_full_gemini_daily_report', mock_legacy)
|
||
monkeypatch.setattr(svc, '_generate_daily_report_hermes_template', mock_hermes)
|
||
|
||
svc.generate_daily_report()
|
||
|
||
assert legacy_called['v'] is True, "flag=false 必須走 legacy 路徑"
|
||
assert hermes_called['v'] is False, "flag=false 不可走 hermes 模板"
|
||
|
||
def test_flag_true_routes_to_hermes_template(self, monkeypatch):
|
||
"""flag=true → _generate_daily_report_hermes_template 被呼叫"""
|
||
monkeypatch.setenv('OPENCLAW_DAILY_HERMES_TEMPLATE', 'true')
|
||
import importlib
|
||
import services.openclaw_strategist_service as svc
|
||
importlib.reload(svc)
|
||
|
||
legacy_called = {'v': False}
|
||
hermes_called = {'v': False}
|
||
|
||
monkeypatch.setattr(svc, '_legacy_full_gemini_daily_report',
|
||
lambda: legacy_called.update(v=True) or {'status': 'ok'})
|
||
monkeypatch.setattr(svc, '_generate_daily_report_hermes_template',
|
||
lambda: hermes_called.update(v=True) or {'status': 'ok'})
|
||
|
||
svc.generate_daily_report()
|
||
|
||
assert hermes_called['v'] is True, "flag=true 必須走 hermes 模板路徑"
|
||
assert legacy_called['v'] is False, "flag=true 不可走 legacy"
|
||
|
||
def test_flag_default_is_false(self, monkeypatch):
|
||
"""無 env 設定時 → 預設 false(戰前行為)"""
|
||
# 不 set env
|
||
import importlib
|
||
import services.openclaw_strategist_service as svc
|
||
importlib.reload(svc)
|
||
|
||
assert svc._daily_hermes_template_enabled() is False
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T3 — KPI 計算(DB 失敗安全降級)
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestKPIComputation:
|
||
|
||
def test_compute_daily_kpi_invalid_date_raises(self):
|
||
import services.openclaw_strategist_service as svc
|
||
with pytest.raises(TypeError):
|
||
svc._compute_daily_kpi("not-a-date")
|
||
|
||
def test_revenue_kpi_returns_safe_default_on_db_error(self, monkeypatch):
|
||
"""DB 異常時 _query_revenue_kpi 回零(不拋 exception)"""
|
||
import services.openclaw_strategist_service as svc
|
||
|
||
class _BrokenSession:
|
||
def execute(self, *a, **kw):
|
||
raise RuntimeError('DB connection lost')
|
||
def close(self):
|
||
pass
|
||
|
||
monkeypatch.setattr(svc, 'get_session', lambda: _BrokenSession())
|
||
|
||
result = svc._query_revenue_kpi(date(2026, 5, 3))
|
||
|
||
assert result['today'] == 0.0
|
||
assert result['dod_pct'] == 0.0
|
||
assert result['wow_pct'] == 0.0
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# T4+T5 — Template 渲染與缺欄位優雅降級
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestTemplateRendering:
|
||
|
||
def test_render_with_full_context_succeeds(self):
|
||
import services.openclaw_strategist_service as svc
|
||
|
||
context = {
|
||
'today': '2026年05月02日',
|
||
'weekday': '週五',
|
||
'revenue': {
|
||
'today': 1234567.0,
|
||
'yesterday': 1100000.0,
|
||
'avg_7d': 1050000.0,
|
||
'dod_pct': 12.2,
|
||
'wow_pct': 17.6,
|
||
},
|
||
'orders': {
|
||
'today_rows': 234,
|
||
'today_sku': 187,
|
||
'avg_value_today': 5276.0,
|
||
},
|
||
'top_skus': [
|
||
{'name': 'SKU-A', 'qty': 50, 'revenue': 100000},
|
||
{'name': 'SKU-B', 'qty': 32, 'revenue': 80000},
|
||
],
|
||
'price_gaps': [
|
||
{'sku_name': '商品X', 'momo_price': 1200, 'comp_price': 980,
|
||
'gap_pct': 22.4, 'competitor': 'PChome'},
|
||
],
|
||
'inventory_alerts': [],
|
||
'priority_actions': ['對 SKU-A 啟動 EA 流程', '觀察 PChome 補貼'],
|
||
'gemini_insight': '今日營收強勁成長,建議加碼家電促銷檔期。',
|
||
}
|
||
|
||
rendered = svc._render_daily_template_v2(context)
|
||
|
||
assert '2026年05月02日' in rendered
|
||
assert '週五' in rendered
|
||
assert 'NT$1,234,567' in rendered
|
||
assert 'SKU-A' in rendered
|
||
assert '商品X' in rendered
|
||
assert 'PChome' in rendered
|
||
assert '今日營收強勁成長' in rendered
|
||
|
||
def test_render_with_missing_fields_does_not_raise(self):
|
||
"""_SafeUndefined 對缺欄位回 — 不拋 UndefinedError"""
|
||
import services.openclaw_strategist_service as svc
|
||
|
||
context = {
|
||
'today': '2026年05月02日',
|
||
'weekday': '週五',
|
||
'revenue': {'today': 0.0, 'dod_pct': 0.0, 'wow_pct': 0.0},
|
||
'orders': {}, # 空 dict
|
||
'top_skus': [],
|
||
'price_gaps': [],
|
||
'inventory_alerts': [],
|
||
'priority_actions': [],
|
||
'gemini_insight': '',
|
||
}
|
||
|
||
# 不 raise 即過
|
||
rendered = svc._render_daily_template_v2(context)
|
||
|
||
assert isinstance(rendered, str)
|
||
assert len(rendered) > 0
|
||
# 缺欄位該降級為 — 或預設值
|
||
assert '今日無熱銷資料' in rendered or '✅' in rendered
|
||
|
||
def test_safe_undefined_nested_access(self):
|
||
"""_SafeUndefined 對 'X.Y.Z' 巢狀存取不爆"""
|
||
import services.openclaw_strategist_service as svc
|
||
|
||
# 完全無 'revenue' 也不該 raise
|
||
context = {
|
||
'today': '2026年05月02日',
|
||
'weekday': '週五',
|
||
# 故意省略 revenue / orders / top_skus 等
|
||
}
|
||
rendered = svc._render_daily_template_v2(context)
|
||
assert isinstance(rendered, str)
|
||
assert '2026年05月02日' in rendered
|