Files
sentinel-home-ai/fam-edge/tests/test_oracle_db.py
ericwyuan 5f7b27bf04 fix(fam-edge): 人物识别结果没同步进事件描述文字
用户反馈:事件卡片上方的人物徽章正确显示"媳妇/爸爸",但描述文字里还是
"人物B双手叉腰站在客厅中央;人物A在远处厨房"这种原始 uid——两处对不上。

根因:rewrite_event_person_names() 只改了 person_list_json /
person_appearances_json[].uid 这些结构化字段,从没碰过 description(事件
描述)和 videos.summary_json(会话摘要)——这两个是大模型写的自然语言,
"人物A"这类 uid 是直接以文字形式嵌在句子里的,结构化字段改名不会带着改。

现在 rewrite_event_person_names() 额外对 description/summary_json 做文本
替换。替换顺序按 rename_map 的 key 长度降序——uid 可能带 "#2"/"#3" 这类
同名冲突后缀,"人物A" 是 "人物A#2" 的前缀,短的先替换会把长的错误地部分
命中,长的先替换才对。

同时跑了两次一次性回填修历史数据:
1. 对所有已有 person_identity_map 映射的 1265 个视频重新跑一遍
   rewrite_event_person_names,把之前只改了徽章、没改描述文字的历史数据
   补上(events 里"纯人物X不带真名"的还剩 258 条,涉及 94 个视频——都是
   性别/年龄特征本来就是 unknown 的画面,闭集识别本来就该留空不瞎猜,
   不是回填没覆盖到,是真的没法从图像信息判断是谁)
2. 找到 9 个从未跑过闭集识别的漏网视频(应该是 person_identifier 功能上线
   前处理的),重新识别后 1 个(video 2194)成功解析出汤圆+爸爸,其余 8 个
   同样是性别未知,正确保持未解析

新增 2 个测试覆盖 description/summary_json 文本替换,以及带后缀 label 的
替换顺序正确性。
2026-08-23 10:50:52 +08:00

