Edge端: - 新增 SQLite 异步任务队列 (queue_manager + consumer) - 新增 TokenBucket 速率限制器 (Gemini 1000 RPM, NVIDIA 40 RPM, burst 2x) - 新增 /api/edge/video/enqueue + /api/edge/results 端点 - 消费者线程从队列消费任务,按速率限制调用AI模型 - orchestrator 集成 rate_limiter,Gemini优先→NVIDIA兜底 NAS端: - Dispatcher 重构为 enqueue 模式(上传后立即返回,不等结果) - 新增 Poller 线程(定期从Edge拉取结果写 MariaDB) - app.py 启动 Poller,config.yaml 新增 poller 配置 - db_layer 更新 valid_stages 添加 'process'
281 lines
9.3 KiB
Python
281 lines
9.3 KiB
Python
"""
|
||
API-Gateway - Flask 蓝图,接收任务
|
||
|
||
模式:
|
||
1. enqueue (异步): NAS 上传视频 → Edge 入队 → 立即返回 → 消费者异步处理 → NAS 轮询拉取结果
|
||
2. push (同步, 兼容保留): NAS 上传 → Edge 同步处理 → 结果随响应返回
|
||
3. analyze (旧拉取模式, 兼容保留)
|
||
"""
|
||
import os
|
||
import threading
|
||
import requests
|
||
from flask import Blueprint, request, jsonify
|
||
|
||
from ..logger import setup_logger
|
||
from ..ai_orchestrator.orchestrator import AIOrchestrator
|
||
from ..video_preprocessor.preprocessor import VideoPreprocessor
|
||
from ..queue import queue_manager
|
||
|
||
logger = setup_logger('fam-edge.api_gateway')
|
||
|
||
api_bp = Blueprint('api_gateway', __name__)
|
||
|
||
_current_task_lock = threading.Lock()
|
||
_currently_processing = False
|
||
|
||
_orchestrator = None
|
||
|
||
|
||
def get_orchestrator():
|
||
global _orchestrator
|
||
if _orchestrator is None:
|
||
_orchestrator = AIOrchestrator()
|
||
return _orchestrator
|
||
|
||
|
||
@api_bp.route('/api/edge/video/analyze', methods=['POST'])
|
||
def receive_task():
|
||
"""接收分析任务"""
|
||
global _currently_processing
|
||
|
||
data = request.get_json(silent=True)
|
||
if not data:
|
||
return jsonify({"error": "Invalid JSON"}), 400
|
||
|
||
task_id = data.get('task_id')
|
||
video_url = data.get('video_url')
|
||
webhook_url = data.get('webhook_url')
|
||
|
||
if not task_id or not video_url or not webhook_url:
|
||
return jsonify({"error": "缺少必填字段: task_id, video_url, webhook_url"}), 400
|
||
|
||
logger.info(f"[task_id={task_id}] 收到任务: {video_url}")
|
||
|
||
# 并发控制
|
||
with _current_task_lock:
|
||
if _currently_processing:
|
||
logger.warning(f"[task_id={task_id}] 队列已满 (当前有任务处理中),返回 429")
|
||
return jsonify({"error": "Queue full", "retry_after": 60}), 429
|
||
_currently_processing = True
|
||
|
||
# 异步处理
|
||
def _process():
|
||
global _currently_processing
|
||
try:
|
||
orch = get_orchestrator()
|
||
orch.process_task(data)
|
||
except Exception as e:
|
||
logger.error(f"[task_id={task_id}] 处理异常: {e}", exc_info=True)
|
||
finally:
|
||
with _current_task_lock:
|
||
_currently_processing = False
|
||
|
||
thread = threading.Thread(target=_process, daemon=True, name=f'task-{task_id}')
|
||
thread.start()
|
||
|
||
return jsonify({"status": "accepted", "task_id": task_id}), 202
|
||
|
||
|
||
@api_bp.route('/api/edge/video/enqueue', methods=['POST'])
|
||
def enqueue_task():
|
||
"""异步模式:接收 multipart 视频上传,入队后立即返回
|
||
|
||
NAS 上传视频 → Edge 保存到磁盘 + 入 SQLite 队列 → 返回 task_id
|
||
消费者线程异步处理,NAS 通过 /api/edge/results 拉取结果
|
||
"""
|
||
task_id_raw = request.form.get('task_id')
|
||
file = request.files.get('video')
|
||
if not task_id_raw or not file:
|
||
return jsonify({"error": "缺少必填字段: task_id, video"}), 400
|
||
|
||
try:
|
||
task_id = int(task_id_raw)
|
||
except ValueError:
|
||
return jsonify({"error": "task_id 必须是整数"}), 400
|
||
|
||
camera_name = request.form.get('camera_name', '')
|
||
event_start_time = request.form.get('event_start_time', '')
|
||
known_members_context = request.form.get('known_members_context', '')
|
||
|
||
upload_dir = os.environ.get('FAM_UPLOAD_DIR', '/tmp/fam_uploads')
|
||
os.makedirs(upload_dir, exist_ok=True)
|
||
video_filename = f"task_{task_id}_{file.filename}"
|
||
video_path = os.path.join(upload_dir, video_filename)
|
||
|
||
try:
|
||
file.save(video_path)
|
||
size_mb = os.path.getsize(video_path) / 1024 / 1024
|
||
logger.info(f"[task_id={task_id}] 入队: {file.filename} ({size_mb:.1f}MB)")
|
||
|
||
queue_id = queue_manager.enqueue(
|
||
nas_task_id=task_id,
|
||
video_filename=file.filename,
|
||
video_path=video_path,
|
||
camera_name=camera_name,
|
||
event_start_time=event_start_time,
|
||
known_members_context=known_members_context,
|
||
)
|
||
|
||
return jsonify({
|
||
"status": "queued",
|
||
"task_id": task_id,
|
||
"queue_id": queue_id,
|
||
}), 202
|
||
|
||
except Exception as e:
|
||
logger.error(f"[task_id={task_id}] 入队失败: {e}", exc_info=True)
|
||
if os.path.exists(video_path):
|
||
os.remove(video_path)
|
||
return jsonify({"error": str(e)}), 500
|
||
|
||
|
||
@api_bp.route('/api/edge/results', methods=['GET'])
|
||
def get_results():
|
||
"""返回已完成但未拉取的结果,标记为已交付"""
|
||
limit = int(request.args.get('limit', 10))
|
||
results = queue_manager.get_undelivered_results(limit=limit)
|
||
|
||
import json
|
||
payload = []
|
||
task_ids = []
|
||
for r in results:
|
||
try:
|
||
result_json = json.loads(r['result_json']) if r['result_json'] else None
|
||
except json.JSONDecodeError:
|
||
result_json = None
|
||
payload.append({
|
||
"nas_task_id": r['nas_task_id'],
|
||
"result": result_json,
|
||
})
|
||
task_ids.append(r['id'])
|
||
|
||
if task_ids:
|
||
queue_manager.mark_delivered(task_ids)
|
||
|
||
return jsonify({"results": payload, "count": len(payload)}), 200
|
||
|
||
|
||
@api_bp.route('/api/edge/queue/stats', methods=['GET'])
|
||
def queue_stats():
|
||
"""队列状态统计"""
|
||
stats = queue_manager.get_queue_stats()
|
||
return jsonify(stats), 200
|
||
|
||
|
||
@api_bp.route('/api/edge/video/push', methods=['POST'])
|
||
def receive_push_task():
|
||
"""推送模式:接收 multipart 视频上传,同步分析,结果随 HTTP 响应返回
|
||
|
||
NAS 无法被 Oracle 反向访问(Tailscale 不通),因此改为 NAS 主动上传视频,
|
||
Edge 用 OpenCV 场景变化检测抽帧后分析,摘要直接放在响应里带回。
|
||
"""
|
||
global _currently_processing
|
||
|
||
task_id_raw = request.form.get('task_id')
|
||
file = request.files.get('video')
|
||
if not task_id_raw or not file:
|
||
return jsonify({"error": "缺少必填字段: task_id, video"}), 400
|
||
|
||
try:
|
||
task_id = int(task_id_raw)
|
||
except ValueError:
|
||
return jsonify({"error": "task_id 必须是整数"}), 400
|
||
|
||
logger.info(f"[task_id={task_id}] 收到推送任务: {file.filename}")
|
||
|
||
# 并发控制(同步处理,占用整个请求周期)
|
||
with _current_task_lock:
|
||
if _currently_processing:
|
||
logger.warning(f"[task_id={task_id}] 已有任务处理中,返回 429")
|
||
return jsonify({"error": "Queue full", "retry_after": 60}), 429
|
||
_currently_processing = True
|
||
|
||
preprocessor = None
|
||
try:
|
||
preprocessor = VideoPreprocessor(task_id)
|
||
video_path = preprocessor.save_upload(file)
|
||
|
||
task_data = {
|
||
"task_id": task_id,
|
||
"camera_name": request.form.get('camera_name', ''),
|
||
"event_start_time": request.form.get('event_start_time', ''),
|
||
"event_end_time": request.form.get('event_end_time', ''),
|
||
"known_members_context": request.form.get('known_members_context', ''),
|
||
}
|
||
|
||
result = get_orchestrator().process_push_task(task_data, video_path, preprocessor)
|
||
return jsonify(result), 200
|
||
|
||
except Exception as e:
|
||
logger.error(f"[task_id={task_id}] 推送任务异常: {e}", exc_info=True)
|
||
return jsonify({
|
||
"task_id": task_id, "status": "failed",
|
||
"failure_stage": "upload", "error_message": str(e)
|
||
}), 200
|
||
finally:
|
||
if preprocessor is not None:
|
||
preprocessor.cleanup()
|
||
with _current_task_lock:
|
||
_currently_processing = False
|
||
|
||
|
||
@api_bp.route('/health', methods=['GET'])
|
||
def health():
|
||
"""健康检查"""
|
||
global _currently_processing
|
||
orch = get_orchestrator()
|
||
healthy = orch.health_check_all()
|
||
if not healthy:
|
||
return jsonify({
|
||
"status": "unavailable",
|
||
"healthy_models": [],
|
||
"processing": _currently_processing
|
||
}), 503
|
||
return jsonify({
|
||
"status": "ok",
|
||
"healthy_models": [a.provider_name for a in healthy],
|
||
"processing": _currently_processing
|
||
}), 200
|
||
|
||
|
||
@api_bp.route('/api/edge/chat', methods=['POST'])
|
||
def chat_proxy():
|
||
"""代理转发至本地 Ollama /api/generate(兼容旧调用,Ollama 未对外暴露)"""
|
||
data = request.get_json(silent=True)
|
||
if not data:
|
||
return jsonify({"error": "Invalid JSON"}), 400
|
||
|
||
try:
|
||
resp = requests.post(
|
||
'http://127.0.0.1:11434/api/generate',
|
||
json=data,
|
||
timeout=data.get('options', {}).get('timeout', 120)
|
||
)
|
||
return jsonify(resp.json()), resp.status_code
|
||
except requests.RequestException as e:
|
||
logger.error(f"Chat proxy error: {e}")
|
||
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
|