Files
sentinel-home-ai/fam-edge/src/fam_edge/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

137 lines
5.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.
"""
QA - 智能问答代理客户端2026-08-23 问答链路整体抽离到独立 ai-gateway 服务后重写)
原来的问答编排本体NVIDIA 文字模型链 -> Gemini 非 flash 文字模型 -> 本地
Ollama 兜底,含 key 轮换/熔断/降级)已经整个搬到独立的 ai-gateway 服务
OpenAI 兼容协议 /v1/chat/completions跟视频分析业务解耦别的项目也能
直接用 OpenAI SDK 接入。fam-edge 这边现在只是一个转发客户端:调 ai-gateway
把它的 OpenAI 格式响应翻译回 fam-edge 原有的 (answer, provider) / 流式事件
字典契约,上层 api_gateway.py 的 /api/edge/chat/ask(/stream) 端点和 fam-core
的调用方完全不用改。
多 provider 之间失败降级(一个模型没吐出任何内容才换下一个、已经开始吐字后
中途失败不悄悄换源)现在整个发生在 ai-gateway 内部,对这个客户端不可见——
本客户端只会看到最终成功 provider 的分块流,或者全部失败时的空流。
"""
import json
import os
from typing import Optional, Tuple
import requests
from .logger import setup_logger
from .config_loader import load_config
logger = setup_logger('fam-edge.qa')
class QAOrchestrator:
def __init__(self):
cfg = load_config().get('ai_gateway', {})
self.base_url = (cfg.get('base_url') or 'http://127.0.0.1:5100').rstrip('/')
self.token = self._resolve_token(cfg.get('token', ''))
self.timeout = cfg.get('timeout', 60)
def _resolve_token(self, raw: str) -> str:
if raw.startswith('${') and raw.endswith('}'):
return os.environ.get(raw[2:-1], '')
return raw
def _headers(self) -> dict:
headers = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def run_qa(self, prompt: str,
max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]:
"""调 ai-gateway 非流式接口,返回 (answer, provider)。"""
try:
resp = requests.post(
f"{self.base_url}/v1/chat/completions",
headers=self._headers(),
json={"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "stream": False},
timeout=self.timeout)
except Exception as e:
logger.warning(f"QA ai-gateway 请求异常: {e}")
return None, None
if resp.status_code != 200:
logger.warning(f"QA ai-gateway 返回 {resp.status_code}: {resp.text[:200]}")
return None, None
try:
data = resp.json()
answer = data["choices"][0]["message"]["content"]
except Exception as e:
logger.warning(f"QA ai-gateway 响应解析失败: {e}")
return None, None
if not answer:
return None, None
provider = data.get("provider")
logger.info(f"QA 命中 provider={provider}")
return answer, provider
def run_qa_stream(self, prompt: str, max_tokens: int = 1024):
"""流式版:转发 ai-gateway 的 SSE 分块,翻译回原有事件字典契约。
事件类型:
{"type":"provider_trying","provider":p} 流里第一次看到这个 provider
{"type":"chunk","provider":p,"text":t} 文本增量
{"type":"done","provider":p} 成功结束(至少吐出过一块)
{"type":"all_failed"} 请求失败或没有任何文本产出
"""
try:
resp = requests.post(
f"{self.base_url}/v1/chat/completions",
headers=self._headers(),
json={"messages": [{"role": "user", "content": prompt}],
"max_tokens": max_tokens, "stream": True},
timeout=self.timeout, stream=True)
except Exception as e:
logger.warning(f"QA ai-gateway 流式请求异常: {e}")
yield {"type": "all_failed"}
return
if resp.status_code != 200:
logger.warning(f"QA ai-gateway 流式返回 {resp.status_code}: {resp.text[:200]}")
yield {"type": "all_failed"}
return
# 响应体固定 UTF-8但 Content-Type 不一定带 charsetrequests 会自己猜
# 编码——猜错就是中文乱码,强制指定跳过嗅探(同源坑见 gemini_adapter 历史修复)。
resp.encoding = 'utf-8'
current_provider = None
got_any = False
try:
for line in resp.iter_lines(decode_unicode=True):
if not line or not line.startswith('data: '):
continue
payload = line[len('data: '):]
if payload == '[DONE]':
break
try:
chunk = json.loads(payload)
except ValueError:
continue
if 'error' in chunk:
logger.warning(f"QA ai-gateway 流式错误: {chunk['error']}")
break
provider = chunk.get('provider')
if provider and provider != current_provider:
current_provider = provider
yield {"type": "provider_trying", "provider": provider}
choices = chunk.get('choices') or []
if not choices:
continue
text = (choices[0].get('delta') or {}).get('content')
if text:
got_any = True
yield {"type": "chunk", "provider": current_provider, "text": text}
except Exception as e:
logger.warning(f"QA ai-gateway 流式读取异常: {e}")
if got_any:
logger.info(f"QA 流式命中 provider={current_provider}")
yield {"type": "done", "provider": current_provider}
else:
yield {"type": "all_failed"}