[架构重构] 移除本地Ollama融合,云端直出JSON直存DB,Q&A三模型降级
1. 视频摘要链路:云端VLM直出结构化JSON → Edge format_cloud_result格式化校验 → 直存NAS DB(移除run_text_fusion本地融合) 2. 智能问答链路:Gemini→NVIDIA→Ollama降级,新增chat()纯文本问答方法 3. 适配器重构:base/gemini/nvidia/ollama adapter新增chat();gemini多图单请求结构化JSON;nvidia逐帧调用聚合 4. 端点变更:/api/edge/chat → /api/edge/chat/ask,调orchestrator.run_qa() 5. chat_handler改经Edge Q&A编排,不再直连Ollama 6. 配置更新:ollama_url → qa_url,Ollama role注释改为Q&A兜底 7. README同步更新架构描述、拓扑图、时序图、模块表
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
"""
|
||||
模型适配器基类 - 所有模型适配器的抽象基类
|
||||
|
||||
新增模型只需继承此类并实现 4 个方法:
|
||||
新增模型只需继承此类并实现方法:
|
||||
1. health_check() -> bool
|
||||
2. analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[str]
|
||||
3. get_timeout() -> int
|
||||
4. get_circuit_breaker() -> CircuitBreaker
|
||||
2. analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[dict]
|
||||
- 视觉分析:输入帧图片路径 + 时间戳 + 成员清单,直接输出**结构化结果 dict**
|
||||
(含 frame_details 等,详见 format_cloud_result 约定)。
|
||||
- 失败/超时返回 None。
|
||||
3. chat(prompt) -> Optional[str]
|
||||
- 纯文本问答(智能问答场景),返回文本或 None。
|
||||
- 默认实现抛 NotImplementedError;文本/视觉模型按需实现。
|
||||
4. get_timeout() -> int
|
||||
5. get_circuit_breaker() -> CircuitBreaker
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class BaseModelAdapter(ABC):
|
||||
@@ -17,7 +23,7 @@ class BaseModelAdapter(ABC):
|
||||
def __init__(self, provider_name: str, config: dict):
|
||||
self.provider_name = provider_name # 如 "ollama", "gemini"
|
||||
self.config = config
|
||||
# 角色: vision=视觉分析, text=文本融合/对话; 默认 vision
|
||||
# 角色: vision=视觉分析, text=智能问答兜底(本地模型); 默认 vision
|
||||
self.role = config.get('role', 'vision')
|
||||
|
||||
def get_role(self) -> str:
|
||||
@@ -32,11 +38,28 @@ class BaseModelAdapter(ABC):
|
||||
@abstractmethod
|
||||
def analyze_frames(self, frame_paths: List[str],
|
||||
frame_timestamps: List[str],
|
||||
known_members_context: str) -> Optional[str]:
|
||||
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,输出自然语言描述。
|
||||
失败/超时返回 None。"""
|
||||
known_members_context: str) -> Optional[Dict]:
|
||||
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,
|
||||
直接输出结构化结果 dict(含 frame_details 等)。失败/超时返回 None。
|
||||
|
||||
约定返回结构(云端模型直出,Edge 仅做格式化校验,不再本地融合):
|
||||
{
|
||||
"global_summary": "整个时段整体摘要(可选,缺失时由 Edge 格式化生成)",
|
||||
"entities_json": [{"person","action","clothing"}] (可选,缺失时由 frame_details 推导),
|
||||
"frame_details": [
|
||||
{"frame_index":int, "frame_timestamp":str, "person":str,
|
||||
"action":str, "clothing":str, "is_attention_event":bool,
|
||||
"source_providers":[provider]}
|
||||
]
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
"""纯文本问答(智能问答场景)。默认不实现。"""
|
||||
raise NotImplementedError(
|
||||
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)")
|
||||
|
||||
@abstractmethod
|
||||
def get_timeout(self) -> int:
|
||||
"""该模型的调用超时秒数"""
|
||||
|
||||
@@ -3,31 +3,32 @@ GeminiAdapter - Google Gemini 云端 VLM 适配器
|
||||
|
||||
provider_name = "gemini"
|
||||
模型: gemini-flash-latest (v1beta 下 gemini-1.5-flash 会 404,用 flash-latest 别名)
|
||||
角色: vision (视觉分析)
|
||||
角色: vision (视觉分析直出结构化 JSON) + 智能问答
|
||||
健康检查: GET /v1beta/models?key=...
|
||||
熔断器: 启用
|
||||
逐帧分析: 与 NVIDIA 统一流程,逐帧调用(也规避多图返回不稳定)
|
||||
视觉分析: 多图单请求直出结构化 JSON(global_summary/entities_json/frame_details)
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import requests
|
||||
from typing import List, Optional
|
||||
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 适配器 (逐帧)"""
|
||||
"""Gemini 云端 VLM 适配器 (视觉直出结构化 JSON + 文本问答)"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("gemini", config)
|
||||
self.model_name = config.get('model_name', 'gemini-flash-latest')
|
||||
self.api_key = self._resolve_key(config.get('api_key', ''))
|
||||
self.timeout = config.get('timeout', 15)
|
||||
self.timeout = config.get('timeout', 30)
|
||||
cb_cfg = config.get('circuit_breaker', {})
|
||||
self._cb = CircuitBreaker(
|
||||
threshold=cb_cfg.get('threshold', 3),
|
||||
@@ -62,61 +63,143 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
logger.error(f"Gemini 健康检查异常: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 视觉分析:多图单请求,直出结构化 JSON
|
||||
# ------------------------------------------------------------------
|
||||
def analyze_frames(self, frame_paths: List[str],
|
||||
frame_timestamps: List[str],
|
||||
known_members_context: str) -> Optional[str]:
|
||||
known_members_context: 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
|
||||
|
||||
results = []
|
||||
for path, ts in zip(frame_paths, frame_timestamps):
|
||||
desc = self._analyze_one(path, ts, known_members_context)
|
||||
if desc:
|
||||
results.append(f"[帧] 时间: {ts}\n{desc}")
|
||||
|
||||
if not results:
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
self._cb.record_success()
|
||||
logger.info(f"Gemini 视觉分析完成,{len(results)} 帧有描述")
|
||||
return "\n".join(results)
|
||||
|
||||
def _analyze_one(self, path: str, ts: str,
|
||||
known_members: str) -> Optional[str]:
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
img = base64.b64encode(f.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"读取图片失败 {path}: {e}")
|
||||
if not frame_paths:
|
||||
logger.warning("Gemini 无帧可分析")
|
||||
return None
|
||||
|
||||
prompt = self._build_prompt(ts, known_members)
|
||||
parts = []
|
||||
ts_map = {}
|
||||
for i, (path, ts) in enumerate(zip(frame_paths, frame_timestamps), 1):
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
img = base64.b64encode(f.read()).decode('utf-8')
|
||||
except Exception as e:
|
||||
logger.error(f"读取图片失败 {path}: {e}")
|
||||
continue
|
||||
parts.append({"inline_data": {"mime_type": "image/jpeg", "data": img}})
|
||||
parts.append({"text": f"[图片{i}] 时间: {ts}"})
|
||||
ts_map[i] = ts
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
parts.insert(0, {"text": self._build_structured_prompt(known_members_context)})
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._base_url}/models/{self.model_name}:generateContent?key={self.api_key}",
|
||||
json={"contents": [{"parts": [
|
||||
{"text": prompt},
|
||||
{"inline_data": {"mime_type": "image/jpeg", "data": img}}
|
||||
]}], "generationConfig": {"temperature": 0.2, "maxOutputTokens": 300}},
|
||||
json={"contents": [{"parts": parts}],
|
||||
"generationConfig": {"temperature": 0.2, "maxOutputTokens": 2048}},
|
||||
timeout=self.timeout
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
cands = resp.json().get('candidates', [])
|
||||
if cands:
|
||||
parts = cands[0].get('content', {}).get('parts', [])
|
||||
text = ''.join(p.get('text', '') for p in parts).strip()
|
||||
return text or None
|
||||
logger.warning("Gemini 返回空 candidates")
|
||||
text = ''.join(
|
||||
p.get('text', '')
|
||||
for p in cands[0].get('content', {}).get('parts', [])
|
||||
).strip()
|
||||
if not text:
|
||||
logger.warning("Gemini 返回空文本")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
try:
|
||||
result = parse_vlm_json(text)
|
||||
# 确保 frame_details 的 frame_timestamp 与标注一致
|
||||
for f in result.get('frame_details', []):
|
||||
idx = f.get('frame_index')
|
||||
if isinstance(idx, int) and idx in ts_map and not f.get('frame_timestamp'):
|
||||
f['frame_timestamp'] = ts_map[idx]
|
||||
for f in result.get('frame_details', []):
|
||||
if 'source_providers' not in f or not f.get('source_providers'):
|
||||
f['source_providers'] = ['gemini']
|
||||
self._cb.record_success()
|
||||
logger.info(f"Gemini 视觉分析完成,frame_details={len(result.get('frame_details', []))}")
|
||||
return result
|
||||
except VLMOutputInvalidError as e:
|
||||
logger.error(f"Gemini 输出无法解析为 JSON: {e}")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"Gemini 单帧失败 HTTP {resp.status_code}: {resp.text[:150]}")
|
||||
logger.warning(f"Gemini 视觉分析 HTTP {resp.status_code}: {resp.text[:150]}")
|
||||
self._cb.record_failure()
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini 单帧超时 ({self.timeout}s)")
|
||||
logger.warning(f"Gemini 视觉分析超时 ({self.timeout}s)")
|
||||
self._cb.record_failure()
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 单帧异常: {e}")
|
||||
logger.error(f"Gemini 视觉分析异常: {e}")
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
|
||||
def _build_structured_prompt(self, known_members: str) -> str:
|
||||
return f"""你是家庭监控视频分析助手。下面按时间顺序排列了多张监控截图。
|
||||
请分析整个时段,只输出合法 JSON(不要 markdown、不要任何解释文字),结构如下:
|
||||
|
||||
{{
|
||||
"global_summary": "整个时段的整体摘要,简体中文,2-4 句,客观描述人物与主要活动",
|
||||
"entities_json": [
|
||||
{{"person": "人物标识(匹配已知成员用真名,否则用'人物A'/'人物B'...)", "action": "主要动作", "clothing": "衣着"}}
|
||||
],
|
||||
"frame_details": [
|
||||
{{
|
||||
"frame_index": 图片序号(从1开始,与[图片N]标注对应),
|
||||
"frame_timestamp": "该帧的时间戳(用[图片N]标注里的时间)",
|
||||
"person": "该帧画面中的人物或'无人'",
|
||||
"action": "该帧可见动作",
|
||||
"clothing": "该帧衣着(颜色+类型)",
|
||||
"is_attention_event": false,
|
||||
"source_providers": ["gemini"]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. 只描述客观画面,不要猜测或想象。
|
||||
2. frame_details 每帧一条,frame_index 与上方[图片N]序号对应,frame_timestamp 用标注时间。
|
||||
3. 已知家庭成员(按特征匹配,匹配到用 real_name,否则用"人物X"):
|
||||
{known_members or '(暂无已知成员)'}
|
||||
4. is_attention_event:是否为跌倒、危险、异常哭闹等需关注事件(没有则为 false)。
|
||||
5. 没有人物出现的帧 person 填"无人",action 填""。"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
if not self.api_key:
|
||||
logger.warning("Gemini API Key 未配置,跳过问答")
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._base_url}/models/{self.model_name}:generateContent?key={self.api_key}",
|
||||
json={"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"temperature": 0.3, "maxOutputTokens": max_tokens}},
|
||||
timeout=self.timeout
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
cands = resp.json().get('candidates', [])
|
||||
if cands:
|
||||
text = ''.join(
|
||||
p.get('text', '')
|
||||
for p in cands[0].get('content', {}).get('parts', [])
|
||||
).strip()
|
||||
return text or None
|
||||
logger.warning(f"Gemini 问答 HTTP {resp.status_code}")
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini 问答超时 ({self.timeout}s)")
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini 问答异常: {e}")
|
||||
return None
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
@@ -124,16 +207,3 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
|
||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||
return self._cb
|
||||
|
||||
def _build_prompt(self, ts: str, known_members: str) -> str:
|
||||
return f"""你是家庭监控视频分析助手。请看这张监控截图(拍摄时间 {ts}),客观描述画面内容,不要猜测。
|
||||
|
||||
需报告:
|
||||
1. 人物:数量、衣着(颜色+类型)、可见动作
|
||||
2. 物品:玩具、奶瓶、家具等显眼物体
|
||||
3. 互动:人与人或人与物体的互动
|
||||
|
||||
已知家庭成员(按特征匹配,匹配到用真名,否则用"人物X"):
|
||||
{known_members or '(暂无)'}
|
||||
|
||||
要求简洁客观,不要输出 JSON 或 markdown。"""
|
||||
|
||||
@@ -3,18 +3,19 @@ NvidiaVisionAdapter - NVIDIA NIM 云端 VLM 适配器
|
||||
|
||||
provider_name = "nvidia"
|
||||
模型: meta/llama-3.2-11b-vision-instruct
|
||||
角色: vision (视觉分析)
|
||||
角色: vision (视觉分析直出结构化 JSON) + 智能问答
|
||||
SDK: openai (NIM 兼容 OpenAI API 规范)
|
||||
限制: NIM 单次请求最多 1 张图 -> 适配器内部逐帧调用
|
||||
限制: NIM 单次请求最多 1 张图 -> 适配器内部逐帧调用,再聚合成 frame_details
|
||||
熔断器: 启用
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
from typing import List, Optional
|
||||
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.nvidia_adapter')
|
||||
|
||||
@@ -25,7 +26,7 @@ except ImportError:
|
||||
|
||||
|
||||
class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
"""NVIDIA NIM 云端 VLM 适配器 (逐帧)"""
|
||||
"""NVIDIA NIM 云端 VLM 适配器 (逐帧结构化 + 聚合; 文本问答)"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__("nvidia", config)
|
||||
@@ -64,31 +65,41 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
logger.warning(f"NVIDIA 健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 视觉分析:逐帧调用(NIM 限 1 图/请求),聚合为 frame_details
|
||||
# ------------------------------------------------------------------
|
||||
def analyze_frames(self, frame_paths: List[str],
|
||||
frame_timestamps: List[str],
|
||||
known_members_context: str) -> Optional[str]:
|
||||
known_members_context: str) -> Optional[Dict]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("NVIDIA 熔断器 OPEN,跳过调用")
|
||||
return None
|
||||
if self._client is None:
|
||||
logger.warning("NVIDIA 客户端未初始化,跳过调用")
|
||||
return None
|
||||
if not frame_paths:
|
||||
logger.warning("NVIDIA 无帧可分析")
|
||||
return None
|
||||
|
||||
results = []
|
||||
for path, ts in zip(frame_paths, frame_timestamps):
|
||||
desc = self._analyze_one(path, ts, known_members_context)
|
||||
if desc:
|
||||
results.append(f"[帧] 时间: {ts}\n{desc}")
|
||||
frame_details = []
|
||||
ok = False
|
||||
for i, (path, ts) in enumerate(zip(frame_paths, frame_timestamps), 1):
|
||||
detail = self._analyze_one_structured(path, ts, i, known_members_context)
|
||||
if detail:
|
||||
frame_details.append(detail)
|
||||
ok = True
|
||||
|
||||
if not results:
|
||||
if not ok:
|
||||
self._cb.record_failure()
|
||||
return None
|
||||
self._cb.record_success()
|
||||
logger.info(f"NVIDIA 视觉分析完成,{len(results)} 帧有描述")
|
||||
return "\n".join(results)
|
||||
|
||||
def _analyze_one(self, path: str, ts: str,
|
||||
known_members: str) -> Optional[str]:
|
||||
self._cb.record_success()
|
||||
logger.info(f"NVIDIA 视觉分析完成,frame_details={len(frame_details)}")
|
||||
# NVIDIA 单帧无法跨帧综合 global_summary,交由 Edge format_cloud_result 格式化生成
|
||||
return {"frame_details": frame_details}
|
||||
|
||||
def _analyze_one_structured(self, path: str, ts: str, idx: int,
|
||||
known_members: str) -> Optional[Dict]:
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
@@ -96,7 +107,7 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
logger.error(f"读取图片失败 {path}: {e}")
|
||||
return None
|
||||
|
||||
prompt = self._build_prompt(ts, known_members)
|
||||
prompt = self._build_structured_prompt(ts, idx, known_members)
|
||||
try:
|
||||
resp = self._client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
@@ -109,26 +120,69 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
timeout=self.timeout
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
return content.strip() if content else None
|
||||
if not content:
|
||||
return None
|
||||
try:
|
||||
data = parse_vlm_json(content)
|
||||
except VLMOutputInvalidError:
|
||||
logger.warning(f"NVIDIA 单帧 JSON 解析失败: {content[:120]}")
|
||||
return None
|
||||
# 组装统一字段
|
||||
return {
|
||||
"frame_index": idx,
|
||||
"frame_timestamp": str(data.get("frame_timestamp", ts)),
|
||||
"person": str(data.get("person", "无人")),
|
||||
"action": str(data.get("action", "")),
|
||||
"clothing": str(data.get("clothing", "")),
|
||||
"is_attention_event": bool(data.get("is_attention_event", False)),
|
||||
"source_providers": ["nvidia"],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"NVIDIA 单帧异常: {e}")
|
||||
return None
|
||||
|
||||
def _build_structured_prompt(self, ts: str, idx: int, known_members: str) -> str:
|
||||
return f"""你是家庭监控视频分析助手。请看这张监控截图(拍摄时间 {ts})。
|
||||
只输出合法 JSON(不要 markdown、不要解释),结构如下:
|
||||
|
||||
{{
|
||||
"frame_timestamp": "{ts}",
|
||||
"person": "该帧画面中的人物或'无人'",
|
||||
"action": "该帧可见动作",
|
||||
"clothing": "该帧衣着(颜色+类型)",
|
||||
"is_attention_event": false
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. 只描述客观画面,不猜测。
|
||||
2. 已知家庭成员(按特征匹配,匹配到用 real_name,否则用"人物X"):
|
||||
{known_members or '(暂无已知成员)'}
|
||||
3. is_attention_event:是否为跌倒、危险、异常哭闹等需关注事件(没有则为 false)。
|
||||
4. 没有人物出现的帧 person 填"无人",action 填""。"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
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
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||
return self._cb
|
||||
|
||||
def _build_prompt(self, ts: str, known_members: str) -> str:
|
||||
return f"""你是家庭监控视频分析助手。请看这张监控截图(拍摄时间 {ts}),客观描述画面内容,不要猜测。
|
||||
|
||||
需报告:
|
||||
1. 人物:数量、衣着(颜色+类型)、可见动作
|
||||
2. 物品:玩具、奶瓶、家具等显眼物体
|
||||
3. 互动:人与人或人与物体的互动
|
||||
|
||||
已知家庭成员(按特征匹配,匹配到用真名,否则用"人物X"):
|
||||
{known_members or '(暂无)'}
|
||||
|
||||
要求简洁客观,不要输出 JSON 或 markdown。"""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
OllamaAdapter - 本地 VLM 模型适配器
|
||||
OllamaAdapter - 本地模型适配器(仅智能问答兜底)
|
||||
|
||||
provider_name = "ollama"
|
||||
模型: llava-phi3
|
||||
模型: qwen2.5:7b(纯文本)
|
||||
角色: text(智能问答兜底;Gemini 与 NVIDIA 均失败时启用)
|
||||
健康检查: GET /api/tags
|
||||
不参与视觉分析、不参与视频结构化输出(云端 VLM 直出)
|
||||
"""
|
||||
import base64
|
||||
import requests
|
||||
@@ -115,6 +117,41 @@ class OllamaAdapter(BaseModelAdapter):
|
||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||
return self._cb
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 智能问答:纯文本(本地模型,仅作 Gemini/NVIDIA 全失败时的兜底)
|
||||
# ------------------------------------------------------------------
|
||||
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||
if self._cb.is_open():
|
||||
logger.warning("Ollama 熔断器 OPEN,跳过问答")
|
||||
return None
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json={
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.3, "num_predict": max_tokens}
|
||||
},
|
||||
timeout=self.timeout
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
output = resp.json().get('response', '').strip()
|
||||
if output:
|
||||
self._cb.record_success()
|
||||
return output
|
||||
self._cb.record_failure()
|
||||
else:
|
||||
logger.error(f"Ollama 问答失败: {resp.status_code} {resp.text[:200]}")
|
||||
self._cb.record_failure()
|
||||
except requests.Timeout:
|
||||
logger.error(f"Ollama 问答超时 ({self.timeout}s)")
|
||||
self._cb.record_failure()
|
||||
except Exception as e:
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user