Files
sentinel-home-ai/fam-edge/tests/test_qa.py
ericwyuan 5caeb299a4 refactor(fam-edge): 问答链路抽离到独立 ai-gateway 服务,fam-edge 改为转发客户端
原本嵌在 fam-edge 里的问答模型降级链(NVIDIA 文字模型 -> Gemini 非 flash 文字
模型 -> 本地 Ollama 兜底,含 key 轮换/熔断)跟视频分析业务无关,是通用能力,
抽成独立 ai-gateway 服务(OpenAI 兼容协议),除了 fam-edge 自己,别的项目也能
直接接入。

- qa.py 重写为 HTTP 转发客户端,调 ai-gateway 的 /v1/chat/completions,翻译回
  原有 run_qa/run_qa_stream 契约,api_gateway.py 和 fam-core 调用方零改动
- 删除 model_adapters/ollama_adapter.py 及其测试(问答专用,视频分析不需要本地模型)
- gemini_adapter.py / nvidia_adapter.py 移除 chat()/chat_stream() 及问答专用超时
  (只保留视频分析用的 analyze_video)
- app.py 移除 Ollama 预热逻辑(现在由 ai-gateway 自己负责)
- config.yaml 移除 3 个问答专用 model 条目,新增 ai_gateway 客户端配置块

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 14:12:01 +08:00

200 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import pytest
from fam_edge.qa import QAOrchestrator
def _orchestrator(monkeypatch, cfg=None, token='tok'):
ai_gateway_cfg = {"base_url": "http://127.0.0.1:5100", "token": token, "timeout": 5}
if cfg:
ai_gateway_cfg.update(cfg)
monkeypatch.setattr(
"fam_edge.qa.load_config", lambda: {"ai_gateway": ai_gateway_cfg})
return QAOrchestrator()
def test_init_reads_base_url_and_token_from_config(monkeypatch):
qa = _orchestrator(monkeypatch, {"base_url": "http://example:5100/"}, token='secret')
assert qa.base_url == "http://example:5100"
assert qa.token == 'secret'
def test_init_resolves_token_from_env_var(monkeypatch):
monkeypatch.setenv("MY_GATEWAY_TOKEN", "resolved-secret")
qa = _orchestrator(monkeypatch, token='${MY_GATEWAY_TOKEN}')
assert qa.token == 'resolved-secret'
def test_init_defaults_base_url_when_unconfigured(monkeypatch):
monkeypatch.setattr("fam_edge.qa.load_config", lambda: {})
qa = QAOrchestrator()
assert qa.base_url == "http://127.0.0.1:5100"
class _FakeResp:
"""模拟 requests.Response非流式用 status_code/json()/text
流式额外提供 iter_lines()(逐行 yield跟真实 SSE 消费方式一致)。"""
def __init__(self, status_code=200, payload=None, text='', lines=None):
self.status_code = status_code
self._payload = payload
self.text = text
self._lines = lines if lines is not None else []
self.encoding = None
def json(self):
return self._payload
def iter_lines(self, decode_unicode=True):
for line in self._lines:
yield line
def _capture_post(monkeypatch, resp):
calls = []
def fake_post(url, headers=None, json=None, timeout=None, stream=False):
calls.append({"url": url, "headers": headers, "json": json,
"timeout": timeout, "stream": stream})
return resp
monkeypatch.setattr("fam_edge.qa.requests.post", fake_post)
return calls
def test_run_qa_success(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(payload={"choices": [{"message": {"content": "你好"}}],
"provider": "nvidia"})
calls = _capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi", max_tokens=100)
assert answer == "你好"
assert provider == "nvidia"
assert calls[0]["json"] == {"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 100, "stream": False}
assert calls[0]["headers"]["Authorization"] == "Bearer tok"
def test_run_qa_non_200_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
_capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def test_run_qa_empty_answer_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(payload={"choices": [{"message": {"content": ""}}], "provider": "gemini"})
_capture_post(monkeypatch, resp)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def test_run_qa_connection_error_returns_none(monkeypatch):
qa = _orchestrator(monkeypatch)
def _raise(*args, **kwargs):
raise ConnectionError("boom")
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
answer, provider = qa.run_qa("hi")
assert answer is None
assert provider is None
def _sse_lines(events):
lines = []
for e in events:
lines.append(f"data: {json.dumps(e, ensure_ascii=False)}")
lines.append("data: [DONE]")
return lines
def test_run_qa_stream_single_provider_success(monkeypatch):
qa = _orchestrator(monkeypatch)
lines = _sse_lines([
{"provider": "nvidia", "choices": [{"delta": {"content": ""}}]},
{"provider": "nvidia", "choices": [{"delta": {"content": ""}}]},
{"provider": "nvidia", "choices": [{"delta": {}}]},
])
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "chunk", "done"]
assert events[1]["text"] == ""
assert events[2]["text"] == ""
assert events[-1]["provider"] == "nvidia"
def test_run_qa_stream_emits_provider_trying_once_per_change(monkeypatch):
"""provider 字段没变化时不该重复吐 provider_trying。"""
qa = _orchestrator(monkeypatch)
lines = _sse_lines([
{"provider": "nvidia", "choices": [{"delta": {"content": "a"}}]},
{"provider": "nvidia", "choices": [{"delta": {"content": "b"}}]},
])
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
trying = [e for e in events if e["type"] == "provider_trying"]
assert len(trying) == 1
assert trying[0]["provider"] == "nvidia"
def test_run_qa_stream_no_chunks_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(lines=["data: [DONE]"])
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_non_200_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(status_code=503, text='{"error":{"message":"所有模型均不可用"}}')
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_connection_error_yields_all_failed(monkeypatch):
qa = _orchestrator(monkeypatch)
def _raise(*args, **kwargs):
raise ConnectionError("boom")
monkeypatch.setattr("fam_edge.qa.requests.post", _raise)
events = list(qa.run_qa_stream("hi"))
assert events == [{"type": "all_failed"}]
def test_run_qa_stream_error_chunk_stops_and_uses_partial_output(monkeypatch):
"""已经吐出过内容后遇到错误块:按"至少吐出过一块就算 done"处理,不是 all_failed。"""
qa = _orchestrator(monkeypatch)
lines = [
f"data: {json.dumps({'provider': 'gemini', 'choices': [{'delta': {'content': '先吐'}}]}, ensure_ascii=False)}",
f"data: {json.dumps({'error': {'message': 'boom'}}, ensure_ascii=False)}",
]
resp = _FakeResp(lines=lines)
_capture_post(monkeypatch, resp)
events = list(qa.run_qa_stream("hi"))
types = [e["type"] for e in events]
assert types == ["provider_trying", "chunk", "done"]
assert events[-1]["provider"] == "gemini"
def test_run_qa_stream_sets_stream_true_and_utf8_encoding(monkeypatch):
qa = _orchestrator(monkeypatch)
resp = _FakeResp(lines=["data: [DONE]"])
calls = _capture_post(monkeypatch, resp)
list(qa.run_qa_stream("hi", max_tokens=222))
assert calls[0]["json"]["stream"] is True
assert calls[0]["json"]["max_tokens"] == 222
assert calls[0]["stream"] is True
assert resp.encoding == 'utf-8'