#include "radical_query.h" #include "../core/data_path.h" #include #include #include #include #include #include #include #include #include namespace metasequoia::local_modes { namespace { // 一行拆字数据。code 是部件拼音连写、word 是汉字、weight 是字频。两段文字首尾相接存在 // Table::text 里:十几万行各带两个 std::string 要占十多 MB 常驻内存,压成一块文本池后只剩几 MB。 struct Entry { std::uint32_t offset = 0; std::uint16_t code_size = 0; std::uint16_t word_size = 0; std::int64_t weight = 0; }; struct Table { std::string text; // 文件顺序即字频降序(生成脚本已排序),所以某前缀的首个命中就是它最高频的那条。 std::vector entries; // entries 的下标按 code 排序。前缀命中的行在排序视图里连续,查询只扫这一小段。 std::vector by_code; std::string_view code(std::size_t index) const { const Entry &entry = entries[index]; return std::string_view(text).substr(entry.offset, entry.code_size); } std::string_view word(std::size_t index) const { const Entry &entry = entries[index]; return std::string_view(text).substr(entry.offset + entry.code_size, entry.word_size); } }; // 生成脚本不会写 BOM,但保留与 helpcode 相同的防御:表由外部工具产出,首行多一个 BOM // 不该让整张表失效。 constexpr char kUtf8Bom[] = "\xEF\xBB\xBF"; bool is_lower_ascii_code(std::string_view code) { return std::all_of(code.begin(), code.end(), [](unsigned char character) { return character >= 'a' && character <= 'z'; }); } std::shared_ptr load_table(const std::filesystem::path &path) { auto table = std::make_shared(); std::ifstream input(path, std::ios::binary); if (!input) { return {}; } std::string line; bool first_line = true; while (std::getline(input, line)) { if (first_line && line.rfind(kUtf8Bom, 0) == 0) { line.erase(0, 3); } first_line = false; if (!line.empty() && line.back() == '\r') { line.pop_back(); } if (line.empty() || line.front() == '#') { continue; } const auto first_tab = line.find('\t'); if (first_tab == std::string::npos) { continue; } const auto second_tab = line.find('\t', first_tab + 1); if (second_tab == std::string::npos) { continue; } const std::string_view code = std::string_view(line).substr(0, first_tab); const std::string_view word = std::string_view(line).substr(first_tab + 1, second_tab - first_tab - 1); // 拆字输入只可能是 a-z,码字域之外的码(繁体、注音、含撇号未归一)没有查询语义。 if (code.empty() || word.empty() || !is_lower_ascii_code(code)) { continue; } // 条目里的长度和偏移是定宽整数;真实数据的一行只有几十字节,超出的行不可能是合法拆字。 if (code.size() > UINT16_MAX || word.size() > UINT16_MAX || table->text.size() + code.size() + word.size() > UINT32_MAX) { continue; } std::int64_t weight = 0; try { weight = std::stoll(line.substr(second_tab + 1)); } catch (...) { continue; } Entry entry; entry.offset = static_cast(table->text.size()); entry.code_size = static_cast(code.size()); entry.word_size = static_cast(word.size()); entry.weight = weight; table->text.append(code); table->text.append(word); table->entries.push_back(entry); } table->text.shrink_to_fit(); table->entries.shrink_to_fit(); table->by_code.resize(table->entries.size()); for (std::size_t index = 0; index < table->entries.size(); ++index) { table->by_code[index] = static_cast(index); } std::sort(table->by_code.begin(), table->by_code.end(), [&](std::uint32_t left, std::uint32_t right) { return table->code(left) < table->code(right); }); return table; } // 表加载后不可变,进程内按路径只读一次。按路径缓存而不是单个静态 local,是为了让测试 // 能指向自己的临时表而不与部署布局打架。 std::shared_ptr cached_table(const std::filesystem::path &path) { static std::mutex mutex; static std::unordered_map> cache; const std::string key = path_to_utf8(path); std::lock_guard lock(mutex); const auto found = cache.find(key); if (found != cache.end()) { return found->second; } std::shared_ptr table = load_table(path); cache.emplace(key, table); return table; } } // namespace std::vector query_radical(const std::filesystem::path &decomposition_path, const std::string &code, int limit) { if (limit <= 0 || code.empty() || !is_lower_ascii_code(code)) { return {}; } const std::shared_ptr table = cached_table(decomposition_path); if (!table || table->entries.empty()) { return {}; } const auto lower = std::lower_bound(table->by_code.begin(), table->by_code.end(), std::string_view(code), [&](std::uint32_t index, std::string_view value) { return table->code(index) < value; }); // 全码命中在前、仅前缀命中在后,各自按字频:打全了某个生僻字的拆法,它不能被一串更高频的 // 长码挤出 limit。 std::vector exact_matches; std::vector prefix_matches; for (auto it = lower; it != table->by_code.end(); ++it) { const std::string_view entry_code = table->code(*it); if (entry_code.size() < code.size() || entry_code.compare(0, code.size(), code) != 0) { // 同一前缀的行在排序视图里连续,越过前缀范围即可停。 break; } (entry_code.size() == code.size() ? exact_matches : prefix_matches).push_back(*it); } // 下标就是文件顺序(字频降序),排序后即高频优先。 std::sort(exact_matches.begin(), exact_matches.end()); std::sort(prefix_matches.begin(), prefix_matches.end()); const auto count = static_cast(limit); std::vector result; // 一个字常有几种拆法(駉 = majiong / majiongkou / matongkou),共享同一前缀时只出一次, // 位置取它排得最前的那条。 std::unordered_set seen; for (const auto *matches : {&exact_matches, &prefix_matches}) { for (const std::size_t index : *matches) { if (result.size() >= count) { return result; } const std::string_view word = table->word(index); if (!seen.insert(word).second) { continue; } // 注音不在这里填:引擎没有汉字→拼音能力,由 Server 组装候选时补。 result.emplace_back(std::string{}, std::string(word), table->entries[index].weight, CandidateSource::Generated); } } return result; } void preload_radical_table(const std::filesystem::path &decomposition_path) { cached_table(decomposition_path); } } // namespace metasequoia::local_modes