feat: initial commit
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/git,visualstudio
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=git,visualstudio
|
||||
|
||||
### Git ###
|
||||
# Created by git for backups. To disable backups in Git:
|
||||
# $ git config --global mergetool.keepBackup false
|
||||
*.orig
|
||||
|
||||
# Created by git when using merge tools for conflicts
|
||||
*.BACKUP.*
|
||||
*.BASE.*
|
||||
*.LOCAL.*
|
||||
*.REMOTE.*
|
||||
*_BACKUP_*.txt
|
||||
*_BASE_*.txt
|
||||
*_LOCAL_*.txt
|
||||
*_REMOTE_*.txt
|
||||
|
||||
### VisualStudio ###
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*[.json, .xml, .info]
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/git,visualstudio
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
<LangVersion>14</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>CA1822</NoWarn>
|
||||
<ProjectName>MaigoLabs.NeedLe</ProjectName>
|
||||
<VSTestLogger>console%3Bverbosity=detailed</VSTestLogger>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>Menci</Authors>
|
||||
<Description>Fuzzy search engine for small text pieces, with Chinese/Japanese pronunciation support</Description>
|
||||
<PackageLicenseExpression>AGPL-3.0-only</PackageLicenseExpression>
|
||||
<RepositoryUrl>https://github.com/MaigoLabs/needLe</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageProjectUrl>https://github.com/MaigoLabs/needLe</PackageProjectUrl>
|
||||
<PackageTags>search;fuzzy;cjk;chinese;japanese;pinyin;romaji</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="DotNetCampus.LatestCSharpFeatures" Version="13.0.1" />
|
||||
<PackageVersion Include="hyjiacan.pinyin4net" Version="4.1.1" />
|
||||
<PackageVersion Include="MeCab.DotNet" Version="1.2.0" />
|
||||
<PackageVersion Include="MyNihongo.KanaConverter" Version="1.0.5" />
|
||||
<PackageVersion Include="OpenccNetLib" Version="1.4.0" />
|
||||
<PackageVersion Include="Telegram.Bot" Version="22.5.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace MaigoLabs.NeedLe.Common;
|
||||
|
||||
// This is for global normalization for any input and documents.
|
||||
public static class CommonNormalization
|
||||
{
|
||||
public static int NormalizeCodePoint(int codePoint)
|
||||
{
|
||||
// Fullwidth ASCII -> Halfwidth ASCII
|
||||
if (codePoint >= 0xFF01 && codePoint <= 0xFF5E) return ToLowerCaseAscii(codePoint - 0xFEE0);
|
||||
// Fullwidth space -> Halfwidth space
|
||||
else if (codePoint == /* ' ' */ 0x3000) return ' ';
|
||||
// Halfwidth kana (U+FF66 - U+FF9D) -> Fullwidth kana
|
||||
else if (codePoint >= 0xFF66 && codePoint <= 0xFF9D) return HALF_TO_FULL_KANA.TryGetValue(codePoint, out var value) ? value : codePoint;
|
||||
else if (codePoint == /* '。' */ 0xFF61) return '。';
|
||||
else if (codePoint == /* '「' */ 0xFF62) return '「';
|
||||
else if (codePoint == /* '」' */ 0xFF63) return '」';
|
||||
else if (codePoint == /* '、' */ 0xFF64) return '、';
|
||||
else if (codePoint == /* '・' */ 0xFF65) return '・';
|
||||
else if (codePoint == /* '゙' */ 0xFF9E || codePoint == /* '゛' */ 0x309B) return 0x3099; // -> COMBINING KATAKANA-HIRAGANA VOICED SOUND MARK
|
||||
else if (codePoint == /* '゚' */ 0xFF9F || codePoint == /* '゜' */ 0x309C) return 0x309A; // -> COMBINING KATAKANA-HIRAGANA SEMI-VOICED SOUND MARK
|
||||
else return ToLowerCaseAscii(codePoint);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<int, int> HALF_TO_FULL_KANA = new Dictionary<int, int> {
|
||||
['ヲ'] = 'ヲ', ['ァ'] = 'ァ', ['ィ'] = 'ィ', ['ゥ'] = 'ゥ', ['ェ'] = 'ェ', ['ォ'] = 'ォ',
|
||||
['ャ'] = 'ャ', ['ュ'] = 'ュ', ['ョ'] = 'ョ', ['ッ'] = 'ッ',
|
||||
['ー'] = 'ー',
|
||||
['ア'] = 'ア', ['イ'] = 'イ', ['ウ'] = 'ウ', ['エ'] = 'エ', ['オ'] = 'オ',
|
||||
['カ'] = 'カ', ['キ'] = 'キ', ['ク'] = 'ク', ['ケ'] = 'ケ', ['コ'] = 'コ',
|
||||
['サ'] = 'サ', ['シ'] = 'シ', ['ス'] = 'ス', ['セ'] = 'セ', ['ソ'] = 'ソ',
|
||||
['タ'] = 'タ', ['チ'] = 'チ', ['ツ'] = 'ツ', ['テ'] = 'テ', ['ト'] = 'ト',
|
||||
['ナ'] = 'ナ', ['ニ'] = 'ニ', ['ヌ'] = 'ヌ', ['ネ'] = 'ネ', ['ノ'] = 'ノ',
|
||||
['ハ'] = 'ハ', ['ヒ'] = 'ヒ', ['フ'] = 'フ', ['ヘ'] = 'ヘ', ['ホ'] = 'ホ',
|
||||
['マ'] = 'マ', ['ミ'] = 'ミ', ['ム'] = 'ム', ['メ'] = 'メ', ['モ'] = 'モ',
|
||||
['ヤ'] = 'ヤ', ['ユ'] = 'ユ', ['ヨ'] = 'ヨ',
|
||||
['ラ'] = 'ラ', ['リ'] = 'リ', ['ル'] = 'ル', ['レ'] = 'レ', ['ロ'] = 'ロ',
|
||||
['ワ'] = 'ワ', ['ン'] = 'ン',
|
||||
};
|
||||
|
||||
public static int ToLowerCaseAscii(int codePoint) => codePoint >= 0x41 && codePoint <= 0x5A ? codePoint + 0x20 : codePoint;
|
||||
|
||||
public static bool IsHiraganaRange(int codePoint) => (codePoint >= 0x3041 && codePoint <= 0x3096) || (codePoint >= 0x309D && codePoint <= 0x309E);
|
||||
public static int ToKatakana(int codePoint) => IsHiraganaRange(codePoint) ? codePoint + 0x60 : codePoint;
|
||||
public static string ToKatakana(string text) => string.Concat(text.Select(c => (char)ToKatakana(c)));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MaigoLabs.NeedLe.Common;
|
||||
|
||||
public static class CommonUtils
|
||||
{
|
||||
public static bool IsWhitespace(int codePoint) =>
|
||||
codePoint == 0x0009 /* \t */ ||
|
||||
codePoint == 0x000A /* \n */ ||
|
||||
codePoint == 0x000B /* Vertical Tab */ ||
|
||||
codePoint == 0x000C /* \f */ ||
|
||||
codePoint == 0x000D /* \r */ ||
|
||||
codePoint == 0x0020 /* Space */ ||
|
||||
codePoint == 0x0085 /* Next Line (NEL) */ ||
|
||||
codePoint == 0x00A0 /* No-Break Space */ ||
|
||||
codePoint == 0x1680 /* Ogham Space Mark */ ||
|
||||
codePoint >= 0x2000 && codePoint <= 0x200A ||
|
||||
codePoint == 0x2028 /* Line Separator */ ||
|
||||
codePoint == 0x2029 /* Paragraph Separator */ ||
|
||||
codePoint == 0x202F /* Narrow No-Break Space */ ||
|
||||
codePoint == 0x205F /* Medium Mathematical Space */ ||
|
||||
codePoint == 0x3000 /* Ideographic Space */;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Common.Extensions;
|
||||
|
||||
public static class UnicodeExtensions
|
||||
{
|
||||
public static IEnumerable<int> ToCodePoints(this string s)
|
||||
{
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
{
|
||||
int codePoint = char.ConvertToUtf32(s, i);
|
||||
if (codePoint > 0xffff) i++;
|
||||
yield return codePoint;
|
||||
}
|
||||
}
|
||||
|
||||
public static StringBuilder ToUtf32StringBuilder(this IEnumerable<int> codePoints)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var codePoint in codePoints) sb.Append(char.ConvertFromUtf32(codePoint));
|
||||
return sb;
|
||||
}
|
||||
|
||||
public static string ToUtf32String(this IEnumerable<int> codePoints) => ToUtf32StringBuilder(codePoints).ToString();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>$(ProjectName).Common</RootNamespace>
|
||||
<AssemblyName>$(RootNamespace)</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>$(RootNamespace)</PackageId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetCampus.LatestCSharpFeatures" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace MaigoLabs.NeedLe.Common;
|
||||
|
||||
public class TrieNode
|
||||
{
|
||||
public required TrieNode? Parent { get; set; }
|
||||
public required Dictionary<int, TrieNode> Children { get; set; } // Unicode code point -> child node
|
||||
public required List<int> TokenIds { get; set; }
|
||||
public required List<int> SubTreeTokenIds { get; set; } // Empty on root.
|
||||
}
|
||||
|
||||
public static class TrieNodeExtensions
|
||||
{
|
||||
public static TrieNode? TraverseStep(this TrieNode? node, int codePoint, bool isIgnorable = false) =>
|
||||
(node?.Children.TryGetValue(codePoint, out var child) ?? false)
|
||||
? child
|
||||
: isIgnorable ? node : null;
|
||||
|
||||
public static TrieNode? Traverse(this TrieNode? node, int[] codePoints, bool isIgnorable = false)
|
||||
{
|
||||
if (node == null) return null;
|
||||
foreach (var codePoint in codePoints)
|
||||
{
|
||||
node = node?.TraverseStep(codePoint, isIgnorable);
|
||||
if (node == null) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
public static List<int> GetTokenIds(this TrieNode? node, bool includeSubTree = false) =>
|
||||
(includeSubTree ? node?.SubTreeTokenIds : node?.TokenIds) ?? [];
|
||||
|
||||
public static bool IsTokenExactMatch(this TrieNode? node, int tokenId) => node?.TokenIds.Contains(tokenId) ?? false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MaigoLabs.NeedLe.Common.Types;
|
||||
|
||||
#pragma warning disable IDE1006 // Naming rule violation
|
||||
|
||||
// For compatibility with TypeScript, we use camelCase property names here.
|
||||
|
||||
public class CompressedInvertedIndex
|
||||
{
|
||||
public required string[] documents { get; set; }
|
||||
public required int[] tokenTypes { get; set; } // Use int values here instead of TokenType enum to avoid JSON serialization issues.
|
||||
public required List<int[]>[] tokenReferences { get; set; } // tokenId -> [documentId, start1, end1, start2, end2, ...]
|
||||
public required CompressedInvertedIndexTries tries { get; set; }
|
||||
}
|
||||
|
||||
public class CompressedInvertedIndexTries
|
||||
{
|
||||
public required int[] romaji { get; set; }
|
||||
public required int[] kana { get; set; }
|
||||
public required int[] other { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MaigoLabs.NeedLe.Common.Types;
|
||||
|
||||
public class OffsetSpan
|
||||
{
|
||||
public required int Start { get; init; }
|
||||
public required int End { get; init; }
|
||||
|
||||
public int Length => End - Start;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MaigoLabs.NeedLe.Common.Types;
|
||||
|
||||
public class TokenDefinition
|
||||
{
|
||||
public required int Id { get; set; }
|
||||
public required TokenType Type { get; set; }
|
||||
public required string Text { get; set; }
|
||||
public required int CodePointLength { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MaigoLabs.NeedLe.Common.Types;
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
Raw,
|
||||
Kana,
|
||||
Romaji,
|
||||
Han,
|
||||
Pinyin,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using OpenccNetLib;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Han;
|
||||
|
||||
public class HanVariantProvider
|
||||
{
|
||||
private readonly Dictionary<int, int[]> EXCHANGE_MAP;
|
||||
public HanVariantProvider(DictWithMaxLength[]? dicts = null)
|
||||
{
|
||||
dicts ??=
|
||||
[
|
||||
DictionaryLib.Provider.hk_variants,
|
||||
DictionaryLib.Provider.hk_variants_rev,
|
||||
DictionaryLib.Provider.jp_variants,
|
||||
DictionaryLib.Provider.jp_variants_rev,
|
||||
DictionaryLib.Provider.st_characters,
|
||||
DictionaryLib.Provider.ts_characters,
|
||||
DictionaryLib.Provider.tw_variants,
|
||||
DictionaryLib.Provider.tw_variants_rev,
|
||||
];
|
||||
EXCHANGE_MAP = BuildHanExchangeMap(dicts);
|
||||
}
|
||||
|
||||
private Dictionary<int, int[]> BuildHanExchangeMap(DictWithMaxLength[] dicts)
|
||||
{
|
||||
var unionFindSet = new UnionFindSet();
|
||||
foreach (var dict in dicts) foreach (var item in dict.Dict)
|
||||
{
|
||||
var from = item.Key.ToCodePoints().ToArray();
|
||||
var to = item.Value.ToCodePoints().ToArray();
|
||||
if (from.Length != 1 || to.Length != 1) continue;
|
||||
unionFindSet.Union(from[0], to[0]);
|
||||
}
|
||||
var variants = new Dictionary<int, List<int>>();
|
||||
foreach (var x in unionFindSet.Keys)
|
||||
{
|
||||
var parent = unionFindSet.Find(x);
|
||||
if (!variants.TryGetValue(parent, out var list)) variants[parent] = list = [];
|
||||
if (x != parent) variants[x] = list;
|
||||
list.Add(x);
|
||||
}
|
||||
return variants.ToDictionary(item => item.Key, item => item.Value.OrderBy(x => x).ToArray());
|
||||
}
|
||||
|
||||
// https://github.com/google/re2/blob/e7aec5985072c1dbe735add802653ef4b36c231a/re2/unicode_groups.cc#L5590-L5615
|
||||
private static readonly (int Min, int Max)[] RE2_SCRIPT_HAN_RENAGES =
|
||||
[
|
||||
// Han_range16
|
||||
(11904, 11929),
|
||||
(11931, 12019),
|
||||
(12032, 12245),
|
||||
(12293, 12293),
|
||||
(12295, 12295),
|
||||
(12321, 12329),
|
||||
(12344, 12347),
|
||||
(13312, 19903),
|
||||
(19968, 40959),
|
||||
(63744, 64109),
|
||||
(64112, 64217),
|
||||
// Han_range32
|
||||
(94178, 94179),
|
||||
(94192, 94193),
|
||||
(131072, 173791),
|
||||
(173824, 177977),
|
||||
(177984, 178205),
|
||||
(178208, 183969),
|
||||
(183984, 191456),
|
||||
(191472, 192093),
|
||||
(194560, 195101),
|
||||
(196608, 201546),
|
||||
(201552, 205743),
|
||||
];
|
||||
|
||||
public static bool IsHanCharacter(int codePoint) => RE2_SCRIPT_HAN_RENAGES.Any(range => codePoint >= range.Min && codePoint <= range.Max);
|
||||
|
||||
public int[] GetHanVariants(int codePoint) => EXCHANGE_MAP.TryGetValue(codePoint, out var variants)
|
||||
? variants
|
||||
: IsHanCharacter(codePoint) ? [codePoint] : [];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using hyjiacan.py4n;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Han;
|
||||
|
||||
public static class PinyinHelper
|
||||
{
|
||||
private static readonly string[] PINYIN_INITIALS = ["b", "p", "m", "f", "d", "t", "n", "l", "g", "k", "h", "j", "q", "x", "zh", "ch", "sh", "r", "z", "c", "s", "y", "w"];
|
||||
private static readonly Dictionary<string, string> PINYIN_FINALS_FUZZY_MAP = new() { ["ang"] = "an", ["eng"] = "en", ["ing"] = "in" };
|
||||
|
||||
public static IEnumerable<string> GetPinyinCandidates(int codePoint) => codePoint < char.MinValue || codePoint > char.MaxValue || !PinyinUtil.IsHanzi((char)codePoint) ? [] :
|
||||
Pinyin4Net.GetPinyin((char)codePoint, PinyinFormat.LOWERCASE | PinyinFormat.WITHOUT_TONE).Where(pinyin => pinyin.Length > 0).SelectMany(pinyin =>
|
||||
{
|
||||
var initial = PINYIN_INITIALS.FirstOrDefault(initial => pinyin.StartsWith(initial));
|
||||
var initialAlphabet = initial != null ? initial[..1] : pinyin[..1];
|
||||
var fuzzySuffix = pinyin.Length < 3 ? null : pinyin[^3..];
|
||||
var fuzzyPinyin = fuzzySuffix != null && PINYIN_FINALS_FUZZY_MAP.TryGetValue(fuzzySuffix, out var fuzzySuffixTarget) ? pinyin[..^3] + fuzzySuffixTarget : null;
|
||||
return new string?[] { pinyin, initial, initialAlphabet, fuzzyPinyin }.OfType<string>();
|
||||
}).Distinct();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace MaigoLabs.NeedLe.Indexer.Han;
|
||||
|
||||
public class UnionFindSet
|
||||
{
|
||||
private Dictionary<int, int> Parent { get; set; } = [];
|
||||
private Dictionary<int, int> Rank { get; set; } = [];
|
||||
|
||||
public IEnumerable<int> Keys => Parent.Keys;
|
||||
|
||||
public int Find(int x)
|
||||
{
|
||||
if (!Parent.TryGetValue(x, out var parent)) return Parent[x] = x;
|
||||
else if (x == parent) return x;
|
||||
else return Parent[x] = Find(parent);
|
||||
}
|
||||
|
||||
public void Union(int x, int y)
|
||||
{
|
||||
x = Find(x);
|
||||
y = Find(y);
|
||||
if (x == y) return;
|
||||
int rankX = GetRank(x), rankY = GetRank(y);
|
||||
if (rankX < rankY) Parent[x] = y;
|
||||
else if (rankX > rankY) Parent[y] = x;
|
||||
else
|
||||
{
|
||||
Parent[y] = x;
|
||||
Rank[x] = rankX + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetRank(int x) => !Rank.TryGetValue(x, out var rank) ? 0 : rank;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Common.Types;
|
||||
using MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
using MaigoLabs.NeedLe.Indexer.Trie;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer;
|
||||
|
||||
public static class InvertedIndexBuilder
|
||||
{
|
||||
private static TrieNode BuildTypedTrie(IEnumerable<TokenDefinition> tokenDefinitions, Func<TokenType, bool> typePredicate) =>
|
||||
TrieBuilder.BuildTrie(tokenDefinitions
|
||||
.Where(token => typePredicate(token.Type))
|
||||
.Select(token => (token.Id, CodePoints: token.Text.ToCodePoints())));
|
||||
|
||||
public static CompressedInvertedIndex BuildInvertedIndex(string[] documents, TokenizerOptions? tokenizerOptions = null)
|
||||
{
|
||||
var tokenizer = new Tokenizer(tokenizerOptions);
|
||||
var documentTokens = documents.Select(tokenizer.Tokenize).ToArray();
|
||||
|
||||
var tokenDefinitions = tokenizer.Tokens.Values;
|
||||
var romajiRoot = BuildTypedTrie(tokenDefinitions, type => type == TokenType.Romaji);
|
||||
var kanaRoot = BuildTypedTrie(tokenDefinitions, type => type == TokenType.Kana);
|
||||
var otherRoot = BuildTypedTrie(tokenDefinitions, type => type != TokenType.Romaji && type != TokenType.Kana);
|
||||
TrieBuilder.GraftTriePaths(romajiRoot, JapaneseNormalization.NORMALIZE_RULES_ROMAJI_CODEPOINTS);
|
||||
TrieBuilder.GraftTriePaths(kanaRoot, JapaneseNormalization.NORMALIZE_RULES_KANA_DAKUTEN_CODEPOINTS);
|
||||
|
||||
var invertedIndex = new CompressedInvertedIndex
|
||||
{
|
||||
documents = documents,
|
||||
tokenTypes = [.. tokenDefinitions.Select(token => (int)token.Type)],
|
||||
tokenReferences = [.. tokenDefinitions.Select(_ => new List<int[]>())],
|
||||
tries = new CompressedInvertedIndexTries
|
||||
{
|
||||
romaji = TrieSerializer.Serialize(romajiRoot),
|
||||
kana = TrieSerializer.Serialize(kanaRoot),
|
||||
other = TrieSerializer.Serialize(otherRoot),
|
||||
},
|
||||
};
|
||||
for (var documentId = 0; documentId < documents.Length; documentId++)
|
||||
{
|
||||
var tokens = documentTokens[documentId];
|
||||
var tokenOccurrences = new Dictionary<int, List<int>>();
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (!tokenOccurrences.TryGetValue(token.Id, out var occurrences)) tokenOccurrences[token.Id] = occurrences = [];
|
||||
occurrences.Add(token.Start);
|
||||
occurrences.Add(token.End);
|
||||
}
|
||||
foreach (var (tokenId, occurrences) in tokenOccurrences)
|
||||
{
|
||||
invertedIndex.tokenReferences[tokenId].Add([documentId, .. occurrences]);
|
||||
}
|
||||
}
|
||||
return invertedIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
public static class JapaneseNormalization
|
||||
{
|
||||
public delegate string Normalizer(string text);
|
||||
|
||||
public static Normalizer CreateNormalizer(Dictionary<string, string> rules) => text =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var beforeCurrentIteration = text;
|
||||
foreach (var (from, to) in rules) text = text.Replace(from, to);
|
||||
if (text == beforeCurrentIteration) break;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
public static IEnumerable<(int[] From, int[] To)> ToCodePointPairs(Dictionary<string, string> rules) =>
|
||||
rules.Select(rule => (From: rule.Key.ToCodePoints().ToArray(), To: rule.Value.ToCodePoints().ToArray()));
|
||||
|
||||
public static readonly Dictionary<string, string> NORMALIZE_RULES_ROMAJI = new()
|
||||
{
|
||||
// Remove all long vowels (sa-ba- -> saba)
|
||||
["-"] = "",
|
||||
// Collapse consecutive vowels
|
||||
["aa"] = "a",
|
||||
["ii"] = "i",
|
||||
["uu"] = "u",
|
||||
["ee"] = "e",
|
||||
["oo"] = "o",
|
||||
["ou"] = "o",
|
||||
// mb/mp/mm -> nb/np/nm (shimbun -> shinbun)
|
||||
["mb"] = "nb",
|
||||
["mp"] = "np",
|
||||
["mm"] = "nm",
|
||||
// Others
|
||||
["sha"] = "sya",
|
||||
["tsu"] = "tu",
|
||||
["chi"] = "ti",
|
||||
["shi"] = "si",
|
||||
["ji"] = "zi",
|
||||
};
|
||||
public static readonly IEnumerable<(int[] From, int[] To)> NORMALIZE_RULES_ROMAJI_CODEPOINTS = ToCodePointPairs(NORMALIZE_RULES_ROMAJI);
|
||||
public static readonly Normalizer NormalizeRomaji = CreateNormalizer(NORMALIZE_RULES_ROMAJI);
|
||||
|
||||
public static readonly Dictionary<string, string> NORMALIZE_RULES_KANA_DAKUTEN = new()
|
||||
{
|
||||
["う\u3099"] = "ゔ",
|
||||
["か\u3099"] = "が", ["き\u3099"] = "ぎ", ["く\u3099"] = "ぐ", ["け\u3099"] = "げ", ["こ\u3099"] = "ご",
|
||||
["さ\u3099"] = "ざ", ["し\u3099"] = "じ", ["す\u3099"] = "ず", ["せ\u3099"] = "ぜ", ["そ\u3099"] = "ぞ",
|
||||
["た\u3099"] = "だ", ["ち\u3099"] = "ぢ", ["つ\u3099"] = "づ", ["て\u3099"] = "で", ["と\u3099"] = "ど",
|
||||
["は\u3099"] = "ば", ["ひ\u3099"] = "び", ["ふ\u3099"] = "ぶ", ["へ\u3099"] = "べ", ["ほ\u3099"] = "ぼ",
|
||||
["は\u309A"] = "ぱ", ["ひ\u309A"] = "ぴ", ["ふ\u309A"] = "ぷ", ["へ\u309A"] = "ぺ", ["ほ\u309A"] = "ぽ",
|
||||
["ゝ\u3099"] = "ゞ",
|
||||
|
||||
["ウ\u3099"] = "ヴ",
|
||||
["カ\u3099"] = "ガ", ["キ\u3099"] = "ギ", ["ク\u3099"] = "グ", ["ケ\u3099"] = "ゲ", ["コ\u3099"] = "ゴ",
|
||||
["サ\u3099"] = "ザ", ["シ\u3099"] = "ジ", ["ス\u3099"] = "ズ", ["セ\u3099"] = "ゼ", ["ソ\u3099"] = "ゾ",
|
||||
["タ\u3099"] = "ダ", ["チ\u3099"] = "ヂ", ["ツ\u3099"] = "ヅ", ["テ\u3099"] = "デ", ["ト\u3099"] = "ド",
|
||||
["ハ\u3099"] = "バ", ["ヒ\u3099"] = "ビ", ["フ\u3099"] = "ブ", ["ヘ\u3099"] = "ベ", ["ホ\u3099"] = "ボ",
|
||||
["ハ\u309A"] = "パ", ["ヒ\u309A"] = "ピ", ["フ\u309A"] = "プ", ["ヘ\u309A"] = "ペ", ["ホ\u309A"] = "ポ",
|
||||
["ワ\u3099"] = "ヷ", ["ヰ\u3099"] = "ヸ", ["ヱ\u3099"] = "ヹ", ["ヲ\u3099"] = "ヺ",
|
||||
["ヽ\u3099"] = "ヾ",
|
||||
};
|
||||
public static readonly IEnumerable<(int[] From, int[] To)> NORMALIZE_RULES_KANA_DAKUTEN_CODEPOINTS = ToCodePointPairs(NORMALIZE_RULES_KANA_DAKUTEN);
|
||||
public static readonly Normalizer NormalizeKanaDakuten = CreateNormalizer(NORMALIZE_RULES_KANA_DAKUTEN);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using MaigoLabs.NeedLe.Indexer.Han;
|
||||
using MyNihongo.KanaConverter;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
public static class JapaneseUtils
|
||||
{
|
||||
public static bool IsMaybeJapanese(int codePoint) =>
|
||||
HanVariantProvider.IsHanCharacter(codePoint) ||
|
||||
IsKana(codePoint) ||
|
||||
IsJapaneseSoundMark(codePoint) ||
|
||||
codePoint == 0x3005 || codePoint == 0x3006 || codePoint == 0x30FC;
|
||||
|
||||
// See also Common/Normalization.cs
|
||||
public static bool IsJapaneseSoundMark(int codePoint) => codePoint == 0x3099 || codePoint == 0x309A;
|
||||
public static string StripJapaneseSoundMarks(string text) => string.Concat(text.Where(codePoint => !IsJapaneseSoundMark(codePoint)));
|
||||
|
||||
public static bool IsKana(int codePoint) => (codePoint >= 0x3041 && codePoint <= 0x309F) || (codePoint >= 0x30A0 && codePoint <= 0x30FF);
|
||||
|
||||
private static readonly int[] KANAS_CANNOT_BE_FIRST =
|
||||
[
|
||||
'ァ', 'ィ', 'ゥ', 'ェ', 'ォ',
|
||||
'ぁ', 'ぃ', 'ぅ', 'ぇ', 'ぉ',
|
||||
'ャ', 'ュ', 'ョ',
|
||||
'ゃ', 'ゅ', 'ょ',
|
||||
'ヮ', 'ゎ',
|
||||
'ㇰ', 'ㇱ', 'ㇲ', 'ㇳ', 'ㇴ', 'ㇵ', 'ㇶ', 'ㇷ', 'ㇸ', 'ㇹ', 'ㇺ', 'ㇻ', 'ㇼ', 'ㇽ', 'ㇾ', 'ㇿ',
|
||||
'ー',
|
||||
];
|
||||
|
||||
private static readonly int[] KANAS_CANNOT_BE_LAST =
|
||||
[
|
||||
'ッ', 'っ'
|
||||
];
|
||||
|
||||
public static string ToRomajiStrictly(string kanaText)
|
||||
{
|
||||
if (kanaText.Length == 0) return "";
|
||||
if (KANAS_CANNOT_BE_FIRST.Contains(kanaText[0])) return "";
|
||||
if (KANAS_CANNOT_BE_LAST.Contains(kanaText[^1])) return "";
|
||||
string romaji;
|
||||
try { romaji = kanaText.ToRomaji(); }
|
||||
catch { return ""; }
|
||||
if (!romaji.All(c => c is >= 'a' and <= 'z')) return "";
|
||||
return romaji;
|
||||
}
|
||||
|
||||
public static bool IsValidJapanesePhrase(ReadOnlySpan<int> codePoints, int start, int length) =>
|
||||
// Skip splittings that cause sound marks to occur in the first position of a phrase
|
||||
!IsJapaneseSoundMark(codePoints[start]) && (start + length == codePoints.Length || !IsJapaneseSoundMark(codePoints[start + length]));
|
||||
public static bool IsValidJapanesePhrase(ReadOnlyMemory<int> codePoints, int start, int length) => IsValidJapanesePhrase(codePoints.Span, start, length);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MeCab;
|
||||
using MeCab.Core;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
public class Transcription
|
||||
{
|
||||
public required int Start { get; set; }
|
||||
public required int Length { get; set; }
|
||||
public required string[] Transcriptions { get; set; }
|
||||
}
|
||||
|
||||
public delegate IEnumerable<Transcription> TranscriptionEnumerator(ReadOnlyMemory<int> codePoints);
|
||||
public delegate bool IsValidPhraseDelegate(ReadOnlyMemory<int> codePoints, int start, int length);
|
||||
public delegate HashSet<string> GetAllTranscriptionsDelegate(string phrase);
|
||||
|
||||
public class TranscriptionProvider
|
||||
{
|
||||
public MeCabDictionary[] Dictionaries { get; set; }
|
||||
|
||||
public TranscriptionProvider(MeCabDictionary[]? dictionaries = null)
|
||||
{
|
||||
if (dictionaries == null)
|
||||
{
|
||||
var param = new MeCabParam();
|
||||
param.LoadDicRC();
|
||||
var dictionary = new MeCabDictionary();
|
||||
dictionary.Open(Path.Combine(param.DicDir, "sys.dic"));
|
||||
dictionaries = [dictionary];
|
||||
}
|
||||
Dictionaries = dictionaries;
|
||||
}
|
||||
|
||||
public static TranscriptionEnumerator CreateTranscriptionEnumerator(IsValidPhraseDelegate isValidPhrase, GetAllTranscriptionsDelegate getAllTranscriptions) => codePoints =>
|
||||
{
|
||||
var resultMap = new Dictionary<(int Start, int Length), Transcription>();
|
||||
for (int phraseLength = 1; phraseLength <= codePoints.Length; phraseLength++) for (int start = 0; start + phraseLength <= codePoints.Length; start++)
|
||||
{
|
||||
if (!isValidPhrase(codePoints, start, phraseLength)) continue;
|
||||
var phrase = MemoryMarshal.ToEnumerable(codePoints.Slice(start, phraseLength)).ToUtf32String();
|
||||
var atomicTranscriptions = getAllTranscriptions(phrase).Where(transcription => transcription != null).Where(candidateTranscription =>
|
||||
{
|
||||
if (candidateTranscription.Length == 0) return false;
|
||||
// Ensure the transcription is atomic (not a combination of multiple shorter transcriptions, separated by any midpoints)
|
||||
var visitedStates = new HashSet<(int PhrasePosition, int TranscriptionPosition)>();
|
||||
var queue = new Queue<(int PhrasePosition, int TranscriptionPosition)>();
|
||||
queue.Enqueue((0, 0));
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (phrasePosition, transcriptionPosition) = queue.Dequeue();
|
||||
for (int prefixLength = 1; prefixLength <= phraseLength - phrasePosition; prefixLength++)
|
||||
{
|
||||
if (!resultMap.TryGetValue((start + phrasePosition, prefixLength), out var prefixResult)) continue;
|
||||
foreach (var transcription in prefixResult.Transcriptions) if (string.Compare(candidateTranscription, transcriptionPosition, transcription, 0, transcription.Length) == 0)
|
||||
{
|
||||
var nextState = (PhrasePosition: phrasePosition + prefixLength, TranscriptionPosition: transcriptionPosition + transcription.Length);
|
||||
if (nextState.PhrasePosition == phraseLength && nextState.TranscriptionPosition == candidateTranscription.Length) return false; // Found a valid combination
|
||||
if (visitedStates.Contains(nextState)) continue;
|
||||
visitedStates.Add(nextState);
|
||||
queue.Enqueue(nextState);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).ToArray();
|
||||
if (atomicTranscriptions.Length > 0) resultMap[(start, phraseLength)] = new() { Start = start, Length = phraseLength, Transcriptions = atomicTranscriptions };
|
||||
}
|
||||
return resultMap.Values;
|
||||
};
|
||||
|
||||
public HashSet<string> GetAllKanaReadings(string phrase)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
var isKana = phrase.All(ch => JapaneseUtils.IsKana(ch));
|
||||
if (isKana) result.Add(CommonNormalization.ToKatakana(phrase));
|
||||
if (isKana && phrase.Length == 1) return result;
|
||||
|
||||
foreach (var dictionary in Dictionaries)
|
||||
{
|
||||
var searchResult = dictionary.ExactMatchSearch(phrase);
|
||||
if (searchResult.Value == -1) continue;
|
||||
var tokens = dictionary.GetToken(searchResult);
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var feature = dictionary.GetFeature(token.Feature);
|
||||
var parts = feature.Split(',');
|
||||
if (parts.Length > 7) result.Add(CommonNormalization.ToKatakana(parts[7]));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public HashSet<string> GetAllKanaReadingsWithNormalization(string phrase) =>
|
||||
GetAllKanaReadings(JapaneseUtils.StripJapaneseSoundMarks(JapaneseNormalization.NormalizeKanaDakuten(phrase)));
|
||||
|
||||
public TranscriptionEnumerator EnumerateKanaTranscriptions => CreateTranscriptionEnumerator(
|
||||
JapaneseUtils.IsValidJapanesePhrase,
|
||||
GetAllKanaReadingsWithNormalization);
|
||||
public TranscriptionEnumerator EnumerateRomajiTranscriptions => CreateTranscriptionEnumerator(
|
||||
JapaneseUtils.IsValidJapanesePhrase,
|
||||
phrase => [.. GetAllKanaReadingsWithNormalization(phrase).Select(kana => JapaneseNormalization.NormalizeRomaji(JapaneseUtils.ToRomajiStrictly(kana)))]);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>$(ProjectName).Indexer</RootNamespace>
|
||||
<AssemblyName>$(RootNamespace)</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>$(RootNamespace)</PackageId>
|
||||
<!-- Don't include MeCab dictionaries in this package; let MeCab.DotNet provide them to end users -->
|
||||
<MeCabUseDefaultDictionary>False</MeCabUseDefaultDictionary>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Common\MaigoLabs.NeedLe.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetCampus.LatestCSharpFeatures" PrivateAssets="all" />
|
||||
<PackageReference Include="hyjiacan.pinyin4net" />
|
||||
<PackageReference Include="MeCab.DotNet" PrivateAssets="analyzers" />
|
||||
<PackageReference Include="MyNihongo.KanaConverter" />
|
||||
<PackageReference Include="OpenccNetLib" PrivateAssets="analyzers" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Common.Types;
|
||||
using MaigoLabs.NeedLe.Indexer.Han;
|
||||
using MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer;
|
||||
|
||||
public class TokenizerOptions
|
||||
{
|
||||
public HanVariantProvider? HanVariantProvider { get; set; }
|
||||
public TranscriptionProvider? TranscriptionProvider { get; set; }
|
||||
}
|
||||
|
||||
public class Tokenizer(TokenizerOptions? options = null)
|
||||
{
|
||||
public HanVariantProvider HanVariantProvider { get; set; } = options?.HanVariantProvider ?? new HanVariantProvider();
|
||||
public TranscriptionProvider TranscriptionProvider { get; set; } = options?.TranscriptionProvider ?? new TranscriptionProvider();
|
||||
|
||||
public class Token
|
||||
{
|
||||
public required int Id { get; set; }
|
||||
public required int Start { get; set; }
|
||||
public required int End { get; set; }
|
||||
}
|
||||
|
||||
public Dictionary<(TokenType Type, string Text), TokenDefinition> Tokens { get; } = [];
|
||||
private TokenDefinition EnsureToken(TokenType type, string text)
|
||||
{
|
||||
var key = (type, text);
|
||||
if (Tokens.TryGetValue(key, out var tokenDefinition)) return tokenDefinition;
|
||||
tokenDefinition = new TokenDefinition { Id = Tokens.Count, Type = type, Text = text, CodePointLength = text.ToCodePoints().Count() };
|
||||
Tokens.Add(key, tokenDefinition);
|
||||
return tokenDefinition;
|
||||
}
|
||||
|
||||
public List<Token> Tokenize(string text)
|
||||
{
|
||||
var codePoints = text.ToCodePoints().Select(CommonNormalization.NormalizeCodePoint).ToArray();
|
||||
var results = new List<Token>();
|
||||
Action<TokenType /* tokenType */, string /* text */> Emitter(int start, int end) =>
|
||||
(tokenType, codePoints) => results.Add(new Token { Id = EnsureToken(tokenType, codePoints).Id, Start = start, End = end });
|
||||
|
||||
void EmitMaybeJapanese(ReadOnlyMemory<int> codePoints, int offset)
|
||||
{
|
||||
foreach (var combination in TranscriptionProvider.EnumerateKanaTranscriptions(codePoints))
|
||||
{
|
||||
var emit = Emitter(offset + combination.Start, offset + combination.Start + combination.Length);
|
||||
foreach (var transcription in combination.Transcriptions) emit(TokenType.Kana, transcription);
|
||||
}
|
||||
foreach (var combination in TranscriptionProvider.EnumerateRomajiTranscriptions(codePoints))
|
||||
{
|
||||
var emit = Emitter(offset + combination.Start, offset + combination.Start + combination.Length);
|
||||
foreach (var transcription in combination.Transcriptions) emit(TokenType.Romaji, transcription);
|
||||
}
|
||||
for (int i = 0; i < codePoints.Length; i++)
|
||||
{
|
||||
// Single character may have not only kana readings, but also Chinese pronunciations or Simplified/Traditional/Japanese variants.
|
||||
var hanAlternates = HanVariantProvider.GetHanVariants(codePoints.Span[i]); // All possible variant characters (Simplified/Traditional/Japanese)
|
||||
var pinyinAlternates = hanAlternates.SelectMany(PinyinHelper.GetPinyinCandidates).Distinct();
|
||||
var emit = Emitter(offset + i, offset + i + 1);
|
||||
foreach (var han in hanAlternates) emit(TokenType.Han, char.ConvertFromUtf32(han));
|
||||
foreach (var pinyin in pinyinAlternates) emit(TokenType.Pinyin, pinyin);
|
||||
}
|
||||
}
|
||||
|
||||
var consequentCharsets = new (Func<int, bool> Is, Action<ReadOnlyMemory<int>, int> Emit)[]
|
||||
{
|
||||
(Is: JapaneseUtils.IsMaybeJapanese, Emit: EmitMaybeJapanese),
|
||||
};
|
||||
|
||||
void EmitRaw(int codePoint, int offset) => Emitter(offset, offset + 1)(TokenType.Raw, char.ConvertFromUtf32(codePoint));
|
||||
|
||||
for (int start = 0; start < codePoints.Length; )
|
||||
{
|
||||
var codePoint = codePoints[start];
|
||||
var emitted = false;
|
||||
foreach (var (Is, Emit) in consequentCharsets)
|
||||
{
|
||||
var length = 0;
|
||||
while (start + length < codePoints.Length && Is(codePoints[start + length])) length++;
|
||||
if (length > 0)
|
||||
{
|
||||
Emit(new Memory<int>(codePoints, start, length), start);
|
||||
start += length;
|
||||
emitted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (emitted) continue;
|
||||
|
||||
// Skip whitespaces
|
||||
if (CommonUtils.IsWhitespace(codePoint))
|
||||
{
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
|
||||
EmitRaw(codePoint, start);
|
||||
start++;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Trie;
|
||||
|
||||
public static class TrieBuilder
|
||||
{
|
||||
private static TrieNode NewNode(TrieNode? parent) => new() { Parent = parent, Children = [], TokenIds = [], SubTreeTokenIds = [] };
|
||||
|
||||
public static TrieNode BuildTrie(IEnumerable<(int Id, IEnumerable<int> CodePoints)> tokens)
|
||||
{
|
||||
var root = NewNode(null);
|
||||
foreach (var (id, codePoints) in tokens)
|
||||
{
|
||||
var node = root;
|
||||
foreach (var codePoint in codePoints)
|
||||
{
|
||||
node.Children.TryGetValue(codePoint, out var childNode);
|
||||
if (childNode == null) node.Children[codePoint] = childNode = NewNode(node);
|
||||
node = childNode;
|
||||
node.SubTreeTokenIds.Add(id);
|
||||
}
|
||||
node.TokenIds.Add(id);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
public static void GraftTriePaths(TrieNode root, IEnumerable<(int[] From, int[] To)> rules)
|
||||
{
|
||||
foreach (var (inputPhrase, graftTo) in rules) if (graftTo.Length > inputPhrase.Length) throw new ArgumentException($"Graft rule {inputPhrase} -> {graftTo} maps to longer string and may cause infinite loop");
|
||||
var visitedNodes = new HashSet<TrieNode>();
|
||||
void GraftFromNode(TrieNode node, bool recursiveChildren)
|
||||
{
|
||||
if (!visitedNodes.Add(node)) return;
|
||||
if (recursiveChildren) foreach (var child in node.Children.Values) GraftFromNode(child, true);
|
||||
while (true)
|
||||
{
|
||||
var nodesWithNewGraftedChildren = new Dictionary<TrieNode, /* depth from initial node */ int>();
|
||||
foreach (var (inputPhrase, graftTo) in rules)
|
||||
{
|
||||
var targetNode = node.Traverse(graftTo);
|
||||
if (targetNode == null) continue;
|
||||
var graftedPath = new TrieNode[inputPhrase.Length - 1];
|
||||
var isGrafted = false;
|
||||
var currentNode = node;
|
||||
for (var i = 0; i < inputPhrase.Length; i++)
|
||||
{
|
||||
var codePoint = inputPhrase[i];
|
||||
currentNode.Children.TryGetValue(codePoint, out var childNode);
|
||||
if (i == inputPhrase.Length - 1)
|
||||
{
|
||||
if (childNode != null)
|
||||
{
|
||||
if (childNode != targetNode) throw new ArgumentException($"Grafted path {inputPhrase} conflicts with existing path");
|
||||
// Already grafted
|
||||
}
|
||||
else
|
||||
{
|
||||
currentNode.Children[codePoint] = childNode = targetNode;
|
||||
isGrafted = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (childNode == null)
|
||||
{
|
||||
childNode = NewNode(currentNode);
|
||||
childNode.SubTreeTokenIds = targetNode.SubTreeTokenIds;
|
||||
currentNode.Children[codePoint] = childNode;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Part of another grafted path?
|
||||
childNode.SubTreeTokenIds = new HashSet<int>(childNode.SubTreeTokenIds.Concat(targetNode.SubTreeTokenIds)).ToList();
|
||||
}
|
||||
graftedPath[i] = currentNode = childNode;
|
||||
}
|
||||
}
|
||||
if (isGrafted) for (var i = 0; i < graftedPath.Length; i++) nodesWithNewGraftedChildren[graftedPath[i]!] = i + 1;
|
||||
}
|
||||
if (nodesWithNewGraftedChildren.Count > 0)
|
||||
{
|
||||
// Re-check graft rules on the newly grafted path
|
||||
// 1. No need to recursive other children (not on this path) since their children are not affected
|
||||
// 2. No need to consider ancestors of this node since they're handled later (we run in DFS order)
|
||||
var sortedNodes = nodesWithNewGraftedChildren.OrderByDescending(x => x.Value);
|
||||
foreach (var (changedNode, _) in sortedNodes) GraftFromNode(changedNode, false);
|
||||
}
|
||||
else break; // No new grafts applied
|
||||
}
|
||||
}
|
||||
GraftFromNode(root, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Indexer.Trie;
|
||||
|
||||
public static class TrieSerializer
|
||||
{
|
||||
private class NodeEntry
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public bool Visited { get; set; }
|
||||
public int[]? Data { get; set; }
|
||||
}
|
||||
|
||||
public static int[] Serialize(TrieNode root)
|
||||
{
|
||||
var nodeEntries = new Dictionary<TrieNode, NodeEntry>();
|
||||
var currentId = 0;
|
||||
NodeEntry GetNodeEntry(TrieNode node) => nodeEntries.TryGetValue(node, out var nodeEntry) ? nodeEntry :
|
||||
nodeEntries[node] = new NodeEntry { Id = ++currentId, Visited = false, Data = null };
|
||||
int SerializeNode(TrieNode node)
|
||||
{
|
||||
var entry = GetNodeEntry(node);
|
||||
if (entry.Visited) return entry.Id;
|
||||
entry.Visited = true;
|
||||
var children = node.Children.Select(child => (CodePoint: child.Key, ChildId: SerializeNode(child.Value))).ToArray();
|
||||
entry.Data =
|
||||
[
|
||||
node.Parent != null ? GetNodeEntry(node.Parent).Id : 0,
|
||||
.. children.Select(child => child.CodePoint),
|
||||
.. children.Select(child => child.ChildId),
|
||||
// End of children list (<= 0 are not valid code points nor node IDs)
|
||||
.. node.TokenIds.Count > 0
|
||||
? node.TokenIds.Select(tokenId => -(tokenId + 1)) // Use the negative value of (tokenId + 1)
|
||||
: [0], // End of children list, no token IDs (token IDs are encoded to negative values)
|
||||
];
|
||||
return entry.Id;
|
||||
}
|
||||
SerializeNode(root);
|
||||
return nodeEntries.Values.OrderBy(entry => entry.Id).SelectMany(entry => entry.Data ?? []).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>$(ProjectName).Playground</RootNamespace>
|
||||
<AssemblyName>$(RootNamespace)</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Telegram.Bot" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe\MaigoLabs.NeedLe.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Indexer;
|
||||
using MaigoLabs.NeedLe.Searcher;
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Polling;
|
||||
using Telegram.Bot.Types;
|
||||
using Telegram.Bot.Types.Enums;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Playground;
|
||||
|
||||
public class Program
|
||||
{
|
||||
private static LoadedInvertedIndex _invertedIndex = null!;
|
||||
private static long _targetChatId;
|
||||
|
||||
public static async Task Main(string[] args)
|
||||
{
|
||||
var botToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")
|
||||
?? throw new InvalidOperationException("Missing environment variable TELEGRAM_BOT_TOKEN");
|
||||
var targetChatIdStr = Environment.GetEnvironmentVariable("TARGET_CHAT_ID")
|
||||
?? throw new InvalidOperationException("Missing environment variable TARGET_CHAT_ID");
|
||||
_targetChatId = long.Parse(targetChatIdStr);
|
||||
|
||||
// Build inverted index
|
||||
var exampleDocuments = File.ReadAllLines("../../example.txt").Where(line => line.Length > 0).ToArray();
|
||||
|
||||
var startBuild = Stopwatch.GetTimestamp();
|
||||
var compressed = InvertedIndexBuilder.BuildInvertedIndex(exampleDocuments);
|
||||
var endBuild = Stopwatch.GetTimestamp();
|
||||
Console.WriteLine($"Built inverted index in {Stopwatch.GetElapsedTime(startBuild, endBuild).TotalMilliseconds}ms");
|
||||
|
||||
var startLoad = Stopwatch.GetTimestamp();
|
||||
_invertedIndex = InvertedIndexLoader.Load(compressed);
|
||||
var endLoad = Stopwatch.GetTimestamp();
|
||||
Console.WriteLine($"Loaded inverted index in {Stopwatch.GetElapsedTime(startLoad, endLoad).TotalMilliseconds}ms");
|
||||
|
||||
// Start bot
|
||||
var bot = new TelegramBotClient(botToken);
|
||||
var me = await bot.GetMe();
|
||||
Console.WriteLine($"Bot logged in as {me.FirstName} (@{me.Username})");
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
|
||||
bot.StartReceiving(
|
||||
updateHandler: HandleUpdateAsync,
|
||||
errorHandler: HandleErrorAsync,
|
||||
receiverOptions: new ReceiverOptions { AllowedUpdates = [UpdateType.Message] },
|
||||
cancellationToken: cts.Token
|
||||
);
|
||||
await Task.Delay(-1, cts.Token).ContinueWith(_ => { });
|
||||
}
|
||||
|
||||
private static async Task HandleUpdateAsync(ITelegramBotClient bot, Update update, CancellationToken ct)
|
||||
{
|
||||
if (update.Message is not { Text: { } text, Chat.Id: var chatId, From: { } from }) return;
|
||||
|
||||
Console.WriteLine($"{chatId}:{from.Id} {JsonSerializer.Serialize(text, JsonSerializerOptions)}");
|
||||
|
||||
if (chatId != _targetChatId) return;
|
||||
|
||||
if (text.StartsWith("/needle "))
|
||||
{
|
||||
var query = text["/needle ".Length..];
|
||||
var response = HandleNeedleCommand(query);
|
||||
await bot.SendMessage(chatId, response, parseMode: ParseMode.Html, cancellationToken: ct);
|
||||
}
|
||||
else if (text.StartsWith("/tokenize "))
|
||||
{
|
||||
var query = text["/tokenize ".Length..];
|
||||
var response = HandleTokenizeCommand(query);
|
||||
await bot.SendMessage(chatId, response, parseMode: ParseMode.Html, cancellationToken: ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task HandleErrorAsync(ITelegramBotClient bot, Exception exception, HandleErrorSource source, CancellationToken ct)
|
||||
{
|
||||
Console.WriteLine($"Error: {exception.Message}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string HandleNeedleCommand(string query)
|
||||
{
|
||||
var startSearch = Stopwatch.GetTimestamp();
|
||||
var results = InvertedIndexSearcher.Search(_invertedIndex, query);
|
||||
var endSearch = Stopwatch.GetTimestamp();
|
||||
var searchDuration = Stopwatch.GetElapsedTime(startSearch, endSearch).TotalMilliseconds.ToString("F3");
|
||||
|
||||
if (results.Length == 0)
|
||||
return Codify($"No results found after {searchDuration}ms");
|
||||
|
||||
var showingResults = results.Take(5).ToArray();
|
||||
return string.Join('\n',
|
||||
[
|
||||
Codify($"Search completed in {searchDuration}ms, showing {showingResults.Length}/{results.Length} results:\n"),
|
||||
.. showingResults.Select(result => InspectSearchResult(result, true))
|
||||
]).TrimEnd();
|
||||
}
|
||||
|
||||
private static string HandleTokenizeCommand(string query)
|
||||
{
|
||||
var tokenizer = new Tokenizer();
|
||||
var startTokenize = Stopwatch.GetTimestamp();
|
||||
var tokens = tokenizer.Tokenize(query);
|
||||
var tokenDefinitions = tokenizer.Tokens.Values.ToArray();
|
||||
var endTokenize = Stopwatch.GetTimestamp();
|
||||
var tokenizeDuration = Stopwatch.GetElapsedTime(startTokenize, endTokenize).TotalMilliseconds.ToString("F3");
|
||||
if (tokens.Count == 0) return Codify($"No tokens emitted after {tokenizeDuration}ms");
|
||||
|
||||
var codePoints = query.ToCodePoints().ToArray();
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"Tokenization completed in {tokenizeDuration}ms, emitted {tokens.Count} tokens:"
|
||||
};
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
var tokenDef = tokenDefinitions[token.Id];
|
||||
var originalPhrase = codePoints.Skip(token.Start).Take(token.End - token.Start).ToUtf32String();
|
||||
lines.Add($" {tokenDef.Type}: {JsonSerializer.Serialize(tokenDef.Text, JsonSerializerOptions)} <- {JsonSerializer.Serialize(originalPhrase, JsonSerializerOptions)} [{token.Start}, {token.End}]");
|
||||
}
|
||||
return Codify(string.Join('\n', lines));
|
||||
}
|
||||
|
||||
private static string InspectSearchResult(SearchResult result, bool htmlHighlight)
|
||||
{
|
||||
var documentText = result.DocumentText;
|
||||
var documentCodePoints = result.DocumentCodePoints;
|
||||
var tokens = result.Tokens;
|
||||
var rangeCount = result.RangeCount;
|
||||
var matchRatio = result.MatchRatio;
|
||||
var matchRatioLevel = result.MatchRatioLevel;
|
||||
|
||||
var resultText = htmlHighlight
|
||||
? string.Join("", SearchResultHighlighter.Highlight(result).Select(part => !part.IsHighlighted ? EscapeHtml(part.Text) : $"<u><b>{EscapeHtml(part.Text)}</b></u>"))
|
||||
: documentText;
|
||||
var description = $" ({rangeCount} ranges, {Math.Round(matchRatio * 10000) / 10000} => L{matchRatioLevel})";
|
||||
return string.Join('\n',
|
||||
[
|
||||
resultText + (htmlHighlight ? $"<code>{description}</code>" : description),
|
||||
.. tokens.Select(token =>
|
||||
{
|
||||
var escapedTokenText = JsonSerializer.Serialize(token.Definition.Text, JsonSerializerOptions);
|
||||
var escapedDocumentText = JsonSerializer.Serialize(documentCodePoints.Skip(token.DocumentOffset.Start).Take(token.DocumentOffset.Length).ToUtf32String(), JsonSerializerOptions);
|
||||
if (htmlHighlight)
|
||||
{
|
||||
escapedTokenText = EscapeHtml(escapedTokenText);
|
||||
escapedDocumentText = EscapeHtml(escapedDocumentText);
|
||||
}
|
||||
var line = $" {token.Definition.Type}: {escapedTokenText} -> {escapedDocumentText}" + (token.IsTokenPrefixMatching ? " (prefix match)" : "");
|
||||
return htmlHighlight ? $"<code>{line}</code>" : line;
|
||||
}),
|
||||
"",
|
||||
]);
|
||||
}
|
||||
|
||||
private static string Codify(string text) => $"<code>{EscapeHtml(text)}</code>";
|
||||
private static JsonSerializerOptions JsonSerializerOptions => new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
|
||||
private static string EscapeHtml(string text) => text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Common.Types;
|
||||
using MaigoLabs.NeedLe.Searcher.Trie;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Searcher;
|
||||
|
||||
public class LoadedInvertedIndex
|
||||
{
|
||||
public class TokenDocumentReference
|
||||
{
|
||||
public required int DocumentId { get; set; }
|
||||
public required OffsetSpan[] Offsets { get; set; }
|
||||
}
|
||||
|
||||
public class TokenDefinitionExtended : TokenDefinition
|
||||
{
|
||||
public required TokenDocumentReference[] References { get; set; }
|
||||
}
|
||||
|
||||
public class TypedTries
|
||||
{
|
||||
public required TrieNode Romaji { get; set; }
|
||||
public required TrieNode Kana { get; set; }
|
||||
public required TrieNode Other { get; set; }
|
||||
}
|
||||
|
||||
public required string[] Documents { get; set; }
|
||||
public required int[][] DocumentCodePoints { get; set; }
|
||||
public required TokenDefinitionExtended[] TokenDefinitions { get; set; }
|
||||
public required TypedTries Tries { get; set; }
|
||||
}
|
||||
|
||||
public class InvertedIndexLoader
|
||||
{
|
||||
public static LoadedInvertedIndex Load(CompressedInvertedIndex compressed)
|
||||
{
|
||||
var documents = compressed.documents;
|
||||
var documentCodePoints = documents.Select(document => document.ToCodePoints().ToArray()).ToArray();
|
||||
|
||||
var romajiTrie = TrieDeserializer.Deserialize(compressed.tries.romaji);
|
||||
var kanaTrie = TrieDeserializer.Deserialize(compressed.tries.kana);
|
||||
var otherTrie = TrieDeserializer.Deserialize(compressed.tries.other);
|
||||
|
||||
var tokenCodePoints = romajiTrie.TokenCodePoints.Concat(kanaTrie.TokenCodePoints).Concat(otherTrie.TokenCodePoints)
|
||||
.ToDictionary(entry => entry.Key, entry => entry.Value);
|
||||
var tokenDefinitions = compressed.tokenTypes.Select((type, index) => new LoadedInvertedIndex.TokenDefinitionExtended
|
||||
{
|
||||
Id = index, Type = (TokenType)type, Text = tokenCodePoints[index].ToUtf32String(),
|
||||
CodePointLength = tokenCodePoints[index].Length,
|
||||
References = compressed.tokenReferences[index].Select(data => new LoadedInvertedIndex.TokenDocumentReference
|
||||
{
|
||||
DocumentId = data[0],
|
||||
Offsets = Enumerable.Range(0, data.Length / 2)
|
||||
.Select(i => new OffsetSpan { Start = data[i * 2 + 1], End = data[i * 2 + 2] }).ToArray(),
|
||||
}).ToArray(),
|
||||
}).ToArray();
|
||||
|
||||
return new LoadedInvertedIndex
|
||||
{
|
||||
Documents = documents,
|
||||
DocumentCodePoints = documentCodePoints,
|
||||
TokenDefinitions = tokenDefinitions,
|
||||
Tries = new LoadedInvertedIndex.TypedTries
|
||||
{
|
||||
Romaji = romajiTrie.Root,
|
||||
Kana = kanaTrie.Root,
|
||||
Other = otherTrie.Root,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Common.Types;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Searcher;
|
||||
|
||||
public class SearchResultToken
|
||||
{
|
||||
public required TokenDefinition Definition { get; set; }
|
||||
public required OffsetSpan DocumentOffset { get; set; }
|
||||
public required OffsetSpan InputOffset { get; set; }
|
||||
public required bool IsTokenPrefixMatching { get; set; }
|
||||
}
|
||||
|
||||
public class SearchResult
|
||||
{
|
||||
public required int DocumentId { get; set; }
|
||||
public required string DocumentText { get; set; }
|
||||
public required int[] DocumentCodePoints { get; set; }
|
||||
public required SearchResultToken[] Tokens { get; set; }
|
||||
public required int PrefixMatchCount { get; set; }
|
||||
public required int RangeCount { get; set; }
|
||||
public required double MatchRatio { get; set; }
|
||||
public required int MatchRatioLevel { get; set; }
|
||||
}
|
||||
|
||||
public static class InvertedIndexSearcher
|
||||
{
|
||||
public abstract class ComparableStateBase<T> : IComparable<T>
|
||||
where T : ComparableStateBase<T>
|
||||
{
|
||||
protected abstract int GetRangeCount();
|
||||
protected abstract int GetPrefixMatchCount();
|
||||
protected abstract OffsetSpan GetFirstTokenDocumentOffset();
|
||||
protected abstract OffsetSpan GetLastTokenDocumentOffset();
|
||||
protected virtual SearchResultToken? GetLastToken() => null; // Not on intermediate results
|
||||
protected virtual int? GetMatchRatioLevel() => null; // Not on intermediate/candidate results
|
||||
protected abstract double GetMatchRatio();
|
||||
protected virtual int FallbackCompareTo(T other) => 0; // Called when all other comparisons are equal
|
||||
|
||||
public int CompareTo(T other)
|
||||
{
|
||||
// Prefer matches that not relying on end-of-input loose matching (full match over prefix match)
|
||||
SearchResultToken? aLastToken = GetLastToken(), bLastToken = other.GetLastToken();
|
||||
if (aLastToken != null && bLastToken != null)
|
||||
{
|
||||
var aDidPrefixMatchByTokenType = aLastToken.IsTokenPrefixMatching && tokenTypePrefixMatchingPolicy[aLastToken.Definition.Type] == TokenTypePrefixMatchingPolicy.AllowOnlyAtInputEnd;
|
||||
var bDidPrefixMatchByTokenType = bLastToken.IsTokenPrefixMatching && tokenTypePrefixMatchingPolicy[bLastToken.Definition.Type] == TokenTypePrefixMatchingPolicy.AllowOnlyAtInputEnd;
|
||||
if (aDidPrefixMatchByTokenType != bDidPrefixMatchByTokenType) return aDidPrefixMatchByTokenType ? 1 : -1;
|
||||
}
|
||||
|
||||
// Prefer results that matched fewer discontinuous ranges over more
|
||||
int aRangeCount = GetRangeCount(), bRangeCount = other.GetRangeCount();
|
||||
if (aRangeCount != bRangeCount) return aRangeCount - bRangeCount;
|
||||
|
||||
// Prefer results that matches first token in document earlier over later
|
||||
OffsetSpan aFirstTokenDocumentOffset = GetFirstTokenDocumentOffset(), bFirstTokenDocumentOffset = other.GetFirstTokenDocumentOffset();
|
||||
if (aFirstTokenDocumentOffset.Start != bFirstTokenDocumentOffset.Start) return aFirstTokenDocumentOffset.Start - bFirstTokenDocumentOffset.Start;
|
||||
|
||||
// Prefer results that has higher match ratio (but don't distinguish similar ratios, so we introduced `matchRatioLevel`)
|
||||
int? aMatchRatioLevel = GetMatchRatioLevel(), bMatchRatioLevel = other.GetMatchRatioLevel();
|
||||
if (aMatchRatioLevel != null && bMatchRatioLevel != null)
|
||||
{
|
||||
if (aMatchRatioLevel.Value != bMatchRatioLevel.Value) return bMatchRatioLevel.Value - aMatchRatioLevel.Value;
|
||||
}
|
||||
|
||||
// Prefer results that last token occurred earlier (if same, ended earlier) in the document over later
|
||||
OffsetSpan aLastTokenDocumentOffset = GetLastTokenDocumentOffset(), bLastTokenDocumentOffset = other.GetLastTokenDocumentOffset();
|
||||
if (aLastTokenDocumentOffset.Start != bLastTokenDocumentOffset.Start) return aLastTokenDocumentOffset.Start - bLastTokenDocumentOffset.Start;
|
||||
if (aLastTokenDocumentOffset.End != bLastTokenDocumentOffset.End) return aLastTokenDocumentOffset.End - bLastTokenDocumentOffset.End;
|
||||
|
||||
// Prefer results that has higher match ratio (precisely)
|
||||
double aMatchRatio = GetMatchRatio(), bMatchRatio = other.GetMatchRatio();
|
||||
if (aMatchRatio != bMatchRatio) return bMatchRatio < aMatchRatio ? -1 : bMatchRatio > aMatchRatio ? 1 : 0;
|
||||
|
||||
return FallbackCompareTo(other);
|
||||
}
|
||||
}
|
||||
|
||||
public class IntermediateResult : ComparableStateBase<IntermediateResult>
|
||||
{
|
||||
public required IntermediateResult? PreviousState { get; init; }
|
||||
public required OffsetSpan FirstTokenDocumentOffset { get; init; }
|
||||
public required int RangeCount { get; init; }
|
||||
public required int TokenCount { get; init; }
|
||||
public required int PrefixMatchCount { get; init; }
|
||||
public required double MatchedTokenLength { get; init; }
|
||||
public required int TokenId { get; init; }
|
||||
public required OffsetSpan DocumentOffset { get; init; }
|
||||
public required OffsetSpan InputOffset { get; init; }
|
||||
public required bool IsTokenPrefixMatching { get; init; }
|
||||
|
||||
protected override int GetRangeCount() => RangeCount;
|
||||
protected override int GetPrefixMatchCount() => PrefixMatchCount;
|
||||
protected override OffsetSpan GetFirstTokenDocumentOffset() => FirstTokenDocumentOffset;
|
||||
protected override OffsetSpan GetLastTokenDocumentOffset() => DocumentOffset;
|
||||
protected override double GetMatchRatio() => MatchedTokenLength; // No need to divide document length since intermediate results are for same document
|
||||
}
|
||||
|
||||
public class CandidateResult : ComparableStateBase<CandidateResult>
|
||||
{
|
||||
public required SearchResultToken[] Tokens { get; init; }
|
||||
public required int PrefixMatchCount { get; init; }
|
||||
public required double MatchedTokenLength { get; init; }
|
||||
public required int RangeCount { get; init; }
|
||||
|
||||
protected override int GetRangeCount() => RangeCount;
|
||||
protected override int GetPrefixMatchCount() => PrefixMatchCount;
|
||||
protected override OffsetSpan GetFirstTokenDocumentOffset() => Tokens[0].DocumentOffset;
|
||||
protected override OffsetSpan GetLastTokenDocumentOffset() => Tokens[^1].DocumentOffset;
|
||||
protected override SearchResultToken? GetLastToken() => Tokens[^1];
|
||||
protected override double GetMatchRatio() => MatchedTokenLength; // No need to divide document length since intermediate results are for same document
|
||||
}
|
||||
|
||||
public class FinalResult : ComparableStateBase<FinalResult>
|
||||
{
|
||||
public required SearchResult Result { get; init; }
|
||||
|
||||
protected override int GetRangeCount() => Result.RangeCount;
|
||||
protected override int GetPrefixMatchCount() => Result.PrefixMatchCount;
|
||||
protected override OffsetSpan GetFirstTokenDocumentOffset() => Result.Tokens[0].DocumentOffset;
|
||||
protected override OffsetSpan GetLastTokenDocumentOffset() => Result.Tokens[^1].DocumentOffset;
|
||||
protected override SearchResultToken? GetLastToken() => Result.Tokens[^1];
|
||||
protected override double GetMatchRatio() => Result.MatchRatio;
|
||||
protected override int? GetMatchRatioLevel() => Result.MatchRatioLevel;
|
||||
protected override int FallbackCompareTo(FinalResult other) => string.Compare(Result.DocumentText, other.Result.DocumentText, StringComparison.InvariantCulture);
|
||||
}
|
||||
|
||||
private static bool IsIgnorableCodePoint(int codePoint) => CommonUtils.IsWhitespace(codePoint) || codePoint == 0x3099 || codePoint == 0x309A;
|
||||
|
||||
public enum TokenTypePrefixMatchingPolicy {
|
||||
AlwaysAllow,
|
||||
NeverAllow,
|
||||
AllowOnlyAtInputEnd,
|
||||
}
|
||||
|
||||
private static Dictionary<TokenType, TokenTypePrefixMatchingPolicy> tokenTypePrefixMatchingPolicy = new()
|
||||
{
|
||||
[TokenType.Romaji] = TokenTypePrefixMatchingPolicy.NeverAllow,
|
||||
[TokenType.Kana] = TokenTypePrefixMatchingPolicy.AlwaysAllow,
|
||||
// These token types are in an "other" Trie
|
||||
[TokenType.Han] = TokenTypePrefixMatchingPolicy.AllowOnlyAtInputEnd, // No effect because always 1 code point
|
||||
[TokenType.Pinyin] = TokenTypePrefixMatchingPolicy.AllowOnlyAtInputEnd,
|
||||
[TokenType.Raw] = TokenTypePrefixMatchingPolicy.AllowOnlyAtInputEnd, // No effect because always 1 code point
|
||||
};
|
||||
|
||||
private static bool ShouldAllowPrefixMatching(TokenType tokenType, bool isAtInputEnd) =>
|
||||
tokenTypePrefixMatchingPolicy[tokenType] == TokenTypePrefixMatchingPolicy.AlwaysAllow ||
|
||||
(tokenTypePrefixMatchingPolicy[tokenType] != TokenTypePrefixMatchingPolicy.NeverAllow && isAtInputEnd);
|
||||
|
||||
private static bool HasNonEmptyCharacters(int[] documentCodePoints, int start, int end) =>
|
||||
start != end && !documentCodePoints.Skip(start).Take(end - start).All(CommonUtils.IsWhitespace);
|
||||
|
||||
public static SearchResult[] Search(LoadedInvertedIndex invertedIndex, string text)
|
||||
{
|
||||
var documents = invertedIndex.Documents;
|
||||
var documentCodePoints = invertedIndex.DocumentCodePoints;
|
||||
var tokenDefinitions = invertedIndex.TokenDefinitions;
|
||||
var tries = invertedIndex.Tries;
|
||||
|
||||
var codePoints = text.ToCodePoints().Select(CommonNormalization.NormalizeCodePoint).Select(CommonNormalization.ToKatakana).ToArray();
|
||||
// dp[i] = docId => end => IntermediateResult, starts from dp[-1] (l === 0), ends at dp[N - 1] (r === N - 1)
|
||||
var dp = Enumerable.Range(0, codePoints.Length).Select(l => new Dictionary<int, Dictionary<int, IntermediateResult>>()).ToArray();
|
||||
for (var l = 0; l < codePoints.Length; l++)
|
||||
{
|
||||
if (l != 0 && dp[l - 1].Count == 0) continue; // No documents match input from beginning to this position
|
||||
var romajiNode = tries.Romaji;
|
||||
var kanaNode = tries.Kana;
|
||||
var otherNode = tries.Other;
|
||||
for (var r = l; r < codePoints.Length && (romajiNode != null || kanaNode != null || otherNode != null); r++) // [l, r]
|
||||
{
|
||||
var codePoint = codePoints[r];
|
||||
romajiNode = romajiNode.TraverseStep(codePoint, IsIgnorableCodePoint(codePoint));
|
||||
kanaNode = kanaNode.TraverseStep(codePoint, IsIgnorableCodePoint(codePoint));
|
||||
otherNode = otherNode.TraverseStep(codePoint, IsIgnorableCodePoint(codePoint));
|
||||
var reachingInputEnd = r == codePoints.Length - 1;
|
||||
HashSet<int> matchingTokenIds =
|
||||
[
|
||||
// Allow suffix matching of romaji/other tokens if we're at the end of the input
|
||||
.. romajiNode.GetTokenIds(ShouldAllowPrefixMatching(TokenType.Romaji, reachingInputEnd)),
|
||||
.. kanaNode.GetTokenIds(ShouldAllowPrefixMatching(TokenType.Kana, reachingInputEnd)),
|
||||
.. otherNode.GetTokenIds(reachingInputEnd),
|
||||
];
|
||||
foreach (var tokenId in matchingTokenIds) foreach (var reference in tokenDefinitions[tokenId].References)
|
||||
{
|
||||
var isTokenPrefixMatching = !romajiNode.IsTokenExactMatch(tokenId) && !kanaNode.IsTokenExactMatch(tokenId) && !otherNode.IsTokenExactMatch(tokenId);
|
||||
var previousMatchesOfDocument = l != 0 && dp[l - 1].TryGetValue(reference.DocumentId, out var previousMatches) ? previousMatches : null;
|
||||
if (l != 0 && previousMatchesOfDocument == null) continue;
|
||||
foreach (var documentOffset in reference.Offsets)
|
||||
{
|
||||
int currentStart = documentOffset.Start, currentEnd = documentOffset.End;
|
||||
if (l == 0) ContributeNextMatchingState(null);
|
||||
else foreach (var (previousEnd, previousMatch) in previousMatchesOfDocument!) if (currentStart >= previousEnd) ContributeNextMatchingState(previousMatch);
|
||||
void ContributeNextMatchingState(IntermediateResult? previousState)
|
||||
{
|
||||
var nextMatchingMap = dp[r];
|
||||
if (!nextMatchingMap.TryGetValue(reference.DocumentId, out var nextMatches)) nextMatches = nextMatchingMap[reference.DocumentId] = [];
|
||||
var oldResult = nextMatches.TryGetValue(currentEnd, out var result) ? result : null;
|
||||
var inputOffset = new OffsetSpan { Start = l, End = r + 1 };
|
||||
var newResult = new IntermediateResult
|
||||
{
|
||||
PreviousState = previousState,
|
||||
FirstTokenDocumentOffset = previousState?.FirstTokenDocumentOffset ?? documentOffset,
|
||||
RangeCount = previousState == null ? 1 :
|
||||
previousState.RangeCount + (HasNonEmptyCharacters(documentCodePoints[reference.DocumentId], previousState.DocumentOffset.End, currentStart) ? 1 : 0),
|
||||
TokenCount = (previousState?.TokenCount ?? 0) + 1,
|
||||
PrefixMatchCount = (previousState?.PrefixMatchCount ?? 0) + (isTokenPrefixMatching ? 1 : 0),
|
||||
MatchedTokenLength = (previousState?.MatchedTokenLength ?? 0) + documentOffset.Length *
|
||||
Math.Min(isTokenPrefixMatching ? (double)inputOffset.Length / tokenDefinitions[tokenId].CodePointLength : double.PositiveInfinity, 1),
|
||||
TokenId = tokenId,
|
||||
DocumentOffset = documentOffset,
|
||||
InputOffset = inputOffset,
|
||||
IsTokenPrefixMatching = isTokenPrefixMatching,
|
||||
};
|
||||
nextMatches[currentEnd] = oldResult == null || newResult.CompareTo(oldResult) < 0 ? newResult : oldResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build search results and sort documents
|
||||
return dp[codePoints.Length - 1].Select(entry =>
|
||||
{
|
||||
var (documentId, matches) = entry;
|
||||
var sortedMatches = matches.Values.Select(match =>
|
||||
{
|
||||
var tokens = new List<SearchResultToken>();
|
||||
// Build token list from backtracking
|
||||
var state = match;
|
||||
while (state != null)
|
||||
{
|
||||
tokens.Add(new SearchResultToken
|
||||
{
|
||||
Definition = tokenDefinitions[state.TokenId],
|
||||
DocumentOffset = state.DocumentOffset, InputOffset = state.InputOffset,
|
||||
IsTokenPrefixMatching = state.IsTokenPrefixMatching,
|
||||
});
|
||||
state = state.PreviousState;
|
||||
}
|
||||
tokens.Reverse();
|
||||
return new CandidateResult
|
||||
{
|
||||
Tokens = tokens.ToArray(),
|
||||
PrefixMatchCount = match.PrefixMatchCount,
|
||||
MatchedTokenLength = match.MatchedTokenLength,
|
||||
RangeCount = match.RangeCount,
|
||||
};
|
||||
}).OrderBy(match => match);
|
||||
var bestMatch = sortedMatches.First();
|
||||
var documentText = documents[documentId];
|
||||
var matchRatio = bestMatch.MatchedTokenLength / documentCodePoints[documentId].Length;
|
||||
var matchRatioLevel = (int)Math.Round(matchRatio * 5);
|
||||
return new FinalResult
|
||||
{
|
||||
Result = new SearchResult
|
||||
{
|
||||
DocumentId = documentId,
|
||||
DocumentText = documentText,
|
||||
DocumentCodePoints = documentCodePoints[documentId],
|
||||
Tokens = bestMatch.Tokens,
|
||||
PrefixMatchCount = bestMatch.PrefixMatchCount,
|
||||
RangeCount = bestMatch.RangeCount,
|
||||
MatchRatio = matchRatio,
|
||||
MatchRatioLevel = matchRatioLevel,
|
||||
}
|
||||
};
|
||||
}).OrderBy(result => result).Select(result => result.Result).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>$(ProjectName).Searcher</RootNamespace>
|
||||
<AssemblyName>$(RootNamespace)</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>$(RootNamespace)</PackageId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Common\MaigoLabs.NeedLe.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotNetCampus.LatestCSharpFeatures" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,37 @@
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Common.Types;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Searcher;
|
||||
|
||||
public class HighlightedTextPart
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required bool IsHighlighted { get; init; }
|
||||
}
|
||||
|
||||
public static class SearchResultHighlighter
|
||||
{
|
||||
public static List<HighlightedTextPart> Highlight(SearchResult resultDocument)
|
||||
{
|
||||
var result = new List<HighlightedTextPart>();
|
||||
var previousHighlightEnd = 0;
|
||||
foreach (var token in resultDocument.Tokens)
|
||||
{
|
||||
var notHighlightedText = resultDocument.DocumentCodePoints.Skip(previousHighlightEnd).Take(token.DocumentOffset.Start - previousHighlightEnd).ToUtf32String();
|
||||
if (notHighlightedText.Length > 0) result.Add(new HighlightedTextPart { Text = notHighlightedText, IsHighlighted = false });
|
||||
var highlightEnd = token.IsTokenPrefixMatching && token.Definition.Type == TokenType.Kana
|
||||
? token.DocumentOffset.Start + Math.Max(
|
||||
1,
|
||||
(int)Math.Round(
|
||||
token.DocumentOffset.Length *
|
||||
Math.Min(1, (double)token.InputOffset.Length / token.Definition.CodePointLength)
|
||||
)
|
||||
)
|
||||
: token.DocumentOffset.End;
|
||||
result.Add(new HighlightedTextPart { Text = resultDocument.DocumentCodePoints.Skip(token.DocumentOffset.Start).Take(highlightEnd - token.DocumentOffset.Start).ToUtf32String(), IsHighlighted = true });
|
||||
previousHighlightEnd = highlightEnd;
|
||||
}
|
||||
if (previousHighlightEnd < resultDocument.DocumentCodePoints.Length) result.Add(new HighlightedTextPart { Text = resultDocument.DocumentCodePoints.Skip(previousHighlightEnd).ToUtf32String(), IsHighlighted = false });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Searcher.Trie;
|
||||
|
||||
public class DeserializedTrie
|
||||
{
|
||||
public required TrieNode Root { get; set; }
|
||||
public required Dictionary<int, int[]> TokenCodePoints { get; set; }
|
||||
}
|
||||
|
||||
public static class TrieDeserializer
|
||||
{
|
||||
public static DeserializedTrie Deserialize(int[] data)
|
||||
{
|
||||
var nodes = new List<TrieNode?>();
|
||||
TrieNode GetNode(int id)
|
||||
{
|
||||
if (id > nodes.Count) nodes.AddRange(Enumerable.Repeat<TrieNode?>(null, id - nodes.Count));
|
||||
return nodes[id - 1] ??= new TrieNode { Parent = null, Children = [], TokenIds = [], SubTreeTokenIds = [] };
|
||||
}
|
||||
var currentId = 0;
|
||||
for (var i = 0; i < data.Length; )
|
||||
{
|
||||
var node = GetNode(++currentId);
|
||||
var parentId = data[i++];
|
||||
node.Parent = parentId != 0 ? GetNode(parentId) : null;
|
||||
|
||||
var endOfChildren = i;
|
||||
while (endOfChildren < data.Length && data[endOfChildren] > 0) endOfChildren++;
|
||||
var numberOfChildren = (endOfChildren - i) / 2;
|
||||
for (var j = i; j < i + numberOfChildren; j++)
|
||||
{
|
||||
var codePoint = data[j];
|
||||
var child = GetNode(data[j + numberOfChildren]);
|
||||
node.Children.Add(codePoint, child);
|
||||
}
|
||||
i = endOfChildren;
|
||||
|
||||
if (data[i] == 0) i++; // No token IDs
|
||||
else while (i < data.Length && data[i] < 0) node.TokenIds.Add(-data[i++] - 1);
|
||||
}
|
||||
var root = nodes[0]!;
|
||||
|
||||
// DFS to construct code point paths for each token
|
||||
var tokenCodePoints = new Dictionary<int, int[]>();
|
||||
var currentCodePoints = new List<int>();
|
||||
void DfsCodePoints(TrieNode node)
|
||||
{
|
||||
foreach (var tokenId in node.TokenIds) tokenCodePoints.Add(tokenId, [.. currentCodePoints]);
|
||||
foreach (var (codePoint, child) in node.Children)
|
||||
{
|
||||
if (child.Parent != node) continue; // Skip grafted paths as these are not the canonical representation of the tokens
|
||||
currentCodePoints.Add(codePoint);
|
||||
DfsCodePoints(child);
|
||||
currentCodePoints.RemoveAt(currentCodePoints.Count - 1);
|
||||
}
|
||||
}
|
||||
DfsCodePoints(root);
|
||||
|
||||
// DFS to construct subTreeTokenIds for each node
|
||||
var visitedNodes = new HashSet<TrieNode>();
|
||||
List<int> DfsSubTreeTokenIds(TrieNode node)
|
||||
{
|
||||
if (visitedNodes.Contains(node)) return node.SubTreeTokenIds;
|
||||
visitedNodes.Add(node);
|
||||
node.SubTreeTokenIds = new HashSet<int>(node.TokenIds.Concat(node.Children.Values.SelectMany(DfsSubTreeTokenIds))).ToList();
|
||||
return node.SubTreeTokenIds;
|
||||
};
|
||||
DfsSubTreeTokenIds(root);
|
||||
|
||||
return new DeserializedTrie { Root = root, TokenCodePoints = tokenCodePoints };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Common;
|
||||
|
||||
#region ToKatakana
|
||||
|
||||
public sealed class ToKatakana_ConvertsHiraganaToKatakanaTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("アイウエオ", CommonNormalization.ToKatakana("あいうえお"));
|
||||
Assert.Equal("カキクケコ", CommonNormalization.ToKatakana("かきくけこ"));
|
||||
Assert.Equal("サシスセソ", CommonNormalization.ToKatakana("さしすせそ"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToKatakana_KeepsKatakanaUnchangedTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("アイウエオ", CommonNormalization.ToKatakana("アイウエオ"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToKatakana_KeepsNonKanaUnchangedTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("abc123", CommonNormalization.ToKatakana("abc123"));
|
||||
Assert.Equal("漢字", CommonNormalization.ToKatakana("漢字"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToKatakana_HandlesMixedInputTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("アアa漢", CommonNormalization.ToKatakana("あアa漢"));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeCodePoint
|
||||
|
||||
public sealed class NormalizeCodePoint_ConvertsFullwidthAsciiToHalfwidthLowercaseTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal('a', CommonNormalization.NormalizeCodePoint('A'));
|
||||
Assert.Equal('b', CommonNormalization.NormalizeCodePoint('B'));
|
||||
Assert.Equal('c', CommonNormalization.NormalizeCodePoint('C'));
|
||||
Assert.Equal('1', CommonNormalization.NormalizeCodePoint('1'));
|
||||
Assert.Equal('2', CommonNormalization.NormalizeCodePoint('2'));
|
||||
Assert.Equal('3', CommonNormalization.NormalizeCodePoint('3'));
|
||||
Assert.Equal('!', CommonNormalization.NormalizeCodePoint('!'));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NormalizeCodePoint_ConvertsFullwidthSpaceToHalfwidthTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal(' ', CommonNormalization.NormalizeCodePoint(' '));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NormalizeCodePoint_ConvertsHalfwidthKanaToFullwidthTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal('ア', CommonNormalization.NormalizeCodePoint('ア'));
|
||||
Assert.Equal('イ', CommonNormalization.NormalizeCodePoint('イ'));
|
||||
Assert.Equal('ウ', CommonNormalization.NormalizeCodePoint('ウ'));
|
||||
Assert.Equal('エ', CommonNormalization.NormalizeCodePoint('エ'));
|
||||
Assert.Equal('オ', CommonNormalization.NormalizeCodePoint('オ'));
|
||||
Assert.Equal('カ', CommonNormalization.NormalizeCodePoint('カ'));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NormalizeCodePoint_NormalizesVoicedSoundMarksTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal(0x3099, CommonNormalization.NormalizeCodePoint('゙')); // halfwidth voiced -> combining
|
||||
Assert.Equal(0x309A, CommonNormalization.NormalizeCodePoint('゚')); // halfwidth semi-voiced -> combining
|
||||
Assert.Equal(0x3099, CommonNormalization.NormalizeCodePoint('゛')); // fullwidth voiced -> combining
|
||||
Assert.Equal(0x309A, CommonNormalization.NormalizeCodePoint('゜')); // fullwidth semi-voiced -> combining
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NormalizeCodePoint_ConvertsHalfwidthPunctuationToFullwidthTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal('。', CommonNormalization.NormalizeCodePoint('。'));
|
||||
Assert.Equal('「', CommonNormalization.NormalizeCodePoint('「'));
|
||||
Assert.Equal('」', CommonNormalization.NormalizeCodePoint('」'));
|
||||
Assert.Equal('、', CommonNormalization.NormalizeCodePoint('、'));
|
||||
Assert.Equal('・', CommonNormalization.NormalizeCodePoint('・'));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NormalizeCodePoint_LowercasesRegularAsciiTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal('a', CommonNormalization.NormalizeCodePoint('A'));
|
||||
Assert.Equal('b', CommonNormalization.NormalizeCodePoint('B'));
|
||||
Assert.Equal('c', CommonNormalization.NormalizeCodePoint('C'));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using MaigoLabs.NeedLe.Indexer;
|
||||
using MaigoLabs.NeedLe.Searcher;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.E2E;
|
||||
|
||||
public sealed class Search_MatchesWithMixedSearchQueryTest : NeedleTestBase
|
||||
{
|
||||
private static readonly string[] TestDocuments =
|
||||
[
|
||||
"ミーティア",
|
||||
"エンドマークに希望と涙を添えて",
|
||||
"宵の鳥",
|
||||
"僕の和風本当上手",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var compressed = InvertedIndexBuilder.BuildInvertedIndex(TestDocuments, TokenizerOptions);
|
||||
var invertedIndex = InvertedIndexLoader.Load(compressed);
|
||||
|
||||
var results = InvertedIndexSearcher.Search(invertedIndex, "bokunoh风じょう");
|
||||
|
||||
// Should have at least one result
|
||||
Assert.NotEmpty(results);
|
||||
|
||||
// The first result should be "僕の和風本当上手"
|
||||
Assert.Equal("僕の和風本当上手", results[0].DocumentText);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Search_HighlightsSearchResultCorrectlyTest : NeedleTestBase
|
||||
{
|
||||
private static readonly string[] TestDocuments =
|
||||
[
|
||||
"ミーティア",
|
||||
"エンドマークに希望と涙を添えて",
|
||||
"宵の鳥",
|
||||
"僕の和風本当上手",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var compressed = InvertedIndexBuilder.BuildInvertedIndex(TestDocuments, TokenizerOptions);
|
||||
var invertedIndex = InvertedIndexLoader.Load(compressed);
|
||||
|
||||
var results = InvertedIndexSearcher.Search(invertedIndex, "bokunoh风じょう");
|
||||
Assert.NotEmpty(results);
|
||||
|
||||
var highlighted = SearchResultHighlighter.Highlight(results[0]);
|
||||
|
||||
// Should be a list of parts
|
||||
Assert.NotEmpty(highlighted);
|
||||
|
||||
// Collect highlighted text
|
||||
var highlightedTexts = highlighted.Where(p => p.IsHighlighted).Select(p => p.Text).ToList();
|
||||
var highlightedJoined = string.Join("", highlightedTexts);
|
||||
|
||||
Assert.Contains("僕", highlightedJoined);
|
||||
Assert.Contains("の", highlightedJoined);
|
||||
Assert.Contains("和", highlightedJoined);
|
||||
Assert.Contains("風", highlightedJoined);
|
||||
Assert.Contains("上", highlightedJoined);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Search_MatchesRomajiInputToKanaDocumentsTest : NeedleTestBase
|
||||
{
|
||||
private static readonly string[] TestDocuments =
|
||||
[
|
||||
"ミーティア",
|
||||
"エンドマークに希望と涙を添えて",
|
||||
"宵の鳥",
|
||||
"僕の和風本当上手",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var compressed = InvertedIndexBuilder.BuildInvertedIndex(TestDocuments, TokenizerOptions);
|
||||
var invertedIndex = InvertedIndexLoader.Load(compressed);
|
||||
|
||||
// Search for "yoi" should match "宵の鳥"
|
||||
var results = InvertedIndexSearcher.Search(invertedIndex, "yoi");
|
||||
var matchedTexts = results.Select(r => r.DocumentText).ToList();
|
||||
|
||||
Assert.Contains("宵の鳥", matchedTexts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Indexer.Trie;
|
||||
using MaigoLabs.NeedLe.Searcher.Trie;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.E2E;
|
||||
|
||||
#region Trie Building
|
||||
|
||||
public sealed class TrieBuilding_BuildsTrieWithMultipleDifferentTokensTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var trie = TrieBuilder.BuildTrie([
|
||||
(0, "hello".ToCodePoints()),
|
||||
(1, "help".ToCodePoints()),
|
||||
(2, "world".ToCodePoints()),
|
||||
(3, "word".ToCodePoints()),
|
||||
]);
|
||||
|
||||
// Traverse to verify structure
|
||||
var helloNode = trie.Traverse("hello".ToCodePoints().ToArray());
|
||||
var helpNode = trie.Traverse("help".ToCodePoints().ToArray());
|
||||
var worldNode = trie.Traverse("world".ToCodePoints().ToArray());
|
||||
var wordNode = trie.Traverse("word".ToCodePoints().ToArray());
|
||||
|
||||
Assert.NotNull(helloNode);
|
||||
Assert.NotNull(helpNode);
|
||||
Assert.NotNull(worldNode);
|
||||
Assert.NotNull(wordNode);
|
||||
|
||||
// Check token IDs
|
||||
Assert.Contains(0, helloNode!.TokenIds);
|
||||
Assert.Contains(1, helpNode!.TokenIds);
|
||||
Assert.Contains(2, worldNode!.TokenIds);
|
||||
Assert.Contains(3, wordNode!.TokenIds);
|
||||
|
||||
// Check that 'hel' prefix node has both tokens in subTree
|
||||
var helNode = trie.Traverse("hel".ToCodePoints().ToArray());
|
||||
Assert.NotNull(helNode);
|
||||
Assert.Contains(0, helNode!.SubTreeTokenIds);
|
||||
Assert.Contains(1, helNode.SubTreeTokenIds);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrieBuilding_HandlesJapaneseTextTokensTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var trie = TrieBuilder.BuildTrie([
|
||||
(0, "さくら".ToCodePoints()),
|
||||
(1, "サクラ".ToCodePoints()),
|
||||
(2, "桜".ToCodePoints()),
|
||||
]);
|
||||
|
||||
Assert.Contains(0, trie.Traverse("さくら".ToCodePoints().ToArray())?.TokenIds ?? []);
|
||||
Assert.Contains(1, trie.Traverse("サクラ".ToCodePoints().ToArray())?.TokenIds ?? []);
|
||||
Assert.Contains(2, trie.Traverse("桜".ToCodePoints().ToArray())?.TokenIds ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trie Serialization
|
||||
|
||||
public sealed class TrieSerialization_SerializesAndDeserializesCorrectlyTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var originalTrie = TrieBuilder.BuildTrie([
|
||||
(0, "apple".ToCodePoints()),
|
||||
(1, "app".ToCodePoints()),
|
||||
(2, "banana".ToCodePoints()),
|
||||
]);
|
||||
|
||||
// Serialize
|
||||
var serialized = TrieSerializer.Serialize(originalTrie);
|
||||
Assert.True(serialized.Length > 0);
|
||||
|
||||
// Deserialize
|
||||
var deserialized = TrieDeserializer.Deserialize(serialized);
|
||||
var deserializedTrie = deserialized.Root;
|
||||
var tokenCodePoints = deserialized.TokenCodePoints;
|
||||
|
||||
// Verify structure is preserved
|
||||
var appleNode = deserializedTrie.Traverse("apple".ToCodePoints().ToArray());
|
||||
var appNode = deserializedTrie.Traverse("app".ToCodePoints().ToArray());
|
||||
var bananaNode = deserializedTrie.Traverse("banana".ToCodePoints().ToArray());
|
||||
|
||||
Assert.NotNull(appleNode);
|
||||
Assert.NotNull(appNode);
|
||||
Assert.NotNull(bananaNode);
|
||||
|
||||
Assert.Contains(0, appleNode!.TokenIds);
|
||||
Assert.Contains(1, appNode!.TokenIds);
|
||||
Assert.Contains(2, bananaNode!.TokenIds);
|
||||
|
||||
// Verify tokenCodePoints map
|
||||
Assert.Equal("apple", tokenCodePoints[0].ToUtf32String());
|
||||
Assert.Equal("app", tokenCodePoints[1].ToUtf32String());
|
||||
Assert.Equal("banana", tokenCodePoints[2].ToUtf32String());
|
||||
|
||||
// Verify subTreeTokenIds are reconstructed
|
||||
Assert.Contains(0, appNode.SubTreeTokenIds);
|
||||
Assert.Contains(1, appNode.SubTreeTokenIds);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TrieSerialization_PreservesParentReferencesAfterDeserializationTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var originalTrie = TrieBuilder.BuildTrie([
|
||||
(0, "test".ToCodePoints()),
|
||||
]);
|
||||
|
||||
var serialized = TrieSerializer.Serialize(originalTrie);
|
||||
var deserialized = TrieDeserializer.Deserialize(serialized);
|
||||
var root = deserialized.Root;
|
||||
|
||||
var testNode = root.Traverse("test".ToCodePoints().ToArray());
|
||||
Assert.NotNull(testNode);
|
||||
|
||||
// Walk back to root via parent references
|
||||
TrieNode? node = testNode;
|
||||
var depth = 0;
|
||||
while (node?.Parent != null)
|
||||
{
|
||||
node = node.Parent;
|
||||
depth++;
|
||||
}
|
||||
Assert.Equal(4, depth); // 't' -> 'e' -> 's' -> 't' -> root
|
||||
Assert.Same(root, node);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using MaigoLabs.NeedLe.Indexer.Han;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer.Han;
|
||||
|
||||
#region IsHanCharacter
|
||||
|
||||
public sealed class IsHanCharacter_ReturnsTrueForCjkCharactersTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.True(HanVariantProvider.IsHanCharacter('中'));
|
||||
Assert.True(HanVariantProvider.IsHanCharacter('国'));
|
||||
Assert.True(HanVariantProvider.IsHanCharacter('日'));
|
||||
Assert.True(HanVariantProvider.IsHanCharacter('本'));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class IsHanCharacter_ReturnsFalseForNonCjkCharactersTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.False(HanVariantProvider.IsHanCharacter('a'));
|
||||
Assert.False(HanVariantProvider.IsHanCharacter('あ'));
|
||||
Assert.False(HanVariantProvider.IsHanCharacter('ア'));
|
||||
Assert.False(HanVariantProvider.IsHanCharacter('1'));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetHanVariants
|
||||
|
||||
public sealed class GetHanVariants_ReturnsVariantsForSimplifiedTraditionalTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var provider = new HanVariantProvider();
|
||||
// 国 (simplified) and 國 (traditional) should be variants of each other
|
||||
var variants1 = provider.GetHanVariants('国');
|
||||
var variants2 = provider.GetHanVariants('國');
|
||||
Assert.Contains('国', variants1);
|
||||
Assert.Contains('國', variants1);
|
||||
Assert.Contains('国', variants2);
|
||||
Assert.Contains('國', variants2);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetHanVariants_ReturnsCharacterItselfForNoVariantsTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var provider = new HanVariantProvider();
|
||||
var variants = provider.GetHanVariants('一');
|
||||
Assert.Contains('一', variants);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetHanVariants_ReturnsEmptyForNonHanCharactersTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var provider = new HanVariantProvider();
|
||||
Assert.Empty(provider.GetHanVariants('a'));
|
||||
Assert.Empty(provider.GetHanVariants('あ'));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using MaigoLabs.NeedLe.Indexer.Han;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer.Han;
|
||||
|
||||
public sealed class GetPinyinCandidates_ReturnsPinyinForHanCharacterTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var candidates = PinyinHelper.GetPinyinCandidates('中').ToList();
|
||||
Assert.Contains("zhong", candidates);
|
||||
Assert.Contains("zh", candidates); // initial
|
||||
Assert.Contains("z", candidates); // first letter
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetPinyinCandidates_ReturnsMultiplePinyinForPolyphonicTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
// 行 can be "xing" or "hang"
|
||||
var candidates = PinyinHelper.GetPinyinCandidates('行').ToList();
|
||||
Assert.Contains("xing", candidates);
|
||||
Assert.Contains("hang", candidates);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetPinyinCandidates_IncludesFuzzyPinyinVariantsTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
// 风 is "feng", should also have fuzzy variant "fen"
|
||||
var candidates = PinyinHelper.GetPinyinCandidates('风').ToList();
|
||||
Assert.Contains("feng", candidates);
|
||||
Assert.Contains("fen", candidates); // fuzzy: eng -> en
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetPinyinCandidates_ReturnsEmptyForNonHanCharactersTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Empty(PinyinHelper.GetPinyinCandidates('a'));
|
||||
Assert.Empty(PinyinHelper.GetPinyinCandidates('あ'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using MaigoLabs.NeedLe.Indexer.Han;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer.Han;
|
||||
|
||||
public sealed class UnionFindSet_FindsSelfAsRootInitiallyTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var ufs = new UnionFindSet();
|
||||
Assert.Equal(1, ufs.Find(1));
|
||||
Assert.Equal(2, ufs.Find(2));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UnionFindSet_UnionsTwoElementsTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var ufs = new UnionFindSet();
|
||||
ufs.Union(1, 2);
|
||||
Assert.Equal(ufs.Find(1), ufs.Find(2));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UnionFindSet_UnionsMultipleElementsTransitivelyTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var ufs = new UnionFindSet();
|
||||
ufs.Union(1, 2);
|
||||
ufs.Union(2, 3);
|
||||
ufs.Union(4, 5);
|
||||
Assert.Equal(ufs.Find(1), ufs.Find(3));
|
||||
Assert.NotEqual(ufs.Find(1), ufs.Find(4));
|
||||
ufs.Union(3, 4);
|
||||
Assert.Equal(ufs.Find(1), ufs.Find(5));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UnionFindSet_IteratesAllKeysTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var ufs = new UnionFindSet();
|
||||
ufs.Union(1, 2);
|
||||
ufs.Union(3, 4);
|
||||
var keys = ufs.Keys.ToList();
|
||||
Assert.Contains(1, keys);
|
||||
Assert.Contains(2, keys);
|
||||
Assert.Contains(3, keys);
|
||||
Assert.Contains(4, keys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer.Japanese;
|
||||
|
||||
#region ToRomajiStrictly
|
||||
|
||||
public sealed class ToRomajiStrictly_ConvertsBasicKanaToRomajiTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("a", JapaneseUtils.ToRomajiStrictly("あ"));
|
||||
Assert.Equal("ka", JapaneseUtils.ToRomajiStrictly("か"));
|
||||
Assert.Equal("sakura", JapaneseUtils.ToRomajiStrictly("さくら"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToRomajiStrictly_ConvertsKatakanaToRomajiTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("a", JapaneseUtils.ToRomajiStrictly("ア"));
|
||||
Assert.Equal("ka", JapaneseUtils.ToRomajiStrictly("カ"));
|
||||
Assert.Equal("sakura", JapaneseUtils.ToRomajiStrictly("サクラ"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToRomajiStrictly_HandlesLongVowelsTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("ou", JapaneseUtils.ToRomajiStrictly("おう"));
|
||||
Assert.Equal("oo", JapaneseUtils.ToRomajiStrictly("おお"));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToRomajiStrictly_ReturnsEmptyForInvalidFirstCharacterTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("", JapaneseUtils.ToRomajiStrictly("ー")); // prolonged sound mark cannot be first
|
||||
Assert.Equal("", JapaneseUtils.ToRomajiStrictly("ゃ")); // small ya cannot be first
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToRomajiStrictly_ReturnsEmptyForInvalidLastCharacterTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("", JapaneseUtils.ToRomajiStrictly("っ")); // small tsu cannot be last
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ToRomajiStrictly_HandlesGeminationTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
Assert.Equal("katta", JapaneseUtils.ToRomajiStrictly("かった"));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer.Japanese;
|
||||
|
||||
public sealed class GetAllKanaReadings_ReturnsKatakanaForPureKanaInputTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var provider = new TranscriptionProvider();
|
||||
var readings = provider.GetAllKanaReadings("あ");
|
||||
Assert.Contains("ア", readings);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetAllKanaReadings_ReturnsReadingsForKanjiTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var provider = new TranscriptionProvider();
|
||||
var readings = provider.GetAllKanaReadings("僕");
|
||||
Assert.NotEmpty(readings);
|
||||
// 僕 should have reading ボク
|
||||
Assert.Contains("ボク", readings);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GetAllKanaReadings_ReturnsReadingsForCompoundWordsTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var provider = new TranscriptionProvider();
|
||||
var readings = provider.GetAllKanaReadings("和風");
|
||||
Assert.NotEmpty(readings);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using MaigoLabs.NeedLe.Common.Types;
|
||||
using MaigoLabs.NeedLe.Indexer;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer;
|
||||
|
||||
public sealed class Tokenizer_TokenizesMixedJapaneseTextTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var tokenizer = new Tokenizer(TokenizerOptions);
|
||||
var tokens = tokenizer.Tokenize("僕の和風本当上手");
|
||||
|
||||
var tokenDefs = tokenizer.Tokens.Values.ToList();
|
||||
|
||||
// Should have tokens of various types
|
||||
var types = tokenDefs.Select(t => t.Type).ToHashSet();
|
||||
Assert.Contains(TokenType.Han, types);
|
||||
Assert.Contains(TokenType.Pinyin, types);
|
||||
Assert.Contains(TokenType.Kana, types);
|
||||
Assert.Contains(TokenType.Romaji, types);
|
||||
|
||||
// Helper to get token texts at a specific position by type
|
||||
List<string> GetTokenTextsAt(int pos, TokenType type) => tokens
|
||||
.Where(t => t.Start <= pos && t.End > pos)
|
||||
.Select(t => tokenDefs.First(d => d.Id == t.Id))
|
||||
.Where(d => d.Type == type)
|
||||
.Select(d => d.Text)
|
||||
.ToList();
|
||||
|
||||
// Position 0: 僕
|
||||
Assert.Contains("僕", GetTokenTextsAt(0, TokenType.Han));
|
||||
Assert.Contains("pu", GetTokenTextsAt(0, TokenType.Pinyin));
|
||||
Assert.Contains("ボク", GetTokenTextsAt(0, TokenType.Kana));
|
||||
Assert.Contains("boku", GetTokenTextsAt(0, TokenType.Romaji));
|
||||
|
||||
// Position 1: の (hiragana, no Han/Pinyin)
|
||||
Assert.Empty(GetTokenTextsAt(1, TokenType.Han));
|
||||
Assert.Empty(GetTokenTextsAt(1, TokenType.Pinyin));
|
||||
Assert.Contains("ノ", GetTokenTextsAt(1, TokenType.Kana));
|
||||
Assert.Contains("no", GetTokenTextsAt(1, TokenType.Romaji));
|
||||
|
||||
// Position 2: 和
|
||||
Assert.Contains("和", GetTokenTextsAt(2, TokenType.Han));
|
||||
Assert.Contains("he", GetTokenTextsAt(2, TokenType.Pinyin));
|
||||
Assert.Contains("ワ", GetTokenTextsAt(2, TokenType.Kana));
|
||||
Assert.Contains("wa", GetTokenTextsAt(2, TokenType.Romaji));
|
||||
|
||||
// Position 3: 風
|
||||
Assert.Contains("風", GetTokenTextsAt(3, TokenType.Han));
|
||||
Assert.Contains("风", GetTokenTextsAt(3, TokenType.Han)); // simplified variant
|
||||
Assert.Contains("feng", GetTokenTextsAt(3, TokenType.Pinyin));
|
||||
Assert.Contains("フウ", GetTokenTextsAt(3, TokenType.Kana));
|
||||
Assert.Contains("fu", GetTokenTextsAt(3, TokenType.Romaji));
|
||||
|
||||
// Position 4: 本
|
||||
Assert.Contains("本", GetTokenTextsAt(4, TokenType.Han));
|
||||
Assert.Contains("ben", GetTokenTextsAt(4, TokenType.Pinyin));
|
||||
Assert.Contains("ホン", GetTokenTextsAt(4, TokenType.Kana));
|
||||
Assert.Contains("hon", GetTokenTextsAt(4, TokenType.Romaji));
|
||||
|
||||
// Position 5: 当
|
||||
Assert.Contains("当", GetTokenTextsAt(5, TokenType.Han));
|
||||
Assert.Contains("當", GetTokenTextsAt(5, TokenType.Han)); // traditional variant
|
||||
Assert.Contains("dang", GetTokenTextsAt(5, TokenType.Pinyin));
|
||||
Assert.Contains("トウ", GetTokenTextsAt(5, TokenType.Kana));
|
||||
Assert.Contains("to", GetTokenTextsAt(5, TokenType.Romaji)); // normalized: tou -> to
|
||||
|
||||
// Position 6: 上
|
||||
Assert.Contains("上", GetTokenTextsAt(6, TokenType.Han));
|
||||
Assert.Contains("shang", GetTokenTextsAt(6, TokenType.Pinyin));
|
||||
Assert.Contains("ジョウ", GetTokenTextsAt(6, TokenType.Kana));
|
||||
Assert.Contains("jo", GetTokenTextsAt(6, TokenType.Romaji)); // normalized: jou -> jo
|
||||
|
||||
// Position 7: 手
|
||||
Assert.Contains("手", GetTokenTextsAt(7, TokenType.Han));
|
||||
Assert.Contains("shou", GetTokenTextsAt(7, TokenType.Pinyin));
|
||||
Assert.Contains("シュ", GetTokenTextsAt(7, TokenType.Kana));
|
||||
Assert.Contains("shu", GetTokenTextsAt(7, TokenType.Romaji));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Tokenizer_NoDuplicateTokensTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var tokenizer = new Tokenizer(TokenizerOptions);
|
||||
|
||||
// Tokenize multiple music names that share some characters
|
||||
tokenizer.Tokenize("僕の和風本当上手");
|
||||
tokenizer.Tokenize("僕");
|
||||
tokenizer.Tokenize("和風");
|
||||
|
||||
// Check that there are no duplicate tokens
|
||||
var tokenDefs = tokenizer.Tokens.Values.ToList();
|
||||
var tokenKeys = tokenDefs.Select(t => $"{t.Type}:{t.Text}").ToList();
|
||||
var uniqueKeys = tokenKeys.ToHashSet();
|
||||
|
||||
Assert.Equal(uniqueKeys.Count, tokenKeys.Count);
|
||||
|
||||
// Also check that IDs are unique
|
||||
var ids = tokenDefs.Select(t => t.Id).ToList();
|
||||
var uniqueIds = ids.ToHashSet();
|
||||
Assert.Equal(uniqueIds.Count, ids.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Tokenizer_HandlesRawTokensForNonCjkTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var tokenizer = new Tokenizer(TokenizerOptions);
|
||||
tokenizer.Tokenize("a-b");
|
||||
|
||||
var tokenDefs = tokenizer.Tokens.Values.ToList();
|
||||
var rawTokenTexts = tokenDefs.Where(t => t.Type == TokenType.Raw).Select(t => t.Text).ToList();
|
||||
|
||||
Assert.Contains("a", rawTokenTexts);
|
||||
Assert.Contains("-", rawTokenTexts);
|
||||
Assert.Contains("b", rawTokenTexts);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Tokenizer_TokenizesCompoundWordKyouTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var tokenizer = new Tokenizer(TokenizerOptions);
|
||||
var tokens = tokenizer.Tokenize("今日");
|
||||
var tokenDefs = tokenizer.Tokens.Values.ToList();
|
||||
|
||||
// Helper to get tokens with specific type and span
|
||||
List<string> GetTokensWithSpan(TokenType type, int start, int end) => tokens
|
||||
.Where(t => t.Start == start && t.End == end)
|
||||
.Select(t => tokenDefs.First(d => d.Id == t.Id))
|
||||
.Where(d => d.Type == type)
|
||||
.Select(d => d.Text)
|
||||
.ToList();
|
||||
|
||||
// Individual character readings at position 0: 今
|
||||
Assert.Contains("今", GetTokensWithSpan(TokenType.Han, 0, 1));
|
||||
Assert.Contains("jin", GetTokensWithSpan(TokenType.Pinyin, 0, 1));
|
||||
Assert.Contains("コン", GetTokensWithSpan(TokenType.Kana, 0, 1));
|
||||
Assert.Contains("イマ", GetTokensWithSpan(TokenType.Kana, 0, 1));
|
||||
Assert.Contains("kon", GetTokensWithSpan(TokenType.Romaji, 0, 1));
|
||||
Assert.Contains("ima", GetTokensWithSpan(TokenType.Romaji, 0, 1));
|
||||
|
||||
// Individual character readings at position 1: 日
|
||||
Assert.Contains("日", GetTokensWithSpan(TokenType.Han, 1, 2));
|
||||
Assert.Contains("ri", GetTokensWithSpan(TokenType.Pinyin, 1, 2));
|
||||
Assert.Contains("ニチ", GetTokensWithSpan(TokenType.Kana, 1, 2));
|
||||
Assert.Contains("ヒ", GetTokensWithSpan(TokenType.Kana, 1, 2));
|
||||
Assert.Contains("niti", GetTokensWithSpan(TokenType.Romaji, 1, 2));
|
||||
Assert.Contains("hi", GetTokensWithSpan(TokenType.Romaji, 1, 2));
|
||||
|
||||
// Combined reading for "今日" [0, 2] - this is an indivisible compound word
|
||||
Assert.Contains("キョウ", GetTokensWithSpan(TokenType.Kana, 0, 2));
|
||||
Assert.Contains("kyo", GetTokensWithSpan(TokenType.Romaji, 0, 2)); // normalized: kyou -> kyo
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using MaigoLabs.NeedLe.Common;
|
||||
using MaigoLabs.NeedLe.Common.Extensions;
|
||||
using MaigoLabs.NeedLe.Indexer.Trie;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests.Indexer;
|
||||
|
||||
#region GraftTriePaths
|
||||
|
||||
public sealed class GraftTriePaths_GraftsPathsAccordingToNormalizationRulesTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
// Build a trie with tokens containing normalized forms
|
||||
var trie = TrieBuilder.BuildTrie([
|
||||
(0, "sya".ToCodePoints()), // normalized form of "sha"
|
||||
(1, "tu".ToCodePoints()), // normalized form of "tsu"
|
||||
]);
|
||||
|
||||
// Graft paths so that "sha" -> "sya" and "tsu" -> "tu"
|
||||
TrieBuilder.GraftTriePaths(trie, [
|
||||
("sha".ToCodePoints().ToArray(), "sya".ToCodePoints().ToArray()),
|
||||
("tsu".ToCodePoints().ToArray(), "tu".ToCodePoints().ToArray()),
|
||||
]);
|
||||
|
||||
// Now we should be able to traverse using both the original and grafted paths
|
||||
var syaNode = trie.Traverse("sya".ToCodePoints().ToArray());
|
||||
var shaNode = trie.Traverse("sha".ToCodePoints().ToArray());
|
||||
Assert.NotNull(syaNode);
|
||||
Assert.NotNull(shaNode);
|
||||
Assert.Same(syaNode, shaNode); // Both paths should lead to the same node
|
||||
|
||||
var tuNode = trie.Traverse("tu".ToCodePoints().ToArray());
|
||||
var tsuNode = trie.Traverse("tsu".ToCodePoints().ToArray());
|
||||
Assert.NotNull(tuNode);
|
||||
Assert.NotNull(tsuNode);
|
||||
Assert.Same(tuNode, tsuNode);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GraftTriePaths_HandlesChainedGraftRulesTest : NeedleTestBase
|
||||
{
|
||||
[Fact]
|
||||
public void Execute()
|
||||
{
|
||||
var trie = TrieBuilder.BuildTrie([
|
||||
(0, "o".ToCodePoints()), // normalized vowel
|
||||
]);
|
||||
|
||||
// Chain: "ou" -> "o", "oo" -> "o"
|
||||
TrieBuilder.GraftTriePaths(trie, [
|
||||
("ou".ToCodePoints().ToArray(), "o".ToCodePoints().ToArray()),
|
||||
("oo".ToCodePoints().ToArray(), "o".ToCodePoints().ToArray()),
|
||||
]);
|
||||
|
||||
var oNode = trie.Traverse("o".ToCodePoints().ToArray());
|
||||
var ouNode = trie.Traverse("ou".ToCodePoints().ToArray());
|
||||
var ooNode = trie.Traverse("oo".ToCodePoints().ToArray());
|
||||
|
||||
Assert.NotNull(oNode);
|
||||
Assert.Same(oNode, ouNode);
|
||||
Assert.Same(oNode, ooNode);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>$(ProjectName).Tests</RootNamespace>
|
||||
<AssemblyName>$(RootNamespace)</AssemblyName>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Common\MaigoLabs.NeedLe.Common.csproj" />
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Indexer\MaigoLabs.NeedLe.Indexer.csproj" />
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Searcher\MaigoLabs.NeedLe.Searcher.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using MaigoLabs.NeedLe.Indexer;
|
||||
using MaigoLabs.NeedLe.Indexer.Han;
|
||||
using MaigoLabs.NeedLe.Indexer.Japanese;
|
||||
|
||||
namespace MaigoLabs.NeedLe.Tests;
|
||||
|
||||
public abstract class NeedleTestBase
|
||||
{
|
||||
public static HanVariantProvider HanVariantProvider { get; set; } = new();
|
||||
public static TranscriptionProvider TranscriptionProvider { get; set; } = new();
|
||||
public static TokenizerOptions TokenizerOptions => new() { HanVariantProvider = HanVariantProvider, TranscriptionProvider = TranscriptionProvider };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Solution>
|
||||
|
||||
<Configurations>
|
||||
<Platform Name="Any CPU" />
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
|
||||
<Project Path="MaigoLabs.NeedLe/MaigoLabs.NeedLe.csproj" />
|
||||
<Project Path="MaigoLabs.NeedLe.Common/MaigoLabs.NeedLe.Common.csproj" />
|
||||
<Project Path="MaigoLabs.NeedLe.Indexer/MaigoLabs.NeedLe.Indexer.csproj" />
|
||||
<Project Path="MaigoLabs.NeedLe.Searcher/MaigoLabs.NeedLe.Searcher.csproj" />
|
||||
<Project Path="MaigoLabs.NeedLe.Playground/MaigoLabs.NeedLe.Playground.csproj" />
|
||||
<Project Path="MaigoLabs.NeedLe.Tests/MaigoLabs.NeedLe.Tests.csproj" />
|
||||
|
||||
</Solution>
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<RootNamespace>$(ProjectName)</RootNamespace>
|
||||
<AssemblyName>$(RootNamespace)</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>$(RootNamespace)</PackageId>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<IncludeContentInPack>false</IncludeContentInPack>
|
||||
<NoPackageAnalysis>true</NoPackageAnalysis>
|
||||
<MeCabUseDefaultDictionary>False</MeCabUseDefaultDictionary>
|
||||
<PackageReadmeFile></PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Common\MaigoLabs.NeedLe.Common.csproj" PrivateAssets="none" />
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Indexer\MaigoLabs.NeedLe.Indexer.csproj" PrivateAssets="none" />
|
||||
<ProjectReference Include="..\MaigoLabs.NeedLe.Searcher\MaigoLabs.NeedLe.Searcher.csproj" PrivateAssets="none" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Directly add README to package files -->
|
||||
<Target Name="AddReadmeToPackage" BeforeTargets="GenerateNuspec">
|
||||
<ItemGroup>
|
||||
<_PackageFiles Include="..\README.md" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,57 @@
|
||||
# `MaigoLabs.NeedLe`
|
||||
|
||||
Fuzzy search engine for small text pieces, with Chinese/Japanese pronunciation support.
|
||||
|
||||
See also [in-browser demo](https://needle.maigo.dev) (TypeScript version, but the same features as in C#).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
dotnet add package MaigoLabs.NeedLe
|
||||
```
|
||||
|
||||
Or install sub-packages separately:
|
||||
|
||||
```bash
|
||||
dotnet add package MaigoLabs.NeedLe.Indexer # For building indexes
|
||||
dotnet add package MaigoLabs.NeedLe.Searcher # For searching only
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Indexing
|
||||
|
||||
Indexing requires dictionaries. These are installed as dependencies of the `MaigoLabs.NeedLe.Indexer` package:
|
||||
|
||||
* MeCab.DotNet
|
||||
* OpenccNetLib
|
||||
* hyjiacan.pinyin4net
|
||||
|
||||
```csharp
|
||||
using MaigoLabs.NeedLe.Indexer;
|
||||
|
||||
var documents = new[] { "你好世界", "こんにちは" };
|
||||
var compressedIndex = InvertedIndexBuilder.BuildInvertedIndex(documents);
|
||||
// To customize dictionary paths, pass the second argument `TokenizerOptions` to `BuildInvertedIndex`.
|
||||
|
||||
// The built index could be stored for later use, or sent to frontend to load with TypeScript package `@maigolabs/needle`.
|
||||
// For compatibility with .NET Standard, we don't provide JSON related methods. You can use any JSON library to serialize/deserialize the index in the way you prefer.
|
||||
var json = JsonSerializer.Serialize(compressedIndex);
|
||||
```
|
||||
|
||||
### Searching
|
||||
|
||||
Searching requires a prebuilt index but doesn't require dictionaries. Searcher is a lightweight package without dependencies.
|
||||
|
||||
```csharp
|
||||
using MaigoLabs.NeedLe.Searcher;
|
||||
|
||||
// Index returned by `BuildInvertedIndex`.
|
||||
var index = InvertedIndexLoader.Load(compressedIndex);
|
||||
|
||||
var results = InvertedIndexSearcher.Search(index, "sekai");
|
||||
foreach (var result in results) Console.WriteLine($"{result.DocumentText} ({result.MatchRatio:P0})")
|
||||
// → 你好世界 (50%)
|
||||
```
|
||||
|
||||
To highlight the search result, see also `SearchResultHighlighter`.
|
||||
Reference in New Issue
Block a user