feat(nas): 模型调用统计镜像与界面 - sync_model_calls 表+拉取+聚合统计;fam-ui 新增'🤖 模型统计'页(按模型成功/失败/成功率/平均耗时 + 最近调用明细含失败原因)
This commit is contained in:
@@ -268,6 +268,74 @@ def get_sync_people() -> List[Dict]:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_sync_model_calls(rows: List[Dict]) -> int:
|
||||||
|
"""批量 upsert Oracle 传来的 model_calls 增量(幂等,重复覆盖)。"""
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
conn = get_conn()
|
||||||
|
n = 0
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for r in rows:
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO sync_model_calls
|
||||||
|
(id, provider, model, video_id, filename, started_at,
|
||||||
|
duration_sec, success, error, created_at, synced_at)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
provider=VALUES(provider),
|
||||||
|
model=VALUES(model),
|
||||||
|
video_id=VALUES(video_id),
|
||||||
|
filename=VALUES(filename),
|
||||||
|
started_at=VALUES(started_at),
|
||||||
|
duration_sec=VALUES(duration_sec),
|
||||||
|
success=VALUES(success),
|
||||||
|
error=VALUES(error),
|
||||||
|
created_at=VALUES(created_at),
|
||||||
|
synced_at=NOW()""",
|
||||||
|
(r.get('id'), r.get('provider'), r.get('model'),
|
||||||
|
r.get('video_id'), r.get('filename'), r.get('started_at'),
|
||||||
|
r.get('duration_sec') or 0, 1 if r.get('success') else 0,
|
||||||
|
(r.get('error') or '')[:500], r.get('created_at')))
|
||||||
|
n += 1
|
||||||
|
conn.commit()
|
||||||
|
return n
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_model_calls(limit: int = 200) -> List[Dict]:
|
||||||
|
"""最近模型调用记录(前端统计展示)。"""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, provider, model, video_id, filename, started_at, "
|
||||||
|
"duration_sec, success, error, created_at "
|
||||||
|
"FROM sync_model_calls ORDER BY id DESC LIMIT %s", (limit,))
|
||||||
|
return cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_model_calls_stats() -> Dict:
|
||||||
|
"""模型调用统计:按 provider+model 聚合成功/失败/平均耗时。"""
|
||||||
|
conn = get_conn()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor(pymysql.cursors.DictCursor)
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT provider, model,
|
||||||
|
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
|
||||||
|
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
|
||||||
|
ROUND(AVG(duration_sec), 1) AS avg_duration,
|
||||||
|
MAX(created_at) AS last_call
|
||||||
|
FROM sync_model_calls
|
||||||
|
GROUP BY provider, model ORDER BY provider, model""")
|
||||||
|
return cur.fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_sync_named_members() -> List[str]:
|
def get_sync_named_members() -> List[str]:
|
||||||
"""已命名成员的真名列表(供 UI 下拉 / 快捷选择)。"""
|
"""已命名成员的真名列表(供 UI 下拉 / 快捷选择)。"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|||||||
@@ -83,21 +83,23 @@ class OracleSync:
|
|||||||
videos = data.get('videos', []) or []
|
videos = data.get('videos', []) or []
|
||||||
events = data.get('events', []) or []
|
events = data.get('events', []) or []
|
||||||
people = data.get('people', []) or []
|
people = data.get('people', []) or []
|
||||||
|
model_calls = data.get('model_calls', []) or []
|
||||||
server_time = data.get('server_time', '') or ''
|
server_time = data.get('server_time', '') or ''
|
||||||
|
|
||||||
n_videos = db_layer.upsert_sync_videos(videos)
|
n_videos = db_layer.upsert_sync_videos(videos)
|
||||||
n_events = db_layer.upsert_sync_events(events)
|
n_events = db_layer.upsert_sync_events(events)
|
||||||
n_people = db_layer.upsert_sync_people(people)
|
n_people = db_layer.upsert_sync_people(people)
|
||||||
|
n_calls = db_layer.upsert_sync_model_calls(model_calls)
|
||||||
|
|
||||||
if server_time:
|
if server_time:
|
||||||
db_layer.set_sync_cursor(server_time)
|
db_layer.set_sync_cursor(server_time)
|
||||||
|
|
||||||
self._last_sync_at = datetime.now()
|
self._last_sync_at = datetime.now()
|
||||||
self._last_error = None
|
self._last_error = None
|
||||||
self._last_count = (n_videos, n_events, n_people)
|
self._last_count = (n_videos, n_events, n_people, n_calls)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
|
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
|
||||||
f"since={since!r} -> server_time={server_time}")
|
f"model_calls+{n_calls} since={since!r} -> server_time={server_time}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ st.sidebar.markdown(
|
|||||||
# 顶部横向导航
|
# 顶部横向导航
|
||||||
page = st.segmented_control(
|
page = st.segmented_control(
|
||||||
"功能页面",
|
"功能页面",
|
||||||
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 人物管理", "📈 统计图表"],
|
["🕒 事件时间轴", "💬 AI 对话", "📝 对话历史", "👤 人物管理", "📈 统计图表", "🤖 模型统计"],
|
||||||
default="🕒 事件时间轴",
|
default="🕒 事件时间轴",
|
||||||
label_visibility="collapsed"
|
label_visibility="collapsed"
|
||||||
) or "🕒 事件时间轴"
|
) or "🕒 事件时间轴"
|
||||||
@@ -901,3 +901,83 @@ elif page == "📈 统计图表":
|
|||||||
finally:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 模型统计页(云端模型识别成功/失败统计)
|
||||||
|
# ============================================================
|
||||||
|
elif page == "🤖 模型统计":
|
||||||
|
page_header('🤖', '云端模型统计', '请求时间 · 耗时 · 成功/失败 · 失败原因')
|
||||||
|
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = get_db_conn()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# ---- 按模型聚合:成功/失败/成功率/平均耗时 ----
|
||||||
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
|
'margin:6px 0 14px 0;">按模型聚合</div>', unsafe_allow_html=True)
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT provider, model,
|
||||||
|
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
|
||||||
|
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
|
||||||
|
ROUND(AVG(duration_sec),1) AS avg_dur,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
MAX(created_at) AS last_call
|
||||||
|
FROM sync_model_calls GROUP BY provider, model
|
||||||
|
ORDER BY total DESC""")
|
||||||
|
agg = cursor.fetchall()
|
||||||
|
if agg:
|
||||||
|
rows = []
|
||||||
|
for r in agg:
|
||||||
|
total = r['total'] or 0
|
||||||
|
ok = r['ok_cnt'] or 0
|
||||||
|
rate = (ok / total * 100) if total else 0
|
||||||
|
rows.append({
|
||||||
|
"模型": f"{r['provider']} / {r['model']}",
|
||||||
|
"成功": ok,
|
||||||
|
"失败": r['fail_cnt'] or 0,
|
||||||
|
"成功率%": round(rate, 1),
|
||||||
|
"平均耗时s": r['avg_dur'],
|
||||||
|
"最后调用": str(r['last_call'] or '—')[:19],
|
||||||
|
})
|
||||||
|
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.markdown('<div class="fam-empty">暂无模型调用记录(视频处理中或尚未同步)</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# ---- 最近调用明细 ----
|
||||||
|
st.markdown('<div style="font-size:15px;font-weight:700;color:#f1f5f9;'
|
||||||
|
'margin:22px 0 14px 0;">最近调用明细</div>', unsafe_allow_html=True)
|
||||||
|
cursor.execute(
|
||||||
|
"""SELECT provider, model, started_at, duration_sec, success, error,
|
||||||
|
filename, video_id
|
||||||
|
FROM sync_model_calls ORDER BY id DESC LIMIT 100""")
|
||||||
|
calls = cursor.fetchall()
|
||||||
|
if calls:
|
||||||
|
import pandas as pd
|
||||||
|
rows = []
|
||||||
|
for c in calls:
|
||||||
|
rows.append({
|
||||||
|
"请求时间": str(c['started_at'] or '—')[:19],
|
||||||
|
"模型": f"{c['provider']} / {c['model']}",
|
||||||
|
"耗时s": round(c['duration_sec'] or 0, 1),
|
||||||
|
"状态": "✅ 成功" if c['success'] else "❌ 失败",
|
||||||
|
"失败原因": (c['error'] or '')[:60],
|
||||||
|
"视频": (c['filename'] or '')[:40],
|
||||||
|
})
|
||||||
|
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||||
|
else:
|
||||||
|
st.markdown('<div class="fam-empty">暂无调用明细</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# ---- 说明 ----
|
||||||
|
st.markdown(
|
||||||
|
'<div style="font-size:11px;color:#64748b;margin-top:10px;">'
|
||||||
|
'统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。'
|
||||||
|
'失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。'
|
||||||
|
'</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
st.error(f"模型统计查询失败: {e}")
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -182,6 +182,24 @@ CREATE TABLE IF NOT EXISTS sync_cursor (
|
|||||||
`value` VARCHAR(64) COMMENT '上次成功拉取到的 server_time(ISO 文本)'
|
`value` VARCHAR(64) COMMENT '上次成功拉取到的 server_time(ISO 文本)'
|
||||||
) ENGINE=InnoDB COMMENT='同步游标表';
|
) ENGINE=InnoDB COMMENT='同步游标表';
|
||||||
|
|
||||||
|
-- 8.1 云端模型调用统计镜像表(Oracle model_calls 镜像)
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_model_calls (
|
||||||
|
id INT PRIMARY KEY COMMENT 'Oracle model_calls.id',
|
||||||
|
provider VARCHAR(50) COMMENT 'gemini / nvidia',
|
||||||
|
model VARCHAR(100) COMMENT '具体模型名(如 gemini-flash-latest)',
|
||||||
|
video_id INT COMMENT '关联视频 id',
|
||||||
|
filename VARCHAR(500) COMMENT '视频文件名',
|
||||||
|
started_at VARCHAR(32) COMMENT '请求开始时间',
|
||||||
|
duration_sec DOUBLE COMMENT '请求耗时(秒)',
|
||||||
|
success TINYINT(1) DEFAULT 0 COMMENT '1=成功 0=失败',
|
||||||
|
error VARCHAR(500) COMMENT '失败原因(429_quota/timeout/http_xxx 等)',
|
||||||
|
created_at VARCHAR(32) COMMENT '记录入库时间',
|
||||||
|
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_provider (provider),
|
||||||
|
INDEX idx_created (created_at),
|
||||||
|
INDEX idx_success (success)
|
||||||
|
) ENGINE=InnoDB COMMENT='云端模型调用统计镜像表';
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 验证
|
-- 验证
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user