refactor(fam-edge): 重构第一阶段 - 人物图片零额外调用 + 运行时稳定性 + 工程质量

人物图片功能重做: bbox 随核心视频分析那一次 Gemini 调用一并产出(prompts.py 加
person_appearances.bbox 字段, [ymin,xmin,ymax,xmax] 0-1000 归一化), frame_service
直接用存好的 bbox 裁剪头像/事件缩略图, 删除原来"展示时额外调用 Gemini 定位人物"的
整套逻辑(locate_person_bbox/VLM 校验/熔断), 从架构上消除与核心视频分析共抢配额的
问题; 用真实数据验证裁剪结果正确框住人物本体。

NVIDIA 模型修复: 实测原配置的 3 个模型均不可用(asset_id 引用 500/400, 不支持视频),
改用 nemotron-3-nano-omni 的 base64 内嵌视频方式(唯一实测打通), 加 max_base64_mb
防止对大文件做注定失败的编码。

Gemini 多 Key 轮换: 支持 extra_api_keys 配置多个独立项目的 key, 配额用尽时依次
换 key 重试(每换 key 需重新上传, Files API 按项目隔离)。

稳定性加固: CircuitBreaker HALF_OPEN 清空旧失败计数(修复探测一失败就重新 OPEN 的
bug); chat() 统一接入熔断器(原来只有视频分析路径检查); NVIDIA 适配器改用共享
json_parser(原来自己重复实现且不做 schema 校验); Gemini Files API 上传超时也尝试
清理远程孤儿文件; video_processor/video_queue 里直接操作 OracleDB._conn 的裸 SQL
改走新增的 set_event_start_time/mark_video_invalid/reset_video_to_pending 方法;
/health 加入队列线程存活状态; 密钥改用 ${ENV_VAR} 引用(.env 已支持自动加载),
不再明文写入 config.yaml。

工程质量: 新增 fam-edge/tests(32 个单元测试, 覆盖熔断器状态机/JSON 解析容错/
时间戳解析/bbox 坐标换算/多 key 解析), 新增 scripts/smoke_test.py(发版前接口
稳定性检查); 清理死代码(OllamaAdapter.analyze_frames、get_sync_delta 死分支、
未使用的 vision_timeout/max_concurrent_tasks 配置项); 修正 get_events_for_label
排序(改最近优先 + 过滤畸形历史时间戳)。

已部署 Oracle 并跑通 smoke test 全部 6 项检查。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-22 00:22:57 +08:00
parent 677c5bdcc7
commit 61db82cb9b
21 changed files with 1272 additions and 399 deletions

View File

@@ -48,6 +48,8 @@ def build_adapters(configs: List[dict]) -> List[BaseModelAdapter]:
def register_adapter(provider_name: str, adapter_cls):
"""注册新适配器(扩展使用)"""
"""注册新适配器(扩展点,供插件式新增 provider 用,无需改这个文件本身。
当前没有调用方——新模型目前都是直接改 _ADAPTER_REGISTRY保留此函数是为了
以后接入第三方/可插拔适配器时不用再改工厂代码)。"""
_ADAPTER_REGISTRY[provider_name] = adapter_cls
logger.info(f"适配器已注册: {provider_name}")

View File

@@ -46,6 +46,7 @@ class CircuitBreaker:
with self._lock:
if self.state == 'OPEN' and self.last_failure and time.time() - self.last_failure > self.cooldown:
self.state = 'HALF_OPEN'
self.failures.clear() # 清空旧失败记录,避免探测调用一失败就被旧记录凑数重新 OPEN
return self.state == 'OPEN'
def __repr__(self):

View File

