feat: 事件时间轴缩略帧 + 人物管理头像 + 人物合并硬规则校验
## 新架构:Oracle 集中计算 + NAS 代理展示 ### Oracle 端 (fam-edge) - 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存) - 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token) - 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中) - 新增 person_identifier: 人物身份识别模块 - Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级 - frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额 - 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位 ### 人物合并硬规则校验(框架级修复) - person_service: LLM 合并结果落库前加硬冲突检测 - 性别冲突 → 绝不合并 - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人) - oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行 - 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化) - get_events_for_label: 只提取该身份组的特征文本,头像定位更精准 ### NAS 端 (fam-core) - 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片 - app.py 注册 img_bp 蓝图 - oracle_sync / db_layer / member_manager 同步人物表 ### UI 端 (fam-ui) - 事件时间轴: 每条事件卡片加时间点缩略帧 - 人物管理: 每人卡片加头像(150x150 圆角) - parse_persons: 剥离括号备注,与 Oracle 归一化一致 - 新增 EventItem 组件、Timeline 页改造 - Chat / ServiceStatus 页相应调整 ### 数据库 - scripts/ddl.sql: 同步表结构更新 - Oracle people 表: features_json / display_uid / source 字段完善
This commit is contained in:
@@ -14,6 +14,7 @@ API-Gateway - Flask 蓝图(新架构 v3)
|
||||
一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.py),frame_service 不再
|
||||
额外调用任何模型。NAS 经 core 代理读取,不在 NAS 做图像计算。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
@@ -102,6 +103,39 @@ def people_correct():
|
||||
return jsonify({"status": "ok", "label": label, "canonical_name": canonical}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/oracle/identity/correct', methods=['POST'])
|
||||
def identity_correct():
|
||||
"""事件时间轴/人物管理"这个人识别错了"纠错入口(比 people/correct 粒度更细)。
|
||||
|
||||
请求: {"video_id": 123, "current_name": "爷爷", "new_name": "爸爸", "token": "..."}
|
||||
只改这一段视频里被错误识别的那个人,不影响同名字符串在其他视频里的映射——
|
||||
人物 uid 只在单次视频分析内稳定,同一个"人物A"字符串在不同视频里可能是不同
|
||||
真人,纠错必须落到 (video_id, 当前展示名) 这一粒度,不能按全局 label 改。
|
||||
写 manual 来源,受保护不会被后续自动识别覆盖回去;立即重写这段视频的展示数据。
|
||||
"""
|
||||
if not _check_token():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid JSON"}), 400
|
||||
video_id = data.get('video_id')
|
||||
current_name = (data.get('current_name') or '').strip()
|
||||
new_name = (data.get('new_name') or '').strip()
|
||||
if not video_id or not current_name or not new_name:
|
||||
return jsonify({"error": "缺少 video_id / current_name / new_name"}), 400
|
||||
try:
|
||||
video_id = int(video_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "video_id 必须是数字"}), 400
|
||||
try:
|
||||
state.get_db().correct_video_identity(video_id, current_name, new_name)
|
||||
except Exception as e:
|
||||
logger.error(f"identity_correct 异常: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
return jsonify({"status": "ok", "video_id": video_id,
|
||||
"current_name": current_name, "new_name": new_name}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/edge/chat/ask', methods=['POST'])
|
||||
def chat_ask():
|
||||
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底)
|
||||
@@ -125,6 +159,28 @@ def chat_ask():
|
||||
return jsonify({"answer": answer, "provider": provider}), 200
|
||||
|
||||
|
||||
@api_bp.route('/api/edge/chat/ask/stream', methods=['POST'])
|
||||
def chat_ask_stream():
|
||||
"""智能问答编排(流式版):SSE 逐块推送,边生成边显示,不用等全量回答。
|
||||
|
||||
请求同 /api/edge/chat/ask。响应 Content-Type: text/event-stream,
|
||||
每行 `data: <json>\\n\\n`,json 结构见 qa.QAOrchestrator.run_qa_stream 注释。
|
||||
"""
|
||||
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))
|
||||
|
||||
def generate():
|
||||
for event in get_qa().run_qa_stream(prompt, max_tokens=max_tokens):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream',
|
||||
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
|
||||
|
||||
|
||||
@api_bp.route('/api/oracle/activity', methods=['GET'])
|
||||
def activity():
|
||||
"""实时服务状态 + 最近活动流(token 校验)。
|
||||
|
||||
Reference in New Issue
Block a user