feat: 收视分析看板前端 L1-L4 实现 + 25期真实数据导入
收视分析页面完整实现:指标卡(含四档动画)、走势折线图(dataZoom滑块+确认按钮)、 季度/编导/题材对比(双列布局)、双引擎象限图(题材热度×叙事结构散点)。 导入25期真实收视数据及AI标签,修复侧边栏fixed定位和滚轮冲突。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
修复 AI 标签映射:ground-truth ep编号 ≠ Excel 播出期号
|
||||
用标题模糊匹配重新关联正确的 AI 标签
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.episode import Episode
|
||||
from app.models.user import User
|
||||
from sqlmodel import select
|
||||
|
||||
project_root = os.path.join(os.path.dirname(__file__), '..')
|
||||
|
||||
# 读 ground-truth
|
||||
with open(os.path.join(project_root, 'ai-labeling', 'benchmark-set', 'ground-truth.json'), 'r', encoding='utf-8') as f:
|
||||
gt_data = json.load(f)
|
||||
|
||||
gt_episodes = gt_data['episodes']
|
||||
|
||||
# 读数据库
|
||||
session = next(get_session())
|
||||
db_episodes = session.exec(select(Episode).order_by(Episode.episode_number)).all()
|
||||
|
||||
print(f"DB episodes: {len(db_episodes)}")
|
||||
print(f"GT episodes: {len(gt_episodes)}")
|
||||
print()
|
||||
|
||||
# ── 标题相似度匹配 ──
|
||||
def title_similarity(a, b):
|
||||
"""计算两个标题的相似度"""
|
||||
# 清理标题
|
||||
clean_a = a.replace('"', '').replace('"', '').replace("'", '').replace('——', '').replace(':', '').replace(' ', '').replace('《', '').replace('》', '')
|
||||
clean_b = b.replace('"', '').replace('"', '').replace("'", '').replace('——', '').replace(':', '').replace(' ', '').replace('《', '').replace('》', '')
|
||||
return SequenceMatcher(None, clean_a, clean_b).ratio()
|
||||
|
||||
# 对每个 DB episode,找最佳匹配的 GT episode
|
||||
matched = []
|
||||
used_gt = set()
|
||||
|
||||
for db_ep in db_episodes:
|
||||
best_score = 0
|
||||
best_gt = None
|
||||
|
||||
for i, gt_ep in enumerate(gt_episodes):
|
||||
if i in used_gt:
|
||||
continue
|
||||
|
||||
gt_title = gt_ep.get('title', '')
|
||||
score = title_similarity(db_ep.program_name, gt_title)
|
||||
|
||||
# 也检查份额是否匹配(辅助判断)
|
||||
gt_share = gt_ep.get('share')
|
||||
share_match = (gt_share is not None and db_ep.audience_share is not None
|
||||
and abs(float(gt_share) - float(db_ep.audience_share)) < 0.01)
|
||||
|
||||
# 份额匹配加分
|
||||
if share_match:
|
||||
score += 0.3
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_gt = (i, gt_ep)
|
||||
|
||||
if best_gt and best_score > 0.3:
|
||||
used_gt.add(best_gt[0])
|
||||
matched.append((db_ep, best_gt[1], best_score))
|
||||
status = 'OK' if best_score > 0.5 else 'WEAK'
|
||||
print(f"[{status}] DB ep{db_ep.episode_number:02d} \"{db_ep.program_name[:15]}\" -> GT ep{best_gt[1]['ep']:02d} \"{best_gt[1]['title'][:15]}\" (score={best_score:.2f})")
|
||||
else:
|
||||
print(f"[MISS] DB ep{db_ep.episode_number:02d} \"{db_ep.program_name[:15]}\" -> NO MATCH")
|
||||
matched.append((db_ep, None, 0))
|
||||
|
||||
print(f"\nMatched: {sum(1 for _,gt,_ in matched if gt is not None)}/{len(db_episodes)}")
|
||||
|
||||
# ── 确认后更新 ──
|
||||
print("\n--- Updating AI labels ---")
|
||||
updated = 0
|
||||
for db_ep, gt_ep, score in matched:
|
||||
if gt_ep is None:
|
||||
continue
|
||||
|
||||
db_ep.program_format = gt_ep.get('program_format')
|
||||
db_ep.equipment_domain = gt_ep.get('equipment_domain')
|
||||
db_ep.scene_tags = gt_ep.get('scene_tags')
|
||||
db_ep.tech_tags = gt_ep.get('tech_tags')
|
||||
db_ep.narrative_structure = gt_ep.get('narrative_structure')
|
||||
db_ep.opening_hook = gt_ep.get('opening_hook')
|
||||
db_ep.ai_label_confidence = 'reviewed'
|
||||
|
||||
session.add(db_ep)
|
||||
updated += 1
|
||||
|
||||
session.commit()
|
||||
print(f"Updated {updated} episodes")
|
||||
|
||||
# ── 验证 ──
|
||||
print("\n--- Verification ---")
|
||||
db_episodes = session.exec(select(Episode).order_by(Episode.episode_number)).all()
|
||||
for ep in db_episodes:
|
||||
print(f"ep{ep.episode_number:02d} | {ep.program_name[:18]:18s} | {ep.program_format or '-':8s} | {ep.narrative_structure or '-':6s} | {ep.opening_hook or '-'}")
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
一次性脚本:清除测试数据,导入 25 期真实收视数据 + 回填 AI 标签
|
||||
数据来源:
|
||||
- 收视数据:ai-labeling/example/2026收视update.xlsx(已导出为 _tmp_excel.json)
|
||||
- AI 标签:ai-labeling/benchmark-set/ground-truth.json(v0.6.0,制片人审定)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
# 加 backend 到 path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.episode import Episode
|
||||
from app.models.user import User # 注册 User 表元数据,FK 解析需要
|
||||
from sqlmodel import select, delete
|
||||
|
||||
# ── 1. 读数据源 ──
|
||||
project_root = os.path.join(os.path.dirname(__file__), '..')
|
||||
|
||||
with open(os.path.join(project_root, '_tmp_excel.json'), 'r', encoding='utf-8') as f:
|
||||
excel_rows = json.load(f)
|
||||
|
||||
with open(os.path.join(project_root, 'ai-labeling', 'benchmark-set', 'ground-truth.json'), 'r', encoding='utf-8') as f:
|
||||
gt_data = json.load(f)
|
||||
|
||||
# ground-truth 按 ep 编号索引
|
||||
gt_map = {ep['ep']: ep for ep in gt_data['episodes']}
|
||||
|
||||
print(f"Excel: {len(excel_rows)} rows")
|
||||
print(f"Ground-truth: {len(gt_map)} episodes (v{gt_data['version']})")
|
||||
|
||||
# ── 2. 构造 Episode 对象 ──
|
||||
new_episodes = []
|
||||
for row in excel_rows:
|
||||
# 解析期号:"第1期" -> 1
|
||||
ep_num_str = row['ep'].replace('第', '').replace('期', '')
|
||||
ep_num = int(ep_num_str)
|
||||
|
||||
# 解析日期:"2026 01 06" -> date(2026, 1, 6)
|
||||
parts = row['date'].split()
|
||||
air = date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
|
||||
# 编导名(去空格)
|
||||
editor_name = row['editor'].replace(' ', '').replace(' ', '')
|
||||
|
||||
# 从 ground-truth 取 AI 标签
|
||||
gt = gt_map.get(ep_num, {})
|
||||
|
||||
ep = Episode(
|
||||
episode_number=ep_num,
|
||||
program_name=row['title'],
|
||||
air_date=air,
|
||||
editor_id=None, # 软引用,暂不关联 user id
|
||||
editor_name_snapshot=editor_name,
|
||||
audience_share=row['share'],
|
||||
audience_rating=row['rating'],
|
||||
# AI 标签
|
||||
program_format=gt.get('program_format'),
|
||||
equipment_domain=gt.get('equipment_domain'),
|
||||
scene_tags=gt.get('scene_tags'),
|
||||
tech_tags=gt.get('tech_tags'),
|
||||
narrative_structure=gt.get('narrative_structure'),
|
||||
opening_hook=gt.get('opening_hook'),
|
||||
ai_label_confidence='reviewed' if gt else None,
|
||||
)
|
||||
new_episodes.append(ep)
|
||||
|
||||
# ── 3. 执行:清旧 + 插新 ──
|
||||
session = next(get_session())
|
||||
|
||||
# 删除所有旧数据
|
||||
old_count = len(session.exec(select(Episode)).all())
|
||||
session.exec(delete(Episode))
|
||||
print(f"Deleted {old_count} old test episodes")
|
||||
|
||||
# 插入新数据
|
||||
for ep in new_episodes:
|
||||
session.add(ep)
|
||||
|
||||
session.commit()
|
||||
print(f"Inserted {len(new_episodes)} real episodes")
|
||||
|
||||
# ── 4. 验证 ──
|
||||
all_eps = session.exec(select(Episode).order_by(Episode.air_date)).all()
|
||||
print(f"\nVerification: {len(all_eps)} episodes in DB")
|
||||
labeled = sum(1 for e in all_eps if e.program_format is not None)
|
||||
print(f"With program_format: {labeled}/{len(all_eps)}")
|
||||
print()
|
||||
for e in all_eps:
|
||||
print(f" ep{e.episode_number:02d} | {str(e.air_date)} | {e.audience_share} | {e.editor_name_snapshot} | {e.program_format or '-'}")
|
||||
Reference in New Issue
Block a user