@@ -8,6 +8,12 @@ provider_name = "gemini"
熔断器: 启用
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
本地不切片、不抽帧Gemini 原生支持长视频)
多 Key 轮换2026-08-21 新增): 不同 Google Cloud 项目的 API Key 各自独立计费/配额,
config 的 `api_key` 为主 Key`extra_api_keys` 可以再配多个(各自项目的 Key
outer loop 按 Key 顺序尝试inner loop 才是原有的模型 fallback 链——因为 Gemini
Files API 上传的文件只能被同一个 Key/项目引用,换 Key 必须重新上传,所以每个 Key
都要重走一遍"上传 -> 模型链尝试 -> 删除",不是简单地在同一次上传后换 key 调用。
"""
import os
import time
@@ -34,7 +40,16 @@ class GeminiAdapter(BaseModelAdapter):
self.model_name = config.get('model_name', 'gemini-flash-latest')
self.model_chain = [self.model_name] + [
m for m in config.get('fallback_models', []) if m and m != self.model_name]
self.api_key = self._resolve_key(config.get('api_key', ''))
# 多 Key 轮换:主 key + extra_api_keys各自独立项目/配额),去重保序
raw_keys = [config.get('api_key', '')] + list(config.get('extra_api_keys', []) or [])
seen = set()
self.api_keys = []
for k in raw_keys:
resolved = self._resolve_key(k)
if resolved and resolved not in seen:
seen.add(resolved)
self.api_keys.append(resolved)
self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
self.timeout = config.get('timeout', 600)
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
@@ -84,58 +99,68 @@ class GeminiAdapter(BaseModelAdapter):
if self._cb.is_open():
logger.warning("Gemini 熔断器 OPEN跳过视频分析")
return None
if not self.api_key:
if not self.api_keys:
logger.warning("Gemini API Key 未配置,跳过视频分析")
return None
if not os.path.isfile(video_path):
logger.warning(f"Gemini 视频文件不存在: {video_path}")
return None
file_uri = self._upload_file(video_path)
if not file_uri:
self._cb.record_failure()
return None
prompt = self._build_video_prompt(known_members_context, event_start_time)
try:
# 3 秒密度 + person_appearances 特征使输出 JSON 较大max_tokens 需足够高防截断
text = self._generate_video(file_uri, prompt, max_tokens=16384, temperature=0.2)
if text is None:
self._cb.record_failure()
return None
last_err = "no_key_available"
for idx, key in enumerate(self.api_keys):
file_uri, file_name = self._upload_file(video_path, key)
if not file_uri:
self._delete_file(file_name, key) # 即使未等到 ACTIVE也尽力清理
last_err = f"key{idx}_upload_failed"
continue
try:
result = parse_vlm_json(text)
result = self._normalize(result)
if not result or 'events' not in result:
logger.error(f"Gemini 视频输出缺少 events: {text[:150]}")
self._cb.record_failure()
return None
result['compute_provider'] = 'gemini'
self._cb.record_success()
logger.info(f"Gemini 整视频分析完成events={len(result.get('events', []))}")
return result
except VLMOutputInvalidError as e:
logger.error(f"Gemini 视频输出无法解析为 JSON: {e}")
self._cb.record_failure()
return None
except requests.Timeout:
logger.warning(f"Gemini 视频分析超时 ({self.timeout}s)")
self._cb.record_failure()
return None
except Exception as e:
logger.error(f"Gemini 视频分析异常: {e}")
self._cb.record_failure()
return None
finally:
self._delete_file(file_uri)
# 3 秒密度 + person_appearances 特征使输出 JSON 较大max_tokens 需足够高防截断
text = self._generate_video(file_uri, prompt, key,
max_tokens=16384, temperature=0.2)
if text is None:
last_err = f"key{idx}_all_models_failed"
continue
try:
result = parse_vlm_json(text)
result = self._normalize(result)
if not result or 'events' not in result:
logger.error(f"Gemini 视频输出缺少 events: {text[:150]}")
last_err = f"key{idx}_missing_events"
continue
result['compute_provider'] = 'gemini'
self._cb.record_success()
logger.info(f"Gemini 视频分析完成key[{idx}]"
f"events={len(result.get('events', []))}")
return result
except VLMOutputInvalidError as e:
logger.error(f"Gemini 视频输出无法解析为 JSON: {e}")
last_err = f"key{idx}_invalid_json"
continue
except requests.Timeout:
logger.warning(f"Gemini key[{idx}] 视频分析超时 ({self.timeout}s)")
last_err = f"key{idx}_timeout"
except Exception as e:
logger.error(f"Gemini key[{idx}] 视频分析异常: {e}")
last_err = f"key{idx}_exception"
finally:
self._delete_file(file_name, key)
self._cb.record_failure()
logger.error(f"Gemini 全部 {len(self.api_keys)} 个 Key 均失败: {last_err}")
return None
def _upload_file(self, video_path: str) -> Optional[str]:
"""用 Files API resumable 可续传协议上传完整视频,返回可引用 URI。"""
def _upload_file(self, video_path: str, api_key: str):
"""用 Files API resumable 可续传协议上传完整视频(用指定 api_key 对应的项目)。
返回 (uri, file_name) 元组uri 在文件 ACTIVE 前不可用时为 None但只要 Gemini
端已经创建了文件file_name 非空),就应该用 file_name 尝试清理,避免超时留下
孤儿文件Google 端存储永远不会被删除)。
"""
name = os.path.basename(video_path)
size = os.path.getsize(video_path)
upload_timeout = max(self.timeout, 900)
# 上传端点必须是 /upload/v1beta/files/v1beta/files 只是元数据端点,不接受上传协议)
base = f"https://generativelanguage.googleapis.com/upload/v1beta/files?key={self.api_key}"
base = f"https://generativelanguage.googleapis.com/upload/v1beta/files?key={api_key}"
# 1) 创建可续传上传会话
try:
r0 = requests.post(
@@ -153,14 +178,14 @@ class GeminiAdapter(BaseModelAdapter):
)
except Exception as e:
logger.error(f"Gemini 创建上传会话异常: {e}")
return None
return None, None
if r0.status_code not in (200, 201):
logger.warning(f"Gemini 创建上传会话失败 HTTP {r0.status_code}: {r0.text[:200]}")
return None
return None, None
session_url = r0.headers.get('X-Goog-Upload-URL')
if not session_url:
logger.warning("Gemini 上传响应缺少 X-Goog-Upload-URL")
return None
return None, None
# 2) 上传文件体(流式)
try:
with open(video_path, 'rb') as f:
@@ -177,13 +202,13 @@ class GeminiAdapter(BaseModelAdapter):
)
except requests.Timeout:
logger.warning(f"Gemini 文件上传超时 ({upload_timeout}s)")
return None
return None, None
except Exception as e:
logger.error(f"Gemini 文件上传异常: {e}")
return None
return None, None
if resp.status_code not in (200, 201):
logger.warning(f"Gemini 文件上传失败 HTTP {resp.status_code}: {resp.text[:200]}")
return None
return None, None
try:
info = resp.json().get('file', {})
uri = info.get('uri')
@@ -191,16 +216,16 @@ class GeminiAdapter(BaseModelAdapter):
state = info.get('state')
except (ValueError, KeyError):
logger.warning("Gemini 文件上传响应解析失败")
return None
return None, None
if not uri:
return None
return None, file_name
# 等待 ACTIVE大文件可能还在处理
if state != 'ACTIVE' and file_name:
uri = self._wait_active(file_name)
return uri
uri = self._wait_active(file_name, api_key)
return uri, file_name
def _wait_active(self, file_name: str, max_wait: int = 120) -> Optional[str]:
url = f"{self._base_url}/{file_name}?key={self.api_key}"
def _wait_active(self, file_name: str, api_key: str, max_wait: int = 120) -> Optional[str]:
url = f"{self._base_url}/{file_name}?key={api_key}"
deadline = time.time() + max_wait
while time.time() < deadline:
try:
@@ -215,16 +240,17 @@ class GeminiAdapter(BaseModelAdapter):
logger.warning(f"Gemini 文件 {file_name} 未在 {max_wait}s 内 ACTIVE")
return None
def _delete_file(self, file_uri: str):
if not file_uri or 'files/' not in file_uri:
def _delete_file(self, file_name: str, api_key: str):
"""按 Files API 的 file_name'files/abc123')删除远程文件,尽力而为。"""
if not file_name:
return
name = file_uri.split('files/', 1)[-1]
name = file_name.split('files/', 1)[-1] if 'files/' in file_name else file_name
try:
requests.delete(f"{self._base_url}/files/{name}?key={self.api_key}", timeout=15)
requests.delete(f"{self._base_url}/files/{name}?key={api_key}", timeout=15)
except Exception:
pass
def _generate_video(self, file_uri: str, prompt: str,
def _generate_video(self, file_uri: str, prompt: str, api_key: str,
max_tokens: int, temperature: float) -> Optional[str]:
"""带模型 fallback 链的 generateContent视频文件引用调用。"""
parts = [
@@ -241,7 +267,7 @@ class GeminiAdapter(BaseModelAdapter):
t0 = time.time()
try:
resp = requests.post(
f"{self._base_url}/models/{model}:generateContent?key={self.api_key}",
f"{self._base_url}/models/{model}:generateContent?key={api_key}",
json={"contents": [{"parts": parts}],
"generationConfig": {
"temperature": temperature,
@@ -339,44 +365,53 @@ class GeminiAdapter(BaseModelAdapter):
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
if not self.api_key:
if self._cb.is_open():
logger.warning("Gemini 熔断器 OPEN跳过问答")
return None
if not self.api_keys:
logger.warning("Gemini API Key 未配置,跳过问答")
return None
try:
return self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
result = self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
except Exception as e:
logger.error(f"Gemini 问答异常: {e}")
return None
result = None
if result:
self._cb.record_success()
else:
self._cb.record_failure()
return result
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
"""纯文本 generateContent(复用模型 fallback 链"""
for model in self.model_chain:
try:
resp = requests.post(
f"{self._base_url}/models/{model}:generateContent?key={self.api_key}",
json={"contents": [{"parts": [{"text": text}]}],
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens}},
timeout=self.timeout
)
except requests.Timeout:
logger.warning(f"Gemini [{model}] 问答超时")
continue
except Exception as e:
logger.error(f"Gemini [{model}] 问答异常: {e}")
continue
if resp.status_code == 200:
cands = resp.json().get('candidates', [])
out = ''.join(
p.get('text', '')
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
).strip() if cands else ''
if out:
return out
elif resp.status_code == 429:
logger.warning(f"Gemini [{model}] 429切换模型")
continue
"""纯文本 generateContent,按 key 轮换 × 模型 fallback 链依次尝试"""
for idx, api_key in enumerate(self.api_keys):
for model in self.model_chain:
try:
resp = requests.post(
f"{self._base_url}/models/{model}:generateContent?key={api_key}",
json={"contents": [{"parts": [{"text": text}]}],
"generationConfig": {
"temperature": temperature,
"maxOutputTokens": max_tokens}},
timeout=self.timeout
)
except requests.Timeout:
logger.warning(f"Gemini key[{idx}] [{model}] 问答超时")
continue
except Exception as e:
logger.error(f"Gemini key[{idx}] [{model}] 问答异常: {e}")
continue
if resp.status_code == 200:
cands = resp.json().get('candidates', [])
out = ''.join(
p.get('text', '')
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
).strip() if cands else ''
if out:
return out
elif resp.status_code == 429:
logger.warning(f"Gemini key[{idx}] [{model}] 429切换下一模型/Key")
continue
return None
def get_timeout(self) -> int:

View File

@@ -2,17 +2,29 @@
NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
provider_name = "nvidia"
模型: nvidia/nemotron-nano-12b-v2-vlNIM 官方支持整视频 video_url 输入,内部自行采样帧
模型: nvidia/nemotron-3-nano-omni-30b-a3b-reasoning唯一实测确认可用的视频理解模型
角色: vision (整视频直出结构化 JSON) + 智能问答
SDK: openai (NIM 兼容 OpenAI API 规范)
整视频分析: 先经 NVIDIA Assets API 上传完整视频拿 asset_id再以 video_url 引用单次调用
—— 本地不切片、不抽帧request payload 有 25MB 上限base64 直塞不可行,必须走 Assets API
整视频分析实测结论2026-08-21 用真实短视频逐个探测):
- nemotron-3-nano-omni-30b-a3b-reasoning: video_url 只认 base64 data URI
`data:video/mp4;base64,<...>`Assets API 的 asset_id 引用方式对它直接 500
(报错 "Only base64 data URLs are supported for now")——所以本适配器不再走
Assets API 上传,直接 base64 内嵌整段视频。
- nemotron-nano-12b-v2-vl: 需要走 NVCF 函数调用协议本身的 NVCF-ASSET-DIR/
NVCF-FUNCTION-ASSET-IDS 请求头,而这两个头的值是 NVCF 服务端按内部路径生成、
不是客户端能自己拼对的(实测传什么都 400 "Invalid NVCF-ASSET-DIR"),标准
OpenAI 兼容 chat.completions 调用打不通,已从模型链移除。
- meta/llama-3.2-11b-vision-instruct: 明确不支持视频输入("At most 0 video(s)
may be provided"),只能单图,已移除。
base64 方案的代价是请求体大小受限(原实现注释称约 25MB 上限),所以本适配器会在
上传前检查文件大小,超过 `max_base64_mb`(默认 20MB直接放弃不做注定失败的
慢速编码+上传。真实监控视频压缩后通常在 20MB 上下,属于"够不到就正常降级到失败
重试",不是本地故意限制过窄。
"""
import base64
import os
import json
import re
import time
import requests
from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional
@@ -20,6 +32,7 @@ from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
from ..logger import setup_logger
from ..ai_orchestrator.prompts import build_video_prompt
from ..ai_orchestrator.json_parser import parse_vlm_json, VLMOutputInvalidError
from ..config_loader import load_config
logger = setup_logger('fam-edge.nvidia_adapter')
@@ -43,12 +56,13 @@ class NvidiaVisionAdapter(BaseModelAdapter):
def __init__(self, config: dict):
super().__init__("nvidia", config)
self.model_name = config.get(
'model_name', 'nvidia/nemotron-nano-12b-v2-vl')
'model_name', 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning')
self.model_chain = [self.model_name] + [
m for m in config.get('fallback_models', []) if m and m != self.model_name]
self.api_key = self._resolve_key(config.get('api_key', ''))
self.base_url = config.get('base_url', 'https://integrate.api.nvidia.com/v1')
self.timeout = config.get('timeout', 600)
self.max_base64_mb = float(config.get('max_base64_mb', 20))
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
self.model_timeouts = {
str(k): int(v) for k, v in (config.get('model_timeouts') or {}).items()}
@@ -86,63 +100,8 @@ class NvidiaVisionAdapter(BaseModelAdapter):
return False
# ------------------------------------------------------------------
# 整视频分析:Assets API 上传 -> video_url(asset_id) 单次调用
# 整视频分析:base64 内嵌 video_url 单次调用omni 只认 base64不认 asset_id 引用)
# ------------------------------------------------------------------
ASSET_API = "https://api.nvcf.nvidia.com/v2/nvcf/assets"
def _upload_asset(self, video_path: str) -> Optional[str]:
"""用 NVIDIA Assets API 上传大视频文件,返回 asset_id 供 video_url 引用。"""
content_type = "video/mp4"
upload_timeout = max(self.timeout, 900)
try:
r = requests.post(
self.ASSET_API,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json={"contentType": content_type, "description": "fam-edge video asset"},
timeout=60,
)
except Exception as e:
logger.warning(f"NVIDIA 创建 asset 异常: {e}")
return None
if r.status_code not in (200, 201):
logger.warning(f"NVIDIA 创建 asset 失败 HTTP {r.status_code}: {r.text[:200]}")
return None
try:
j = r.json()
asset_id = j.get("assetId")
upload_url = j.get("uploadUrl")
except ValueError:
logger.warning("NVIDIA asset 响应解析失败")
return None
if not asset_id or not upload_url:
logger.warning("NVIDIA asset 响应缺少 assetId/uploadUrl")
return None
try:
with open(video_path, 'rb') as f:
up = requests.put(
upload_url,
data=f,
# 必须全小写 header 名且 content-type 值与 POST 的 contentType 一致:
# 预签名 S3 URL 签名覆盖这两个值,不一致会 SignatureDoesNotMatch
headers={"content-type": content_type,
"x-amz-meta-nvcf-asset-description": "fam-edge video asset"},
timeout=upload_timeout,
)
except requests.Timeout:
logger.warning(f"NVIDIA 上传 asset 超时 ({upload_timeout}s)")
return None
except Exception as e:
logger.warning(f"NVIDIA 上传 asset 异常: {e}")
return None
if up.status_code not in (200, 201):
logger.warning(f"NVIDIA 上传 asset 失败 HTTP {up.status_code}: {up.text[:200]}")
return None
logger.info(f"NVIDIA asset 上传成功: {asset_id}")
return asset_id
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
@@ -156,10 +115,18 @@ class NvidiaVisionAdapter(BaseModelAdapter):
logger.warning(f"NVIDIA 视频文件不存在: {video_path}")
return None
# asset 只上传一次,模型链内复用同一 assetId
asset_id = self._upload_asset(video_path)
if not asset_id:
self._cb.record_failure()
size_mb = os.path.getsize(video_path) / (1024 * 1024)
if size_mb > self.max_base64_mb:
logger.warning(
f"NVIDIA 视频 {size_mb:.1f}MB 超过 base64 上限 {self.max_base64_mb}MB"
"跳过(不做注定失败的慢速编码)")
return None
try:
with open(video_path, 'rb') as f:
video_b64 = base64.b64encode(f.read()).decode()
except Exception as e:
logger.warning(f"NVIDIA 读取/编码视频失败: {e}")
return None
prompt = self._build_video_prompt(known_members_context, event_start_time)
@@ -176,7 +143,7 @@ class NvidiaVisionAdapter(BaseModelAdapter):
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "video_url", "video_url": {
"url": f"data:video/mp4;asset_id={asset_id}"}}
"url": f"data:video/mp4;base64,{video_b64}"}}
]}],
temperature=0.2,
max_tokens=16384,
@@ -192,22 +159,19 @@ class NvidiaVisionAdapter(BaseModelAdapter):
last_err = f"{model}_empty"
self._sleep_switch(idx)
continue
data = self._parse_json(content)
if not data or 'events' not in data:
try:
data = parse_vlm_json(content)
except VLMOutputInvalidError as e:
self._emit_model_call(model, started, duration, False, "json_parse_failed")
logger.warning(f"NVIDIA [{model}] JSON 解析失败,切换下一模型: {content[:120]}")
logger.warning(f"NVIDIA [{model}] JSON 解析失败,切换下一模型: {e}")
last_err = f"{model}_json"
self._sleep_switch(idx)
continue
self._emit_model_call(model, started, duration, True)
self._cb.record_success()
logger.info(f"NVIDIA [{model}] 整视频分析完成events={len(data.get('events', []))}")
return {
"global_summary": str(data.get('global_summary', '')),
"events": data.get('events', []),
"people_mentioned": data.get('people_mentioned', []),
"compute_provider": f"nvidia:{model}",
}
data['compute_provider'] = f"nvidia:{model}"
return data
except Exception as e:
duration = time.time() - t0
self._emit_model_call(model, started, duration, False, str(e))
@@ -224,27 +188,6 @@ class NvidiaVisionAdapter(BaseModelAdapter):
logger.info(f"NVIDIA 等待 {self.switch_interval_sec}s 后切换下一模型")
time.sleep(self.switch_interval_sec)
@staticmethod
def _parse_json(content: str) -> Optional[dict]:
content = content.strip()
try:
return json.loads(content)
except json.JSONDecodeError:
pass
fence = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
if fence:
try:
return json.loads(fence.group(1))
except json.JSONDecodeError:
pass
brace = re.search(r'\{.*\}', content, re.DOTALL)
if brace:
try:
return json.loads(brace.group(0))
except json.JSONDecodeError:
pass
return None
def _build_video_prompt(self, known_members: str, event_start_time: str) -> str:
camera = load_config().get('gdrive_sync', {}).get('camera_name', '')
return build_video_prompt(known_members, event_start_time, camera)
@@ -253,22 +196,29 @@ class NvidiaVisionAdapter(BaseModelAdapter):
# 智能问答:纯文本
# ------------------------------------------------------------------
def chat(self, prompt: str, max_tokens: int = 2048) -> Optional[str]:
if self._cb.is_open():
logger.warning("NVIDIA 熔断器 OPEN跳过问答")
return None
if self._client is None:
logger.warning("NVIDIA 客户端未初始化,跳过问答")
return None
try:
resp = self._client.chat.completions.create(
model=self.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=max_tokens,
timeout=self.timeout
)
content = resp.choices[0].message.content
return content.strip() if content else None
except Exception as e:
logger.warning(f"NVIDIA 问答异常: {e}")
return None
for model in self.model_chain:
try:
resp = self._client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=max_tokens,
timeout=self.timeout
)
content = resp.choices[0].message.content
if content:
self._cb.record_success()
return content.strip()
except Exception as e:
logger.warning(f"NVIDIA [{model}] 问答异常: {e}")
self._cb.record_failure()
return None
def get_timeout(self) -> int:
return self.timeout

