Skip to content

About

把听到的中文名字对到一份花名册上:按音节比拼音、多音姓、口音归一、字形兜底;拿不准时给候选和理由,不替你下结论。零依赖。

Resources

Stars

120 stars

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

Repository files navigation

roster-match

把听到的一个中文名字对到一份花名册上,并且在拿不准的时候说自己拿不准。

仓库:https://github.com/charlenemercury/roster-match · 零依赖 · Node >= 14

npm install roster-match
const { RosterMatch } = require('roster-match');
const M = new RosterMatch();

M.match('杨林', [{ id: 1, name: '杨凌' }, { id: 2, name: '李明' }]);
// → { student: { id: 1, name: '杨凌' },
//     kind: 'oneLetter', score: 85, why: '一个字的读音差一个字母',
//     alts: [], ambiguous: false, confident: true }

这是一个开发者组件,不是开箱即用的语音录入软件。 它不做语音识别、不提取分数、不写数据库。那些各家都不一样,也都比匹配本身更需要小心。

它解决什么问题

有人对着手机说一句「杨林交了,李明没交」,语音识别把名字听成了别的字 —— 「杨凌」被听成「杨林」。要把这句话落到具体的人头上,就得在名单里按读音找人。

这个包只干这一件事,外加一个把整段话按人切开的小工具。

为什么不直接用通用的模糊匹配库

通用库比的是字符串相似度;中文名字要比的是读音,而且读音有自己的结构:

  • 「李明」和「李鸣」后一个字完全不同,却是同一个音
  • 「陈思远」和「陈思达」两个字一样,读音差得远
  • 「王西安」(wang-xi-an)和「王先」(wang-xian)拼起来一模一样,但显然不是一个人
  • 「单」平时读 dān,做姓读 shàn;「曾」「区」「仇」「解」「查」都是这样

最后两条是这个包和"把拼音拼成字符串再算编辑距离"的主要区别: 它按音节比,并且知道哪些字在姓的位置上换个音。

分层与判定

命中哪一层,返回值里的 kind 就是什么:

kind 分 什么情况 例子
exact 100 姓名一模一样 李明 → 李明
samePinyin 95 音节逐个相同 李鸣 → 李明
oneLetter 85 有一个字的读音差一个字母 杨林 → 杨凌(lin / ling)
surnameNear 78 同姓,名字读音接近
accent 70 口音归一后相同 刘兰 → 刘楠(n/l 不分)
sameChars 65 名字里有两个字一样 陈思达 → 陈思远(打字打错生僻字时读音根本不像)
loneSurname 62 名单里只有这一个这个姓
nearPinyin 55 读音接近

判定用的是 kind,不是分数。 你可以通过 scores 调排序,但调不出一个假的 exact。

而且只有前四层(exact / samePinyin / oneLetter / surnameNear)才够格标成 confident。 「名单里只有一个姓李」这种猜测,给它 99 分也还是猜测。这条可以用 reliable 选项调整。

该看哪两个字段

const r = M.match(said, roster);
if (!r)                 // 没人说得通
else if (r.ambiguous)   // 还有别人也说得通 → 把 r.alts 摆出来让人选
else if (r.confident)   // 层级可靠、分数够高、没有并列
else                    // 有人选,但心里没底 → 也该让人确认

⚠️ confident 是这套启发式的自信程度,不是识别正确率。 85 分不等于 85% 准确。它为真也不代表可以直接写库。

最要紧的一条设计原则

拿不准的时候要老实说拿不准。

匹配错一个人,代价是对方对着一条错记录发愣,而且多半发现不了; 说一句「拿不准,你选一下」,代价只是多点一下。所以这个包宁可多报不确定。

一个具体体现:语音只能挑一种汉字写法。名单里同时有「李明」和「李鸣」时, 识别结果正好写成「李明」不能证明说的就是那个人 —— 所以默认把同音的也摆进 alts。

new RosterMatch();                 // 默认:输入来自语音 → 同音的一律并列
new RosterMatch({ voice: false }); // 确定是键盘打的 → 写法对上就算确定

把一整段话按人切开

const { splitSpeech } = require('roster-match');
splitSpeech('杨凌90+30李明80+20');   // → ['杨凌90+30', '李明80+20']

语音识别经常一个标点都不给。只按标点切的话整句是一段,贪婪匹配会把名字吃成 「杨凌90+30李明」——只出一个人,还对错了人。

规则:数字后面紧跟汉字 = 换了一个人;后面跟的是 + + 加 . 或另一个数字时,说明还在同一个人身上。 全角数字会先归一成半角;名字和数字之间的空格不当分隔(「李明 92」不切开)。

支持:名字 + 阿拉伯数字,人与人之间有标点、空格或直接连读。 不支持,而且会原样留在一段里(不会误切成两个人): 中文数字连读「李明九十分王红八十」、加减分描述「李明90分扣2分」。 这是刻意的 —— 中文数字和业务规则该由上层决定,不该在这里无限追加正则去猜。 (识别 加 扣 减 得 共 总 这几个字,就是为了不把它们当成下一个人的姓。)

上线前必做:查缺字

这类工具最容易悄悄失效 —— 名字里有个字查不到读音,就匹配不上,而且不报错。

M.auditRoster(roster);   // → ['翀', '龑']  这些字表里没有读音

拿你的真实名单在本地跑一遍,缺的字补进 pinyin-table.js 再上线。 覆盖 GB2312 + GBK 和扩展区的检测,但姓名里的生僻字永远补不完。

