178 lines
6.1 KiB
Python
178 lines
6.1 KiB
Python
"""
|
||
API-Gateway - Flask 蓝图(新架构 v3)
|
||
|
||
端点:
|
||
GET /api/oracle/sync NAS 每 30 分钟拉取增量(since + token 校验)
|
||
POST /api/oracle/people/correct NAS 推送手动命名校正(label -> canonical_name)
|
||
POST /api/edge/chat/ask 智能问答编排(Gemini -> NVIDIA -> Ollama)
|
||
GET /api/oracle/activity 实时服务状态 + 最近活动流
|
||
GET /health 健康检查
|
||
|
||
已移除(v3 去除帧图/avatar 依赖,改用大模型特征值):
|
||
/api/oracle/video/<id>/thumb, /api/oracle/event/<id>/thumb,
|
||
/api/oracle/person/avatar —— 不再生成 jpg,UI 读 sync_people.features_json
|
||
|
||
已移除(旧推送/分块/队列模式): /video/push, /enqueue, /chunk, /assemble,
|
||
/results, /queue/stats, /mark_frames
|
||
"""
|
||
import os
|
||
|
||
from flask import Blueprint, request, jsonify
|
||
|
||
from ..logger import setup_logger
|
||
from .. import state
|
||
from ..qa import QAOrchestrator
|
||
|
||
logger = setup_logger('fam-edge.api_gateway')
|
||
|
||
api_bp = Blueprint('api_gateway', __name__)
|
||
|
||
_qa = None
|
||
|
||
|
||
def get_qa():
|
||
global _qa
|
||
if _qa is None:
|
||
_qa = QAOrchestrator()
|
||
return _qa
|
||
|
||
|
||
def _check_token() -> bool:
|
||
expected = _sync_token()
|
||
token = request.args.get('token') or request.form.get('token') or \
|
||
(request.get_json(silent=True) or {}).get('token', '')
|
||
return bool(expected) and token == expected
|
||
|
||
|
||
_SYNC_TOK = None
|
||
|
||
|
||
def _sync_token():
|
||
global _SYNC_TOK
|
||
if _SYNC_TOK is None:
|
||
from ..config_loader import load_config
|
||
_SYNC_TOK = load_config().get('sync_api', {}).get('token', '${ORACLE_SYNC_TOKEN}')
|
||
if _SYNC_TOK.startswith('${') and _SYNC_TOK.endswith('}'):
|
||
_SYNC_TOK = os.environ.get(_SYNC_TOK[2:-1], '')
|
||
return _SYNC_TOK or ''
|
||
|
||
|
||
def _now_iso_str() -> str:
|
||
from datetime import datetime, timezone, timedelta
|
||
return datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
|
||
|
||
|
||
@api_bp.route('/api/oracle/sync', methods=['GET'])
|
||
def sync_pull():
|
||
"""NAS 拉取增量数据。since=ISO 时间字符串(默认 '' 拉全量)。
|
||
|
||
返回: {videos:[...], events:[...], people:[...], server_time}
|
||
"""
|
||
if not _check_token():
|
||
return jsonify({"error": "unauthorized"}), 401
|
||
since = request.args.get('since', '')
|
||
try:
|
||
delta = state.get_db().get_sync_delta(since)
|
||
except Exception as e:
|
||
logger.error(f"sync_pull 异常: {e}")
|
||
return jsonify({"error": str(e)}), 500
|
||
return jsonify(delta), 200
|
||
|
||
|
||
@api_bp.route('/api/oracle/people/correct', methods=['POST'])
|
||
def people_correct():
|
||
"""NAS 手动命名校正推送。
|
||
|
||
请求: {"label": "人物A", "canonical_name": "张三", "token": "..."}
|
||
更新 people 表(manual 优先,不被 LLM 覆盖),立即重算 known_members_context。
|
||
"""
|
||
if not _check_token():
|
||
return jsonify({"error": "unauthorized"}), 401
|
||
data = request.get_json(silent=True)
|
||
if not data:
|
||
return jsonify({"error": "Invalid JSON"}), 400
|
||
label = (data.get('label') or '').strip()
|
||
canonical = (data.get('canonical_name') or '').strip()
|
||
if not label or not canonical:
|
||
return jsonify({"error": "缺少 label / canonical_name"}), 400
|
||
try:
|
||
state.get_db().set_canonical(label, canonical, source='manual')
|
||
except Exception as e:
|
||
logger.error(f"people_correct 异常: {e}")
|
||
return jsonify({"error": str(e)}), 500
|
||
return jsonify({"status": "ok", "label": label, "canonical_name": canonical}), 200
|
||
|
||
|
||
@api_bp.route('/api/edge/chat/ask', methods=['POST'])
|
||
def chat_ask():
|
||
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底)
|
||
|
||
请求: {"prompt": "...", "max_tokens": 512}
|
||
响应: {"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', 1024))
|
||
|
||
answer, provider = get_qa().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
|
||
|
||
|
||
@api_bp.route('/api/oracle/activity', methods=['GET'])
|
||
def activity():
|
||
"""实时服务状态 + 最近活动流(token 校验)。
|
||
|
||
返回各服务当前状态(队列/当前处理视频/rclone 最近同步/人物合并/模型调用)
|
||
与最近 50 条活动(service_activity,只保留 7 天)。
|
||
"""
|
||
if not _check_token():
|
||
return jsonify({"error": "unauthorized"}), 401
|
||
db = state.get_db()
|
||
queue = state.get_queue()
|
||
# 队列实时状态
|
||
q_status = None
|
||
if queue is not None:
|
||
try:
|
||
q_status = queue.status()
|
||
except Exception as e:
|
||
logger.warning(f"queue.status 异常: {e}")
|
||
# 各服务最近活动(rclone / person / model 切换)
|
||
def _last_activity(service):
|
||
row = db._conn.execute(
|
||
"SELECT service, action, detail, ts FROM service_activity "
|
||
"WHERE service=? ORDER BY id DESC LIMIT 1", (service,)).fetchone()
|
||
return dict(row) if row else None
|
||
# 最近模型调用(实时模型卡)
|
||
model_calls = db._conn.execute(
|
||
"SELECT id, provider, model, filename, started_at, duration_sec, "
|
||
"success, error FROM model_calls ORDER BY id DESC LIMIT 5").fetchall()
|
||
return jsonify({
|
||
"queue": q_status,
|
||
"db": db.get_queue_status(),
|
||
"rclone": _last_activity('rclone'),
|
||
"person": _last_activity('person'),
|
||
"model_calls": [dict(m) for m in model_calls],
|
||
"activities": db.get_recent_activities(50),
|
||
"ts": _now_iso_str(),
|
||
}), 200
|
||
|
||
|
||
@api_bp.route('/health', methods=['GET'])
|
||
def health():
|
||
"""健康检查"""
|
||
try:
|
||
db = state.get_db()
|
||
vids = db._conn.execute(
|
||
"SELECT COUNT(*) c FROM videos WHERE status='done'").fetchone()['c']
|
||
return jsonify({"status": "ok", "processed_videos": vids}), 200
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "error": str(e)}), 500
|