[阶段2] FAM-Edge 重构为整视频分析+同步接口+人物服务 - 移除切片/抽帧/队列,新增 oracle_db/person_service/qa/watch_processor/video_processor,api_gateway 提供 /api/oracle/sync 与 /api/oracle/people/correct
This commit is contained in:
238
fam-edge/src/fam_edge/oracle_db.py
Normal file
238
fam-edge/src/fam_edge/oracle_db.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Oracle 本地库(SQLite) - 视频摘要 / 事件 / 人物 存储
|
||||
|
||||
表结构:
|
||||
videos : 每个被处理的视频一个记录(含全局摘要 + 事件列表 + 人物列表,JSON 冗余存储便于查询)
|
||||
events : 视频拆出的事件(时间点 + 描述 + 涉及人物)
|
||||
people : 规范人物表(canonical_name + 别名),由 person_service 维护
|
||||
sync_cursor: 同步游标(NAS 拉取用,记录最后成功同步时间)
|
||||
|
||||
对外提供:
|
||||
- upsert_video / get_pending_videos / mark_video_processed
|
||||
- upsert_event
|
||||
- upsert_person / get_known_members_context
|
||||
- get_sync_delta(since_iso) -> 增量数据(供 NAS 拉取)
|
||||
- set_cursor / get_cursor
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = None # 延迟注入,避免循环 import
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
class OracleDB:
|
||||
def __init__(self, db_path: str):
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
self.db_path = db_path
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._init_schema()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _init_schema(self):
|
||||
c = self._conn
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
drive_file_id TEXT,
|
||||
filename TEXT UNIQUE,
|
||||
local_path TEXT,
|
||||
camera_name TEXT,
|
||||
duration_sec REAL,
|
||||
event_start_time TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
summary_json TEXT,
|
||||
events_json TEXT,
|
||||
people_json TEXT,
|
||||
compute_provider TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
processed_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_id INTEGER,
|
||||
ts TEXT,
|
||||
description TEXT,
|
||||
person_list_json TEXT,
|
||||
is_attention_event INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(video_id) REFERENCES videos(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT UNIQUE,
|
||||
canonical_name TEXT,
|
||||
first_seen TEXT,
|
||||
appearances INTEGER DEFAULT 0,
|
||||
source TEXT DEFAULT 'llm',
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_cursor (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_videos_updated ON videos(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_video ON events(video_id);
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# videos
|
||||
# ------------------------------------------------------------------
|
||||
def get_video_by_filename(self, filename: str) -> Optional[sqlite3.Row]:
|
||||
cur = self._conn.execute("SELECT * FROM videos WHERE filename=?", (filename,))
|
||||
return cur.fetchone()
|
||||
|
||||
def ensure_video(self, filename: str, local_path: str,
|
||||
camera_name: str = '', event_start_time: str = '',
|
||||
duration_sec: float = 0.0, drive_file_id: str = '') -> int:
|
||||
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。"""
|
||||
now = _now_iso()
|
||||
row = self.get_video_by_filename(filename)
|
||||
if row:
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET local_path=?, camera_name=?, event_start_time=?, "
|
||||
"duration_sec=?, updated_at=? WHERE id=?",
|
||||
(local_path, camera_name, event_start_time, duration_sec, now, row['id']))
|
||||
self._conn.commit()
|
||||
return row['id']
|
||||
cur = self._conn.execute(
|
||||
"INSERT INTO videos (drive_file_id, filename, local_path, camera_name, "
|
||||
"duration_sec, event_start_time, status, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?, 'pending', ?, ?)",
|
||||
(drive_file_id, filename, local_path, camera_name, duration_sec,
|
||||
event_start_time, now, now))
|
||||
self._conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def get_pending_videos(self, limit: int = 1) -> List[sqlite3.Row]:
|
||||
cur = self._conn.execute(
|
||||
"SELECT * FROM videos WHERE status IN ('pending','failed') "
|
||||
"ORDER BY id ASC LIMIT ?", (limit,))
|
||||
return cur.fetchall()
|
||||
|
||||
def mark_video_processed(self, video_id: int, summary: str, events: List[dict],
|
||||
people: List[str], compute_provider: str):
|
||||
now = _now_iso()
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET status='done', summary_json=?, events_json=?, "
|
||||
"people_json=?, compute_provider=?, updated_at=?, processed_at=? WHERE id=?",
|
||||
(summary, json.dumps(events, ensure_ascii=False), json.dumps(people, ensure_ascii=False),
|
||||
compute_provider, now, now, video_id))
|
||||
# 事件落独立表,便于 NAS 拉取
|
||||
self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,))
|
||||
for ev in events:
|
||||
self._conn.execute(
|
||||
"INSERT INTO events (video_id, ts, description, person_list_json, "
|
||||
"is_attention_event) VALUES (?,?,?,?,?)",
|
||||
(video_id, ev.get('timestamp', ''), ev.get('description', ''),
|
||||
json.dumps(ev.get('people', []), ensure_ascii=False),
|
||||
1 if ev.get('is_attention_event') else 0))
|
||||
self._conn.commit()
|
||||
|
||||
def mark_video_failed(self, video_id: int, error: str = ''):
|
||||
now = _now_iso()
|
||||
self._conn.execute(
|
||||
"UPDATE videos SET status='failed', summary_json=?, updated_at=? WHERE id=?",
|
||||
(error, now, video_id))
|
||||
self._conn.commit()
|
||||
|
||||
def get_all_videos(self) -> List[sqlite3.Row]:
|
||||
return self._conn.execute(
|
||||
"SELECT * FROM videos WHERE status='done' ORDER BY id ASC").fetchall()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# people
|
||||
# ------------------------------------------------------------------
|
||||
def upsert_person(self, label: str, canonical_name: str = '', source: str = 'llm',
|
||||
first_seen: str = ''):
|
||||
now = _now_iso()
|
||||
row = self._conn.execute("SELECT * FROM people WHERE label=?", (label,)).fetchone()
|
||||
if row:
|
||||
# manual 覆盖 llm;llm 不覆盖 manual
|
||||
if source == 'manual' or row['source'] != 'manual':
|
||||
self._conn.execute(
|
||||
"UPDATE people SET canonical_name=?, source=?, appearances=appearances+1, "
|
||||
"updated_at=? WHERE label=?",
|
||||
(canonical_name or row['canonical_name'], source, now, label))
|
||||
else:
|
||||
self._conn.execute(
|
||||
"UPDATE people SET appearances=appearances+1, updated_at=? WHERE label=?",
|
||||
(now, label))
|
||||
else:
|
||||
self._conn.execute(
|
||||
"INSERT INTO people (label, canonical_name, first_seen, appearances, "
|
||||
"source, updated_at) VALUES (?,?,?,1,?,?)",
|
||||
(label, canonical_name, first_seen or now, source, now))
|
||||
self._conn.commit()
|
||||
|
||||
def set_canonical(self, label: str, canonical_name: str, source: str = 'manual'):
|
||||
"""手动命名:设置规范名(label 可视为别名)。"""
|
||||
self.upsert_person(label, canonical_name, source='manual')
|
||||
|
||||
def get_people(self) -> List[sqlite3.Row]:
|
||||
return self._conn.execute("SELECT * FROM people ORDER BY id ASC").fetchall()
|
||||
|
||||
def get_known_members_context(self) -> str:
|
||||
"""生成 known_members_context 文本,注入视频提示让模型用真名。"""
|
||||
rows = self.get_people()
|
||||
lines = []
|
||||
for r in rows:
|
||||
name = r['canonical_name'] or r['label']
|
||||
if name and name != r['label']:
|
||||
lines.append(f"- {name}(别名/标识:{r['label']})")
|
||||
else:
|
||||
lines.append(f"- {name}")
|
||||
return '\n'.join(lines) if lines else ''
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 同步导出(供 NAS 拉取)
|
||||
# ------------------------------------------------------------------
|
||||
def get_sync_delta(self, since_iso: str) -> Dict:
|
||||
"""返回 since 之后变更的 videos / events / people。"""
|
||||
videos = self._conn.execute(
|
||||
"SELECT * FROM videos WHERE updated_at > ? ORDER BY id ASC", (since_iso,)
|
||||
).fetchall()
|
||||
events = self._conn.execute(
|
||||
"SELECT * FROM events WHERE updated_at > ? ORDER BY id ASC", (since_iso,)
|
||||
).fetchall() if False else self._conn.execute(
|
||||
"SELECT e.* FROM events e JOIN videos v ON e.video_id=v.id "
|
||||
"WHERE v.updated_at > ? ORDER BY e.id ASC", (since_iso,)).fetchall()
|
||||
people = self._conn.execute(
|
||||
"SELECT * FROM people WHERE updated_at > ? ORDER BY id ASC", (since_iso,)
|
||||
).fetchall()
|
||||
|
||||
def _ser(row):
|
||||
d = dict(row)
|
||||
return d
|
||||
|
||||
return {
|
||||
"videos": [_ser(v) for v in videos],
|
||||
"events": [_ser(e) for e in events],
|
||||
"people": [_ser(p) for p in people],
|
||||
"server_time": _now_iso(),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 同步游标
|
||||
# ------------------------------------------------------------------
|
||||
def get_cursor(self, key: str) -> str:
|
||||
row = self._conn.execute("SELECT value FROM sync_cursor WHERE key=?", (key,)).fetchone()
|
||||
return row['value'] if row else ''
|
||||
|
||||
def set_cursor(self, key: str, value: str):
|
||||
self._conn.execute(
|
||||
"INSERT INTO sync_cursor (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value))
|
||||
self._conn.commit()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
Reference in New Issue
Block a user