[架构重构] 移除本地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:
90
README.md
90
README.md
@@ -14,11 +14,11 @@
|
|||||||
### 1.1 首期范围(已基本完成)
|
### 1.1 首期范围(已基本完成)
|
||||||
|
|
||||||
- **FAM-Core**(NAS 端单进程):Task-Scheduler / Dispatcher / Event-Receiver / Chat-Handler / Member-Manager / Video-Server 六个子模块
|
- **FAM-Core**(NAS 端单进程):Task-Scheduler / Dispatcher / Event-Receiver / Chat-Handler / Member-Manager / Video-Server 六个子模块
|
||||||
- **FAM-Edge**(Oracle 端单进程):接收视频上传 → FFmpeg 快速抽帧 → OpenCV 关键帧筛选 → 云端 VLM 视觉分析(Gemini/NVIDIA NIM)→ 本地 Ollama 文本融合 → 结果同步返回 全链路
|
- **FAM-Edge**(Oracle 端单进程):接收视频上传 → FFmpeg 快速抽帧 → OpenCV 关键帧筛选 → 云端 VLM 视觉分析(Gemini/NVIDIA NIM)直出结构化 JSON → Edge 仅做格式化/校验 → 结果同步返回 全链路(**本地模型不参与视频分析**)
|
||||||
- **FAM-UI**(NAS 端):Streamlit 直读 DB,事件列表 + 成员命名页 + AI 对话页 + 对话历史
|
- **FAM-UI**(NAS 端):Streamlit 直读 DB,事件列表 + 成员命名页 + AI 对话页 + 对话历史
|
||||||
- **数据库六张表**:process_tasks / monitor_events / event_details / chat_history / family_members / daily_summaries(预留)
|
- **数据库六张表**:process_tasks / monitor_events / event_details / chat_history / family_members / daily_summaries(预留)
|
||||||
- **任务状态机**:PENDING → PROCESSING → SUCCESS/FAILED,含退避重试与僵尸任务回收
|
- **任务状态机**:PENDING → PROCESSING → SUCCESS/FAILED,含退避重试与僵尸任务回收
|
||||||
- **AI 对话**:查 event_details 拼上下文 → 经 FAM-Edge 代理调 Oracle 本地 Ollama 纯文本 → 返回回答并写 chat_history
|
- **AI 对话**:查 event_details 拼上下文 → 经 FAM-Edge 问答编排(Gemini → NVIDIA → 本地 Ollama 兜底)生成回答 → 返回并写 chat_history
|
||||||
- **交互式成员命名**:VLM 按特征提取"人物A/B/C"落库,用户命名后批量回溯更新历史,后续分析直接用真名
|
- **交互式成员命名**:VLM 按特征提取"人物A/B/C"落库,用户命名后批量回溯更新历史,后续分析直接用真名
|
||||||
|
|
||||||
### 1.2 不在首期范围(推迟 v1.1+)
|
### 1.2 不在首期范围(推迟 v1.1+)
|
||||||
@@ -39,19 +39,19 @@
|
|||||||
| **Tailscale 端口不通** | NAS tailscaled 以 userspace 模式运行(无 TUN 网卡),Oracle 无法反向访问 NAS;当前 NAS→Oracle 走公网 IP | 推送模式已规避反向访问,但流量走公网 |
|
| **Tailscale 端口不通** | NAS tailscaled 以 userspace 模式运行(无 TUN 网卡),Oracle 无法反向访问 NAS;当前 NAS→Oracle 走公网 IP | 推送模式已规避反向访问,但流量走公网 |
|
||||||
| **历史视频积压** | 正式目录 `/volume1/surveillance/Generic_ONVIF-001` 有约 285 个历史视频(~100GB),288 个历史任务已标记 FAILED 避免全量上传 | 切回生产目录前需确认回补策略 |
|
| **历史视频积压** | 正式目录 `/volume1/surveillance/Generic_ONVIF-001` 有约 285 个历史视频(~100GB),288 个历史任务已标记 FAILED 避免全量上传 | 切回生产目录前需确认回补策略 |
|
||||||
|
|
||||||
**规划方向**:**云端大模型负责视觉识别、甲骨文本地大模型负责文本问答**。按任务类型分工:
|
**规划方向(新框架)**:**云端大模型负责视觉识别与结构化输出、本地大模型仅做智能问答兜底**。按任务类型分工:
|
||||||
|
|
||||||
| 任务 | 执行方 | 模型 | 说明 |
|
| 任务 | 执行方 | 模型 | 说明 |
|
||||||
|------|--------|------|------|
|
|------|--------|------|------|
|
||||||
| **视觉分析**(看图识人/动作/衣着) | 云端 | Gemini → NVIDIA NIM(fallback 降级) | 云端 GPU 推理,支持多图,质量与速度均优于本地 ARM |
|
| **视觉分析 + 结构化输出**(看图识人/动作/衣着 → 直出 JSON) | 云端 | Gemini → NVIDIA NIM(fallback 降级) | 云端 VLM 直接产出 `global_summary` / `entities_json` / `frame_details`,Edge 仅做**格式化校验**后直存 NAS,**本地模型不介入** |
|
||||||
| **文本融合**(多帧视觉结果 → 结构化 JSON) | 本地 | Ollama qwen2.5:7b | 纯文本能力,不涉及图片 |
|
| **结果格式化**(云端 JSON → 入库 schema) | Edge 进程 | 无模型调用 | `format_cloud_result`:字段归一化、补 `source_providers`/`compute_provider`、推导 `entities`、缺失 `global_summary` 时事实拼接;纯数据转换,非 LLM 二次汇总 |
|
||||||
| **AI 对话**("汤圆今天干嘛了") | 本地 | Ollama qwen2.5:7b | 纯文本问答 |
|
| **AI 对话**("汤圆今天干嘛了") | 云端优先 + 本地兜底 | Gemini → NVIDIA NIM → 本地 Ollama | 两云端任一成功即用;**仅当 Gemini 与 NVIDIA 都失败**才回退本地 Ollama qwen2.5:7b |
|
||||||
|
|
||||||
- **Google Gemini**(`gemini-1.5-flash`,API Key 已验证,单帧 2~3s 且真正支持多图)
|
- **Google Gemini**(`gemini-flash-latest`,API Key 已验证,支持多图单请求,视觉 + 问答均参与)
|
||||||
- **NVIDIA NIM**(`meta/llama-3.2-11b-vision-instruct`,OpenAI 兼容 API,云端 GPU 推理,响应快)
|
- **NVIDIA NIM**(`meta/llama-3.2-11b-vision-instruct`,OpenAI 兼容 API,云端 GPU 推理,单请求限 1 图故逐帧调用;视觉 + 问答均参与)
|
||||||
- **本地 Ollama**(qwen2.5:7b)**专职文本任务**(融合 + 对话),不再参与视觉分析;视觉链路两云端全失败 → 任务 FAILED 走重试
|
- **本地 Ollama**(qwen2.5:7b)**仅参与智能问答,且仅作兜底**:视觉链路两云端全失败 → 任务 FAILED 走重试,**绝不回退本地模型做视觉/融合**
|
||||||
|
|
||||||
Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA NIM;记录每模型实际执行耗时与成功状态。`analyze_frames` 改为逐帧请求。
|
Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA NIM;云端模型直出结构化 JSON 后由 `format_cloud_result` 格式化。问答阶段按 `gemini → nvidia → ollama` 顺序,仅末位本地模型作兜底。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,10 +97,10 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
┌──────────────────────── Oracle Cloud (129.146.203.203) ──────────────────────────┐
|
┌──────────────────────── Oracle Cloud (129.146.203.203) ──────────────────────────┐
|
||||||
│ FAM-Edge (Flask :5000, gunicorn --timeout 1800, 单 worker) │
|
│ FAM-Edge (Flask :5000, gunicorn --timeout 1800, 单 worker) │
|
||||||
│ ├─ POST /api/edge/video/push(multipart 视频,同步分析,结果随响应返回) │
|
│ ├─ POST /api/edge/video/push(multipart 视频,同步分析,结果随响应返回) │
|
||||||
│ ├─ AI-Orchestrator: 健康检查 → 抽帧 → 选帧 → 压缩 → 云端VLM视觉 → 本地Ollama文本融合 │
|
│ ├─ AI-Orchestrator: 健康检查 → 抽帧 → 选帧 → 压缩 → 云端VLM视觉直出结构化JSON → 格式化校验(无本地融合) │
|
||||||
│ ├─ POST /api/edge/chat(代理转发本地 Ollama 纯文本问答) │
|
│ ├─ POST /api/edge/chat/ask(问答编排: Gemini→NVIDIA→本地Ollama 兜底) │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ 云端: Gemini / NVIDIA NIM (视觉) 本地: Ollama :11434 (qwen2.5:7b, 文本融合+对话) │
|
│ 云端: Gemini / NVIDIA NIM (视觉直出结构化 + 问答) 本地: Ollama :11434 (qwen2.5:7b, 仅问答兜底) │
|
||||||
└───────────────────────────────────────────────────────────────────────────────────┘
|
└───────────────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -112,8 +112,8 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
- a. 保存上传视频到临时目录(超时 60s)
|
- a. 保存上传视频到临时目录(超时 60s)
|
||||||
- b. FFmpeg 快速 seek(`-ss <ts> -frames:v 1`)粗抽候选帧,帧数随视频时长自适应
|
- b. FFmpeg 快速 seek(`-ss <ts> -frames:v 1`)粗抽候选帧,帧数随视频时长自适应
|
||||||
- c. OpenCV MSE 帧差分析筛选关键帧 → 压缩(长边 ≤ 1024px,JPEG 质量 80)
|
- c. OpenCV MSE 帧差分析筛选关键帧 → 压缩(长边 ≤ 1024px,JPEG 质量 80)
|
||||||
- d. 云端视觉模型按 `orchestrator.mode`(fallback)顺序降级:Gemini(timeout 15s)→ NVIDIA NIM(timeout 20s);首个返回非 None 结果即采用,两云端全失败 → 任务 FAILED 走重试(不回退本地 Ollama,本地仅负责文本)
|
- d. 云端视觉模型按 `orchestrator.mode`(fallback)顺序降级:Gemini(timeout 30s)→ NVIDIA NIM(timeout 20s);首个**直出结构化 JSON** 成功的模型即采用,两云端全失败 → 任务 FAILED 走重试(绝不回退本地 Ollama,本地模型不参与视频分析)
|
||||||
- e. 本地 Ollama 文本融合(超时 300s,输入各帧视觉描述 + 时间戳 + known_members 上下文)→ JSON 结构化结果
|
- e. `format_cloud_result` 格式化校验(无模型调用):字段归一化、补 `source_providers=[provider]` / `compute_provider=[provider]`、缺失 `entities_json` 由 `frame_details` 推导、缺失 `global_summary` 时事实拼接 → 合法入库 schema
|
||||||
- f. `event_end_time` = event_start_time + 视频时长(Edge 推算)
|
- f. `event_end_time` = event_start_time + 视频时长(Edge 推算)
|
||||||
- g. `finally` 清理临时文件
|
- g. `finally` 清理临时文件
|
||||||
4. Edge 把结果 JSON 直接作为 HTTP 响应返回(无 webhook)
|
4. Edge 把结果 JSON 直接作为 HTTP 响应返回(无 webhook)
|
||||||
@@ -135,7 +135,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
| Task-Scheduler | `scheduler/scheduler.py` | 60s 轮询视频目录(`os.walk` 递归,支持 AM/PM 子目录),`video_path` 去重,稳定文件建 PENDING 任务 |
|
| Task-Scheduler | `scheduler/scheduler.py` | 60s 轮询视频目录(`os.walk` 递归,支持 AM/PM 子目录),`video_path` 去重,稳定文件建 PENDING 任务 |
|
||||||
| Dispatcher | `dispatcher/dispatcher.py` | 30s 轮询 PENDING;multipart 上传视频至 Edge push 端点;收响应后经 `apply_success_event` 落库;僵尸 PROCESSING 回收;退避重试 |
|
| Dispatcher | `dispatcher/dispatcher.py` | 30s 轮询 PENDING;multipart 上传视频至 Edge push 端点;收响应后经 `apply_success_event` 落库;僵尸 PROCESSING 回收;退避重试 |
|
||||||
| Event-Receiver | `event_receiver/event_receiver.py` | `/api/core/callback/event`(webhook 兼容保留);核心逻辑抽为 `apply_success_event(data)` 供 Dispatcher 推送模式复用;未命名 abstract_label 自动 upsert `family_members` |
|
| Event-Receiver | `event_receiver/event_receiver.py` | `/api/core/callback/event`(webhook 兼容保留);核心逻辑抽为 `apply_success_event(data)` 供 Dispatcher 推送模式复用;未命名 abstract_label 自动 upsert `family_members` |
|
||||||
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask` 查 event_details 拼上下文 → 经 Edge 代理调 Ollama → 写 chat_history;明细 > 50 条按小时聚合 |
|
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask` 查 event_details 拼上下文 → 经 Edge `/api/edge/chat/ask` 问答编排(Gemini→NVIDIA→本地 Ollama 兜底)→ 写 chat_history;明细 > 50 条按小时聚合 |
|
||||||
| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/name` / `/api/member/list`;命名后批量回溯 UPDATE event_details(MariaDB 不支持 `$[*]` JSON 路径,Python 层逐行更新) |
|
| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/name` / `/api/member/list`;命名后批量回溯 UPDATE event_details(MariaDB 不支持 `$[*]` JSON 路径,Python 层逐行更新) |
|
||||||
| Video-Server | `video_server/video_server.py` | `/media/<path>?token=xxx` 静态视频服务(推送模式下主链路不再使用,保留备用) |
|
| Video-Server | `video_server/video_server.py` | `/media/<path>?token=xxx` 静态视频服务(推送模式下主链路不再使用,保留备用) |
|
||||||
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);datetime 空串归一化 NULL + NOT NULL 列兜底;文件日志 |
|
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接(unix_socket);datetime 空串归一化 NULL + NOT NULL 列兜底;文件日志 |
|
||||||
@@ -146,11 +146,11 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级:Gemini → NVIDIA N
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| API-Gateway | `api_gateway/api_gateway.py` | `POST /api/edge/video/push`(multipart 上传 + 同步分析 + 结果返回);`POST /api/edge/video/analyze`(旧拉取模式,兼容保留);`POST /api/edge/chat`(Ollama 代理);`GET /health`;单并发控制(处理中返回 429) |
|
| API-Gateway | `api_gateway/api_gateway.py` | `POST /api/edge/video/push`(multipart 上传 + 同步分析 + 结果返回);`POST /api/edge/video/analyze`(旧拉取模式,兼容保留);`POST /api/edge/chat`(Ollama 代理);`GET /health`;单并发控制(处理中返回 429) |
|
||||||
| Video-Preprocessor | `video_preprocessor/preprocessor.py` | `save_upload` 保存上传视频;FFmpeg 快速 seek 粗抽候选帧(帧数自适应);OpenCV MSE 帧差筛选关键帧(首末帧必选);压缩;`compute_timestamps` 用 start+偏移算绝对时间戳;`video_duration` 供 event_end_time 推算 |
|
| Video-Preprocessor | `video_preprocessor/preprocessor.py` | `save_upload` 保存上传视频;FFmpeg 快速 seek 粗抽候选帧(帧数自适应);OpenCV MSE 帧差筛选关键帧(首末帧必选);压缩;`compute_timestamps` 用 start+偏移算绝对时间戳;`video_duration` 供 event_end_time 推算 |
|
||||||
| AI-Orchestrator | `ai_orchestrator/orchestrator.py` | 模型健康检查 → 云端视觉适配器按 `orchestrator.mode`(fallback 顺序降级)调度 → 本地 Ollama 文本融合 → JSON 解析三层容错 + schema 校验;`process_push_task` 为推送模式入口(不触发 webhook);记录各模型实际执行耗时与成功状态到 `compute_provider` 数组 |
|
| AI-Orchestrator | `ai_orchestrator/orchestrator.py` | 模型健康检查 → 云端视觉适配器按 `orchestrator.mode`(fallback 顺序降级)调度,**直出结构化 JSON** → `format_cloud_result` 格式化校验(无本地融合)→ JSON schema 校验;`run_qa` 实现问答编排(Gemini→NVIDIA→本地 Ollama 兜底);`process_push_task` 为推送模式入口(不触发 webhook);记录各模型实际执行耗时与成功状态到 `compute_provider` 数组 |
|
||||||
| Model-Adapters | `model_adapters/` | `BaseModelAdapter` 抽象基类(`__init__` / `health_check` / `analyze_frames` / `get_timeout` / 熔断器实例);`build_adapter` 工厂函数按 `provider` 字段分发实例化;视觉适配器(gemini/nvidia)参与视觉分析,文本适配器(ollama)专职融合与对话 |
|
| Model-Adapters | `model_adapters/` | `BaseModelAdapter` 抽象基类(`__init__` / `health_check` / `analyze_frames` / `chat` / `get_timeout` / 熔断器实例);`build_adapter` 工厂函数按 `provider` 字段分发实例化;视觉适配器(gemini/nvidia)直出结构化 JSON,文本适配器(ollama)仅智能问答兜底 |
|
||||||
| └ OllamaAdapter | `model_adapters/ollama_adapter.py` | requests 直调本地 REST `/api/chat`,`num_predict` 可配;**role: text**(文本融合 + AI 对话,不参与视觉分析) |
|
| └ OllamaAdapter | `model_adapters/ollama_adapter.py` | requests 直调本地 REST `/api/generate`,`num_predict` 可配;**role: text, usage: qa_fallback**(仅智能问答兜底,不参与视觉分析、不参与融合) |
|
||||||
| └ GeminiAdapter | `model_adapters/gemini_adapter.py` | requests 直调 Google REST `:generateContent`,支持多图;**role: vision** |
|
| └ GeminiAdapter | `model_adapters/gemini_adapter.py` | requests 直调 Google REST `:generateContent`,**多图单请求直出结构化 JSON**;**role: vision**;`chat()` 参与问答 |
|
||||||
| └ NvidiaVisionAdapter | `model_adapters/nvidia_adapter.py` | **基于 openai SDK**(NIM 兼容 OpenAI API 规范),`base_url=https://integrate.api.nvidia.com/v1`,`api_key` 从 `${NVIDIA_API_KEY}` 展开;`analyze_frames` 用 OpenAI 标准 `image_url`(Base64 内联)格式打包多张关键帧;`health_check` 调 `client.models.list()`;**role: vision** |
|
| └ NvidiaVisionAdapter | `model_adapters/nvidia_adapter.py` | **基于 openai SDK**(NIM 兼容 OpenAI API 规范),`base_url=https://integrate.api.nvidia.com/v1`,`api_key` 从 `${NVIDIA_API_KEY}` 展开;**逐帧返回结构化单帧 JSON 并聚合为 frame_details**(NIM 限 1 图/请求);`health_check` 调 `client.models.list()`;**role: vision**;`chat()` 参与问答 |
|
||||||
| Storage-Cleaner | `storage_cleaner/` | `finally` 删除临时视频与帧图片 |
|
| Storage-Cleaner | `storage_cleaner/` | `finally` 删除临时视频与帧图片 |
|
||||||
|
|
||||||
### 3.3 FAM-UI(NAS 端)
|
### 3.3 FAM-UI(NAS 端)
|
||||||
@@ -195,7 +195,7 @@ chat_history 独立表
|
|||||||
|
|
||||||
### 4.3 compute_provider / source_providers
|
### 4.3 compute_provider / source_providers
|
||||||
|
|
||||||
- `monitor_events.compute_provider`:JSON 数组,记录本次任务实际成功调用的模型,如 `["ollama"]` 或 `["ollama","gemini"]`
|
- `monitor_events.compute_provider`:JSON 数组,记录本次任务实际成功调用(**视觉分析**)的云端模型,如 `["gemini"]` 或 `["nvidia"]`;本地 Ollama 不参与视频分析,不会出现在该字段
|
||||||
- `event_details.source_providers`:该条明细被哪些模型识别到(可能少于 compute_provider)
|
- `event_details.source_providers`:该条明细被哪些模型识别到(可能少于 compute_provider)
|
||||||
- 多模型交叉验证:多模型一致 → 可信度高;仅单一模型描述 → source_providers 仅含该模型;冲突 → 多数派为准
|
- 多模型交叉验证:多模型一致 → 可信度高;仅单一模型描述 → source_providers 仅含该模型;冲突 → 多数派为准
|
||||||
|
|
||||||
@@ -227,9 +227,9 @@ chat_history 独立表
|
|||||||
"entities_json": [{"person": "汤圆", "action": "...", "clothing": "..."}],
|
"entities_json": [{"person": "汤圆", "action": "...", "clothing": "..."}],
|
||||||
"frame_details": [
|
"frame_details": [
|
||||||
{"frame_index": 1, "frame_timestamp": "...", "person": "...", "action": "...",
|
{"frame_index": 1, "frame_timestamp": "...", "person": "...", "action": "...",
|
||||||
"clothing": "...", "is_attention_event": false, "source_providers": ["ollama"]}
|
"clothing": "...", "is_attention_event": false, "source_providers": ["gemini"]}
|
||||||
],
|
],
|
||||||
"compute_provider": ["ollama"]
|
"compute_provider": ["gemini"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -237,7 +237,8 @@ chat_history 独立表
|
|||||||
- 429:已有任务处理中(单并发);503:全部模型不健康
|
- 429:已有任务处理中(单并发);503:全部模型不健康
|
||||||
|
|
||||||
**POST /api/edge/video/analyze** — 旧拉取模式(Edge 拉 video_url + webhook 回调),兼容保留,主链路不再使用
|
**POST /api/edge/video/analyze** — 旧拉取模式(Edge 拉 video_url + webhook 回调),兼容保留,主链路不再使用
|
||||||
**POST /api/edge/chat** — Ollama 聊天代理(FAM-Core Chat-Handler 调用)
|
**POST /api/edge/chat/ask** — 智能问答编排(FAM-Core Chat-Handler 调用):请求 `{"prompt"}` → 响应 `{"answer","provider"}`;内部按 Gemini → NVIDIA → 本地 Ollama 顺序,仅两云端都失败才用本地兜底
|
||||||
|
**POST /api/edge/chat** — Ollama 直连代理(兼容旧调用,保留)
|
||||||
**GET /health** — 服务与模型健康状态(任务处理中可能无响应,单 worker 忙)
|
**GET /health** — 服务与模型健康状态(任务处理中可能无响应,单 worker 忙)
|
||||||
|
|
||||||
### 5.2 FAM-Core(NAS :8000)
|
### 5.2 FAM-Core(NAS :8000)
|
||||||
@@ -270,9 +271,9 @@ chat_history 独立表
|
|||||||
- **压缩**:长边 > 1024px 才缩放,JPEG 质量 80
|
- **压缩**:长边 > 1024px 才缩放,JPEG 质量 80
|
||||||
- **异常兜底**:ffprobe 失败退化为 60s 间隔抽帧;帧差异常退化为等距 5 帧
|
- **异常兜底**:ffprobe 失败退化为 60s 间隔抽帧;帧差异常退化为等距 5 帧
|
||||||
|
|
||||||
### 6.2 Ollama 调用(ARM CPU 实测调优,专职文本任务)
|
### 6.2 本地 Ollama(仅智能问答兜底,ARM CPU)
|
||||||
|
|
||||||
本地 Ollama(qwen2.5:7b,纯文本模型)**不参与视觉分析**,专职承担文本融合与 AI 对话两项纯文本任务。视觉分析全部由云端模型承担。以下参数作为文本任务的调优依据保留。
|
本地 Ollama(qwen2.5:7b,纯文本模型)**不参与视觉分析、不参与云端结果融合**。它只在**智能问答**场景下、且 Gemini 与 NVIDIA 两云端模型都失败时才被启用作为兜底。视觉分析与结构化输出全部由云端模型承担。以下参数作为问答任务的调优依据保留。
|
||||||
|
|
||||||
| 参数 | 值 | 依据 |
|
| 参数 | 值 | 依据 |
|
||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
@@ -308,8 +309,8 @@ chat_history 独立表
|
|||||||
| `nvidia` | `NvidiaVisionAdapter` | **vision** | **openai SDK**(NIM 兼容 OpenAI API 规范) | 待实现 |
|
| `nvidia` | `NvidiaVisionAdapter` | **vision** | **openai SDK**(NIM 兼容 OpenAI API 规范) | 待实现 |
|
||||||
|
|
||||||
**role 语义**:
|
**role 语义**:
|
||||||
- `vision`:参与视觉分析阶段,按 fallback 顺序降级
|
- `vision`:参与视觉分析阶段,按 fallback 顺序降级,直出结构化 JSON
|
||||||
- `text`:参与文本融合与 AI 对话,不参与视觉分析
|
- `text`:仅参与智能问答(`usage: qa_fallback`),且为 Gemini/NVIDIA 都失败时的兜底,不参与视觉分析、不参与云端结果融合
|
||||||
|
|
||||||
**NvidiaVisionAdapter 关键实现**(`fam_edge/adapters/nvidia_adapter.py`):
|
**NvidiaVisionAdapter 关键实现**(`fam_edge/adapters/nvidia_adapter.py`):
|
||||||
|
|
||||||
@@ -328,24 +329,39 @@ chat_history 独立表
|
|||||||
**视觉分析降级链路(fallback 模式,仅云端 role=vision 模型)**:
|
**视觉分析降级链路(fallback 模式,仅云端 role=vision 模型)**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Gemini (gemini-1.5-flash, role=vision)
|
Gemini (gemini-flash-latest, role=vision) —— 多图单请求直出结构化 JSON
|
||||||
│ 失败 / 熔断 OPEN / 超时
|
│ 失败 / 熔断 OPEN / 超时
|
||||||
▼
|
▼
|
||||||
NVIDIA NIM (llama-3.2-11b-vision-instruct, role=vision)
|
NVIDIA NIM (llama-3.2-11b-vision-instruct, role=vision) —— 逐帧结构化聚合
|
||||||
│ 失败 / 熔断 OPEN / 超时
|
│ 失败 / 熔断 OPEN / 超时
|
||||||
▼
|
▼
|
||||||
任务 FAILED(走重试,不回退本地 Ollama —— 本地仅负责文本)
|
任务 FAILED(走重试,绝不回退本地 Ollama —— 本地仅负责问答兜底)
|
||||||
```
|
```
|
||||||
|
|
||||||
**文本阶段链路**:
|
**格式化阶段(无模型调用,仅数据转换)**:
|
||||||
|
|
||||||
```
|
```
|
||||||
各帧视觉描述(来自云端成功 provider)+ 时间戳 + known_members 上下文
|
云端成功 provider 直出的结构化 JSON(frame_details / 可选 global_summary / entities_json)
|
||||||
▼
|
▼
|
||||||
本地 Ollama (qwen2.5:7b, role=text) 文本融合 → JSON 结构化结果
|
format_cloud_result:字段归一化 → 补 source_providers=[provider] / compute_provider=[provider]
|
||||||
│ 失败 / 超时
|
→ 缺失 entities_json 由 frame_details 推导 → 缺失 global_summary 则事实拼接 → schema 校验
|
||||||
▼
|
▼
|
||||||
任务 FAILED(走重试)
|
合法入库结构,随响应返回 NAS 直接落库(本地模型不介入)
|
||||||
|
```
|
||||||
|
|
||||||
|
**智能问答降级链路(chat 场景)**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Gemini (role=vision, 也参与问答)
|
||||||
|
│ 失败 / 熔断 OPEN / 超时
|
||||||
|
▼
|
||||||
|
NVIDIA NIM (role=vision, 也参与问答)
|
||||||
|
│ 失败 / 熔断 OPEN / 超时
|
||||||
|
▼
|
||||||
|
本地 Ollama (qwen2.5:7b, role=text, usage=qa_fallback) —— 仅当两云端都失败才启用
|
||||||
|
│ 失败
|
||||||
|
▼
|
||||||
|
返回"所有模型均不可用"
|
||||||
```
|
```
|
||||||
|
|
||||||
**熔断器策略**(按 provider 独立,仅云端模型启用):
|
**熔断器策略**(按 provider 独立,仅云端模型启用):
|
||||||
|
|||||||
@@ -37,6 +37,6 @@ video_server:
|
|||||||
video_dir: "/volume1/surveillance"
|
video_dir: "/volume1/surveillance"
|
||||||
|
|
||||||
chat_handler:
|
chat_handler:
|
||||||
ollama_url: "http://129.146.203.203:5000/api/edge/chat"
|
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||||
model_name: "llava-phi3"
|
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
||||||
timeout: 120
|
timeout: 120
|
||||||
|
|||||||
@@ -71,27 +71,25 @@ def _aggregate_by_hour(details):
|
|||||||
return '\n'.join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _call_ollama(prompt: str) -> str:
|
def _call_edge_qa(prompt: str) -> str:
|
||||||
"""调用 Oracle Ollama 纯文本模式"""
|
"""调用 FAM-Edge 问答编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)"""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
ollama_url = cfg.get('chat_handler', {}).get(
|
qa_url = cfg.get('chat_handler', {}).get(
|
||||||
'ollama_url', 'http://localhost:11434/api/generate'
|
'qa_url', 'http://129.146.203.203:5000/api/edge/chat/ask'
|
||||||
)
|
)
|
||||||
model_name = cfg.get('chat_handler', {}).get('model_name', 'llava-phi3')
|
|
||||||
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
|
||||||
|
|
||||||
resp = requests.post(ollama_url, json={
|
resp = requests.post(qa_url, json={"prompt": prompt}, timeout=timeout)
|
||||||
"model": model_name,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
"options": {"temperature": 0.3}
|
|
||||||
}, timeout=timeout)
|
|
||||||
|
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
return resp.json().get('response', '')
|
data = resp.json()
|
||||||
|
answer = data.get('answer', '')
|
||||||
|
provider = data.get('provider', 'unknown')
|
||||||
|
logger.info(f"问答由 {provider} 提供回答(长度={len(answer)})")
|
||||||
|
return answer
|
||||||
else:
|
else:
|
||||||
logger.error(f"Ollama 调用失败: {resp.status_code} {resp.text}")
|
logger.error(f"Edge 问答调用失败: {resp.status_code} {resp.text[:200]}")
|
||||||
raise Exception(f"Ollama error: {resp.status_code}")
|
raise Exception(f"Edge QA error: {resp.status_code}")
|
||||||
|
|
||||||
|
|
||||||
@chat_bp.route('/api/chat/ask', methods=['POST'])
|
@chat_bp.route('/api/chat/ask', methods=['POST'])
|
||||||
@@ -136,9 +134,9 @@ def chat_ask():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
answer = _call_ollama(prompt)
|
answer = _call_edge_qa(prompt)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ollama 调用失败: {e}")
|
logger.error(f"问答编排调用失败: {e}")
|
||||||
return jsonify({"error": f"AI 调用失败: {e}"}), 503
|
return jsonify({"error": f"AI 调用失败: {e}"}), 503
|
||||||
|
|
||||||
# 3. 写入 chat_history
|
# 3. 写入 chat_history
|
||||||
|
|||||||
@@ -39,9 +39,15 @@ timeout:
|
|||||||
callback: 30
|
callback: 30
|
||||||
overall: 1800
|
overall: 1800
|
||||||
|
|
||||||
# 多模型池配置
|
# 多模型池配置(新框架:本地大模型不参与视频分析,仅智能问答兜底)
|
||||||
# 视觉分析: Gemini(主) -> NVIDIA NIM(备) 顺序降级; 全失败 -> 任务 FAILED 走重试
|
#
|
||||||
# 文本融合/对话: 本地 Ollama qwen2.5:7b 专职 (不参与视觉)
|
# 视频分析链路(推送模式):
|
||||||
|
# 云端 VLM 直接产出结构化 JSON (global_summary / entities_json / frame_details)
|
||||||
|
# -> Edge 仅做格式化/校验 (format_cloud_result) -> 直接回写 NAS,无本地融合步骤
|
||||||
|
# 视觉角色: Gemini(主) -> NVIDIA NIM(备) 顺序降级; 两云端全失败 -> 任务 FAILED 走重试
|
||||||
|
#
|
||||||
|
# 智能问答链路:
|
||||||
|
# Gemini -> NVIDIA -> 本地 Ollama (仅当两云端都失败才启用本地兜底)
|
||||||
models:
|
models:
|
||||||
- provider: "gemini"
|
- provider: "gemini"
|
||||||
role: "vision"
|
role: "vision"
|
||||||
@@ -66,12 +72,14 @@ models:
|
|||||||
threshold: 3
|
threshold: 3
|
||||||
cooldown: 600
|
cooldown: 600
|
||||||
|
|
||||||
|
# 本地模型:纯文本 qwen2.5:7b,仅参与智能问答,作为 Gemini/NVIDIA 都失败时的兜底
|
||||||
- provider: "ollama"
|
- provider: "ollama"
|
||||||
role: "text"
|
role: "text"
|
||||||
|
usage: "qa_fallback"
|
||||||
enabled: true
|
enabled: true
|
||||||
model_name: "qwen2.5:7b"
|
model_name: "qwen2.5:7b"
|
||||||
base_url: "http://localhost:11434"
|
base_url: "http://localhost:11434"
|
||||||
timeout: 300
|
timeout: 120
|
||||||
num_predict: 512
|
num_predict: 512
|
||||||
circuit_breaker:
|
circuit_breaker:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
AI-Orchestrator - 多模型并行编排
|
AI-Orchestrator - 多模型编排
|
||||||
|
|
||||||
流程:
|
视频分析链路(新框架):
|
||||||
1. 加载所有启用的模型适配器
|
1. 加载所有启用的模型适配器
|
||||||
2. 健康检查
|
2. 健康检查
|
||||||
3. 抽帧 + 关键帧筛选 + 压缩
|
3. 抽帧 + 关键帧筛选 + 压缩
|
||||||
4. 并行调用所有健康模型(ThreadPoolExecutor)
|
4. 云端 VLM 视觉分析(Gemini 主 / NVIDIA 兜底),直出结构化 JSON
|
||||||
5. 文本融合(多模型输出平等交叉验证)
|
5. format_cloud_result:对云端结果做**格式化/校验**(无本地模型调用,不汇总摘要)
|
||||||
6. 回调 NAS
|
6. 同步返回 NAS → 落库
|
||||||
7. 清理临时文件
|
|
||||||
|
智能问答链路(新框架):
|
||||||
|
- run_qa:Gemini → NVIDIA → 本地 Ollama(仅当两云端都失败才用本地兜底)
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
@@ -16,63 +18,17 @@ import base64
|
|||||||
import requests
|
import requests
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
|
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from ..logger import setup_logger, log_task
|
from ..logger import setup_logger, log_task
|
||||||
from ..config_loader import load_config
|
from ..config_loader import load_config
|
||||||
from ..model_adapters.adapter_factory import build_adapters
|
from ..model_adapters.adapter_factory import build_adapters
|
||||||
from ..model_adapters.base_adapter import BaseModelAdapter
|
from ..model_adapters.base_adapter import BaseModelAdapter
|
||||||
from ..video_preprocessor.preprocessor import VideoPreprocessor
|
from ..video_preprocessor.preprocessor import VideoPreprocessor
|
||||||
from .json_parser import parse_vlm_json, VLMOutputInvalidError
|
from .json_parser import VLMOutputInvalidError, validate_schema
|
||||||
|
|
||||||
logger = setup_logger('fam-edge.orchestrator')
|
logger = setup_logger('fam-edge.orchestrator')
|
||||||
|
|
||||||
FUSION_SYSTEM_PROMPT = """你是一个无情的数据提取器。不要输出任何思考过程,只输出合法 JSON。
|
|
||||||
|
|
||||||
输入参数:
|
|
||||||
- 多模型视觉分析日志(每个模型独立输出,平等对待,交叉验证):
|
|
||||||
{model_outputs}
|
|
||||||
- 已知成员清单: {known_members}
|
|
||||||
|
|
||||||
执行规则:
|
|
||||||
1. 多个模型的输出平等对待,交叉验证:
|
|
||||||
- 多个模型一致描述的内容 → 可信度高,必须纳入 frame_details,source_providers 列出所有一致的模型
|
|
||||||
- 仅单一模型描述的内容 → 纳入 frame_details,source_providers 仅含该模型
|
|
||||||
- 多个模型冲突时(如人物动作描述不一致)→ 以多数模型一致为准,source_providers 列出多数派模型
|
|
||||||
2. 画面人物按特征匹配已知成员清单:
|
|
||||||
- 匹配到已命名成员(real_name 非空)→ person 字段填 real_name
|
|
||||||
- 匹配到未命名成员(real_name 为空)→ person 字段填 abstract_label
|
|
||||||
- 都不匹配 → 按出现顺序赋予新标识"人物B"、"人物C"...
|
|
||||||
3. 提取每张关键帧对应的时间点、人物、动作、衣着,输出到 frame_details 数组。
|
|
||||||
4. frame_details 每条必须包含 source_providers 数组。
|
|
||||||
5. compute_provider 字段填入本次实际成功调用的所有模型标识数组(去重)。
|
|
||||||
6. 仅输出合法 JSON,不输出任何思考过程、markdown 标记或注释。
|
|
||||||
|
|
||||||
输出 JSON 结构:
|
|
||||||
{{
|
|
||||||
"global_summary": "字符串,整个时段的整体摘要,简体中文",
|
|
||||||
"entities_json": [
|
|
||||||
{{
|
|
||||||
"person": "字符串",
|
|
||||||
"action": "字符串",
|
|
||||||
"clothing": "字符串"
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"frame_details": [
|
|
||||||
{{
|
|
||||||
"frame_index": "数字",
|
|
||||||
"frame_timestamp": "字符串,ISO 8601 格式时间戳",
|
|
||||||
"person": "字符串",
|
|
||||||
"action": "字符串",
|
|
||||||
"clothing": "字符串",
|
|
||||||
"is_attention_event": "布尔值",
|
|
||||||
"source_providers": "数组"
|
|
||||||
}}
|
|
||||||
],
|
|
||||||
"compute_provider": "数组"
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class AIOrchestrator:
|
class AIOrchestrator:
|
||||||
"""AI 编排器"""
|
"""AI 编排器"""
|
||||||
@@ -96,7 +52,7 @@ class AIOrchestrator:
|
|||||||
def run_visual_analysis(self, adapters: List[BaseModelAdapter],
|
def run_visual_analysis(self, adapters: List[BaseModelAdapter],
|
||||||
frame_paths: List[str],
|
frame_paths: List[str],
|
||||||
frame_timestamps: List[str],
|
frame_timestamps: List[str],
|
||||||
known_members_context: str) -> Dict[str, str]:
|
known_members_context: str) -> Dict[str, dict]:
|
||||||
"""视觉分析阶段:仅 role=vision 的适配器参与
|
"""视觉分析阶段:仅 role=vision 的适配器参与
|
||||||
|
|
||||||
orchestrator.mode:
|
orchestrator.mode:
|
||||||
@@ -141,7 +97,7 @@ class AIOrchestrator:
|
|||||||
return model_outputs
|
return model_outputs
|
||||||
|
|
||||||
def _run_visual_ensemble(self, vision_adapters, frame_paths,
|
def _run_visual_ensemble(self, vision_adapters, frame_paths,
|
||||||
frame_timestamps, known_members_context) -> Dict[str, str]:
|
frame_timestamps, known_members_context) -> Dict[str, dict]:
|
||||||
"""并行调用所有健康 vision 模型,保留全部成功结果(交叉验证)"""
|
"""并行调用所有健康 vision 模型,保留全部成功结果(交叉验证)"""
|
||||||
model_outputs = {}
|
model_outputs = {}
|
||||||
max_timeout = max((a.get_timeout() for a in vision_adapters), default=240)
|
max_timeout = max((a.get_timeout() for a in vision_adapters), default=240)
|
||||||
@@ -180,68 +136,115 @@ class AIOrchestrator:
|
|||||||
adapter.get_circuit_breaker().record_failure()
|
adapter.get_circuit_breaker().record_failure()
|
||||||
return model_outputs
|
return model_outputs
|
||||||
|
|
||||||
def run_text_fusion(self, model_outputs: Dict[str, str],
|
def format_cloud_result(self, provider: str, raw_result: dict,
|
||||||
known_members_context: str,
|
known_members_context: str = '',
|
||||||
task_id: int) -> dict:
|
task_id: int = 0) -> dict:
|
||||||
"""文本融合阶段 - 多模型输出平等交叉验证"""
|
"""格式化云端 VLM 直出的结构化结果(**无本地模型调用**)。
|
||||||
# 构建 model_outputs 文本
|
|
||||||
outputs_text = '\n'.join(
|
|
||||||
f" - {provider} 输出: {output}" for provider, output in model_outputs.items()
|
|
||||||
)
|
|
||||||
|
|
||||||
prompt = FUSION_SYSTEM_PROMPT.format(
|
- 云端模型已产出结构化数据(frame_details / 可选 global_summary / entities_json)
|
||||||
model_outputs=outputs_text,
|
- 本方法仅做:字段归一化、source_providers 与 compute_provider 填充、
|
||||||
known_members=known_members_context or '(暂无已知成员)'
|
entities 推导、global_summary 缺失时格式化生成
|
||||||
)
|
- 解析/校验失败抛 VLMOutputInvalidError
|
||||||
|
"""
|
||||||
|
if not isinstance(raw_result, dict):
|
||||||
|
raise VLMOutputInvalidError("云端视觉模型未返回结构化数据(dict)")
|
||||||
|
|
||||||
# 调用文本角色模型(role=text,默认 ollama / qwen2.5:7b)做融合
|
data = dict(raw_result)
|
||||||
text_cfg = next(
|
frame_details = data.get('frame_details')
|
||||||
(cfg for cfg in self.config.get('models', []) if cfg.get('role') == 'text'), None
|
if not isinstance(frame_details, list) or not frame_details:
|
||||||
) or next(
|
raise VLMOutputInvalidError("云端结果缺少非空的 frame_details")
|
||||||
(cfg for cfg in self.config.get('models', []) if cfg.get('provider') == 'ollama'), None
|
|
||||||
)
|
|
||||||
if not text_cfg:
|
|
||||||
raise VLMOutputInvalidError("没有文本角色模型配置,无法执行文本融合")
|
|
||||||
|
|
||||||
base_url = text_cfg.get('base_url', 'http://localhost:11434')
|
# 归一化每条 frame_detail
|
||||||
model_name = text_cfg.get('model_name', 'qwen2.5:7b')
|
normalized = []
|
||||||
fusion_timeout = self.timeout_cfg.get('vlm_fusion', 300)
|
for f in frame_details:
|
||||||
num_predict = text_cfg.get('num_predict', 1024)
|
if not isinstance(f, dict):
|
||||||
|
continue
|
||||||
|
sp = f.get('source_providers')
|
||||||
|
if not isinstance(sp, list) or not sp:
|
||||||
|
sp = [provider]
|
||||||
|
normalized.append({
|
||||||
|
"frame_index": int(f.get("frame_index", len(normalized) + 1)),
|
||||||
|
"frame_timestamp": str(f.get("frame_timestamp", "")),
|
||||||
|
"person": str(f.get("person", "无人")),
|
||||||
|
"action": str(f.get("action", "")),
|
||||||
|
"clothing": str(f.get("clothing", "")),
|
||||||
|
"is_attention_event": bool(f.get("is_attention_event", False)),
|
||||||
|
"source_providers": [str(p) for p in sp],
|
||||||
|
})
|
||||||
|
if not normalized:
|
||||||
|
raise VLMOutputInvalidError("frame_details 解析后为空")
|
||||||
|
data['frame_details'] = normalized
|
||||||
|
|
||||||
start = time.time()
|
# compute_provider:本次实际成功的云端模型
|
||||||
resp = requests.post(
|
data['compute_provider'] = [provider]
|
||||||
f"{base_url}/api/generate",
|
|
||||||
json={
|
|
||||||
"model": model_name,
|
|
||||||
"prompt": prompt,
|
|
||||||
"stream": False,
|
|
||||||
"format": "json",
|
|
||||||
"options": {"temperature": 0.0, "num_predict": num_predict}
|
|
||||||
},
|
|
||||||
timeout=fusion_timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code != 200:
|
# entities_json:缺失时由 frame_details 推导(按人物去重)
|
||||||
raise VLMOutputInvalidError(f"融合阶段 Ollama 调用失败: {resp.status_code}")
|
if not data.get('entities_json'):
|
||||||
|
seen = set()
|
||||||
|
ents = []
|
||||||
|
for f in normalized:
|
||||||
|
p = f['person']
|
||||||
|
if p and p != '无人' and p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
ents.append({
|
||||||
|
"person": p,
|
||||||
|
"action": f['action'],
|
||||||
|
"clothing": f['clothing'],
|
||||||
|
})
|
||||||
|
data['entities_json'] = ents
|
||||||
|
|
||||||
raw_output = resp.json().get('response', '')
|
# global_summary:云端未给则格式化生成(非 LLM 汇总,仅拼接事实)
|
||||||
duration_ms = int((time.time() - start) * 1000)
|
if not data.get('global_summary'):
|
||||||
log_task(logger, task_id, 'vlm_fusion', f'融合完成,原始输出长度={len(raw_output)}', duration_ms=duration_ms)
|
data['global_summary'] = self._build_summary_from_frames(normalized)
|
||||||
|
|
||||||
# 解析 JSON(三层容错)
|
return validate_schema(data)
|
||||||
result = parse_vlm_json(raw_output)
|
|
||||||
|
|
||||||
# 确保 compute_provider 与实际调用的模型一致
|
def _build_summary_from_frames(self, frame_details: List[dict]) -> str:
|
||||||
result['compute_provider'] = list(model_outputs.keys())
|
"""当云端模型未提供 global_summary 时,由 frame_details 格式化生成摘要。
|
||||||
|
注意:这是确定性事实拼接,非 LLM 二次汇总。"""
|
||||||
|
persons = {}
|
||||||
|
has_attention = False
|
||||||
|
for f in frame_details:
|
||||||
|
p = f['person']
|
||||||
|
if p and p != '无人':
|
||||||
|
persons.setdefault(p, set()).add(f['action'])
|
||||||
|
if f.get('is_attention_event'):
|
||||||
|
has_attention = True
|
||||||
|
if not persons:
|
||||||
|
summary = "整个时段内画面中未检测到人物出现,主要为环境静态画面。"
|
||||||
|
else:
|
||||||
|
parts = []
|
||||||
|
for p, acts in persons.items():
|
||||||
|
acts_desc = "、".join(sorted(a for a in acts if a)) or "无明显动作"
|
||||||
|
parts.append(f"{p}({acts_desc})")
|
||||||
|
summary = f"时段内检测到:{';'.join(parts)}。"
|
||||||
|
if has_attention:
|
||||||
|
summary += " ⚠️ 存在需关注的异常事件。"
|
||||||
|
return summary
|
||||||
|
|
||||||
# 确保 frame_details 的 source_providers 只包含实际成功的模型
|
def run_qa(self, prompt: str, max_tokens: int = 512) -> Tuple[Optional[str], Optional[str]]:
|
||||||
valid_providers = set(model_outputs.keys())
|
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(仅当两云端都失败才用本地兜底)。
|
||||||
for frame in result.get('frame_details', []):
|
|
||||||
frame['source_providers'] = [
|
|
||||||
p for p in frame.get('source_providers', []) if p in valid_providers
|
|
||||||
] or list(valid_providers)
|
|
||||||
|
|
||||||
return result
|
返回 (answer, provider);全部失败返回 (None, None)。
|
||||||
|
"""
|
||||||
|
qa_order = ['gemini', 'nvidia', 'ollama']
|
||||||
|
for name in qa_order:
|
||||||
|
adapter = next((a for a in self.adapters if a.provider_name == name), None)
|
||||||
|
if adapter is None:
|
||||||
|
logger.warning(f"[qa] 未配置模型 {name},跳过")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if adapter.get_circuit_breaker().is_open():
|
||||||
|
logger.warning(f"[qa] {name} 熔断器 OPEN,跳过")
|
||||||
|
continue
|
||||||
|
answer = adapter.chat(prompt, max_tokens=max_tokens)
|
||||||
|
if answer:
|
||||||
|
logger.info(f"[qa] 由 {name} 回答(长度={len(answer)})")
|
||||||
|
return answer, name
|
||||||
|
logger.warning(f"[qa] {name} 返回空")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[qa] {name} 调用异常: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
def send_callback(self, webhook_url: str, task_id: int,
|
def send_callback(self, webhook_url: str, task_id: int,
|
||||||
result: dict, camera_name: str = '',
|
result: dict, camera_name: str = '',
|
||||||
@@ -340,8 +343,10 @@ class AIOrchestrator:
|
|||||||
if not model_outputs:
|
if not model_outputs:
|
||||||
raise Exception('All models failed in visual analysis')
|
raise Exception('All models failed in visual analysis')
|
||||||
|
|
||||||
# 4. 文本融合
|
# 4. 云端直出结果格式化(无本地融合)
|
||||||
fusion_result = self.run_text_fusion(model_outputs, known_members, task_id)
|
provider = next(iter(model_outputs))
|
||||||
|
fusion_result = self.format_cloud_result(
|
||||||
|
provider, model_outputs[provider], known_members, task_id)
|
||||||
|
|
||||||
# 5. 回调
|
# 5. 回调
|
||||||
# 从视频文件名推断 camera_name
|
# 从视频文件名推断 camera_name
|
||||||
@@ -359,7 +364,7 @@ class AIOrchestrator:
|
|||||||
log_task(logger, task_id, 'overall', f'任务完成', duration_ms=total_ms)
|
log_task(logger, task_id, 'overall', f'任务完成', duration_ms=total_ms)
|
||||||
|
|
||||||
except VLMOutputInvalidError as e:
|
except VLMOutputInvalidError as e:
|
||||||
logger.error(f"[task_id={task_id}] VLM 输出解析失败: {e}")
|
logger.error(f"[task_id={task_id}] 云端结果格式化失败: {e}")
|
||||||
self.send_failure_callback(webhook_url, task_id, 'vlm_fusion', str(e))
|
self.send_failure_callback(webhook_url, task_id, 'vlm_fusion', str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[task_id={task_id}] 任务处理失败: {e}", exc_info=True)
|
logger.error(f"[task_id={task_id}] 任务处理失败: {e}", exc_info=True)
|
||||||
@@ -429,8 +434,10 @@ class AIOrchestrator:
|
|||||||
if not model_outputs:
|
if not model_outputs:
|
||||||
raise Exception('All models failed in visual analysis')
|
raise Exception('All models failed in visual analysis')
|
||||||
|
|
||||||
# 4. 文本融合
|
# 4. 云端直出结果格式化(无本地融合)
|
||||||
fusion_result = self.run_text_fusion(model_outputs, known_members, task_id)
|
provider = next(iter(model_outputs))
|
||||||
|
fusion_result = self.format_cloud_result(
|
||||||
|
provider, model_outputs[provider], known_members, task_id)
|
||||||
|
|
||||||
total_ms = int((time.time() - start_time) * 1000)
|
total_ms = int((time.time() - start_time) * 1000)
|
||||||
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
|
log_task(logger, task_id, 'overall', '推送任务完成', duration_ms=total_ms)
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ def health():
|
|||||||
|
|
||||||
@api_bp.route('/api/edge/chat', methods=['POST'])
|
@api_bp.route('/api/edge/chat', methods=['POST'])
|
||||||
def chat_proxy():
|
def chat_proxy():
|
||||||
"""代理转发至本地 Ollama /api/generate(Ollama 未对外暴露)"""
|
"""代理转发至本地 Ollama /api/generate(兼容旧调用,Ollama 未对外暴露)"""
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "Invalid JSON"}), 400
|
return jsonify({"error": "Invalid JSON"}), 400
|
||||||
@@ -165,3 +165,26 @@ def chat_proxy():
|
|||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
logger.error(f"Chat proxy error: {e}")
|
logger.error(f"Chat proxy error: {e}")
|
||||||
return jsonify({"error": f"Ollama unreachable: {e}"}), 502
|
return jsonify({"error": f"Ollama unreachable: {e}"}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/api/edge/chat/ask', methods=['POST'])
|
||||||
|
def chat_ask():
|
||||||
|
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底)
|
||||||
|
|
||||||
|
请求: {"prompt": "..."}
|
||||||
|
响应: {"answer": "...", "provider": "gemini"|"nvidia"|"ollama"}
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data or 'prompt' not in data:
|
||||||
|
return jsonify({"error": "缺少必填字段: prompt"}), 400
|
||||||
|
|
||||||
|
prompt = data['prompt']
|
||||||
|
max_tokens = int(data.get('max_tokens', 512))
|
||||||
|
|
||||||
|
answer, provider = get_orchestrator().run_qa(prompt, max_tokens=max_tokens)
|
||||||
|
if answer is None:
|
||||||
|
return jsonify({
|
||||||
|
"error": "所有模型均不可用(Gemini / NVIDIA / Ollama 全部失败)"
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
return jsonify({"answer": answer, "provider": provider}), 200
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
模型适配器基类 - 所有模型适配器的抽象基类
|
模型适配器基类 - 所有模型适配器的抽象基类
|
||||||
|
|
||||||
新增模型只需继承此类并实现 4 个方法:
|
新增模型只需继承此类并实现方法:
|
||||||
1. health_check() -> bool
|
1. health_check() -> bool
|
||||||
2. analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[str]
|
2. analyze_frames(frame_paths, frame_timestamps, known_members_context) -> Optional[dict]
|
||||||
3. get_timeout() -> int
|
- 视觉分析:输入帧图片路径 + 时间戳 + 成员清单,直接输出**结构化结果 dict**
|
||||||
4. get_circuit_breaker() -> CircuitBreaker
|
(含 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 abc import ABC, abstractmethod
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
class BaseModelAdapter(ABC):
|
class BaseModelAdapter(ABC):
|
||||||
@@ -17,7 +23,7 @@ class BaseModelAdapter(ABC):
|
|||||||
def __init__(self, provider_name: str, config: dict):
|
def __init__(self, provider_name: str, config: dict):
|
||||||
self.provider_name = provider_name # 如 "ollama", "gemini"
|
self.provider_name = provider_name # 如 "ollama", "gemini"
|
||||||
self.config = config
|
self.config = config
|
||||||
# 角色: vision=视觉分析, text=文本融合/对话; 默认 vision
|
# 角色: vision=视觉分析, text=智能问答兜底(本地模型); 默认 vision
|
||||||
self.role = config.get('role', 'vision')
|
self.role = config.get('role', 'vision')
|
||||||
|
|
||||||
def get_role(self) -> str:
|
def get_role(self) -> str:
|
||||||
@@ -32,11 +38,28 @@ class BaseModelAdapter(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
def analyze_frames(self, frame_paths: List[str],
|
def analyze_frames(self, frame_paths: List[str],
|
||||||
frame_timestamps: List[str],
|
frame_timestamps: List[str],
|
||||||
known_members_context: str) -> Optional[str]:
|
known_members_context: str) -> Optional[Dict]:
|
||||||
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,输出自然语言描述。
|
"""视觉分析:输入帧图片路径 + 时间戳 + 成员清单,
|
||||||
失败/超时返回 None。"""
|
直接输出结构化结果 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
|
pass
|
||||||
|
|
||||||
|
def chat(self, prompt: str, max_tokens: int = 512) -> Optional[str]:
|
||||||
|
"""纯文本问答(智能问答场景)。默认不实现。"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)")
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_timeout(self) -> int:
|
def get_timeout(self) -> int:
|
||||||
"""该模型的调用超时秒数"""
|
"""该模型的调用超时秒数"""
|
||||||
|
|||||||
@@ -3,31 +3,32 @@ GeminiAdapter - Google Gemini 云端 VLM 适配器
|
|||||||
|
|
||||||
provider_name = "gemini"
|
provider_name = "gemini"
|
||||||
模型: gemini-flash-latest (v1beta 下 gemini-1.5-flash 会 404,用 flash-latest 别名)
|
模型: gemini-flash-latest (v1beta 下 gemini-1.5-flash 会 404,用 flash-latest 别名)
|
||||||
角色: vision (视觉分析)
|
角色: vision (视觉分析直出结构化 JSON) + 智能问答
|
||||||
健康检查: GET /v1beta/models?key=...
|
健康检查: GET /v1beta/models?key=...
|
||||||
熔断器: 启用
|
熔断器: 启用
|
||||||
逐帧分析: 与 NVIDIA 统一流程,逐帧调用(也规避多图返回不稳定)
|
视觉分析: 多图单请求直出结构化 JSON(global_summary/entities_json/frame_details)
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
import requests
|
import requests
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .base_adapter import BaseModelAdapter
|
from .base_adapter import BaseModelAdapter
|
||||||
from .circuit_breaker import CircuitBreaker
|
from .circuit_breaker import CircuitBreaker
|
||||||
from ..logger import setup_logger
|
from ..logger import setup_logger
|
||||||
|
from ..ai_orchestrator.json_parser import parse_vlm_json, VLMOutputInvalidError
|
||||||
|
|
||||||
logger = setup_logger('fam-edge.gemini_adapter')
|
logger = setup_logger('fam-edge.gemini_adapter')
|
||||||
|
|
||||||
|
|
||||||
class GeminiAdapter(BaseModelAdapter):
|
class GeminiAdapter(BaseModelAdapter):
|
||||||
"""Gemini 云端 VLM 适配器 (逐帧)"""
|
"""Gemini 云端 VLM 适配器 (视觉直出结构化 JSON + 文本问答)"""
|
||||||
|
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
super().__init__("gemini", config)
|
super().__init__("gemini", config)
|
||||||
self.model_name = config.get('model_name', 'gemini-flash-latest')
|
self.model_name = config.get('model_name', 'gemini-flash-latest')
|
||||||
self.api_key = self._resolve_key(config.get('api_key', ''))
|
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', {})
|
cb_cfg = config.get('circuit_breaker', {})
|
||||||
self._cb = CircuitBreaker(
|
self._cb = CircuitBreaker(
|
||||||
threshold=cb_cfg.get('threshold', 3),
|
threshold=cb_cfg.get('threshold', 3),
|
||||||
@@ -62,61 +63,143 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
logger.error(f"Gemini 健康检查异常: {e}")
|
logger.error(f"Gemini 健康检查异常: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 视觉分析:多图单请求,直出结构化 JSON
|
||||||
|
# ------------------------------------------------------------------
|
||||||
def analyze_frames(self, frame_paths: List[str],
|
def analyze_frames(self, frame_paths: List[str],
|
||||||
frame_timestamps: List[str],
|
frame_timestamps: List[str],
|
||||||
known_members_context: str) -> Optional[str]:
|
known_members_context: str) -> Optional[Dict]:
|
||||||
if self._cb.is_open():
|
if self._cb.is_open():
|
||||||
logger.warning("Gemini 熔断器 OPEN,跳过调用")
|
logger.warning("Gemini 熔断器 OPEN,跳过调用")
|
||||||
return None
|
return None
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
logger.warning("Gemini API Key 未配置,跳过调用")
|
logger.warning("Gemini API Key 未配置,跳过调用")
|
||||||
return None
|
return None
|
||||||
|
if not frame_paths:
|
||||||
results = []
|
logger.warning("Gemini 无帧可分析")
|
||||||
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
|
return None
|
||||||
self._cb.record_success()
|
|
||||||
logger.info(f"Gemini 视觉分析完成,{len(results)} 帧有描述")
|
|
||||||
return "\n".join(results)
|
|
||||||
|
|
||||||
def _analyze_one(self, path: str, ts: str,
|
parts = []
|
||||||
known_members: str) -> Optional[str]:
|
ts_map = {}
|
||||||
|
for i, (path, ts) in enumerate(zip(frame_paths, frame_timestamps), 1):
|
||||||
try:
|
try:
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
img = base64.b64encode(f.read()).decode('utf-8')
|
img = base64.b64encode(f.read()).decode('utf-8')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"读取图片失败 {path}: {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
|
return None
|
||||||
|
|
||||||
prompt = self._build_prompt(ts, known_members)
|
parts.insert(0, {"text": self._build_structured_prompt(known_members_context)})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{self._base_url}/models/{self.model_name}:generateContent?key={self.api_key}",
|
f"{self._base_url}/models/{self.model_name}:generateContent?key={self.api_key}",
|
||||||
json={"contents": [{"parts": [
|
json={"contents": [{"parts": parts}],
|
||||||
{"text": prompt},
|
"generationConfig": {"temperature": 0.2, "maxOutputTokens": 2048}},
|
||||||
{"inline_data": {"mime_type": "image/jpeg", "data": img}}
|
|
||||||
]}], "generationConfig": {"temperature": 0.2, "maxOutputTokens": 300}},
|
|
||||||
timeout=self.timeout
|
timeout=self.timeout
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
cands = resp.json().get('candidates', [])
|
cands = resp.json().get('candidates', [])
|
||||||
if cands:
|
if cands:
|
||||||
parts = cands[0].get('content', {}).get('parts', [])
|
text = ''.join(
|
||||||
text = ''.join(p.get('text', '') for p in parts).strip()
|
p.get('text', '')
|
||||||
return text or None
|
for p in cands[0].get('content', {}).get('parts', [])
|
||||||
logger.warning("Gemini 返回空 candidates")
|
).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:
|
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:
|
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:
|
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
|
return None
|
||||||
|
|
||||||
def get_timeout(self) -> int:
|
def get_timeout(self) -> int:
|
||||||
@@ -124,16 +207,3 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
|
|
||||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||||
return self._cb
|
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"
|
provider_name = "nvidia"
|
||||||
模型: meta/llama-3.2-11b-vision-instruct
|
模型: meta/llama-3.2-11b-vision-instruct
|
||||||
角色: vision (视觉分析)
|
角色: vision (视觉分析直出结构化 JSON) + 智能问答
|
||||||
SDK: openai (NIM 兼容 OpenAI API 规范)
|
SDK: openai (NIM 兼容 OpenAI API 规范)
|
||||||
限制: NIM 单次请求最多 1 张图 -> 适配器内部逐帧调用
|
限制: NIM 单次请求最多 1 张图 -> 适配器内部逐帧调用,再聚合成 frame_details
|
||||||
熔断器: 启用
|
熔断器: 启用
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import base64
|
import base64
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .base_adapter import BaseModelAdapter
|
from .base_adapter import BaseModelAdapter
|
||||||
from .circuit_breaker import CircuitBreaker
|
from .circuit_breaker import CircuitBreaker
|
||||||
from ..logger import setup_logger
|
from ..logger import setup_logger
|
||||||
|
from ..ai_orchestrator.json_parser import parse_vlm_json, VLMOutputInvalidError
|
||||||
|
|
||||||
logger = setup_logger('fam-edge.nvidia_adapter')
|
logger = setup_logger('fam-edge.nvidia_adapter')
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
class NvidiaVisionAdapter(BaseModelAdapter):
|
class NvidiaVisionAdapter(BaseModelAdapter):
|
||||||
"""NVIDIA NIM 云端 VLM 适配器 (逐帧)"""
|
"""NVIDIA NIM 云端 VLM 适配器 (逐帧结构化 + 聚合; 文本问答)"""
|
||||||
|
|
||||||
def __init__(self, config: dict):
|
def __init__(self, config: dict):
|
||||||
super().__init__("nvidia", config)
|
super().__init__("nvidia", config)
|
||||||
@@ -64,31 +65,41 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
|||||||
logger.warning(f"NVIDIA 健康检查失败: {e}")
|
logger.warning(f"NVIDIA 健康检查失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 视觉分析:逐帧调用(NIM 限 1 图/请求),聚合为 frame_details
|
||||||
|
# ------------------------------------------------------------------
|
||||||
def analyze_frames(self, frame_paths: List[str],
|
def analyze_frames(self, frame_paths: List[str],
|
||||||
frame_timestamps: List[str],
|
frame_timestamps: List[str],
|
||||||
known_members_context: str) -> Optional[str]:
|
known_members_context: str) -> Optional[Dict]:
|
||||||
if self._cb.is_open():
|
if self._cb.is_open():
|
||||||
logger.warning("NVIDIA 熔断器 OPEN,跳过调用")
|
logger.warning("NVIDIA 熔断器 OPEN,跳过调用")
|
||||||
return None
|
return None
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
logger.warning("NVIDIA 客户端未初始化,跳过调用")
|
logger.warning("NVIDIA 客户端未初始化,跳过调用")
|
||||||
return None
|
return None
|
||||||
|
if not frame_paths:
|
||||||
|
logger.warning("NVIDIA 无帧可分析")
|
||||||
|
return None
|
||||||
|
|
||||||
results = []
|
frame_details = []
|
||||||
for path, ts in zip(frame_paths, frame_timestamps):
|
ok = False
|
||||||
desc = self._analyze_one(path, ts, known_members_context)
|
for i, (path, ts) in enumerate(zip(frame_paths, frame_timestamps), 1):
|
||||||
if desc:
|
detail = self._analyze_one_structured(path, ts, i, known_members_context)
|
||||||
results.append(f"[帧] 时间: {ts}\n{desc}")
|
if detail:
|
||||||
|
frame_details.append(detail)
|
||||||
|
ok = True
|
||||||
|
|
||||||
if not results:
|
if not ok:
|
||||||
self._cb.record_failure()
|
self._cb.record_failure()
|
||||||
return None
|
return None
|
||||||
self._cb.record_success()
|
|
||||||
logger.info(f"NVIDIA 视觉分析完成,{len(results)} 帧有描述")
|
|
||||||
return "\n".join(results)
|
|
||||||
|
|
||||||
def _analyze_one(self, path: str, ts: str,
|
self._cb.record_success()
|
||||||
known_members: str) -> Optional[str]:
|
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:
|
try:
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
b64 = base64.b64encode(f.read()).decode('utf-8')
|
b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||||
@@ -96,7 +107,7 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
|||||||
logger.error(f"读取图片失败 {path}: {e}")
|
logger.error(f"读取图片失败 {path}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
prompt = self._build_prompt(ts, known_members)
|
prompt = self._build_structured_prompt(ts, idx, known_members)
|
||||||
try:
|
try:
|
||||||
resp = self._client.chat.completions.create(
|
resp = self._client.chat.completions.create(
|
||||||
model=self.model_name,
|
model=self.model_name,
|
||||||
@@ -109,26 +120,69 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
|||||||
timeout=self.timeout
|
timeout=self.timeout
|
||||||
)
|
)
|
||||||
content = resp.choices[0].message.content
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"NVIDIA 单帧异常: {e}")
|
logger.warning(f"NVIDIA 单帧异常: {e}")
|
||||||
return None
|
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:
|
def get_timeout(self) -> int:
|
||||||
return self.timeout
|
return self.timeout
|
||||||
|
|
||||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||||
return self._cb
|
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"
|
provider_name = "ollama"
|
||||||
模型: llava-phi3
|
模型: qwen2.5:7b(纯文本)
|
||||||
|
角色: text(智能问答兜底;Gemini 与 NVIDIA 均失败时启用)
|
||||||
健康检查: GET /api/tags
|
健康检查: GET /api/tags
|
||||||
|
不参与视觉分析、不参与视频结构化输出(云端 VLM 直出)
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import requests
|
import requests
|
||||||
@@ -115,6 +117,41 @@ class OllamaAdapter(BaseModelAdapter):
|
|||||||
def get_circuit_breaker(self) -> CircuitBreaker:
|
def get_circuit_breaker(self) -> CircuitBreaker:
|
||||||
return self._cb
|
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:
|
def _build_visual_prompt(self, n: int, timestamps: List[str], known_members: str) -> str:
|
||||||
"""构建视觉分析 Prompt"""
|
"""构建视觉分析 Prompt"""
|
||||||
ts_lines = '\n'.join(
|
ts_lines = '\n'.join(
|
||||||
|
|||||||
Reference in New Issue
Block a user