fix(engine): 纯简拼读法否决权,纠错不再劫持 nbn 等简拼候选 (#881)

* fix(engine): 纯简拼读法否决权,纠错不再劫持 nbn 等简拼候选

纯简拼形输入(切分全是单字母声母)若词库 jp 列有该输入读法,纠错不得接管:
nbn 是「能不能」、nqn 是「女青年」、bqng 是「并且能够」,纠错却把它们改写成
字母更近的错串候选(难/帮),顶掉真实命中的简拼词。

输入形状分不干净——sahng 也全是合法声母却必须保留乱序纠错;唯一分得开的是
词库有没有这个简拼读法,故判定落在同时持有 DB 与纠错掩码的查询层。

- 新增 quanpin::has_pure_jianpin_reading(jp 列 LIMIT 1 查询)
- QuanpinDictionary::effective_autocorrect_types 统一 query_exact 与
  insert_word_to_series_cache 的有效掩码,避免 series 缓存键分歧
- 非纯简拼(shng=[sh,n,g]、shbng、sahng/shangg/sjhang)纠错不变

* fix(engine): 纯简拼整词改为排在纠错前面,不再整个关掉纠错

原方案在词库有纯简拼读法时把纠错掩码置 0,漏了元音的真手误(bng 想打 bang)
也随之纠不了,而且只有词库层知道纠错被关,预编辑与选词消耗仍按原掩码判断。

改为不动掩码:纠错照常进行,纠错分支里字面结果中的纯简拼整词
(literal_jianpin_pinyin_match:两个及以上单字母声母盖住整串,逐个是正读首字母,
a/e/o 不算)和混合简拼一样放进领衔组。纠错读法排在后面,仍可选、仍带纠错标记。
判定直接看已查回的字面结果,不新增 SQL;掩码不变,缓存键也不变,
因此删掉 has_pure_jianpin_reading 与 effective_autocorrect_types。

测试补上预编辑(n'b'n)、选前缀单字只消耗自己的声母、bng 仍保留「帮」。

---------

Co-authored-by: fanlusky <1730976608@qq.com>
This commit is contained in:
Jia Suiandfanlusky authored and GitHub committed 2026-10-12 17:23:30 +08:00
1 parent 341ca93be5
commit fc90cb1f2c
6 files changed
+186 -3

No files matched your search

+10 -1
View File
@@ -321,6 +321,12 @@ unsigned literal_mixed_match(const WordItem &item, const std::string &raw_input)
: 0;
}
bool literal_jianpin_match(const WordItem &item, const std::string &raw_input)
{
return (item.source == CandidateSource::Database || item.source == CandidateSource::UserDatabase) &&
quanpin::literal_jianpin_pinyin_match(raw_input, item.pinyin, item.canonical_pinyin);
}
// 读音就是 key 这条切分的行(整键块)。整句行按敲的读音(pinyin)认:词格整句的
// canonical_pinyin 是它实际走的读音,开着模糊音时可能与切分不同(min 走成 ming)。
bool reads_full_key(const WordItem &item, const std::string &key)
@@ -515,9 +521,12 @@ std::vector<WordItem> QuanpinDictionary::query_exact(const std::string &raw_inpu
const std::string fallback_segmentation =
segmentation.empty() ? quanpin::join_segments(segments) : segmentation;
const auto literal = query_series(raw_input, fallback_segmentation, segments);
// 纯简拼字面整词与混合简拼同样领衔:全辅音串照常进纠错(形状分不出 nbn 与漏了元音
// 的 bng),但词库里真有这个简拼读法时(nbn「能不能」、bqng「并且能够」),纠错读法
// 只排在它后面,仍可选、仍带纠错标记。
for (const auto &item : literal)
{
if (literal_mixed_match(item, raw_input))
if (literal_mixed_match(item, raw_input) || literal_jianpin_match(item, raw_input))
literal_mixed.push_back(item);
}
append_unique_words(result, literal);
+23 -2
View File
@@ -50,6 +50,25 @@ unsigned literal_mixed_pinyin_match(const std::string &raw, const std::string &c
return full && initial ? (tail.empty() ? 1u : 2u) : 0u;
}
bool literal_jianpin_pinyin_match(const std::string &raw, const std::string &code, const std::string &canonical)
{
if (raw.find('\'') != std::string::npos || canonical.empty())
return false;
const auto typed = split_segments(code);
const auto reading = split_segments(canonical);
if (typed.size() < 2 || typed.size() != reading.size())
return false;
std::string letters;
for (size_t i = 0; i < typed.size(); ++i)
{
if (typed[i].size() != 1 || !prefix_pinyin_set().count(typed[i]) || intact_pinyin_set().count(typed[i]) ||
!intact_pinyin_set().count(reading[i]) || reading[i][0] != typed[i][0])
return false;
letters += typed[i];
}
return letters == raw;
}
namespace
{
struct SparsePinyinFallbackEntry
@@ -214,8 +233,10 @@ bool looks_like_jianpin_spelling(const std::string &pinyin)
// trailing letter ("zheg" = zhe + g) to count as jianpin intent. The cut
// backtracks like the query-side segmentation ("zaijinut" = zai'ji'nu + t).
// All-consonant strings match zero syllables and return false on purpose:
// the engine has no multi-letter jianpin, so correction is the only useful
// reading of e.g. "bqng" -> bang.
// shape alone cannot tell a pure jianpin ("nbn" = 能不能) from a dropped
// vowel ("bng" -> bang), so both still enter correction. The dictionary
// layer then puts a literal pure-jianpin whole word ahead of the corrected
// readings (literal_jianpin_pinyin_match), keeping the correction behind it.
if (is_complete_pinyin_input(pinyin))
{
// Fully reduced to legal syllables: a legitimate spelling, not a typo.
+3
View File
@@ -29,6 +29,9 @@ bool has_only_complete_pinyin_segments(const Segments &segments);
// 0: no literal mixed match; 1: whole input; 2: one unfinished 1-2-letter syllable remains.
// Both full syllables and single-letter initials must match the candidate's canonical key.
unsigned literal_mixed_pinyin_match(const std::string &raw, const std::string &code, const std::string &canonical);
// 纯简拼字面整词:两个及以上单字母声母(a/e/o 是完整音节,不算)盖住整串输入,且逐个是
// 候选正读音节的首字母(nbn = n'b'n -> neng'bu'neng)。前缀单字和带完整音节的混合简拼不算。
bool literal_jianpin_pinyin_match(const std::string &raw, const std::string &code, const std::string &canonical);
SyllableGraph build_syllable_graph(const std::string &pinyin);
std::vector<Segments> enumerate_complete_segmentations(const SyllableGraph &graph, size_t path_limit = 32);
std::vector<std::string> cut_one_piece_greedy(const std::string &pinyin, bool intact_only);
+90
View File
@@ -1525,6 +1525,95 @@ void run_autocorrect_generated_space_tests(const std::filesystem::path &data_dir
}
}
// 纯简拼形输入(每个字母都是一个声母)如果词库里真有它的简拼整词,由字面整词领衔:
// nbn/nqn/bqng 分别命中词库 jp 列的 能不能/女青年/并且能够,纠错读法(难/帮)只排在
// 后面,仍可选、仍带纠错标记。预编辑按字面简拼切分,选前缀单字只消耗它自己的字母。
// 漏了元音的 bng 即使有简拼整词「不能够」,「帮」也不能丢。shng 含双字母切分 sh,
// 不是纯简拼,漏字纠错照旧。
void run_autocorrect_pure_jianpin_guard_tests(const std::filesystem::path &data_directory)
{
const std::filesystem::path directory = data_directory / "autocorrect-pure-jianpin";
std::filesystem::create_directories(directory);
{
Database database(directory / "msime.db");
database.execute("CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang','s','上',900);"
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_n VALUES('nan','n','难',900);"
"INSERT INTO tbl_1_n VALUES('neng','n','能',800);"
"CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_b VALUES('bang','b','帮',900);"
"CREATE TABLE tbl_3_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_3_b VALUES('bu''neng''gou','bng','不能够',2000);"
"CREATE TABLE tbl_3_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_3_n VALUES('neng''bu''neng','nbn','能不能',5000);"
"INSERT INTO tbl_3_n VALUES('nv''qing''nian','nqn','女青年',3000);"
"CREATE TABLE tbl_4_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_4_b VALUES('bing''qie''neng''gou','bqng','并且能够',4000);");
}
metasequoia::RuntimePaths paths;
paths.resources = directory;
paths.user_data = directory;
paths.cache = directory;
paths.dictionaries = directory;
const unsigned all = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor |
quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
struct Case
{
const char *input;
const char *word;
const char *display;
const char *corrected;
};
for (const Case &test : {Case{"nbn", "能不能", "n'b'n", "难"}, Case{"nqn", "女青年", "n'q'n", "难"},
Case{"bqng", "并且能够", "b'q'n'g", "帮"}, Case{"bng", "不能够", "b'n'g", "帮"}})
{
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
type(session, test.input);
require(!session.candidates().empty() && session.candidates().front().word == test.word &&
session.candidates().front().corrected_from.empty(),
(std::string("A pure-jianpin whole word must lead, uncorrected: ") + test.input).c_str());
const auto corrected = find_candidate_index(session, test.corrected);
require(corrected < session.candidates().size() && session.candidates()[corrected].corrected_from == test.input,
(std::string("The corrected reading must stay behind the jianpin word: ") + test.input).c_str());
require(session.get_pinyin_segmentation_with_cases() == test.display,
(std::string("The preedit must follow the literal jianpin reading: ") + test.input + " -> " +
session.get_pinyin_segmentation_with_cases())
.c_str());
}
// 选纯简拼的前缀单字只消耗它自己的声母,后面的 bn 留着继续输入。
{
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
type(session, "nbn");
const auto committed = session.select_candidate(candidate_index(session, "能"));
require(committed.commit == "能" && session.has_composition() && session.preedit() == "bn",
"Selecting a jianpin prefix character must consume only its own initial.");
}
// 含双字母切分的输入不是纯简拼,漏字纠错照旧命中「上」并记下敲入的字母。
{
metasequoia::InputSession session(SchemeType::Quanpin, all, true, true, true, paths);
type(session, "shng");
const auto found = find_candidate_index(session, "上");
require(found < session.candidates().size() && session.candidates()[found].corrected_from == "shng",
"A non-pure-jianpin typo (shng) must still be corrected with the typed letters recorded.");
}
// 纠错关时逐位不变:纯简拼输入仍由字面简拼读法领衔,且无纠错标记。
{
metasequoia::InputSession session(SchemeType::Quanpin, 0u, true, true, true, paths);
type(session, "bqng");
require(!session.candidates().empty() && session.candidates().front().word == "并且能够" &&
session.candidates().front().corrected_from.empty(),
"With autocorrection off a pure-jianpin reading must still lead, uncorrected.");
}
std::filesystem::remove_all(directory);
}
void run_caret_prefix_session_tests(const std::filesystem::path &data_directory)
{
const std::filesystem::path directory = data_directory / "caret-prefix";
@@ -2521,6 +2610,7 @@ int run_test()
run_autocorrect_costlier_context_tests(data_directory);
run_autocorrect_generated_space_tests(data_directory);
run_autocorrect_pure_jianpin_guard_tests(data_directory);
#endif
#ifndef METASEQUOIA_SKIP_FREQUENCY_TESTS
+29
View File
@@ -919,12 +919,21 @@ std::filesystem::path create_autocorrect_probe_database()
throw std::runtime_error("Failed to create the autocorrect probe database.");
}
const char *sql = "CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_3_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_4_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_4_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang','s','上',100);"
"INSERT INTO tbl_1_n VALUES('nan','n','难',100);"
"INSERT INTO tbl_1_b VALUES('bang','b','帮',100);"
"INSERT INTO tbl_2_s VALUES('shang''zhi','sz','上至',100);"
"INSERT INTO tbl_2_j VALUES('jian''du','jd','监督',100);"
"INSERT INTO tbl_3_n VALUES('neng''bu''neng','nbn','能不能',5000);"
"INSERT INTO tbl_3_n VALUES('nv''qing''nian','nqn','女青年',3000);"
"INSERT INTO tbl_4_b VALUES('bing''qie''neng''gou','bqng','并且能够',4000);"
"INSERT INTO tbl_4_s VALUES('sa''huang''na''ge','shng','撒谎那个',1000);";
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
sqlite3_close(db);
@@ -1123,6 +1132,26 @@ void test_quanpin_autocorrect_switches_and_guard()
{
QuanpinDictionary dictionary(db_path.string());
// 纯简拼字面整词领衔:纯简拼形输入(每个字母都是一个声母)且词库里有这个简拼整词时,
// 它排在纠错读法前面且无 corrected_from;纠错读法仍留在后面、带纠错标记。
// 放在既有断言之前:本函数后段有一条与本任务无关的既有失败会抛出,其后的断言不再执行。
const unsigned all_four =
transposition_only | neighbor_only | quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
const auto pure_nbn = dictionary.query("nbn", "", all_four);
expect(!pure_nbn.empty() && pure_nbn.front().word == "能不能" && pure_nbn.front().corrected_from.empty(),
"A pure-jianpin input with a dictionary reading must lead uncorrected (nbn -> 能不能).");
expect(std::any_of(pure_nbn.begin(), pure_nbn.end(),
[](const WordItem &item) { return item.word == "难" && item.corrected_from == "nbn"; }),
"The corrected reading must stay behind the jianpin word (nbn -> 难).");
const auto pure_bqng = dictionary.query("bqng", "", all_four);
expect(!pure_bqng.empty() && pure_bqng.front().word == "并且能够" && pure_bqng.front().corrected_from.empty(),
"A pure-jianpin input with a dictionary reading must lead uncorrected (bqng -> 并且能够).");
// 非纯简拼(shng = sh + n + g,含双字母切分)不受保护:漏字纠错照旧命中「上」。
const auto non_pure_shng = dictionary.query("shng", "sh'n'g", all_four);
expect(!non_pure_shng.empty() && non_pure_shng.front().word == "上" &&
non_pure_shng.front().corrected_from == "shng",
"A non-pure-jianpin typo must still be corrected (shng -> 上).");
const auto corrected = dictionary.query("sahng", "sa'h'n'g", both);
expect(!corrected.empty() && corrected.front().word == "上",
"Both switches on must put the corrected candidate first.");
+31
View File
@@ -978,16 +978,24 @@ std::filesystem::path CreateAutocorrectDatabase()
throw std::runtime_error("Failed to create temporary autocorrect database.");
}
const char *sql = "CREATE TABLE tbl_1_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_1_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_g(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_j(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_2_q(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_3_n(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_4_s(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"CREATE TABLE tbl_4_b(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
"INSERT INTO tbl_1_s VALUES('shang','s','上',100);"
"INSERT INTO tbl_1_n VALUES('nan','n','难',100);"
"INSERT INTO tbl_1_b VALUES('bang','b','帮',100);"
"INSERT INTO tbl_2_s VALUES('shang''zhi','sz','上至',100);"
"INSERT INTO tbl_2_g VALUES('guan''li','gl','管理',100);"
"INSERT INTO tbl_2_j VALUES('jian''du','jd','监督',100);"
"INSERT INTO tbl_2_q VALUES('quan''li','ql','权利',100);"
"INSERT INTO tbl_3_n VALUES('neng''bu''neng','nbn','能不能',5000);"
"INSERT INTO tbl_4_b VALUES('bing''qie''neng''gou','bqng','并且能够',4000);"
"INSERT INTO tbl_4_s VALUES('sa''huang''na''ge','shng','撒谎那个',1000);";
const int result = sqlite3_exec(db, sql, nullptr, nullptr, nullptr);
sqlite3_close(db);
@@ -1063,6 +1071,29 @@ TEST_CASE(QuanpinDictionaryAutocorrectDeletionTypo)
std::none_of(legacy.begin(), legacy.end(), [](const WordItem &item) { return item.corrected_from == "shng"; }));
}
TEST_CASE(QuanpinDictionaryPureJianpinWordLeadsAutocorrect)
{
const auto db_path = CreateAutocorrectDatabase();
QuanpinDictionary dictionary(test::Utf8(db_path));
const unsigned all = quanpin::kAutocorrectTransposition | quanpin::kAutocorrectNeighbor |
quanpin::kAutocorrectDeletion | quanpin::kAutocorrectInsertion;
// 纯简拼字面整词领衔:纯简拼形输入(每个字母都是一个声母)且词库里有这个简拼整词时,
// 它排在纠错读法前面且无 corrected_from;纠错读法仍留在后面、带纠错标记。
const auto bqng = dictionary.query("bqng", "", all);
REQUIRE(!bqng.empty());
REQUIRE_EQ(bqng.front().word, std::string("并且能够"));
REQUIRE(bqng.front().corrected_from.empty());
REQUIRE(std::any_of(bqng.begin(), bqng.end(),
[](const WordItem &item) { return item.word == "帮" && item.corrected_from == "bqng"; }));
// 非纯简拼(shng = sh + n + g,含双字母切分)不受保护:漏字纠错照旧命中「上」。
const auto shng = dictionary.query("shng", "sh'n'g", all);
REQUIRE(!shng.empty());
REQUIRE_EQ(shng.front().word, std::string("上"));
REQUIRE_EQ(shng.front().corrected_from, std::string("shng"));
}
TEST_CASE(QuanpinDictionaryAutocorrectInsertionTypo)
{
const auto db_path = CreateAutocorrectDatabase();