feat: 拼音写时标注服务(词表最长匹配 + 逐字对齐 JSON)
This commit is contained in:
@@ -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",
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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("空文本应返回 []")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
|
github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2
|
||||||
github.com/gogf/gf/v2 v2.10.2
|
github.com/gogf/gf/v2 v2.10.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/mozillazg/go-pinyin v0.21.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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-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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||||
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||||
|
|||||||
Reference in New Issue
Block a user