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

102 lines
4.3 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.
"""
模型适配器基类 - 所有模型适配器的抽象基类
新增模型只需继承此类并实现方法:
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