原本嵌在 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>
102 lines
4.3 KiB
Python
102 lines
4.3 KiB
Python
"""
|
||
模型适配器基类 - 所有模型适配器的抽象基类
|
||
|
||
新增模型只需继承此类并实现方法:
|
||
1. health_check() -> bool
|
||
2. analyze_video(video_path, known_members_context, event_start_time) -> Optional[dict]
|
||
- 整视频分析:直接把完整视频交给云端 VLM(本地不切片、不抽帧)。
|
||
- 模型内部自行采样帧,输出结构化结果 dict。失败/超时返回 None。
|
||
- 返回约定:
|
||
{
|
||
"global_summary": str, # 整段视频摘要
|
||
"events": [ # 有用时间点 + 画面信息
|
||
{"timestamp": "2026-08-21 08:15:30", # 绝对北京时间(event_start_time 推算)
|
||
"description": str,
|
||
"people": [str], # 该时刻出现的人物标识
|
||
"person_appearances": [ # 该时刻每个人物的结构化特征
|
||
{"uid": str, # 与 people 数组里的标识一致
|
||
"features": { # 客观可见特征,看不清写 "unknown"
|
||
"gender, age_band, build, hair, clothing, face, distinguishing"
|
||
},
|
||
"action": str}],
|
||
"is_attention_event": bool}, ...],
|
||
"people_mentioned": [str], # 本视频出现的人物标识/真名
|
||
}
|
||
3. chat(prompt) -> Optional[str]
|
||
- 纯文本问答(智能问答场景),返回文本或 None。
|
||
4. get_timeout() -> int
|
||
5. get_circuit_breaker() -> CircuitBreaker
|
||
"""
|
||
from abc import ABC, abstractmethod
|
||
from typing import Dict, List, Optional
|
||
|
||
|
||
class BaseModelAdapter(ABC):
|
||
"""所有模型适配器的抽象基类"""
|
||
|
||
def __init__(self, provider_name: str, config: dict):
|
||
self.provider_name = provider_name # 如 "ollama", "gemini"
|
||
self.config = config
|
||
# 角色: vision=视觉分析, text=智能问答(问答链路已抽离到 ai-gateway,
|
||
# 这里目前只有 vision 在用;text 角色留给尚未清理的旧 person_service)
|
||
self.role = config.get('role', 'vision')
|
||
# 模型调用统计回调(由编排层注入):
|
||
# hook(provider, model, started_at, duration_sec, success, error)
|
||
self.model_call_hook = None
|
||
|
||
def _emit_model_call(self, model: str, started_at: str,
|
||
duration_sec: float, success: bool,
|
||
error: str = ''):
|
||
"""上报一次模型调用统计(供前端展示成功/失败/耗时/失败原因)"""
|
||
hook = self.model_call_hook
|
||
if hook is None:
|
||
return
|
||
try:
|
||
hook(self.provider_name, model, started_at, duration_sec, success, error)
|
||
except Exception:
|
||
pass # 统计失败不影响主流程
|
||
|
||
def get_role(self) -> str:
|
||
"""返回适配器角色: 'vision' 或 'text'"""
|
||
return self.role
|
||
|
||
@abstractmethod
|
||
def health_check(self) -> bool:
|
||
"""健康检查,返回 True/False"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
def analyze_video(self, video_path: str,
|
||
known_members_context: str,
|
||
event_start_time: str = '') -> Optional[Dict]:
|
||
"""整视频分析:把完整视频交给云端 VLM,输出结构化结果 dict。
|
||
|
||
本地不切片、不抽帧;模型内部自行采样帧。
|
||
失败/超时返回 None。
|
||
"""
|
||
pass
|
||
|
||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||
"""纯文本问答(智能问答场景)。默认不实现。"""
|
||
raise NotImplementedError(
|
||
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)")
|
||
|
||
def chat_stream(self, prompt: str, max_tokens: int = 512):
|
||
"""流式问答:逐块 yield 文本增量。默认实现退化为"等 chat() 整段返回后
|
||
一次性当一个大 chunk 吐出"——子类没有真流式 API(或懒得接)时这样也能
|
||
用,只是没有逐字显示的效果;Gemini 有原生 SSE 流式接口,重写了这个方法。
|
||
"""
|
||
result = self.chat(prompt, max_tokens=max_tokens)
|
||
if result:
|
||
yield result
|
||
|
||
@abstractmethod
|
||
def get_timeout(self) -> int:
|
||
"""该模型的调用超时秒数"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_circuit_breaker(self):
|
||
"""返回该模型专属的熔断器实例"""
|
||
pass
|