返回空不等于这张表足够覆盖你的名单,只说明这几个字查得到音。 另外:别把真实名单贴到公开 issue 里求补字,只贴那几个单字。

名单的要求

每个人必须有唯一的 id。缺了或者重了会直接抛错 —— 不能悄悄当成同一个人,那会把两个同名的人合并掉。

可调的地方

new RosterMatch({
  voice: false,               // 输入是键盘打的,不是语音
  accent: ['zcs', 'nasal'],   // 只开平翘舌和前后鼻音,关掉 n/l 和 h/f
  surnames: { 澹: 'tan' },     // 补充多音姓的读音
  accept: 90,                 // 分数线调高 → 更多情况要人确认
  tie: 12,                    // 和第一名差 12 分以内都算并列
  scores: { oneLetter: 80 },  // 单独调某一层的分数(只影响排序)
  table: myTable,             // 换一张拼音表
});

accent 默认全开是给西南官话区用的(平翘舌、前后鼻音、n/l、h/f 都不分)。 别的地方按需关掉 —— 开得越多,误判越多。

要不要再配一个大模型

这个包本身不需要模型 —— 纯规则、离线、即时、免费,没有额度也没有延迟。

模型能补上规则做不到的几类:口误改口(「林可晴88,哦不对是89」)、说法不规整 (「后面那几个都没交」)、两个人读音完全一样要靠上下文判断。真要加,建议这么分工:

  • 数值一律本地算,只让模型认名字 —— 模型参与了数字,就有改错数字的机会
  • 发给模型的名单只带学号 + 姓名,不带成绩、评价
  • 模型给回的结果仍然过本包的可靠性判定,不因为"模型说的"就直接写库
  • 必须有本地兜底:模型超时或返回空时退回规则匹配,别让界面卡在那儿

选什么模型:别用推理模型

按读音在名单里挑人是查表对音,不是推理任务。推理模型会先写一大段思维链, 而思维链的字数也算进 max_tokens —— 预算被吃光时返回的正文是空字符串, finish_reason 是 length,看起来像"模型没响应",其实是被自己的思考挤掉了。

同一份请求实测(47 人名单,一次认 8 个名字,max_tokens=3500,temperature 0):

模型 耗时 结果
MiniMax-M3(推理型) 25–27 秒 正文时常为空(finish_reason=length,思考写了 8000+ 字)
MiniMax-M3,预算调到 8000 67 秒 正文仍为空,思考写了 22000+ 字
MiniMax-Text-01 3.7 秒 正常
abab6.5s-chat 5.4 秒 正常

准确率呢?拿 10 个同音听错的用例(「王浩燃→王浩然」「何智远→何知远」「林可青→林可晴」 这类)对照,三个模型都是 10/10 —— 推理没带来任何准确率收益,只带来了延迟和空返回。

注意第二行:把 max_tokens 调大反而更糟,它只会想得更久。这个直觉是反的。

所以:

  • 选不带思维链的通用文本模型,temperature 设 0,要求只输出 JSON
  • 万一只有推理模型可用,max_tokens 至少给 3000 以上,并且把空正文当失败处理
  • 课堂/窗口期场景的硬指标是几秒内出结果:老师站在讲台上,等不了 30 秒

环境

零运行时依赖,Node 14+。目前是 CommonJS,浏览器里要用得先打包(esbuild / webpack / vite 都行), 不能直接 <script src> 引。

来历与边界

从一个真实教学场景里抽出来的。真正有用的三件事都写在上面了: 按音节比而不是拼成字符串、拿不准就交给人、缺字要查得出来。

还没有公开的评测集和使用量数据,所以不宣称比现有方案更准。 要做完整的语音录入,这个包之外还需要:语音服务选型、数值范围校验、写入幂等、撤销、权限 —— 这些都不在这里。

许可证

AGPL-3.0-only(全文见 LICENSE)。下面是提要,以许可证全文为准:

  • 商用是允许的。 AGPL 不禁止收费、不禁止用在商业产品里 —— 它要求的是在分发或提供网络服务时让使用者拿到对应源码。
  • 自己用、不改、不给别人用:没有额外义务。
  • 改了之后让别人通过网络使用它(第 13 条):要向这些使用者提供你修改后的对应源码 —— 内网部署也算,只要使用的人不是你自己。
  • 分发(给别人拷贝、装进产品发出去):同样要给源码,并且按 AGPL 继续授权。

什么时候才需要另谈商业许可? 不是因为"要收费",而是因为你不想承担上面这些开源义务。 只要你愿意照 AGPL 办,直接用就行,不用找任何人。

判断具体场景请看 GNU 官方说明:https://www.gnu.org/licenses/gpl-faq.html 和 https://www.gnu.org/licenses/why-affero-gpl.html。这里的提要不构成法律意见。

内置拼音数据来自 pypinyin(MIT), 上游版权与许可全文见 NOTICE.md。

想提 PR?

先看 CLA.md,在第一个 PR 里回一句"我已阅读并同意本项目的 CLA"就行, 不用写真名、不用扫描件。

里面有一条没有例外:不要提交任何真实的个人信息, 尤其是未成年人的姓名、学号、成绩、照片。测试数据必须是编的。 这个仓库里现有的「杨凌」「李明」「顾言七」全是编出来的。

About

把听到的中文名字对到一份花名册上:按音节比拼音、多音姓、口音归一、字形兜底;拿不准时给候选和理由,不替你下结论。零依赖。

Resources

Stars

120 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages