374 lines
16 KiB
Python
374 lines
16 KiB
Python
"""
|
||
GeminiAdapter - Google Gemini 云端 VLM 适配器
|
||
|
||
provider_name = "gemini"
|
||
模型: gemini-flash-latest
|
||
角色: vision (整视频直出结构化 JSON) + 智能问答
|
||
健康检查: GET /v1beta/models?key=...
|
||
熔断器: 启用
|
||
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
|
||
(本地不切片、不抽帧;Gemini 原生支持长视频)
|
||
"""
|
||
import os
|
||
import time
|
||
import json
|
||
import requests
|
||
from typing import Dict, List, Optional
|
||
|
||
from .base_adapter import BaseModelAdapter
|
||
from .circuit_breaker import CircuitBreaker
|
||
from ..logger import setup_logger
|
||
from ..ai_orchestrator.json_parser import parse_vlm_json, VLMOutputInvalidError
|
||
|
||
logger = setup_logger('fam-edge.gemini_adapter')
|
||
|
||
|
||
class GeminiAdapter(BaseModelAdapter):
|
||
"""Gemini 云端 VLM 适配器 (整视频直出结构化 JSON + 文本问答)"""
|
||
|
||
def __init__(self, config: dict):
|
||
super().__init__("gemini", config)
|
||
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', ''))
|
||
self.timeout = config.get('timeout', 600)
|
||
cb_cfg = config.get('circuit_breaker', {})
|
||
self._cb = CircuitBreaker(
|
||
threshold=cb_cfg.get('threshold', 3),
|
||
cooldown=cb_cfg.get('cooldown', 600),
|
||
enabled=cb_cfg.get('enabled', True)
|
||
)
|
||
self._base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||
|
||
def _resolve_key(self, raw: str) -> str:
|
||
if raw.startswith('${') and raw.endswith('}'):
|
||
return os.environ.get(raw[2:-1], '')
|
||
return raw
|
||
|
||
def health_check(self) -> bool:
|
||
if not self.api_key:
|
||
logger.warning("Gemini API Key 未配置,健康检查失败")
|
||
return False
|
||
try:
|
||
resp = requests.get(
|
||
f"{self._base_url}/models?key={self.api_key}", timeout=10)
|
||
if resp.status_code == 200:
|
||
models = resp.json().get('models', [])
|
||
names = [m.get('name', '') for m in models]
|
||
missing = [m for m in self.model_chain if not any(m in n for n in names)]
|
||
if not missing:
|
||
logger.info(f"Gemini 健康检查通过: {' -> '.join(self.model_chain)}")
|
||
return True
|
||
logger.warning(f"Gemini 模型未找到: {missing}; 可用: {names[:5]}")
|
||
return len(missing) < len(self.model_chain)
|
||
logger.warning(f"Gemini 健康检查 HTTP {resp.status_code}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"Gemini 健康检查异常: {e}")
|
||
return False
|
||
|
||
# ------------------------------------------------------------------
|
||
# 整视频分析:Files API 上传 -> generateContent
|
||
# ------------------------------------------------------------------
|
||
def analyze_video(self, video_path: str,
|
||
known_members_context: str,
|
||
event_start_time: str = '') -> Optional[Dict]:
|
||
if self._cb.is_open():
|
||
logger.warning("Gemini 熔断器 OPEN,跳过视频分析")
|
||
return None
|
||
if not self.api_key:
|
||
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:
|
||
text = self._generate_video(file_uri, prompt, max_tokens=4096, temperature=0.2)
|
||
if text is None:
|
||
self._cb.record_failure()
|
||
return None
|
||
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)
|
||
|
||
def _upload_file(self, video_path: str) -> Optional[str]:
|
||
"""用 Files API resumable 可续传协议上传完整视频,返回可引用 URI。"""
|
||
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}"
|
||
# 1) 创建可续传上传会话
|
||
try:
|
||
r0 = requests.post(
|
||
base,
|
||
headers={
|
||
"X-Goog-Upload-Protocol": "resumable",
|
||
"X-Goog-Upload-Command": "start",
|
||
"X-Goog-Upload-Header-Content-Length": str(size),
|
||
"X-Goog-Upload-Header-Content-Type": "video/mp4",
|
||
"X-Goog-Upload-File-Name": name,
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={"file": {"display_name": name}},
|
||
timeout=60,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Gemini 创建上传会话异常: {e}")
|
||
return None
|
||
if r0.status_code not in (200, 201):
|
||
logger.warning(f"Gemini 创建上传会话失败 HTTP {r0.status_code}: {r0.text[:200]}")
|
||
return None
|
||
session_url = r0.headers.get('X-Goog-Upload-URL')
|
||
if not session_url:
|
||
logger.warning("Gemini 上传响应缺少 X-Goog-Upload-URL")
|
||
return None
|
||
# 2) 上传文件体(流式)
|
||
try:
|
||
with open(video_path, 'rb') as f:
|
||
resp = requests.post(
|
||
session_url,
|
||
headers={
|
||
"Content-Length": str(size),
|
||
"X-Goog-Upload-Command": "upload, finalize",
|
||
"X-Goog-Upload-Offset": "0",
|
||
"Content-Type": "video/mp4",
|
||
},
|
||
data=f,
|
||
timeout=upload_timeout,
|
||
)
|
||
except requests.Timeout:
|
||
logger.warning(f"Gemini 文件上传超时 ({upload_timeout}s)")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"Gemini 文件上传异常: {e}")
|
||
return None
|
||
if resp.status_code not in (200, 201):
|
||
logger.warning(f"Gemini 文件上传失败 HTTP {resp.status_code}: {resp.text[:200]}")
|
||
return None
|
||
try:
|
||
info = resp.json().get('file', {})
|
||
uri = info.get('uri')
|
||
file_name = info.get('name')
|
||
state = info.get('state')
|
||
except (ValueError, KeyError):
|
||
logger.warning("Gemini 文件上传响应解析失败")
|
||
return None
|
||
if not uri:
|
||
return None
|
||
# 等待 ACTIVE(大文件可能还在处理)
|
||
if state != 'ACTIVE' and file_name:
|
||
uri = self._wait_active(file_name)
|
||
return uri
|
||
|
||
def _wait_active(self, file_name: str, max_wait: int = 120) -> Optional[str]:
|
||
url = f"{self._base_url}/{file_name}?key={self.api_key}"
|
||
deadline = time.time() + max_wait
|
||
while time.time() < deadline:
|
||
try:
|
||
r = requests.get(url, timeout=15)
|
||
if r.status_code == 200:
|
||
j = r.json()
|
||
if j.get('state') == 'ACTIVE':
|
||
return j.get('uri')
|
||
except Exception:
|
||
pass
|
||
time.sleep(5)
|
||
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:
|
||
return
|
||
name = file_uri.split('files/', 1)[-1]
|
||
try:
|
||
requests.delete(f"{self._base_url}/files/{name}?key={self.api_key}", timeout=15)
|
||
except Exception:
|
||
pass
|
||
|
||
def _generate_video(self, file_uri: str, prompt: str,
|
||
max_tokens: int, temperature: float) -> Optional[str]:
|
||
"""带模型 fallback 链的 generateContent(视频文件引用)调用。"""
|
||
parts = [
|
||
{"file_data": {"mime_type": "video/mp4", "file_uri": file_uri}},
|
||
{"text": prompt},
|
||
]
|
||
for model in self.model_chain:
|
||
for attempt in range(2):
|
||
if attempt == 0:
|
||
logger.info(f"Gemini [{model}] 本轮请求超时 {self.timeout}s")
|
||
try:
|
||
resp = requests.post(
|
||
f"{self._base_url}/models/{model}:generateContent?key={self.api_key}",
|
||
json={"contents": [{"parts": parts}],
|
||
"generationConfig": {
|
||
"temperature": temperature,
|
||
"maxOutputTokens": max_tokens}},
|
||
timeout=self.timeout
|
||
)
|
||
except requests.Timeout:
|
||
logger.warning(f"Gemini [{model}] 视频请求超时 ({self.timeout}s)")
|
||
break
|
||
except Exception as e:
|
||
logger.error(f"Gemini [{model}] 视频请求异常: {e}")
|
||
break
|
||
|
||
if resp.status_code == 200:
|
||
cands = resp.json().get('candidates', [])
|
||
text = ''.join(
|
||
p.get('text', '')
|
||
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
|
||
).strip() if cands else ''
|
||
if text:
|
||
if model != self.model_name:
|
||
logger.info(f"Gemini 主模型不可用,由 fallback [{model}] 出结果")
|
||
return text
|
||
logger.warning(f"Gemini [{model}] 返回空文本")
|
||
continue
|
||
detail = resp.text[:150].replace('\n', ' ')
|
||
if resp.status_code == 429:
|
||
logger.warning(f"Gemini [{model}] 429 配额耗尽,切换下一模型")
|
||
break
|
||
if resp.status_code == 503:
|
||
if attempt == 0:
|
||
logger.warning(f"Gemini [{model}] 503 过载,3s 后重试")
|
||
time.sleep(3)
|
||
continue
|
||
break
|
||
logger.warning(f"Gemini [{model}] HTTP {resp.status_code}: {detail}")
|
||
break
|
||
return None
|
||
|
||
@staticmethod
|
||
def _normalize(result: dict) -> dict:
|
||
"""统一字段名:frame_details -> events(兼容旧结构)。"""
|
||
events = result.get('events')
|
||
if events is None and 'frame_details' in result:
|
||
events = []
|
||
for f in result['frame_details']:
|
||
events.append({
|
||
"timestamp": f.get('frame_timestamp', ''),
|
||
"description": f.get('action', ''),
|
||
"people": [f.get('person', '')] if f.get('person') else [],
|
||
"is_attention_event": bool(f.get('is_attention_event', False)),
|
||
})
|
||
if events is None:
|
||
events = []
|
||
people = result.get('people_mentioned') or result.get('entities_json') or []
|
||
if isinstance(people, list) and people and isinstance(people[0], dict):
|
||
people = [p.get('person', '') for p in people]
|
||
people = [p for p in people if p]
|
||
return {
|
||
"global_summary": result.get('global_summary', ''),
|
||
"events": events,
|
||
"people_mentioned": people,
|
||
}
|
||
|
||
def _build_video_prompt(self, known_members: str, event_start_time: str) -> str:
|
||
start_hint = ""
|
||
if event_start_time:
|
||
start_hint = f"\n视频开始时间(北京时间)约为:{event_start_time}。请据此推算每个事件的绝对时间戳。"
|
||
return f"""你是家庭监控视频分析助手。下面是一段完整监控录像(已整段上传)。
|
||
请观看整段视频,提取其中有用的信息,只输出合法 JSON(不要 markdown、不要任何解释文字),结构如下:
|
||
|
||
{{
|
||
"global_summary": "整个时段的整体摘要,简体中文,2-4 句,客观描述人物与主要活动",
|
||
"events": [
|
||
{{
|
||
"timestamp": "事件发生时的绝对北京时间(格式 YYYY-MM-DD HH:MM:SS)",
|
||
"description": "该时间点的画面/动作信息摘要(谁、在做什么、位置)",
|
||
"people": ["出现在该时刻的人物,用已知成员真名或'人物A'/'人物B'"],
|
||
"is_attention_event": false
|
||
}}
|
||
],
|
||
"people_mentioned": ["本视频出现过的所有人物标识/真名"]
|
||
}}{start_hint}
|
||
|
||
规则:
|
||
1. 只描述客观画面,不要猜测或想象。
|
||
2. events 提取视频中"有意义的时间点"(人物出现/动作变化/异常),不要逐秒罗列;timestamp 用绝对北京时间。
|
||
3. 已知家庭成员(按特征匹配,匹配到用 real_name,否则用"人物X"):
|
||
{known_members or '(暂无已知成员)'}
|
||
4. is_attention_event:是否为跌倒、危险、异常哭闹等需关注事件(没有则为 false)。
|
||
5. 没有人物出现的时段不要单独成 event;people 留空数组。"""
|
||
|
||
# ------------------------------------------------------------------
|
||
# 智能问答:纯文本
|
||
# ------------------------------------------------------------------
|
||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||
if not self.api_key:
|
||
logger.warning("Gemini API Key 未配置,跳过问答")
|
||
return None
|
||
try:
|
||
return self._generate_text(prompt, max_tokens=max_tokens, temperature=0.3)
|
||
except Exception as e:
|
||
logger.error(f"Gemini 问答异常: {e}")
|
||
return None
|
||
|
||
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
|
||
return None
|
||
|
||
def get_timeout(self) -> int:
|
||
return self.timeout
|
||
|
||
def get_circuit_breaker(self) -> CircuitBreaker:
|
||
return self._cb
|