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>
This commit is contained in:
@@ -1,109 +1,199 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from fam_edge.qa import QAOrchestrator
|
||||
|
||||
|
||||
class _FakeRoleAdapter:
|
||||
"""用于 __init__ 过滤逻辑测试,只需要 get_role(),不需要真的能 chat。"""
|
||||
def __init__(self, provider_name, role):
|
||||
self.provider_name = provider_name
|
||||
self.role = role
|
||||
|
||||
def get_role(self):
|
||||
return self.role
|
||||
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_only_keeps_text_role_adapters_in_config_order(monkeypatch):
|
||||
"""核心诉求: 问答链路只用 role='text' 的适配器(跟视频分析用的
|
||||
role='vision' 完全隔离),且顺序沿用 config.yaml 里 models 数组的出现
|
||||
顺序,不需要额外的 qa_order 配置。"""
|
||||
fake_adapters = [
|
||||
_FakeRoleAdapter("gemini", "vision"), # 视频分析用的 gemini-flash,不该出现
|
||||
_FakeRoleAdapter("nvidia", "vision"), # 视频分析用的 nvidia omni,不该出现
|
||||
_FakeRoleAdapter("nvidia", "text"), # 新的问答专用 nvidia 文字模型链
|
||||
_FakeRoleAdapter("gemini", "text"), # 新的问答专用 gemini 非 flash 文字模型
|
||||
_FakeRoleAdapter("ollama", "text"), # 本地兜底
|
||||
]
|
||||
monkeypatch.setattr("fam_edge.qa.load_config", lambda: {"models": []})
|
||||
monkeypatch.setattr("fam_edge.qa.build_adapters", lambda models: fake_adapters)
|
||||
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 [a.role for a in qa.adapters] == ["text", "text", "text"]
|
||||
assert len(qa.adapters) == 3
|
||||
assert qa.base_url == "http://127.0.0.1:5100"
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
def __init__(self, provider_name, chunks=None, raises=False):
|
||||
self.provider_name = provider_name
|
||||
self._chunks = chunks or []
|
||||
self._raises = raises
|
||||
class _FakeResp:
|
||||
"""模拟 requests.Response:非流式用 status_code/json()/text,
|
||||
流式额外提供 iter_lines()(逐行 yield,跟真实 SSE 消费方式一致)。"""
|
||||
|
||||
def chat_stream(self, prompt, max_tokens=512):
|
||||
if self._raises:
|
||||
raise RuntimeError("boom")
|
||||
for c in self._chunks:
|
||||
yield c
|
||||
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 chat(self, prompt, max_tokens=512):
|
||||
return ''.join(self._chunks) or None
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def iter_lines(self, decode_unicode=True):
|
||||
for line in self._lines:
|
||||
yield line
|
||||
|
||||
|
||||
def _orchestrator(adapters):
|
||||
qa = QAOrchestrator.__new__(QAOrchestrator) # 跳过 __init__(不需要真实 config/adapters)
|
||||
qa.adapters = adapters
|
||||
return qa
|
||||
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_stream_first_provider_success():
|
||||
qa = _orchestrator([_FakeAdapter("gemini", chunks=["你", "好"])])
|
||||
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"] == "gemini"
|
||||
|
||||
|
||||
def test_run_qa_stream_falls_back_when_first_yields_nothing():
|
||||
"""核心诉求: 第一个 provider 一个字都没吐出来才允许换下一个——不是失败就切,
|
||||
是"完全没有产出"才切。"""
|
||||
qa = _orchestrator([
|
||||
_FakeAdapter("gemini", chunks=[]),
|
||||
_FakeAdapter("nvidia", chunks=["答案"]),
|
||||
])
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
types = [e["type"] for e in events]
|
||||
assert types == ["provider_trying", "provider_failed", "provider_trying", "chunk", "done"]
|
||||
assert events[-1]["provider"] == "nvidia"
|
||||
|
||||
|
||||
def test_run_qa_stream_does_not_switch_after_partial_output():
|
||||
"""核心诉求: 已经开始吐字之后中途失败,不能悄悄换下一个 provider 接着写
|
||||
(会出现两段风格/内容不连贯的回答拼在一起)——直接结束这次生成。"""
|
||||
class _PartialThenRaise:
|
||||
provider_name = "gemini"
|
||||
def chat_stream(self, prompt, max_tokens=512):
|
||||
yield "先吐"
|
||||
raise RuntimeError("connection reset")
|
||||
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"
|
||||
|
||||
qa = _orchestrator([_PartialThenRaise(), _FakeAdapter("nvidia", chunks=["不该被用到"])])
|
||||
|
||||
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]["text"] == "先吐"
|
||||
assert events[-1]["provider"] == "gemini"
|
||||
|
||||
|
||||
def test_run_qa_stream_all_providers_fail():
|
||||
qa = _orchestrator([
|
||||
_FakeAdapter("gemini", chunks=[]),
|
||||
_FakeAdapter("nvidia", chunks=[], raises=True),
|
||||
])
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
assert events[-1]["type"] == "all_failed"
|
||||
assert "provider_failed" in [e["type"] for e in events]
|
||||
|
||||
|
||||
def test_run_qa_stream_exception_treated_as_no_output():
|
||||
qa = _orchestrator([_FakeAdapter("gemini", raises=True), _FakeAdapter("nvidia", chunks=["ok"])])
|
||||
events = list(qa.run_qa_stream("hi"))
|
||||
assert events[0] == {"type": "provider_trying", "provider": "gemini"}
|
||||
assert events[1] == {"type": "provider_failed", "provider": "gemini"}
|
||||
assert events[-1]["provider"] == "nvidia"
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user