514 lines
22 KiB
Python
514 lines
22 KiB
Python
"""
|
||
test_nemotron_qwen3_compat.py
|
||
─────────────────────────────────────────────────────────────────
|
||
Operation Ollama-First v5.0 / Phase 3 / A9 — Nemotron qwen3 切換相容性測試
|
||
|
||
驗證面:
|
||
T1. qwen3 chat 回應 OpenAI tool_calls 結構 → _parse_tool_calls_struct 正確
|
||
T2. qwen3 沒回 tool_calls → _parse_content_fallback 正確(與 NIM 同等容錯)
|
||
T3. qwen3 同時回 tool_calls + content → 優先採用 tool_calls
|
||
T4. qwen3 連線失敗 → 不丟例外給上游,自動 fallback NIM 路徑
|
||
T5. qwen3 + NIM 都失敗 → ADR-004 走 Hermes 規則引擎降級(含「🟡 [規則引擎]」標記)
|
||
T6. NEMOTRON_OLLAMA_FIRST=false → 緊急退回 NIM-first,不呼叫 qwen3
|
||
|
||
紀律:
|
||
- 所有 HTTP 互動 mock,不實際呼叫 GCP Ollama 或 NIM
|
||
- 與 test_nemotron_fallback 共存,使用同款 FakeThreat
|
||
- assert log_ai_call 路徑可被 monkeypatch 旁路(不污染 ai_calls 表)
|
||
"""
|
||
from dataclasses import dataclass
|
||
from contextlib import contextmanager
|
||
import pytest
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# Fixtures
|
||
# ─────────────────────────────────────────────────────────────
|
||
@dataclass
|
||
class FakeThreat:
|
||
sku: str = "SKU-Q1"
|
||
name: str = "qwen3 測試品"
|
||
momo_price: float = 1200.0
|
||
pchome_price: float = 980.0
|
||
gap_pct: float = 22.4
|
||
sales_7d_delta_pct: float = -35.0
|
||
risk: str = "HIGH"
|
||
recommended_action: str = "建議跟進降價"
|
||
confidence: float = 0.85
|
||
sales_7d_curr_amount: float = 78000.0
|
||
sales_7d_prev_amount: float = 120000.0
|
||
|
||
|
||
class _FakeResp:
|
||
def __init__(self, payload: dict, status: int = 200):
|
||
self._payload = payload
|
||
self.status_code = status
|
||
|
||
def raise_for_status(self):
|
||
if self.status_code >= 400:
|
||
import requests
|
||
raise requests.HTTPError(f"HTTP {self.status_code}", response=self)
|
||
|
||
def json(self):
|
||
return self._payload
|
||
|
||
|
||
@contextmanager
|
||
def _noop_log_ai_call(*args, **kwargs):
|
||
"""Mock log_ai_call context manager — 不寫 ai_calls 表,回傳具備所需 setter 的 stub"""
|
||
class _Ctx:
|
||
def set_tokens(self, **_kw): pass
|
||
def set_provider(self, *_a, **_kw): pass
|
||
def set_error(self, *_a, **_kw): pass
|
||
def fallback_to_caller(self, *_a, **_kw): pass
|
||
def set_cache_hit(self, *_a, **_kw): pass
|
||
def add_meta(self, *_a, **_kw): pass
|
||
yield _Ctx()
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_global_state():
|
||
"""test 互相污染防線:每個 test 前後清 _ALERT_CACHE + ollama unhealthy marks。
|
||
|
||
根因:dispatch() line 1328 _is_duplicate_alert 用 module-level _ALERT_CACHE,
|
||
第一個 test 跑完留 "SKU-Q1" 在 cache,後續 test 命中去重 → dispatched=0 失敗。
|
||
"""
|
||
import services.nemoton_dispatcher_service as _nem
|
||
import services.ollama_service as _oss
|
||
_nem._ALERT_CACHE.clear()
|
||
_oss._unhealthy_marks.clear()
|
||
_oss._resolved_host_cache['host'] = None
|
||
_oss._resolved_host_cache['ts'] = 0
|
||
yield
|
||
_nem._ALERT_CACHE.clear()
|
||
_oss._unhealthy_marks.clear()
|
||
_oss._resolved_host_cache['host'] = None
|
||
_oss._resolved_host_cache['ts'] = 0
|
||
|
||
|
||
def _patch_execution_methods(monkeypatch, dispatcher):
|
||
"""攔截實際 Telegram/DB 寫入,記錄被呼叫的 tool 名稱與 args(與 fallback test 共用 pattern)"""
|
||
calls = []
|
||
|
||
def record(kind):
|
||
def _inner(*args, **kwargs):
|
||
calls.append({"kind": kind, "args": args, "kwargs": kwargs})
|
||
return _inner
|
||
|
||
monkeypatch.setattr(dispatcher, "_exec_trigger_price_alert", record("price_alert"))
|
||
monkeypatch.setattr(dispatcher, "_exec_add_to_recommendation", record("recommendation"))
|
||
monkeypatch.setattr(dispatcher, "_exec_flag_for_human_review", record("human_review"))
|
||
return calls
|
||
|
||
|
||
def _enable_qwen3_path(monkeypatch, module):
|
||
"""打開 NEMOTRON_OLLAMA_FIRST + 旁路 mcp/log_ai_call/resolve_host 等副作用"""
|
||
monkeypatch.setattr(module, "NEMOTRON_OLLAMA_FIRST", True)
|
||
monkeypatch.setattr(module, "log_ai_call", _noop_log_ai_call)
|
||
monkeypatch.setattr(module, "build_mcp_context", lambda: "MCP-MOCK")
|
||
# 確保即使未被呼叫,import 路徑可解析
|
||
import services.ollama_service as ollama_module
|
||
monkeypatch.setattr(ollama_module, "resolve_ollama_host", lambda: "http://34.87.90.216:11434")
|
||
monkeypatch.setattr(ollama_module, "mark_unhealthy", lambda *a, **kw: None)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T1. qwen3 OpenAI tool_calls 結構 → 正確解析
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_qwen3_tool_calls_struct_parsed_and_executed(monkeypatch):
|
||
"""qwen3 回標準 OpenAI tool_calls 結構,dispatcher 應跳過 NIM 直接走工具執行"""
|
||
import services.nemoton_dispatcher_service as module
|
||
|
||
_enable_qwen3_path(monkeypatch, module)
|
||
|
||
# mock GCP Ollama /api/chat 回 OpenAI 兼容結構
|
||
fake_body = {
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": "",
|
||
"tool_calls": [
|
||
{
|
||
"function": {
|
||
"name": "trigger_price_alert",
|
||
"arguments": {
|
||
"sku": "SKU-Q1",
|
||
"name": "qwen3 測試品",
|
||
"gap_pct": 22.4,
|
||
"sales_delta": -35.0,
|
||
"action": "跟進降價至 $980",
|
||
"confidence": 0.85,
|
||
},
|
||
}
|
||
}
|
||
],
|
||
},
|
||
"prompt_eval_count": 320,
|
||
"eval_count": 64,
|
||
"done": True,
|
||
}
|
||
monkeypatch.setattr(
|
||
module.requests, "post", lambda *a, **kw: _FakeResp(fake_body)
|
||
)
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
calls = _patch_execution_methods(monkeypatch, dispatcher)
|
||
# NIM 路徑必須完全沒被觸發(驗證 qwen3 確實是主路徑)
|
||
nim_called = {"v": False}
|
||
|
||
def _nim_should_not_be_called(*a, **kw):
|
||
nim_called["v"] = True
|
||
raise AssertionError("NIM 不應被呼叫,qwen3 已成功")
|
||
|
||
monkeypatch.setattr(dispatcher, "_call_nim", _nim_should_not_be_called)
|
||
|
||
threats = [FakeThreat()]
|
||
result = dispatcher.dispatch(threats, hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert nim_called["v"] is False, "qwen3 成功時 NIM 不可被觸發"
|
||
assert result["dispatched"] == 1
|
||
assert result["nim_stats"].get("provider") == "gcp_ollama"
|
||
assert result["nim_stats"].get("model") == module.NEMOTRON_OLLAMA_MODEL
|
||
assert calls and calls[0]["kind"] == "price_alert"
|
||
|
||
|
||
def test_qwen3_retries_secondary_when_primary_chat_fails(monkeypatch):
|
||
"""qwen3 chat 第一台失敗時,應標 unhealthy 並在同一請求嘗試第二台。"""
|
||
import requests
|
||
import services.nemoton_dispatcher_service as module
|
||
import services.ollama_service as ollama_module
|
||
|
||
_enable_qwen3_path(monkeypatch, module)
|
||
hosts = iter([
|
||
"http://34.87.90.216:11434",
|
||
"http://34.21.145.224:11434",
|
||
])
|
||
marked = []
|
||
monkeypatch.setattr(ollama_module, "resolve_ollama_host", lambda: next(hosts))
|
||
monkeypatch.setattr(ollama_module, "mark_unhealthy", lambda host: marked.append(host))
|
||
|
||
fake_body = {
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": "",
|
||
"tool_calls": [{
|
||
"function": {
|
||
"name": "trigger_price_alert",
|
||
"arguments": {
|
||
"sku": "SKU-Q1",
|
||
"name": "qwen3 測試品",
|
||
"gap_pct": 22.4,
|
||
"sales_delta": -35.0,
|
||
"action": "跟進降價",
|
||
"confidence": 0.85,
|
||
},
|
||
}
|
||
}],
|
||
},
|
||
"prompt_eval_count": 200,
|
||
"eval_count": 40,
|
||
}
|
||
responses = [requests.Timeout("primary down"), _FakeResp(fake_body)]
|
||
|
||
def fake_post(*args, **kwargs):
|
||
result = responses.pop(0)
|
||
if isinstance(result, Exception):
|
||
raise result
|
||
return result
|
||
|
||
monkeypatch.setattr(module.requests, "post", fake_post)
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
calls = _patch_execution_methods(monkeypatch, dispatcher)
|
||
result = dispatcher.dispatch([FakeThreat()], hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert marked == ["http://34.87.90.216:11434"]
|
||
assert result["nim_stats"].get("host") == "http://34.21.145.224:11434"
|
||
assert result["dispatched"] == 1
|
||
assert calls[0]["kind"] == "price_alert"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T2. qwen3 沒回 tool_calls 但 content 含 JSON list → fallback 解析
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_qwen3_content_only_fallback_parsing(monkeypatch):
|
||
"""qwen3 把工具呼叫塞在 content(list[dict])→ _parse_content_fallback 應接住"""
|
||
import services.nemoton_dispatcher_service as module
|
||
|
||
_enable_qwen3_path(monkeypatch, module)
|
||
|
||
content_payload = (
|
||
'[{"name": "flag_for_human_review", '
|
||
'"parameters": {"sku": "SKU-Q1", "name": "qwen3 測試品", '
|
||
'"concern": "信心不足", "confidence": 0.45}}]'
|
||
)
|
||
fake_body = {
|
||
"message": {"role": "assistant", "content": content_payload, "tool_calls": []},
|
||
"prompt_eval_count": 100,
|
||
"eval_count": 30,
|
||
}
|
||
monkeypatch.setattr(module.requests, "post", lambda *a, **kw: _FakeResp(fake_body))
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
calls = _patch_execution_methods(monkeypatch, dispatcher)
|
||
monkeypatch.setattr(
|
||
dispatcher, "_call_nim",
|
||
lambda threats: (_ for _ in ()).throw(AssertionError("NIM 不應被呼叫")),
|
||
)
|
||
|
||
result = dispatcher.dispatch([FakeThreat(confidence=0.45)], hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert result["dispatched"] == 1
|
||
assert calls and calls[0]["kind"] == "human_review"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T3. tool_calls + content 同時存在 → 優先 tool_calls
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_qwen3_tool_calls_takes_precedence_over_content(monkeypatch):
|
||
import services.nemoton_dispatcher_service as module
|
||
|
||
_enable_qwen3_path(monkeypatch, module)
|
||
|
||
fake_body = {
|
||
"message": {
|
||
"role": "assistant",
|
||
"content": '[{"name": "flag_for_human_review", "parameters": {"sku": "X"}}]',
|
||
"tool_calls": [
|
||
{
|
||
"function": {
|
||
"name": "trigger_price_alert",
|
||
"arguments": {
|
||
"sku": "SKU-Q1",
|
||
"name": "qwen3 測試品",
|
||
"gap_pct": 22.4,
|
||
"sales_delta": -35.0,
|
||
"action": "降價",
|
||
"confidence": 0.85,
|
||
},
|
||
}
|
||
}
|
||
],
|
||
},
|
||
"prompt_eval_count": 200,
|
||
"eval_count": 40,
|
||
}
|
||
monkeypatch.setattr(module.requests, "post", lambda *a, **kw: _FakeResp(fake_body))
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
calls = _patch_execution_methods(monkeypatch, dispatcher)
|
||
result = dispatcher.dispatch([FakeThreat()], hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert result["dispatched"] == 1
|
||
assert calls[0]["kind"] == "price_alert", "tool_calls 結構必須優先於 content fallback"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T4. qwen3 連線失敗 → 不爆,自動 fallback 到 NIM
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_qwen3_connection_error_falls_back_to_nim(monkeypatch):
|
||
"""GCP Ollama 連不上時,dispatcher 應靜默改走 NIM,最終仍能 dispatch"""
|
||
import requests
|
||
import services.nemoton_dispatcher_service as module
|
||
|
||
_enable_qwen3_path(monkeypatch, module)
|
||
|
||
def _boom(*a, **kw):
|
||
raise requests.ConnectionError("GCP unreachable")
|
||
|
||
monkeypatch.setattr(module.requests, "post", _boom)
|
||
|
||
# NIM 路徑:給 valid key + quota,且 mock _call_nim 回傳 1 個 tool_call
|
||
monkeypatch.setattr(module, "NIM_API_KEY", "fake-key")
|
||
monkeypatch.setattr(module, "_check_nim_quota", lambda: True)
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
calls = _patch_execution_methods(monkeypatch, dispatcher)
|
||
nim_invoked = {"v": False}
|
||
|
||
def _fake_nim(threats):
|
||
nim_invoked["v"] = True
|
||
return (
|
||
[{
|
||
"tool": "trigger_price_alert",
|
||
"args": {
|
||
"sku": "SKU-Q1", "name": "qwen3 測試品",
|
||
"gap_pct": 22.4, "sales_delta": -35.0,
|
||
"action": "降價", "confidence": 0.85,
|
||
},
|
||
}],
|
||
{"total_tokens": 256, "quota_used": 5},
|
||
)
|
||
|
||
monkeypatch.setattr(dispatcher, "_call_nim", _fake_nim)
|
||
|
||
result = dispatcher.dispatch([FakeThreat()], hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert nim_invoked["v"] is True, "qwen3 失敗後必須 fallback 至 NIM"
|
||
assert result["dispatched"] == 1
|
||
assert result["nim_stats"].get("total_tokens") == 256
|
||
assert calls[0]["kind"] == "price_alert"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T5. qwen3 + NIM 全失敗 → ADR-004 Hermes 規則引擎兜底
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_qwen3_and_nim_both_fail_falls_back_to_hermes_rules(monkeypatch):
|
||
"""雙路全爆時必須走 Hermes 規則引擎,並保留 🟡 [規則引擎] 標記"""
|
||
import requests
|
||
import services.nemoton_dispatcher_service as module
|
||
|
||
_enable_qwen3_path(monkeypatch, module)
|
||
monkeypatch.setattr(module.requests, "post",
|
||
lambda *a, **kw: (_ for _ in ()).throw(requests.ConnectionError("qwen3 down")))
|
||
|
||
monkeypatch.setattr(module, "NIM_API_KEY", "fake-key")
|
||
monkeypatch.setattr(module, "_check_nim_quota", lambda: True)
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
|
||
# 攔 _call_nim 也擲 timeout
|
||
monkeypatch.setattr(
|
||
dispatcher, "_call_nim",
|
||
lambda threats: (_ for _ in ()).throw(requests.Timeout("NIM timeout")),
|
||
)
|
||
|
||
# 攔住規則引擎內部呼叫的 _exec_*,記錄 concern / reason 文字驗證 🟡 標記
|
||
# 規則引擎部分 _exec_* 用 positional args(line 787-795 _exec_trigger_price_alert
|
||
# 簽名: sku, name, gap_pct, sales_delta, action, confidence, ...),
|
||
# record helper 必須把 positional 與 keyword 合併才能 .get('action')。
|
||
captured = []
|
||
|
||
def _merge_positional(name_order, args, kwargs):
|
||
merged = dict(kwargs)
|
||
for i, val in enumerate(args):
|
||
if i < len(name_order):
|
||
merged.setdefault(name_order[i], val)
|
||
return merged
|
||
|
||
def record_review(*args, **kwargs):
|
||
merged = _merge_positional(
|
||
['sku', 'name', 'concern', 'confidence', 'footprint',
|
||
'momo_price', 'comp_price', 'gap_pct', 'sales_delta',
|
||
'revenue_loss_7d', 'recommended_price'],
|
||
args, kwargs)
|
||
captured.append(("human_review", merged))
|
||
|
||
def record_alert(*args, **kwargs):
|
||
merged = _merge_positional(
|
||
['sku', 'name', 'gap_pct', 'sales_delta', 'action', 'confidence',
|
||
'momo_price', 'comp_price', 'footprint',
|
||
'revenue_loss_7d', 'recommended_price'],
|
||
args, kwargs)
|
||
captured.append(("price_alert", merged))
|
||
|
||
def record_reco(*args, **kwargs):
|
||
captured.append(("recommendation", kwargs))
|
||
|
||
monkeypatch.setattr(dispatcher, "_exec_flag_for_human_review", record_review)
|
||
monkeypatch.setattr(dispatcher, "_exec_trigger_price_alert", record_alert)
|
||
monkeypatch.setattr(dispatcher, "_exec_add_to_recommendation", record_reco)
|
||
|
||
# gap_pct=22.4 + risk=HIGH → 規則 2:trigger_price_alert,action 應有 🟡 [規則引擎] 前綴
|
||
result = dispatcher.dispatch([FakeThreat()], hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert result["nim_stats"].get("degraded") is True, "ADR-004 降級旗標必須存在"
|
||
assert captured, "規則引擎必須兜底執行至少一次"
|
||
kind, kwargs = captured[0]
|
||
assert kind == "price_alert"
|
||
assert "🟡 [規則引擎]" in kwargs.get("action", ""), \
|
||
"ADR-004 鐵律:Hermes 規則引擎兜底時必須帶『🟡 [規則引擎]』標記"
|
||
# footprint 也應帶 🟡 [降級模式 ADR-004] 標記(給 Telegram 告警頭顯示)
|
||
assert "🟡 [降級模式 ADR-004]" in kwargs.get("footprint", "")
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T6. feature flag 顯式 false → 緊急退路,qwen3 完全不被呼叫
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_flag_false_preserves_nim_first_emergency_path(monkeypatch):
|
||
"""NEMOTRON_OLLAMA_FIRST=false 時:dispatch 不應觸碰 GCP Ollama,
|
||
nim_stats 不可帶 provider='gcp_ollama'。"""
|
||
import services.nemoton_dispatcher_service as module
|
||
|
||
# 不打開 flag(預設值),但安全起見明確 set false
|
||
monkeypatch.setattr(module, "NEMOTRON_OLLAMA_FIRST", False)
|
||
|
||
# 任何呼叫 requests.post 都視為錯誤(戰前 NIM 路徑會被 _call_nim mock 接走)
|
||
qwen3_post_called = {"v": False}
|
||
|
||
def _maybe_post(*a, **kw):
|
||
qwen3_post_called["v"] = True
|
||
raise AssertionError("flag=false 時不可呼叫 GCP Ollama HTTP")
|
||
|
||
monkeypatch.setattr(module.requests, "post", _maybe_post)
|
||
|
||
monkeypatch.setattr(module, "NIM_API_KEY", "fake-key")
|
||
monkeypatch.setattr(module, "_check_nim_quota", lambda: True)
|
||
|
||
dispatcher = module.NemotronDispatcher()
|
||
calls = _patch_execution_methods(monkeypatch, dispatcher)
|
||
monkeypatch.setattr(
|
||
dispatcher, "_call_nim",
|
||
lambda threats: (
|
||
[{
|
||
"tool": "trigger_price_alert",
|
||
"args": {
|
||
"sku": "SKU-Q1", "name": "qwen3 測試品",
|
||
"gap_pct": 22.4, "sales_delta": -35.0,
|
||
"action": "降價", "confidence": 0.85,
|
||
},
|
||
}],
|
||
{"total_tokens": 100, "quota_used": 1},
|
||
),
|
||
)
|
||
|
||
result = dispatcher.dispatch([FakeThreat()], hermes_stats={"duration_sec": 1.0})
|
||
|
||
assert qwen3_post_called["v"] is False
|
||
assert result["dispatched"] == 1
|
||
assert result["nim_stats"].get("provider") in (None, "nim"), \
|
||
"flag=false 時 nim_stats 不應帶 provider='gcp_ollama'"
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────
|
||
# T7. 共用 helper 純單元測試(OpenAI tool_calls schema 邊界)
|
||
# ─────────────────────────────────────────────────────────────
|
||
def test_parse_tool_calls_struct_handles_string_arguments():
|
||
"""NIM 回 arguments 是 JSON 字串、qwen3 回 dict — 兩者都得接住"""
|
||
from services.nemoton_dispatcher_service import _parse_tool_calls_struct
|
||
|
||
# NIM 風格(arguments 是 JSON 字串)
|
||
nim_style = [{"function": {"name": "foo", "arguments": '{"a": 1, "b": "x"}'}}]
|
||
out_nim = _parse_tool_calls_struct(nim_style)
|
||
assert out_nim == [{"tool": "foo", "args": {"a": 1, "b": "x"}}]
|
||
|
||
# qwen3/Ollama 風格(arguments 已是 dict)
|
||
qwen_style = [{"function": {"name": "bar", "arguments": {"a": 2}}}]
|
||
out_qwen = _parse_tool_calls_struct(qwen_style)
|
||
assert out_qwen == [{"tool": "bar", "args": {"a": 2}}]
|
||
|
||
# 邊界:空 / 壞 JSON / 缺 name → 不爆,回空或忽略
|
||
assert _parse_tool_calls_struct([]) == []
|
||
assert _parse_tool_calls_struct(None) == []
|
||
bad = [{"function": {"name": "baz", "arguments": "{not json"}}]
|
||
out_bad = _parse_tool_calls_struct(bad)
|
||
assert out_bad == [{"tool": "baz", "args": {}}]
|
||
no_name = [{"function": {"arguments": "{}"}}]
|
||
assert _parse_tool_calls_struct(no_name) == []
|
||
|
||
|
||
def test_parse_content_fallback_handles_various_shapes():
|
||
from services.nemoton_dispatcher_service import _parse_content_fallback
|
||
|
||
# OpenAI 老風格 [{"name", "parameters"}]
|
||
out1 = _parse_content_fallback('[{"name": "foo", "parameters": {"a": 1}}]')
|
||
assert out1 == [{"tool": "foo", "args": {"a": 1}}]
|
||
|
||
# 帶 function 嵌套
|
||
out2 = _parse_content_fallback('[{"function": {"name": "bar"}, "arguments": "{\\"b\\": 2}"}]')
|
||
assert out2 == [{"tool": "bar", "args": {"b": 2}}]
|
||
|
||
# 非 list / 非 JSON / 空字串 → []
|
||
assert _parse_content_fallback("") == []
|
||
assert _parse_content_fallback("not json") == []
|
||
assert _parse_content_fallback('{"a":1}') == []
|