Files
msime-windows/engine/quanpin/quanpin_dictionary.cpp
Lu Fan(SonnyCalcr) a60e2948db fix(engine): 整句联想按 P(读音|字) 压住多音字的生僻读音 (#1123)
tai'zhuan'qian'le 出「大赚钱了」:大 也读 tai,词库里这一行权重 4719,占 tai 的
0.47%,已有的 reading_prior(看 P(字|拼音),1% 免罚线)只扣 0.33,翻不过语言模型
对「大赚」约 1.6 的偏好。换个角度看,大 读 tai 只占它全部权重的 0.14%——这才是
「这个字几乎不这么读」,语言模型只认汉字,这一维要由读音补上。

词格跨度查询对单字行加一项 P(读音|字) 惩罚:占比低于 10% 的部分按 log10 扣分,
上限 2.5。长 chang、重 chong、行 hang 这类常见第二读音不扣;大 tai/dai、会 kuai、
都 du、给 ji 扣 1.5~2。只罚单字,词本身就是读音的证据(会计、成都、大夫)。多音字
各读音的权重和在第一次整句联想时扫一遍单字表载入,全拼与双拼(含直接辅助码)共用。

自建 111 句多音字评测从 79 提到 82,无回归;5% 档翻不过「大赚钱了」,20% 与 10%
结果相同,取保守的一边。test_engine_smoke 加固定权重的断言,句子评测集补两条。
2026-10-20 08:47:27 +08:00

2319 lines
108 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "fuzzy_pinyin.h"
#include "quanpin_dictionary.h"
#include "../user_dictionary/user_dictionary_journal.h"
#include "../common/helpcode_utils.h"
#include "quanpin_query.h"
#include "quanpin_utils.h"
#include "../shuangpin/shuangpin_utils.h"
#include <algorithm>
#include <climits>
#include <cstring>
#include <fmt/format.h>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utf8/cpp17.h>
namespace
{
constexpr size_t kSparsePinyinFallbackThreshold = 8;
constexpr size_t kSyllableGraphPathLimit = 32;
constexpr size_t kMaxSyllablesForMultipleSegmentations = 4;
constexpr int kAlternativeSegmentationCandidateLimit = 128;
constexpr size_t kBestAlternativeSegmentationMaxIndex = 1;
// 保护位只对「排在首页之外」的备选读音生效。默认 page_size 是 6,所以自然排序已经
// 进了前 6 的候选一律按权重原样呈现——用户调频写的就是权重,再钉一次等于把刚调出来
// 的名次覆盖掉。page_size 可配(3..9),这里取默认值:调大页长最多是少提升一两个
// 本来就看得见的词,不会把该提升的漏掉。
constexpr size_t kAlternativeSegmentationFirstPageSize = 6;
// 备选切分保护位的词频门槛:备选读音的最佳词权重达到主切分首位的 1/RATIO 以上
// 才配保护位。跨表权重不可直接比(单字是语料计数、词组是小尺度词权),这个量级
// 判断只负责把「真歧义」和「罕见重码」分开:xian -> 西安(55K) 对 先(1.66M),
// 比值 3.3% 仍提升;xie -> 西鄂(6) 对 些(3.75M) 被拒,jiang -> 激昂(23.7K) 对
// 将(2.63M) 同样被拒。
constexpr std::int64_t kAlternativeSegmentationPromotionRatio = 100;
// 同档纠错读法的上下文消解(阶段 1 design):同档备选切分各自过一遍完整管线,
// 跨切分比较词格路径分(log10×1000,同档等罚,分差即纯上下文差)。胜出切分边际
// 达标时接管「主切」角色领衔候选;不达标或词格不可用时与现状逐位一致。
// 解码预算:只解通过静态门槛(上面 1/100 同款)且词库有行的备选,上限 4 个——
// uanli 这类掉声母输入有 10-15 个等权目标,全解会伤 p95。
constexpr size_t kAutocorrectContextDecodeLimit = 4;
// 贵档读法另给的解码预算:它们不要求整词、不过静态门槛,只按 k-best 次序取前几条。
constexpr size_t kCostlierContextDecodeLimit = 2;
// 纠错表权重换算成手误代价(路径分单位):换位 10 -> 2.0(约 1% 的手误率),漏字 11 ->
// 2.2,多字 12 -> 2.4,邻键 13 -> 2.6,生成式 15 -> 3.0。只有跨档比较时它才起作用:
// 同档读法权重相同,代价相互抵消。ponytail: 线性换算;校准路径 = user_journal 的纠错
// 采纳事件(方向阶段 3)。
constexpr double kAutocorrectWeightToLog = 0.2;
// 接管边际:以打分器自身的对数单位计——词格 LM 打分是 log10(1.0 ≈ 10× 句概率),
// 无 LM 的启发式回退是 ln(1.0 ≈ e≈2.7×)。对标 CN105045778B 的 β 型先验比率边际
// (同音校对 β=0.01 → 2.0),手误场景放宽一档。ponytail: 朴素常数;校准路径 =
// user_journal 挖混淆先验(方向阶段 3)。
constexpr double kAutocorrectContextMarginLog = 1.0;
bool is_alpha_vk(ImeKeyCode vk)
{
return vk >= 'A' && vk <= 'Z';
}
std::string remove_delimiters(const std::string &segmented)
{
std::string normalized = segmented;
normalized.erase(std::remove(normalized.begin(), normalized.end(), '\''), normalized.end());
return normalized;
}
// 正字法别名归一:把非标准 ü 拼写改写成词库标准键,输出即进入查询管线的权威拼写
// (查库键、pinyin_segmentation_、调频/造词键因此全部落在标准拼法上)。标准拼音约定:
// ü 保留鱼眼的音节(n/l 系)键写 v(nv/nve/lv/lve),省略鱼眼的(j/q/x/y 系)键写 u
// (ju/jue/qu/.../yue)。nu/lu 是真实音节(怒/路),所以 n/l 系只按段精确匹配 nue/lue,
// 绝不改写 nv/lv/nu/lu。规则本身在 quanpin::canonical_umlaut_spelling。
quanpin::Segments normalize_umlaut_aliases(quanpin::Segments segments)
{
for (auto &segment : segments)
{
segment = quanpin::canonical_umlaut_spelling(std::move(segment));
}
return segments;
}
std::string series_cache_key(const std::string &raw_input, const std::string &segmentation)
{
const char *prefix = raw_input.find('\'') == std::string::npos ? "A:" : "M:";
return prefix + (segmentation.empty() ? raw_input : segmentation);
}
// Folds letters for autocorrect comparisons: lowercases and strips manual
// delimiters. v and u must compare as distinct letters — the ü-style alias
// rewrite is a first-class marking source (the trailing star badge), so
// 'nve' (primary segmentation) differing from typed 'nue' is exactly what
// produces the corrected_from label. The old contract ("aliases never look
// rewritten, hence never marked") was flipped by product decision.
std::string fold_autocorrect_letters(const std::string &text)
{
std::string folded;
folded.reserve(text.size());
for (const char ch : text)
{
if (ch == '\'')
{
continue;
}
folded.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(ch))));
}
return folded;
}
// How many ranked correction cuts feed the query pipeline: the primary cut
// becomes the query key and the rest ride along as alternative segmentations
// for query-time disambiguation (patent CN 101133411 B M3). k=9 is set by the
// phase-4/5 evaluation data: dropped-initial keys such as uan have 10-15
// equally-weighted deletion targets (cuan/duan/guan/.../zuan), and k=3 cut the
// list by table order before reaching the correct reading (quan'li for uanli).
// k=9 lifts R@1/R@3 across the deletion and mixed models with no p95 change.
constexpr std::size_t kAutocorrectCutKBest = 9;
// 合法输入上的纠错读法个数上限:每条都要在按键路径上多解一次词格。
constexpr std::size_t kLegalCorrectionCutLimit = 3;
// 合法输入上每处纠正的手误代价,与词格路径分同单位(加载 sc.lm 时是 log10:2.0 =
// 纠错读法的句概率要高出 100 倍才抵得过一处纠正,约等于把手误率估成 1%)。改变字母
// 数的纠正(多字、漏字)贵一档:合法输入里第二个音节是 e/a/er/an 的真词很多(邪恶
// xie'e、答案 da'an、其二 qi'er),按多一个字母读都能拼出另一个读音;同价时词库扫描
// 第 2 位的纠错从 6 个涨到 34 个,jioa 也被读成多了个 o 的「家」而不是换位的「叫」。
// 3.0 让它们只在语言模型分不低于原读法时才露面。与纠错表权重(换位 10 < 漏字 11 <
// 多字 12)同序。ponytail: 朴素常数;校准路径 = user_journal 的纠错采纳事件(第 2 期)。
constexpr double kLegalInputTranspositionPenaltyLog = 2.0;
constexpr double kLegalInputLengthChangePenaltyLog = 3.0;
// 原读法领衔时,纠错读法离它在这个分差以内才出现在第 2 位;更远的不出现。没有它,
// 每个碰巧能纠成别的音节的输入都会在第 2 位挂一个语言模型都不认的读法。
constexpr double kLegalInputRunnerUpGapLog = 3.0;
// 合法输入的等音节数字面切分(ban'an / ba'nan):最多枚举几条、最多多解几条词格。
// 每多解一条,按键路径上就多一次词格解码;绝大多数输入只有一两处歧义边界。
constexpr size_t kLiteralSegmentationEnumerateLimit = 8;
constexpr size_t kLiteralSegmentationDecodeLimit = 3;
quanpin::Segments cut_syllables(const quanpin::AutocorrectCut &cut)
{
quanpin::Segments syllables;
syllables.reserve(cut.segments.size());
for (const auto &segment : cut.segments)
{
syllables.push_back(segment.syllable);
}
return syllables;
}
SeriesQueryResolution resolve_series_query(const std::string &raw_input, const quanpin::Segments &segments,
unsigned autocorrect_types)
{
SeriesQueryResolution result;
// Guard order matters: the jianpin-shape predicate runs before autocorrect_cut
// so a guarded input never pays for the BFS. Both guards express "the user did
// not mistype" and either one disables the rewrite entirely. The base
// segmentation is deliberately NOT part of the gate: the BFS works on the raw
// letters, so an empty base cut (nothing segmentable, e.g. a dropped initial
// such as uanli for quan'li) is often exactly the input that needs correction.
// With no correction reading the search returns nothing and the plain path is
// kept unchanged.
const bool eligible = autocorrect_types != 0 && raw_input.find('\'') == std::string::npos &&
!quanpin::has_only_complete_pinyin_segments(segments) &&
!quanpin::looks_like_jianpin_spelling(raw_input);
if (eligible)
{
// Fill corrected_segments and the two alternative tiers from cost-ranked
// cuts, optionally appending a trailing jianpin segment (used by the
// head-correction + jianpin-tail composition below). cuts are ranked
// cheapest-first (edge count, then weight): the primary defines the cost
// tier, same-cost readings frequency-compete with it, costlier readings
// stay behind it (see the two vectors' contracts in the header).
const auto populate_from_cuts = [&](const std::vector<quanpin::AutocorrectCut> &cuts,
const std::string &jianpin_tail) {
const auto to_segments = [&](const quanpin::AutocorrectCut &cut) {
quanpin::Segments segs = cut_syllables(cut);
if (!jianpin_tail.empty())
{
segs.push_back(jianpin_tail);
}
return segs;
};
const auto &primary_cut = cuts.front();
result.corrected_segments = to_segments(primary_cut);
result.corrected_weight = primary_cut.weight;
for (std::size_t i = 1; i < cuts.size(); ++i)
{
if (cuts[i].same_cost_as(primary_cut))
{
result.alternative_corrected_cuts.push_back(to_segments(cuts[i]));
}
else
{
result.costlier_corrected_cuts.push_back(to_segments(cuts[i]));
result.costlier_weights.push_back(cuts[i].weight);
result.costlier_promotable.push_back(
std::all_of(cuts[i].segments.begin(), cuts[i].segments.end(), [](const auto &segment) {
return segment.weight <= quanpin::kAutocorrectInsertionWeight;
}));
}
}
};
// 静态优先语义由 k-best 搜索自身保证(见 quanpin_utils.h 契约):最优
// 切分为纯静态时生成切分已在搜索内被丢弃,下面的 head+简拼尾组合路径
// 与 k=1 预编辑投影等调用方同享该保证;生成读法只在静态空间完全无法
// 解释输入时出场。混表竞争的加权仲裁依赖训练权重,留给方向阶段 3。
const auto cuts = quanpin::autocorrect_cut_kbest(raw_input, autocorrect_types, kAutocorrectCutKBest);
if (!cuts.empty())
{
result.corrected_input = true;
populate_from_cuts(cuts, "");
}
else
{
// Compose a correction with a trailing jianpin tail. The k-best search
// only reaches the end when every segment is a complete syllable, so an
// input like "hauzh" (hau typo + zh jianpin) or "hauz" fails outright.
// Retry on the head with the trailing incomplete-syllable prefix removed;
// query_series then handles the tail exactly as it does for the correctly
// spelled "huazh" (hua + zh). Shortest tail first, so the correction
// explains as much of the input as possible.
//
// The head runs with the full mask, so the static neighbour table
// composes just like the structural typos ("shabgz" -> shang + z, from
// the b->n neighbour) and the composed readings are ranked by edit cost
// then dictionary frequency. The neighbour bit used to be masked off
// here because this path already relaxes the "whole input is a legal
// cut" constraint by trusting a speculative jianpin boundary, and
// stacking the widest false-positive surface on top was said to turn
// deletion-shaped input into noise ("shng" -> "sun" + "g"). That sample
// is unreachable in production: the deletion table explains "shng" as
// shang/sheng, so the whole-input k-best is non-empty and this branch is
// never entered for it.
//
// The neighbour bit is not just the static table, though: it also
// carries the generated far-substitution space (see quanpin_utils.h),
// and those reads must NOT compose. A correctly spelled head would be
// rewritten for no reason: "huanzh" (huan + zh) reads huang + h under
// 0xe/0xf because the generated z->g substitution turns the head "huanz"
// into huang. The composed reading takes over the segmentation and the
// plain one is only reachable as an unmarked alternative afterwards, so
// the cost tier cannot arbitrate against it. Accept only a pure-static
// top cut below; k-best static priority makes a generated front exactly
// "no static reading explains the head", so moving on to a longer tail
// (or no composition at all) keeps the plain segmentation for inputs the
// static tables already handle.
//
// The static neighbour table keeps a false-positive surface of its own,
// though: the shortest-tail rule can split an intended two-letter tail, so
// a typed "xish" (xi + sh) composes as xia + h through the s->a neighbour
// and the composed cut owns the segmentation -- the plain reading survives
// only as an unmarked alternative. The cost tier cannot arbitrate that
// class; the fix is the pre-scan below, not a cost knob.
//
// 自洽明文读法优先:head 自己也要过整串那道简拼形状门。只要有一条「合法简拼尾
// + 形如简拼的 head」的切分(tail_len 1..2;head 是完整音节,或完整音节再带一个
// 声母、或一个声母再接完整音节),输入本身就有一个不靠猜测的明文读法,整个组合
// 纠错让位。这与 eligible 用 looks_like_jianpin_spelling 挡整串是同一判据:整串
// 不纠的 "shag"(sha + g),后面多接一个简拼尾也不该纠。它一次盖住几类改写:
// 最短尾把 sh/zh 拆成 s/h 后 head 末位被邻键凑成合法音节(xish -> xia'h、
// lvsh -> lve'h);完整音节接两个简拼声母,head 末位声母被邻键凑成韵尾
// (xibd = xi + b + d,不能经 b->n 读成 xin'd;tamd、zhemd 同理);生成式远键
// (huanzh 的 head huanz 是 huan + z)。别名拼写(jv/lue/nue 等)两种形态都在
// intact 表里,所以判定不需要先做 ü 归一。
//
// 代价:head 碰巧能切成合法音节的结构性手误(xinazh 的 xina = xi'na)在这里
// 不再纠正。这与整串路径一致——单打 xina 走的是合法输入分支,也不会被改成
// xian 领衔。
const auto &prefixes = quanpin::prefix_pinyin_set();
const auto &intact = quanpin::intact_pinyin_set();
std::vector<std::size_t> composable_tails;
for (std::size_t tail_len = 1; tail_len <= 2 && tail_len < raw_input.size(); ++tail_len)
{
// A jianpin tail is an incomplete syllable: a valid pinyin prefix
// that is not itself a complete syllable (e.g. "zh", "z", "h").
const std::string tail = raw_input.substr(raw_input.size() - tail_len);
if (prefixes.count(tail) == 0 || intact.count(tail) != 0)
{
continue;
}
if (quanpin::looks_like_jianpin_spelling(raw_input.substr(0, raw_input.size() - tail_len)))
{
composable_tails.clear();
break;
}
composable_tails.push_back(tail_len);
}
for (const std::size_t tail_len : composable_tails)
{
const std::string tail = raw_input.substr(raw_input.size() - tail_len);
const std::string head = raw_input.substr(0, raw_input.size() - tail_len);
const auto head_cuts = quanpin::autocorrect_cut_kbest(head, autocorrect_types, kAutocorrectCutKBest);
// 静态优先由 k-best 自身保证:front 带 has_generated 等价于「没有任何
// 静态读法能解释 head」,此时不组合(生成式远键在 head 上净负,见上)。
if (head_cuts.empty() || head_cuts.front().has_generated)
{
continue;
}
result.corrected_input = true;
populate_from_cuts(head_cuts, tail);
break;
}
}
}
else if (autocorrect_types != 0)
{
// 合法输入:上面的闸把它整个挡在纠错外。这里只列出纠错读法,主切不动,
// 取舍在 arbitrate_legal_corrections 按整句打分。
result.legal_corrected_cuts =
quanpin::legal_input_correction_cuts(raw_input, segments, autocorrect_types, kLegalCorrectionCutLimit);
}
// Both branches rebuild the segmentation string from segments: they already
// carry the canonical (alias-normalised) spelling, while a caller-passed
// explicit string could keep the alias spelling — the query would succeed
// yet nothing would look rewritten, so alias inputs with manual delimiters
// ("nue'hao") could never get marked. Delimiters sit on syllable boundaries,
// so split/join round-trips them unchanged ("nu'e" stays "nu'e").
result.segmentation = result.corrected_input ? quanpin::join_segments(result.corrected_segments)
: (segments.empty() ? raw_input : quanpin::join_segments(segments));
// 合法输入带纠错读法时候选列表也变了,同样要另占缓存槽:关掉纠错后不能读到它。
const char *cache_prefix = result.corrected_input ? "C:" : (result.legal_corrected_cuts.empty() ? "" : "L:");
result.cache_key = cache_prefix + series_cache_key(raw_input, result.segmentation);
// The literal fallback depends on typed letters, even when two typos share a correction key.
if (result.corrected_input)
result.cache_key += '\x1f' + raw_input;
return result;
}
// 输入能切成词库里的真词时,首位留给这个字面整词:纠错与改写字母的别名读法
// (nia 的 nai「奶」、yongan 复制辅音读成 yong'gan「勇敢」)最多排第 2。字面整词
// = 词库行、读音字母与敲的字母逐个相同;同一串字母的另一种切分(fangan 的方案/
// 反感、xian 的先/西安)字母没变,照旧按权重竞争,不受这里约束。字面切分只拼得出
// 单字或整句时(zi'a'zhe'li'ya、ji'o'a)没有字面整词,纠错照常可以领衔。
std::string candidate_letters(const WordItem &item)
{
return fold_autocorrect_letters(item.canonical_pinyin.empty() ? item.pinyin : item.canonical_pinyin);
}
// 字面整词:词库行、读音字母与敲的字母逐个相同(任一种切分都算)。
std::vector<WordItem>::iterator find_literal_whole_word(std::vector<WordItem> &candidates, const std::string &raw_input)
{
const std::string typed = fold_autocorrect_letters(raw_input);
return std::find_if(candidates.begin(), candidates.end(), [&](const WordItem &item) {
return (item.source == CandidateSource::Database || item.source == CandidateSource::UserDatabase) &&
candidate_letters(item) == typed;
});
}
void keep_literal_whole_word_first(std::vector<WordItem> &candidates, const std::string &raw_input)
{
if (candidates.empty() || candidate_letters(candidates.front()) == fold_autocorrect_letters(raw_input))
{
return;
}
const auto literal = find_literal_whole_word(candidates, raw_input);
if (literal != candidates.end())
{
std::rotate(candidates.begin(), literal, literal + 1);
}
}
unsigned literal_mixed_match(const WordItem &item, const std::string &raw_input)
{
return item.source == CandidateSource::Database || item.source == CandidateSource::UserDatabase
? quanpin::literal_mixed_pinyin_match(raw_input, item.pinyin, item.canonical_pinyin)
: 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)
{
return item.canonical_pinyin == key || (item.sentence_association && item.pinyin == key);
}
void keep_literal_mixed_words_first(std::vector<WordItem> &candidates, const std::string &raw_input)
{
// Stable groups preserve dictionary/user order without manufacturing frequency weights.
const auto whole_end = std::stable_partition(candidates.begin(), candidates.end(), [&](const WordItem &item) {
return literal_mixed_match(item, raw_input) == 1;
});
std::stable_partition(whole_end, candidates.end(),
[&](const WordItem &item) { return literal_mixed_match(item, raw_input) == 2; });
}
std::string escape_sql_text(std::string text)
{
size_t pos = 0;
while ((pos = text.find('\'', pos)) != std::string::npos)
{
text.insert(pos, 1, '\'');
pos += 2;
}
return text;
}
} // namespace
QuanpinDictionary::QuanpinDictionary(std::string db_path, metasequoia::RuntimePaths paths)
: cache_(128), series_cache_(128), segmentation_cache_(128), prefix_miss_cache_(128), resolution_cache_(128),
lattice_best_path_cache_(64), paths_(std::move(paths)),
decoder_(paths_.resource(metasequoia::assets::pinyin_model),
paths_.user(metasequoia::assets::pinyin_user_dictionary)),
language_model_(&ngram::shared_language_model(paths_.resource(metasequoia::assets::language_model))),
neural_keyboard_model_(metasequoia::path_to_utf8(paths_.resource(metasequoia::assets::neural_model_keyboard))),
db_path_(db_path.empty() ? metasequoia::path_to_utf8(paths_.dictionary(metasequoia::assets::main_dictionary))
: std::move(db_path))
{
const int exit = sqlite3_open(db_path_.c_str(), &db_);
if (exit != SQLITE_OK)
{
(void)0;
}
else
{
// A learning write (or the settings app) holds the write lock for a few
// milliseconds at commit; without a busy timeout a query landing in that
// window fails with SQLITE_BUSY and the page comes back empty.
sqlite3_busy_timeout(db_, quanpin::kDictionaryBusyTimeoutMs);
}
quanpin::warm_up(db_, statement_cache_);
reset_cache_if_database_changed();
}
QuanpinDictionary::~QuanpinDictionary()
{
for (auto &[sql, stmt] : statement_cache_)
{
if (stmt != nullptr)
{
sqlite3_finalize(stmt);
}
}
if (db_ != nullptr)
{
sqlite3_close(db_);
}
}
std::vector<WordItem> QuanpinDictionary::query_exact(const std::string &raw_input, const std::string &segmentation,
unsigned autocorrect_types)
{
if (raw_input.empty())
{
current_candidate_list_.clear();
return {};
}
pinyin_sequence_ = raw_input;
const auto segments =
resolve_segments(raw_input, segmentation, quanpin::correction_aliases_enabled(autocorrect_types));
// Typing autocorrection: when the spelling is not a legal pinyin
// combination (the correction cut already fell back to greedy), try to
// rewrite the whole string into legal syllables. The corrected
// segmentation becomes the primary key so that selection and weight
// updates land on the right dictionary entries, and the original
// (garbage-leaning) candidates stay behind as a fallback tail.
// Memoize the k-best correction search: it is the expensive part of every
// keystroke, yet a pure function of this input tuple, so a re-typed or
// backspaced prefix should reuse it instead of re-running the k=9 beam.
const std::string resolution_key = std::to_string(autocorrect_types) + '\x1f' + raw_input + '\x1f' + segmentation;
SeriesQueryResolution resolution;
if (const auto *cached_resolution = resolution_cache_.find(resolution_key))
{
resolution = *cached_resolution;
}
else
{
resolution = resolve_series_query(raw_input, segments, autocorrect_types);
resolution_cache_.insert(resolution_key, resolution);
}
pinyin_segmentation_ = resolution.segmentation;
// The alternative readings must be published even on the cache-hit path:
// mark_autocorrect_candidates runs after every query, cached or not.
pinyin_alternative_segmentations_.clear();
for (const auto &alternative : resolution.alternative_corrected_cuts)
{
pinyin_alternative_segmentations_.push_back(quanpin::join_segments(alternative));
}
// Costlier readings are still corrections, so they must be marked as such
// even though they rank below the primary tier (see the tail append below).
for (const auto &alternative : resolution.costlier_corrected_cuts)
{
pinyin_alternative_segmentations_.push_back(quanpin::join_segments(alternative));
}
// 合法输入上的纠错读法不进这张表:它们多半会被裁决丢掉,而标记按读音字母匹配,
// 丢掉的读法会把原读法里字母恰好相同的前缀单字误标(xiee 的 xie 读法让 写/些/血
// 全带上纠错星标)。真正用上的那条在 arbitrate_legal_corrections 里直接打标。
// Autocorrected results get their own cache slot so they never leak the
// fallback tail into plain (correct) spellings sharing the same key.
if (series_cache_.find(resolution.cache_key))
{
reset_cache_if_database_changed();
if (const auto *cached = series_cache_.find(resolution.cache_key))
{
current_candidate_list_ = *cached;
return current_candidate_list_;
}
}
std::vector<quanpin::Segments> alternative_segmentations;
std::unordered_set<std::string> seen_segmentations = {pinyin_segmentation_};
// Keep costlier readings out of the frequency-competing merge tier; they are
// appended after it below so dictionary frequency never lifts a dearer
// correction above the cheaper primary. Seeding "seen" here also blocks the
// greedy correction paths from re-introducing them into the merge tier.
for (const auto &costlier : resolution.costlier_corrected_cuts)
{
seen_segmentations.insert(quanpin::join_segments(costlier));
}
const auto append_alternative = [&](const quanpin::Segments &candidate) {
const std::string key = quanpin::join_segments(candidate);
if (!key.empty() && seen_segmentations.insert(key).second &&
alternative_segmentations.size() < kSyllableGraphPathLimit)
{
alternative_segmentations.push_back(candidate);
}
};
// Correction-mode segmentation is part of autocorrection and must follow the
// same switch. query() defaults autocorrect_types to 0, so running this
// unconditionally changed the default behaviour for every caller: a misspelled
// input such as "sahng" started offering the corrected candidate even with
// autocorrection explicitly turned off.
if (autocorrect_types != 0)
{
// Correction alternatives first: they explain the letters the user
// actually typed, so they outrank the phonetic-shape alias readings that
// follow (the alias layer rewrites legal-looking spellings regardless).
for (const auto &candidate : resolution.alternative_corrected_cuts)
{
append_alternative(candidate);
}
const auto correction_paths = quanpin::cut_pinyin_by_mode(
raw_input, "correction", quanpin::correction_aliases_enabled(autocorrect_types));
for (const auto &candidate : correction_paths)
{
append_alternative(candidate);
}
}
if (raw_input.find('\'') == std::string::npos && segments.size() <= kMaxSyllablesForMultipleSegmentations &&
quanpin::has_only_complete_pinyin_segments(segments))
{
const auto complete_paths = quanpin::enumerate_complete_segmentations(quanpin::build_syllable_graph(raw_input),
kSyllableGraphPathLimit);
for (const auto &candidate : complete_paths)
{
append_alternative(candidate);
}
}
std::vector<WordItem> result;
std::vector<WordItem> literal_mixed;
if (resolution.corrected_input)
{
result = query_series(raw_input, pinyin_segmentation_, resolution.corrected_segments);
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) || literal_jianpin_match(item, raw_input))
literal_mixed.push_back(item);
}
append_unique_words(result, literal);
// 上下文消解(阶段 1):任一整句来源开着时直接解出主切、同档备选与前几条贵档
// 读法的最佳路径分,扣掉各自的手误代价后裁决,静态词频只承担同档的解码前置过滤。
// 门控看 any_sentence_source 而不是 word_lattice:只开万象重排(不显示 Trigram 整句)
// 时词格照样在内部解码,shiideya 曾因此退回静态排序、让「是爹呀」领衔。
// 不能从候选列表读分——
// merge_lattice_candidates 会把与已有候选同词的生成行去重掉(already 集),
// 最优路径通常恰好就是词库首行,列表里根本看不到它。整句来源全关或边际不足
// 时,下面的一切与改造前逐位一致(降级矩阵见任务 design.md)。
bool context_reordered = false;
if (sentence_association_.any_sentence_source() &&
(!resolution.alternative_corrected_cuts.empty() || !resolution.costlier_corrected_cuts.empty()) &&
resolution.corrected_segments.size() >= 2 &&
quanpin::has_only_complete_pinyin_segments(resolution.corrected_segments))
{
const auto best_path_score = [this](const quanpin::Segments &cut) -> std::optional<double> {
if (cut.size() < 2)
{
return std::nullopt;
}
return lattice_best_path_score(cut);
};
const auto primary_score = best_path_score(resolution.corrected_segments);
if (primary_score.has_value())
{
// 前置过滤沿用保护位同款 1/100 量级:今天就赢不了晋升的备选不付解码钱。
// 基准必须与 merge_alternative_segmentations 的 primary_top_weight 同源,
// 即取正读**整键**命中的首位权重、没有整键行时取 0。不能取 result.front():
// result 是 query_series 的完整输出,头部可能是高权重前缀单字(真实词库里
// 单字是 1e6+ 的语料计数,见 heuristic_log_prob)或生成的整句。正读两音节
// 在词库里没有整词、词格只能用单字拼出路径时,那正是重排价值最高的场景,拿单字
// 当基准会在解码之前就把正常备选全筛掉,重排悄悄不生效。同参调用
// query_single_path 走缓存,不额外查库。
const std::string primary_key = quanpin::join_segments(resolution.corrected_segments);
const auto primary_full = query_single_path(raw_input, primary_key, resolution.corrected_segments);
const std::int64_t primary_top_weight = primary_full.empty() ? 0 : primary_full.front().weight;
const auto alternative_items = quanpin::query_exact_segmentations_keyed_flat(
resolution.alternative_corrected_cuts, db_, statement_cache_,
kAlternativeSegmentationCandidateLimit);
// 每个备选切分的最佳词权重:行按权重降序返回,emplace 只留首行。
std::unordered_map<std::string, std::int64_t> best_weight_by_key;
best_weight_by_key.reserve(alternative_items.size());
for (const auto &item : alternative_items)
{
best_weight_by_key.emplace(item.key, item.weight);
}
struct DecodeCandidate
{
std::int64_t top_weight = 0;
quanpin::Segments cut;
double penalty = 0.0;
};
const double primary_penalty = kAutocorrectWeightToLog * resolution.corrected_weight;
std::vector<DecodeCandidate> decode_pool;
for (const auto &cut : resolution.alternative_corrected_cuts)
{
const auto found = best_weight_by_key.find(quanpin::join_segments(cut));
if (found == best_weight_by_key.end() ||
found->second * kAlternativeSegmentationPromotionRatio < primary_top_weight)
{
continue;
}
decode_pool.push_back({found->second, cut, primary_penalty});
}
std::stable_sort(decode_pool.begin(), decode_pool.end(),
[](const auto &lhs, const auto &rhs) { return lhs.top_weight > rhs.top_weight; });
if (decode_pool.size() > kAutocorrectContextDecodeLimit)
{
decode_pool.resize(kAutocorrectContextDecodeLimit);
}
// 贵档读法按 k-best 次序再解几条,不要求词库有整词:shiideya 的多字读法
// shi'de'ya 拼不出整词,却是比换位读法 shi'die'ya(是爹呀)通顺得多的句子。
// 它们多付的手误代价按纠错表权重差计入总分。只收结构性手误的读法,见
// SeriesQueryResolution::costlier_promotable。
size_t costlier_decoded = 0;
for (size_t i = 0;
i < resolution.costlier_corrected_cuts.size() && costlier_decoded < kCostlierContextDecodeLimit;
++i)
{
if (!resolution.costlier_promotable[i])
{
continue;
}
++costlier_decoded;
const auto &cut = resolution.costlier_corrected_cuts[i];
const auto found = best_weight_by_key.find(quanpin::join_segments(cut));
decode_pool.push_back({found == best_weight_by_key.end() ? 0 : found->second, cut,
kAutocorrectWeightToLog * resolution.costlier_weights[i]});
}
// 第一遍只解路径分选胜者,只为胜出切分付完整管线的钱。总分 = 路径分 - 手误代价,
// 同档读法代价相同,比的仍是纯上下文差。
std::optional<double> winner_total;
std::int64_t winner_top_weight = 0;
quanpin::Segments winner_segments;
for (const auto &candidate : decode_pool)
{
const auto cut_score = best_path_score(candidate.cut);
if (!cut_score.has_value())
{
continue;
}
const double total = *cut_score - candidate.penalty;
if (!winner_total.has_value() || total > *winner_total)
{
winner_total = total;
winner_top_weight = candidate.top_weight;
winner_segments = candidate.cut;
}
}
// 用户在主切读音上的选择优先于上下文。调频只改词库权重,而加载 LM 时
// 路径分里的权重只剩 dictionary_tiebreak(≤0.07)和 reading_prior,翻不过
// 1.0 的边际:不拦的话,用户每次都选班长,办证系照样每次领衔。调频把选中词
// 抬到它越过的词之上,所以「主切整键首位权重不低于胜出切分首位」正是用户已经
// 把主切读音选到前面的样子;再要求主切键有用户 upsert,把这条限制在用户
// 表达过偏好的键上,出货词频本身仍交给上下文裁决。不接管时走下面的静态
// 合并,它按权重排序,调频照常生效。只在边际达标后才查日志,不给每次按键加查询。
const auto user_prefers_primary = [&] {
return primary_top_weight >= winner_top_weight && user_prefers_reading(primary_key);
};
if (winner_total.has_value() &&
*winner_total - (*primary_score - primary_penalty) >= kAutocorrectContextMarginLog &&
!user_prefers_primary())
{
// 边际达标:胜出切分领衔。merge_alternative_segmentations 假定传入
// result 的头部是主切词库行,所以这里仍以主切为锚组装其余列表(从
// 合并池摘掉胜出切分),再把胜出列表接到最前。胜出列表占满首页时,
// 原保护位钉的名次随整体后移——首页本就该是胜出读音的词。
std::vector<quanpin::Segments> remaining_alternatives;
remaining_alternatives.reserve(alternative_segmentations.size());
const std::string winner_key = quanpin::join_segments(winner_segments);
for (const auto &alternative : alternative_segmentations)
{
if (quanpin::join_segments(alternative) != winner_key)
{
remaining_alternatives.push_back(alternative);
}
}
std::vector<WordItem> rest =
merge_alternative_segmentations(raw_input, pinyin_segmentation_, resolution.corrected_segments,
remaining_alternatives, std::move(result));
// query_series 的 count 从整键递减到 1,返回的不只是胜出切分的整键行,还有它
// 首音节的全部单字。整句与词格行跟整键行同键——append_ime_fallback 用完整
// segmentation 填 canonical_pinyin,词格 select_distinct 用 path->key——所以
// 按 canonical_pinyin == winner_key 能干净地把前缀行摘出去。
//
// 同档胜者只能出自用 query_exact_segmentations_keyed_flat 收精确键的那段池,
// 整键档走 query_single_cut_keyed 的精确键分支,canonical 就是 winner_key。
// 贵档胜者可能没有整词(shi'de'ya),整键档会退到前缀区间扫描,那种行的 key
// 是别的读音,按这个判据沉到前缀块——它们本来就不是用户敲的读音,沉下去正好;
// 整句行仍带 winner_key,照样领衔。
//
// 不切开会破坏 merge_alternative_segmentations 的既有分层(merged_full 在前、
// 前缀作为 remaining 追加):query_series(ban'zheng) 先出 办证/辩证/整句,再出
// 班/办/半/般…,而班长、搬账这些同键位整词在 rest 里、排在单字之后,整组被挤
// 出首页。这里恢复同一分层:整键块、rest、前缀块。
std::vector<WordItem> winner_full;
std::vector<WordItem> winner_prefix;
for (auto &item : query_series(raw_input, winner_key, winner_segments))
{
(reads_full_key(item, winner_key) ? winner_full : winner_prefix).push_back(std::move(item));
}
// 贵档胜者没有整词、也没有整句行时(整句来源只开了重排、模型没载入),
// 整键块是空的;不能让主切的行顶回首位,由胜者的最长前缀词领衔。
if (winner_full.empty())
{
winner_full.swap(winner_prefix);
}
result = std::move(winner_full);
append_unique_words(result, std::move(rest));
append_unique_words(result, std::move(winner_prefix));
context_reordered = true;
}
}
}
// Query-time disambiguation (patent M3): the alternative readings of the
// same typo compete with the primary cut under dictionary word
// frequency; the best one keeps a protected slot near the top.
if (!context_reordered && !alternative_segmentations.empty())
{
result = merge_alternative_segmentations(raw_input, pinyin_segmentation_, resolution.corrected_segments,
alternative_segmentations, std::move(result));
}
// Costlier readings sit below the whole primary cost tier: append them
// last so a high-frequency dearer correction (gau -> gai, weight 13)
// can never precede the cheaper one (gau -> gua, weight 10) on
// dictionary frequency alone. Only the context rerank above may lift one,
// and only after paying its larger typo cost.
for (const auto &costlier : resolution.costlier_corrected_cuts)
{
append_unique_words(result, query_series(raw_input, quanpin::join_segments(costlier), costlier));
}
}
else
{
result = query_series(raw_input, pinyin_segmentation_, segments);
for (const auto &item : result)
{
if (literal_mixed_match(item, raw_input))
literal_mixed.push_back(item);
}
if (!alternative_segmentations.empty())
{
result = merge_alternative_segmentations(raw_input, pinyin_segmentation_, segments,
alternative_segmentations, std::move(result));
}
// 字面切分先争出领衔,纠错读法再与领衔的那条比:纠错胜出时第 2 位是得分最高的字面读法。
quanpin::Segments leading_segments;
result = arbitrate_literal_segmentations(raw_input, segments, std::move(result), leading_segments);
if (!resolution.legal_corrected_cuts.empty())
{
result = arbitrate_legal_corrections(raw_input, leading_segments, resolution.legal_corrected_cuts,
std::move(result));
}
keep_literal_whole_word_first(result, raw_input);
}
if (!literal_mixed.empty())
{
// Merge after reranking, with original metadata and frequency order winning same-word deduplication.
keep_literal_mixed_words_first(literal_mixed, raw_input);
append_unique_words(literal_mixed, result);
result = std::move(literal_mixed);
}
series_cache_.insert(resolution.cache_key, result);
current_candidate_list_ = result;
return current_candidate_list_;
}
std::optional<WordItem> QuanpinDictionary::find_candidate(const std::string &key, const std::string &value)
{
const std::string table = quanpin::build_table_name(quanpin::split_segments(key));
if (!db_ || table.empty())
return std::nullopt;
sqlite3_stmt *stmt = nullptr;
const std::string sql = "SELECT weight FROM \"" + table + "\" WHERE key=?1 AND value=?2 LIMIT 1";
if (sqlite3_prepare_v2(db_, sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
std::unique_ptr<sqlite3_stmt, decltype(&sqlite3_finalize)> guard(stmt, sqlite3_finalize);
if (sqlite3_bind_text(stmt, 1, key.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_bind_text(stmt, 2, value.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK ||
sqlite3_step(stmt) != SQLITE_ROW)
return std::nullopt;
return WordItem(key, value, sqlite3_column_int64(stmt, 0), CandidateSource::Database, key);
}
bool QuanpinDictionary::can_expand_initial_candidates(const std::string &code, const std::vector<WordItem> &candidates)
{
if (code.size() != 1)
{
return false;
}
// 首字母候选第一次只取 24 条;正好截满才说明库里可能还有,展开过之后数目就不再是 24。
constexpr size_t kInitialCandidateLimit = 24;
return static_cast<size_t>(std::count_if(candidates.begin(), candidates.end(), [&](const WordItem &item) {
return item.source == CandidateSource::Database && item.pinyin == code;
})) == kInitialCandidateLimit;
}
bool QuanpinDictionary::expand_initial_candidates(const std::string &code, std::vector<WordItem> &candidates)
{
if (!can_expand_initial_candidates(code, candidates))
{
return false;
}
const auto is_limited_initial = [&](const WordItem &item) {
return item.source == CandidateSource::Database && item.pinyin == code;
};
const size_t limited_count =
static_cast<size_t>(std::count_if(candidates.begin(), candidates.end(), is_limited_initial));
auto expanded = query_initial(code, INT_MAX);
if (expanded.size() <= limited_count)
{
return false;
}
for (auto &item : expanded)
{
item.canonical_pinyin = item.pinyin;
item.pinyin = code;
}
std::vector<WordItem> merged;
merged.reserve(candidates.size() - limited_count + expanded.size());
bool inserted = false;
for (auto &item : candidates)
{
if (is_limited_initial(item))
{
if (!inserted)
{
merged.insert(merged.end(), expanded.begin(), expanded.end());
inserted = true;
}
continue;
}
merged.push_back(std::move(item));
}
candidates = std::move(merged);
cache_.insert(code, expanded);
series_cache_.insert(series_cache_key(pinyin_sequence_, pinyin_segmentation_), candidates);
current_candidate_list_ = candidates;
return true;
}
std::vector<WordItem> QuanpinDictionary::query_series(const std::string &raw_input, const std::string &segmentation,
const quanpin::Segments &segments)
{
if (segments.empty())
{
return query_single_path(raw_input, segmentation, segments);
}
std::vector<WordItem> result;
for (size_t count = segments.size(); count > 0; --count)
{
quanpin::Segments partial_segments(segments.begin(), segments.begin() + static_cast<std::ptrdiff_t>(count));
const std::string partial_segmentation = quanpin::join_segments(partial_segments);
const std::string partial_input = remove_delimiters(partial_segmentation);
// 整句联想只对完整输入出一句。前缀查不到词时 query_single_path 会补一条 Google 整句,
// 留着就会在长句后面缀上一串越来越短的子串整句;前缀只取词库里真有的词,
// 查不到就交给更短的前缀。
auto partial_result = count < segments.size()
? query_prefix_path(partial_input, partial_segmentation, partial_segments)
: query_single_path(partial_input, partial_segmentation, partial_segments);
// 前缀命中的 cache_ 条目可能是这串当初作为完整输入时查的,带着兜底整句。
if (count < segments.size())
{
partial_result.erase(std::remove_if(partial_result.begin(), partial_result.end(),
[](const WordItem &item) {
return item.source == CandidateSource::Fallback &&
item.sentence_association;
}),
partial_result.end());
}
result.insert(result.end(), partial_result.begin(), partial_result.end());
}
if (segments.size() >= 2 && quanpin::has_only_complete_pinyin_segments(segments))
{
// 两条整句来源各出一句:词格(kenlm 三元模型打分)在前,Google 解码器在后。
// 词格换成 sc.lm 之后整体比 Google 那条准,所以由它占首位;Google 那条保留,
// 它在词格覆盖不到的输入上仍然有用。两边都只出一句,免得近似重复的整句把
// 候选页挤满。
//
// 两条都插在 generated_sentence_insert_position 给的位置上,也就是开头那串
// 「整串拼音精确命中词库」的候选之后:整句是猜出来的,不该压过词库里真有的
// 短语。相对次序仍由插入点决定——merge_lattice_candidates 算出的位置就是这条
// Fallback 所在的下标,词格随后落在它之前。
//
// Google 解码器自己重新切分,这正是它的价值所在(词格覆盖不到的输入靠它),
// 所以默认喂裸串。但手动分隔符是用户明确表达的切分意图,去掉之后 nu'e 会被
// 它读成 nüe 而出「虐」。撇号本来就是它认的音节分隔符(双拼那条一直是连着
// 撇号传的),手打分隔符时原样传下去即可。
//
// 不管走哪条,送进去之前都要把 ü 换成它认的写法:它的音节表里只有 nue/lue,
// nve 会被拆成 nv + e。见 quanpin::to_google_spelling。
// Google 整句(Fallback):仅在开关打开时才解码、才出候选。
if (sentence_association_.google)
{
const std::string google_input =
raw_input.find('\'') != std::string::npos
? quanpin::to_google_spelling(raw_input)
: remove_delimiters(quanpin::to_google_spelling(segmentation.empty() ? raw_input : segmentation));
const std::string google_sentence = search_sentence_from_ime_engine(google_input);
if (!google_sentence.empty())
{
const auto duplicate = std::find_if(result.begin(), result.end(),
[&](const WordItem &item) { return item.word == google_sentence; });
if (duplicate == result.end())
{
// 整句 fallback 必须带上 canonical quanpin,否则以它结尾的造词无法落库:
// update_creating_word_progress 依赖 canonical_pinyin 才能拼出完整读音。
// segmentation 为空时保持为空,交由既有逻辑判定为不可落库。
const size_t insert_at = quanpin::generated_sentence_insert_position(result, segments);
WordItem sentence(segmentation.empty() ? raw_input : segmentation, google_sentence, 1,
CandidateSource::Fallback, segmentation);
sentence.sentence_association = true;
result.insert(result.begin() + static_cast<std::ptrdiff_t>(insert_at), std::move(sentence));
}
}
}
// 词格给 Trigram 候选和神经模型共用:只开神经时仍在内部解出 n-best 供它重排,
// 但不把词格自己的首选显示出来。神经模型不自己造句,只在这批路径中选一句。
std::vector<quanpin::SourcedLatticeReranker> neural_rerankers;
if (sentence_association_.neural_keyboard && neural_keyboard_model_.get() != nullptr)
{
neural_rerankers.push_back({quanpin::make_neural_reranker(neural_keyboard_model_.get(), rescoring_context_),
CandidateSource::NeuralKeyboard});
}
if (sentence_association_.neural_desktop && neural_desktop_model_.get() != nullptr)
{
neural_rerankers.push_back({quanpin::make_neural_reranker(neural_desktop_model_.get(), rescoring_context_),
CandidateSource::NeuralDesktop});
}
// 万象语法模型不再单独重排:加成分只在词格解码期注入一次,〔万象〕行即解码最优路径
// (见 WordLatticeOptions::include_collocation_best)。
quanpin::WordLatticeOptions lattice_options;
const bool has_collocation =
collocation_db_ != nullptr && collocation_db_->valid() && sentence_association_.collocation_weight != 0.0;
// 神经模型和去重补位都需要完整候选池;否则只解最优的一条。
const bool needs_alternatives = !neural_rerankers.empty() || (sentence_association_.word_lattice &&
sentence_association_.show_next_on_duplicate);
lattice_options.nbest = needs_alternatives ? static_cast<int>(neural::RerankOptions{}.max_paths) : 1;
lattice_options.include_lattice_best = sentence_association_.word_lattice;
lattice_options.include_collocation_best = sentence_association_.collocation_rerank && has_collocation;
lattice_options.show_next_on_duplicate = sentence_association_.show_next_on_duplicate;
lattice_options.language_model = language_model_;
// 万象语法模型的搭配项:逐边叠加字级搭配分,与三元模型在同一 Viterbi 里竞争。
// query() 是自然对数域,乘 kLnToLog10 换到词格的 log10 域;六参数来自配置。
if (has_collocation)
{
lattice_options.collocation_scorer = [db = collocation_db_,
config = sentence_association_.collocation_grammar](
std::string_view tail, std::string_view word, bool is_rear) {
return db->query(std::string(tail), std::string(word), is_rear, config) * quanpin::kLnToLog10;
};
lattice_options.collocation_weight = sentence_association_.collocation_weight;
}
if (sentence_association_.word_lattice || !neural_rerankers.empty() || lattice_options.include_collocation_best)
{
quanpin::merge_lattice_candidates(result, segments, lattice_lookup(lattice_options.span_limit),
segmentation.empty() ? raw_input : segmentation, lattice_options,
neural_rerankers);
}
}
if (result.size() < kSparsePinyinFallbackThreshold)
{
result = append_sparse_pinyin_fallbacks(segments, std::move(result));
}
return result;
}
std::vector<WordItem> QuanpinDictionary::query_single_path(const std::string &raw_input,
const std::string &segmentation,
const quanpin::Segments &segments)
{
const std::string cache_key = segmentation.empty() ? raw_input : segmentation;
if (const auto *cached = cache_.find(cache_key))
{
return *cached;
}
std::vector<WordItem> result = query_database(segments, segmentation);
result = append_ime_fallback(raw_input, segmentation, std::move(result));
cache_.insert(cache_key, result);
return result;
}
std::vector<WordItem> QuanpinDictionary::query_prefix_path(const std::string &raw_input,
const std::string &segmentation,
const quanpin::Segments &segments)
{
const std::string cache_key = segmentation.empty() ? raw_input : segmentation;
if (const auto *cached = cache_.find(cache_key))
{
return *cached;
}
if (prefix_miss_cache_.find(cache_key))
{
return {};
}
std::vector<WordItem> result = query_database(segments, segmentation);
if (result.empty())
{
prefix_miss_cache_.insert(cache_key, true);
}
else
{
// 兜底整句只在词库查不到时才补,所以这正是 query_single_path 会写入的值。
cache_.insert(cache_key, result);
}
return result;
}
quanpin::Segments QuanpinDictionary::resolve_segments(const std::string &raw_input, const std::string &segmentation,
bool include_correction_aliases)
{
// 单一归一咽喉点:显式 segmentation 与自动切分两条路径都过这里,进入查询
// 管线的 segments 由此保证已归一恰一次。查库、调频、造词各调用点因此
// 不必各自处理别名。
quanpin::Segments segments = segmentation.empty() ? get_or_compute_segments(raw_input, include_correction_aliases)
: quanpin::split_segments(segmentation);
return normalize_umlaut_aliases(std::move(segments));
}
quanpin::Segments QuanpinDictionary::get_or_compute_segments(const std::string &raw_input,
bool include_correction_aliases)
{
// 两种掩码状态下同名输入的切分可能不同(手误别名层在 mask != 0 时才跑),缓存键
// 带上该标志,避免「开着纠错算出来的 shang」被关掉纠错的同名查询复用。
const std::string cache_key = (include_correction_aliases ? "1:" : "0:") + raw_input;
if (const auto *cached = segmentation_cache_.find(cache_key))
{
return *cached;
}
const auto cuts = quanpin::cut_pinyin_by_mode(raw_input, "correction", include_correction_aliases);
const auto segments = cuts.empty() ? quanpin::Segments{} : cuts.front();
segmentation_cache_.insert(cache_key, segments);
return segments;
}
std::vector<quanpin::Segments> QuanpinDictionary::cut_for_write(const std::string &pinyin) const
{
return quanpin::cut_pinyin_by_mode(pinyin, "correction", correction_aliases_enabled_);
}
int QuanpinDictionary::handleVkCode(ImeKeyCode vk, ImeModifierMask modifiers_down, ImeCharacter wch)
{
(void)modifiers_down;
if (vk == ImeKey::Backspace)
{
if (!pinyin_sequence_.empty())
{
pinyin_sequence_.pop_back();
}
}
else if (vk == ImeKey::Escape || vk == ImeKey::Return || vk == ImeKey::Space)
{
reset_state();
return OK;
}
else if (vk == ImeKey::Apostrophe)
{
pinyin_sequence_.push_back('\'');
}
else if (is_alpha_vk(vk))
{
if (wch >= u'A' && wch <= u'Z')
{
pinyin_sequence_.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(wch))));
}
else if (wch >= u'a' && wch <= u'z')
{
pinyin_sequence_.push_back(static_cast<char>(wch));
}
else
{
pinyin_sequence_.push_back(static_cast<char>(vk + ('a' - 'A')));
}
}
query(pinyin_sequence_);
return OK;
}
std::vector<WordItem> QuanpinDictionary::query_database(const quanpin::Segments &segments,
const std::string &segmentation)
{
if (db_ == nullptr)
{
return {};
}
try
{
if (segments.size() == 1 && segments.front().size() == 1)
{
constexpr int kInitialCandidateLimit = 24;
auto result = query_initial(segments.front(), kInitialCandidateLimit);
const std::string matched_code = segmentation.empty() ? segments.front() : segmentation;
for (auto &item : result)
{
item.canonical_pinyin = item.pinyin;
item.pinyin = matched_code;
}
return result;
}
// segments 已在 resolve_segments 归一,直接按标准键查库。
const auto flat_items = quanpin::query_segments_keyed_flat(segments, db_, statement_cache_, INT_MAX);
std::vector<WordItem> result;
result.reserve(flat_items.size());
const std::string code = segmentation.empty() ? quanpin::join_segments(segments) : segmentation;
for (const auto &item : flat_items)
{
result.emplace_back(code, item.value, item.weight, CandidateSource::Database, item.key);
}
return result;
}
catch (const std::exception &)
{
(void)0;
return {};
}
}
std::vector<WordItem> QuanpinDictionary::query_initial(const std::string &code, int limit)
{
if (db_ == nullptr || code.size() != 1)
{
return {};
}
const auto rows = quanpin::query_initial(db_, code, limit);
std::vector<WordItem> result;
result.reserve(rows.size());
for (const auto &item : rows)
{
result.emplace_back(item.key, item.value, item.weight, CandidateSource::Database, item.key);
}
return result;
}
std::vector<WordItem> QuanpinDictionary::merge_alternative_segmentations(
const std::string &raw_input, const std::string &primary_segmentation, const quanpin::Segments &primary_segments,
const std::vector<quanpin::Segments> &alternative_segmentations, std::vector<WordItem> result)
{
const auto alternative_items = quanpin::query_exact_segmentations_keyed_flat(
alternative_segmentations, db_, statement_cache_, kAlternativeSegmentationCandidateLimit);
if (alternative_items.empty())
{
return result;
}
const auto primary_full = query_single_path(raw_input, primary_segmentation, primary_segments);
std::vector<WordItem> alternative_full;
alternative_full.reserve(alternative_items.size());
for (const auto &item : alternative_items)
{
alternative_full.emplace_back(item.key, item.value, item.weight, CandidateSource::Database, item.key);
}
std::vector<WordItem> merged_full = primary_full;
merged_full.insert(merged_full.end(), alternative_full.begin(), alternative_full.end());
std::stable_sort(merged_full.begin(), merged_full.end(),
[](const WordItem &lhs, const WordItem &rhs) { return lhs.weight > rhs.weight; });
std::unordered_set<std::string> seen_full_words;
merged_full.erase(std::remove_if(merged_full.begin(), merged_full.end(),
[&](const WordItem &item) { return !seen_full_words.insert(item.word).second; }),
merged_full.end());
// Weights rank candidates reliably within one pinyin key, but are not directly comparable across
// different segmentations. Keep the best alternative interpretation visible without letting every
// segmentation occupy a protected slot on the first page -- but only when the best alternative
// word is not dwarfed by the primary reading's top candidate, otherwise the slot goes to noise
// like 西鄂 for "xie" instead of a reading the user might actually have meant.
const std::int64_t primary_top_weight = primary_full.empty() ? 0 : primary_full.front().weight;
const bool promote_alternative =
static_cast<std::int64_t>(alternative_items.front().weight) * kAlternativeSegmentationPromotionRatio >=
primary_top_weight;
const std::string &best_alternative_word = alternative_items.front().value;
const auto best_alternative = std::find_if(merged_full.begin(), merged_full.end(), [&](const WordItem &item) {
return item.word == best_alternative_word;
});
// 这个保护位只负责「把首页外的备选读音拉进首页」,不负责给它排座次。已经排进首页的
// 候选一律不动:它的位置是权重排出来的,而用户调频写的就是权重,再钉一次等于把调频
// 的结果覆盖掉。吉安 就是这么被弹到第 2 位的——调频按 promote 把它放到 index 4,
// 它因此成了 ji'an 组里权重最高的词、过了上面的门槛,于是又被拽到 index 1,用户看到
// 的是「选一次就跳到第二」。西安(自然位置 16,在首页外)不受影响,照旧进 index 1。
const size_t best_alternative_index =
best_alternative == merged_full.end()
? 0
: static_cast<size_t>(std::distance(merged_full.begin(), best_alternative));
if (promote_alternative && best_alternative != merged_full.end() &&
best_alternative_index >= kAlternativeSegmentationFirstPageSize)
{
WordItem promoted = std::move(*best_alternative);
merged_full.erase(best_alternative);
merged_full.insert(merged_full.begin() + static_cast<std::ptrdiff_t>(kBestAlternativeSegmentationMaxIndex),
std::move(promoted));
}
std::vector<WordItem> merged = std::move(merged_full);
const size_t primary_full_count = primary_full.size() < result.size() ? primary_full.size() : result.size();
std::vector<WordItem> remaining(result.begin() + static_cast<std::ptrdiff_t>(primary_full_count), result.end());
append_unique_words(merged, remaining);
return merged;
}
// 合法输入常有不止一种等音节数的切分:yibananzhao 既是 yi'ba'nan'zhao 也是
// yi'ban'an'zhao,最少音节规则平局时取较短的首音节,于是固定落在前者、出「一把难找」。
// 切分是在查词之前定的,没有上下文,这里把它交给整句打分:
// - 任一条切分在词库里有整词(fang'an 的方案 / fan'gan 的反感、西安)时不介入,那是
// merge_alternative_segmentations 按词频比的单词级歧义,已经调好,不该被整句分翻动;
// - 否则各切分解一遍词格,路径分最高的领衔,同分仍是原切分(最少音节规则降为平局判据);
// - 输的一方分差不超过 kLegalInputRunnerUpGapLog 时,它的整句占第 2 位;更远就不出,
// 免得每个带 n/ng 歧义的输入都在第 2 位挂一句不通的话。
// 只在整句来源开着时介入:整句来源全关时没有整句可比,结果与改造前逐位一致。用户在原
// 切分键上调过频或造过词时,原切分不让出领衔(同 arbitrate_legal_corrections)。
std::vector<WordItem> QuanpinDictionary::arbitrate_literal_segmentations(const std::string &raw_input,
const quanpin::Segments &segments,
std::vector<WordItem> result,
quanpin::Segments &leading_segments)
{
leading_segments = segments;
if (!sentence_association_.any_sentence_source() || result.empty() || segments.size() < 2 ||
raw_input.find('\'') != std::string::npos || !quanpin::has_only_complete_pinyin_segments(segments))
{
return result;
}
// 主切分必须就是字面切分之一(别名改写过字母的读法不算),备选是其余等音节数的切分。
bool primary_is_literal = false;
std::vector<quanpin::Segments> alternatives;
for (auto &cut : quanpin::enumerate_min_segmentations(raw_input, kLiteralSegmentationEnumerateLimit))
{
cut = normalize_umlaut_aliases(std::move(cut));
if (cut == segments)
{
primary_is_literal = true;
}
else if (alternatives.size() < kLiteralSegmentationDecodeLimit)
{
alternatives.push_back(std::move(cut));
}
}
if (!primary_is_literal || alternatives.empty())
{
return result;
}
std::vector<quanpin::Segments> all_cuts = alternatives;
all_cuts.push_back(segments);
if (!quanpin::query_exact_segmentations_keyed_flat(all_cuts, db_, statement_cache_, 1).empty())
{
return result;
}
const auto primary_score = lattice_best_path_score(segments);
if (!primary_score.has_value())
{
return result;
}
std::optional<double> best_score;
const quanpin::Segments *best_cut = nullptr;
for (const auto &cut : alternatives)
{
const auto score = lattice_best_path_score(cut);
if (score.has_value() && (!best_score.has_value() || *score > *best_score))
{
best_score = score;
best_cut = &cut;
}
}
if (best_cut == nullptr)
{
return result;
}
const std::string primary_key = quanpin::join_segments(segments);
const std::string best_key = quanpin::join_segments(*best_cut);
const auto is_sentence_of = [](const std::string &key) {
return [&key](const WordItem &item) { return reads_full_key(item, key); };
};
const bool alternative_leads = *best_score > *primary_score && !user_prefers_reading(primary_key);
const double gap = alternative_leads ? *best_score - *primary_score : *primary_score - *best_score;
if (!alternative_leads)
{
if (gap > kLegalInputRunnerUpGapLog)
{
return result;
}
// 第 2 位只要一句。Trigram 整句开着时直接用打分时解出的那条最优路径,与 merge_lattice_candidates
// 造的行同形;不必为这一行再跑一遍带神经重排的 query_series。只开神经等来源时 Trigram 行不显示,
// 那就照常查一遍,取它的整键块首行(词库里没有这条切分的整词,上面已经排除,整键块就是整句行)。
std::optional<WordItem> runner;
if (sentence_association_.word_lattice)
{
if (const auto path = lattice_best_path(*best_cut))
{
runner = WordItem(best_key, path->sentence, static_cast<std::int64_t>(path->log_prob * 1000.0),
CandidateSource::Generated, path->key);
runner->sentence_association = true;
}
}
else
{
const auto alternative = query_series(raw_input, best_key, *best_cut);
const auto found = std::find_if(alternative.begin(), alternative.end(), is_sentence_of(best_key));
if (found != alternative.end())
{
runner = *found;
}
}
if (!runner.has_value())
{
return result;
}
std::vector<WordItem> merged;
merged.push_back(result.front());
append_unique_words(merged, {*runner});
append_unique_words(merged, result);
return merged;
}
leading_segments = *best_cut;
// 同 query_exact 里纠错的上下文消解:胜出切分的整键块(整句行)领衔,其余照旧,
// 胜出切分的前缀词垫底;整键块为空时(整句只开了重排、模型没载入)由前缀词领衔。
std::vector<WordItem> winner_full;
std::vector<WordItem> winner_prefix;
for (auto &item : query_series(raw_input, best_key, *best_cut))
{
(reads_full_key(item, best_key) ? winner_full : winner_prefix).push_back(std::move(item));
}
if (winner_full.empty())
{
winner_full.swap(winner_prefix);
}
if (winner_full.empty())
{
leading_segments = segments;
return result;
}
// 原切分的整句:分差以内占第 2 位,更远就整组不出(前缀词照留)。
// 原切分的前缀区间扫描会捞出胜出切分的词,读音却记在原切分上(yi'ba 扫出「一般」,pinyin 是
// yi'ba):同一个词两边都有时换成胜出切分那一行,选它才按 yi'ban 消耗字母。位置不变。
std::unordered_map<std::string, const WordItem *> winner_prefix_by_word;
for (const auto &item : winner_prefix)
{
winner_prefix_by_word.emplace(item.word, &item);
}
std::vector<WordItem> primary_sentences;
std::vector<WordItem> rest;
for (auto &item : result)
{
if (reads_full_key(item, primary_key))
{
primary_sentences.push_back(std::move(item));
continue;
}
const auto same_word = winner_prefix_by_word.find(item.word);
rest.push_back(same_word == winner_prefix_by_word.end() ? std::move(item) : *same_word->second);
}
std::vector<WordItem> merged;
merged.push_back(winner_full.front());
if (gap <= kLegalInputRunnerUpGapLog && !primary_sentences.empty())
{
append_unique_words(merged, {primary_sentences.front()});
append_unique_words(merged, winner_full);
append_unique_words(merged, primary_sentences);
}
else
{
append_unique_words(merged, winner_full);
}
append_unique_words(merged, rest);
append_unique_words(merged, winner_prefix);
return merged;
}
// 合法输入上的手误(ziazheliya、jioa、jiuzheeyang、nia)按噪声信道整句打分:
// 总分 = 词格最佳路径分 - 每处纠正的手误代价(换位 2.0,多字/漏字 3.0)
// 原读法代价为 0。最高分领衔,另一方的最佳读法占第 2 位:纠错领衔时原读法永远在
// 第 2 位(纠错可能误判,原读法要留在手边);原读法领衔时,纠错读法只在分差不超过
// kLegalInputRunnerUpGapLog 时出现,否则整组不出——碰巧能纠成别的音节的输入很多,
// 语言模型都不认的读法不该挂在第 2 位。
// 打分不看整句候选开关:那个开关管的是显示不显示整句候选,判断用户想打哪个读音
// 是另一件事。用户在原读法键上调过频或造过词时,纠错不抢领衔(同上下文接管)。
std::vector<WordItem> QuanpinDictionary::arbitrate_legal_corrections(
const std::string &raw_input, const quanpin::Segments &plain_segments,
const std::vector<quanpin::AutocorrectCut> &corrected_cuts, std::vector<WordItem> result)
{
// 字面切分本身就是真词时(邪恶 xie'e、答案 da'an),不认改变字母数的读法:少一个
// 音节的句子在语言模型里天然分高,「些」「但」会稳定挂在第 2 位,而这些输入几乎都是
// 照原样打的。换位读法不改音节数,没有这个偏差,照常参与(你啊 / 奶)。
const bool literal_is_word = find_literal_whole_word(result, raw_input) != result.end();
std::optional<double> best_total;
const quanpin::AutocorrectCut *best_cut = nullptr;
for (const auto &cut : corrected_cuts)
{
double penalty = 0.0;
bool changes_length = false;
for (const auto &segment : cut.segments)
{
if (!segment.corrected)
{
continue;
}
const bool same_length = segment.raw_text.size() == segment.syllable.size();
changes_length = changes_length || !same_length;
penalty += same_length ? kLegalInputTranspositionPenaltyLog : kLegalInputLengthChangePenaltyLog;
}
if (literal_is_word && changes_length)
{
continue;
}
const auto score = lattice_best_path_score(cut_syllables(cut));
if (!score.has_value())
{
continue;
}
const double total = *score - penalty;
if (!best_total.has_value() || total > *best_total)
{
best_total = total;
best_cut = &cut;
}
}
if (best_cut == nullptr)
{
return result;
}
const auto plain_total = lattice_best_path_score(plain_segments);
const quanpin::Segments corrected_segments = cut_syllables(*best_cut);
const std::string corrected_key = quanpin::join_segments(corrected_segments);
std::vector<WordItem> corrected = query_series(raw_input, corrected_key, corrected_segments);
if (corrected.empty())
{
return result;
}
// 只给纠错读法的整读音候选打标(与 mark_autocorrect_candidates 同一判据:读音字母
// 等于纠错读法、又不同于敲的字母),前缀单字不标。标记随结果进缓存。
const std::string typed_letters = fold_autocorrect_letters(raw_input);
const std::string corrected_letters = fold_autocorrect_letters(corrected_key);
for (auto &item : corrected)
{
if (item.corrected_from.empty() && fold_autocorrect_letters(item.pinyin) == corrected_letters &&
corrected_letters != typed_letters)
{
item.corrected_from = typed_letters;
}
}
const bool correction_leads = (!plain_total.has_value() || *best_total > *plain_total) &&
!user_prefers_reading(quanpin::join_segments(plain_segments));
if (!correction_leads && (!plain_total.has_value() || *plain_total - *best_total > kLegalInputRunnerUpGapLog))
{
return result;
}
std::vector<WordItem> &leader = correction_leads ? corrected : result;
std::vector<WordItem> &runner = correction_leads ? result : corrected;
std::vector<WordItem> merged;
if (!leader.empty())
{
merged.push_back(leader.front());
}
if (!runner.empty())
{
append_unique_words(merged, {runner.front()});
}
append_unique_words(merged, leader);
append_unique_words(merged, runner);
return merged;
}
std::optional<double> QuanpinDictionary::lattice_best_path_score(const quanpin::Segments &cut)
{
const auto path = lattice_best_path(cut);
return path.has_value() ? std::optional<double>(path->log_prob) : std::nullopt;
}
std::optional<quanpin::LatticePath> QuanpinDictionary::lattice_best_path(const quanpin::Segments &cut)
{
if (cut.empty() || !quanpin::has_only_complete_pinyin_segments(cut))
{
return std::nullopt;
}
const std::string cache_key = quanpin::join_segments(cut);
if (const auto *cached = lattice_best_path_cache_.find(cache_key))
{
return *cached;
}
// 仲裁回到只用 kenlm:切分/纠错裁决不再让 octagram 参与。搭配项是当前同音/切分被带偏的
// 放大器之一,而 librime 的切分器也不由 grammar 驱动(见 design D1)。
quanpin::WordLatticeOptions lattice_options;
lattice_options.language_model = language_model_;
// 不带模糊音:这个分用来裁决切分与纠错,模糊读音只给敲定的切分多出整句,不参与投票。
// 带上它,字面读法会借模糊读音的分压过纠错(jiuzheeyang 的「就这恶言」压过「就这样」)。
const auto lookup = lattice_lookup(lattice_options.span_limit, false);
auto cut_paths = quanpin::decode_word_lattice(cut, lookup, lattice_options);
std::optional<quanpin::LatticePath> best;
if (!cut_paths.empty())
{
best = std::move(cut_paths.front());
}
lattice_best_path_cache_.insert(cache_key, best);
return best;
}
// 用户在这个读音键上有过调频或造词记录。只在边际已达标、要决定是否接管时才查,
// 不给每次按键加一次日志查询。
bool QuanpinDictionary::user_prefers_reading(const std::string &key)
{
return user_dictionary::has_user_upsert_for_key(
metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, key);
}
std::vector<WordItem> QuanpinDictionary::append_ime_fallback(const std::string &raw_input,
const std::string &segmentation,
std::vector<WordItem> result)
{
if (!result.empty())
{
return result;
}
// 这条兜底整句也来自 Google 解码器,随「Google 整句联想」开关一起关闭。
if (!sentence_association_.google)
{
return result;
}
const std::string normalized = remove_delimiters(segmentation.empty() ? raw_input : segmentation);
const std::string sentence = search_sentence_from_ime_engine(normalized);
if (sentence.empty())
{
return result;
}
const auto exists =
std::find_if(result.begin(), result.end(), [&](const WordItem &item) { return item.word == sentence; });
if (exists == result.end())
{
// 同上,整句 fallback 需要 canonical quanpin 才能参与造词落库。
result.emplace_back(segmentation.empty() ? raw_input : segmentation, sentence, 1, CandidateSource::Fallback,
segmentation);
result.back().sentence_association = true;
}
return result;
}
std::vector<WordItem> QuanpinDictionary::append_sparse_pinyin_fallbacks(const quanpin::Segments &segments,
std::vector<WordItem> result)
{
for (const auto &fallback_segments : quanpin::sparse_pinyin_fallback_segments(segments))
{
if (fallback_segments.empty())
{
continue;
}
const std::string fallback_segmentation = quanpin::join_segments(fallback_segments);
const std::string fallback_input = remove_delimiters(fallback_segmentation);
const auto fallback_result = query_single_path(fallback_input, fallback_segmentation, fallback_segments);
append_unique_words(result, fallback_result);
}
return result;
}
void QuanpinDictionary::append_unique_words(std::vector<WordItem> &result, const std::vector<WordItem> &extra)
{
// 两边都是整串查询结果,未截断时各有成百上千条,逐条线性比对会在一次按键里做上百万次字符串比较。
// 先按最终大小预留,push_back 不再搬动元素,集合里的 string_view 在整个合并期间都有效。
result.reserve(result.size() + extra.size());
std::unordered_set<std::string_view> seen;
seen.reserve(result.size() + extra.size());
for (const auto &existing : result)
{
seen.insert(existing.word);
}
for (const auto &item : extra)
{
if (seen.count(item.word) == 0)
{
result.push_back(item);
seen.insert(result.back().word);
}
}
}
void QuanpinDictionary::mark_autocorrect_candidates(std::vector<WordItem> &candidates, const std::string &raw_input)
{
// A candidate comes from a corrected interpretation exactly when its code
// letters equal any correction cut's letters (primary or ranked
// alternative) while those differ from the typed letters. The letters-only
// comparison alone would also sweep up prefix candidates (keneng -> ke,
// single-letter jianpin expansions) that the user spelled correctly; both
// rules together keep those unmarked. The ü-style alias rewrite (nue->nve,
// jv->ju) is a first-class marking source: it runs in resolve_segments
// regardless of the autocorrect switches, so an alias-rewritten query marks
// its candidates with the typed spelling, while a standard spelling exits
// at the all-equal gate because every set equals the typed letters.
std::vector<std::string> corrected_letter_sets;
corrected_letter_sets.reserve(1 + pinyin_alternative_segmentations_.size());
corrected_letter_sets.push_back(fold_autocorrect_letters(pinyin_segmentation_));
for (const auto &alternative : pinyin_alternative_segmentations_)
{
corrected_letter_sets.push_back(fold_autocorrect_letters(alternative));
}
corrected_letter_sets.erase(std::remove_if(corrected_letter_sets.begin(), corrected_letter_sets.end(),
[](const std::string &letters) { return letters.empty(); }),
corrected_letter_sets.end());
const std::string raw_letters = fold_autocorrect_letters(raw_input);
if (corrected_letter_sets.empty() ||
std::all_of(corrected_letter_sets.begin(), corrected_letter_sets.end(),
[&](const std::string &letters) { return letters == raw_letters; }))
{
return;
}
for (auto &item : candidates)
{
if (!item.corrected_from.empty() || literal_mixed_match(item, raw_input))
{
continue;
}
const std::string item_letters = fold_autocorrect_letters(item.pinyin);
// Match only against readings that actually differ from the typed
// letters. An alternative cut can fold back to exactly raw_letters
// (letters removed by the fold but rewritten by the cut); matching that
// set would stamp corrected_from onto a candidate the user spelled
// correctly. The all-equal gate above only guards the case where EVERY
// set equals raw_letters, so this per-set filter is needed.
if (std::any_of(corrected_letter_sets.begin(), corrected_letter_sets.end(),
[&](const std::string &letters) { return letters != raw_letters && item_letters == letters; }))
{
item.corrected_from = raw_letters;
}
}
}
int QuanpinDictionary::create_word(std::string pinyin, std::string word)
{
pinyin = remove_delimiters(pinyin);
const auto cuts = cut_for_write(pinyin);
if (cuts.empty())
{
return ERROR_CODE;
}
pinyin = quanpin::join_segments(cuts.front());
const std::string jp = quanpin::segments_to_jianpin(cuts.front());
if (!do_validate(pinyin, jp, word))
{
return ERROR_CODE;
}
if (check_data(build_sql_for_checking_word(pinyin, word)))
{
return OK;
}
if (insert_data(build_sql_for_inserting_word(pinyin, jp, word)) != OK)
{
return ERROR_CODE;
}
(void)user_dictionary::record_user_insert(metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, pinyin, word, 10000);
reset_cache();
return OK;
}
int QuanpinDictionary::create_word_from_canonical_pinyin(std::string pinyin, std::string word)
{
const auto segments = quanpin::split_segments(pinyin);
const size_t han_count = HelpcodeUtils::count_han_chars(word);
if (segments.empty() || segments.size() != han_count ||
std::any_of(segments.begin(), segments.end(), [](const std::string &segment) {
return segment.empty() || !quanpin::is_complete_pinyin_input(segment);
}))
{
return ERROR_CODE;
}
pinyin = quanpin::join_segments(segments);
const std::string jp = quanpin::segments_to_jianpin(segments);
// 不再走 do_validate:这里的音节边界由调用方(canonical 键)显式给定,上面已按
// split_segments 校验过段数、逐段完整性,jp 也是每段一字母、长度必然等于字数。
// do_validate 会先 remove_delimiters 再用贪心 correction 重切,把 qi'e'huan 断成
// qie'huan(段数 3→2),从而误杀这条合法整句——正是 AI / 云联想选中却入不了库的根因。
if (check_data(build_sql_for_checking_word(pinyin, word)))
{
return OK;
}
if (insert_data(build_sql_for_inserting_word(pinyin, jp, word)) != OK)
{
return ERROR_CODE;
}
(void)user_dictionary::record_user_insert(metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, pinyin, word, 10000);
reset_cache();
return OK;
}
int QuanpinDictionary::update_weight_by_word(std::string word)
{
return update_weight_by_pinyin_and_word(remove_delimiters(pinyin_segmentation_), std::move(word));
}
int QuanpinDictionary::update_weight_by_pinyin_and_word(std::string pinyin, std::string word)
{
pinyin = remove_delimiters(pinyin);
const auto cuts = cut_for_write(pinyin);
if (cuts.empty())
return ERROR_CODE;
auto segments = cuts.front();
// 别名拼写(nue/lue 等词库不存在的键)不得承载用户调频数据:改写为标准键,
// 保证权重更新落在标准字典行上(PRD R3)。
segments = normalize_umlaut_aliases(std::move(segments));
const size_t han_count = HelpcodeUtils::count_han_chars(word);
if (segments.size() > han_count)
segments.resize(han_count);
const std::string normalized = quanpin::join_segments(segments);
if (update_data(build_sql_for_updating_word(normalized, word)) != OK)
{
return ERROR_CODE;
}
(void)user_dictionary::record_pinyin_upsert_from_database(
db_path_, normalized, word, metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)));
reset_cache();
return OK;
}
int QuanpinDictionary::delete_by_pinyin_and_word(std::string pinyin, std::string word)
{
pinyin = remove_delimiters(pinyin);
const auto cuts = cut_for_write(pinyin);
if (cuts.empty())
return ERROR_CODE;
const std::string normalized = quanpin::join_segments(cuts.front());
if (!user_dictionary::delete_dictionary_candidate(
db_path_, metasequoia::path_to_utf8(paths_.user(metasequoia::assets::user_journal)),
user_dictionary::DictionaryKind::Pinyin, normalized, word))
return ERROR_CODE;
reset_cache();
return OK;
}
int QuanpinDictionary::insert_word_to_series_cache(const std::string &pinyin, const std::string &word,
CandidateSource source)
{
if (pinyin.empty() || word.empty())
{
return ERROR_CODE;
}
const auto cuts = cut_for_write(pinyin);
const std::string segmentation = cuts.empty() ? pinyin : quanpin::join_segments(cuts.front());
const std::string cache_key = series_cache_key(pinyin, segmentation);
return insert_word_to_series_cache_key(cache_key, pinyin, word, source);
}
int QuanpinDictionary::insert_word_to_series_cache(const std::string &raw_input, const std::string &segmentation,
unsigned autocorrect_types, const std::string &word,
CandidateSource source)
{
if (raw_input.empty() || word.empty())
{
return ERROR_CODE;
}
const auto segments =
resolve_segments(raw_input, segmentation, quanpin::correction_aliases_enabled(autocorrect_types));
const auto resolution = resolve_series_query(raw_input, segments, autocorrect_types);
return insert_word_to_series_cache_key(resolution.cache_key, raw_input, word, source);
}
int QuanpinDictionary::insert_word_to_series_cache_key(const std::string &cache_key, const std::string &pinyin,
const std::string &word, CandidateSource source)
{
auto list = series_cache_.get(cache_key).value_or(std::vector<WordItem>{});
// Keep at most one cloud/AI suggestion in the series cache for this key.
if (source == CandidateSource::AiSuggestion || source == CandidateSource::CloudSuggestion)
{
list.erase(
std::remove_if(list.begin(), list.end(), [source](const WordItem &item) { return item.source == source; }),
list.end());
}
const auto exists = std::find_if(list.begin(), list.end(), [&](const WordItem &item) { return item.word == word; });
if (exists == list.end())
{
if (list.empty())
{
list.emplace_back(pinyin, word, 1, source);
}
else
{
const size_t index = source == CandidateSource::AiSuggestion ? std::min<size_t>(2, list.size()) : 1;
list.insert(list.begin() + index, WordItem(pinyin, word, 1, source));
}
}
if (source == CandidateSource::CloudSuggestion)
{
const auto ai = std::find_if(list.begin(), list.end(),
[](const WordItem &item) { return item.source == CandidateSource::AiSuggestion; });
if (ai != list.end())
{
WordItem ai_item = std::move(*ai);
list.erase(ai);
list.insert(list.begin() + std::min<size_t>(2, list.size()), std::move(ai_item));
}
}
series_cache_.insert(cache_key, list);
return OK;
}
std::string QuanpinDictionary::search_sentence_from_ime_engine(const std::string &user_pinyin)
{
return decoder_.sentence(user_pinyin);
}
void QuanpinDictionary::reset_state()
{
pinyin_sequence_.clear();
pinyin_segmentation_.clear();
pinyin_alternative_segmentations_.clear();
current_candidate_list_.clear();
}
void QuanpinDictionary::reset_cache()
{
cache_.clear();
series_cache_.clear();
segmentation_cache_.clear();
prefix_miss_cache_.clear();
lattice_best_path_cache_.clear();
lattice_span_cache_.clear();
}
quanpin::WordLatticeLookup QuanpinDictionary::lattice_lookup(int limit, bool with_fuzzy)
{
const auto fuzzy = with_fuzzy ? fuzzy_pinyin_ : metasequoia::FuzzyPinyinOptions{};
if (!char_reading_totals_)
{
char_reading_totals_ = quanpin::load_char_reading_totals(db_);
}
return [this, lookup = quanpin::make_lattice_db_lookup(db_, statement_cache_, limit, fuzzy, char_reading_totals_),
limit, fuzzy](const quanpin::Segments &span) {
// 超过上限整体清掉,免得一次长时间输入把内存撑大(同双拼的 direct_span_cache_)。
constexpr std::size_t kLatticeSpanCacheLimit = 8192;
std::string key = quanpin::join_segments(span);
key.push_back('#');
key += std::to_string(limit);
if (fuzzy.rules)
{
key += "#f";
}
auto found = lattice_span_cache_.find(key);
if (found == lattice_span_cache_.end())
{
if (lattice_span_cache_.size() >= kLatticeSpanCacheLimit)
{
lattice_span_cache_.clear();
}
found = lattice_span_cache_.emplace(std::move(key), lookup(span)).first;
}
return found->second;
};
}
void QuanpinDictionary::set_fuzzy_pinyin(metasequoia::FuzzyPinyinOptions options)
{
if (fuzzy_pinyin_.rules == options.rules)
{
return;
}
fuzzy_pinyin_ = options;
// 词格跨度、最佳路径分和按它们排出的候选列表都按旧规则算出。规则只在设置页改,
// 清一次比给每层缓存的键都带上规则简单,也不会让关掉模糊音后读到带模糊行的旧列表。
reset_cache();
}
void QuanpinDictionary::reset_sentence_cache()
{
// 词格整句只在 query_series 里合入,结果只经 series_cache_ 缓存;cache_ 是逐前缀的
// 词库行加 Google 整句,segmentation_cache_ 是纯切分,都不受重排影响。
series_cache_.clear();
}
void QuanpinDictionary::set_sentence_association(const SentenceAssociationOptions &options)
{
if (sentence_association_ == options)
{
return;
}
sentence_association_ = options;
// 模型路径随开关一起变更时才重新解析:进程内按路径共享(shared_gram_db),
// 重复解析只是查一次表,路径无效时拿到 valid()==false 的实例,词格静默降级。
// 路径是宿主下发的 UTF-8,必须显式按 UTF-8 转,隐式转换走 ANSI 代码页。
collocation_db_ =
sentence_association_.collocation_model.empty()
? nullptr
: gram::shared_gram_db(metasequoia::path_from_utf8(sentence_association_.collocation_model.c_str()));
// 高精度档模型的路径同样随选项下发(模型按需下载,不在资源目录)。重建句柄只是丢掉
// 已解析的指针,模型本身由 shared_sentence_model 按路径持有,重排线程手里的指针不失效。
neural_desktop_model_ = neural::LazySentenceModel(sentence_association_.neural_desktop_model);
// 开关变了,之前缓存的候选列表按旧开关算出,必须清掉,否则打开/关闭后要到缓存过期
// 才见效。整句候选都进这几个缓存,见 query_series / query_single_path。
reset_cache();
}
void QuanpinDictionary::set_rescoring_context(const std::string &context)
{
if (rescoring_context_ == context)
{
return;
}
rescoring_context_ = context;
// 上文变了,缓存里的顺序是按旧上文重排出来的,同 set_sentence_association。
// 上文只经神经重排进入候选;没有生效的神经模型时缓存与上文无关,不清。
// 每次上屏都会换上文,无条件清会让下一个词的前几键都变成冷查询。之后
// 打开神经重排会经 set_sentence_association 清缓存,不会沿用旧结果。
const bool rescoring_active = (sentence_association_.neural_keyboard && neural_keyboard_model_.get() != nullptr) ||
(sentence_association_.neural_desktop && neural_desktop_model_.get() != nullptr);
if (rescoring_active)
{
reset_sentence_cache();
}
}
void QuanpinDictionary::reset_cache_if_database_changed()
{
if (db_ == nullptr)
{
return;
}
sqlite3_stmt *statement = nullptr;
if (sqlite3_prepare_v2(db_, "PRAGMA data_version", -1, &statement, nullptr) != SQLITE_OK)
{
return;
}
if (sqlite3_step(statement) != SQLITE_ROW)
{
sqlite3_finalize(statement);
return;
}
const sqlite3_int64 current_version = sqlite3_column_int64(statement, 0);
sqlite3_finalize(statement);
if (data_version_ >= 0 && current_version != data_version_)
{
reset_cache();
}
data_version_ = current_version;
}
std::vector<std::string> QuanpinDictionary::select_data(const std::string &sql_str)
{
std::vector<std::string> candidate_list;
if (db_ == nullptr)
{
return candidate_list;
}
sqlite3_stmt *stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, 0) != SQLITE_OK)
{
(void)0;
return candidate_list;
}
while (sqlite3_step(stmt) == SQLITE_ROW)
{
candidate_list.push_back(std::string(reinterpret_cast<const char *>(sqlite3_column_text(stmt, 2))));
}
sqlite3_finalize(stmt);
return candidate_list;
}
std::vector<WordItem> QuanpinDictionary::select_complete_data(const std::string &sql_str)
{
std::vector<WordItem> candidate_list;
if (db_ == nullptr)
{
return candidate_list;
}
sqlite3_stmt *stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, 0) != SQLITE_OK)
{
(void)0;
return candidate_list;
}
while (sqlite3_step(stmt) == SQLITE_ROW)
{
candidate_list.emplace_back(std::string(reinterpret_cast<const char *>(sqlite3_column_text(stmt, 0))),
std::string(reinterpret_cast<const char *>(sqlite3_column_text(stmt, 2))),
sqlite3_column_int64(stmt, 3), CandidateSource::Database,
std::string(reinterpret_cast<const char *>(sqlite3_column_text(stmt, 0))));
}
sqlite3_finalize(stmt);
return candidate_list;
}
int QuanpinDictionary::check_data(const std::string &sql_str)
{
if (db_ == nullptr)
{
return false;
}
sqlite3_stmt *stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, 0) != SQLITE_OK)
{
(void)0;
return false;
}
const bool exists = sqlite3_step(stmt) == SQLITE_ROW;
sqlite3_finalize(stmt);
return exists;
}
int QuanpinDictionary::insert_data(const std::string &sql_str)
{
if (db_ == nullptr)
{
return ERROR_CODE;
}
sqlite3_stmt *stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, 0) != SQLITE_OK)
{
(void)0;
return ERROR_CODE;
}
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok ? OK : ERROR_CODE;
}
int QuanpinDictionary::update_data(const std::string &sql_str)
{
if (db_ == nullptr)
{
return ERROR_CODE;
}
sqlite3_stmt *stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, 0) != SQLITE_OK)
{
(void)0;
return ERROR_CODE;
}
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok ? OK : ERROR_CODE;
}
int QuanpinDictionary::delete_data(const std::string &sql_str)
{
if (db_ == nullptr)
{
return ERROR_CODE;
}
sqlite3_stmt *stmt = nullptr;
if (sqlite3_prepare_v2(db_, sql_str.c_str(), -1, &stmt, 0) != SQLITE_OK)
{
(void)0;
return ERROR_CODE;
}
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return ok ? OK : ERROR_CODE;
}
std::string QuanpinDictionary::build_sql_for_creating_word(const std::string &pinyin)
{
const auto cuts = cut_for_write(pinyin);
if (cuts.empty())
{
return "";
}
std::string sql;
for (size_t i = 1; i <= cuts.front().size(); ++i)
{
std::vector<std::string> partial(cuts.front().begin(), cuts.front().begin() + i);
const std::string key = quanpin::join_segments(partial);
const std::string table = quanpin::build_table_name(partial);
const std::string each =
fmt::format("select * from(select * from {} where key = '{}' order by weight desc)", table, key);
sql = sql.empty() ? each : each + " union all " + sql;
}
return sql;
}
std::string QuanpinDictionary::build_sql_for_checking_word(const std::string &key, const std::string &value)
{
const auto cuts = cut_for_write(key);
if (cuts.empty())
{
return "";
}
const std::string table = quanpin::build_table_name(cuts.front());
return fmt::format("select 1 from {} where key = '{}' and value = '{}';", table, escape_sql_text(key),
escape_sql_text(value));
}
std::string QuanpinDictionary::build_sql_for_inserting_word(const std::string &key, const std::string &jp,
const std::string &value)
{
const auto cuts = cut_for_write(key);
if (cuts.empty())
{
return "";
}
const std::string table = quanpin::build_table_name(cuts.front());
return fmt::format("insert into {} (key, jp, value, weight) values ('{}', '{}', '{}', '{}');", table,
escape_sql_text(key), escape_sql_text(jp), escape_sql_text(value), 10000);
}
std::string QuanpinDictionary::build_sql_for_updating_word(const std::string &word)
{
return build_sql_for_updating_word(remove_delimiters(pinyin_segmentation_), word);
}
std::string QuanpinDictionary::build_sql_for_updating_word(std::string pinyin, const std::string &word)
{
pinyin = remove_delimiters(pinyin);
const auto cuts = cut_for_write(pinyin);
if (cuts.empty())
{
return "";
}
size_t han_cnt = HelpcodeUtils::count_han_chars(word);
auto segments = cuts.front();
if (segments.size() > han_cnt)
{
segments.resize(han_cnt);
}
pinyin = quanpin::join_segments(segments);
const std::string jp = quanpin::segments_to_jianpin(segments);
if (!do_validate(pinyin, jp, word))
{
return "";
}
const std::string table = quanpin::build_table_name(segments);
return fmt::format("update {0} set weight = ( select MAX(weight) + 1 from {0} AS sub where sub.key = '{1}') "
"where key = '{1}' and value = '{2}';",
table, escape_sql_text(pinyin), escape_sql_text(word));
}
std::string QuanpinDictionary::build_sql_for_deleting_word(std::string pinyin, const std::string &word)
{
pinyin = remove_delimiters(pinyin);
const auto cuts = cut_for_write(pinyin);
if (cuts.empty())
{
return "";
}
const std::string normalized = quanpin::join_segments(cuts.front());
const std::string jp = quanpin::segments_to_jianpin(cuts.front());
if (!do_validate(normalized, jp, word))
{
return "";
}
return fmt::format("delete from {} where key = '{}' and value = '{}';", quanpin::build_table_name(cuts.front()),
escape_sql_text(normalized), escape_sql_text(word));
}
bool QuanpinDictionary::do_validate(const std::string &key, const std::string &jp, const std::string &value)
{
const std::string pure_key = remove_delimiters(key);
if (pure_key.empty())
{
return false;
}
const size_t han_count = HelpcodeUtils::count_han_chars(value);
if (jp.size() != han_count)
{
return false;
}
const auto cuts = cut_for_write(pure_key);
if (cuts.empty())
{
return false;
}
return cuts.front().size() == han_count;
}
std::vector<WordItem> QuanpinDictionary::fuzzy_candidates(const std::string &segmentation,
metasequoia::FuzzyPinyinOptions options)
{
std::vector<WordItem> result;
if (!options.rules || !db_)
return result;
reset_cache_if_database_changed();
const auto cache_key = "fuzzy:" + std::to_string(options.rules) + ":" + segmentation;
if (const auto *cached = series_cache_.find(cache_key))
return *cached;
const auto segments = quanpin::split_segments(segmentation);
std::size_t budget = 128;
for (std::size_t count = segments.size(); count > 0 && budget > 1; --count)
{
const quanpin::Segments prefix(segments.begin(), segments.begin() + count);
const auto paths = quanpin::fuzzy_segmentations(prefix, options, std::min<std::size_t>(64, budget));
if (paths.empty())
continue;
budget -= paths.size();
const auto rows = quanpin::query_exact_segmentations_keyed_flat(paths, db_, statement_cache_, 128);
for (const auto &row : rows)
{
WordItem item(quanpin::join_segments(prefix), row.value, row.weight, CandidateSource::Database, row.key);
item.fuzzy = true;
result.push_back(std::move(item));
}
}
series_cache_.insert(cache_key, result);
return result;
}
std::vector<WordItem> QuanpinDictionary::query(const std::string &raw_input, const std::string &segmentation,
unsigned autocorrect, metasequoia::FuzzyPinyinOptions fuzzy)
{
// 词格要按同一套规则收模糊读音,先于 query_exact 下发。
set_fuzzy_pinyin(fuzzy);
// 写回路径随后按这次查询的切分重切(cut_for_write),状态要和查询一致。
correction_aliases_enabled_ = quanpin::correction_aliases_enabled(autocorrect);
auto result = query_exact(raw_input, segmentation, autocorrect);
if (fuzzy.rules && !raw_input.empty())
{
// Keep ordinary cache slots free of preference-specific candidates.
const auto typed =
segmentation.empty()
? quanpin::join_segments(resolve_segments(raw_input, segmentation, correction_aliases_enabled_))
: segmentation;
// Specificity is how many typed letters a candidate accounts for, not the raw
// length of its stored key: alternative cuts ("xi'e" for "xie", "you'di'an"
// for "you'dian") and fuzzy variants carry apostrophes, so comparing pinyin
// size() verbatim lifted rarer re-segmentations above the exact reading.
const auto matched_letters = [](const WordItem &item) {
size_t letters = 0;
for (const char ch : item.pinyin)
{
letters += ch != '\'';
}
return letters;
};
result = quanpin::merge_fuzzy_candidates(std::move(result), fuzzy_candidates(typed, fuzzy), matched_letters);
}
// Labeling runs after every mutation (including the fuzzy merge) so the
// returned list and the published candidate list always agree.
if (fuzzy.rules)
keep_literal_mixed_words_first(result, raw_input);
mark_autocorrect_candidates(result, raw_input);
current_candidate_list_ = result;
return result;
}