From 42fbb9541ce7c311044b9bf37051d4ee83eeb5bb Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Tue, 29 Sep 2026 14:31:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(engine):=20=E5=BF=AB=E6=8D=B7=E7=9F=AD?= =?UTF-8?q?=E8=AF=AD=E6=B7=B7=E5=85=A5=E5=85=A8=E6=8B=BC=20/=20=E5=8F=8C?= =?UTF-8?q?=E6=8B=BC=E5=80=99=E9=80=89=E5=B9=B6=E5=8F=82=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+K 快捷短语模式保留,仍按编码前缀查询(query_quick_phrases)。另外全拼 / 双拼下 编码精确匹配(query_quick_phrases_by_code)的快捷短语作为一组插在固定位置候选之后,由 LocalModeOptions::quick_phrase_candidates 单独开关;五笔不混。 用户日志新增 quick_phrase_slots 表记录快捷短语组前的普通候选数:选中短语让组前移, 越过组选普通候选让组后移,后移上限为页长减一。quick_phrase_frequency 关掉时组固定在 首位。与普通候选同文本时保留快捷短语那份。 --- engine/CMakeLists.txt | 4 + engine/core/input_session.cpp | 111 ++++++- engine/core/input_session.h | 7 + engine/core/input_session_types.h | 5 + engine/local_modes/quick_phrase_query.cpp | 127 ++++++-- engine/local_modes/quick_phrase_query.h | 17 ++ engine/tests/src/test_local_modes.cpp | 11 + engine/tests/src/test_personal_dictionary.cpp | 14 + .../src/test_quick_phrase_input_session.cpp | 285 ++++++++++++++++++ engine/tests/src/test_runtime_isolation.cpp | 3 + .../user_dictionary_journal.cpp | 171 ++++++++++- .../user_dictionary/user_dictionary_journal.h | 16 + 12 files changed, 747 insertions(+), 24 deletions(-) create mode 100644 engine/tests/src/test_quick_phrase_input_session.cpp diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 9c1c1e6d..58fe544d 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -232,6 +232,10 @@ if(BUILD_TESTING) target_link_libraries(test_english_input_session PRIVATE MetasequoiaIme::Engine) add_test(NAME english_input_session COMMAND test_english_input_session) + add_executable(test_quick_phrase_input_session tests/src/test_quick_phrase_input_session.cpp) + target_link_libraries(test_quick_phrase_input_session PRIVATE MetasequoiaIme::Engine) + add_test(NAME quick_phrase_input_session COMMAND test_quick_phrase_input_session) + add_executable(test_mixed_expressive_input_session tests/src/test_mixed_expressive_input_session.cpp) target_link_libraries(test_mixed_expressive_input_session PRIVATE MetasequoiaIme::Engine) add_test(NAME mixed_expressive_input_session COMMAND test_mixed_expressive_input_session) diff --git a/engine/core/input_session.cpp b/engine/core/input_session.cpp index dda51511..6623f75a 100644 --- a/engine/core/input_session.cpp +++ b/engine/core/input_session.cpp @@ -23,6 +23,9 @@ namespace metasequoia { namespace { +// 引擎没有页长的概念,快捷短语组最多退到默认页长 6 的末位。 +constexpr int kQuickPhraseMaxSlot = 5; + const char *frequency_mode_name(FrequencyAdjustmentMode mode) { switch (mode) @@ -420,7 +423,13 @@ const FrequencyAdjustmentOptions &InputSession::frequency_adjustment() const void InputSession::set_local_mode_options(LocalModeOptions options) { + const bool quick_phrase_changed = local_mode_options_.quick_phrase_candidates != options.quick_phrase_candidates || + local_mode_options_.quick_phrase_frequency != options.quick_phrase_frequency; local_mode_options_ = options; + if (quick_phrase_changed && local_input_mode_ == LocalInputMode::None && !dedicated_english_mode_) + { + update_mixed_candidates(); + } if ((local_input_mode_ == LocalInputMode::Unicode && !local_mode_options_.unicode) || (local_input_mode_ == LocalInputMode::DateTime && !local_mode_options_.date_time) || (local_input_mode_ == LocalInputMode::QuickPhrase && !local_mode_options_.quick_phrase) || @@ -648,10 +657,7 @@ const std::vector &InputSession::candidates() const { return local_candidates_; } - if (fixed_positions_enabled_ || - ((english_input_options_.mixed_candidates || mixed_expressive_options_.emoji_candidates || - mixed_expressive_options_.kaomoji_candidates) && - (scheme() == SchemeType::Quanpin || scheme() == SchemeType::Shuangpin))) + if (mixed_candidates_active()) { // update_mixed_candidates() assembles this from the decoded prefix while it is active. return mixed_candidates_; @@ -840,6 +846,98 @@ void InputSession::update_mixed_candidates() mixed_candidates_ = candidate_queries_.mixed(decoded, association_prefix, scheme(), english_input_options_, mixed_expressive_options_, dedicated_english_mode_, local_input_mode_); apply_candidate_positions(mixed_candidates_); + // 只有混排列表才会被 candidates() 返回;Server 自己混排,不走这里。 + const auto code = mixed_candidates_active() ? quick_phrase_code() : std::nullopt; + if (!code) + { + return; + } + auto phrases = local_modes::query_quick_phrases_by_code(*code, paths_.dictionary(assets::main_dictionary)); + if (phrases.candidates.empty()) + { + return; + } + std::size_t slot = 0; + if (local_mode_options_.quick_phrase_frequency && frequency_adjustment_configured_ && + frequency_adjustment_.mode != FrequencyAdjustmentMode::Disabled) + { + const int stored = user_dictionary::quick_phrase_slot(path_to_utf8(paths_.user(assets::user_journal)), *code); + slot = static_cast(std::clamp(stored, 0, kQuickPhraseMaxSlot)); + } + local_modes::place_quick_phrases(mixed_candidates_, std::move(phrases.candidates), slot); +} + +bool InputSession::mixed_candidates_active() const +{ + return fixed_positions_enabled_ || + ((english_input_options_.mixed_candidates || mixed_expressive_options_.emoji_candidates || + mixed_expressive_options_.kaomoji_candidates) && + (scheme() == SchemeType::Quanpin || scheme() == SchemeType::Shuangpin)); +} + +std::optional InputSession::quick_phrase_code() const +{ + if (!local_mode_options_.quick_phrase_candidates || local_input_mode_ != LocalInputMode::None || + dedicated_english_mode_ || (scheme() != SchemeType::Quanpin && scheme() != SchemeType::Shuangpin) || + prefix_candidates_active_ || !immediate_phrase_progress_.word.empty() || has_active_helpcode()) + { + return std::nullopt; + } + const auto &request = engine_.get_request(); + const std::string &code = request.raw_input_with_cases.empty() ? request.raw_input : request.raw_input_with_cases; + if (code.empty() || !std::all_of(code.begin(), code.end(), + [](unsigned char character) { return character >= 'a' && character <= 'z'; })) + { + return std::nullopt; + } + return code; +} + +std::optional InputSession::learn_quick_phrase_order(std::size_t index) +{ + if (!local_mode_options_.quick_phrase_frequency || !frequency_adjustment_configured_ || + frequency_adjustment_.mode == FrequencyAdjustmentMode::Disabled) + { + return std::nullopt; + } + const auto code = quick_phrase_code(); + if (!code) + { + return std::nullopt; + } + const auto &list = candidates(); + const WordItem &selected = list[index]; + const std::string main_db = path_to_utf8(paths_.dictionary(assets::main_dictionary)); + const std::string user_db = path_to_utf8(paths_.user(assets::user_journal)); + const std::string mode = frequency_mode_name(frequency_adjustment_.mode); + if (selected.source == CandidateSource::QuickPhrase) + { + const auto first = std::find_if( + list.begin(), list.end(), [](const WordItem &item) { return item.source == CandidateSource::QuickPhrase; }); + const bool first_in_group = first == list.begin() + static_cast(index); + if (!user_dictionary::learn_quick_phrase_selection(main_db, user_db, *code, selected.word, first_in_group, mode, + frequency_adjustment_.linear_step, + frequency_adjustment_.trigger_count)) + { + return std::string("Unable to persist quick phrase frequency adjustment."); + } + return std::nullopt; + } + if (!local_modes::counts_toward_quick_phrase_slot(selected) || + std::none_of(list.begin(), list.end(), + [](const WordItem &item) { return item.source == CandidateSource::QuickPhrase; })) + { + return std::nullopt; + } + const auto ordinary_rank = std::count_if(list.begin(), list.begin() + static_cast(index), + local_modes::counts_toward_quick_phrase_slot); + if (!user_dictionary::learn_quick_phrase_bypass(user_db, *code, static_cast(ordinary_rank), mode, + frequency_adjustment_.linear_step, + frequency_adjustment_.trigger_count, kQuickPhraseMaxSlot)) + { + return std::string("Unable to persist quick phrase frequency adjustment."); + } + return std::nullopt; } void InputSession::set_caret(std::optional caret) @@ -991,6 +1089,11 @@ std::optional InputSession::learn_candidate(std::size_t index) } const WordItem &selected = candidates()[index]; + std::optional quick_phrase_diagnostic = learn_quick_phrase_order(index); + if (selected.source == CandidateSource::QuickPhrase) + { + return quick_phrase_diagnostic; + } // 整句候选走造词,不走调频:它在词库里没有行可改。不受 frequency_adjustment_ // 约束(那是候选词频策略),也不跳过 index == 0——首位整句同样没有落库, // 下次仍要靠猜,存下来才稳定。 diff --git a/engine/core/input_session.h b/engine/core/input_session.h index aa9cfbb7..62fa5d47 100644 --- a/engine/core/input_session.h +++ b/engine/core/input_session.h @@ -275,6 +275,13 @@ class InputSession void reset_composition(); void discard_abandoned_phrase_progress(); std::optional learn_candidate(std::size_t index); + // 当前组合可以混入快捷短语时返回它的编码:全拼/双拼、非本地模式、未造词、无光标前缀和 + // 辅助码,且原始输入全是小写字母。 + std::optional quick_phrase_code() const; + // candidates() 返回混排列表(固定位置、英文/表情/颜文字混排任一开启)的条件。 + bool mixed_candidates_active() const; + // 选中快捷短语时把组前移,越过组选普通候选时把组后移。 + std::optional learn_quick_phrase_order(std::size_t index); // 词格 / Google 解码器猜出来的整句在词库里没有对应行,选中后落成一条用户词组。 std::optional learn_sentence_candidate(const WordItem &selected); std::optional adjust_candidate_frequency(std::size_t index, FrequencyAdjustmentOptions options, diff --git a/engine/core/input_session_types.h b/engine/core/input_session_types.h index 1f4d4fca..2e6a2de3 100644 --- a/engine/core/input_session_types.h +++ b/engine/core/input_session_types.h @@ -72,7 +72,12 @@ struct LocalModeOptions { bool unicode = true; bool date_time = true; + // Shift+K 快捷短语模式,按编码前缀查。 bool quick_phrase = true; + // 全拼/双拼里把编码精确匹配的快捷短语混进候选;五笔不混。与 K 模式各自开关。 + bool quick_phrase_candidates = true; + // 快捷短语参与调频:选择会把整组前移,越过它选普通候选会把它后移。关掉时组固定在首位。 + bool quick_phrase_frequency = true; bool emoji = true; bool kaomoji = true; bool super_jianpin = true; diff --git a/engine/local_modes/quick_phrase_query.cpp b/engine/local_modes/quick_phrase_query.cpp index 8c957ccc..9d5dff57 100644 --- a/engine/local_modes/quick_phrase_query.cpp +++ b/engine/local_modes/quick_phrase_query.cpp @@ -7,6 +7,7 @@ #include #include +#include #include namespace metasequoia::local_modes @@ -26,27 +27,21 @@ struct StatementCloser using Statement = std::unique_ptr; -bool valid_prefix(const std::string &prefix) +bool valid_code(const std::string &code) { - return !prefix.empty() && std::all_of(prefix.begin(), prefix.end(), - [](unsigned char character) { return character >= 'a' && character <= 'z'; }); + return !code.empty() && std::all_of(code.begin(), code.end(), + [](unsigned char character) { return character >= 'a' && character <= 'z'; }); } QuickPhraseQueryResult query_failure(const char *diagnostic) { return {{}, std::string(diagnostic)}; } -} // namespace - -QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, int limit) +// 前缀查询给 K 模式用,精确查询给全拼/双拼混排用;上界拼 0x7f 让 key 落在 [code, code+0x7f)。 +QuickPhraseQueryResult query_quick_phrase_rows(const std::string &code, const std::filesystem::path &database_path, + int limit, bool exact) { - return query_quick_phrases(prefix, data_file_path(metasequoia::assets::main_dictionary), limit); -} - -QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, const std::filesystem::path &database_path, - int limit) -{ - if (!valid_prefix(prefix) || limit <= 0) + if (!valid_code(code) || limit <= 0) { return {}; } @@ -61,17 +56,22 @@ QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, const std: return query_failure("Quick phrase database is unavailable."); } - constexpr const char *kSql = "SELECT key,value,weight FROM quick_parases WHERE key>=?1 AND key=?1 AND key(0x7f)); - if (sqlite3_bind_text(statement.get(), 1, prefix.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK || + std::string upper_bound = code; + if (!exact) + { + upper_bound.push_back(static_cast(0x7f)); + } + if (sqlite3_bind_text(statement.get(), 1, code.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement.get(), 2, upper_bound.c_str(), -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_int(statement.get(), 3, limit) != SQLITE_OK) { @@ -96,4 +96,93 @@ QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, const std: } return result; } +} // namespace + +QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, int limit) +{ + return query_quick_phrases(prefix, data_file_path(metasequoia::assets::main_dictionary), limit); +} + +QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, const std::filesystem::path &database_path, + int limit) +{ + return query_quick_phrase_rows(prefix, database_path, limit, false); +} + +QuickPhraseQueryResult query_quick_phrases_by_code(const std::string &code, int limit) +{ + return query_quick_phrases_by_code(code, data_file_path(metasequoia::assets::main_dictionary), limit); +} + +QuickPhraseQueryResult query_quick_phrases_by_code(const std::string &code, const std::filesystem::path &database_path, + int limit) +{ + return query_quick_phrase_rows(code, database_path, limit, true); +} + +bool counts_toward_quick_phrase_slot(const WordItem &item) +{ + if (item.fixed_position > 0) + { + return false; + } + switch (item.source) + { + case CandidateSource::QuickPhrase: + case CandidateSource::CloudSuggestion: + case CandidateSource::AiSuggestion: + case CandidateSource::EnglishDictionary: + case CandidateSource::Emoji: + case CandidateSource::Kaomoji: + return false; + default: + return true; + } +} + +void place_quick_phrases(std::vector &candidates, std::vector phrases, std::size_t slot) +{ + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), + [&](const WordItem &item) { + return item.source == CandidateSource::QuickPhrase || + std::any_of(phrases.begin(), phrases.end(), [&](const WordItem &phrase) { + return phrase.word == item.word; + }); + }), + candidates.end()); + if (phrases.empty()) + { + return; + } + + std::vector fixed; + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), + [&](WordItem &item) { + if (item.fixed_position <= 0) + return false; + fixed.push_back(std::move(item)); + return true; + }), + candidates.end()); + + std::size_t index = 0; + for (std::size_t counted = 0; index < candidates.size() && counted < slot; ++index) + { + if (counts_toward_quick_phrase_slot(candidates[index])) + { + ++counted; + } + } + candidates.insert(candidates.begin() + static_cast(index), std::make_move_iterator(phrases.begin()), + std::make_move_iterator(phrases.end())); + + std::stable_sort(fixed.begin(), fixed.end(), [](const WordItem &left, const WordItem &right) { + return left.fixed_position < right.fixed_position; + }); + for (auto &item : fixed) + { + const std::size_t position = (std::min)(static_cast(item.fixed_position - 1), candidates.size()); + candidates.insert(candidates.begin() + static_cast(position), std::move(item)); + } +} } // namespace metasequoia::local_modes diff --git a/engine/local_modes/quick_phrase_query.h b/engine/local_modes/quick_phrase_query.h index e6b2058e..8a90aeb3 100644 --- a/engine/local_modes/quick_phrase_query.h +++ b/engine/local_modes/quick_phrase_query.h @@ -2,14 +2,31 @@ #include "local_query_result.h" +#include #include #include +#include namespace metasequoia::local_modes { using QuickPhraseQueryResult = LocalQueryResult; +// K 模式:按编码前缀匹配,权重降序。 QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, int limit = 100); QuickPhraseQueryResult query_quick_phrases(const std::string &prefix, const std::filesystem::path &database_path, int limit = 100); + +// 混进全拼/双拼普通候选的那组:按编码精确匹配,组内按权重降序。 +QuickPhraseQueryResult query_quick_phrases_by_code(const std::string &code, int limit = 20); +QuickPhraseQueryResult query_quick_phrases_by_code(const std::string &code, const std::filesystem::path &database_path, + int limit = 20); + +// 快捷短语组的槽位只数这些候选:词库、用户词库和整句。云/AI/英文/表情/颜文字按各自的 +// 异步槽位插队,固定位置的候选按自己的位置放,都不参与计数。 +bool counts_toward_quick_phrase_slot(const WordItem &item); + +// 把同码快捷短语整组插到第 slot 个普通候选之前(slot 为 0 即首位,固定位置候选仍在它们 +// 自己的位置上)。与快捷短语同文字的其他候选会被去掉,只留快捷短语那份;列表里已有的 +// 快捷短语先整体移除,重复调用结果不变。 +void place_quick_phrases(std::vector &candidates, std::vector phrases, std::size_t slot); } // namespace metasequoia::local_modes diff --git a/engine/tests/src/test_local_modes.cpp b/engine/tests/src/test_local_modes.cpp index 4c16e77b..f97344f5 100644 --- a/engine/tests/src/test_local_modes.cpp +++ b/engine/tests/src/test_local_modes.cpp @@ -176,6 +176,17 @@ int main() metasequoia::local_modes::query_quick_phrases("a", quick_phrase_database, 0).candidates.empty(), "Quick-phrase query accepted an invalid prefix or limit."); + const auto exact_quick_phrases = + metasequoia::local_modes::query_quick_phrases_by_code("aa", quick_phrase_database, 10); + require(!exact_quick_phrases.diagnostic.has_value() && exact_quick_phrases.candidates.size() == 2 && + exact_quick_phrases.candidates[0].word == "tie a" && exact_quick_phrases.candidates[1].word == "tie b", + "Exact quick-phrase query diverged from its code."); + require( + metasequoia::local_modes::query_quick_phrases_by_code("a", quick_phrase_database, 10).candidates.empty() && + metasequoia::local_modes::query_quick_phrases_by_code("A", quick_phrase_database, 10).candidates.empty() && + metasequoia::local_modes::query_quick_phrases_by_code("aa", quick_phrase_database, 0).candidates.empty(), + "Exact quick-phrase query matched a prefix or accepted an invalid code or limit."); + const auto missing_quick_phrases = metasequoia::local_modes::query_quick_phrases("secret", quick_phrase_directory / "private-missing.db", 10); require(missing_quick_phrases.candidates.empty() && missing_quick_phrases.diagnostic.has_value() && diff --git a/engine/tests/src/test_personal_dictionary.cpp b/engine/tests/src/test_personal_dictionary.cpp index ebd741b6..bf355539 100644 --- a/engine/tests/src/test_personal_dictionary.cpp +++ b/engine/tests/src/test_personal_dictionary.cpp @@ -98,6 +98,20 @@ void run() [&](const auto &candidate) { return candidate.word == quick.value; }), "Quick phrase is missing from the local input mode"); } + { + // 快捷短语按编码精确匹配、混在普通候选首位。 + PersonalDictionaryEntry letters{PersonalDictionaryKind::QuickPhrase, "qpfixture", "letters phrase", 10}; + require(edit_personal_dictionary(paths, std::nullopt, letters).success, "Quick phrase add failed"); + SessionOptions options; + options.paths = paths; + Session session(options); + for (char ch : letters.key) + session.character(ch); + const auto snapshot = session.snapshot(); + require(!snapshot.candidates.empty() && snapshot.candidates.front().word == letters.value, + "Quick phrase is not mixed first into the candidate list"); + require(edit_personal_dictionary(paths, letters, std::nullopt).success, "Quick phrase delete failed"); + } // Moving between dictionaries must commit the old tombstone and new candidate together. PersonalDictionaryEntry moved{PersonalDictionaryKind::English, "fixtureword", "FixtureWord", quick.weight}; require(edit_personal_dictionary(paths, quick, moved).success && diff --git a/engine/tests/src/test_quick_phrase_input_session.cpp b/engine/tests/src/test_quick_phrase_input_session.cpp new file mode 100644 index 00000000..604dadcb --- /dev/null +++ b/engine/tests/src/test_quick_phrase_input_session.cpp @@ -0,0 +1,285 @@ +#include "../../contracts/assets/assets.h" +#include "../../core/data_path.h" +#include "../../core/input_session.h" +#include "../../core/runtime_paths.h" +#include "../../local_modes/quick_phrase_query.h" +#include "../../user_dictionary/user_dictionary_journal.h" +#include "test_directory_cleanup.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class Database +{ + public: + explicit Database(const std::filesystem::path &path) + { + if (sqlite3_open(metasequoia::path_to_utf8(path).c_str(), &database_) != SQLITE_OK) + { + throw std::runtime_error("Failed to open the quick-phrase test dictionary."); + } + } + + ~Database() + { + sqlite3_close(database_); + } + + void execute(const char *sql) + { + char *error = nullptr; + if (sqlite3_exec(database_, sql, nullptr, nullptr, &error) != SQLITE_OK) + { + const std::string message = error == nullptr ? "SQLite operation failed." : error; + sqlite3_free(error); + throw std::runtime_error(message); + } + } + + std::int64_t query_integer(const char *sql) + { + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(database_, sql, -1, &statement, nullptr) != SQLITE_OK || + sqlite3_step(statement) != SQLITE_ROW) + { + sqlite3_finalize(statement); + throw std::runtime_error("Failed to query the quick-phrase test dictionary."); + } + const std::int64_t value = sqlite3_column_int64(statement, 0); + sqlite3_finalize(statement); + return value; + } + + private: + sqlite3 *database_ = nullptr; +}; + +void require(bool condition, const char *message) +{ + if (!condition) + { + throw std::runtime_error(message); + } +} + +void type(metasequoia::InputSession &session, const std::string &text) +{ + for (const char character : text) + { + require(session.handle_character(character).handled, "A quick-phrase test character was not handled."); + } +} + +void set_data_directory(const std::filesystem::path &directory) +{ +#ifdef _WIN32 + if (_wputenv_s(L"METASEQUOIA_IME_DATA_DIR", directory.c_str()) != 0) +#else + if (setenv("METASEQUOIA_IME_DATA_DIR", metasequoia::path_to_utf8(directory).c_str(), 1) != 0) +#endif + { + throw std::runtime_error("Failed to set the quick-phrase test data directory."); + } +} + +void prepare_main_database(const std::filesystem::path &directory) +{ + std::filesystem::create_directories(directory); + Database database(directory / "msime.db"); + database.execute("CREATE TABLE tbl_1_n(key TEXT,jp TEXT,value TEXT,weight INTEGER)"); + database.execute("INSERT INTO tbl_1_n VALUES('ni','n','你',300)"); + database.execute("INSERT INTO tbl_1_n VALUES('ni','n','呢',200)"); + database.execute("INSERT INTO tbl_1_n VALUES('ni','n','泥',100)"); + database.execute("CREATE TABLE quick_parases(key TEXT,value TEXT,weight INTEGER)"); + database.execute("INSERT INTO quick_parases VALUES('ni','快捷一',20)"); + database.execute("INSERT INTO quick_parases VALUES('ni','快捷二',10)"); + database.execute("INSERT INTO quick_parases VALUES('ni','泥',5)"); +} + +std::size_t first_quick_phrase(const std::vector &candidates) +{ + const auto found = std::find_if(candidates.begin(), candidates.end(), + [](const WordItem &item) { return item.source == CandidateSource::QuickPhrase; }); + return static_cast(found - candidates.begin()); +} + +std::size_t index_of(const std::vector &candidates, const std::string &word) +{ + const auto found = + std::find_if(candidates.begin(), candidates.end(), [&](const WordItem &item) { return item.word == word; }); + return static_cast(found - candidates.begin()); +} + +std::unique_ptr make_session(bool frequency) +{ + auto session = std::make_unique(SchemeType::Quanpin); + session->enable_fixed_positions(); + if (!session->set_frequency_adjustment({metasequoia::FrequencyAdjustmentMode::Promote, 1, 1})) + { + throw std::runtime_error("Promote frequency adjustment was rejected."); + } + metasequoia::LocalModeOptions options; + options.quick_phrase_frequency = frequency; + session->set_local_mode_options(options); + return session; +} + +void test_placement() +{ + auto session = make_session(true); + type(*session, "ni"); + const auto &candidates = session->candidates(); + require(candidates.size() >= 5 && candidates[0].word == "快捷一" && candidates[1].word == "快捷二" && + candidates[2].word == "泥" && candidates[2].source == CandidateSource::QuickPhrase && + candidates[3].word == "你", + "Quick phrases were not placed first in weight order."); + require(std::count_if(candidates.begin(), candidates.end(), + [](const WordItem &item) { return item.word == "泥"; }) == 1, + "An ordinary candidate duplicating a quick phrase was kept."); + + auto partial = make_session(true); + type(*partial, "n"); + require(first_quick_phrase(partial->candidates()) == partial->candidates().size(), + "Quick phrases matched a code prefix instead of the exact code."); + + metasequoia::InputSession wubi(SchemeType::Wubi); + wubi.enable_fixed_positions(); + (void)wubi.handle_character('n'); + (void)wubi.handle_character('i'); + require(first_quick_phrase(wubi.candidates()) == wubi.candidates().size(), + "Quick phrases were mixed into a wubi composition."); + + // 混排关掉只影响候选,Shift+K 仍可用。 + metasequoia::InputSession disabled(SchemeType::Quanpin); + disabled.enable_fixed_positions(); + metasequoia::LocalModeOptions disabled_options; + disabled_options.quick_phrase_candidates = false; + disabled.set_local_mode_options(disabled_options); + type(disabled, "ni"); + require(first_quick_phrase(disabled.candidates()) == disabled.candidates().size() && + index_of(disabled.candidates(), "泥") < disabled.candidates().size(), + "Disabled quick-phrase candidates still reached the candidate list."); + metasequoia::InputSession k_only(SchemeType::Quanpin); + k_only.set_local_mode_options(disabled_options); + require(k_only.handle_character('K', true).handled && + k_only.local_input_mode() == metasequoia::LocalInputMode::QuickPhrase, + "Disabling quick-phrase candidates also disabled Shift+K."); + type(k_only, "n"); + require(!k_only.candidates().empty() && k_only.candidates().front().word == "快捷一", + "Shift+K lost its prefix query when quick-phrase candidates were disabled."); + + // K 模式关掉不影响混排。 + auto without_k = make_session(true); + metasequoia::LocalModeOptions without_k_options; + without_k_options.quick_phrase = false; + without_k->set_local_mode_options(without_k_options); + require(!without_k->handle_character('K', true).handled, "A disabled Shift+K was still swallowed."); + type(*without_k, "ni"); + require(first_quick_phrase(without_k->candidates()) == 0, + "Disabling Shift+K also removed quick phrases from the candidate list."); +} + +void test_learning(const std::filesystem::path &directory) +{ + const std::string user_db = + metasequoia::path_to_utf8(metasequoia::RuntimePaths::legacy().user(metasequoia::assets::user_journal)); + + // 越过组选普通候选:组往后退一格。 + auto first = make_session(true); + type(*first, "ni"); + require(first->select_candidate(index_of(first->candidates(), "你")).commit == "你", + "Selecting an ordinary candidate committed the wrong text."); + require(user_dictionary::quick_phrase_slot(user_db, "ni") == 1, + "Bypassing the quick-phrase group did not demote it."); + + auto second = make_session(true); + type(*second, "ni"); + require(first_quick_phrase(second->candidates()) == 1 && second->candidates()[0].word == "你", + "A demoted quick-phrase group was not placed after one ordinary candidate."); + // 选的普通候选排在组前面,不算越过。 + (void)second->select_candidate(0); + require(user_dictionary::quick_phrase_slot(user_db, "ni") == 1, + "Selecting a candidate above the group changed its slot."); + + auto third = make_session(true); + type(*third, "ni"); + (void)third->select_candidate(index_of(third->candidates(), "呢")); + require(user_dictionary::quick_phrase_slot(user_db, "ni") == 2, + "Bypassing the group again did not demote it further."); + + // 选中组里非首条的快捷短语:组前移,并在组内升到最前。 + auto fourth = make_session(true); + type(*fourth, "ni"); + require(first_quick_phrase(fourth->candidates()) == 2, "The quick-phrase group was not at slot 2."); + const auto commit = fourth->select_candidate(index_of(fourth->candidates(), "快捷二")); + require(commit.commit == "快捷二" && !commit.diagnostic.has_value(), + "Selecting a quick phrase did not commit it cleanly."); + require(user_dictionary::quick_phrase_slot(user_db, "ni") == 1, + "Selecting a quick phrase did not promote the group."); + { + Database database(directory / "msime.db"); + require(database.query_integer("SELECT weight FROM quick_parases WHERE key='ni' AND value='快捷二'") == 21, + "The selected quick phrase was not raised above its group."); + } + + auto fifth = make_session(true); + type(*fifth, "ni"); + require(fifth->candidates()[1].word == "快捷二" && fifth->candidates()[2].word == "快捷一", + "The learned quick-phrase order was not applied."); + + // 调频开关关掉:组固定在首位,选择也不再学习。 + auto fixed = make_session(false); + type(*fixed, "ni"); + require(first_quick_phrase(fixed->candidates()) == 0, + "Disabling quick-phrase frequency did not keep the group first."); + (void)fixed->select_candidate(index_of(fixed->candidates(), "你")); + require(user_dictionary::quick_phrase_slot(user_db, "ni") == 1, + "Quick-phrase learning ran with its switch disabled."); +} + +void test_slot_cap() +{ + std::vector candidates; + for (int index = 0; index < 8; ++index) + { + candidates.emplace_back("ni", "词" + std::to_string(index), 100 - index, CandidateSource::Database); + } + candidates[1].fixed_position = 1; + std::vector phrases{WordItem("ni", "短语", 1, CandidateSource::QuickPhrase)}; + metasequoia::local_modes::place_quick_phrases(candidates, phrases, 2); + require(candidates[0].word == "词1" && candidates[1].word == "词0" && candidates[2].word == "词2" && + candidates[3].word == "短语", + "A pinned candidate was counted toward the quick-phrase slot."); + metasequoia::local_modes::place_quick_phrases(candidates, phrases, 2); + require(std::count_if(candidates.begin(), candidates.end(), + [](const WordItem &item) { return item.source == CandidateSource::QuickPhrase; }) == 1 && + candidates[3].word == "短语", + "Placing quick phrases twice was not idempotent."); +} +} // namespace + +int main() +{ + const auto suffix = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + const std::filesystem::path root = + std::filesystem::temp_directory_path() / ("metasequoia-quick-phrase-session-" + suffix); + metasequoia::test::ScopedDataDirectoryCleanup cleanup(root); + const std::filesystem::path directory = root / "data"; + prepare_main_database(directory); + set_data_directory(directory); + + test_placement(); + test_learning(directory); + test_slot_cap(); + return 0; +} diff --git a/engine/tests/src/test_runtime_isolation.cpp b/engine/tests/src/test_runtime_isolation.cpp index 6afa080a..85d060e3 100644 --- a/engine/tests/src/test_runtime_isolation.cpp +++ b/engine/tests/src/test_runtime_isolation.cpp @@ -421,6 +421,9 @@ void test_runtime_isolation() a.character('K', true); a.character('x'); require(a.snapshot().candidates.front().word == "你短语", "Local mode ignored runtime paths"); + a.command(Command::Cancel); + a.character('x'); + require(a.snapshot().candidates.front().word == "你短语", "Mixed quick phrases ignored runtime paths"); } { SessionOptions options; diff --git a/engine/user_dictionary/user_dictionary_journal.cpp b/engine/user_dictionary/user_dictionary_journal.cpp index 90cc8a1d..467fca66 100644 --- a/engine/user_dictionary/user_dictionary_journal.cpp +++ b/engine/user_dictionary/user_dictionary_journal.cpp @@ -129,7 +129,9 @@ bool ensure_schema(sqlite3 *db) "CREATE TABLE IF NOT EXISTS fixed_candidate_positions(" "context_key TEXT NOT NULL,entry_key TEXT NOT NULL,value TEXT NOT NULL," "position INTEGER NOT NULL CHECK(position BETWEEN 1 AND 5)," - "PRIMARY KEY(context_key,entry_key,value),UNIQUE(context_key,position));"; + "PRIMARY KEY(context_key,entry_key,value),UNIQUE(context_key,position));" + "CREATE TABLE IF NOT EXISTS quick_phrase_slots(" + "code TEXT PRIMARY KEY,slot INTEGER NOT NULL CHECK(slot>=0));"; if (sqlite3_exec(db, sql, nullptr, nullptr, nullptr) != SQLITE_OK) return false; @@ -801,6 +803,173 @@ bool bump_wubi_weight(const std::string &main_db_path, const std::string &user_d return true; } +namespace +{ +// 快捷短语组的选择计数借用 candidate_selection_state,context 按编码区分,promote / bypass +// 两个方向各数各的。value 写编码本身:状态导出要求它非空。 +const std::string kQuickPhraseSelectionContext = "quick_phrase:"; + +bool read_quick_phrase_slot(sqlite3 *db, const std::string &schema, const std::string &code, int &slot) +{ + auto stmt = prepare(db, "SELECT slot FROM " + schema + "quick_phrase_slots WHERE code=?1"); + if (!stmt || !bind_text(stmt.get(), 1, code)) + return false; + const int status = sqlite3_step(stmt.get()); + if (status == SQLITE_ROW) + { + slot = (std::max)(0, sqlite3_column_int(stmt.get(), 0)); + return true; + } + slot = 0; + return status == SQLITE_DONE; +} + +bool write_quick_phrase_slot(sqlite3 *db, const std::string &schema, const std::string &code, int slot) +{ + auto stmt = slot <= 0 ? prepare(db, "DELETE FROM " + schema + "quick_phrase_slots WHERE code=?1") + : prepare(db, "INSERT INTO " + schema + + "quick_phrase_slots(code,slot) VALUES(?1,?2)" + " ON CONFLICT(code) DO UPDATE SET slot=excluded.slot"); + return stmt && bind_text(stmt.get(), 1, code) && + (slot <= 0 || sqlite3_bind_int(stmt.get(), 2, slot) == SQLITE_OK) && sqlite3_step(stmt.get()) == SQLITE_DONE; +} + +// 计一次数;到 trigger_count 时置 reached 并清零,计数不会越过状态导出允许的上限。 +bool count_quick_phrase_selection(sqlite3 *db, const std::string &schema, const std::string &code, + const char *direction, int trigger_count, bool &reached) +{ + reached = false; + const std::string context = kQuickPhraseSelectionContext + code; + { + auto counter = prepare(db, "INSERT INTO " + schema + + "candidate_selection_state(context_key,entry_key,value,selection_count)" + " VALUES(?1,?2,?3,1) ON CONFLICT(context_key,entry_key,value)" + " DO UPDATE SET selection_count=selection_count+1 RETURNING selection_count"); + if (!counter || !bind_text(counter.get(), 1, context) || !bind_text(counter.get(), 2, direction) || + !bind_text(counter.get(), 3, code) || sqlite3_step(counter.get()) != SQLITE_ROW) + return false; + reached = sqlite3_column_int(counter.get(), 0) >= (std::max)(1, (std::min)(10, trigger_count)); + } + if (!reached) + return true; + auto reset = prepare(db, "DELETE FROM " + schema + + "candidate_selection_state WHERE context_key=?1 AND entry_key=?2 AND value=?3"); + return reset && bind_text(reset.get(), 1, context) && bind_text(reset.get(), 2, direction) && + bind_text(reset.get(), 3, code) && sqlite3_step(reset.get()) == SQLITE_DONE; +} +} // namespace + +int quick_phrase_slot(const std::string &user_db_path, const std::string &code) +{ + if (code.empty()) + return 0; + UserDatabase db(user_db_path); + int slot = 0; + if (!db || !read_quick_phrase_slot(db.get(), "", code, slot)) + return 0; + return slot; +} + +bool learn_quick_phrase_selection(const std::string &main_db_path, const std::string &user_db_path, + const std::string &code, const std::string &value, bool first_in_group, + const std::string &mode, int linear_step, int trigger_count) +{ + if (code.empty() || value.empty()) + return false; + if (mode == "disabled") + return true; + // Initialize the journal before attaching it; every operation owns its connections. + if (!ensure_user_database(user_db_path)) + return false; + auto database = open_database(main_db_path, SQLITE_OPEN_READWRITE); + auto attach = database ? prepare(database.get(), "ATTACH DATABASE ?1 AS candidate_journal") : Stmt{}; + if (!attach || !bind_text(attach.get(), 1, user_db_path) || sqlite3_step(attach.get()) != SQLITE_DONE) + return false; + attach.reset(); + if (!execute_sql(database.get(), "BEGIN IMMEDIATE")) + return false; + const auto rollback = [&]() { + (void)execute_sql(database.get(), "ROLLBACK"); + return false; + }; + const auto commit = [&]() { return execute_sql(database.get(), "COMMIT") || rollback(); }; + const std::string schema = "candidate_journal."; + int slot = 0; + if (!read_quick_phrase_slot(database.get(), schema, code, slot)) + return rollback(); + // 组在首位、选的又是组内第一条:这就是默认上屏的那一个,没有可学的。 + if (slot == 0 && first_in_group) + return commit(); + bool reached = false; + if (!count_quick_phrase_selection(database.get(), schema, code, "promote", trigger_count, reached)) + return rollback(); + if (!reached) + return commit(); + if (slot > 0 && !write_quick_phrase_slot(database.get(), schema, code, + static_cast(ranking_target(static_cast(slot), mode, + (std::max)(1, linear_step), false)))) + return rollback(); + if (!first_in_group) + { + // 组内顺序按权重走,语义同 bump_wubi_weight:选中的那条升到同码最高 + 1。 + auto current = prepare(database.get(), "SELECT MAX(weight) FROM main.quick_parases WHERE key=?1"); + if (!current || !bind_text(current.get(), 1, code) || sqlite3_step(current.get()) != SQLITE_ROW) + return rollback(); + const std::int64_t new_weight = clamp_managed_weight(sqlite3_column_int64(current.get(), 0) + 1); + current.reset(); + auto bump = prepare(database.get(), "UPDATE main.quick_parases SET weight=?1 WHERE key=?2 AND value=?3"); + if (!bump || sqlite3_bind_int64(bump.get(), 1, new_weight) != SQLITE_OK || !bind_text(bump.get(), 2, code) || + !bind_text(bump.get(), 3, value) || sqlite3_step(bump.get()) != SQLITE_DONE || + sqlite3_changes(database.get()) == 0) + return rollback(); + bump.reset(); + auto journal = + prepare(database.get(), "INSERT INTO candidate_journal.user_dictionary_operations(dictionary,key,value," + "operation,weight,display) VALUES(?1,?2,?3,'upsert',?4,'')" + " ON CONFLICT(dictionary,key,value) DO UPDATE SET operation='upsert'," + "weight=excluded.weight,display='',updated_at=unixepoch()"); + if (!journal || !bind_text(journal.get(), 1, kind_name(DictionaryKind::QuickPhrase)) || + !bind_text(journal.get(), 2, code) || !bind_text(journal.get(), 3, value) || + sqlite3_bind_int64(journal.get(), 4, new_weight) != SQLITE_OK || sqlite3_step(journal.get()) != SQLITE_DONE) + return rollback(); + } + return commit(); +} + +bool learn_quick_phrase_bypass(const std::string &user_db_path, const std::string &code, int ordinary_rank, + const std::string &mode, int linear_step, int trigger_count, int max_slot) +{ + if (code.empty() || ordinary_rank < 0) + return false; + if (mode == "disabled" || max_slot <= 0) + return true; + UserDatabase db(user_db_path); + if (!db || !execute_sql(db.get(), "BEGIN IMMEDIATE")) + return false; + const auto rollback = [&]() { + (void)execute_sql(db.get(), "ROLLBACK"); + return false; + }; + const auto commit = [&]() { return execute_sql(db.get(), "COMMIT") || rollback(); }; + int slot = 0; + if (!read_quick_phrase_slot(db.get(), "", code, slot)) + return rollback(); + // 选中的候选本来就在组前面,或者组已经退到首页最后一位。 + if (ordinary_rank < slot || slot >= max_slot) + return commit(); + // 整组算作它前面的一个位置:它的目标位置落在组的位置或更前,才算越过了这一组。 + const size_t target = + ranking_target(static_cast(ordinary_rank) + 1, mode, (std::max)(1, linear_step), false); + if (target > static_cast(slot)) + return commit(); + bool reached = false; + if (!count_quick_phrase_selection(db.get(), "", code, "bypass", trigger_count, reached)) + return rollback(); + if (reached && !write_quick_phrase_slot(db.get(), "", code, (std::min)(slot + 1, max_slot))) + return rollback(); + return commit(); +} + bool learn_entered_english_word(const std::string &english_db_path, const std::string &user_db_path, const std::string &display, std::int64_t weight) { diff --git a/engine/user_dictionary/user_dictionary_journal.h b/engine/user_dictionary/user_dictionary_journal.h index 94a54526..b7f217ab 100644 --- a/engine/user_dictionary/user_dictionary_journal.h +++ b/engine/user_dictionary/user_dictionary_journal.h @@ -39,6 +39,22 @@ bool record_pinyin_upsert_from_database(const std::string &main_db_path, const s bool bump_wubi_weight(const std::string &main_db_path, const std::string &user_db_path, const std::string &key, const std::string &value); +// 快捷短语混排的调频状态。快捷短语与拼音候选的权重不在同一个量纲上,不能互相比较,所以 +// 按编码记一个槽位:同码快捷短语组前面排几个普通候选(见 +// metasequoia::local_modes::counts_toward_quick_phrase_slot)。没有记录即 0,组在首位。 +int quick_phrase_slot(const std::string &user_db_path, const std::string &code); +// 用户选中了组里的一条快捷短语。累计到 trigger_count 次后按调频模式把组往前挪(槽位按 +// ranking_target 缩小),选中的不是组内第一条时再把它的权重升到组内最高 + 1 并记日志。 +// mode 为 "disabled" 时什么都不做。 +bool learn_quick_phrase_selection(const std::string &main_db_path, const std::string &user_db_path, + const std::string &code, const std::string &value, bool first_in_group, + const std::string &mode, int linear_step, int trigger_count); +// 用户越过快捷短语组选了排在组后面的普通候选,ordinary_rank 是它在普通候选里的名次。 +// 把整组当成它前面的一个位置,它按调频模式的目标位置越过这一组时,累计到 trigger_count +// 次后槽位 + 1,最多到 max_slot(首页最后一位)。 +bool learn_quick_phrase_bypass(const std::string &user_db_path, const std::string &code, int ordinary_rank, + const std::string &mode, int linear_step, int trigger_count, int max_slot); + struct ReplayResult { int applied = 0;