366 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from fam_edge.oracle_db import OracleDB
def _db(tmp_path):
return OracleDB(str(tmp_path / "oracle.db"))
def _set_heartbeat_age(db, age_sec):
"""把心跳时间戳直接改写成"距现在 age_sec 秒前",用于测试新鲜度阈值边界。"""
from datetime import datetime, timedelta, timezone
ts = (datetime.now(timezone(timedelta(hours=8))) - timedelta(seconds=age_sec))
db.set_cursor('motion_heartbeat_at', ts.strftime('%Y-%m-%d %H:%M:%S'))
def test_upsert_person_same_gender_merges_into_one_row(tmp_path):
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": "", "hair": "短发黑色"})
db.upsert_person("人物A", features={"gender": "", "clothing": "蓝色T恤"})
rows = db._conn.execute("SELECT * FROM people").fetchall()
assert len(rows) == 1
assert rows[0]["appearances"] == 2
feats = json.loads(rows[0]["features_json"])
assert feats["hair"] == "短发黑色"
assert feats["clothing"] == "蓝色T恤"
def test_upsert_person_gender_conflict_splits_into_new_label(tmp_path):
"""核心诉求: 大模型给的 人物A/B/C 这类 uid 只在单次视频分析内稳定,不同视频
独立编号,同一字符串完全可能撞到不同真人(实测 "人物A" 混了男女两个人)。
性别冲突时不能直接合并覆盖,要拆成新 label避免两个人的特征越merge越乱。"""
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": "", "clothing": "蓝色Polo衫"})
db.upsert_person("人物A", features={"gender": "", "clothing": "白色上衣"})
rows = {r["label"]: r for r in db._conn.execute("SELECT * FROM people").fetchall()}
assert set(rows.keys()) == {"人物A", "人物A#2"}
assert rows["人物A"]["appearances"] == 1
assert json.loads(rows["人物A"]["features_json"])["gender"] == ""
assert rows["人物A#2"]["appearances"] == 1
assert json.loads(rows["人物A#2"]["features_json"])["gender"] == ""
# 派生行的 display_uid 仍然记录原始大模型 uid方便追溯来源
assert rows["人物A#2"]["display_uid"] == "人物A"
def test_upsert_person_gender_conflict_allocates_next_free_suffix(tmp_path):
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": ""})
db.upsert_person("人物A", features={"gender": ""}) # -> 人物A#2
db.upsert_person("人物A", features={"gender": "unknown"}) # unknown 不冲突,合并回 人物A
db.upsert_person("人物A", features={"gender": "", "hair": "光头"}) # 冲突人物A 有 gender 男了不冲突;应仍合并
labels = {r["label"] for r in db._conn.execute("SELECT label FROM people").fetchall()}
assert labels == {"人物A", "人物A#2"}
# 再来一次性别冲突(对着 人物A#2性别女应该分配 人物A#3而不是复用 人物A#2
db.upsert_person("人物A", features={"gender": ""})
db.upsert_person("人物A", features={"gender": ""})
# 这次新的女性冲突会先撞到 人物A此时是男分裂出下一个空闲后缀
labels = {r["label"] for r in db._conn.execute("SELECT label FROM people").fetchall()}
assert "人物A" in labels
assert len(labels) >= 2
def test_upsert_person_unknown_gender_never_triggers_split(tmp_path):
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": ""})
db.upsert_person("人物A", features={"gender": "unknown"})
db.upsert_person("人物A", features={"gender": "未知"})
rows = db._conn.execute("SELECT * FROM people").fetchall()
assert len(rows) == 1
assert rows[0]["appearances"] == 3
def test_upsert_person_no_features_never_triggers_split(tmp_path):
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": ""})
db.upsert_person("人物A") # 无 featuressource 更新等场景)
rows = db._conn.execute("SELECT * FROM people").fetchall()
assert len(rows) == 1
assert rows[0]["appearances"] == 2
# ----------------------------------------------------------------------
# 运动侦测事件NAS 推送)
# ----------------------------------------------------------------------
def test_record_motion_events_upserts_by_event_id(tmp_path):
db = _db(tmp_path)
n = db.record_motion_events([
{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5},
{"event_id": 2, "camera_id": 2, "event_type": 10, "start_time": 2000, "duration": 3},
])
assert n == 2
rows = db._conn.execute("SELECT * FROM ss_motion_events ORDER BY event_id").fetchall()
assert len(rows) == 2
# 重复推送同一个 event_id幂等应该更新而不是新增一行
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 99}])
rows = db._conn.execute("SELECT * FROM ss_motion_events").fetchall()
assert len(rows) == 2
updated = db._conn.execute(
"SELECT duration FROM ss_motion_events WHERE event_id=1").fetchone()
assert updated['duration'] == 99
def test_record_motion_events_skips_missing_event_id(tmp_path):
db = _db(tmp_path)
n = db.record_motion_events([{"camera_id": 2, "start_time": 1000}])
assert n == 0
def test_heartbeat_age_none_when_never_recorded(tmp_path):
db = _db(tmp_path)
assert db.get_motion_heartbeat_age_sec() is None
def test_heartbeat_age_near_zero_right_after_recording(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
age = db.get_motion_heartbeat_age_sec()
assert age is not None and age < 5
def test_has_motion_in_range_local_fails_open_without_heartbeat(tmp_path):
"""核心诉求: 从未收到过心跳冷启动NAS 推送链路还没接上)必须 fail-open
不能因为本地表是空的就悄悄跳过分析。"""
db = _db(tmp_path)
assert db.has_motion_in_range_local(1000, 2000) is None
def test_has_motion_in_range_local_fails_open_when_heartbeat_stale(tmp_path):
"""核心诉求: 表里有大量历史运动事件(曾经推送链路是健康的),但心跳已经
过期太久NAS 服务挂了/网络断了/DSM Webhook 规则被误关)——这时候不能信任
"查询结果是 0 条 = 确认无运动"必须当作链路已死fail-open。"""
db = _db(tmp_path)
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 500, "duration": 10}])
_set_heartbeat_age(db, 1000) # 超过默认阈值 900s
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
def test_has_motion_in_range_local_trusts_result_when_heartbeat_fresh(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is False
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is True
def test_has_motion_in_range_local_respects_heartbeat_boundary(tmp_path):
db = _db(tmp_path)
_set_heartbeat_age(db, 899)
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is not None
_set_heartbeat_age(db, 901)
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
def test_has_motion_in_range_local_overlap_semantics(tmp_path):
"""事件区间 [start_time, start_time+duration] 只要和查询窗口有重叠就算命中,
不要求事件完全落在窗口内部(也不要求窗口完全覆盖事件)。"""
db = _db(tmp_path)
db.record_motion_heartbeat()
# 事件在窗口开始之前就开始,但持续到窗口内 -> 应该命中
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1990, "duration": 20}])
assert db.has_motion_in_range_local(2000, 3000) is True
def test_has_motion_in_range_local_ignores_non_motion_event_type(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 99, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000) is False
def test_has_motion_in_range_local_filters_by_camera_id(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
db.record_motion_events(
[{"event_id": 1, "camera_id": 99, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, camera_id=2) is False
assert db.has_motion_in_range_local(2000, 3000, camera_id=99) is True
# ----------------------------------------------------------------------
# 人物对应关系表video_id, raw_uid) -> canonical_name
# ----------------------------------------------------------------------
def _seed_video_with_events(db, filename="motion_1_1000.mp4"):
vid = db.ensure_video(filename, f"/tmp/{filename}", event_start_time="2026-08-22 10:00:00")
events = [
{"timestamp": "10:00:01", "description": "在客厅走动", "people": ["人物A"],
"person_appearances": [{"uid": "人物A", "features": {"gender": ""}, "action": "走动"}]},
{"timestamp": "10:00:05", "description": "坐下", "people": ["人物A", "人物B"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "坐下"},
{"uid": "人物B", "features": {"gender": ""}, "action": "站立"}]},
]
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物B"], "gemini")
return vid
def test_set_identity_mapping_inserts_new_row(tmp_path):
db = _db(tmp_path)
assert db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id") is True
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
def test_set_identity_mapping_updates_existing_non_manual_row(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert db.set_identity_mapping(1, "人物A", "爸爸", source="auto_id") is True
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
def test_set_identity_mapping_manual_protected_from_auto_overwrite(tmp_path):
"""核心诉求: 人工纠正过的映射不能被后续自动识别悄悄改回去。"""
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert changed is False
assert db.get_identity_map_for_video(1) == {"人物A": "爸爸"}
def test_set_identity_mapping_manual_can_override_manual(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爸爸", source="manual")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="manual")
assert changed is True
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
def test_set_identity_mapping_no_change_returns_false(tmp_path):
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
changed = db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
assert changed is False
def test_get_identity_map_for_video_scoped_per_video(tmp_path):
"""核心诉求: 同一个 raw_uid 字符串在不同视频里可能是不同真人,映射必须按
video_id 隔离,不能串。"""
db = _db(tmp_path)
db.set_identity_mapping(1, "人物A", "爷爷", source="auto_id")
db.set_identity_mapping(2, "人物A", "爸爸", source="auto_id")
assert db.get_identity_map_for_video(1) == {"人物A": "爷爷"}
assert db.get_identity_map_for_video(2) == {"人物A": "爸爸"}
def test_rewrite_event_person_names_updates_events_and_video(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物B": "媳妇"})
rows = db._conn.execute(
"SELECT person_list_json, person_appearances_json FROM events "
"WHERE video_id=? ORDER BY id", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爷爷"]
pa0 = json.loads(rows[0]["person_appearances_json"])
assert pa0[0]["uid"] == "爷爷"
assert json.loads(rows[1]["person_list_json"]) == ["爷爷", "媳妇"]
pa1 = json.loads(rows[1]["person_appearances_json"])
assert {p["uid"] for p in pa1} == {"爷爷", "媳妇"}
vrow = db._conn.execute("SELECT people_json FROM videos WHERE id=?", (vid,)).fetchone()
assert set(json.loads(vrow["people_json"])) == {"爷爷", "媳妇"}
def test_rewrite_event_person_names_rewrites_description_text(tmp_path):
"""核心诉求: description 是大模型写的自然语言句子,"人物A/人物B"这类 uid
会直接以文字形式嵌在句子里,只改 person_list_json/person_appearances_json
这些结构化字段的话,事件卡片上方徽章显示对了,描述文字里还是旧 uid两处
对不上——description 也要做文本替换。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/motion_2_2000.mp4",
event_start_time="2026-08-22 13:00:00")
events = [
{"timestamp": "13:29:24",
"description": "人物B双手叉腰站在客厅中央人物A在远处厨房儿童已离开画面。",
"people": ["人物A", "人物B"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "站立"},
{"uid": "人物B", "features": {"gender": ""}, "action": "叉腰"}]},
]
db.mark_video_processed(vid, "人物A和人物B都在客厅活动。", events,
["人物A", "人物B"], "gemini")
db.rewrite_event_person_names(vid, {"人物A": "爸爸", "人物B": "媳妇"})
ev_row = db._conn.execute(
"SELECT description FROM events WHERE video_id=?", (vid,)).fetchone()
assert ev_row["description"] == "媳妇双手叉腰站在客厅中央;爸爸在远处厨房;儿童已离开画面。"
v_row = db._conn.execute(
"SELECT summary_json FROM videos WHERE id=?", (vid,)).fetchone()
assert v_row["summary_json"] == "爸爸和媳妇都在客厅活动。"
def test_rewrite_event_person_names_longer_labels_replaced_before_shorter(tmp_path):
"""核心诉求: uid 可能带 "#2"/"#3" 这类同名冲突后缀,"人物A""人物A#2"
前缀——如果先替换短的 "人物A""人物A#2" 会被错误地部分命中变成"爷爷#2"
而不是走它自己在 rename_map 里对应的正确目标。必须长的先替换。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_3_3000.mp4", "/tmp/motion_3_3000.mp4",
event_start_time="2026-08-22 13:00:00")
events = [
{"timestamp": "13:00:01",
"description": "人物A和人物A#2一起在客厅。",
"people": ["人物A", "人物A#2"],
"person_appearances": [
{"uid": "人物A", "features": {"gender": ""}, "action": "站立"},
{"uid": "人物A#2", "features": {"gender": ""}, "action": "站立"}]},
]
db.mark_video_processed(vid, "摘要", events, ["人物A", "人物A#2"], "gemini")
db.rewrite_event_person_names(vid, {"人物A": "爷爷", "人物A#2": "媳妇"})
ev_row = db._conn.execute(
"SELECT description FROM events WHERE video_id=?", (vid,)).fetchone()
assert ev_row["description"] == "爷爷和媳妇一起在客厅。"
def test_rewrite_event_person_names_noop_on_empty_map(tmp_path):
db = _db(tmp_path)
vid = _seed_video_with_events(db)
before = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
db.rewrite_event_person_names(vid, {})
after = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
assert [r["person_list_json"] for r in before] == [r["person_list_json"] for r in after]
def test_correct_video_identity_end_to_end(tmp_path):
"""核心诉求: 纠错入口应该找到当前展示名对应的映射行,改写映射 + 立即重写
展示数据,且标记为 manual受保护"""
db = _db(tmp_path)
vid = _seed_video_with_events(db)
db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
db.rewrite_event_person_names(vid, {"人物A": "爷爷"})
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
assert db.get_identity_map_for_video(vid) == {"人物A": "爸爸"}
rows = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=? ORDER BY id", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
# manual 之后不能被自动识别覆盖回去
changed = db.set_identity_mapping(vid, "人物A", "爷爷", source="auto_id")
assert changed is False
def test_correct_video_identity_without_prior_mapping_uses_current_name_as_raw_uid(tmp_path):
"""核心诉求: 老流水线时代产出的数据从没跑过闭集识别,映射表里没有记录——
纠错依然要能生效,把 current_name 本身当 raw_uid 存一条新映射。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_2_2000.mp4", "/tmp/x.mp4", event_start_time="2026-08-22 10:00:00")
events = [{"timestamp": "10:00:01", "description": "走动", "people": ["爷爷"],
"person_appearances": [{"uid": "爷爷", "features": {"gender": ""}, "action": "走动"}]}]
db.mark_video_processed(vid, "摘要", events, ["爷爷"], "gemini")
db.correct_video_identity(vid, current_name="爷爷", new_name="爸爸")
assert db.get_identity_map_for_video(vid) == {"爷爷": "爸爸"}
rows = db._conn.execute(
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]