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:
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,17 +2,29 @@
|
||||
NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
|
||||
|
||||
provider_name = "nvidia"
|
||||
模型: nvidia/nemotron-nano-12b-v2-vl(NIM 官方支持整视频 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
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
Reference in New Issue
Block a user