feat: 拼音写时标注服务(词表最长匹配 + 逐字对齐 JSON)

This commit is contained in:
2026-08-13 13:33:44 +08:00
parent 841191185e
commit 1ce2ab2b24
5 changed files with 184 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
package consts
// PinyinOverrides 拼音标注词表:整词 override(成语整体、专名),标注时最长匹配优先,
// 保证教育内容多音字准确(如「声东击西」不能拆成 zhong dong ji xi)。
var PinyinOverrides = map[string]string{
"瞒天过海": "mán tiān guò hǎi",
"围魏救赵": "wéi wèi jiù zhào",
"借刀杀人": "jiè dāo shā rén",
"以逸待劳": "yǐ yì dài láo",
"趁火打劫": "chèn huǒ dǎ jié",
"声东击西": "shēng dōng jī xī",
"无中生有": "wú zhōng shēng yǒu",
"暗渡陈仓": "àn dù chén cāng",
"隔岸观火": "gé àn guān huǒ",
"笑里藏刀": "xiào lǐ cáng dāo",
"李代桃僵": "lǐ dài táo jiāng",
"顺手牵羊": "shùn shǒu qiān yáng",
"打草惊蛇": "dǎ cǎo jīng shé",
"借尸还魂": "jiè shī huán hún",
"调虎离山": "diào hǔ lí shān",
"欲擒故纵": "yù qín gù zòng",
"抛砖引玉": "pāo zhuān yǐn yù",
"擒贼擒王": "qín zéi qín wáng",
"釜底抽薪": "fǔ dǐ chōu xīn",
"混水摸鱼": "hún shuǐ mō yú",
"金蝉脱壳": "jīn chán tuō qiào",
"关门捉贼": "guān mén zhuō zéi",
"远交近攻": "yuǎn jiāo jìn gōng",
"假道伐虢": "jiǎ dào fá guó",
"偷梁换柱": "tōu liáng huàn zhù",
"指桑骂槐": "zhǐ sāng mà huái",
"假痴不癫": "jiǎ chī bù diān",
"上屋抽梯": "shàng wū chōu tī",
"树上开花": "shù shàng kāi huā",
"反客为主": "fǎn kè wéi zhǔ",
"美人计": "měi rén jì",
"空城计": "kōng chéng jì",
"反间计": "fǎn jiàn jì",
"苦肉计": "kǔ ròu jì",
"连环计": "lián huán jì",
"走为上计": "zǒu wéi shàng jì",
// 种子内容常用专名(扫描 seed_36_ji/*.json 的人物/场景名补充,发现多音误标就加)
"小明": "xiǎo míng",
"小虎": "xiǎo hǔ",
"朵朵": "duǒ duǒ",
"幼儿园": "yòu ér yuán",
"足球场": "zú qiú chǎng",
"游乐场": "yóu lè chǎng",
"小兔子": "xiǎo tù zi",
"大哥哥": "dà gē ge",
}
+69
View File
@@ -0,0 +1,69 @@
package common
import (
"encoding/json"
"sort"
"strings"
"unicode"
"github.com/mozillazg/go-pinyin"
"36wisdom/biz/consts"
)
// AnnotatePinyin 写时一次性标注:词表最长匹配优先(整体转拼音),未命中逐字转
// pinyin.Tone 带声调);返回逐字对齐的 JSON 数组字符串,非汉字对应空串,
// 下标与原文 rune 一一对应。纯函数,文本未变结果不变(幂等)。
func AnnotatePinyin(text string) string {
runes := []rune(text)
out := make([]string, len(runes))
if len(runes) == 0 {
return "[]"
}
words := make([]string, 0, len(consts.PinyinOverrides))
for w := range consts.PinyinOverrides {
words = append(words, w)
}
sort.Slice(words, func(i, j int) bool { return len(words[i]) > len(words[j]) })
// 词表最长匹配切分:命中片段整体标注(词表拼音音节数须与字数一致)
for i := 0; i < len(runes); {
matched := false
for _, w := range words {
n := len([]rune(w))
if i+n > len(runes) || string(runes[i:i+n]) != w {
continue
}
pys := strings.Fields(consts.PinyinOverrides[w])
if len(pys) != n {
continue
}
copy(out[i:i+n], pys)
i += n
matched = true
break
}
if !matched {
i++
}
}
// 未命中汉字逐字转拼音;pi 与 pinyin.Pinyin 的汉字输出顺序同步推进
args := pinyin.NewArgs()
args.Style = pinyin.Tone
all := pinyin.Pinyin(string(runes), args)
pi := 0
for idx, r := range runes {
if !unicode.Is(unicode.Han, r) {
continue
}
if out[idx] == "" && pi < len(all) && len(all[pi]) > 0 {
out[idx] = all[pi][0]
}
pi++
}
b, _ := json.Marshal(out)
return string(b)
}
+61
View File
@@ -0,0 +1,61 @@
package common
import (
"encoding/json"
"testing"
)
func parse(t *testing.T, s string) []string {
t.Helper()
var out []string
if err := json.Unmarshal([]byte(s), &out); err != nil {
t.Fatalf("拼音 JSON 解析失败: %v", err)
}
return out
}
func TestAnnotatePinyin_IdiomOverride(t *testing.T) {
// 词表命中整体转拼音(带声调)
pys := parse(t, AnnotatePinyin("声东击西"))
want := []string{"shēng", "dōng", "jī", "xī"}
if len(pys) != len(want) {
t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys)
}
for i := range want {
if pys[i] != want[i] {
t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i])
}
}
}
func TestAnnotatePinyin_Alignment(t *testing.T) {
// 逐字对齐:标点/非汉字对应空串
pys := parse(t, AnnotatePinyin("小明,你好!"))
want := []string{"xiǎo", "míng", "", "nǐ", "hǎo", ""}
if len(pys) != len(want) {
t.Fatalf("长度 %d != %d: %v", len(pys), len(want), pys)
}
for i := range want {
if pys[i] != want[i] {
t.Fatalf("第 %d 字 %q != %q", i, pys[i], want[i])
}
}
}
func TestAnnotatePinyin_NonHan(t *testing.T) {
pys := parse(t, AnnotatePinyin("123abc"))
if len(pys) != 6 {
t.Fatalf("长度应 6,实际 %d", len(pys))
}
for i, p := range pys {
if p != "" {
t.Fatalf("第 %d 字符应空串,实际 %q", i, p)
}
}
}
func TestAnnotatePinyin_Empty(t *testing.T) {
if AnnotatePinyin("") != "[]" {
t.Fatal("空文本应返回 []")
}
}
+1
View File
@@ -6,6 +6,7 @@ require (
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
github.com/gogf/gf/v2 v2.10.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/mozillazg/go-pinyin v0.21.0
golang.org/x/crypto v0.55.0
)
+2
View File
@@ -48,6 +48,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=