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:
@@ -80,6 +80,15 @@ class BaseModelAdapter(ABC):
|
||||
raise NotImplementedError(
|
||||
f"{self.provider_name} 适配器未实现 chat()(不参与智能问答)")
|
||||
|
||||
def chat_stream(self, prompt: str, max_tokens: int = 512):
|
||||
"""流式问答:逐块 yield 文本增量。默认实现退化为"等 chat() 整段返回后
|
||||
一次性当一个大 chunk 吐出"——子类没有真流式 API(或懒得接)时这样也能
|
||||
用,只是没有逐字显示的效果;Gemini 有原生 SSE 流式接口,重写了这个方法。
|
||||
"""
|
||||
result = self.chat(prompt, max_tokens=max_tokens)
|
||||
if result:
|
||||
yield result
|
||||
|
||||
@abstractmethod
|
||||
def get_timeout(self) -> int:
|
||||
"""该模型的调用超时秒数"""
|
||||
|
||||
@@ -74,6 +74,10 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
self.key_labels.append(str(label))
|
||||
self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
|
||||
self.timeout = config.get('timeout', 600)
|
||||
# 问答(chat)专用超时——跟视频分析的 timeout 分开,不能共用 600s:
|
||||
# 智能问答是同步等待用户看结果的交互场景,一个 key/模型卡住不该让用户等
|
||||
# 10 分钟,超时应该短、快速降级到下一个 key/模型/provider
|
||||
self.chat_timeout = config.get('chat_timeout', 20)
|
||||
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
|
||||
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
|
||||
self.model_timeouts = {
|
||||
@@ -431,10 +435,10 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
"generationConfig": {
|
||||
"temperature": temperature,
|
||||
"maxOutputTokens": max_tokens}},
|
||||
timeout=self.timeout
|
||||
timeout=self.chat_timeout
|
||||
)
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 问答超时")
|
||||
logger.warning(f"Gemini {key_label} [{model}] 问答超时({self.chat_timeout}s)")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}")
|
||||
@@ -452,6 +456,69 @@ class GeminiAdapter(BaseModelAdapter):
|
||||
continue
|
||||
return None
|
||||
|
||||
def chat_stream(self, prompt: str, max_tokens: int = 512):
|
||||
"""流式问答:逐块 yield 文本增量。用于聊天界面边生成边显示,不用等全量
|
||||
返回再展示——之前整段等待是"卡住没反馈"体验差的根源之一。
|
||||
|
||||
按 key 轮换 × 模型链依次尝试,但只在"这次尝试还没吐出任何文本"时才允许
|
||||
换下一个 key/模型;一旦已经开始吐字给用户看了,中途出错就直接结束这次
|
||||
生成(不再悄悄换 provider 接着写,否则会出现两段风格/内容不连贯的回答
|
||||
拼在一起,比直接告知"生成中断"更让人困惑)。
|
||||
"""
|
||||
if self._cb.is_open():
|
||||
logger.warning("Gemini 熔断器 OPEN,跳过问答(流式)")
|
||||
return
|
||||
if not self.api_keys:
|
||||
logger.warning("Gemini API Key 未配置,跳过问答(流式)")
|
||||
return
|
||||
got_any = False
|
||||
for idx, api_key in self._rotated_keys():
|
||||
key_label = self.key_labels[idx]
|
||||
for model in self.model_chain:
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{self._base_url}/models/{model}:streamGenerateContent"
|
||||
f"?alt=sse&key={api_key}",
|
||||
json={"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"temperature": 0.3, "maxOutputTokens": max_tokens}},
|
||||
timeout=self.chat_timeout, stream=True,
|
||||
)
|
||||
except requests.Timeout:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 流式问答超时({self.chat_timeout}s)")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"Gemini {key_label} [{model}] 流式问答异常: {e}")
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 流式问答 HTTP {resp.status_code}")
|
||||
resp.close()
|
||||
continue
|
||||
try:
|
||||
for line in resp.iter_lines(decode_unicode=True):
|
||||
if not line or not line.startswith('data: '):
|
||||
continue
|
||||
chunk = line[len('data: '):]
|
||||
try:
|
||||
obj = json.loads(chunk)
|
||||
except ValueError:
|
||||
continue
|
||||
cands = obj.get('candidates', [])
|
||||
text = ''.join(
|
||||
p.get('text', '')
|
||||
for p in (cands[0].get('content', {}) if cands else {}).get('parts', []))
|
||||
if text:
|
||||
got_any = True
|
||||
yield text
|
||||
except Exception as e:
|
||||
logger.warning(f"Gemini {key_label} [{model}] 流式读取中断: {e}")
|
||||
finally:
|
||||
resp.close()
|
||||
if got_any:
|
||||
self._cb.record_success()
|
||||
return # 已经开始吐字,不管这次是否读完都不再换 provider
|
||||
self._cb.record_failure()
|
||||
|
||||
def get_timeout(self) -> int:
|
||||
return self.timeout
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
self.api_key = self._resolve_key(config.get('api_key', ''))
|
||||
self.base_url = config.get('base_url', 'https://integrate.api.nvidia.com/v1')
|
||||
self.timeout = config.get('timeout', 600)
|
||||
# 问答专用超时,跟视频分析分开——交互式问答不该等到跟视频分析一样久
|
||||
self.chat_timeout = config.get('chat_timeout', 20)
|
||||
self.max_base64_mb = float(config.get('max_base64_mb', 20))
|
||||
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
|
||||
self.model_timeouts = {
|
||||
@@ -209,7 +211,7 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
timeout=self.timeout
|
||||
timeout=self.chat_timeout
|
||||
)
|
||||
content = resp.choices[0].message.content
|
||||
if content:
|
||||
|
||||
Reference in New Issue
Block a user