## 乱码修复 Gemini 流式接口 resp.iter_lines(decode_unicode=True) 没有显式设置 resp.encoding,requests 自己猜编码猜错了(Gemini 的 SSE 响应 Content-Type 不带 charset 参数),导致中文变成典型的 UTF-8 被当 Latin-1 解码的乱码。 fam-core 转发这一跳的 requests.iter_lines 调用有同样的坑,一并修了。 两处响应的 Content-Type 都补上 charset=utf-8,减少下游再犯同样错误的机会。 顺手用 latin-1/utf-8 往返把 NAS chat_history 里已经存了乱码的 3 条历史记录 恢复成正常文字。 ## 问答模型链重新设计(跟视频分析完全独立) 用户要求问答不再用 flash/omni,优先用 NVIDIA 免费文字模型里上下文最大的几个, 不行再退 Gemini 非 flash 文字模型,最后本地 Ollama 兜底。 - base_adapter 新增 usage 字段(纯标记,不影响行为) - qa.py 的 QAOrchestrator 只挑 role='text' 的适配器参与问答(按 config 出现 顺序决定降级顺序),role='vision' 的视频分析适配器(gemini-flash-latest / nvidia omni)不再参与问答,两条链路彻底解耦 - 新增问答专用 NVIDIA 文字模型链(同一适配器内部 model_chain 降级): nemotron-3-ultra-550b-a55b(1M上下文/561B) -> nemotron-3-super-120b-a12b (1M上下文/124B) -> openai/gpt-oss-120b(131K上下文/117B)。三个都用真实 API 调用验证过当前账号可访问;同时验证过 llama-3.1-70b-instruct 和 nemotron-super-49b-v1.5 都收到"08/25/2026 起弃用"通知,排除; nemotron-ultra-253b/mistral-large-2/nemotron-4-340b/kimi-k2.6 在目录里 能看到但实测调用返回 404"账号无权限",排除 - 新增问答专用 Gemini 非 flash 文字模型:gemini-pro-latest -> gemini-2.5-pro (1M 上下文,复用视频分析同一批多 key 轮换) - /api/edge/chat/ask(/stream) 默认 max_tokens 从 1024 提到 3072:新链路 优先用的都是"推理"模型,回答前会先吐一段思考过程,1024 经常在思考阶段 就被截断,用户永远看不到真正答案 新增 test_qa.py::test_init_only_keeps_text_role_adapters_in_config_order 验证角色过滤+顺序正确。 本地检查过 Ollama 服务本身:systemd enabled+running(8h 正常运行, qwen2.5:7b 已加载),并非没启动——用户观察到的现象另有原因。
104 lines
4.5 KiB
Python
104 lines
4.5 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=智能问答; 默认 vision
|
||
self.role = config.get('role', 'vision')
|
||
# usage: 纯文档/编排层筛选用的标记(如 "qa_primary"/"qa_fallback"),
|
||
# 不影响本适配器自身行为;QAOrchestrator 据此挑选参与问答链路的适配器。
|
||
self.usage = config.get('usage', '')
|
||
# 模型调用统计回调(由编排层注入):
|
||
# 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
|