Files
xwlb/tools/compare_raw_improve.py
T
simon 60f8c263f2 《新闻联播》每日抓取入库:全链路 + 可移植化 + 定时任务
从 CCTV 主页抓取《新闻联播》,下载 → 转 MP3/WAV → 静音切分 → ASR 识别
→ LLM 校对 → 切分为单条新闻 → 入库 MySQL。

主要内容:
- 全链路:getVideo5 抓取下载、audioRead 转写、deepseek 校对与切分、newsProcess 入库
- 可移植化:配置分层,.env 只放密钥、config.yml 放模型/接入点/路由/参数
- 可换供应商:endpoints(kind/base_url/api_key_env/extra_body)+ routes 按环节选路
- 数据保真:数值事实守卫,校对改动数字/年份/届次则整片回退 ASR 原文;
  识别不完整不发布该日精编,避免半天内容被当成完整一天
- 定时任务:systemd 每天 21:00,失败 21:30 / 22:00 重试;
  只缺切分时只重跑切分(省掉全部 ASR),用 state/.asr_complete_* 标记判定阶段
- 隧道自愈:13306 不通时自动执行 autossh.sh(所有入口共用,systemd 托管时只等待)
- 中间产物每日清理;97 项离线自检(配置/清理/事实守卫/解析/隧道)
2026-09-25 11:17:46 +08:00