View File

@@ -7,9 +7,8 @@ provider_name = "ollama"
健康检查: GET /api/tags
不参与视觉分析、不参与视频结构化输出(云端 VLM 直出)
"""
import base64
import requests
from typing import Dict, List, Optional
from typing import Dict, Optional
from .base_adapter import BaseModelAdapter
from .circuit_breaker import CircuitBreaker
@@ -54,63 +53,6 @@ class OllamaAdapter(BaseModelAdapter):
logger.error(f"Ollama 健康检查异常: {e}")
return False
def analyze_frames(self, frame_paths: List[str],
frame_timestamps: List[str],
known_members_context: str) -> Optional[str]:
"""调用 Ollama 视觉分析"""
if self._cb.is_open():
logger.warning("Ollama 熔断器 OPEN跳过调用")
return None
# 构建 Prompt
n = len(frame_paths)
prompt = self._build_visual_prompt(n, frame_timestamps, known_members_context)
# 读取图片并 Base64 编码
images = []
for path in frame_paths:
try:
with open(path, 'rb') as f:
images.append(base64.b64encode(f.read()).decode('utf-8'))
except Exception as e:
logger.error(f"读取图片失败 {path}: {e}")
if not images:
logger.error("没有可用的图片帧")
return None
try:
resp = requests.post(
f"{self.base_url}/api/generate",
json={
"model": self.model_name,
"prompt": prompt,
"images": images,
"stream": False,
"options": {"temperature": 0.2, "top_p": 0.8, "num_predict": self.num_predict}
},
timeout=self.timeout
)
if resp.status_code == 200:
output = resp.json().get('response', '')
self._cb.record_success()
logger.info(f"Ollama 视觉分析完成,输出长度={len(output)}")
return output
else:
logger.error(f"Ollama 调用失败: {resp.status_code} {resp.text[:200]}")
self._cb.record_failure()
return None
except requests.Timeout:
logger.error(f"Ollama 调用超时 ({self.timeout}s)")
self._cb.record_failure()
return None
except Exception as e:
logger.error(f"Ollama 调用异常: {e}")
self._cb.record_failure()
return None
def analyze_video(self, video_path: str,
known_members_context: str,
event_start_time: str = '') -> Optional[Dict]:
@@ -158,27 +100,3 @@ class OllamaAdapter(BaseModelAdapter):
logger.error(f"Ollama 问答异常: {e}")
self._cb.record_failure()
return None
def _build_visual_prompt(self, n: int, timestamps: List[str], known_members: str) -> str:
"""构建视觉分析 Prompt"""
ts_lines = '\n'.join(
f"[图片{i+1}] 时间: {ts}" for i, ts in enumerate(timestamps)
)
return f"""你是家庭监控视频分析助手。请按时间顺序描述下列 {n} 张图片中可见的内容,只描述客观画面,不要猜测或推测。
每张图片对应的时间戳如下:
{ts_lines}
每张图片需报告:
1. 人物:数量、衣着(颜色+类型)、可见动作
2. 物品:玩具、奶瓶、家具等显眼物品
3. 互动:人与人、人与物品之间的互动
已知家庭成员清单(按特征匹配,匹配成功用 real_name未匹配用"人物X"标识):
{known_members or '(暂无已知成员)'}
输出格式(纯文本,每张图片一段,保留时间戳标记):
[图片1] 时间: {timestamps[0] if timestamps else ''}
内容: ...
要求简洁、客观。不要输出 JSON不要输出 markdown。"""