docs: ground punctuation tutorial in observed CPU output

Signed-off-by: zhifu gao <[email protected]>
This commit is contained in:
zhifu gao
2026-09-10 05:44:35 +00:00
parent 83a3e3dc9c
commit f98502d8a4
6 changed files with 254 additions and 247 deletions
@@ -35,7 +35,7 @@
"blog/japanese-speech-recognition.html": "399f5ce84e68ac00854bdf70b52b1cde6795efca7c800fd6492035a37a7c1b68",
"blog/lightweight-speech-recognition-cpu.html": "d6270066222ed5baef0df108e6a4155f4a87bb5482169f23f218794d371f3281",
"blog/meeting-transcript-acceptance.html": "8173b3049ca707bdac46013748bc0ca7d9576ebf6f7ff6db6c940a833b12e4a9",
"blog/punctuation-restoration-python.html": "6cd26e03bf75b4343afd7b351c2681ebd513b058fb549c5888802be9ff3a1229",
"blog/punctuation-restoration-python.html": "063c5fe584bff780edc50f95214decbb3432326d4983334fcd8b38aabeb16128",
"blog/self-hosted-deepgram-assemblyai-alternative.html": "feb17aae8e0f3fe46914c8725eacc8fcdf7fe78bf22a291347d6107c24e3992e",
"blog/self-hosted-google-aws-azure-speech-to-text.html": "8e4000fb9e2b33a48a3907d4bd0f55db57ba74121a89b5fa12b412c3c963f49a",
"blog/self-hosted-openai-whisper-api-alternative.html": "254184cd753f30e308329c301ae47e8f5f5dfb57c01d0abf3ce341034f2990f9",
@@ -77,7 +77,7 @@
"en/blog/japanese-speech-recognition.html": "c476adcc2be1ed7c19e2345cc91b8ee6a0e04efd794b9b6476dd5d9b3a2c8b04",
"en/blog/lightweight-speech-recognition-cpu.html": "0e02c0e0853b12613d32c250f593c05c2052f18231207b63ee01d6e6600a86f7",
"en/blog/meeting-transcript-acceptance.html": "ffc6eb2329267515bb367f21bb2d1c0523ff90d3f02b2e734ae41f78b02d0515",
"en/blog/punctuation-restoration-python.html": "d6e30049f48d9dc6e6bc22eb567830013e4161eca51215616738b675dd1ffc40",
"en/blog/punctuation-restoration-python.html": "5853658aa8e936eda988fa087cba4a297de8044f979f6a9deef49206b8967a79",
"en/blog/self-hosted-deepgram-assemblyai-alternative.html": "4239a93246726c6f010a2fb20208211e050792a372e3ea356d1a93ac028a8a96",
"en/blog/self-hosted-google-aws-azure-speech-to-text.html": "4a8453451722b3ec8735224a586744ddfbfec9458cedd433f9fc908351027e9a",
"en/blog/self-hosted-openai-whisper-api-alternative.html": "14bdb877adfd113eeb04b215ffa015c29d8323859eeddd00d9e03d166dfa0473",
+5 -5
View File
@@ -337,14 +337,14 @@
{
"slug": "punctuation-restoration-python",
"category": "explanations",
"reviewed": false,
"reviewed": true,
"zh": {
"title": "怎样给转写结果补标点",
"summary": ""
"title": "文字都在,标点为什么还是断错?",
"summary": "保留原始转写,生成标点候选,再检查归句和专名,不把句号当作语义修复。"
},
"en": {
"title": "Restoring punctuation in a transcript",
"summary": ""
"title": "The words are right. Why is the punctuation wrong?",
"summary": "Keep the original, generate a candidate, then review sentence meaning and names."
}
},
{
@@ -3,134 +3,68 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>标点恢复(Punctuation Restoration)Python 实战:给无标点文本/ASR 结果自动加标点 | FunASR 博客</title>
<meta name="description" content="怎么给无标点的文本或语音识别结果自动加标点?FunASR ct-punc 是开源标点恢复模型,中英文双语,3 行 Python 补全 ,。?,英文还做句首大写;也能一行挂到 ASR 上。附真实实测输出。">
<meta name="keywords" content="标点恢复, 文本加标点, 标点预测, 标点符号恢复, punctuation restoration, add punctuation to text python, ct-punc, ASR 后处理, FunASR, 语音识别标点">
<title>文字都在,标点为什么还是断错? | FunASR</title>
<meta name="description" content="保留原始转写,生成标点候选,再检查归句和专名。">
<link rel="icon" href="/logo.png">
<meta property="og:title" content="标点恢复(Punctuation Restoration)Python 实战:给无标点文本/ASR 结果自动加标点 | FunASR 博客">
<meta property="og:description" content="怎么给无标点的文本或语音识别结果自动加标点?FunASR ct-punc 是开源标点恢复模型,中英文双语,3 行 Python 补全 ,。?,英文还做句首大写;也能一行挂到 ASR 上。附真实实测输出。">
<meta property="og:type" content="article">
<meta property="og:url" content="https://www.funasr.com/blog/punctuation-restoration-python.html">
<meta property="og:image" content="https://www.funasr.com/logo.png">
<link rel="canonical" href="https://www.funasr.com/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="zh" href="https://www.funasr.com/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="x-default" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"BlogPosting","headline":"标点恢复(Punctuation Restoration)Python 实战:给无标点文本/ASR 结果自动加标点 | FunASR 博客","description":"怎么给无标点的文本或语音识别结果自动加标点?FunASR ct-punc 是开源标点恢复模型,中英文双语,3 行 Python 补全 ,。?,英文还做句首大写;也能一行挂到 ASR 上。附真实实测输出。","datePublished":"2026-06-23","dateModified":"2026-06-23","author":{"@type":"Organization","name":"FunASR / Tongyi Lab"},"publisher":{"@type":"Organization","name":"FunASR","logo":{"@type":"ImageObject","url":"https://www.funasr.com/logo.png"}},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/blog/punctuation-restoration-python.html"},"image":"https://www.funasr.com/logo.png"}
</script>
<style>:root{--primary:#2563eb;--primary-dark:#1d4ed8;--bg:#fff;--surface:#f8fafc;--border:#e2e8f0;--text:#0f172a;--text-soft:#475569;--text-muted:#94a3b8;--radius:12px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,sans-serif;color:var(--text);line-height:1.7}
a{color:var(--primary);text-decoration:none}a:hover{text-decoration:underline}
.container{max-width:800px;margin:0 auto;padding:0 24px}
.nav{position:fixed;top:0;left:0;right:0;z-index:100;background:rgba(255,255,255,0.9);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 0}
.nav .container{max-width:1120px;display:flex;align-items:center;gap:24px}
.nav-logo{font-size:1.2rem;font-weight:800;color:var(--text)}.nav-logo span{color:var(--primary)}
.nav-links{display:flex;gap:20px;margin-left:auto;flex-wrap:wrap}
.nav-links a{font-size:0.85rem;font-weight:500;color:var(--text-soft)}
.nav-btn{padding:7px 16px;background:var(--primary);color:#fff;border-radius:7px;font-size:0.83rem;font-weight:600}
article{padding:120px 0 80px}
h1{font-size:2rem;font-weight:800;line-height:1.3;margin-bottom:14px}
h2{font-size:1.3rem;font-weight:700;margin:36px 0 14px;padding-top:16px;border-top:1px solid var(--border)}
h3{font-size:1.05rem;font-weight:700;margin:22px 0 10px}
p{margin-bottom:14px;color:var(--text-soft)}
ul,ol{margin:0 0 14px 22px;color:var(--text-soft)}li{margin-bottom:6px}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:0.86rem}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border)}
th{font-weight:600;background:var(--surface);font-size:0.78rem;color:var(--text-muted)}
pre{background:#1e293b;color:#e2e8f0;padding:18px;border-radius:10px;overflow-x:auto;font-size:0.82rem;line-height:1.7;margin:14px 0}
code{font-family:ui-monospace,Menlo,monospace}
p code,li code{background:var(--surface);padding:2px 6px;border-radius:5px;font-size:0.85em;color:var(--primary-dark)}
.post-meta{color:var(--text-muted);font-size:0.85rem;margin-bottom:24px}
.cta{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin:28px 0;text-align:center}
.cta a.btn{display:inline-block;padding:10px 24px;background:var(--primary);color:#fff;border-radius:8px;font-weight:600}
.post-list{list-style:none;margin:0;padding:0}
.post-list li{margin:0 0 8px}
.post-card{display:block;border:1px solid var(--border);border-radius:var(--radius);padding:20px 22px;margin-bottom:14px}
.post-card:hover{border-color:var(--primary);text-decoration:none}
.post-card h2{border:none;padding:0;margin:0 0 6px;font-size:1.15rem;color:var(--text)}
.post-card p{margin:0;color:var(--text-soft);font-size:0.9rem}
.post-card .date{color:var(--text-muted);font-size:0.8rem}
footer{background:#0f172a;color:#94a3b8;padding:28px 0;text-align:center;font-size:0.8rem}
footer a{color:#94a3b8}</style>
<meta property="og:type" content="article">
<meta property="og:title" content="文字都在,标点为什么还是断错?">
<meta property="og:description" content="保留原始转写,生成标点候选,再检查归句和专名。">
<meta property="og:url" content="https://www.funasr.com/blog/punctuation-restoration-python.html">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"文字都在,标点为什么还是断错?","description":"保留原始转写,生成标点候选,再检查归句和专名。","datePublished":"2026-06-23","dateModified":"2026-09-10","author":{"@type":"Organization","name":"FunASR"},"publisher":{"@type":"Organization","name":"FunASR"},"mainEntityOfPage":"https://www.funasr.com/blog/punctuation-restoration-python.html"}</script>
<style>body{margin:0;font-family:system-ui,sans-serif;color:#202827;line-height:1.8;background:#fff}*{box-sizing:border-box}.container{max-width:920px;margin:auto;padding:0 24px}a{color:#006a57;text-underline-offset:.15em}.nav{padding:14px 0;border-bottom:1px solid #dce3df}.nav .container{display:flex;gap:20px;align-items:center}.nav-logo{font-weight:700}.nav-links{margin-left:auto}article{padding:48px 0 64px}h1{font-size:2rem;line-height:1.3;letter-spacing:0}h2{font-size:1.4rem;margin-top:36px;letter-spacing:0}.lead{font-size:1.1rem}.meta{color:#515c56;font-size:.875rem}code{overflow-wrap:anywhere}pre{padding:16px;background:#f3f6f5;border:1px solid #dce3df;border-radius:6px;font-size:.875rem;white-space:pre-wrap;overflow-wrap:anywhere}.legacy-page pre[data-example]{white-space:pre-wrap;overflow-wrap:anywhere;padding-top:54px}blockquote{margin:24px 0;padding:8px 20px;border-left:3px solid #bd4770;background:#fcf6f8}details{margin:24px 0}summary{cursor:pointer;font-weight:600}footer{padding:24px;border-top:1px solid #dce3df}@media(max-width:700px){.container{padding:0 20px}h1{font-size:1.6rem}.nav-links{display:none}}</style>
</head>
<body>
<nav class="nav"><div class="container"><a href="/" class="nav-logo">Fun<span>ASR</span></a><div class="nav-links"><a href="/#capabilities">核心能力</a><a href="/#usage">如何使用</a><a href="/#benchmark">性能</a><a href="/#demo">演示</a><a href="https://modelscope.github.io/FunASR/zh/" target="_blank">文档</a><a href="/quickstart.html">快速上手</a><a href="/models.html">模型</a><a href="/ecosystem.html">生态</a><a href="/blog/">技术博客</a><a href="/vs-whisper.html">对比 Whisper</a><a href="/donors.html">功德榜</a></div><a href="https://www.funasr.com/en/blog/punctuation-restoration-python.html" style="font-size:0.83rem;font-weight:500;color:var(--text-soft)">EN</a><a href="https://github.com/modelscope/FunASR" target="_blank" class="nav-btn">GitHub ★</a></div></nav>
<nav class="nav"><div class="container"><a class="nav-logo" href="/">FunASR</a><a class="nav-links" href="/blog/">技术博客</a><a href="/en/blog/punctuation-restoration-python.html">English</a></div></nav>
<article><div class="container">
<h1>标点恢复(Punctuation Restoration)Python 实战:给无标点文本/ASR 结果自动加标点</h1>
<p class="post-meta">2026-06-23 · FunASR 团队</p>
<p>很多语音识别(ASR)模型输出的是<strong>没有标点</strong>的纯文本——一长串字读起来很费劲。<strong>标点恢复</strong>就是把"。"",""?"这些标点自动补回去,让转写结果可读。<strong>FunASR 的 <code>ct-punc</code></strong> 是一个开源的标点恢复模型,<strong>中英文都支持</strong>,3 行 Python 就能给任意文本加标点。下面全是真实实测输出。</p>
<h2>3 行代码做标点恢复(实测输出)</h2>
<pre>pip install funasr
<h1>文字都在,标点为什么还是断错?</h1>
<p class="meta">2026-06-23 · 更新 2026-09-10 · FunASR</p>
<p class="lead" data-editorial="opening">转写没有漏字,读起来却像另一句话,问题可能出在标点归句。先别挪字幕时间,也别重跑整段音频:留下原始文本,单独生成标点候选,再比较意思有没有被改变。</p>
<h2>1. 留一份原文,再生成候选</h2>
<p data-editorial="environment">先按<a data-installation-guide href="/docs/installation.html">安装与环境验证指南</a>准备 CPU 环境,确认 PyTorch 与 torchaudio 配套且可导入,安装 <code>funasr==1.4.15</code> 并运行 <code>python -m pip check</code>。这里用独立的 <code>ct-punc</code> 文本模型,不需要音频或 VAD;首次运行仍需下载权重。</p>
<pre data-example="punctuate">import json
from funasr import AutoModel
model = AutoModel(model="ct-punc", disable_update=True)
print(model.generate(input="我们都是木头人不许说话不许动")[0]["text"])
# 我们都是木头人,不许说话,不许动。</pre>
<p>输入是没有标点的连续文本,输出自动补上了逗号和句号。</p>
<h2>中英文真实示例</h2>
<table>
<tr><th>输入(无标点)</th><th>输出(ct-punc 加标点)</th></tr>
<tr><td>我们都是木头人不许说话不许动</td><td>我们都是木头人,不许说话,不许动。</td></tr>
<tr><td>今天天气怎么样我想出去走走你要一起吗</td><td>今天天气怎么样,我想出去走走,你要一起<strong>吗?</strong></td></tr>
<tr><td>the meeting is at 3 pm please bring your laptop and the report</td><td><strong>T</strong>he meeting is at 3 pm<strong>,</strong> please bring your laptop and the report<strong>.</strong></td></tr>
</table>
<p>可以看到:中文补「,。?」(问句还会自动判断用<strong>问号</strong>);英文补逗号、句号,还会把<strong>句首字母大写</strong>。同一个模型双语通吃。</p>
<h2>最常见用法:给 ASR 结果加标点</h2>
<p>标点恢复最大的用处是清洗语音识别的输出。FunASR 的 ASR 模型可以<strong>一行挂载</strong> ct-punc,识别完直接带标点:</p>
<pre>from funasr import AutoModel
# ASR + VAD + 标点,一次完成(以中文 Paraformer 为例)
model = AutoModel(model="paraformer-zh", vad_model="fsmn-vad", punc_model="ct-punc")
result = model.generate(input="audio.wav")
print(result[0]["text"]) # 输出已带标点</pre>
<p>当然你也可以像最上面那样<strong>单独</strong>用 ct-punc 处理任意文本——不限于语音,任何无标点文本(老字幕、OCR 结果、聊天记录)都能加标点。</p>
<h2>为什么用 ct-punc</h2>
<table>
<tr><th></th><th>ct-punc(FunASR)</th></tr>
<tr><td>语言</td><td>中文 + 英文(单模型双语)</td></tr>
<tr><td>用法</td><td>3 行 Python,输入文本即可;也能一行挂到 ASR 上</td></tr>
<tr><td>标点</td><td>,。?等;英文还做句首大写</td></tr>
<tr><td>许可证</td><td>开源,可商用</td></tr>
</table>
<p>想要完整的语音流程,看 <a href="/blog/chinese-speech-recognition.html">中文语音识别实战</a>、<a href="/blog/voice-activity-detection-python.html">VAD/去静音</a> 和 <a href="/blog/funasr-speaker-diarization.html">说话人分离</a>;不确定选哪个 ASR 模型看 <a href="/blog/which-funasr-model.html">模型选型指南</a>(默认推荐旗舰 Fun-ASR-Nano)。</p>
<div class="cta"><p><strong>FunASR 全家桶开源(MIT)</strong>——标点恢复、ASR、VAD、说话人、情感、LLM-ASR(旗舰 Fun-ASR-Nano),拿来即用。觉得有用就点个 Star 👇</p><p><a class="btn" href="https://github.com/modelscope/FunASR" target="_blank">⭐ Star FunASR</a></p><p style="margin-top:10px;font-size:0.85rem">也欢迎 Star:<a href="https://github.com/QwenAudio/SenseVoice" target="_blank">SenseVoice</a> · <a href="https://github.com/QwenAudio/Fun-ASR" target="_blank">Fun-ASR</a> · <a href="https://github.com/modelscope/FunClip" target="_blank">FunClip</a></p></div>
<!--related-posts-->
<h2>相关文章</h2>
<ul class="post-list">
<li><a href="/blog/funasr-vs-whisper-benchmark.html">FunASR vs Whisper 实测对比</a></li>
<li><a href="/blog/sensevoice-deployment-guide.html">SenseVoice 部署指南</a></li>
<li><a href="/blog/fun-asr-nano-guide.html">Fun-ASR-Nano 使用指南</a></li>
<li><a href="/blog/funasr-speaker-diarization.html">说话人分离:谁在何时说话</a></li>
<li><a href="/blog/sensevoice-emotion-language-detection.html">情感与语种检测</a></li>
<li><a href="/blog/funasr-realtime-streaming-asr.html">实时流式语音识别</a></li>
<li><a href="/blog/funasr-transcribe-long-audio.html">转写超长音频(1小时一次搞定)</a></li>
<li><a href="/blog/funasr-cli-transcribe-command-line.html">命令行转写(文本/JSON/SRT)</a></li>
<li><a href="/blog/self-hosted-openai-whisper-api-alternative.html">自托管 OpenAI Whisper API 替代</a></li>
<li><a href="/blog/generate-subtitles-srt-vtt-from-audio-video.html">自动生成字幕(SRT / VTT)</a></li>
<li><a href="/blog/speech-to-text-python-transcribe-audio.html">Python 语音转文字教程</a></li>
<li><a href="/blog/funasr-llama-cpp-whisper-cpp-alternative.html">FunASR 跑进 llama.cpp(whisper.cpp 替代)</a></li>
<li><a href="/blog/funasr-vs-faster-whisper-chinese.html">FunASR vs faster-whisper(中文/粤语)</a></li>
<li><a href="/blog/lightweight-speech-recognition-cpu.html">轻量语音识别(CPU 250MB)</a></li>
<li><a href="/blog/self-hosted-deepgram-assemblyai-alternative.html">自托管替代 Deepgram/AssemblyAI</a></li>
<li><a href="/blog/which-funasr-model.html">选哪个 FunASR 模型</a></li>
<li><a href="/blog/cantonese-speech-recognition.html">粤语语音识别(SenseVoice 原生粤语)</a></li>
<li><a href="/blog/japanese-speech-recognition.html">日语语音识别(SenseVoice 转写+标点+情感)</a></li>
<li><a href="/blog/voice-activity-detection-python.html">Python 语音活动检测(VAD)</a></li>
<li><a href="/blog/self-hosted-google-aws-azure-speech-to-text.html">自托管替代 Google/AWS/Azure 云语音 API</a></li>
<li><a href="/blog/chinese-speech-recognition.html">中文语音识别(普通话)实战</a></li>
<li><a href="/blog/speech-to-text-timestamps-python.html">语音识别带时间戳(字级)</a></li>
</ul>
model = AutoModel(model="ct-punc", device="cpu", disable_update=True)
texts = [
"我们都是木头人不许说话不许动",
"the meeting is at 3 pm please bring your laptop and the report",
"人不是石头人有主观价值",
]
observed = []
for original in texts:
candidate = model.generate(input=original)[0]["text"]
observed.append({"original": original, "candidate": candidate})
print(json.dumps(observed, ensure_ascii=False, indent=2))</pre>
<p><code>original</code> 保留输入,<code>candidate</code> 单独保存模型结果。先保留空白和大小写,避免显示清洗掩盖差异;这些短文本是演示,不是准确率评测集。</p>
<h2>2. 看看模型实际做了什么</h2>
<pre data-example="observed">[
{
"original": "我们都是木头人不许说话不许动",
"candidate": "我们都是木头人,不许说话,不许动。"
},
{
"original": "the meeting is at 3 pm please bring your laptop and the report",
"candidate": " The meeting is at 3 pm, please bring your laptop and the report."
},
{
"original": "人不是石头人有主观价值",
"candidate": "人不是石头人有主观价值。"
}
]</pre>
<p>第一条得到了可读的停顿;英文结果包含句首大写,也包含一个前导空格。第三条只多了句号,仍没有把第二个“人”归到后半句。输出能读出来,不代表标点已经放对。</p>
<blockquote data-editorial="manual-edit"><p><strong>人工编辑候选,不是本次模型输出:</strong><br>人不是石头,人有主观价值。</p></blockquote>
<p data-editorial="case-boundary"><a href="https://github.com/modelscope/FunASR/issues/3644">一次社区断句反馈</a>区分了播放器时间修正和标点归句问题。这里用的是重新构造的短文本,不是该录音的完整上下文;不能据此认定原问题已修复,也不建议把很短的字幕间隔直接强拼。</p>
<h2>3. 交付前,检查三个地方</h2>
<ul><li><strong>语义归句:</strong>人名、否定词、引语和转折是否仍属于正确的句子?有音频时回听上下文,不只看句末符号。</li><li><strong>大小写与排版:</strong>独立模型实现对部分 ASCII token 调用 <code>.capitalize()</code>,也会重组空格。缩写、品牌名和混合大小写需要复核,不是无损的标点插入。</li><li><strong>句末完整性:</strong>实现可能把末尾逗号替换为句号或补句号。最后一个句号不是语义完整的证明;先保留原文再编辑。</li></ul>
<p data-editorial="timing-boundary"><code>punc_array</code> 是标点类别,不是时间戳或置信度。独立文本调用不会告诉你句子在音频中的起止位置;不要靠补标点或整体挪字幕时间来声称精准对齐。ASR+VAD 流水线还有独立的文本重组逻辑,不能把这里的大小写表现泛化到所有调用路径。</p>
<p data-editorial="native-boundary">使用 <a href="/docs/native-transformers.html">Fun-ASR-Nano 原生 Transformers</a>时,也先保留原始识别结果。<code>ct-punc</code> 是可选后处理,不是原生 Nano 自带的时间戳、说话人分离或实时流式能力;已有合理标点的结果不必再盲目处理一遍。</p>
<p data-editorial="next-step">需要交付字幕时,接着走<a href="/blog/generate-subtitles-srt-vtt-from-audio-video.html">SRT、VTT 与回听检查流程</a>,把文字编辑和时间校验分开完成。</p>
<details><summary>本次复现环境与实现依据</summary><p>2026-09-10:FunASR 1.4.15,PyTorch / torchaudio 2.10.0 CPU,ModelScope <a href="https://modelscope.cn/models/iic/punc_ct-transformer_cn-en-common-vocab471067-large">iic/punc_ct-transformer_cn-en-common-vocab471067-large</a>,缓存 revision 为 <code>master</code>。它会变化,不等于固定权重;本次 <code>model.pt</code> SHA256 为 <code>7176cae922a872e130e6b88aef9a1153581711baf79c9124c7c95be383cd6f81</code>。代码与权重许可应分别核对模型卡和仓库,本文不替代授权说明。</p><p>可核对<a href="https://github.com/modelscope/FunASR/blob/83a3e3dc9ceb7b70dd3327c8c9b7a79bdb8f3a8d/funasr/models/ct_transformer/model.py#L382">大小写与句末处理</a>及<a href="https://github.com/modelscope/FunASR/blob/83a3e3dc9ceb7b70dd3327c8c9b7a79bdb8f3a8d/funasr/auto/auto_model.py#L1086">流水线文本重组</a>。单次输出不代表准确率、实时延迟或所有权重版本的结果。</p></details>
</div></article>
<footer><div class="container">© 2024-2026 Tongyi Lab, Alibaba Group · <a href="https://github.com/modelscope/FunASR/blob/main/LICENSE">MIT License</a></div></footer>
<footer><div class="container"><a href="https://github.com/modelscope/FunASR">FunASR GitHub</a></div></footer>
</body>
</html>
</html>
@@ -3,134 +3,68 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Punctuation Restoration in Python — Add Punctuation to Text / ASR Output | FunASR Blog</title>
<meta name="description" content="How to add punctuation to unpunctuated text or ASR output? FunASR ct-punc is an open-source bilingual (Chinese + English) punctuation-restoration model: 3 lines of Python to add commas, periods, and question marks (and capitalize English), or attach to ASR in one line. With real output.">
<meta name="keywords" content="punctuation restoration, add punctuation to text python, restore punctuation, punctuation restoration model, punctuation prediction, ct-punc, ASR postprocessing, FunASR, speech to text punctuation">
<title>The words are right. Why is the punctuation wrong? | FunASR</title>
<meta name="description" content="Keep the original, generate a candidate, then review sentence meaning and names.">
<link rel="icon" href="/logo.png">
<meta property="og:title" content="Punctuation Restoration in Python — Add Punctuation to Text / ASR Output | FunASR Blog">
<meta property="og:description" content="How to add punctuation to unpunctuated text or ASR output? FunASR ct-punc is an open-source bilingual (Chinese + English) punctuation-restoration model: 3 lines of Python to add commas, periods, and question marks (and capitalize English), or attach to ASR in one line. With real output.">
<meta property="og:type" content="article">
<meta property="og:url" content="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<meta property="og:image" content="https://www.funasr.com/logo.png">
<link rel="canonical" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="zh" href="https://www.funasr.com/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="en" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link rel="alternate" hreflang="x-default" href="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"BlogPosting","headline":"Punctuation Restoration in Python — Add Punctuation to Text / ASR Output | FunASR Blog","description":"How to add punctuation to unpunctuated text or ASR output? FunASR ct-punc is an open-source bilingual (Chinese + English) punctuation-restoration model: 3 lines of Python to add commas, periods, and question marks (and capitalize English), or attach to ASR in one line. With real output.","datePublished":"2026-06-23","dateModified":"2026-06-23","author":{"@type":"Organization","name":"FunASR / Tongyi Lab"},"publisher":{"@type":"Organization","name":"FunASR","logo":{"@type":"ImageObject","url":"https://www.funasr.com/logo.png"}},"mainEntityOfPage":{"@type":"WebPage","@id":"https://www.funasr.com/en/blog/punctuation-restoration-python.html"},"image":"https://www.funasr.com/logo.png"}
</script>
<style>:root{--primary:#2563eb;--primary-dark:#1d4ed8;--bg:#fff;--surface:#f8fafc;--border:#e2e8f0;--text:#0f172a;--text-soft:#475569;--text-muted:#94a3b8;--radius:12px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Inter',-apple-system,sans-serif;color:var(--text);line-height:1.7}
a{color:var(--primary);text-decoration:none}a:hover{text-decoration:underline}
.container{max-width:800px;margin:0 auto;padding:0 24px}
.nav{position:fixed;top:0;left:0;right:0;z-index:100;background:rgba(255,255,255,0.9);backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:14px 0}
.nav .container{max-width:1120px;display:flex;align-items:center;gap:24px}
.nav-logo{font-size:1.2rem;font-weight:800;color:var(--text)}.nav-logo span{color:var(--primary)}
.nav-links{display:flex;gap:20px;margin-left:auto;flex-wrap:wrap}
.nav-links a{font-size:0.85rem;font-weight:500;color:var(--text-soft)}
.nav-btn{padding:7px 16px;background:var(--primary);color:#fff;border-radius:7px;font-size:0.83rem;font-weight:600}
article{padding:120px 0 80px}
h1{font-size:2rem;font-weight:800;line-height:1.3;margin-bottom:14px}
h2{font-size:1.3rem;font-weight:700;margin:36px 0 14px;padding-top:16px;border-top:1px solid var(--border)}
h3{font-size:1.05rem;font-weight:700;margin:22px 0 10px}
p{margin-bottom:14px;color:var(--text-soft)}
ul,ol{margin:0 0 14px 22px;color:var(--text-soft)}li{margin-bottom:6px}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:0.86rem}
th,td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--border)}
th{font-weight:600;background:var(--surface);font-size:0.78rem;color:var(--text-muted)}
pre{background:#1e293b;color:#e2e8f0;padding:18px;border-radius:10px;overflow-x:auto;font-size:0.82rem;line-height:1.7;margin:14px 0}
code{font-family:ui-monospace,Menlo,monospace}
p code,li code{background:var(--surface);padding:2px 6px;border-radius:5px;font-size:0.85em;color:var(--primary-dark)}
.post-meta{color:var(--text-muted);font-size:0.85rem;margin-bottom:24px}
.cta{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;margin:28px 0;text-align:center}
.cta a.btn{display:inline-block;padding:10px 24px;background:var(--primary);color:#fff;border-radius:8px;font-weight:600}
.post-list{list-style:none;margin:0;padding:0}
.post-list li{margin:0 0 8px}
.post-card{display:block;border:1px solid var(--border);border-radius:var(--radius);padding:20px 22px;margin-bottom:14px}
.post-card:hover{border-color:var(--primary);text-decoration:none}
.post-card h2{border:none;padding:0;margin:0 0 6px;font-size:1.15rem;color:var(--text)}
.post-card p{margin:0;color:var(--text-soft);font-size:0.9rem}
.post-card .date{color:var(--text-muted);font-size:0.8rem}
footer{background:#0f172a;color:#94a3b8;padding:28px 0;text-align:center;font-size:0.8rem}
footer a{color:#94a3b8}</style>
<meta property="og:type" content="article">
<meta property="og:title" content="The words are right. Why is the punctuation wrong?">
<meta property="og:description" content="Keep the original, generate a candidate, then review sentence meaning and names.">
<meta property="og:url" content="https://www.funasr.com/en/blog/punctuation-restoration-python.html">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BlogPosting","headline":"The words are right. Why is the punctuation wrong?","description":"Keep the original, generate a candidate, then review sentence meaning and names.","datePublished":"2026-06-23","dateModified":"2026-09-10","author":{"@type":"Organization","name":"FunASR"},"publisher":{"@type":"Organization","name":"FunASR"},"mainEntityOfPage":"https://www.funasr.com/en/blog/punctuation-restoration-python.html"}</script>
<style>body{margin:0;font-family:system-ui,sans-serif;color:#202827;line-height:1.8;background:#fff}*{box-sizing:border-box}.container{max-width:920px;margin:auto;padding:0 24px}a{color:#006a57;text-underline-offset:.15em}.nav{padding:14px 0;border-bottom:1px solid #dce3df}.nav .container{display:flex;gap:20px;align-items:center}.nav-logo{font-weight:700}.nav-links{margin-left:auto}article{padding:48px 0 64px}h1{font-size:2rem;line-height:1.3;letter-spacing:0}h2{font-size:1.4rem;margin-top:36px;letter-spacing:0}.lead{font-size:1.1rem}.meta{color:#515c56;font-size:.875rem}code{overflow-wrap:anywhere}pre{padding:16px;background:#f3f6f5;border:1px solid #dce3df;border-radius:6px;font-size:.875rem;white-space:pre-wrap;overflow-wrap:anywhere}.legacy-page pre[data-example]{white-space:pre-wrap;overflow-wrap:anywhere;padding-top:54px}blockquote{margin:24px 0;padding:8px 20px;border-left:3px solid #bd4770;background:#fcf6f8}details{margin:24px 0}summary{cursor:pointer;font-weight:600}footer{padding:24px;border-top:1px solid #dce3df}@media(max-width:700px){.container{padding:0 20px}h1{font-size:1.6rem}.nav-links{display:none}}</style>
</head>
<body>
<nav class="nav"><div class="container"><a href="/en/" class="nav-logo">Fun<span>ASR</span></a><div class="nav-links"><a href="/en/#capabilities">Capabilities</a><a href="/en/#usage">How to Use</a><a href="/en/#benchmark">Benchmark</a><a href="/en/#demo">Demo</a><a href="https://modelscope.github.io/FunASR/" target="_blank">Docs</a><a href="/en/quickstart.html">Quickstart</a><a href="/en/models.html">Models</a><a href="/en/ecosystem.html">Ecosystem</a><a href="/en/blog/">Blog</a><a href="/en/vs-whisper.html">vs Whisper</a><a href="/en/donors.html">Donors</a></div><a href="https://www.funasr.com/blog/punctuation-restoration-python.html" style="font-size:0.83rem;font-weight:500;color:var(--text-soft)">中文</a><a href="https://github.com/modelscope/FunASR" target="_blank" class="nav-btn">GitHub ★</a></div></nav>
<nav class="nav"><div class="container"><a class="nav-logo" href="/en/">FunASR</a><a class="nav-links" href="/en/blog/">Blog</a><a href="/blog/punctuation-restoration-python.html">中文</a></div></nav>
<article><div class="container">
<h1>Punctuation Restoration in Python — Add Punctuation to Unpunctuated Text / ASR Output</h1>
<p class="post-meta">2026-06-23 · FunASR Team</p>
<p>Many speech recognition (ASR) models output <strong>unpunctuated</strong> text — one long run of words that is hard to read. <strong>Punctuation restoration</strong> adds the commas, periods, and question marks back so the transcript is readable. <strong>FunASR's <code>ct-punc</code></strong> is an open-source punctuation-restoration model that works for <strong>both Chinese and English</strong>, adding punctuation to any text in three lines of Python. Everything below is real measured output.</p>
<h2>Punctuation restoration in three lines (real output)</h2>
<pre>pip install funasr
<h1>The words are right. Why is the punctuation wrong?</h1>
<p class="meta">2026-06-23 · Updated 2026-09-10 · FunASR</p>
<p class="lead" data-editorial="opening">A transcript can keep every word and still split a thought in the wrong place. Before shifting subtitle times or transcribing the recording again, keep the raw text, generate a separate punctuation candidate, and check whether its meaning survives.</p>
<h2>1. Keep the input and generate a candidate</h2>
<p data-editorial="environment">Follow the <a data-installation-guide href="/en/docs/installation.html">installation and environment checks</a> for CPU. Confirm compatible PyTorch and torchaudio imports, install <code>funasr==1.4.15</code>, and run <code>python -m pip check</code>. This standalone <code>ct-punc</code> call takes text, not audio or VAD output. The first run still downloads model weights.</p>
<pre data-example="punctuate">import json
from funasr import AutoModel
model = AutoModel(model="ct-punc", disable_update=True)
print(model.generate(input="the meeting is at 3 pm please bring your laptop and the report")[0]["text"])
# The meeting is at 3 pm, please bring your laptop and the report.</pre>
<p>The input is a continuous string with no punctuation; the output gets a comma, a period, and a capitalized first letter.</p>
<h2>Real Chinese &amp; English examples</h2>
<table>
<tr><th>Input (no punctuation)</th><th>Output (ct-punc)</th></tr>
<tr><td>the meeting is at 3 pm please bring your laptop and the report</td><td><strong>T</strong>he meeting is at 3 pm<strong>,</strong> please bring your laptop and the report<strong>.</strong></td></tr>
<tr><td>我们都是木头人不许说话不许动</td><td>我们都是木头人,不许说话,不许动。</td></tr>
<tr><td>今天天气怎么样我想出去走走你要一起吗</td><td>今天天气怎么样,我想出去走走,你要一起<strong>吗?</strong></td></tr>
</table>
<p>So: English gets commas, periods, and a capitalized first letter; Chinese gets ,。? — and it even picks a <strong>question mark</strong> for questions. One model handles both languages.</p>
<h2>The most common use: punctuate ASR output</h2>
<p>The biggest use of punctuation restoration is cleaning up speech-to-text output. FunASR's ASR models let you attach ct-punc <strong>in one line</strong> so the transcript comes out punctuated:</p>
<pre>from funasr import AutoModel
# ASR + VAD + punctuation in one call (Chinese Paraformer here)
model = AutoModel(model="paraformer-zh", vad_model="fsmn-vad", punc_model="ct-punc")
result = model.generate(input="audio.wav")
print(result[0]["text"]) # output already has punctuation</pre>
<p>You can also use ct-punc <strong>standalone</strong> on any text — not just speech. Old subtitles, OCR output, chat logs: anything unpunctuated can be punctuated.</p>
<h2>Why ct-punc</h2>
<table>
<tr><th></th><th>ct-punc (FunASR)</th></tr>
<tr><td>Languages</td><td>Chinese + English (one bilingual model)</td></tr>
<tr><td>Usage</td><td>3 lines of Python on raw text; or attach to ASR in one line</td></tr>
<tr><td>Punctuation</td><td>,。? etc.; also capitalizes the first letter in English</td></tr>
<tr><td>License</td><td>open-source, commercial-friendly</td></tr>
</table>
<p>For full speech pipelines, see <a href="/en/blog/chinese-speech-recognition.html">Chinese speech recognition</a>, <a href="/en/blog/voice-activity-detection-python.html">VAD / silence removal</a>, and <a href="/en/blog/funasr-speaker-diarization.html">speaker diarization</a>; to pick an ASR model, see the <a href="/en/blog/which-funasr-model.html">model selection guide</a> (default recommendation: the flagship Fun-ASR-Nano).</p>
<div class="cta"><p><strong>The whole FunASR stack is open-source (MIT)</strong> — punctuation restoration, ASR, VAD, speaker, emotion, LLM-ASR (flagship Fun-ASR-Nano), ready to use. If it helps, a GitHub Star supports the project 👇</p><p><a class="btn" href="https://github.com/modelscope/FunASR" target="_blank">⭐ Star FunASR</a></p><p style="margin-top:10px;font-size:0.85rem">Also star:<a href="https://github.com/QwenAudio/SenseVoice" target="_blank">SenseVoice</a> · <a href="https://github.com/QwenAudio/Fun-ASR" target="_blank">Fun-ASR</a> · <a href="https://github.com/modelscope/FunClip" target="_blank">FunClip</a></p></div>
<!--related-posts-->
<h2>Related posts</h2>
<ul class="post-list">
<li><a href="/en/blog/funasr-vs-whisper-benchmark.html">FunASR vs Whisper Benchmark</a></li>
<li><a href="/en/blog/sensevoice-deployment-guide.html">SenseVoice Deployment Guide</a></li>
<li><a href="/en/blog/fun-asr-nano-guide.html">Fun-ASR-Nano Guide</a></li>
<li><a href="/en/blog/funasr-speaker-diarization.html">Speaker Diarization: Who Spoke When</a></li>
<li><a href="/en/blog/sensevoice-emotion-language-detection.html">Emotion & Language Detection</a></li>
<li><a href="/en/blog/funasr-realtime-streaming-asr.html">Real-Time Streaming Speech-to-Text</a></li>
<li><a href="/en/blog/funasr-transcribe-long-audio.html">Transcribe Long Audio (Hours in One Call)</a></li>
<li><a href="/en/blog/funasr-cli-transcribe-command-line.html">Transcribe from the Command Line</a></li>
<li><a href="/en/blog/self-hosted-openai-whisper-api-alternative.html">Self-Hosted OpenAI Whisper API Alternative</a></li>
<li><a href="/en/blog/generate-subtitles-srt-vtt-from-audio-video.html">Auto-Generate Subtitles (SRT / VTT)</a></li>
<li><a href="/en/blog/speech-to-text-python-transcribe-audio.html">Speech to Text in Python</a></li>
<li><a href="/en/blog/funasr-llama-cpp-whisper-cpp-alternative.html">FunASR on llama.cpp (whisper.cpp Alternative)</a></li>
<li><a href="/en/blog/funasr-vs-faster-whisper-chinese.html">FunASR vs faster-whisper (Chinese/Cantonese)</a></li>
<li><a href="/en/blog/lightweight-speech-recognition-cpu.html">Lightweight Speech Recognition on CPU</a></li>
<li><a href="/en/blog/self-hosted-deepgram-assemblyai-alternative.html">Self-Hosted Deepgram/AssemblyAI Alternative</a></li>
<li><a href="/en/blog/which-funasr-model.html">Which FunASR Model?</a></li>
<li><a href="/en/blog/cantonese-speech-recognition.html">Cantonese Speech Recognition (SenseVoice)</a></li>
<li><a href="/en/blog/japanese-speech-recognition.html">Japanese Speech Recognition (SenseVoice)</a></li>
<li><a href="/en/blog/voice-activity-detection-python.html">Voice Activity Detection in Python</a></li>
<li><a href="/en/blog/self-hosted-google-aws-azure-speech-to-text.html">Self-Hosted Google/AWS/Azure STT Alternative</a></li>
<li><a href="/en/blog/chinese-speech-recognition.html">Chinese Speech Recognition in Python</a></li>
<li><a href="/en/blog/speech-to-text-timestamps-python.html">Speech-to-Text with Timestamps in Python</a></li>
</ul>
model = AutoModel(model="ct-punc", device="cpu", disable_update=True)
texts = [
"我们都是木头人不许说话不许动",
"the meeting is at 3 pm please bring your laptop and the report",
"人不是石头人有主观价值",
]
observed = []
for original in texts:
candidate = model.generate(input=original)[0]["text"]
observed.append({"original": original, "candidate": candidate})
print(json.dumps(observed, ensure_ascii=False, indent=2))</pre>
<p>Keep <code>original</code> unchanged and store <code>candidate</code> separately. Preserve whitespace and case initially so display cleanup does not hide differences. These short examples are demonstrations, not an accuracy benchmark.</p>
<h2>2. Inspect what the model actually changed</h2>
<pre data-example="observed">[
{
"original": "我们都是木头人不许说话不许动",
"candidate": "我们都是木头人,不许说话,不许动。"
},
{
"original": "the meeting is at 3 pm please bring your laptop and the report",
"candidate": " The meeting is at 3 pm, please bring your laptop and the report."
},
{
"original": "人不是石头人有主观价值",
"candidate": "人不是石头人有主观价值。"
}
]</pre>
<p>The first result adds readable pauses. The English result capitalizes the opening word but also contains a leading space. The last Chinese result only adds a full stop: it does not move the second “人” into the next clause. A readable string is not necessarily a correctly punctuated sentence.</p>
<blockquote data-editorial="manual-edit"><p><strong>A human edit, not the model output above:</strong><br>人不是石头,人有主观价值。</p></blockquote>
<p data-editorial="case-boundary">A <a href="https://github.com/modelscope/FunASR/issues/3644">community sentence-boundary report</a> separated player timing adjustments from punctuation errors. This is a reconstructed short text, not that recording's full context. It does not establish that the reported problem is fixed, or justify joining subtitle segments solely because the gap is short.</p>
<h2>3. Review three things before delivery</h2>
<ul><li><strong>Sentence meaning:</strong> do names, negation, quotations and contrast still belong to the right clause? Listen to the surrounding audio when available, not just an isolated subtitle.</li><li><strong>Case and layout:</strong> the standalone implementation calls <code>.capitalize()</code> on some ASCII tokens and rebuilds spaces. Check acronyms, brands and mixed-case names; this is not lossless punctuation insertion.</li><li><strong>The ending:</strong> implementation rules can replace a trailing comma or add a full stop. A final period does not prove that the thought is complete. Keep the input before editing.</li></ul>
<p data-editorial="timing-boundary"><code>punc_array</code> contains punctuation classes, not timestamps or confidence scores. A standalone text call does not locate the sentence in audio. Adding punctuation or shifting subtitle times cannot establish precise alignment. ASR+VAD pipelines also have separate text-reassembly logic, so their casing behavior need not match this standalone call.</p>
<p data-editorial="native-boundary">With <a href="/en/docs/native-transformers.html">native Fun-ASR-Nano in Transformers</a>, keep the original recognition result too. <code>ct-punc</code> is optional postprocessing, not native Nano timestamps, diarization or realtime streaming. Do not automatically repunctuate an already satisfactory transcript.</p>
<p data-editorial="next-step">For subtitle delivery, continue with the <a href="/en/blog/generate-subtitles-srt-vtt-from-audio-video.html">SRT, VTT and listening-review workflow</a>, treating text editing and timing checks as separate steps.</p>
<details><summary>Reproduction environment and implementation</summary><p>Run on 2026-09-10 with FunASR 1.4.15 and PyTorch / torchaudio 2.10.0 CPU. ModelScope artifact: <a href="https://modelscope.cn/models/iic/punc_ct-transformer_cn-en-common-vocab471067-large">iic/punc_ct-transformer_cn-en-common-vocab471067-large</a>, cached revision <code>master</code>. That revision can change; it is not an immutable weight pin. The observed <code>model.pt</code> SHA256 is <code>7176cae922a872e130e6b88aef9a1153581711baf79c9124c7c95be383cd6f81</code>. Check code and weight licensing separately in the repository and model card; this article is not an authorization statement.</p><p>See the pinned <a href="https://github.com/modelscope/FunASR/blob/83a3e3dc9ceb7b70dd3327c8c9b7a79bdb8f3a8d/funasr/models/ct_transformer/model.py#L382">case and sentence-ending implementation</a> and <a href="https://github.com/modelscope/FunASR/blob/83a3e3dc9ceb7b70dd3327c8c9b7a79bdb8f3a8d/funasr/auto/auto_model.py#L1086">pipeline text reassembly</a>. These observations are not an accuracy, realtime-latency or all-revisions guarantee.</p></details>
</div></article>
<footer><div class="container">© 2024-2026 Tongyi Lab, Alibaba Group · <a href="https://github.com/modelscope/FunASR/blob/main/LICENSE">MIT License</a></div></footer>
<footer><div class="container"><a href="https://github.com/modelscope/FunASR">FunASR GitHub</a></div></footer>
</body>
</html>
</html>
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test';
for (const prefix of ['', 'en/']) {
for (const width of [320, 390, 1440]) {
test(`Punctuation candidate ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(String(error)));
await page.setViewportSize({ width, height: 1000 });
await page.goto(`/${prefix}blog/explanations/`);
await page.locator(`a[data-blog-story][href="/${prefix}blog/punctuation-restoration-python.html"]`).click();
const article = page.locator('article');
await expect(article.locator('h1')).toBeVisible();
await expect(article.locator('[data-editorial="opening"]')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('opening.png') });
for (const name of ['punctuate', 'observed']) {
const example = article.locator(`pre[data-example="${name}"]`);
await example.scrollIntoViewIfNeeded();
expect(await example.evaluate(node => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
expect(await example.evaluate(node => {
const text = [...node.childNodes].find(child => child.nodeType === Node.TEXT_NODE && child.textContent?.trim());
const button = node.querySelector('button');
if (!text || !button) return false;
const range = document.createRange();
const start = text.textContent!.search(/\S/);
range.setStart(text, start);
range.setEnd(text, start + 1);
return range.getBoundingClientRect().top >= button.getBoundingClientRect().bottom + 2;
}), `${name} text must not overlap its copy button`).toBeTruthy();
}
await expect(article.locator('[data-example="observed"]')).toContainText('人不是石头人有主观价值。');
await article.locator('[data-editorial="manual-edit"]').scrollIntoViewIfNeeded();
await expect(article.locator('[data-editorial="manual-edit"]')).toContainText('人不是石头,人有主观价值。');
await page.screenshot({ path: testInfo.outputPath('review.png') });
expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
const peer = prefix ? '' : 'en/';
await page.locator(`.header-actions a[href="/${peer}blog/punctuation-restoration-python.html"]`).click();
await expect(page).toHaveURL(new RegExp(`/${peer}blog/punctuation-restoration-python\\.html$`));
expect(errors).toEqual([]);
});
}
}
@@ -0,0 +1,98 @@
"""Punctuation is an editable text candidate, not timing or a semantic guarantee."""
import ast
import json
from pathlib import Path
import sys
from bs4 import BeautifulSoup
import pytest
SITE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SITE))
from build import build
SLUG = "punctuation-restoration-python"
INPUTS = [
"我们都是木头人不许说话不许动",
"the meeting is at 3 pm please bring your laptop and the report",
"人不是石头人有主观价值",
]
EXPECTED = [
{"original": INPUTS[0], "candidate": "我们都是木头人,不许说话,不许动。"},
{"original": INPUTS[1], "candidate": " The meeting is at 3 pm, please bring your laptop and the report."},
{"original": INPUTS[2], "candidate": "人不是石头人有主观价值。"},
]
@pytest.fixture(scope="module", params=["source", "built"])
def root(request, tmp_path_factory):
if request.param == "source":
return SITE / "legacy"
output = tmp_path_factory.mktemp("punctuation-story")
build(output)
return output
@pytest.mark.parametrize("prefix", ["", "en/"])
def test_reader_sees_candidate_boundaries_and_one_next_step(root, prefix):
soup = BeautifulSoup((root / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser")
article = soup.select_one("article")
assert article.select_one('[data-editorial="opening"]')
assert len(article.select("h1")) == 1
assert len(article.select("h2")) <= 4
assert "ct-punc" in article.select_one('[data-editorial="environment"]').get_text()
assert article.select_one('a[data-installation-guide]')
boundary = article.select_one('[data-editorial="timing-boundary"]').get_text().lower()
assert ("timestamps" if prefix else "时间戳") in boundary
assert article.select_one('[data-editorial="native-boundary"] a')["href"] == f"/{prefix}docs/native-transformers.html"
assert article.select_one('[data-editorial="case-boundary"] a')["href"] == "https://github.com/modelscope/FunASR/issues/3644"
assert len(article.select('[data-editorial="next-step"] a')) == 1
assert article.select_one('[data-editorial="manual-edit"]')
assert not any(text in article.get_text() for text in [
"任何无标点文本", "同一个模型双语通吃", "全家桶开源(MIT)",
"Everything below is real measured output", "anything unpunctuated",
"commercial-friendly",
])
metadata = json.loads(soup.select_one('script[type="application/ld+json"]').get_text())
assert metadata["datePublished"] == "2026-06-23"
assert metadata["dateModified"] == "2026-09-10"
assert metadata["headline"] == article.h1.get_text()
assert soup.select_one('link[rel="canonical"]')["href"] == f"https://www.funasr.com/{prefix}blog/{SLUG}.html"
@pytest.mark.parametrize("prefix", ["", "en/"])
def test_example_keeps_raw_text_and_observed_counterexample(root, prefix):
soup = BeautifulSoup((root / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser")
code = soup.select_one('pre[data-example="punctuate"]')
assert code is not None
tree = ast.parse(code.get_text())
calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)]
model = next(node for node in calls if isinstance(node.func, ast.Name) and node.func.id == "AutoModel")
assert {kw.arg: ast.literal_eval(kw.value) for kw in model.keywords} == {
"model": "ct-punc", "device": "cpu", "disable_update": True,
}
text_assignment = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == "texts" for t in node.targets))
assert ast.literal_eval(text_assignment.value) == INPUTS
assert '"original": original' in code.get_text()
assert ".strip(" not in code.get_text()
assert json.loads(soup.select_one('pre[data-example="observed"]').get_text()) == EXPECTED
assert "人不是石头,人有主观价值。" in soup.select_one('[data-editorial="manual-edit"]').get_text()
def test_bilingual_examples_match_and_homepage_is_not_expanded():
examples = []
for prefix in ["", "en/"]:
soup = BeautifulSoup((SITE / "legacy" / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser")
code = soup.select_one('pre[data-example="punctuate"]')
assert code is not None
examples.append(code.get_text())
assert examples[0] == examples[1]
data = json.loads((SITE / "data/blog.json").read_text())
row = next(entry for entry in data["articles"] if entry["slug"] == SLUG)
assert row["reviewed"] and row["category"] == "explanations"
assert data["lead"] == "funclip-v2-2-0-moss-speaker-clipping"
assert data["selected"] == [
"meeting-transcript-acceptance", "fun-asr-nano-transformers",
"self-hosted-openai-whisper-api-alternative", "funasr-transcribe-long-audio",
]