""" 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//thumb, /api/oracle/event//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