doco: v2说话人分段模式 — ASR说话人分离+大block拆分+三维动画解说识别
- asr_adapter: 新增roleType=1说话人分离参数,新增parse_order_result_with_speaker(),write_asr_result自动输出asr_v2_timed_spk.txt - fusion_align: 新增speaker-aware alignment v2流程(_annotate_b_lines_with_speakers区间匹配、_detect_speaker_blocks、SYSTEM_PROMPT_SPEAKER_ALIGN大block拆分prompt、_build_broadcast_segments支持block内多段拆分) - cli: 兼容v1/v2 stats字典 - 新增convert_to_md.py(20期融合A稿docx转md+YAML frontmatter) - backup_before_spk/: 修改前代码备份
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
讯飞 ASR 适配层
|
||||
=================================================
|
||||
来源: demo 跑通的 xfyun_asr_standard.py
|
||||
改动: 凭证从环境变量读取,不再硬编码
|
||||
|
||||
接口: https://raasr.xfyun.cn/v2/api/upload / getResult
|
||||
签名: signa = base64(HmacSHA1(MD5(appid + ts), secretKey))
|
||||
|
||||
特性:
|
||||
- 支持热词列表(hotWord),提升专业术语识别率
|
||||
- 支持军事领域参数(pd=mil)
|
||||
- 支持顺滑+口语规整(输出更接近书面语)
|
||||
- 默认语种 cn(中文普通话),免费包标配
|
||||
|
||||
凭证来源: 环境变量
|
||||
- XFYUN_APP_ID
|
||||
- XFYUN_SECRET_KEY
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
import requests
|
||||
|
||||
# ========================================================================
|
||||
# 凭证 — 优先加载 doco/.env(与 llm.py 相同方式)
|
||||
# ========================================================================
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_doco_env = Path(__file__).resolve().parent.parent.parent / ".env" # doco/.env
|
||||
if _doco_env.exists():
|
||||
load_dotenv(str(_doco_env), override=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
APP_ID = os.environ.get("XFYUN_APP_ID", "").strip()
|
||||
SECRET_KEY = os.environ.get("XFYUN_SECRET_KEY", "").strip()
|
||||
|
||||
if not APP_ID or not SECRET_KEY:
|
||||
print("[ASR 配置错误] XFYUN_APP_ID 或 XFYUN_SECRET_KEY 未配置或为空", file=sys.stderr)
|
||||
print("[ASR 配置错误] 请在 doco/.env 中设置这两个环境变量", file=sys.stderr)
|
||||
print("[ASR 配置错误] 格式: XFYUN_APP_ID=你的appid / XFYUN_SECRET_KEY=你的secret", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 接口配置
|
||||
# ========================================================================
|
||||
|
||||
HOST = "https://raasr.xfyun.cn/v2/api"
|
||||
UPLOAD_URL = HOST + "/upload"
|
||||
RESULT_URL = HOST + "/getResult"
|
||||
|
||||
# 业务参数
|
||||
LANGUAGE = "cn" # 中文普通话
|
||||
PD = "mil" # 军事领域优化
|
||||
ENG_SMOOTHPROC = "true" # 顺滑(去掉"嗯/那个")
|
||||
ENG_COLLOQPROC = "true" # 口语规整
|
||||
|
||||
# 轮询配置
|
||||
POLL_INTERVAL_SECONDS = 30
|
||||
MAX_WAIT_MINUTES = 30
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 热词列表(每期节目调用前从 A 稿提取)
|
||||
# ========================================================================
|
||||
|
||||
def get_hot_words(episode_id: str) -> List[str]:
|
||||
"""
|
||||
读取 programs/<episode_id>/本期热词表.txt,
|
||||
按 "|" 切分、strip、去空去重,返回 List[str]。
|
||||
文件缺失返回 [] 并 stderr 警告(不退出)。
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# doco 项目根 = doco/src/doco/asr_adapter.py → 上3级到达 doco/
|
||||
_project_root = _Path(__file__).resolve().parent.parent.parent
|
||||
hotwords_file = _project_root / "programs" / episode_id / "本期热词表.txt"
|
||||
|
||||
if not hotwords_file.exists():
|
||||
print(f"[ASR 热词] 未找到热词表: {hotwords_file},热词跳过", file=sys.stderr)
|
||||
return []
|
||||
|
||||
try:
|
||||
raw = hotwords_file.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"[ASR 热词] 读取热词表失败: {e}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
# 按 | 切分、strip、过滤空字符串、去重(保持顺序)
|
||||
words: List[str] = []
|
||||
seen: set = set()
|
||||
for token in raw.split("|"):
|
||||
w = token.strip()
|
||||
if w and w not in seen:
|
||||
seen.add(w)
|
||||
words.append(w)
|
||||
|
||||
return words
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 签名+工具
|
||||
# ========================================================================
|
||||
|
||||
|
||||
def make_signa(app_id: str, secret_key: str, ts: str) -> str:
|
||||
"""
|
||||
讯飞老版签名:signa = base64(HmacSHA1(MD5(appid + ts), secretKey))
|
||||
"""
|
||||
base_string = (app_id + ts).encode("utf-8")
|
||||
md5_str = hashlib.md5(base_string).hexdigest() # 32位小写hex
|
||||
mac = hmac.new(
|
||||
secret_key.encode("utf-8"),
|
||||
md5_str.encode("utf-8"),
|
||||
digestmod=hashlib.sha1,
|
||||
)
|
||||
signa = base64.b64encode(mac.digest()).decode("utf-8")
|
||||
return signa
|
||||
|
||||
|
||||
def get_audio_duration_ms(filepath: str) -> int:
|
||||
"""获取音频时长(毫秒)。WAV用内置,MP3用mutagen。"""
|
||||
ext = os.path.splitext(filepath)[1].lower()
|
||||
|
||||
if ext == ".wav":
|
||||
with wave.open(filepath, "rb") as wf:
|
||||
n_frames = wf.getnframes()
|
||||
sample_rate = wf.getframerate()
|
||||
duration_ms = int(round(n_frames / sample_rate * 1000))
|
||||
return duration_ms
|
||||
|
||||
if ext == ".mp3":
|
||||
try:
|
||||
from mutagen.mp3 import MP3
|
||||
return int(MP3(filepath).info.length * 1000)
|
||||
except ImportError:
|
||||
return 0
|
||||
|
||||
raise ValueError(f"不支持的音频格式: {ext}")
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 上传
|
||||
# ========================================================================
|
||||
|
||||
|
||||
def upload_audio(
|
||||
filepath: str,
|
||||
hot_words: Optional[List[str]] = None,
|
||||
) -> str:
|
||||
"""上传音频,返回 orderId"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"音频文件不存在: {filepath}")
|
||||
|
||||
if not APP_ID or not SECRET_KEY:
|
||||
raise ValueError("请先设置 XFYUN_APP_ID 和 XFYUN_SECRET_KEY 环境变量")
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
file_name = os.path.basename(filepath)
|
||||
duration_ms = get_audio_duration_ms(filepath)
|
||||
ts = str(int(time.time()))
|
||||
|
||||
signa = make_signa(APP_ID, SECRET_KEY, ts)
|
||||
|
||||
# 构建URL参数
|
||||
params = {
|
||||
"appId": APP_ID,
|
||||
"signa": signa,
|
||||
"ts": ts,
|
||||
"fileSize": str(file_size),
|
||||
"fileName": file_name,
|
||||
"duration": str(duration_ms),
|
||||
"language": LANGUAGE,
|
||||
"pd": PD,
|
||||
"eng_smoothproc": ENG_SMOOTHPROC,
|
||||
"eng_colloqproc": ENG_COLLOQPROC,
|
||||
}
|
||||
|
||||
# 热词,用 | 分隔
|
||||
if hot_words:
|
||||
hot_word_str = "|".join(hot_words)
|
||||
params["hotWord"] = hot_word_str
|
||||
|
||||
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
|
||||
url = f"{UPLOAD_URL}?{'&'.join(url_parts)}"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
with open(filepath, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
|
||||
resp = requests.post(url, headers=headers, data=audio_bytes, timeout=300)
|
||||
|
||||
data = resp.json()
|
||||
if data.get("code") != "000000":
|
||||
raise RuntimeError(f"上传失败: code={data.get('code')}, desc={data.get('descInfo')}")
|
||||
|
||||
order_id = data["content"]["orderId"]
|
||||
return order_id
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 查询结果
|
||||
# ========================================================================
|
||||
|
||||
|
||||
def query_result(order_id: str) -> dict:
|
||||
"""单次查询"""
|
||||
ts = str(int(time.time()))
|
||||
signa = make_signa(APP_ID, SECRET_KEY, ts)
|
||||
|
||||
params = {
|
||||
"appId": APP_ID,
|
||||
"signa": signa,
|
||||
"ts": ts,
|
||||
"orderId": order_id,
|
||||
"resultType": "transfer",
|
||||
}
|
||||
url_parts = [f"{quote(k, safe='')}={quote(str(v), safe='')}" for k, v in params.items()]
|
||||
url = f"{RESULT_URL}?{'&'.join(url_parts)}"
|
||||
|
||||
resp = requests.post(url, timeout=30)
|
||||
return resp.json()
|
||||
|
||||
|
||||
def poll_until_done(order_id: str) -> dict:
|
||||
"""轮询直到完成"""
|
||||
start_time = time.time()
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed > MAX_WAIT_MINUTES * 60:
|
||||
raise TimeoutError(f"超过 {MAX_WAIT_MINUTES} 分钟未完成")
|
||||
|
||||
data = query_result(order_id)
|
||||
order_info = data.get("content", {}).get("orderInfo", {})
|
||||
status = order_info.get("status")
|
||||
fail_type = order_info.get("failType", 0)
|
||||
|
||||
if status == 4:
|
||||
return data
|
||||
if status == -1:
|
||||
raise RuntimeError(f"转写失败: failType={fail_type}, 数据: {data}")
|
||||
|
||||
time.sleep(POLL_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
# ========================================================================
|
||||
# 结果解析
|
||||
# ========================================================================
|
||||
|
||||
|
||||
def parse_order_result(order_result_str: str) -> List[Tuple[int, int, str]]:
|
||||
"""
|
||||
解析嵌套JSON,返回 [(sentence_start_ms, sentence_end_ms, text), ...]
|
||||
"""
|
||||
if not order_result_str:
|
||||
return []
|
||||
|
||||
cleaned = re.sub(r"\\\\", r"\\", order_result_str)
|
||||
outer = json.loads(cleaned)
|
||||
|
||||
sentences = []
|
||||
for item in outer.get("lattice", []):
|
||||
inner_str = item.get("json_1best", "")
|
||||
if not inner_str:
|
||||
continue
|
||||
inner = json.loads(inner_str)
|
||||
st = inner.get("st", {})
|
||||
bg = int(st.get("bg", 0))
|
||||
ed = int(st.get("ed", 0))
|
||||
|
||||
words = []
|
||||
for rt in st.get("rt", []):
|
||||
for ws in rt.get("ws", []):
|
||||
for cw in ws.get("cw", []):
|
||||
w = cw.get("w", "").strip()
|
||||
wp = cw.get("wp", "n")
|
||||
if w and wp != "g":
|
||||
words.append(w)
|
||||
sentence = "".join(words).strip()
|
||||
if sentence:
|
||||
sentences.append((bg, ed, sentence))
|
||||
|
||||
return sentences
|
||||
|
||||
|
||||
def format_timestamp(ms: int) -> str:
|
||||
"""毫秒转 [Nm Ns] 格式"""
|
||||
total_sec = ms // 1000
|
||||
return f"{total_sec // 60}m{total_sec % 60}s"
|
||||
|
||||
|
||||
def transcribe(
|
||||
audio_path: str,
|
||||
hot_words: Optional[List[str]] = None,
|
||||
) -> Tuple[List[Tuple[int, int, str]], str]:
|
||||
"""
|
||||
完整转写流程:上传 → 轮询 → 解析
|
||||
返回 (sentences, raw_order_result_json_str)
|
||||
- sentences: [(start_ms, end_ms, text), ...]
|
||||
- raw_order_result_json_str: 讯飞原始 orderResult 字段原文(用于断点续跑落盘)
|
||||
"""
|
||||
order_id = upload_audio(audio_path, hot_words=hot_words)
|
||||
result_data = poll_until_done(order_id)
|
||||
order_result_str = result_data["content"]["orderResult"]
|
||||
sentences = parse_order_result(order_result_str)
|
||||
return sentences, order_result_str
|
||||
|
||||
|
||||
def write_asr_result(
|
||||
sentences: List[Tuple[int, int, str]],
|
||||
output_dir: str,
|
||||
raw_order_result: str = "",
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
将 ASR 结果写入文件
|
||||
返回 (timed_txt_path, raw_json_path)
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
timed_lines = [f"[{format_timestamp(bg)}] {text}" for bg, _, text in sentences]
|
||||
timed_path = os.path.join(output_dir, "asr_v2_timed.txt")
|
||||
with open(timed_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(timed_lines))
|
||||
|
||||
raw_path = os.path.join(output_dir, "asr_result_raw.json")
|
||||
with open(raw_path, "w", encoding="utf-8") as f:
|
||||
if raw_order_result:
|
||||
f.write(raw_order_result)
|
||||
else:
|
||||
# 没有原始数据时写空对象(兼容旧调用)
|
||||
f.write("{}")
|
||||
|
||||
return timed_path, raw_path
|
||||
@@ -0,0 +1,782 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
doco CLI 入口
|
||||
P1: doco split 子命令
|
||||
P3: doco process 子命令(带 --input-a-draft 和 --cleanup-level)
|
||||
P3 C1: doco terms 子命令
|
||||
P3 run: 一键全流程 P1→P2→C1→C2→C3→C4
|
||||
"""
|
||||
|
||||
import click
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# P1 相关
|
||||
from .video_split import split_video, extract_audio
|
||||
|
||||
# P3 C1 术语提取
|
||||
from .term_extract import run_terms
|
||||
|
||||
# P3 C2 讯飞 ASR
|
||||
from .asr_adapter import get_hot_words, transcribe, write_asr_result
|
||||
|
||||
# P3 C3 B稿⊕ASR 交叉复审融合
|
||||
from .fusion_review import run_fusion
|
||||
|
||||
# P3 C4 分段对齐 → 融合A稿
|
||||
from .fusion_align import run_compose, run_skeleton
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version="0.1.0")
|
||||
def main():
|
||||
"""TPS 中台 - 终版文稿生成工具"""
|
||||
pass
|
||||
|
||||
|
||||
@main.command("split")
|
||||
@click.option(
|
||||
"--episode-id",
|
||||
required=True,
|
||||
help="节目 ID,如 ep001_20260612_fangkong_fandao",
|
||||
)
|
||||
@click.option(
|
||||
"--input-video",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="输入视频文件路径",
|
||||
)
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
required=True,
|
||||
type=click.Path(),
|
||||
help="输出目录(work/ 路径)",
|
||||
)
|
||||
@click.option(
|
||||
"--hash-algorithm",
|
||||
default="dhash",
|
||||
type=click.Choice(["dhash", "phash"]),
|
||||
help="哈希算法:dhash(默认,对边缘敏感) 或 phash(感知哈希)",
|
||||
)
|
||||
@click.option(
|
||||
"--phash-threshold",
|
||||
default=2,
|
||||
type=int,
|
||||
help="pHash 海明距离阈值(默认 2)",
|
||||
)
|
||||
@click.option(
|
||||
"--dhash-threshold",
|
||||
default=5,
|
||||
type=int,
|
||||
help="dHash 海明距离阈值(默认 5)",
|
||||
)
|
||||
@click.option(
|
||||
"--iou-threshold",
|
||||
default=0.95,
|
||||
type=float,
|
||||
help="IoU 保底阈值:二值化帧间 IoU > 此值视为同字幕(默认 0.95)",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="只抽帧+裁切,不调 OCR API;用于验证裁切框位置是否正确",
|
||||
)
|
||||
def split(
|
||||
episode_id: str,
|
||||
input_video: str,
|
||||
output_dir: str,
|
||||
hash_algorithm: str,
|
||||
phash_threshold: int,
|
||||
dhash_threshold: int,
|
||||
iou_threshold: float,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""
|
||||
P1: 视频双路拆分
|
||||
|
||||
A 路:抽帧 + 空白帧过滤 + 哈希变化检测 + OCR → B 稿 txt
|
||||
B 路:提取音频(16kHz/单声道/16bit WAV)
|
||||
|
||||
使用 --dry-run 可跳过 OCR 调用,先验证裁切框位置:
|
||||
1. 运行 dry-run
|
||||
2. 检查 work/frames/ 下的前几张关键帧小图
|
||||
3. 确认字幕被完整框住后,去掉 --dry-run 跑正式版
|
||||
"""
|
||||
video_path = Path(input_video)
|
||||
out_dir = Path(output_dir)
|
||||
|
||||
click.echo(f"[doco split] episode_id={episode_id}")
|
||||
click.echo(f"[doco split] input_video={video_path}")
|
||||
click.echo(f"[doco split] output_dir={out_dir}")
|
||||
click.echo(f"[doco split] hash_algorithm={hash_algorithm}")
|
||||
click.echo(f"[doco split] phash_threshold={phash_threshold}")
|
||||
click.echo(f"[doco split] dhash_threshold={dhash_threshold}")
|
||||
click.echo(f"[doco split] iou_threshold={iou_threshold}")
|
||||
click.echo(f"[doco split] dry_run={dry_run}")
|
||||
|
||||
try:
|
||||
result = split_video(
|
||||
video_path=video_path,
|
||||
output_dir=out_dir,
|
||||
episode_id=episode_id,
|
||||
hash_algorithm=hash_algorithm,
|
||||
phash_threshold=phash_threshold,
|
||||
dhash_threshold=dhash_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
if dry_run:
|
||||
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
|
||||
click.echo(f"[ok] 音频: {result['audio_path']}")
|
||||
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
|
||||
click.echo("[ok] dry-run 完成,请检查 frames/ 目录下的关键帧小图")
|
||||
else:
|
||||
click.echo(f"[ok] B 稿: {result['b_manuscript_path']}")
|
||||
click.echo(f"[ok] 音频: {result['audio_path']}")
|
||||
click.echo(f"[ok] 关键帧索引: {result['keyframes_path']}")
|
||||
click.echo(f"[ok] 关键帧数量: {result['keyframe_count']}")
|
||||
except Exception as e:
|
||||
click.echo(f"[error] {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("process")
|
||||
@click.option("--episode-id", required=True, help="节目 ID")
|
||||
@click.option("--input-video", required=True, type=click.Path(exists=True), help="输入视频")
|
||||
@click.option("--input-a-draft", required=True, type=click.Path(exists=True), help="A 稿 docx")
|
||||
@click.option("--output-dir", required=True, type=click.Path(), help="输出目录")
|
||||
@click.option(
|
||||
"--cleanup-level",
|
||||
default="medium",
|
||||
type=click.Choice(["keep_all", "medium", "clean"]),
|
||||
help="口语清理档位(默认 medium)",
|
||||
)
|
||||
def process(
|
||||
episode_id: str,
|
||||
input_video: str,
|
||||
input_a_draft: str,
|
||||
output_dir: str,
|
||||
cleanup_level: str,
|
||||
):
|
||||
"""
|
||||
P3: 三方融合全流程
|
||||
需要 A 稿 + B 稿(本命令调用 split) + ASR 结果,融合输出终版 docx + 差异报告
|
||||
"""
|
||||
click.echo("[doco process] P3 全流程暂未实现,请先使用 split 命令")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("terms")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||||
@click.option(
|
||||
"--a-script",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="A 稿 txt 文件路径(按纯文本读取)",
|
||||
)
|
||||
@click.option(
|
||||
"--no-ai",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="跳过 AI 层提取(Claude),仅使用规则层",
|
||||
)
|
||||
def terms(
|
||||
episode_id: str,
|
||||
a_script: str,
|
||||
no_ai: bool,
|
||||
):
|
||||
"""
|
||||
P3 C1: 术语提取 + 累积词典 + 本期热词表
|
||||
|
||||
从本期 A 稿提取专有名词 → 更新中台累积词典 → 产出本期热词表(给讯飞 ASR 用)。
|
||||
|
||||
两层提取:
|
||||
A) 规则层(必跑):正则抓型号/番号/兵器名/国名/机构名/人名
|
||||
B) AI 层(--no-ai 跳过):调 Claude 补抓专名并归类
|
||||
|
||||
产物:
|
||||
- doco/data/term_dict.json(累积词典,幂等更新)
|
||||
- doco/programs/<episode-id>/本期热词表.txt(| 分隔,最多 200 条)
|
||||
- doco/programs/<episode-id>/c1_term_candidates.json(三段留痕)
|
||||
"""
|
||||
script_path = Path(a_script)
|
||||
|
||||
click.echo(f"[doco terms] episode_id={episode_id}")
|
||||
click.echo(f"[doco terms] A 稿={script_path}")
|
||||
click.echo(f"[doco terms] no_ai={no_ai}")
|
||||
|
||||
try:
|
||||
result = run_terms(
|
||||
episode_id=episode_id,
|
||||
a_script_path=script_path,
|
||||
no_ai=no_ai,
|
||||
)
|
||||
click.echo(f"[ok] 规则候选: {result['rule_count']} 条")
|
||||
click.echo(f"[ok] AI 候选: {result['ai_count']} 条")
|
||||
click.echo(f"[ok] 合并后: {result['merged_count']} 条")
|
||||
click.echo(f"[ok] 词典新增: {result['dict_new_entries']} 条 / 词典共 {result['dict_total']} 条")
|
||||
click.echo(f"[ok] 本期热词表: {result['hotword_count']} 条 → {result['hotwords_path']}")
|
||||
click.echo(f"[ok] 留痕: {result['audit_path']}")
|
||||
except Exception as e:
|
||||
click.echo(f"[error] {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("fuse")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
type=click.Path(),
|
||||
help="输出目录(默认 programs/<episode-id>/)",
|
||||
)
|
||||
@click.option(
|
||||
"--no-ai",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="跳过 LLM 只跑规则层(=全 unchanged)",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
default=35,
|
||||
type=int,
|
||||
help="每批送审行数(默认 35)",
|
||||
)
|
||||
def fuse(
|
||||
episode_id: str,
|
||||
output_dir: str,
|
||||
no_ai: bool,
|
||||
batch_size: int,
|
||||
):
|
||||
"""
|
||||
P3 C3: B稿 ⊕ ASR 交叉复审融合
|
||||
|
||||
逐行复审 B稿(屏幕字幕OCR),以 ASR(口语转写)为上下文参考,
|
||||
只做纠错,绝不合并行、不拆行、不增删行、不改时间戳。
|
||||
|
||||
--no-ai: 跳过 LLM,全 unchanged(验证管道)
|
||||
--batch-size: 每批送审行数,默认 35
|
||||
|
||||
产物:
|
||||
- 融合B稿.txt(与 B稿_v2 逐行时间戳一致)
|
||||
- fusion_review.csv(仅含 change_type≠unchanged 或 confidence<0.8 的行)
|
||||
"""
|
||||
if output_dir is None:
|
||||
out_dir = Path("programs") / episode_id
|
||||
else:
|
||||
out_dir = Path(output_dir)
|
||||
|
||||
click.echo(f"[doco fuse] episode_id={episode_id}")
|
||||
click.echo(f"[doco fuse] output_dir={out_dir}")
|
||||
click.echo(f"[doco fuse] no_ai={no_ai}")
|
||||
click.echo(f"[doco fuse] batch_size={batch_size}")
|
||||
|
||||
try:
|
||||
stats = run_fusion(
|
||||
episode_id=episode_id,
|
||||
output_dir=str(out_dir),
|
||||
no_ai=no_ai,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
click.echo(f"[ok] 总行数: {stats['total_lines']}")
|
||||
click.echo(f"[ok] 各 change_type 计数: {stats['change_counts']}")
|
||||
click.echo(f"[ok] 进 review 行数: {stats['review_lines']}")
|
||||
click.echo(f"[ok] 融合B稿: {stats['fused_path']}")
|
||||
click.echo(f"[ok] review CSV: {stats['csv_path']}")
|
||||
except Exception as e:
|
||||
click.echo(f"[error] {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("skeleton")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
|
||||
@click.option("--a-script", required=True, type=click.Path(exists=True), help="A 稿 docx 路径")
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
type=click.Path(),
|
||||
help="输出目录(默认 programs/<episode-id>/)",
|
||||
)
|
||||
@click.option(
|
||||
"--max-tokens",
|
||||
default=16000,
|
||||
type=int,
|
||||
help="LLM max_tokens(默认 16000,长稿可调大)",
|
||||
)
|
||||
def skeleton(
|
||||
episode_id: str,
|
||||
a_script: str,
|
||||
output_dir: str,
|
||||
max_tokens: int,
|
||||
):
|
||||
"""
|
||||
P3 新增: LLM 分段骨架抽取(只产骨架,不跑对齐)
|
||||
|
||||
流程:
|
||||
1. extract_a_paragraphs: 纯 docx 段落样式提取
|
||||
2. extract_skeleton_llm: LLM 判断分段结构 → JSON 骨架
|
||||
3. validate_skeleton_coverage: 全覆盖硬校验
|
||||
4. 落盘 <episode_id>_a_skeleton.json + 打印人类可读预览表
|
||||
|
||||
跑完请人工核验骨架预览表(role_label 是否含真人姓名? ignore 是否漏/多?)
|
||||
确认无误后,再跑 doco compose 完成对齐。
|
||||
"""
|
||||
if output_dir is None:
|
||||
out_dir = Path("programs") / episode_id
|
||||
else:
|
||||
out_dir = Path(output_dir)
|
||||
|
||||
click.echo(f"[doco skeleton] episode_id={episode_id}")
|
||||
click.echo(f"[doco skeleton] a_script={a_script}")
|
||||
click.echo(f"[doco skeleton] output_dir={out_dir}")
|
||||
click.echo(f"[doco skeleton] max_tokens={max_tokens}")
|
||||
|
||||
try:
|
||||
result = run_skeleton(
|
||||
episode_id=episode_id,
|
||||
a_script_path=a_script,
|
||||
output_dir=str(out_dir),
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
click.echo(f"[ok] 段落数: {result['total_paras']} (含标题)")
|
||||
click.echo(f"[ok] 骨架段数: {result['skeleton_count']}")
|
||||
click.echo(f"[ok] 全覆盖校验: {'通过' if result['coverage_ok'] else '失败'}")
|
||||
click.echo(f"[ok] 骨架已保存: {result['skeleton_path']}")
|
||||
click.echo(f"[提示] 请人工确认骨架预览表后,再运行: doco compose --episode-id {episode_id}")
|
||||
except Exception as e:
|
||||
click.echo(f"[error] {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("asr")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||||
@click.option(
|
||||
"--input-video",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="输入视频文件路径",
|
||||
)
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
type=click.Path(),
|
||||
help="输出目录(默认 programs/<episode-id>/)",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-asr",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="只分离音频不调讯飞,用于先验证 WAV",
|
||||
)
|
||||
def asr(
|
||||
episode_id: str,
|
||||
input_video: str,
|
||||
output_dir: str,
|
||||
skip_asr: bool,
|
||||
):
|
||||
"""
|
||||
P3 C2: 讯飞 ASR 转写
|
||||
|
||||
流程:
|
||||
1. video_split.extract_audio() 分离 16kHz/单声道/16bit WAV
|
||||
2. get_hot_words() 读取本期热词表
|
||||
3. --skip-asr 时到此为止;否则调 transcribe() → write_asr_result()
|
||||
|
||||
产物:
|
||||
- audio_16k.wav(音频)
|
||||
- asr_v2_timed.txt(带时间戳的转写文本)
|
||||
- asr_result_raw.json(讯飞原始返回,断点续跑用)
|
||||
"""
|
||||
from .asr_adapter import get_audio_duration_ms as _wav_duration
|
||||
|
||||
video_path = Path(input_video)
|
||||
if output_dir is None:
|
||||
out_dir = Path("programs") / episode_id
|
||||
else:
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
click.echo(f"[doco asr] episode_id={episode_id}")
|
||||
click.echo(f"[doco asr] input_video={video_path}")
|
||||
click.echo(f"[doco asr] output_dir={out_dir}")
|
||||
click.echo(f"[doco asr] skip_asr={skip_asr}")
|
||||
|
||||
# ---- a. 音频分离 ----
|
||||
wav_path = out_dir / "audio_16k.wav"
|
||||
if wav_path.exists():
|
||||
click.echo(f"[doco asr] audio_16k.wav 已存在,复用: {wav_path}")
|
||||
else:
|
||||
click.echo("[doco asr] 从视频分离音频(16kHz/单声道/16bit)...")
|
||||
try:
|
||||
extract_audio(video_path, wav_path)
|
||||
click.echo(f"[doco asr] 音频分离完成: {wav_path}")
|
||||
except Exception as e:
|
||||
click.echo(f"[error] 音频分离失败: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# 打印 WAV 时长
|
||||
try:
|
||||
dur_ms = _wav_duration(str(wav_path))
|
||||
dur_sec = dur_ms / 1000.0
|
||||
fsize = wav_path.stat().st_size
|
||||
click.echo(f"[doco asr] audio_16k.wav 大小: {fsize / 1024 / 1024:.1f} MB, 时长: {dur_sec:.1f}s ({dur_ms} ms)")
|
||||
except Exception as e:
|
||||
click.echo(f"[doco asr] 无法读取 WAV 时长: {e}")
|
||||
|
||||
# ---- b. --skip-asr 时到此为止 ----
|
||||
if skip_asr:
|
||||
click.echo(f"[doco asr] --skip-asr 模式,到此为止。WAV: {wav_path}")
|
||||
return
|
||||
|
||||
# ---- c. 热词 ----
|
||||
hot_words = get_hot_words(episode_id)
|
||||
click.echo(f"[doco asr] 热词条数: {len(hot_words)}")
|
||||
|
||||
# ---- d. 转写 ----
|
||||
click.echo("[doco asr] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
|
||||
try:
|
||||
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
|
||||
except Exception as e:
|
||||
click.echo(f"[error] ASR 转写失败: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
timed_path, raw_path = write_asr_result(
|
||||
sentences,
|
||||
str(out_dir),
|
||||
raw_order_result=raw_order_result,
|
||||
)
|
||||
|
||||
# ---- e. 打印摘要 ----
|
||||
click.echo(f"[ok] 热词条数: {len(hot_words)}")
|
||||
click.echo(f"[ok] 句子数: {len(sentences)}")
|
||||
click.echo(f"[ok] asr_v2_timed.txt: {timed_path}")
|
||||
click.echo(f"[ok] asr_result_raw.json: {raw_path}")
|
||||
|
||||
|
||||
# 模板脚本目录(stage_a_extract_ocr.py / stage_b_dedup_output.py)
|
||||
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
||||
|
||||
|
||||
def _stage_header(title: str):
|
||||
"""打印阶段分隔线"""
|
||||
click.echo("═════════════════════════════")
|
||||
click.echo(title)
|
||||
click.echo("═════════════════════════════")
|
||||
|
||||
|
||||
@main.command("compose")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep001_20260612_fangkong_fandao")
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
type=click.Path(),
|
||||
help="输出目录(默认 programs/<episode-id>/)",
|
||||
)
|
||||
@click.option(
|
||||
"--no-ai",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="跳过 LLM 对齐,按时间均分到各段(仅验证管道)",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
default=40,
|
||||
type=int,
|
||||
help="每批送对齐行数(默认 40)",
|
||||
)
|
||||
def compose(
|
||||
episode_id: str,
|
||||
output_dir: str,
|
||||
no_ai: bool,
|
||||
batch_size: int,
|
||||
):
|
||||
"""
|
||||
P3 C4: 融合B稿 + A稿分段骨架 → 融合A稿.docx(公文格式)
|
||||
|
||||
AI 唯一职责: 给每行 B 句打段序号,正文一字不改、纯规则拼接。
|
||||
|
||||
产物:
|
||||
- 融合A稿.docx (GB/T 9704 公文格式)
|
||||
- c4_alignment.csv (分段对齐留痕)
|
||||
"""
|
||||
if output_dir is None:
|
||||
out_dir = Path("programs") / episode_id
|
||||
else:
|
||||
out_dir = Path(output_dir)
|
||||
|
||||
click.echo(f"[doco compose] episode_id={episode_id}")
|
||||
click.echo(f"[doco compose] output_dir={out_dir}")
|
||||
click.echo(f"[doco compose] no_ai={no_ai}")
|
||||
click.echo(f"[doco compose] batch_size={batch_size}")
|
||||
|
||||
try:
|
||||
stats = run_compose(
|
||||
episode_id=episode_id,
|
||||
output_dir=str(out_dir),
|
||||
no_ai=no_ai,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
click.echo(f"[ok] 总行数: {stats['total_lines']}")
|
||||
click.echo(f"[ok] 段数: {stats['segment_count']}")
|
||||
click.echo(f"[ok] 空段数: {stats['empty_segments']}")
|
||||
click.echo(f"[ok] 低把握段数: {stats['low_confidence_segments']}")
|
||||
click.echo(f"[ok] 单调修正行数: {stats['audit_forced_lines']}")
|
||||
click.echo(f"[ok] 融合A稿: {stats['docx_path']}")
|
||||
click.echo(f"[ok] 留痕 CSV: {stats['csv_path']}")
|
||||
except Exception as e:
|
||||
click.echo(f"[error] {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command("run")
|
||||
@click.option("--episode-id", required=True, help="节目 ID,如 ep002_20260127_qianting_fangsheng")
|
||||
@click.option(
|
||||
"--a-script",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="A 稿 docx 路径",
|
||||
)
|
||||
@click.option(
|
||||
"--input-video",
|
||||
required=True,
|
||||
type=click.Path(exists=True),
|
||||
help="输入视频 mp4 路径",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
default=25,
|
||||
type=int,
|
||||
help="C4 对齐用每批行数(默认 25)",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-p1",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="跳过 P1/P2(抽帧+OCR+去重),从 C1 续跑(已有 B稿v2 时)",
|
||||
)
|
||||
def run(
|
||||
episode_id: str,
|
||||
a_script: str,
|
||||
input_video: str,
|
||||
batch_size: int,
|
||||
skip_p1: bool,
|
||||
):
|
||||
"""
|
||||
一键全流程: P1→P2→C1→C2→C3→C4
|
||||
|
||||
串联抽帧+OCR(P1)、文本去重(P2)、术语提取(C1)、ASR 转写(C2)、
|
||||
融合复审(C3)、对齐出稿(C4) 六个阶段。
|
||||
|
||||
用 --skip-p1 可跳过 P1/P2,从 C1 续跑(适用于已有 B稿v2 的场景)。
|
||||
C4 开始前要求骨架文件已存在(需先手动跑 doco skeleton 并人工核验)。
|
||||
"""
|
||||
from .asr_adapter import get_audio_duration_ms as _wav_duration
|
||||
|
||||
episode_dir = Path("programs") / episode_id
|
||||
episode_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_path = Path(input_video)
|
||||
a_script_path = Path(a_script)
|
||||
|
||||
# 记录已完成阶段,用于失败时打印
|
||||
completed_stages: list = []
|
||||
|
||||
# ── 汇总数据 ──
|
||||
b_v2_lines = 0
|
||||
hotword_count = 0
|
||||
asr_sentence_count = 0
|
||||
fused_b_lines = 0
|
||||
fused_a_docx = ""
|
||||
|
||||
try:
|
||||
# ════════════════════════════════════════════════════════
|
||||
# P1: 抽帧 + OCR
|
||||
# ════════════════════════════════════════════════════════
|
||||
if not skip_p1:
|
||||
_stage_header("P1: 抽帧 + OCR")
|
||||
|
||||
stage_a_path = episode_dir / "stage_a_extract_ocr.py"
|
||||
if not stage_a_path.exists():
|
||||
src = TEMPLATES_DIR / "stage_a_extract_ocr.py"
|
||||
click.echo(f"[run] 复制模板: {src} → {stage_a_path}")
|
||||
shutil.copy2(str(src), str(stage_a_path))
|
||||
|
||||
click.echo(f"[run] 执行: {sys.executable} {stage_a_path}")
|
||||
click.echo(f"[run] 工作目录: {episode_dir}")
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(stage_a_path)],
|
||||
cwd=str(episode_dir),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"P1 stage_a_extract_ocr.py 退出码: {proc.returncode}")
|
||||
|
||||
completed_stages.append("P1: 抽帧 + OCR")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# P2: 文本去重
|
||||
# ════════════════════════════════════════════════════════
|
||||
if not skip_p1:
|
||||
_stage_header("P2: 文本去重")
|
||||
|
||||
stage_b_path = episode_dir / "stage_b_dedup_output.py"
|
||||
if not stage_b_path.exists():
|
||||
src = TEMPLATES_DIR / "stage_b_dedup_output.py"
|
||||
click.echo(f"[run] 复制模板: {src} → {stage_b_path}")
|
||||
shutil.copy2(str(src), str(stage_b_path))
|
||||
|
||||
click.echo(f"[run] 执行: {sys.executable} {stage_b_path}")
|
||||
click.echo(f"[run] 工作目录: {episode_dir}")
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(stage_b_path)],
|
||||
cwd=str(episode_dir),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"P2 stage_b_dedup_output.py 退出码: {proc.returncode}")
|
||||
|
||||
b_v2_path = episode_dir / "B稿_v2.txt"
|
||||
if not b_v2_path.exists():
|
||||
raise FileNotFoundError(f"P2 跑完但 B稿_v2.txt 不存在: {b_v2_path}")
|
||||
|
||||
completed_stages.append("P2: 文本去重")
|
||||
|
||||
# 读 B稿_v2 行数(无论是否 skip_p1,后续步骤都用得到)
|
||||
b_v2_path = episode_dir / "B稿_v2.txt"
|
||||
if b_v2_path.exists():
|
||||
with open(b_v2_path, "r", encoding="utf-8") as fh:
|
||||
b_v2_lines = sum(1 for line in fh if line.strip())
|
||||
elif not skip_p1:
|
||||
raise FileNotFoundError(f"B稿_v2.txt 不存在: {b_v2_path}")
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f"使用 --skip-p1 但 B稿_v2.txt 不存在: {b_v2_path}\n"
|
||||
"请先跑 P1+P2 或确认 B稿_v2.txt 已就绪。"
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C1: 术语提取
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C1: 术语提取")
|
||||
|
||||
c1_result = run_terms(
|
||||
episode_id=episode_id,
|
||||
a_script_path=a_script_path,
|
||||
no_ai=False,
|
||||
)
|
||||
hotword_count = c1_result.get("hotword_count", 0)
|
||||
click.echo(f"[run] C1 完成: 规则 {c1_result.get('rule_count', 0)} 条, "
|
||||
f"AI {c1_result.get('ai_count', 0)} 条, "
|
||||
f"热词 {hotword_count} 条")
|
||||
|
||||
completed_stages.append("C1: 术语提取")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C2: ASR
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C2: ASR 转写")
|
||||
|
||||
asr_timed_path = episode_dir / "asr_v2_timed.txt"
|
||||
wav_path = episode_dir / "audio_16k.wav"
|
||||
|
||||
# 分离音频(已存在则复用)
|
||||
if wav_path.exists():
|
||||
click.echo(f"[run] audio_16k.wav 已存在,复用: {wav_path}")
|
||||
else:
|
||||
click.echo("[run] 从视频分离音频(16kHz/单声道/16bit)...")
|
||||
extract_audio(video_path, wav_path)
|
||||
click.echo(f"[run] 音频分离完成: {wav_path}")
|
||||
|
||||
if asr_timed_path.exists():
|
||||
click.echo(f"[run] asr_v2_timed.txt 已存在,跳过 ASR(花钱的步骤不重复跑): {asr_timed_path}")
|
||||
else:
|
||||
hot_words = get_hot_words(episode_id)
|
||||
click.echo(f"[run] 热词条数: {len(hot_words)}")
|
||||
click.echo("[run] 上传音频 → 讯飞 ASR 转写(可能需要数分钟)...")
|
||||
|
||||
sentences, raw_order_result = transcribe(str(wav_path), hot_words=hot_words)
|
||||
asr_sentence_count = len(sentences)
|
||||
|
||||
timed_path, raw_path = write_asr_result(
|
||||
sentences,
|
||||
str(episode_dir),
|
||||
raw_order_result=raw_order_result,
|
||||
)
|
||||
click.echo(f"[run] ASR 完成: {asr_sentence_count} 句")
|
||||
|
||||
# 如果跳过了 ASR(已存在),读取句子数用于汇总
|
||||
if asr_sentence_count == 0 and asr_timed_path.exists():
|
||||
with open(asr_timed_path, "r", encoding="utf-8") as fh:
|
||||
asr_sentence_count = sum(1 for line in fh if line.strip())
|
||||
|
||||
completed_stages.append("C2: ASR")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C3: 融合复审
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C3: 融合复审")
|
||||
|
||||
c3_stats = run_fusion(
|
||||
episode_id=episode_id,
|
||||
output_dir=str(episode_dir),
|
||||
no_ai=False,
|
||||
batch_size=35,
|
||||
)
|
||||
fused_b_lines = c3_stats.get("total_lines", 0)
|
||||
click.echo(f"[run] C3 完成: 融合B稿 {fused_b_lines} 行")
|
||||
|
||||
completed_stages.append("C3: 融合复审")
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# C4: 对齐出稿
|
||||
# ════════════════════════════════════════════════════════
|
||||
_stage_header("C4: 对齐出稿")
|
||||
|
||||
# 检查骨架文件
|
||||
skeleton_path = episode_dir / f"{episode_id}_a_skeleton.json"
|
||||
if not skeleton_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"骨架文件不存在: {skeleton_path}\n"
|
||||
f"骨架需人工核验,请先手动运行: doco skeleton --episode-id {episode_id} "
|
||||
f"--a-script {a_script}\n"
|
||||
f"核验无误后,再运行 doco run。"
|
||||
)
|
||||
|
||||
c4_stats = run_compose(
|
||||
episode_id=episode_id,
|
||||
output_dir=str(episode_dir),
|
||||
no_ai=False,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
fused_a_docx = c4_stats.get("docx_path", "")
|
||||
click.echo(f"[run] C4 完成: 融合A稿 → {fused_a_docx}")
|
||||
|
||||
completed_stages.append("C4: 对齐出稿")
|
||||
|
||||
except Exception as e:
|
||||
click.echo("")
|
||||
click.echo("═════════════════════════════")
|
||||
click.echo("❌ 流程中断")
|
||||
click.echo("═════════════════════════════")
|
||||
if completed_stages:
|
||||
click.echo("已完成的阶段:")
|
||||
for s in completed_stages:
|
||||
click.echo(f" ✅ {s}")
|
||||
click.echo(f"失败阶段: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
# ── 全部完成 ──
|
||||
click.echo("")
|
||||
_stage_header("✅ 全流程完成")
|
||||
click.echo(f"B稿v2: {b_v2_lines} 行")
|
||||
click.echo(f"热词: {hotword_count} 条")
|
||||
click.echo(f"ASR: {asr_sentence_count} 句")
|
||||
click.echo(f"融合B稿: {fused_b_lines} 行")
|
||||
click.echo(f"融合A稿: {fused_a_docx}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,535 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
C3: B稿v2 ⊕ ASR 交叉复审 → 融合B稿(743行) + fusion_review.csv
|
||||
=============================================================
|
||||
职责:逐行复审 B稿(屏幕字幕OCR),以 ASR(口语转写)为上下文参考,
|
||||
只做纠错,严禁改行数/时间戳。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from .llm import chat
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 常量
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
CHANGE_TYPE_ENUM = frozenset(
|
||||
{
|
||||
"unchanged",
|
||||
"minor_edit",
|
||||
"term_normalize",
|
||||
"rewrite_large",
|
||||
"segment_delete",
|
||||
"segment_add",
|
||||
"editor_typo",
|
||||
}
|
||||
)
|
||||
|
||||
SYSTEM_PROMPT = """你是《军事科技》专题片文稿校审员。给你 B稿(屏幕字幕OCR,逐行碎句,带时间戳) 和对应的 ASR(口语转写)。
|
||||
你的任务:逐行复审 B稿,只做纠错,绝不合并行、不拆行、不增删行、不改时间戳。
|
||||
权威优先级:
|
||||
- 屏幕术语/型号/番号(箭-3/萨德/见证者-136等): B稿为准(屏幕实打的字)
|
||||
- B稿明显是OCR错字而ASR是对的: 用ASR覆盖
|
||||
- ⚠️ 专有名词铁律:厂名/型号/番号/国名/人名/机构名等专名,遇B稿与ASR同音异写(如斯泰尔vs斯太尔、美以vs美伊),一律以B稿/A稿书面写法为准,零容忍采ASR。ASR是口语转写,同音字极多,专名绝不信ASR。
|
||||
- 同音事实错(如"美以"vs"美伊"): 以书面规范为准,存疑进review
|
||||
- 一两个字的等价差异(的/地、啊等语气): 算 unchanged,不要改
|
||||
每行输出: line_no, final_text(纠错后,默认等于B原文), change_type(7选1), confidence(0~1), reason(简短,unchanged时留空)
|
||||
只返回JSON数组,不要任何解释文字。
|
||||
change_type枚举: unchanged/minor_edit/term_normalize/rewrite_large/segment_delete/segment_add/editor_typo"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. 解析带时间戳的行
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def parse_timed_lines(path) -> List[dict]:
|
||||
r"""
|
||||
解析 "[XmYs] 文本" → [{"idx":int, "ts_raw":"0m8s", "ts_sec":8, "text":"导弹呼啸而过"}]
|
||||
正则: ^\[(\d+)m(\d+)s\]\s*(.*)$ ; ts_sec = m*60+s
|
||||
解析失败的行要抛异常并打印行号,不许静默跳过
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {path}")
|
||||
|
||||
pattern = re.compile(r"^\[(\d+)m(\d+)s\]\s*(.*)$")
|
||||
lines_raw = p.read_text(encoding="utf-8").splitlines()
|
||||
result = []
|
||||
|
||||
for idx, line in enumerate(lines_raw, start=1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue # 跳过空行
|
||||
m = pattern.match(line)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
f"行 {idx} 解析失败,无法匹配时间戳格式: {repr(line[:120])}\n"
|
||||
f"文件: {path}"
|
||||
)
|
||||
minutes = int(m.group(1))
|
||||
seconds = int(m.group(2))
|
||||
ts_raw = f"{minutes}m{seconds}s"
|
||||
ts_sec = minutes * 60 + seconds
|
||||
text = m.group(3).strip()
|
||||
result.append(
|
||||
{
|
||||
"idx": idx,
|
||||
"ts_raw": ts_raw,
|
||||
"ts_sec": ts_sec,
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. 对齐 ASR 上下文
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def align_asr_context(b_lines: List[dict], asr_lines: List[dict]) -> List[str]:
|
||||
"""
|
||||
为每个 B 行找时间窗内的 ASR 上下文(用于喂 LLM)
|
||||
规则: 取 ts_sec 落在 [b_ts-3, b_next_ts+3] 区间的 ASR 句拼接;
|
||||
边界用前后各扩 1 句兜底。返回与 b_lines 等长的 context 列表
|
||||
"""
|
||||
n = len(b_lines)
|
||||
contexts = []
|
||||
|
||||
# 预计算 B 行的时间窗: [b[i].ts_sec - 3, b[i+1].ts_sec + 3]
|
||||
# 最后一行用 b[i].ts_sec + 10 作为上界
|
||||
windows = []
|
||||
for i, bl in enumerate(b_lines):
|
||||
lo = bl["ts_sec"] - 3
|
||||
if i + 1 < n:
|
||||
hi = b_lines[i + 1]["ts_sec"] + 3
|
||||
else:
|
||||
hi = bl["ts_sec"] + 10
|
||||
windows.append((lo, hi))
|
||||
|
||||
asr_count = len(asr_lines)
|
||||
|
||||
for i, (lo, hi) in enumerate(windows):
|
||||
# 找到落在窗口内的 ASR 句索引
|
||||
hit_indices = []
|
||||
for j, al in enumerate(asr_lines):
|
||||
if lo <= al["ts_sec"] <= hi:
|
||||
hit_indices.append(j)
|
||||
|
||||
if not hit_indices:
|
||||
# 无命中:取距离最近的 1 句
|
||||
best_j = None
|
||||
best_dist = float("inf")
|
||||
mid_ts = (lo + hi) / 2
|
||||
for j, al in enumerate(asr_lines):
|
||||
dist = abs(al["ts_sec"] - mid_ts)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_j = j
|
||||
if best_j is not None:
|
||||
start_j = max(0, best_j - 1)
|
||||
end_j = min(asr_count - 1, best_j + 1)
|
||||
else:
|
||||
start_j = 0
|
||||
end_j = 0
|
||||
else:
|
||||
# 命中句的范围 + 前后各扩 1
|
||||
start_j = max(0, hit_indices[0] - 1)
|
||||
end_j = min(asr_count - 1, hit_indices[-1] + 1)
|
||||
|
||||
# 拼接 [start_j, end_j] 的 ASR 文本
|
||||
selected = asr_lines[start_j : end_j + 1]
|
||||
context = " ".join(s["text"] for s in selected)
|
||||
contexts.append(context)
|
||||
|
||||
assert len(contexts) == n, f"context 列表长度 {len(contexts)} != B 稿行数 {n}"
|
||||
return contexts
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. 构造 Prompt
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_prompt(batch_b: List[dict], batch_ctx: List[str]) -> List[dict]:
|
||||
"""
|
||||
构造 messages,见下方"四、Prompt 模板"
|
||||
"""
|
||||
assert len(batch_b) == len(batch_ctx), (
|
||||
f"batch_b({len(batch_b)}) 与 batch_ctx({len(batch_ctx)}) 长度不一致"
|
||||
)
|
||||
|
||||
user_lines = []
|
||||
for bl, ctx in zip(batch_b, batch_ctx):
|
||||
line_no = bl["idx"]
|
||||
b_text = bl["text"]
|
||||
asr_text = ctx if ctx else "(无ASR上下文)"
|
||||
user_lines.append(
|
||||
f"[行{line_no}] B稿: \"{b_text}\" ASR上下文: \"{asr_text}\""
|
||||
)
|
||||
|
||||
user_content = "\n".join(user_lines)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
return messages
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. 单批复审
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def review_batch(
|
||||
batch_b: List[dict], batch_ctx: List[str], no_ai: bool = False
|
||||
) -> List[dict]:
|
||||
"""
|
||||
no_ai=True: 直接回填 unchanged(final_text=b原文, change_type="unchanged", confidence=1.0)
|
||||
no_ai=False: 调 llm.chat(messages, thinking=False, max_tokens=4000, temperature=0.0)
|
||||
解析返回 JSON 数组; 每元素 {line_no, final_text, change_type, confidence, reason}
|
||||
返回标准化记录列表
|
||||
"""
|
||||
if no_ai:
|
||||
records = []
|
||||
for bl in batch_b:
|
||||
records.append(
|
||||
{
|
||||
"line_no": bl["idx"],
|
||||
"final_text": bl["text"],
|
||||
"change_type": "unchanged",
|
||||
"confidence": 1.0,
|
||||
"reason": "",
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
# ---- AI 路径 ----
|
||||
messages = build_prompt(batch_b, batch_ctx)
|
||||
|
||||
try:
|
||||
raw_response = chat(
|
||||
messages,
|
||||
thinking=False,
|
||||
max_tokens=4000,
|
||||
temperature=0.0,
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[fusion_review] LLM 调用失败,回退为 unchanged 批次: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# 回退:全部 unchanged
|
||||
records = []
|
||||
for bl in batch_b:
|
||||
records.append(
|
||||
{
|
||||
"line_no": bl["idx"],
|
||||
"final_text": bl["text"],
|
||||
"change_type": "unchanged",
|
||||
"confidence": 1.0,
|
||||
"reason": f"LLM调用失败回退: {str(e)[:80]}",
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
||||
# 解析 JSON
|
||||
parsed = _parse_llm_json_response(raw_response, len(batch_b))
|
||||
|
||||
# 标准化并校验
|
||||
records = []
|
||||
for item in parsed:
|
||||
line_no = item.get("line_no")
|
||||
final_text = item.get("final_text", "")
|
||||
change_type = item.get("change_type", "unchanged")
|
||||
confidence = item.get("confidence", 1.0)
|
||||
reason = item.get("reason", "")
|
||||
|
||||
# 校验 change_type
|
||||
if change_type not in CHANGE_TYPE_ENUM:
|
||||
original_ct = change_type
|
||||
print(
|
||||
f"[fusion_review] 行 {line_no} 非法 change_type='{original_ct}', 强制改为 unchanged",
|
||||
file=sys.stderr,
|
||||
)
|
||||
change_type = "unchanged"
|
||||
final_text = "" # 下面会回填
|
||||
reason = f"LLM返回非法change_type({original_ct}),回退unchanged"
|
||||
|
||||
# 如果 change_type 被改为 unchanged 但 final_text 为空,回填 B 原文
|
||||
if change_type == "unchanged" and not final_text:
|
||||
# 从 batch_b 找回原文
|
||||
for bl in batch_b:
|
||||
if bl["idx"] == line_no:
|
||||
final_text = bl["text"]
|
||||
break
|
||||
|
||||
records.append(
|
||||
{
|
||||
"line_no": line_no,
|
||||
"final_text": final_text,
|
||||
"change_type": change_type,
|
||||
"confidence": float(confidence),
|
||||
"reason": reason or "",
|
||||
}
|
||||
)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
def _parse_llm_json_response(raw: str, expected_len: int) -> List[dict]:
|
||||
"""解析 LLM 返回的 JSON,处理 markdown code fences 等常见包装。"""
|
||||
text = raw.strip()
|
||||
|
||||
# 去掉可能的 markdown code fences
|
||||
if text.startswith("```"):
|
||||
lines = text.splitlines()
|
||||
# 去掉第一行 ```json 或 ```
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# 去掉最后一行 ```
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
try:
|
||||
result = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(
|
||||
f"LLM 返回 JSON 解析失败: {e}\n"
|
||||
f"原始响应前 500 字符: {raw[:500]}"
|
||||
)
|
||||
|
||||
if not isinstance(result, list):
|
||||
raise ValueError(
|
||||
f"LLM 返回不是 JSON 数组,类型为 {type(result).__name__}"
|
||||
)
|
||||
|
||||
if len(result) != expected_len:
|
||||
raise ValueError(
|
||||
f"LLM 返回 {len(result)} 条记录,期望 {expected_len} 条。"
|
||||
f"该批次将回退为 unchanged 并重新请求。"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. 主流程
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def run_fusion(
|
||||
episode_id: str,
|
||||
output_dir: str,
|
||||
no_ai: bool = False,
|
||||
batch_size: int = 35,
|
||||
) -> dict:
|
||||
"""
|
||||
主流程:
|
||||
1. 读 output_dir/B稿_v2.txt → b_lines(断言行数>0)
|
||||
2. 读 output_dir/asr_v2_timed.txt → asr_lines
|
||||
3. align_asr_context 生成等长 context
|
||||
4. 按 batch_size 分块;每块结果落缓存,已存在则复用(断点续跑)
|
||||
5. 逐块 review_batch,汇总所有记录
|
||||
6. 硬校验(任一不过就 raise,不写出文件)
|
||||
7. 写 output_dir/融合B稿.txt
|
||||
8. 写 output_dir/fusion_review.csv
|
||||
9. 返回统计 dict
|
||||
"""
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
b_path = out_dir / "B稿_v2.txt"
|
||||
asr_path = out_dir / "asr_v2_timed.txt"
|
||||
|
||||
if not b_path.exists():
|
||||
raise FileNotFoundError(f"B稿_v2.txt 不存在: {b_path}")
|
||||
if not asr_path.exists():
|
||||
raise FileNotFoundError(f"asr_v2_timed.txt 不存在: {asr_path}")
|
||||
|
||||
# Step 1: 解析 B 稿
|
||||
b_lines = parse_timed_lines(b_path)
|
||||
assert len(b_lines) > 0, f"B稿_v2.txt 解析后为空: {b_path}"
|
||||
|
||||
# Step 2: 解析 ASR
|
||||
asr_lines = parse_timed_lines(asr_path)
|
||||
|
||||
# Step 3: 对齐 ASR 上下文
|
||||
contexts = align_asr_context(b_lines, asr_lines)
|
||||
assert len(contexts) == len(b_lines), (
|
||||
f"context 长度 {len(contexts)} != B 稿行数 {len(b_lines)}"
|
||||
)
|
||||
|
||||
# Step 4: 分块 + 缓存
|
||||
cache_dir = out_dir / ".c3_cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
all_records = []
|
||||
total_batches = (len(b_lines) + batch_size - 1) // batch_size
|
||||
|
||||
for batch_idx in range(total_batches):
|
||||
start = batch_idx * batch_size
|
||||
end = min(start + batch_size, len(b_lines))
|
||||
batch_b = b_lines[start:end]
|
||||
batch_ctx = contexts[start:end]
|
||||
|
||||
cache_path = cache_dir / f"batch_{batch_idx}.json"
|
||||
|
||||
if cache_path.exists():
|
||||
# 断点续跑:复用缓存
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
print(f"[fusion_review] 复用缓存 batch_{batch_idx} ({len(cached)} 条)")
|
||||
all_records.extend(cached)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[fusion_review] 缓存 batch_{batch_idx} 损坏,重新计算: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(
|
||||
f"[fusion_review] 复审 batch {batch_idx + 1}/{total_batches} "
|
||||
f"(行 {start + 1}-{end})..."
|
||||
)
|
||||
try:
|
||||
batch_records = review_batch(batch_b, batch_ctx, no_ai=no_ai)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[fusion_review] batch {batch_idx + 1} 失败,跳过缓存写入: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# 不写缓存,下次重跑时重新请求该批
|
||||
continue
|
||||
|
||||
# 写入缓存
|
||||
cache_path.write_text(
|
||||
json.dumps(batch_records, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
all_records.extend(batch_records)
|
||||
|
||||
# Step 6: 硬校验
|
||||
_hard_validate(all_records, b_lines)
|
||||
|
||||
# Step 6.5: 修正语义——final_text 等于 B 原文的行强制归为 unchanged
|
||||
_normalize_unchanged_when_no_edit(all_records, b_lines)
|
||||
|
||||
# Step 7: 写 融合B稿.txt
|
||||
fused_path = out_dir / "融合B稿.txt"
|
||||
fused_lines = []
|
||||
for rec, bl in zip(all_records, b_lines):
|
||||
fused_lines.append(f"[{bl['ts_raw']}] {rec['final_text']}")
|
||||
fused_path.write_text("\n".join(fused_lines) + "\n", encoding="utf-8")
|
||||
|
||||
# Step 8: 写 fusion_review.csv
|
||||
csv_path = out_dir / "fusion_review.csv"
|
||||
csv_rows = [
|
||||
"line_no,timestamp,b_original,asr_context,final_text,change_type,confidence,reason"
|
||||
]
|
||||
for rec, bl, ctx in zip(all_records, b_lines, contexts):
|
||||
if rec["change_type"] == "unchanged" and rec["confidence"] >= 0.8:
|
||||
continue # 只写需要 review 的行
|
||||
# CSV 转义: 字段含逗号或引号时用双引号包裹
|
||||
row_fields = [
|
||||
str(rec["line_no"]),
|
||||
bl["ts_raw"],
|
||||
bl["text"],
|
||||
ctx,
|
||||
rec["final_text"],
|
||||
rec["change_type"],
|
||||
str(rec["confidence"]),
|
||||
rec["reason"],
|
||||
]
|
||||
csv_rows.append(_csv_row(row_fields))
|
||||
csv_path.write_text("\n".join(csv_rows) + "\n", encoding="utf-8")
|
||||
|
||||
# Step 9: 统计
|
||||
stats = {
|
||||
"total_lines": len(b_lines),
|
||||
"change_counts": {},
|
||||
"review_lines": 0,
|
||||
}
|
||||
for rec in all_records:
|
||||
ct = rec["change_type"]
|
||||
stats["change_counts"][ct] = stats["change_counts"].get(ct, 0) + 1
|
||||
if ct != "unchanged" or rec["confidence"] < 0.8:
|
||||
stats["review_lines"] += 1
|
||||
|
||||
print(f"[fusion_review] 融合完成: 总行数={stats['total_lines']}")
|
||||
print(f"[fusion_review] 各 change_type 计数: {stats['change_counts']}")
|
||||
print(f"[fusion_review] 进 review 行数: {stats['review_lines']}")
|
||||
print(f"[fusion_review] 融合B稿: {fused_path}")
|
||||
print(f"[fusion_review] review CSV: {csv_path}")
|
||||
|
||||
stats["fused_path"] = str(fused_path)
|
||||
stats["csv_path"] = str(csv_path)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def _hard_validate(records: List[dict], b_lines: List[dict]) -> None:
|
||||
"""硬校验,任一不过就 raise ValueError,不写出文件。"""
|
||||
# 校验 1: 行数必须相等
|
||||
if len(records) != len(b_lines):
|
||||
raise ValueError(
|
||||
f"行数不一致: records={len(records)}, B稿={len(b_lines)}"
|
||||
)
|
||||
|
||||
# 校验 2: 逐行时间戳不能被动
|
||||
for i, (rec, bl) in enumerate(zip(records, b_lines)):
|
||||
rec_line_no = rec.get("line_no")
|
||||
expected_line_no = bl["idx"]
|
||||
if rec_line_no != expected_line_no:
|
||||
raise ValueError(
|
||||
f"第 {i} 条记录 line_no={rec_line_no}, 期望 {expected_line_no}"
|
||||
)
|
||||
|
||||
# 校验 3: change_type 枚举
|
||||
for rec in records:
|
||||
ct = rec.get("change_type", "")
|
||||
if ct not in CHANGE_TYPE_ENUM:
|
||||
raise ValueError(
|
||||
f"行 {rec.get('line_no')}: 非法 change_type='{ct}'"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_unchanged_when_no_edit(
|
||||
records: List[dict], b_lines: List[dict]
|
||||
) -> None:
|
||||
"""修正语义:final_text 等于 B 原文的行,强制归为 unchanged。
|
||||
|
||||
LLM 有时会把"考虑后决定保留 B 稿"标成 minor_edit,
|
||||
但实际 final_text == b_original, 这不在留痕范围内。
|
||||
"""
|
||||
b_text_by_idx = {bl["idx"]: bl["text"] for bl in b_lines}
|
||||
fixed = 0
|
||||
for rec in records:
|
||||
line_no = rec.get("line_no")
|
||||
b_orig = b_text_by_idx.get(line_no)
|
||||
if b_orig is not None and rec.get("final_text") == b_orig:
|
||||
if rec.get("change_type") != "unchanged":
|
||||
rec["change_type"] = "unchanged"
|
||||
rec["confidence"] = 1.0
|
||||
rec["reason"] = ""
|
||||
fixed += 1
|
||||
if fixed:
|
||||
print(
|
||||
f"[fusion_review] 修正 {fixed} 行: final_text==B原文但change_type≠unchanged, 已强制归为 unchanged"
|
||||
)
|
||||
|
||||
|
||||
def _csv_row(fields: List[str]) -> str:
|
||||
"""将字段列表格式化为 CSV 行,处理逗号和引号转义。"""
|
||||
escaped = []
|
||||
for f in fields:
|
||||
s = str(f)
|
||||
if "," in s or '"' in s or "\n" in s:
|
||||
s = s.replace('"', '""')
|
||||
s = f'"{s}"'
|
||||
escaped.append(s)
|
||||
return ",".join(escaped)
|
||||
Reference in New Issue
Block a user