""" QA - 智能问答编排 问答链路(2026-08-23 重构)跟视频分析链路完全独立,不再复用视频分析用的 Gemini flash / NVIDIA omni 模型: role='text' 的适配器才参与问答,按 config.yaml 里 models 数组的出现顺序 依次尝试 chat()/chat_stream(),首个成功即用。role='vision' 的适配器 (视频分析用的 Gemini flash-latest、NVIDIA omni)完全不参与问答。 当前链路(config.yaml 里对应 usage 标记,仅供人读,编排逻辑只看 role+顺序): 1. NVIDIA 文字模型链(usage=qa_primary):nemotron-3-ultra-550b-a55b -> nemotron-3-super-120b-a12b -> gpt-oss-120b(同一个 NvidiaVisionAdapter 实例内部 model_chain 依次降级,见该适配器 chat()) 2. Gemini 非 flash 文字模型(usage=qa_primary):gemini-pro-latest -> gemini-2.5-pro 3. 本地 Ollama qwen2.5:7b(usage=qa_fallback,兜底) """ from typing import Optional, Tuple from .logger import setup_logger from .config_loader import load_config from .model_adapters.adapter_factory import build_adapters logger = setup_logger('fam-edge.qa') class QAOrchestrator: def __init__(self): self.config = load_config() all_adapters = build_adapters(self.config.get('models', [])) # 只有 role='text' 的适配器参与问答;按 config.yaml 里的出现顺序决定 # 降级顺序,不需要额外的 qa_order 配置——顺序即优先级。 self.adapters = [a for a in all_adapters if a.get_role() == 'text'] def run_qa(self, prompt: str, max_tokens: int = 1024) -> Tuple[Optional[str], Optional[str]]: """依次尝试各适配器的 chat(),返回 (answer, provider)。""" for adapter in self.adapters: try: answer = adapter.chat(prompt, max_tokens=max_tokens) except Exception as e: logger.warning(f"QA {adapter.provider_name} 异常: {e}") continue if answer: logger.info(f"QA 命中 provider={adapter.provider_name}") return answer, adapter.provider_name logger.info(f"QA {adapter.provider_name} 无返回,降级下一模型") return None, None def run_qa_stream(self, prompt: str, max_tokens: int = 1024): """流式版:依次尝试各适配器的 chat_stream(),yield 结构化事件字典。 事件类型: {"type":"provider_trying","provider":p} 开始尝试这个 provider {"type":"chunk","provider":p,"text":t} 这个 provider 吐出的文本增量 {"type":"provider_failed","provider":p} 这个 provider 一个字都没吐出就失败,换下一个 {"type":"done","provider":p} 成功结束(这个 provider 至少吐出过一块) {"type":"all_failed"} 所有 provider 都失败 跟 run_qa 一样"仅在还没吐出任何文本时才允许换下一个 provider"——一旦 开始给用户看字了,中途失败就结束这次生成,不再悄悄换源接着写。 """ for adapter in self.adapters: yield {"type": "provider_trying", "provider": adapter.provider_name} got_any = False try: for chunk in adapter.chat_stream(prompt, max_tokens=max_tokens): if chunk: got_any = True yield {"type": "chunk", "provider": adapter.provider_name, "text": chunk} except Exception as e: logger.warning(f"QA {adapter.provider_name} 流式异常: {e}") if got_any: logger.info(f"QA 流式命中 provider={adapter.provider_name}") yield {"type": "done", "provider": adapter.provider_name} return logger.info(f"QA {adapter.provider_name} 流式无返回,降级下一模型") yield {"type": "provider_failed", "provider": adapter.provider_name} yield {"type": "all_failed"}