""" 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 不一定带 charset,requests 会自己猜 # 编码——猜错就是中文乱码,强制指定跳过嗅探(同源坑见 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"}