273 lines
11 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.
"""对比 news_raw / news_improve(并联动 news_content),评估 LLM 校对的必要性与 token 成本
.venv/bin/python tools/compare_raw_improve.py [--limit N] [--examples N] [--md 输出文件]
分类口径(逐分片):
identical 原文与校对后完全相同 → 校对没做任何事
punct_only 去掉标点/空白后相同 → 校对只动了标点、空白、断句
numeral_only 再去掉数字(中文数字与阿拉伯数字)后相同 → 只动了数字写法或标点
word_change 仍有差异 → 真正改了字词(可能修错别字,也可能是改写/篡改)
token 估算:中文近似 1 字符 ≈ 1 token(保守上限,实际分词器约 0.6~1.0),
只用于比较两个方案的**相对**开销,绝对费用请按自己供应商单价换算。
"""
import argparse
import re
import sys
from collections import Counter
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from mysqlHandle import MySQLDB # noqa: E402
# 标点/空白(除中文、字母、数字之外的一切)
_NON_WORD = re.compile(r'[^\u4e00-\u9fffA-Za-z0-9]')
# 中文数字(与阿拉伯数字一并视为"数字")
_CN_NUM = '零〇一二两三四五六七八九十百千万亿'
_NON_WORD_OR_DIGIT = re.compile(r'[^\u4e00-\u9fffA-Za-z]')
_PUNCT_ONLY = re.compile(r'[\u4e00-\u9fffA-Za-z0-9]')
def strip_punct(text):
"""去掉标点与空白,只留中文/字母/数字"""
return _PUNCT_ONLY.findall(text or '')
def strip_punct_and_digits(text):
"""去掉标点、空白与所有数字(含中文数字)"""
return [ch for ch in (text or '') if ch not in _CN_NUM and not ch.isdigit() and _PUNCT_ONLY.match(ch)]
def punct_density(text):
"""每 100 字符中的标点数"""
text = text or ''
if not text:
return 0.0
return len(_NON_WORD.findall(text)) / len(text) * 100
def title_marks(text):
"""书名号《》出现次数(可读性的直观指标)"""
return (text or '').count('《')
def multiset_delta(a, b):
"""a 相对 b 的字符多重集差异数(近似"改了多少字",O(n))"""
ca, cb = Counter(a or ''), Counter(b or '')
return sum((ca - cb).values())
def classify(raw, improve):
if raw == improve:
return 'identical'
if strip_punct(raw) == strip_punct(improve):
return 'punct_only'
if strip_punct_and_digits(raw) == strip_punct_and_digits(improve):
return 'numeral_only'
return 'word_change'
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--limit', type=int, default=0, help='只取最近 N 天')
ap.add_argument('--examples', type=int, default=2, help='每类抽样条数')
ap.add_argument('--md', default='', help='同时写出 markdown 报告')
args = ap.parse_args()
db = MySQLDB()
try:
where = '1=1'
params = None
if args.limit:
dates = db.query_data(
'xwlb_daily', 'DISTINCT news_days d',
'1=1 ORDER BY news_days DESC LIMIT %s', (args.limit,))
if not dates:
print('没有数据')
return 1
d0 = min(r['d'] for r in dates)
where, params = 'news_days >= %s', (d0,)
rows = db.query_data(
'xwlb_daily', 'news_days, daily_sub_id, news_raw, news_improve',
f'{where} ORDER BY news_days, daily_sub_id', params)
content_rows = db.query_data(
'xwlb_daily_ext', 'news_date, sub_id, news_content',
'1=1 ORDER BY news_date, sub_id', None)
finally:
db.close()
# ---------- 逐分片分类 ----------
stats = {k: {'n': 0, 'len_raw': 0, 'len_imp': 0, 'delta': 0, 'delta_words': 0}
for k in ('identical', 'punct_only', 'numeral_only', 'word_change')}
removed_chars, added_chars = Counter(), Counter()
monthly = {}
examples = {k: [] for k in stats}
total_raw = total_imp = 0
pd_raw = pd_imp = 0.0
tm_raw = tm_imp = 0
days = set()
day_changed = {}
for r in rows:
raw, imp = r['news_raw'] or '', r['news_improve'] or ''
cat = classify(raw, imp)
s = stats[cat]
s['n'] += 1
s['len_raw'] += len(raw)
s['len_imp'] += len(imp)
s['delta'] += multiset_delta(raw, imp)
# 排除标点/数字后的"真实字词"改动量
s['delta_words'] += multiset_delta(''.join(strip_punct_and_digits(raw)),
''.join(strip_punct_and_digits(imp)))
if cat == 'word_change':
removed_chars.update((Counter(raw) - Counter(imp)).elements())
added_chars.update((Counter(imp) - Counter(raw)).elements())
key = str(r['news_days'])[:7]
m = monthly.setdefault(key, {'n': 0, 'same': 0})
m['n'] += 1
if cat == 'identical':
m['same'] += 1
total_raw += len(raw)
total_imp += len(imp)
pd_raw += punct_density(raw)
pd_imp += punct_density(imp)
tm_raw += title_marks(raw)
tm_imp += title_marks(imp)
days.add(r['news_days'])
day_changed.setdefault(r['news_days'], 0)
if cat != 'identical':
day_changed[r['news_days']] += 1
if len(examples[cat]) < args.examples:
examples[cat].append((r['news_days'], r['daily_sub_id'], raw, imp))
n = len(rows) or 1
out = []
def w(line=''):
print(line)
out.append(line)
w(f"# news_raw vs news_improve 评估报告")
w()
w(f"样本:`xwlb_daily` {len(rows)} 个分片,覆盖 {len(days)} 天"
f"({min(days)} ~ {max(days)});`xwlb_daily_ext` {len(content_rows)} 条")
w()
w("## 1. 校对到底改了什么")
w()
w("| 分类 | 分片数 | 占比 | 平均改动字符数 | 含义 |")
w("|---|---|---|---|---|")
label = {
'identical': '完全没改',
'punct_only': '只改标点/空白/断句',
'numeral_only': '只改数字写法或标点',
'word_change': '**真的改了字词**',
}
for k in ('identical', 'punct_only', 'numeral_only', 'word_change'):
s = stats[k]
avg_delta = s['delta'] / s['n'] if s['n'] else 0
w(f"| {k} | {s['n']} | {s['n'] / n * 100:.1f}% | {avg_delta:.1f} | {label[k]} |")
wc = stats['word_change']
if wc['n']:
w()
w(f"「真的改了字词」的 {wc['n']} 个分片里,**排除标点与数字后**的平均改动量只有 "
f"**{wc['delta_words'] / wc['n']:.1f} 字**"
f"(占分片平均长度 {wc['len_raw'] / wc['n']:.0f} 字的 "
f"{wc['delta_words'] / max(wc['len_raw'], 1) * 100:.1f}%)")
w()
w("改动最频繁的字符(raw 有而 improve 没有 → improve 新增):")
w()
w("| 被替换掉的字符 | 次数 | 新增的字符 | 次数 |")
w("|---|---|---|---|")
top_rm = removed_chars.most_common(10)
top_ad = added_chars.most_common(10)
for i in range(max(len(top_rm), len(top_ad))):
rm = top_rm[i] if i < len(top_rm) else ('', '')
ad = top_ad[i] if i < len(top_ad) else ('', '')
rm_ch = rm[0].replace(' ', '␠').replace('\n', '⏎')
ad_ch = ad[0].replace(' ', '␠').replace('\n', '⏎')
w(f"| `{rm_ch}` | {rm[1]} | `{ad_ch}` | {ad[1]} |")
changed = n - stats['identical']['n']
w()
w(f"- 校对**实际生效**的分片:{changed}/{n}({changed / n * 100:.1f}%);"
f"完全没动的:{stats['identical']['n']}/{n}({stats['identical']['n'] / n * 100:.1f}%)")
all_same_days = sum(1 for d in days if day_changed.get(d, 0) == 0)
w(f"- 全天 11 个分片**全都没被改**的天数:{all_same_days}/{len(days)}")
w()
w("### 可读性指标(越大越接近正式书面语)")
w()
w("| 指标 | 原文 news_raw | 校对后 news_improve | 变化 |")
w("|---|---|---|---|")
w(f"| 标点密度(每百字标点数) | {pd_raw / n:.2f} | {pd_imp / n:.2f} | "
f"{(pd_imp - pd_raw) / n:+.2f} |")
w(f"| 书名号《总数 | {tm_raw} | {tm_imp} | {tm_imp - tm_raw:+d} |")
w(f"| 总字数 | {total_raw} | {total_imp} | {total_imp - total_raw:+d} |")
if stats['word_change']['n']:
w()
w("### 「真的改了字词」的抽样(判断是修错字还是改写)")
for d, sid, raw, imp in examples['word_change']:
w()
w(f"- **{d} 第{sid}片**(改动约 {multiset_delta(raw, imp)} 字)")
w(f" - raw: {raw[:120]}")
w(f" - imp: {imp[:120]}")
w()
w("### 按月的「完全没改」比例(用于识别校对失效的历史区间)")
w()
w("| 月份 | 分片数 | 完全没改 | 占比 |")
w("|---|---|---|---|")
for key in sorted(monthly):
m = monthly[key]
w(f"| {key} | {m['n']} | {m['same']} | {m['same'] / m['n'] * 100:.0f}% |")
# ---------- token 预算 ----------
w()
w("## 2. token 开销:现状 vs 两个替代方案")
w()
by_day_raw, by_day_imp = {}, {}
for r in rows:
by_day_raw[r['news_days']] = by_day_raw.get(r['news_days'], 0) + len(r['news_raw'] or '')
by_day_imp[r['news_days']] = by_day_imp.get(r['news_days'], 0) + len(r['news_improve'] or '')
by_day_content = {}
for r in content_rows:
if r['news_date'] in by_day_raw:
by_day_content[r['news_date']] = by_day_content.get(r['news_date'], 0) + len(r['news_content'] or '')
common = sorted(set(by_day_raw) & set(by_day_content))
if common:
raw_c = sum(by_day_raw[d] for d in common) / len(common)
imp_c = sum(by_day_imp[d] for d in common) / len(common)
con_c = sum(by_day_content[d] for d in common) / len(common)
corr_in, corr_out = raw_c, imp_c
split_in, split_out = imp_c, con_c
merged_in, merged_out = raw_c, con_c
cur = corr_in + corr_out + split_in + split_out
merged = merged_in + merged_out
off = raw_c + con_c
w(f"统计口径:{len(common)} 天同时有原文与精编的天数,取日均字符数(≈token 数,中文 1 字≈1 token)")
w()
w("| 方案 | 输入 | 输出 | 合计/天 | 相对现状 |")
w("|---|---|---|---|---|")
w(f"| A 现状(校对 + 切分 两次调用) | {corr_in + split_in:.0f} | {corr_out + split_out:.0f} | **{cur:.0f}** | — |")
w(f"| B 合并进切分(一次调用同时校对+切分) | {merged_in:.0f} | {merged_out:.0f} | **{merged:.0f}** | "
f"省 {(1 - merged / cur) * 100:.0f}% |")
w(f"| C 关掉校对(直接用 ASR 原文切分) | {off:.0f} | {con_c:.0f} | **{off:.0f}** | "
f"省 {(1 - off / cur) * 100:.0f}% |")
w()
w(f"其中校对这一次调用本身消耗 输入 {corr_in:.0f} + 输出 {corr_out:.0f} = "
f"**{corr_in + corr_out:.0f} token/天**,占现状总开销的 "
f"{(corr_in + corr_out) / cur * 100:.0f}%。")
w()
w(f"按 {len(common)} 天累计:校对一项约消耗 "
f"{(corr_in + corr_out) * len(common) / 1e6:.2f} M token。")
if args.md:
Path(args.md).write_text('\n'.join(out) + '\n', encoding='utf-8')
print(f"\n报告已写入 {args.md}")
return 0
if __name__ == '__main__':
sys.exit(main())