164 lines
6.2 KiB
Python
164 lines
6.2 KiB
Python
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
AI 校对器 — ASR 稿与 A 稿比对 + 上下文纠错
|
|||
|
|
|
|||
|
|
解决的核心问题:
|
|||
|
|
- ASR 同音字误识别("建制"→"舰只"、"舰手"→"舰艏")
|
|||
|
|
- 军事术语规范化("f15j"→"F-15J")
|
|||
|
|
- 的/地/得纠错
|
|||
|
|
- 去除口语填充词("嗯""那个""就是说")
|
|||
|
|
|
|||
|
|
策略:
|
|||
|
|
- 将 ASR 全文 + A 稿全文一起发给 DeepSeek
|
|||
|
|
- AI 结合节目主题和上下文做纠错
|
|||
|
|
- 返回修正后的句子列表 + 修改说明
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import List, Tuple, Dict
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from dotenv import load_dotenv
|
|||
|
|
_env_path = Path(__file__).resolve().parent.parent / ".env"
|
|||
|
|
if _env_path.exists():
|
|||
|
|
load_dotenv(str(_env_path), override=True)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
PROOFREAD_SYSTEM_PROMPT = """你是电视军事节目《军事科技》的字幕校对专家。你将收到两份材料:
|
|||
|
|
1. **ASR稿**:语音识别的转写结果,带有时间编号,是字幕的基础
|
|||
|
|
2. **A稿**:编导写的节目文稿(仅包含解说词,不包含专家采访的具体内容)
|
|||
|
|
|
|||
|
|
你的任务是校对 ASR 稿中的**语音识别错误**。
|
|||
|
|
|
|||
|
|
**铁律(违反任何一条都算失败):**
|
|||
|
|
- ASR稿是已经录好的音频的转写,内容不能改——**绝不润色语句、绝不调整语序、绝不增删实词**
|
|||
|
|
- 只修三类问题:① 错别字/同音字 ② 术语格式 ③ 口语填充词
|
|||
|
|
- 除这三类外的一切文字,原封不动照抄,一个字都不能动
|
|||
|
|
- A稿只用来判断"这个词在本期节目的语境下应该是哪个字",不能把ASR稿往A稿的措辞上靠
|
|||
|
|
|
|||
|
|
**允许修的三类:**
|
|||
|
|
1. **同音字/错别字**(ASR听错的字):如"建制"→"舰只"、"舰手"→"舰艏"、"继承"→"击沉"、"空花弹"→"滑翔弹"
|
|||
|
|
2. **术语格式**:英文型号大小写+连字符("f15j"→"F-15J"、"v22"→"V-22"、"rq四"→"RQ-4")
|
|||
|
|
3. **口语填充词删除**:只删"嗯""呃""唉""啊""呢""那个""就是说""这个"这类纯填充词。如果"这个"后面紧跟名词作指示代词("这个导弹"),保留不删
|
|||
|
|
|
|||
|
|
**绝对不许做的(哪怕你觉得改了更好也不许):**
|
|||
|
|
- 不许调整语序("它在性质上就是"不许改成"它本质上就是")
|
|||
|
|
- 不许替换实词("不是那么特别的顺利"不许改成"不太顺利")
|
|||
|
|
- 不许增删标点来改变句子结构
|
|||
|
|
- 不许把口语化表达改成书面语
|
|||
|
|
- 不许根据A稿的措辞替换ASR中意思相同但用词不同的表达
|
|||
|
|
|
|||
|
|
**输出格式:**
|
|||
|
|
JSON数组,每个元素:{"id": 编号, "original": "原文", "corrected": "修正后", "changes": "修改说明(无修改写空字符串)"}
|
|||
|
|
只输出JSON,不要其他内容。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
PROOFREAD_USER_TEMPLATE = """**A稿(节目文稿,仅供参考):**
|
|||
|
|
{script_text}
|
|||
|
|
|
|||
|
|
**ASR稿(需要校对,请逐条检查):**
|
|||
|
|
{asr_text}"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _create_client():
|
|||
|
|
api_key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
|
|||
|
|
if not api_key:
|
|||
|
|
raise ValueError("请在 .env 中设置 DEEPSEEK_API_KEY")
|
|||
|
|
from openai import OpenAI
|
|||
|
|
return OpenAI(
|
|||
|
|
api_key=api_key,
|
|||
|
|
base_url=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def proofread_batch(
|
|||
|
|
asr_sentences: List[Tuple[int, int, str, int]],
|
|||
|
|
script_text: str,
|
|||
|
|
batch_size: int = 30,
|
|||
|
|
) -> List[Tuple[int, int, str, int]]:
|
|||
|
|
"""
|
|||
|
|
对 ASR 句子列表做 AI 校对。
|
|||
|
|
|
|||
|
|
输入:
|
|||
|
|
asr_sentences: [(start_ms, end_ms, text, speaker_id), ...]
|
|||
|
|
script_text: A稿全文
|
|||
|
|
batch_size: 每批处理的句子数
|
|||
|
|
|
|||
|
|
返回:
|
|||
|
|
校对后的句子列表,格式同输入
|
|||
|
|
"""
|
|||
|
|
if not asr_sentences:
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
client = _create_client()
|
|||
|
|
|
|||
|
|
# A稿截取(太长的话截前8000字,够提供上下文了)
|
|||
|
|
script_truncated = script_text[:8000] if len(script_text) > 8000 else script_text
|
|||
|
|
|
|||
|
|
corrected_sentences = list(asr_sentences) # 浅拷贝
|
|||
|
|
total_changes = 0
|
|||
|
|
|
|||
|
|
for batch_start in range(0, len(asr_sentences), batch_size):
|
|||
|
|
batch = asr_sentences[batch_start:batch_start + batch_size]
|
|||
|
|
batch_end = batch_start + len(batch)
|
|||
|
|
|
|||
|
|
# 构建 ASR 文本(带编号)
|
|||
|
|
asr_lines = []
|
|||
|
|
for i, (bg, ed, text, spk) in enumerate(batch):
|
|||
|
|
asr_lines.append(f"[{i+1}] {text}")
|
|||
|
|
asr_text = "\n".join(asr_lines)
|
|||
|
|
|
|||
|
|
print(f"[校对] 处理第 {batch_start+1}-{batch_end} 句...")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
resp = client.chat.completions.create(
|
|||
|
|
model=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"),
|
|||
|
|
messages=[
|
|||
|
|
{"role": "system", "content": PROOFREAD_SYSTEM_PROMPT},
|
|||
|
|
{"role": "user", "content": PROOFREAD_USER_TEMPLATE.format(
|
|||
|
|
script_text=script_truncated,
|
|||
|
|
asr_text=asr_text,
|
|||
|
|
)},
|
|||
|
|
],
|
|||
|
|
temperature=0.1,
|
|||
|
|
max_tokens=4000,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
result_text = resp.choices[0].message.content.strip()
|
|||
|
|
|
|||
|
|
# 尝试解析 JSON
|
|||
|
|
# 去掉可能的 markdown 代码块标记
|
|||
|
|
if result_text.startswith("```"):
|
|||
|
|
result_text = result_text.split("\n", 1)[1]
|
|||
|
|
if result_text.endswith("```"):
|
|||
|
|
result_text = result_text[:-3]
|
|||
|
|
result_text = result_text.strip()
|
|||
|
|
|
|||
|
|
corrections = json.loads(result_text)
|
|||
|
|
|
|||
|
|
# 应用修正
|
|||
|
|
for item in corrections:
|
|||
|
|
idx = item.get("id", 0) - 1 # 编号从1开始
|
|||
|
|
corrected = item.get("corrected", "")
|
|||
|
|
changes = item.get("changes", "")
|
|||
|
|
|
|||
|
|
if 0 <= idx < len(batch) and corrected and changes:
|
|||
|
|
original_idx = batch_start + idx
|
|||
|
|
bg, ed, _, spk = corrected_sentences[original_idx]
|
|||
|
|
corrected_sentences[original_idx] = (bg, ed, corrected, spk)
|
|||
|
|
total_changes += 1
|
|||
|
|
print(f" 修正: '{item.get('original','')}' → '{corrected}' ({changes})")
|
|||
|
|
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
print(f"[校对] JSON解析失败,跳过本批: {e}", file=sys.stderr)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[校对] 出错: {e}", file=sys.stderr)
|
|||
|
|
|
|||
|
|
print(f"[校对] 完成,共修正 {total_changes} 处")
|
|||
|
|
return corrected_sentences
|