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:
ericwyuan
2026-08-23 00:13:56 +08:00
parent aca1a674b1
commit 2c5bf950c5
31 changed files with 2166 additions and 25 deletions

View File

@@ -14,6 +14,7 @@ API-Gateway - Flask 蓝图(新架构 v3
一次 Gemini 调用一并产出(见 ai_orchestrator/prompts.pyframe_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 校验)。