用户反馈:事件卡片上方的人物徽章正确显示"媳妇/爸爸",但描述文字里还是 "人物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 的 替换顺序正确性。
366 lines
18 KiB
Python
366 lines
18 KiB
Python
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") # 无 features(source 更新等场景)
|
||
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"]) == ["爸爸"]
|