Files
msime-windows/engine/providers/japanese_candidate_provider.cpp
古明四季andfanlusky 0bad4c1aaa fix(engine): 修复日语分段选词吞掉后续输入 (#1020)
* fix(engine): 修复日语分段选词吞掉后续输入

* fix(engine): 日语分段选词后重建混排候选并去掉剩余输入开头的分隔符

固定候选位打开时 candidates() 返回混排列表,分段选词后不重建就还是选词前整串的候选,
下一次选词会选到过期项。ka'ta 选「か」剩下 'ta,开头的分隔符转不出假名,剩余输入一个
候选都没有;现在和拼音路径一样去掉,去掉后为空就按整串上屏处理。
已转换部分的边界改用 segments,与整句候选同一来源。

---------

Co-authored-by: fanlusky <1730976608@qq.com>
2026-10-17 17:41:01 +08:00

322 lines
13 KiB
C++

#include "japanese_candidate_provider.h"
#include "../japanese/japanese_matrix_search.h"
#include "../japanese/romaji_converter.h"
#include "../quanpin/quanpin_query.h"
#include <algorithm>
#include <mutex>
#include <iterator>
#include <unordered_map>
#include "../core/data_path.h"
#include "../contracts/assets/assets.h"
#include <unordered_set>
#include <utility>
namespace
{
constexpr int kNoMutation = 0;
using SharedDecoder = std::shared_ptr<const japanese::JapaneseSentenceDecoder>;
SharedDecoder SharedSentenceDecoder(const std::string &path)
{
static std::mutex mutex;
static std::unordered_map<std::string, std::weak_ptr<const japanese::JapaneseSentenceDecoder>> models;
std::lock_guard lock(mutex);
for (auto it = models.begin(); it != models.end();)
it = it->second.expired() ? models.erase(it) : std::next(it);
auto &entry = models[path];
if (auto existing = entry.lock())
return existing;
auto model = std::make_shared<const japanese::JapaneseSentenceDecoder>(path);
if (model->ready())
entry = model;
return model;
}
// WordItem 默认带 Quanpin。日文候选必须标上自己的方案,否则调频、置顶、造词会把罗马音当拼音键,
// 把日文词写进拼音词库的日志,升级回放时它们就成了中文候选(#869)。
WordItem JapaneseItem(const std::string &code, const std::string &value, std::int64_t weight, CandidateSource source)
{
WordItem item(code, value, weight, source, code);
item.scheme = SchemeType::JapaneseRomaji;
return item;
}
void AppendUnique(std::vector<WordItem> &items, std::unordered_set<std::string> &seen, const std::string &code,
const std::string &value, std::int64_t weight, CandidateSource source = CandidateSource::Database)
{
if (!value.empty() && seen.insert(value).second)
{
items.push_back(JapaneseItem(code, value, weight, source));
}
}
std::string EscapeLikePrefix(const std::string &code)
{
std::string escaped;
escaped.reserve(code.size() + 1);
for (const char ch : code)
{
if (ch == '%' || ch == '_' || ch == '#')
escaped.push_back('#');
escaped.push_back(ch);
}
escaped.push_back('%');
return escaped;
}
} // namespace
JapaneseCandidateProvider::JapaneseCandidateProvider(std::string db_path, std::string model_path)
: db_path_(db_path.empty() ? quanpin::get_default_db_path() : std::move(db_path)),
model_path_(model_path.empty()
? metasequoia::path_to_utf8(metasequoia::data_file_path(metasequoia::assets::japanese_model))
: std::move(model_path))
{
}
JapaneseCandidateProvider::~JapaneseCandidateProvider()
{
close_database();
}
std::vector<WordItem> JapaneseCandidateProvider::query(const QueryRequest &request)
{
if (!request.valid || request.scheme != SchemeType::JapaneseRomaji)
{
return {};
}
std::vector<WordItem> candidates;
std::unordered_set<std::string> seen;
// 单独按 '-' 时给出两个候选:长音符 ー 在前,普通连字符 '-' 在后。
// 这里直接返回,避免句子搜索在两项之间插进无关候选。
if (request.raw_input == "-")
{
AppendUnique(candidates, seen, request.raw_input_with_cases, "ー", 1000000, CandidateSource::Generated);
AppendUnique(candidates, seen, request.raw_input_with_cases, "-", 999999, CandidateSource::Generated);
return candidates;
}
const auto conversion = japanese::ConvertRomaji(request.raw_input);
const bool kana_first = japanese::IsSingleKanaConversion(conversion);
if (kana_first)
{
AppendUnique(candidates, seen, request.raw_input_with_cases, conversion.hiragana, 1000000,
CandidateSource::Generated);
AppendUnique(candidates, seen, request.raw_input_with_cases, japanese::HiraganaToKatakana(conversion.hiragana),
999999, CandidateSource::Generated);
}
if (!sentence_decoder_)
sentence_decoder_ = SharedSentenceDecoder(model_path_);
if (sentence_decoder_ && sentence_decoder_->ready())
{
const auto pending_kana = japanese::KanaForRomajiPrefix(conversion.pending);
if (!conversion.hiragana.empty() && !conversion.pending.empty())
{
for (const auto &kana : pending_kana)
{
// The kana prefix already matches the typed reading. Converting it
// back to one romaji spelling would reject nn/n' and other aliases.
for (const auto &lemma : sentence_decoder_->PrefixLemmas(conversion.hiragana + kana, 24))
{
AppendUnique(candidates, seen, request.raw_input_with_cases, lemma.surface,
980000 - lemma.word_cost, CandidateSource::Database);
}
}
}
japanese::JapaneseMatrixSearch search(*sentence_decoder_);
const auto sentences = search.SearchConverted(conversion, 12);
size_t next_sentence = 0;
// Once the reading is complete, convert what was typed before predicting
// longer words: the best whole-reading path and exact lemmas, then
// predictions, then the remaining sentence tail (other paths, kana
// fallbacks and lemmas covering only a prefix of the reading). Keep the
// prediction-first path above for pending romaji.
if (conversion.pending.empty() && conversion.hiragana.size() >= 6)
{
std::int64_t weight_floor = 980000;
const auto append_exact = [&](const std::string &text, std::int64_t weight) {
const size_t before = candidates.size();
AppendUnique(candidates, seen, request.raw_input_with_cases, text, weight, CandidateSource::Database);
if (candidates.size() != before)
weight_floor = (std::min)(weight_floor, weight);
};
if (!sentences.empty())
append_exact(sentences[next_sentence++].text, 900000 - sentences.front().cost);
for (const auto &lemma : sentence_decoder_->ExactLemmas(conversion.hiragana, 16))
append_exact(lemma.surface, 900000 - lemma.word_cost);
// Predictions must not outweigh the conversions above them, or a
// weight-based re-sort would undo this order.
for (const auto &lemma : sentence_decoder_->LongerPrefixLemmas(conversion.hiragana, 16))
AppendUnique(candidates, seen, request.raw_input_with_cases, lemma.surface,
(std::min)(weight_floor, 980000 - static_cast<std::int64_t>(lemma.word_cost)),
CandidateSource::Database);
}
for (; next_sentence < sentences.size(); ++next_sentence)
{
const auto &sentence = sentences[next_sentence];
size_t input_length = request.raw_input_with_cases.size();
if (sentence.reading_length != 0)
{
const auto length = conversion.InputLengthForHiragana(sentence.reading_length);
// きゃ 等一次输入的假名组合不能从中间切开,否则会丢失后半个假名。
if (!length)
continue;
input_length = *length;
}
AppendUnique(candidates, seen, request.raw_input_with_cases.substr(0, input_length), sentence.text,
900000 - sentence.cost, CandidateSource::Database);
}
}
if (ensure_query_statement())
{
sqlite3_reset(query_statement_);
sqlite3_clear_bindings(query_statement_);
sqlite3_bind_text(query_statement_, 1, request.raw_input_with_cases.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(query_statement_, 2, request.raw_input.c_str(), -1, SQLITE_TRANSIENT);
const std::string like_raw = EscapeLikePrefix(request.raw_input);
const std::string like_q = EscapeLikePrefix(std::string("q") + request.raw_input);
sqlite3_bind_text(query_statement_, 3, like_raw.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(query_statement_, 4, like_q.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(query_statement_) == SQLITE_ROW)
{
const auto *code = reinterpret_cast<const char *>(sqlite3_column_text(query_statement_, 0));
const auto *value = reinterpret_cast<const char *>(sqlite3_column_text(query_statement_, 1));
if (code && value)
{
AppendUnique(candidates, seen, code, value, sqlite3_column_int64(query_statement_, 2));
}
}
}
if (!conversion.hiragana.empty() && !kana_first)
{
// 和整句候选用同一份按键边界,hiragana 非空时 segments 一定有末段。
const std::string converted_input =
request.raw_input_with_cases.substr(0, conversion.segments.back().input_end);
AppendUnique(candidates, seen, converted_input, conversion.hiragana, 1000000, CandidateSource::Generated);
AppendUnique(candidates, seen, converted_input, japanese::HiraganaToKatakana(conversion.hiragana), 999999,
CandidateSource::Generated);
}
const auto dynamic = dynamic_candidates_.get(request.raw_input);
if (dynamic.has_value())
{
std::size_t insertion = std::min<std::size_t>(kana_first ? 2 : 1, candidates.size());
for (const auto &item : *dynamic)
{
if (seen.insert(item.word).second)
{
candidates.insert(candidates.begin() + static_cast<std::ptrdiff_t>(insertion), item);
++insertion;
}
}
}
return candidates;
}
std::optional<WordItem> JapaneseCandidateProvider::find_candidate(SchemeType scheme, const std::string &key,
const std::string &value)
{
if (scheme != SchemeType::JapaneseRomaji || !ensure_query_statement())
return std::nullopt;
sqlite3_reset(query_statement_);
sqlite3_clear_bindings(query_statement_);
sqlite3_bind_text(query_statement_, 1, key.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(query_statement_, 2, key.c_str(), -1, SQLITE_TRANSIENT);
const std::string like_raw = EscapeLikePrefix(key);
const std::string like_q = EscapeLikePrefix(std::string("q") + key);
sqlite3_bind_text(query_statement_, 3, like_raw.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(query_statement_, 4, like_q.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(query_statement_) == SQLITE_ROW)
{
const auto *code = reinterpret_cast<const char *>(sqlite3_column_text(query_statement_, 0));
const auto *candidate = reinterpret_cast<const char *>(sqlite3_column_text(query_statement_, 1));
if (code && candidate && value == candidate)
return JapaneseItem(code, candidate, sqlite3_column_int64(query_statement_, 2), CandidateSource::Database);
}
return std::nullopt;
}
void JapaneseCandidateProvider::reset_cache()
{
close_database();
dynamic_candidates_.clear();
// The sentence model is immutable and shared process-wide. Keep it warm when
// SQLite/user-dictionary caches are reset.
}
int JapaneseCandidateProvider::create_word(SchemeType, std::string, std::string)
{
return kNoMutation;
}
int JapaneseCandidateProvider::update_weight_by_pinyin_and_word(SchemeType, std::string, std::string)
{
return kNoMutation;
}
int JapaneseCandidateProvider::delete_by_pinyin_and_word(SchemeType, std::string, std::string)
{
return kNoMutation;
}
int JapaneseCandidateProvider::cache_dynamic_candidate(SchemeType scheme, const std::string &code,
const std::string &word, CandidateSource source)
{
if (scheme != SchemeType::JapaneseRomaji || code.empty() || word.empty() ||
source != CandidateSource::CloudSuggestion)
{
return -1;
}
auto items = dynamic_candidates_.get(code).value_or(std::vector<WordItem>{});
items.erase(
std::remove_if(items.begin(), items.end(), [source](const WordItem &item) { return item.source == source; }),
items.end());
items.push_back(JapaneseItem(code, word, 1, source));
dynamic_candidates_.insert(code, items);
return kNoMutation;
}
int JapaneseCandidateProvider::cache_dynamic_candidate_for_request(const QueryRequest &request, const std::string &word,
CandidateSource source)
{
return cache_dynamic_candidate(request.scheme, request.raw_input, word, source);
}
bool JapaneseCandidateProvider::ensure_query_statement()
{
if (query_statement_)
return true;
if (!db_ &&
sqlite3_open_v2(db_path_.c_str(), &db_, SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX, nullptr) != SQLITE_OK)
{
close_database();
return false;
}
constexpr const char *sql = "SELECT code, value, weight FROM japanese_lexicon "
"WHERE code=?1 OR code=?2 OR code LIKE ?3 ESCAPE '#' OR code LIKE ?4 ESCAPE '#' "
"ORDER BY weight DESC, rowid ASC LIMIT 64";
if (sqlite3_prepare_v2(db_, sql, -1, &query_statement_, nullptr) != SQLITE_OK)
{
close_database();
return false;
}
return true;
}
void JapaneseCandidateProvider::close_database()
{
if (query_statement_)
{
sqlite3_finalize(query_statement_);
query_statement_ = nullptr;
}
if (db_)
{
sqlite3_close(db_);
db_ = nullptr;
}
}