mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-02-06 11:20:11 +01:00
Compare commits
11 Commits
dev/migrie
...
dev/vanzue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee702f9edf | ||
|
|
916182e47d | ||
|
|
753689309e | ||
|
|
0e4d1c1496 | ||
|
|
d6bebf8423 | ||
|
|
ebf36a324a | ||
|
|
eba7760ee1 | ||
|
|
0998bed0d4 | ||
|
|
ad958759fa | ||
|
|
dbf16cf62a | ||
|
|
38d460cc2b |
@@ -1,61 +0,0 @@
|
||||
---
|
||||
description: 'Guidelines for shared libraries including logging, IPC, settings, DPI, telemetry, and utilities consumed by multiple modules'
|
||||
applyTo: 'src/common/**'
|
||||
---
|
||||
|
||||
# Common Libraries – Shared Code Guidance
|
||||
|
||||
Guidelines for modifying shared code in `src/common/`. Changes here can have wide-reaching impact across the entire PowerToys codebase.
|
||||
|
||||
## Scope
|
||||
|
||||
- Logging infrastructure (`src/common/logger/`)
|
||||
- IPC primitives and named pipe utilities
|
||||
- Settings serialization and management
|
||||
- DPI awareness and scaling utilities
|
||||
- Telemetry helpers
|
||||
- General utilities (JSON parsing, string helpers, etc.)
|
||||
|
||||
## Guidelines
|
||||
|
||||
### API Stability
|
||||
|
||||
- Avoid breaking public headers/APIs; if changed, search & update all callers
|
||||
- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility
|
||||
- When modifying public interfaces, grep the entire codebase for usages
|
||||
|
||||
### Performance
|
||||
|
||||
- Watch perf in hot paths (hooks, timers, serialization)
|
||||
- Avoid avoidable allocations in frequently called code
|
||||
- Profile changes that touch performance-sensitive areas
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Ask before adding third-party deps or changing serialization formats
|
||||
- New dependencies must be MIT-licensed or approved by PM team
|
||||
- Add any new external packages to `NOTICE.md`
|
||||
|
||||
### Logging
|
||||
|
||||
- C++ logging uses spdlog (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`)
|
||||
- Initialize with `init_logger()` early in startup
|
||||
- Keep hot paths quiet – no logging in tight loops or hooks
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- No unintended ABI breaks
|
||||
- No noisy logs in hot paths
|
||||
- New non-obvious symbols briefly commented
|
||||
- All callers updated when interfaces change
|
||||
|
||||
## Code Style
|
||||
|
||||
- **C++**: Follow `.clang-format` in `src/`; use Modern C++ patterns per C++ Core Guidelines
|
||||
- **C#**: Follow `src/.editorconfig`; enforce StyleCop.Analyzers
|
||||
|
||||
## Validation
|
||||
|
||||
- Build: `tools\build\build.cmd` from `src/common/` folder
|
||||
- Verify no ABI breaks: grep for changed function/struct names across codebase
|
||||
- Check logs: ensure no new logging in performance-critical paths
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
description: 'Guidelines for Runner and Settings UI components that communicate via named pipes and manage module lifecycle'
|
||||
applyTo: 'src/runner/**,src/settings-ui/**'
|
||||
---
|
||||
|
||||
# Runner & Settings UI – Core Components Guidance
|
||||
|
||||
Guidelines for modifying the Runner (tray/module loader) and Settings UI (configuration app). These components communicate via Windows Named Pipes using JSON messages.
|
||||
|
||||
## Runner (`src/runner/`)
|
||||
|
||||
### Scope
|
||||
|
||||
- Module bootstrap, hotkey management, settings bridge, update/elevation handling
|
||||
|
||||
### Guidelines
|
||||
|
||||
- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`
|
||||
- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules
|
||||
- Keep startup lean: avoid blocking/network calls in early init path
|
||||
- Preserve GPO & elevation behaviors; confirm no regression in policy handling
|
||||
- Ask before modifying update workflow or elevation logic
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Stable startup, consistent contracts, no unnecessary logging noise
|
||||
|
||||
## Settings UI (`src/settings-ui/`)
|
||||
|
||||
### Scope
|
||||
|
||||
- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Don't break settings schema silently; add migration when shape changes
|
||||
- If IPC/JSON contracts change, align with `src/runner/**` implementation
|
||||
- Keep UI responsive: marshal to UI thread for UI-bound operations
|
||||
- Reuse existing styles/resources; avoid duplicate theme keys
|
||||
- Add/adjust migration or serialization tests when changing persisted settings
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Schema integrity preserved, responsive UI, consistent contracts, no style duplication
|
||||
|
||||
## Shared Concerns
|
||||
|
||||
### IPC Contract Changes
|
||||
|
||||
When modifying the JSON message format between Runner and Settings UI:
|
||||
|
||||
1. Update both `src/runner/` and `src/settings-ui/` in the same PR
|
||||
2. Preserve backward compatibility where possible
|
||||
3. Add migration logic for settings schema changes
|
||||
4. Test both directions of communication
|
||||
|
||||
### Code Style
|
||||
|
||||
- **C++ (Runner)**: Follow `.clang-format` in `src/`
|
||||
- **C# (Settings UI)**: Follow `src/.editorconfig`, use StyleCop.Analyzers
|
||||
- **XAML**: Use XamlStyler or run `.\.pipelines\applyXamlStyling.ps1 -Main`
|
||||
|
||||
## Validation
|
||||
|
||||
- Build Runner: `tools\build\build.cmd` from `src/runner/`
|
||||
- Build Settings UI: `tools\build\build.cmd` from `src/settings-ui/`
|
||||
- Test IPC: Launch both Runner and Settings UI, verify communication works
|
||||
- Schema changes: Run serialization tests if settings shape changed
|
||||
@@ -63,7 +63,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
|
||||
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
|
||||
@@ -77,10 +77,8 @@
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.231216.1"/>
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="1.8.251104000" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="1.8.39" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="1.8.251106002" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental4" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.0.130-experimental" />
|
||||
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="2.0.9" />
|
||||
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
||||
<PackageVersion Include="ModernWpfUI" Version="0.9.4" />
|
||||
|
||||
@@ -1059,6 +1059,16 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/tools/SettingsSearchEvaluation/">
|
||||
<Project Path="tools/SettingsSearchEvaluation/SettingsSearchEvaluation.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="tools/SettingsSearchEvaluation.Tests/SettingsSearchEvaluation.Tests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".vsconfig" />
|
||||
<File Path="Cpp.Build.props" />
|
||||
|
||||
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Windows App SDK Semantic Search API 总结
|
||||
|
||||
## 1. 环境与依赖
|
||||
|
||||
| 项目 | 版本/值 |
|
||||
|------|---------|
|
||||
| **Windows App SDK** | `2.0.0-experimental3` |
|
||||
| **.NET** | `net9.0-windows10.0.26100.0` |
|
||||
| **AI Search NuGet** | `Microsoft.WindowsAppSDK.AI` (2.0.57-experimental) |
|
||||
| **命名空间** | `Microsoft.Windows.AI.Search.Experimental.AppContentIndex` |
|
||||
| **应用类型** | WinUI 3 MSIX 打包应用 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心 API
|
||||
|
||||
### 2.1 索引管理
|
||||
```csharp
|
||||
// 创建/打开索引
|
||||
var result = AppContentIndexer.GetOrCreateIndex("indexName");
|
||||
if (result.Succeeded) {
|
||||
_indexer = result.Indexer;
|
||||
// result.Status: CreatedNew | OpenedExisting
|
||||
}
|
||||
|
||||
// 等待索引能力就绪
|
||||
await _indexer.WaitForIndexCapabilitiesAsync();
|
||||
|
||||
// 等待索引空闲(建索引完成)
|
||||
await _indexer.WaitForIndexingIdleAsync(TimeSpan.FromSeconds(120));
|
||||
|
||||
// 清理
|
||||
_indexer.RemoveAll(); // 删除所有索引
|
||||
_indexer.Remove(id); // 删除单个
|
||||
_indexer.Dispose();
|
||||
```
|
||||
|
||||
### 2.2 添加内容到索引
|
||||
```csharp
|
||||
// 索引文本 → 自动建立 TextLexical + TextSemantic 索引
|
||||
IndexableAppContent textContent = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer.AddOrUpdate(textContent);
|
||||
|
||||
// 索引图片 → 自动建立 ImageSemantic + ImageOcr 索引
|
||||
IndexableAppContent imageContent = AppManagedIndexableAppContent.CreateFromBitmap(id, softwareBitmap);
|
||||
_indexer.AddOrUpdate(imageContent);
|
||||
```
|
||||
|
||||
### 2.3 查询
|
||||
```csharp
|
||||
// 文本查询
|
||||
TextQueryOptions options = new TextQueryOptions {
|
||||
Language = "en-US", // 可选
|
||||
MatchScope = QueryMatchScope.Unconstrained, // 匹配范围
|
||||
TextMatchType = TextLexicalMatchType.Fuzzy // Fuzzy | Exact
|
||||
};
|
||||
AppIndexTextQuery query = _indexer.CreateTextQuery(searchText, options);
|
||||
IReadOnlyList<TextQueryMatch> matches = query.GetNextMatches(5);
|
||||
|
||||
// 图片查询
|
||||
ImageQueryOptions imgOptions = new ImageQueryOptions {
|
||||
MatchScope = QueryMatchScope.Unconstrained,
|
||||
ImageOcrTextMatchType = TextLexicalMatchType.Fuzzy
|
||||
};
|
||||
AppIndexImageQuery imgQuery = _indexer.CreateImageQuery(searchText, imgOptions);
|
||||
IReadOnlyList<ImageQueryMatch> imgMatches = imgQuery.GetNextMatches(5);
|
||||
```
|
||||
|
||||
### 2.4 能力检查(只读)
|
||||
```csharp
|
||||
IndexCapabilities capabilities = _indexer.GetIndexCapabilities();
|
||||
|
||||
bool textLexicalOK = capabilities.GetCapabilityState(IndexCapability.TextLexical)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool textSemanticOK = capabilities.GetCapabilityState(IndexCapability.TextSemantic)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool imageSemanticOK = capabilities.GetCapabilityState(IndexCapability.ImageSemantic)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool imageOcrOK = capabilities.GetCapabilityState(IndexCapability.ImageOcr)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 四种索引能力
|
||||
|
||||
| 能力 | 说明 | 触发方式 |
|
||||
|------|------|----------|
|
||||
| `TextLexical` | 词法/关键词搜索 | CreateFromString() 自动 |
|
||||
| `TextSemantic` | AI 语义搜索 (Embedding) | CreateFromString() 自动 |
|
||||
| `ImageSemantic` | 图像语义搜索 | CreateFromBitmap() 自动 |
|
||||
| `ImageOcr` | 图片 OCR 文字搜索 | CreateFromBitmap() 自动 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 可控选项(有限)
|
||||
|
||||
### TextQueryOptions
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `Language` | string | 查询语言(可选,如 "en-US")|
|
||||
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
|
||||
| `TextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact(仅影响 Lexical)|
|
||||
|
||||
### ImageQueryOptions
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
|
||||
| `ImageOcrTextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact(仅影响 OCR)|
|
||||
|
||||
### 枚举值说明
|
||||
|
||||
**QueryMatchScope:**
|
||||
- `Unconstrained` - 无约束,同时使用 Lexical + Semantic
|
||||
- `Region` - 限制在特定区域
|
||||
- `ContentItem` - 限制在单个内容项
|
||||
|
||||
**TextLexicalMatchType:**
|
||||
- `Fuzzy` - 模糊匹配,允许拼写错误、近似词
|
||||
- `Exact` - 精确匹配,必须完全一致
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键限制 ⚠️
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| **不能单独指定 Semantic/Lexical** | 系统自动同时使用所有可用能力 |
|
||||
| **Fuzzy/Exact 只影响 Lexical** | 对 Semantic 搜索无效 |
|
||||
| **能力检查是只读的** | `GetIndexCapabilities()` 只能查看,不能控制 |
|
||||
| **无相似度阈值** | 不能设置 Semantic 匹配的阈值 |
|
||||
| **无结果排序控制** | 无法指定按相关度或其他方式排序 |
|
||||
| **语言需手动传** | 不会自动检测,需开发者指定 |
|
||||
| **无相关度分数** | 查询结果不返回匹配分数 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 典型使用流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App 启动时 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. GetOrCreateIndex("name") // 创建/打开索引 │
|
||||
│ 2. WaitForIndexCapabilitiesAsync() // 等待能力就绪 │
|
||||
│ 3. GetIndexCapabilities() // 检查可用能力 │
|
||||
│ 4. IndexAll() // 索引所有数据 │
|
||||
│ ├─ CreateFromString() × N │
|
||||
│ └─ CreateFromBitmap() × N │
|
||||
│ 5. WaitForIndexingIdleAsync() // 等待索引完成 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 运行时查询 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. CreateTextQuery(text, options) // 创建查询 │
|
||||
│ 2. query.GetNextMatches(N) // 获取结果 │
|
||||
│ 3. 处理 TextQueryMatch / ImageQueryMatch │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App 退出时 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. _indexer.RemoveAll() // 清理索引 │
|
||||
│ 2. _indexer.Dispose() // 释放资源 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 查询结果处理
|
||||
|
||||
```csharp
|
||||
// 文本查询结果
|
||||
foreach (var match in textMatches)
|
||||
{
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedText)
|
||||
{
|
||||
AppManagedTextQueryMatch textResult = (AppManagedTextQueryMatch)match;
|
||||
string contentId = match.ContentId; // 内容 ID
|
||||
int offset = textResult.TextOffset; // 匹配文本偏移
|
||||
int length = textResult.TextLength; // 匹配文本长度
|
||||
}
|
||||
}
|
||||
|
||||
// 图片查询结果
|
||||
foreach (var match in imageMatches)
|
||||
{
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedImage)
|
||||
{
|
||||
AppManagedImageQueryMatch imageResult = (AppManagedImageQueryMatch)match;
|
||||
string contentId = imageResult.ContentId; // 图片 ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 能力变化监听
|
||||
|
||||
```csharp
|
||||
// 监听索引能力变化
|
||||
_indexer.Listener.IndexCapabilitiesChanged += (indexer, capabilities) =>
|
||||
{
|
||||
// 重新检查能力状态,更新 UI
|
||||
LoadAppIndexCapabilities();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 结论
|
||||
|
||||
这是一个**高度封装的黑盒 API**:
|
||||
|
||||
### 优点 ✅
|
||||
- 简单易用,几行代码即可实现搜索
|
||||
- 自动处理 Lexical + Semantic
|
||||
- 支持文本和图片多模态搜索
|
||||
- 系统级集成,无需额外部署模型
|
||||
|
||||
### 缺点 ❌
|
||||
- 无法精细控制搜索类型
|
||||
- 不能只用 Semantic Search
|
||||
- 选项有限,缺乏高级配置
|
||||
- 实验性 API,可能变更
|
||||
|
||||
### 替代方案
|
||||
**如果需要纯 Semantic Search(向量搜索)**,建议:
|
||||
- 直接使用 Embedding 模型生成向量
|
||||
- 配合向量数据库(Azure Cosmos DB、FAISS、Qdrant 等)
|
||||
|
||||
---
|
||||
|
||||
## 10. 相关 NuGet 包
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK.AI" Version="2.0.57-experimental" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档生成日期: 2026-01-21*
|
||||
325
doc/devdocs/sparse-package-investigation.md
Normal file
325
doc/devdocs/sparse-package-investigation.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Sparse Package + WinUI 3 调查报告
|
||||
|
||||
## 背景
|
||||
|
||||
PowerToys 希望使用 Windows App SDK 的 Semantic Search API (`AppContentIndexer.GetOrCreateIndex`) 来实现语义搜索功能。该 API 要求应用具有 **Package Identity**。
|
||||
|
||||
## 问题现象
|
||||
|
||||
### 1. Semantic Search API 调用失败
|
||||
|
||||
在 [SemanticSearchIndex.cs](../../src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs) 中调用 `AppContentIndexer.GetOrCreateIndex(_indexName)` 时,抛出 COM 异常:
|
||||
|
||||
```
|
||||
System.Runtime.InteropServices.COMException (0x80004005): Error HRESULT E_FAIL has been returned from a call to a COM component.
|
||||
at WinRT.ExceptionHelpers.<ThrowExceptionForHR>g__Throw|39_0(Int32 hr)
|
||||
at Microsoft.Windows.AI.Search.AppContentIndexer.GetOrCreateIndex(String indexName)
|
||||
```
|
||||
|
||||
### 2. API 要求
|
||||
|
||||
根据 [Windows App SDK 文档](https://learn.microsoft.com/en-us/windows/ai/apis/content-search),Semantic Search API 需要:
|
||||
- Windows 11 24H2 或更高版本
|
||||
- NPU 硬件支持
|
||||
- **Package Identity**(应用需要有 MSIX 包标识)
|
||||
|
||||
## Sparse Package 方案
|
||||
|
||||
### 什么是 Sparse Package
|
||||
|
||||
Sparse Package(稀疏包)是一种为非打包(unpackaged)Win32 应用提供 Package Identity 的技术,无需完整的 MSIX 打包。
|
||||
|
||||
参考:[Grant package identity by packaging with external location](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps)
|
||||
|
||||
### 实现架构
|
||||
|
||||
```
|
||||
PowerToysSparse.msix (仅包含 manifest 和图标)
|
||||
│
|
||||
├── AppxManifest.xml (声明应用和依赖)
|
||||
├── Square44x44Logo.png
|
||||
├── Square150x150Logo.png
|
||||
└── StoreLogo.png
|
||||
|
||||
ExternalLocation (指向实际应用目录)
|
||||
│
|
||||
└── ARM64\Debug\
|
||||
├── PowerToys.Settings.exe
|
||||
├── PowerToys.Settings.pri
|
||||
└── ... (其他应用文件)
|
||||
```
|
||||
|
||||
### 关键组件
|
||||
|
||||
| 文件 | 位置 | 作用 |
|
||||
|------|------|------|
|
||||
| AppxManifest.xml | src/PackageIdentity/ | 定义 sparse package 的应用、依赖和能力 |
|
||||
| app.manifest | src/settings-ui/Settings.UI/ | 嵌入 exe 中,声明与 sparse package 的关联 |
|
||||
| BuildSparsePackage.ps1 | src/PackageIdentity/ | 构建和签名脚本 |
|
||||
|
||||
### Publisher 配置
|
||||
|
||||
**重要**:app.manifest 和 AppxManifest.xml 中的 Publisher 必须匹配。
|
||||
|
||||
| 环境 | Publisher |
|
||||
|------|-----------|
|
||||
| 开发环境 | `CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US` |
|
||||
| 生产环境 | `CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US` |
|
||||
|
||||
BuildSparsePackage.ps1 会在本地构建时**自动**将 AppxManifest.xml 的 Publisher 替换为开发环境值,无需手动修改源码。
|
||||
|
||||
## 当前问题:WinUI 3 + Sparse Package 崩溃
|
||||
|
||||
### 现象
|
||||
|
||||
当 Settings.exe(WinUI 3 应用)通过 sparse package 启动时,立即崩溃:
|
||||
|
||||
```
|
||||
Microsoft.UI.Xaml.Markup.XamlParseException (-2144665590):
|
||||
Cannot locate resource from 'ms-appx:///Microsoft.UI.Xaml/Themes/themeresources.xaml'. [Line: 11 Position: 40]
|
||||
```
|
||||
|
||||
### 新观察(2026-01-25)
|
||||
|
||||
对齐 WinAppSDK 版本并恢复 app-local 运行时后,仍可复现**更早期**的崩溃(未写入 Settings 日志):
|
||||
|
||||
- Application Error / WER(AUMID 启动):
|
||||
- Faulting module: `CoreMessagingXP.dll`
|
||||
- Exception code: `0xc0000602`
|
||||
- Faulting module path: `C:\PowerToys\ARM64\Debug\WinUI3Apps\CoreMessagingXP.dll`
|
||||
- 暂时移除 `CoreMessagingXP.dll` 后,出现 .NET Runtime 1026:
|
||||
- `COMException (0x80040111): ClassFactory cannot supply requested class`
|
||||
- 发生在 `Microsoft.UI.Xaml.Application.Start(...)`
|
||||
|
||||
这说明 **“themeresources.xaml 无法解析”并不是唯一/必现的失败模式**,app-local 运行时在 sparse identity 下可能存在更底层的初始化问题。
|
||||
|
||||
### 新观察(2026-01-25 晚间)
|
||||
|
||||
framework-dependent + bootstrap 方向有实质进展:
|
||||
|
||||
- 设置 `WindowsAppSDKSelfContained=false`(仅在 `UseSparseIdentity=true` 时生效)
|
||||
- 添加 `WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp=true`
|
||||
- **从 ExternalLocation 根目录与 `WinUI3Apps` 目录移除 app-local WinAppSDK 运行时文件**
|
||||
- 尤其是 `CoreMessagingXP.dll`,否则会优先加载并导致 `0xc0000602`
|
||||
- **保留/放回 bootstrap DLL**
|
||||
- 必需:`Microsoft.WindowsAppRuntime.Bootstrap.Net.dll`
|
||||
- 建议同时保留:`Microsoft.WindowsAppRuntime.Bootstrap.dll`
|
||||
|
||||
按以上处理后,Settings 通过 AUMID 启动不再崩溃,日志写入恢复。
|
||||
|
||||
### 根本原因分析
|
||||
|
||||
1. **ms-appx:/// URI 机制**
|
||||
- WinUI 3 使用 `ms-appx:///` URI 加载 XAML 资源
|
||||
- 这个 URI scheme 依赖于 MSIX 包的资源索引系统
|
||||
|
||||
2. **框架资源位置**
|
||||
- `themeresources.xaml` 等主题资源在 Windows App Runtime 框架包中
|
||||
- 框架包位置:`C:\Program Files\WindowsApps\Microsoft.WindowsAppRuntime.2.0-experimental4_*\`(应与 WinAppSDK 版本匹配)
|
||||
- 资源编译在框架包的 `resources.pri` 中
|
||||
|
||||
3. **WinAppSDK 版本/依赖不一致(更可能的原因)**
|
||||
- 仓库当前引用 `Microsoft.WindowsAppSDK` **2.0.0-experimental4**(见 `Directory.Packages.props`)
|
||||
- Sparse manifest 仍依赖 **Microsoft.WindowsAppRuntime.2.0-experimental3**(`MinVersion=0.676.658.0`)
|
||||
- 通过包标识启动时会走框架包资源图,如果依赖版本不匹配,WinUI 资源解析可能失败,从而触发上述 `XamlParseException`
|
||||
- 需要先对齐依赖版本,再判断是否是 sparse 本身限制
|
||||
|
||||
4. **app-local 运行时在 sparse identity 下崩溃(已观测)**
|
||||
- 即使对齐 WinAppSDK 版本,也可能在 `CoreMessagingXP.dll` 处崩溃(`0xc0000602`)
|
||||
- 此时 Settings 日志不一定写入,需查看 Application Event Log
|
||||
|
||||
4. **Sparse Package 的限制(待验证)**
|
||||
- 之前推断 `ms-appx:///` 在 sparse package 下无法解析框架依赖资源
|
||||
- 但在修正依赖版本之前无法下结论
|
||||
|
||||
### 对比:WPF 应用可以工作
|
||||
|
||||
WPF 应用(如 ImageResizer)使用 sparse package 时**可以正常工作**,因为:
|
||||
- WPF 不依赖 `ms-appx:///` URI
|
||||
- WPF 资源加载使用不同的机制
|
||||
|
||||
## 已尝试的解决方案
|
||||
|
||||
| 方案 | 结果 | 原因 |
|
||||
|------|------|------|
|
||||
| 复制 PRI 文件到根目录 | ❌ 失败 | `ms-appx:///` 不查找本地 PRI |
|
||||
| 复制 themeresources 到本地 | ❌ 失败 | 资源在 PRI 中,不是独立文件 |
|
||||
| 修改 Settings OutputPath 到根目录 | ❌ 失败 | 问题不在于应用资源位置 |
|
||||
| 复制框架 resources.pri | ❌ 失败 | `ms-appx:///` 机制问题 |
|
||||
| 对齐 WindowsAppRuntime 依赖版本 | ⏳ 待验证 | 先排除依赖不一致导致的资源解析失败 |
|
||||
| app-local 运行时(self-contained)+ sparse identity | ❌ 失败 | Application Error: `CoreMessagingXP.dll` / `0xc0000602` |
|
||||
| 移除 `CoreMessagingXP.dll` | ❌ 失败 | .NET Runtime 1026: `ClassFactory cannot supply requested class` |
|
||||
| framework-dependent + bootstrap + 清理 ExternalLocation 中 app-local 运行时 | ✅ 成功 | 需保留 `Microsoft.WindowsAppRuntime.Bootstrap*.dll` |
|
||||
| 将 resources.pri 打进 sparse MSIX | ✅ 成功 | MRT 可从包内 resources.pri 正常解析字符串 |
|
||||
|
||||
## 当前代码状态
|
||||
|
||||
### 已修正(建议保留)
|
||||
|
||||
1. **Settings.UI 输出路径**
|
||||
- 文件:`src/settings-ui/Settings.UI/PowerToys.Settings.csproj`
|
||||
- 修改:恢复为 `WinUI3Apps`(避免破坏 runner/installer/脚本路径假设)
|
||||
|
||||
2. **AppxManifest.xml 的 Executable 路径**
|
||||
- 文件:`src/PackageIdentity/AppxManifest.xml`
|
||||
- 修改:恢复为 `WinUI3Apps\PowerToys.Settings.exe`
|
||||
|
||||
3. **AppxManifest.xml 的 WindowsAppRuntime 依赖**
|
||||
- 文件:`src/PackageIdentity/AppxManifest.xml`
|
||||
- 修改:更新为 `Microsoft.WindowsAppRuntime.2.0-experimental4`,`MinVersion=0.738.2207.0`(与 `Microsoft.WindowsAppSDK.Runtime` 2.0.0-experimental4 对齐)
|
||||
|
||||
### 未修改(源码中保持生产配置)
|
||||
|
||||
- AppxManifest.xml 的 Publisher 保持 Microsoft Corporation(脚本会自动替换)
|
||||
|
||||
### 验证步骤(建议)
|
||||
|
||||
1. **确认 WindowsAppRuntime 版本已安装**
|
||||
- `Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2.0-experimental4`
|
||||
- 如缺失,可从 NuGet 缓存安装:
|
||||
`Add-AppxPackage -Path "$env:USERPROFILE\.nuget\packages\microsoft.windowsappsdk.runtime\2.0.0-experimental4\tools\MSIX\win10-x64\Microsoft.WindowsAppRuntime.2.0-experimental4.msix"`
|
||||
|
||||
2. **构建并注册 sparse package**
|
||||
- `.\src\PackageIdentity\BuildSparsePackage.ps1 -Platform x64 -Configuration Debug`
|
||||
- `Add-AppxPackage -Path ".\x64\Debug\PowerToysSparse.msix" -ExternalLocation ".\x64\Debug"`
|
||||
|
||||
3. **用包标识启动 Settings**
|
||||
- AUMID:`Microsoft.PowerToys.SparseApp!PowerToys.SettingsUI`
|
||||
- 预期:不再触发 `themeresources.xaml` 解析错误
|
||||
|
||||
## 可能的解决方向
|
||||
|
||||
### 方向 1:等待 Windows App SDK 修复
|
||||
|
||||
- 可能是 Windows App SDK 的已知限制或 bug
|
||||
- 需要在 GitHub issues 中搜索或提交新 issue
|
||||
|
||||
### 方向 2:使用完整 MSIX 打包
|
||||
|
||||
- 不使用 sparse package,而是完整打包
|
||||
- 影响:改变部署模型,增加复杂性
|
||||
|
||||
### 方向 3:创建非 WinUI 3 的 Helper 进程
|
||||
|
||||
- 创建一个 Console App 或 WPF App 作为 helper
|
||||
- 该 helper 具有 package identity,专门调用 Semantic Search API
|
||||
- Settings 通过 IPC 与 helper 通信
|
||||
- 优点:不影响现有 Settings 架构
|
||||
|
||||
### 方向 4:进一步调查 ms-appx:/// 解析
|
||||
|
||||
- 研究是否有配置选项让 sparse package 正确解析框架资源
|
||||
- 可能需要深入 Windows App SDK 源码或联系微软
|
||||
|
||||
### 方向 5:切换为 framework-dependent + Bootstrap(待验证)
|
||||
|
||||
- 设置 `WindowsAppSDKSelfContained=false` 并**重新构建** Settings
|
||||
- 确保外部目录不再携带 app-local WinAppSDK 运行时
|
||||
- 让 `Bootstrap.TryInitialize(...)` 生效,走框架包动态依赖
|
||||
|
||||
## 可复现的工作流(已验证 2026-01-25)
|
||||
|
||||
目标:Settings 使用 sparse identity 启动,WinUI 资源/字符串正常加载。
|
||||
|
||||
### 1) 构建 Settings(framework-dependent + bootstrap no-op)
|
||||
|
||||
在 `PowerToys.Settings.csproj` 中添加(仅在 `UseSparseIdentity=true` 时生效):
|
||||
|
||||
```
|
||||
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
构建:
|
||||
|
||||
```
|
||||
MSBuild.exe src\settings-ui\Settings.UI\PowerToys.Settings.csproj /p:Platform=ARM64 /p:Configuration=Debug /p:UseSparseIdentity=true /m:1 /p:CL_MPCount=1 /nodeReuse:false
|
||||
```
|
||||
|
||||
### 2) 清理 ExternalLocation 的 app-local WinAppSDK 运行时
|
||||
|
||||
**必须移除** app-local WinAppSDK 运行时文件,否则会优先加载并崩溃(`CoreMessagingXP.dll` / `0xc0000602`)。
|
||||
|
||||
需清理的目录:
|
||||
- `ARM64\Debug`(ExternalLocation 根)
|
||||
- `ARM64\Debug\WinUI3Apps`
|
||||
|
||||
建议只移除 app-local WinAppSDK 相关文件(保留业务 DLL)。
|
||||
|
||||
**保留/放回 bootstrap DLL(必要):**
|
||||
- `Microsoft.WindowsAppRuntime.Bootstrap.dll`
|
||||
- `Microsoft.WindowsAppRuntime.Bootstrap.Net.dll`
|
||||
|
||||
### 3) 生成与包名一致的 resources.pri
|
||||
|
||||
关键点:resources.pri 的 **ResourceMap name 必须与包名一致**。
|
||||
|
||||
使用 `makepri.exe new` 生成,确保 `/mn` 指向 sparse 包的 `AppxManifest.xml`:
|
||||
|
||||
```
|
||||
makepri.exe new ^
|
||||
/pr C:\PowerToys\src\settings-ui\Settings.UI ^
|
||||
/cf C:\PowerToys\src\settings-ui\Settings.UI\obj\ARM64\Debug\priconfig.xml ^
|
||||
/mn C:\PowerToys\src\PackageIdentity\AppxManifest.xml ^
|
||||
/of C:\PowerToys\ARM64\Debug\resources.pri ^
|
||||
/o
|
||||
```
|
||||
|
||||
### 4) 将 resources.pri 打进 sparse MSIX
|
||||
|
||||
在 `BuildSparsePackage.ps1` 中把 `resources.pri` 放入 staging(脚本已更新):
|
||||
- 优先取 `ARM64\Debug\resources.pri`
|
||||
- 如果不存在则回退 `ARM64\Debug\WinUI3Apps\PowerToys.Settings.pri`
|
||||
|
||||
重新打包:
|
||||
|
||||
```
|
||||
.\src\PackageIdentity\BuildSparsePackage.ps1 -Platform ARM64 -Configuration Debug
|
||||
```
|
||||
|
||||
### 5) 重新注册 sparse 包(如需先卸载)
|
||||
|
||||
如果因为内容变更被阻止,先卸载再安装:
|
||||
|
||||
```
|
||||
Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Remove-AppxPackage
|
||||
Add-AppxPackage -Path .\ARM64\Debug\PowerToysSparse.msix -ExternalLocation .\ARM64\Debug -ForceApplicationShutdown
|
||||
```
|
||||
|
||||
### 6) 启动 Settings(验证)
|
||||
|
||||
```
|
||||
Start-Process "shell:AppsFolder\Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8!PowerToys.SettingsUI"
|
||||
```
|
||||
|
||||
验证要点:
|
||||
- Settings 正常启动,UI 文本显示
|
||||
- 日志正常写入:`%LOCALAPPDATA%\Microsoft\PowerToys\Settings\Logs\0.0.1.0\`
|
||||
|
||||
### 备注(可选)
|
||||
|
||||
如果出现 `ms-appx:///CommunityToolkit...` 资源缺失,可将对应的 `.pri`(从 NuGet 缓存)复制到 `ARM64\Debug\WinUI3Apps`,但在 **resources.pri 已正确打包** 后通常不再需要。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
1. [ ] WinUI 3 + Sparse Package 的兼容性问题是否有官方文档说明?
|
||||
2. [ ] 是否有其他项目成功实现 WinUI 3 + Sparse Package?
|
||||
3. [ ] Windows App SDK GitHub 上是否有相关 issue?
|
||||
4. [ ] 修正依赖版本后,Settings 是否能在 sparse identity 下正常启动?
|
||||
5. [ ] framework-dependent(Bootstrap)方式是否能在 sparse identity 下启动?
|
||||
|
||||
## 相关文件
|
||||
|
||||
- [SemanticSearchIndex.cs](../../src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs) - Semantic Search 实现
|
||||
- [AppxManifest.xml](../../src/PackageIdentity/AppxManifest.xml) - Sparse package manifest
|
||||
- [BuildSparsePackage.ps1](../../src/PackageIdentity/BuildSparsePackage.ps1) - 构建脚本
|
||||
- [app.manifest](../../src/settings-ui/Settings.UI/app.manifest) - Settings 应用 manifest
|
||||
- [PowerToys.Settings.csproj](../../src/settings-ui/Settings.UI/PowerToys.Settings.csproj) - Settings 项目文件
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Grant package identity by packaging with external location](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps)
|
||||
- [Windows App SDK - Content Search API](https://learn.microsoft.com/en-us/windows/ai/apis/content-search)
|
||||
- [Windows App SDK GitHub Issues](https://github.com/microsoft/WindowsAppSDK/issues)
|
||||
496
doc/specs/common-search-library.md
Normal file
496
doc/specs/common-search-library.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Common.Search Library Specification
|
||||
|
||||
## Overview
|
||||
|
||||
本文档描述 `Common.Search` 库的重构设计,目标是提供一个通用的、可插拔的搜索框架,支持多种搜索引擎实现(Fuzzy Match、Semantic Search 等)。
|
||||
|
||||
## Goals
|
||||
|
||||
1. **解耦** - 搜索引擎与数据源完全解耦
|
||||
2. **可插拔** - 支持替换不同的搜索引擎实现
|
||||
3. **泛型** - 不绑定特定业务类型(如 SettingEntry)
|
||||
4. **可组合** - 支持多引擎组合(即时 Fuzzy + 延迟 Semantic)
|
||||
5. **可复用** - 可被 PowerToys 多个模块使用
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Consumer (e.g., Settings.UI) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SettingsDataProvider ← 业务特定的数据加载 │
|
||||
│ SettingsSearchService ← 业务特定的搜索服务 │
|
||||
│ SettingEntry : ISearchable ← 业务实体实现搜索契约 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Common.Search (Library) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Core Abstractions │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ ISearchable ← 可搜索内容契约 │ │
|
||||
│ │ ISearchEngine<T> ← 搜索引擎接口 │ │
|
||||
│ │ SearchResult<T> ← 统一结果模型 │ │
|
||||
│ │ SearchOptions ← 搜索选项 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Implementations │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ FuzzSearch/ │ │
|
||||
│ │ ├── FuzzSearchEngine<T> ← 内存 Fuzzy 搜索 │ │
|
||||
│ │ ├── StringMatcher ← 现有的模糊匹配算法 │ │
|
||||
│ │ └── MatchResult ← Fuzzy 匹配结果 │ │
|
||||
│ │ │ │
|
||||
│ │ SemanticSearch/ │ │
|
||||
│ │ ├── SemanticSearchEngine ← Windows AI Search 封装 │ │
|
||||
│ │ └── SemanticSearchCapabilities │ │
|
||||
│ │ │ │
|
||||
│ │ CompositeSearch/ │ │
|
||||
│ │ └── CompositeSearchEngine<T> ← 多引擎组合 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
### ISearchable
|
||||
|
||||
定义可搜索内容的最小契约。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a searchable item that can be indexed and searched.
|
||||
/// </summary>
|
||||
public interface ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this item.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary searchable text (e.g., title, header).
|
||||
/// </summary>
|
||||
string SearchableText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional secondary searchable text (e.g., description).
|
||||
/// Returns null if not available.
|
||||
/// </summary>
|
||||
string? SecondarySearchableText { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### ISearchEngine<T>
|
||||
|
||||
搜索引擎核心接口。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a pluggable search engine that can index and search items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
|
||||
public interface ISearchEngine<T> : IDisposable
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the engine capabilities.
|
||||
/// </summary>
|
||||
SearchEngineCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine.
|
||||
/// </summary>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item.
|
||||
/// </summary>
|
||||
Task IndexAsync(T item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch.
|
||||
/// </summary>
|
||||
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the index by its ID.
|
||||
/// </summary>
|
||||
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all indexed items.
|
||||
/// </summary>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
### SearchResult<T>
|
||||
|
||||
统一的搜索结果模型。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result with the matched item and scoring information.
|
||||
/// </summary>
|
||||
public sealed class SearchResult<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched item.
|
||||
/// </summary>
|
||||
public required T Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score (higher is more relevant).
|
||||
/// </summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match that produced this result.
|
||||
/// </summary>
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the match details for highlighting (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span of matched text for highlighting.
|
||||
/// </summary>
|
||||
public readonly record struct MatchSpan(int Start, int Length);
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of match that produced a search result.
|
||||
/// </summary>
|
||||
public enum SearchMatchKind
|
||||
{
|
||||
/// <summary>Exact text match.</summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>Fuzzy/approximate text match.</summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>Semantic/AI-based match.</summary>
|
||||
Semantic,
|
||||
|
||||
/// <summary>Combined match from multiple engines.</summary>
|
||||
Composite,
|
||||
}
|
||||
```
|
||||
|
||||
### SearchOptions
|
||||
|
||||
搜索配置选项。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring search behavior.
|
||||
/// </summary>
|
||||
public sealed class SearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// Default is 20.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score threshold (0.0 to 1.0).
|
||||
/// Results below this score are filtered out.
|
||||
/// Default is 0.0 (no filtering).
|
||||
/// </summary>
|
||||
public double MinScore { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language hint for the search (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include match spans for highlighting.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool IncludeMatchSpans { get; set; } = false;
|
||||
}
|
||||
```
|
||||
|
||||
### SearchEngineCapabilities
|
||||
|
||||
引擎能力描述。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the capabilities of a search engine.
|
||||
/// </summary>
|
||||
public sealed class SearchEngineCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports fuzzy matching.
|
||||
/// </summary>
|
||||
public bool SupportsFuzzyMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports semantic search.
|
||||
/// </summary>
|
||||
public bool SupportsSemanticSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine persists the index to disk.
|
||||
/// </summary>
|
||||
public bool PersistsIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports incremental indexing.
|
||||
/// </summary>
|
||||
public bool SupportsIncrementalIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports match span highlighting.
|
||||
/// </summary>
|
||||
public bool SupportsMatchSpans { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## Implementations
|
||||
|
||||
### FuzzSearchEngine<T>
|
||||
|
||||
基于现有 StringMatcher 的内存搜索引擎。
|
||||
|
||||
**特点:**
|
||||
- 纯内存,无持久化
|
||||
- 即时响应(毫秒级)
|
||||
- 支持 match spans 高亮
|
||||
- 基于字符的模糊匹配
|
||||
|
||||
**Capabilities:**
|
||||
```csharp
|
||||
new SearchEngineCapabilities
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = false,
|
||||
PersistsIndex = false,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = true,
|
||||
}
|
||||
```
|
||||
|
||||
### SemanticSearchEngine
|
||||
|
||||
基于 Windows App SDK AI Search API 的语义搜索引擎。
|
||||
|
||||
**特点:**
|
||||
- 系统管理的持久化索引
|
||||
- AI 驱动的语义理解
|
||||
- 需要模型初始化(可能较慢)
|
||||
- 可能不可用(依赖系统支持)
|
||||
|
||||
**Capabilities:**
|
||||
```csharp
|
||||
new SearchEngineCapabilities
|
||||
{
|
||||
SupportsFuzzyMatch = true, // API 同时提供 lexical + semantic
|
||||
SupportsSemanticSearch = true,
|
||||
PersistsIndex = true,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = false, // API 不返回详细位置
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** SemanticSearchEngine 不是泛型的,因为它需要将内容转换为字符串存入系统索引。实现时通过 `ISearchable` 接口提取文本。
|
||||
|
||||
### CompositeSearchEngine<T>
|
||||
|
||||
组合多个搜索引擎,支持 fallback 和结果合并。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// A search engine that combines results from multiple engines.
|
||||
/// </summary>
|
||||
public sealed class CompositeSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for combining results from multiple engines.
|
||||
/// </summary>
|
||||
public enum CombineStrategy
|
||||
{
|
||||
/// <summary>Use first ready engine only.</summary>
|
||||
FirstReady,
|
||||
|
||||
/// <summary>Merge results from all ready engines.</summary>
|
||||
MergeAll,
|
||||
|
||||
/// <summary>Use primary, fallback to secondary if primary not ready.</summary>
|
||||
PrimaryWithFallback,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**典型用法:** Fuzzy 作为即时响应,Semantic 准备好后增强结果。
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/common/Common.Search/
|
||||
├── Common.Search.csproj
|
||||
├── GlobalSuppressions.cs
|
||||
├── ISearchable.cs
|
||||
├── ISearchEngine.cs
|
||||
├── SearchResult.cs
|
||||
├── SearchOptions.cs
|
||||
├── SearchEngineCapabilities.cs
|
||||
├── SearchMatchKind.cs
|
||||
├── MatchSpan.cs
|
||||
│
|
||||
├── FuzzSearch/
|
||||
│ ├── FuzzSearchEngine.cs
|
||||
│ ├── StringMatcher.cs (existing)
|
||||
│ ├── MatchOption.cs (existing)
|
||||
│ ├── MatchResult.cs (existing)
|
||||
│ └── SearchPrecisionScore.cs (existing)
|
||||
│
|
||||
├── SemanticSearch/
|
||||
│ ├── SemanticSearchEngine.cs
|
||||
│ ├── SemanticSearchCapabilities.cs
|
||||
│ └── SemanticSearchAdapter.cs (adapts ISearchable to Windows API)
|
||||
│
|
||||
└── CompositeSearch/
|
||||
└── CompositeSearchEngine.cs
|
||||
```
|
||||
|
||||
## Consumer Usage (Settings.UI)
|
||||
|
||||
### SettingEntry 实现 ISearchable
|
||||
|
||||
```csharp
|
||||
// Settings.UI.Library/SettingEntry.cs
|
||||
public struct SettingEntry : ISearchable
|
||||
{
|
||||
// Existing properties...
|
||||
|
||||
// ISearchable implementation
|
||||
public string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
|
||||
public string SearchableText => Header ?? string.Empty;
|
||||
public string? SecondarySearchableText => Description;
|
||||
}
|
||||
```
|
||||
|
||||
### SettingsSearchService
|
||||
|
||||
```csharp
|
||||
// Settings.UI/Services/SettingsSearchService.cs
|
||||
public sealed class SettingsSearchService : IDisposable
|
||||
{
|
||||
private readonly ISearchEngine<SettingEntry> _engine;
|
||||
|
||||
public SettingsSearchService(ISearchEngine<SettingEntry> engine)
|
||||
{
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(IEnumerable<SettingEntry> entries)
|
||||
{
|
||||
await _engine.InitializeAsync();
|
||||
await _engine.IndexBatchAsync(entries);
|
||||
}
|
||||
|
||||
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken ct = default)
|
||||
{
|
||||
var results = await _engine.SearchAsync(query, cancellationToken: ct);
|
||||
return results.Select(r => r.Item).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Startup Configuration
|
||||
|
||||
```csharp
|
||||
// Option 1: Fuzzy only (default, immediate)
|
||||
var engine = new FuzzSearchEngine<SettingEntry>();
|
||||
|
||||
// Option 2: Semantic only (requires Windows AI)
|
||||
var engine = new SemanticSearchAdapter<SettingEntry>("PowerToysSettings");
|
||||
|
||||
// Option 3: Composite (best of both worlds)
|
||||
var engine = new CompositeSearchEngine<SettingEntry>(
|
||||
primary: new SemanticSearchAdapter<SettingEntry>("PowerToysSettings"),
|
||||
fallback: new FuzzSearchEngine<SettingEntry>(),
|
||||
strategy: CombineStrategy.PrimaryWithFallback
|
||||
);
|
||||
|
||||
var searchService = new SettingsSearchService(engine);
|
||||
await searchService.InitializeAsync(settingEntries);
|
||||
```
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Core Abstractions
|
||||
1. 创建 `ISearchable`, `ISearchEngine<T>`, `SearchResult<T>` 等核心接口
|
||||
2. 保持现有 FuzzSearch 代码不变
|
||||
|
||||
### Phase 2: FuzzSearchEngine<T>
|
||||
1. 创建泛型 `FuzzSearchEngine<T>` 实现
|
||||
2. 内部复用现有 `StringMatcher`
|
||||
|
||||
### Phase 3: SemanticSearchEngine
|
||||
1. 完善现有 `SemanticSearchEngine` 实现
|
||||
2. 创建 `SemanticSearchAdapter<T>` 桥接泛型接口
|
||||
|
||||
### Phase 4: Settings.UI Migration
|
||||
1. `SettingEntry` 实现 `ISearchable`
|
||||
2. 创建 `SettingsSearchService`
|
||||
3. 迁移 `SearchIndexService` 到新架构
|
||||
4. 保持 API 兼容,逐步废弃旧方法
|
||||
|
||||
### Phase 5: CompositeSearchEngine (Optional)
|
||||
1. 实现组合引擎
|
||||
2. 支持 Fuzzy + Semantic 混合搜索
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **是否需要支持图片搜索?** 当前 SemanticSearchEngine 支持 `IndexImage`,但 `ISearchable` 只有文本。如果需要图片,可能需要 `IImageSearchable` 扩展。
|
||||
|
||||
2. **结果去重策略?** CompositeEngine 合并结果时,同一个 Item 可能被多个引擎匹配,如何去重和合并分数?
|
||||
|
||||
3. **异步 vs 同步?** FuzzSearch 完全可以同步执行,但接口统一用 `Task` 是否合适?考虑提供同步重载?
|
||||
|
||||
4. **索引更新策略?** 当 Settings 内容变化时(例如用户切换语言),如何高效更新索引?
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Last Updated: 2026-01-21*
|
||||
@@ -29,6 +29,7 @@
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.26226.0" />
|
||||
<PackageDependency Name="Microsoft.WindowsAppRuntime.2.0-experimental4" MinVersion="0.738.2207.0" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
|
||||
</Dependencies>
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
@@ -80,6 +81,7 @@
|
||||
<Extensions>
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
|
||||
<com:ExeServer Executable="Microsoft.CmdPal.Ext.PowerToys.exe" Arguments="-RegisterProcessAsComServer" DisplayName="PowerToys Command Palette Extension">
|
||||
<com:Class Id="7EC02C7D-8F98-4A2E-9F23-B58C2C2F2B17" DisplayName="PowerToys Command Palette Extension" />
|
||||
</com:ExeServer>
|
||||
|
||||
@@ -320,6 +320,19 @@ try {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Include resources.pri in the sparse MSIX if available to enable MRT in packaged mode.
|
||||
# Prefer a prebuilt resources.pri in output root; fall back to Settings pri.
|
||||
$resourcesPriSource = Join-Path $outDir "resources.pri"
|
||||
if (-not (Test-Path $resourcesPriSource)) {
|
||||
$resourcesPriSource = Join-Path $outDir "WinUI3Apps\\PowerToys.Settings.pri"
|
||||
}
|
||||
if (Test-Path $resourcesPriSource) {
|
||||
Copy-Item -Path $resourcesPriSource -Destination (Join-Path $stagingDir "resources.pri") -Force -ErrorAction SilentlyContinue
|
||||
Write-BuildLog "Including resources.pri from: $resourcesPriSource" -Level Info
|
||||
} else {
|
||||
Write-BuildLog "resources.pri not found; strings may be missing in sparse identity." -Level Warning
|
||||
}
|
||||
|
||||
# Ensure publisher matches the dev certificate for local builds
|
||||
$manifestStagingPath = Join-Path $stagingDir 'AppxManifest.xml'
|
||||
|
||||
@@ -4,5 +4,16 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS8305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ManagedCommon\ManagedCommon.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Common.Search.FuzzSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A search engine that uses fuzzy string matching for search.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search.</typeparam>
|
||||
public sealed class FuzzSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
private readonly object _lockObject = new();
|
||||
private readonly Dictionary<string, T> _itemsById = new();
|
||||
private readonly Dictionary<string, (string PrimaryNorm, string? SecondaryNorm)> _normalizedCache = new();
|
||||
private bool _isReady;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SearchEngineCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = false,
|
||||
PersistsIndex = false,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = true,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isReady = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
_normalizedCache[item.Id] = (
|
||||
NormalizeString(item.SearchableText),
|
||||
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
|
||||
);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_itemsById[item.Id] = item;
|
||||
_normalizedCache[item.Id] = (
|
||||
NormalizeString(item.SearchableText),
|
||||
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Remove(id);
|
||||
_normalizedCache.Remove(id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
_normalizedCache.Clear();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
|
||||
List<KeyValuePair<string, T>> snapshot;
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_itemsById.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
snapshot = _itemsById.ToList();
|
||||
}
|
||||
|
||||
var bag = new ConcurrentBag<SearchResult<T>>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Parallel.ForEach(snapshot, po, kvp =>
|
||||
{
|
||||
var (primaryNorm, secondaryNorm) = GetNormalizedTexts(kvp.Key);
|
||||
|
||||
var primaryResult = StringMatcher.FuzzyMatch(normalizedQuery, primaryNorm);
|
||||
double score = primaryResult.Score;
|
||||
List<int>? matchData = primaryResult.MatchData;
|
||||
|
||||
if (!string.IsNullOrEmpty(secondaryNorm))
|
||||
{
|
||||
var secondaryResult = StringMatcher.FuzzyMatch(normalizedQuery, secondaryNorm);
|
||||
if (secondaryResult.Success && secondaryResult.Score * 0.8 > score)
|
||||
{
|
||||
score = secondaryResult.Score * 0.8;
|
||||
matchData = null; // Secondary matches don't have primary text spans
|
||||
}
|
||||
}
|
||||
|
||||
if (score > options.MinScore)
|
||||
{
|
||||
var result = new SearchResult<T>
|
||||
{
|
||||
Item = kvp.Value,
|
||||
Score = score,
|
||||
MatchKind = SearchMatchKind.Fuzzy,
|
||||
MatchSpans = options.IncludeMatchSpans && matchData != null
|
||||
? ConvertToMatchSpans(matchData)
|
||||
: null,
|
||||
};
|
||||
|
||||
bag.Add(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
var results = bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(options.MaxResults)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
_normalizedCache.Clear();
|
||||
_isReady = false;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private (string PrimaryNorm, string? SecondaryNorm) GetNormalizedTexts(string id)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
return (string.Empty, null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MatchSpan> ConvertToMatchSpans(List<int> matchData)
|
||||
{
|
||||
if (matchData == null || matchData.Count == 0)
|
||||
{
|
||||
return Array.Empty<MatchSpan>();
|
||||
}
|
||||
|
||||
// Convert individual match indices to spans
|
||||
var spans = new List<MatchSpan>();
|
||||
var sortedIndices = matchData.OrderBy(i => i).ToList();
|
||||
|
||||
int start = sortedIndices[0];
|
||||
int length = 1;
|
||||
|
||||
for (int i = 1; i < sortedIndices.Count; i++)
|
||||
{
|
||||
if (sortedIndices[i] == sortedIndices[i - 1] + 1)
|
||||
{
|
||||
// Consecutive index, extend the span
|
||||
length++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Gap found, save current span and start new one
|
||||
spans.Add(new MatchSpan(start, length));
|
||||
start = sortedIndices[i];
|
||||
length = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last span
|
||||
spans.Add(new MatchSpan(start, length));
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
private static string NormalizeString(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var sb = new StringBuilder(normalized.Length);
|
||||
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (category != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,9 @@ public class StringMatcher
|
||||
/// 6. Move onto the next substring's characters until all substrings are checked.
|
||||
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
|
||||
/// </summary>
|
||||
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null)
|
||||
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption? opt = null)
|
||||
{
|
||||
opt = opt ?? new MatchOption();
|
||||
opt ??= new MatchOption();
|
||||
|
||||
if (string.IsNullOrEmpty(stringToCompare))
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ using System.Diagnostics.CodeAnalysis;
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._indexName")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._indexer")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._disposed")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._capabilities")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")]
|
||||
[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
|
||||
|
||||
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a pluggable search engine that can index and search items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
|
||||
public interface ISearchEngine<T> : IDisposable
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the engine capabilities.
|
||||
/// </summary>
|
||||
SearchEngineCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task IndexAsync(T item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the index by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the item to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all indexed items.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of search results ordered by relevance.</returns>
|
||||
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
27
src/common/Common.Search/ISearchable.cs
Normal file
27
src/common/Common.Search/ISearchable.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a searchable item that can be indexed and searched.
|
||||
/// </summary>
|
||||
public interface ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this item.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary searchable text (e.g., title, header).
|
||||
/// </summary>
|
||||
string SearchableText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional secondary searchable text (e.g., description).
|
||||
/// Returns null if not available.
|
||||
/// </summary>
|
||||
string? SecondarySearchableText { get; }
|
||||
}
|
||||
12
src/common/Common.Search/MatchSpan.cs
Normal file
12
src/common/Common.Search/MatchSpan.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span of matched text for highlighting.
|
||||
/// </summary>
|
||||
/// <param name="Start">The starting index of the match.</param>
|
||||
/// <param name="Length">The length of the match.</param>
|
||||
public readonly record struct MatchSpan(int Start, int Length);
|
||||
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the capabilities of a search engine.
|
||||
/// </summary>
|
||||
public sealed class SearchEngineCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports fuzzy matching.
|
||||
/// </summary>
|
||||
public bool SupportsFuzzyMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports semantic search.
|
||||
/// </summary>
|
||||
public bool SupportsSemanticSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine persists the index to disk.
|
||||
/// </summary>
|
||||
public bool PersistsIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports incremental indexing.
|
||||
/// </summary>
|
||||
public bool SupportsIncrementalIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports match span highlighting.
|
||||
/// </summary>
|
||||
public bool SupportsMatchSpans { get; init; }
|
||||
}
|
||||
134
src/common/Common.Search/SearchError.cs
Normal file
134
src/common/Common.Search/SearchError.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an error that occurred during a search operation.
|
||||
/// </summary>
|
||||
public sealed class SearchError
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchError"/> class.
|
||||
/// </summary>
|
||||
/// <param name="code">The error code.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="details">Optional additional details.</param>
|
||||
/// <param name="exception">Optional exception that caused the error.</param>
|
||||
public SearchError(SearchErrorCode code, string message, string? details = null, Exception? exception = null)
|
||||
{
|
||||
Code = code;
|
||||
Message = message;
|
||||
Details = details;
|
||||
Exception = exception;
|
||||
Timestamp = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error code.
|
||||
/// </summary>
|
||||
public SearchErrorCode Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional details about the error.
|
||||
/// </summary>
|
||||
public string? Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exception that caused the error, if any.
|
||||
/// </summary>
|
||||
public Exception? Exception { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the error occurred.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for initialization failure.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the index.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <param name="exception">Optional exception.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError InitializationFailed(string indexName, string? details = null, Exception? exception = null)
|
||||
=> new(SearchErrorCode.InitializationFailed, $"Failed to initialize search index '{indexName}'.", details, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for indexing failure.
|
||||
/// </summary>
|
||||
/// <param name="contentId">The ID of the content that failed to index.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <param name="exception">Optional exception.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError IndexingFailed(string contentId, string? details = null, Exception? exception = null)
|
||||
=> new(SearchErrorCode.IndexingFailed, $"Failed to index content '{contentId}'.", details, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for search query failure.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query that failed.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <param name="exception">Optional exception.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError SearchFailed(string query, string? details = null, Exception? exception = null)
|
||||
=> new(SearchErrorCode.SearchFailed, $"Search query '{query}' failed.", details, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for engine not ready.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation that was attempted.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError EngineNotReady(string operation)
|
||||
=> new(SearchErrorCode.EngineNotReady, $"Search engine is not ready. Operation '{operation}' cannot be performed.");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for capability unavailable.
|
||||
/// </summary>
|
||||
/// <param name="capability">The capability that is unavailable.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError CapabilityUnavailable(string capability, string? details = null)
|
||||
=> new(SearchErrorCode.CapabilityUnavailable, $"Search capability '{capability}' is not available.", details);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for timeout.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation that timed out.</param>
|
||||
/// <param name="timeout">The timeout duration.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError Timeout(string operation, TimeSpan timeout)
|
||||
=> new(SearchErrorCode.Timeout, $"Operation '{operation}' timed out after {timeout.TotalSeconds:F1} seconds.");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for an unexpected error.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation that failed.</param>
|
||||
/// <param name="exception">The exception that occurred.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError Unexpected(string operation, Exception exception)
|
||||
=> new(SearchErrorCode.Unexpected, $"Unexpected error during '{operation}'.", exception.Message, exception);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
{
|
||||
var result = $"[{Code}] {Message}";
|
||||
if (!string.IsNullOrEmpty(Details))
|
||||
{
|
||||
result += $" Details: {Details}";
|
||||
}
|
||||
|
||||
if (Exception != null)
|
||||
{
|
||||
result += $" Exception: {Exception.GetType().Name}: {Exception.Message}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
51
src/common/Common.Search/SearchErrorCode.cs
Normal file
51
src/common/Common.Search/SearchErrorCode.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines error codes for search operations.
|
||||
/// </summary>
|
||||
public enum SearchErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// No error occurred.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The search engine failed to initialize.
|
||||
/// </summary>
|
||||
InitializationFailed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Failed to index content.
|
||||
/// </summary>
|
||||
IndexingFailed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The search query failed to execute.
|
||||
/// </summary>
|
||||
SearchFailed = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The search engine is not ready to perform the operation.
|
||||
/// </summary>
|
||||
EngineNotReady = 4,
|
||||
|
||||
/// <summary>
|
||||
/// A required capability is not available.
|
||||
/// </summary>
|
||||
CapabilityUnavailable = 5,
|
||||
|
||||
/// <summary>
|
||||
/// The operation timed out.
|
||||
/// </summary>
|
||||
Timeout = 6,
|
||||
|
||||
/// <summary>
|
||||
/// An unexpected error occurred.
|
||||
/// </summary>
|
||||
Unexpected = 99,
|
||||
}
|
||||
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of match that produced a search result.
|
||||
/// </summary>
|
||||
public enum SearchMatchKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact text match.
|
||||
/// </summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>
|
||||
/// Fuzzy/approximate text match.
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// Semantic/AI-based match.
|
||||
/// </summary>
|
||||
Semantic,
|
||||
|
||||
/// <summary>
|
||||
/// Combined match from multiple engines.
|
||||
/// </summary>
|
||||
Composite,
|
||||
}
|
||||
53
src/common/Common.Search/SearchOperationResult.cs
Normal file
53
src/common/Common.Search/SearchOperationResult.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a search operation that may have errors.
|
||||
/// </summary>
|
||||
public sealed class SearchOperationResult
|
||||
{
|
||||
private SearchOperationResult(bool success, SearchError? error = null)
|
||||
{
|
||||
IsSuccess = success;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation failed.
|
||||
/// </summary>
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error if the operation failed, null otherwise.
|
||||
/// </summary>
|
||||
public SearchError? Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
/// <returns>A successful SearchOperationResult.</returns>
|
||||
public static SearchOperationResult Success() => new(true);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with the specified error.
|
||||
/// </summary>
|
||||
/// <param name="error">The error that caused the failure.</param>
|
||||
/// <returns>A failed SearchOperationResult.</returns>
|
||||
public static SearchOperationResult Failure(SearchError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
return new SearchOperationResult(false, error);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
=> IsSuccess ? "Success" : $"Failure: {Error}";
|
||||
}
|
||||
87
src/common/Common.Search/SearchOperationResult1.cs
Normal file
87
src/common/Common.Search/SearchOperationResult1.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
#pragma warning disable SA1649 // File name should match first type name - Generic type file naming convention
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a search operation that returns a value and may have errors.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the result value.</typeparam>
|
||||
public sealed class SearchOperationResult<T>
|
||||
{
|
||||
private SearchOperationResult(bool success, T? value, SearchError? error)
|
||||
{
|
||||
IsSuccess = success;
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation failed.
|
||||
/// </summary>
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result value if the operation was successful.
|
||||
/// </summary>
|
||||
public T? Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error if the operation failed, null otherwise.
|
||||
/// </summary>
|
||||
public SearchError? Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value or a default if the operation failed.
|
||||
/// </summary>
|
||||
/// <param name="defaultValue">The default value to return if the operation failed.</param>
|
||||
/// <returns>The value if successful, otherwise the default value.</returns>
|
||||
public T GetValueOrDefault(T defaultValue) => IsSuccess && Value is not null ? Value : defaultValue;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
=> IsSuccess ? $"Success: {Value}" : $"Failure: {Error}";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result with the specified value.
|
||||
/// </summary>
|
||||
/// <param name="value">The result value.</param>
|
||||
/// <returns>A successful SearchOperationResult.</returns>
|
||||
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
|
||||
public static SearchOperationResult<T> Success(T value) => new(true, value, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with the specified error.
|
||||
/// </summary>
|
||||
/// <param name="error">The error that caused the failure.</param>
|
||||
/// <returns>A failed SearchOperationResult.</returns>
|
||||
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
|
||||
public static SearchOperationResult<T> Failure(SearchError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
return new SearchOperationResult<T>(false, default, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with the specified error and a fallback value.
|
||||
/// </summary>
|
||||
/// <param name="error">The error that caused the failure.</param>
|
||||
/// <param name="fallbackValue">A fallback value to use despite the failure.</param>
|
||||
/// <returns>A failed SearchOperationResult with a fallback value.</returns>
|
||||
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
|
||||
public static SearchOperationResult<T> FailureWithFallback(SearchError error, T fallbackValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
return new SearchOperationResult<T>(false, fallbackValue, error);
|
||||
}
|
||||
}
|
||||
35
src/common/Common.Search/SearchOptions.cs
Normal file
35
src/common/Common.Search/SearchOptions.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring search behavior.
|
||||
/// </summary>
|
||||
public sealed class SearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// Default is 20.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score threshold.
|
||||
/// Results below this score are filtered out.
|
||||
/// Default is 0.0 (no filtering).
|
||||
/// </summary>
|
||||
public double MinScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language hint for the search (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include match spans for highlighting.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool IncludeMatchSpans { get; set; }
|
||||
}
|
||||
33
src/common/Common.Search/SearchResult`1.cs
Normal file
33
src/common/Common.Search/SearchResult`1.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result with the matched item and scoring information.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the matched item.</typeparam>
|
||||
public sealed class SearchResult<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched item.
|
||||
/// </summary>
|
||||
public required T Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score (higher is more relevant).
|
||||
/// </summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match that produced this result.
|
||||
/// </summary>
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the match details for highlighting (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the capabilities of the semantic search index.
|
||||
/// </summary>
|
||||
public class SemanticSearchCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether text lexical (keyword) search is available.
|
||||
/// </summary>
|
||||
public bool TextLexicalAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether text semantic (AI embedding) search is available.
|
||||
/// </summary>
|
||||
public bool TextSemanticAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image semantic search is available.
|
||||
/// </summary>
|
||||
public bool ImageSemanticAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image OCR search is available.
|
||||
/// </summary>
|
||||
public bool ImageOcrAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any search capability is available.
|
||||
/// </summary>
|
||||
public bool AnyAvailable => TextLexicalAvailable || TextSemanticAvailable || ImageSemanticAvailable || ImageOcrAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether text search (lexical or semantic) is available.
|
||||
/// </summary>
|
||||
public bool TextSearchAvailable => TextLexicalAvailable || TextSemanticAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether image search (semantic or OCR) is available.
|
||||
/// </summary>
|
||||
public bool ImageSearchAvailable => ImageSemanticAvailable || ImageOcrAvailable;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of content in a semantic search result.
|
||||
/// </summary>
|
||||
public enum SemanticSearchContentKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Text content.
|
||||
/// </summary>
|
||||
Text,
|
||||
|
||||
/// <summary>
|
||||
/// Image content.
|
||||
/// </summary>
|
||||
Image,
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A semantic search engine that implements the common search interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search.</typeparam>
|
||||
public sealed class SemanticSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
private readonly SemanticSearchIndex _index;
|
||||
private readonly Dictionary<string, T> _itemsById = new();
|
||||
private readonly object _lockObject = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchEngine{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the search index.</param>
|
||||
public SemanticSearchEngine(string indexName)
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] Creating engine. IndexName={indexName}, ItemType={typeof(T).Name}");
|
||||
_index = new SemanticSearchIndex(indexName);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady => _index.IsInitialized;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SearchEngineCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = true,
|
||||
PersistsIndex = true,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying semantic search capabilities.
|
||||
/// </summary>
|
||||
public SemanticSearchCapabilities? SemanticCapabilities => _index.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error that occurred during a search operation, or null if no error occurred.
|
||||
/// </summary>
|
||||
public SearchError? LastError => _index.LastError;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the semantic search capabilities change.
|
||||
/// </summary>
|
||||
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged
|
||||
{
|
||||
add => _index.CapabilitiesChanged += value;
|
||||
remove => _index.CapabilitiesChanged -= value;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync starting. ItemType={typeof(T).Name}");
|
||||
var result = await _index.InitializeAsync().ConfigureAwait(false);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
Logger.LogWarning($"[SemanticSearchEngine] InitializeAsync failed. ItemType={typeof(T).Name}, Error={result.Error?.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync completed. ItemType={typeof(T).Name}");
|
||||
}
|
||||
|
||||
// Note: We don't throw here to maintain backward compatibility,
|
||||
// but callers can check LastError for details if initialization failed.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public async Task<SearchOperationResult> InitializeWithResultAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return await _index.InitializeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync skipped (empty text). Id={item.Id}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync. Id={item.Id}, TextLength={text.Length}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.IndexText(item.Id, text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public Task<SearchOperationResult> IndexWithResultAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Task.FromResult(SearchOperationResult.Success());
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
}
|
||||
|
||||
return Task.FromResult(_index.IndexText(item.Id, text));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var batch = new List<(string Id, string Text)>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] IndexBatchAsync cancelled. ItemsProcessed={batch.Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
batch.Add((item.Id, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SemanticSearchEngine] IndexBatchAsync. BatchSize={batch.Count}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.IndexTextBatch(batch);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public Task<SearchOperationResult> IndexBatchWithResultAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var batch = new List<(string Id, string Text)>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
batch.Add((item.Id, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(_index.IndexTextBatch(batch));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Remove(id);
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] RemoveAsync. Id={id}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.Remove(id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
int count;
|
||||
lock (_lockObject)
|
||||
{
|
||||
count = _itemsById.Count;
|
||||
_itemsById.Clear();
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SemanticSearchEngine] ClearAsync. ItemsCleared={count}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.RemoveAll();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync skipped (empty query).");
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync starting. Query={query}, MaxResults={options.MaxResults}");
|
||||
|
||||
var semanticOptions = new SemanticSearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
Language = options.Language,
|
||||
MatchScope = SemanticSearchMatchScope.Unconstrained,
|
||||
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
|
||||
};
|
||||
|
||||
var searchResult = _index.SearchText(query, semanticOptions);
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
|
||||
var results = new List<SearchResult<T>>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (_itemsById.TryGetValue(match.ContentId, out var item))
|
||||
{
|
||||
results.Add(new SearchResult<T>
|
||||
{
|
||||
Item = item,
|
||||
Score = 100.0, // Semantic search doesn't return scores, use fixed value
|
||||
MatchKind = SearchMatchKind.Semantic,
|
||||
MatchSpans = null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync completed. Query={query}, Matches={matches.Count}, Results={results.Count}");
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result containing search results or error details.</returns>
|
||||
public Task<SearchOperationResult<IReadOnlyList<SearchResult<T>>>> SearchWithResultAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(Array.Empty<SearchResult<T>>()));
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
|
||||
var semanticOptions = new SemanticSearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
Language = options.Language,
|
||||
MatchScope = SemanticSearchMatchScope.Unconstrained,
|
||||
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
|
||||
};
|
||||
|
||||
var searchResult = _index.SearchText(query, semanticOptions);
|
||||
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
|
||||
var results = new List<SearchResult<T>>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (_itemsById.TryGetValue(match.ContentId, out var item))
|
||||
{
|
||||
results.Add(new SearchResult<T>
|
||||
{
|
||||
Item = item,
|
||||
Score = 100.0,
|
||||
MatchKind = SearchMatchKind.Semantic,
|
||||
MatchSpans = null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchResult.IsFailure)
|
||||
{
|
||||
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.FailureWithFallback(searchResult.Error!, results));
|
||||
}
|
||||
|
||||
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(results));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the indexing process to complete.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
await _index.WaitForIndexingCompleteAsync(timeout).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] Disposing. ItemType={typeof(T).Name}");
|
||||
_index.Dispose();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static string BuildSearchableText(T item)
|
||||
{
|
||||
var primary = item.SearchableText ?? string.Empty;
|
||||
var secondary = item.SecondarySearchableText;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secondary))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
return $"{primary} {secondary}";
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
455
src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs
Normal file
455
src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs
Normal file
@@ -0,0 +1,455 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using ManagedCommon;
|
||||
using Microsoft.Windows.Search.AppContentIndex;
|
||||
using Windows.Graphics.Imaging;
|
||||
using SearchOperationResult = Common.Search.SearchOperationResult;
|
||||
using SearchOperationResultT = Common.Search.SearchOperationResult<System.Collections.Generic.IReadOnlyList<Common.Search.SemanticSearch.SemanticSearchResult>>;
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A semantic search engine powered by Windows App SDK AI Search APIs.
|
||||
/// Provides text and image indexing with lexical and semantic search capabilities.
|
||||
/// </summary>
|
||||
public sealed class SemanticSearchIndex : IDisposable
|
||||
{
|
||||
private readonly string _indexName;
|
||||
private AppContentIndexer? _indexer;
|
||||
private bool _disposed;
|
||||
private SemanticSearchCapabilities? _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchIndex"/> class.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the search index.</param>
|
||||
public SemanticSearchIndex(string indexName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(indexName);
|
||||
_indexName = indexName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error that occurred during an operation, or null if no error occurred.
|
||||
/// </summary>
|
||||
public SearchError? LastError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the index capabilities change.
|
||||
/// </summary>
|
||||
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the search engine is initialized.
|
||||
/// </summary>
|
||||
public bool IsInitialized => _indexer != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current index capabilities, or null if not initialized.
|
||||
/// </summary>
|
||||
public SemanticSearchCapabilities? Capabilities => _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine and creates or opens the index.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation. Returns a result indicating success or failure with error details.</returns>
|
||||
public async Task<SearchOperationResult> InitializeAsync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
LastError = null;
|
||||
|
||||
if (_indexer != null)
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchIndex] Already initialized. IndexName={_indexName}");
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SemanticSearchIndex] Initializing. IndexName={_indexName}");
|
||||
|
||||
try
|
||||
{
|
||||
var result = AppContentIndexer.GetOrCreateIndex(_indexName);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
var errorDetails = $"Succeeded={result.Succeeded}, ExtendedError={result.ExtendedError}";
|
||||
Logger.LogError($"[SemanticSearchIndex] GetOrCreateIndex failed. IndexName={_indexName}, {errorDetails}");
|
||||
LastError = SearchError.InitializationFailed(_indexName, errorDetails);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
|
||||
_indexer = result.Indexer;
|
||||
|
||||
// Wait for index capabilities to be ready
|
||||
Logger.LogDebug($"[SemanticSearchIndex] Waiting for index capabilities. IndexName={_indexName}");
|
||||
await _indexer.WaitForIndexCapabilitiesAsync();
|
||||
|
||||
// Load capabilities
|
||||
_capabilities = LoadCapabilities();
|
||||
Logger.LogInfo($"[SemanticSearchIndex] Initialized successfully. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
|
||||
|
||||
// Subscribe to capability changes
|
||||
_indexer.Listener.IndexCapabilitiesChanged += OnIndexCapabilitiesChanged;
|
||||
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] Initialization failed with exception. IndexName={_indexName}", ex);
|
||||
LastError = SearchError.InitializationFailed(_indexName, ex.Message, ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the indexing process to complete.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait for indexing to complete.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
await _indexer!.WaitForIndexingIdleAsync(timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current index capabilities.
|
||||
/// </summary>
|
||||
/// <returns>The current capabilities of the search index.</returns>
|
||||
public SemanticSearchCapabilities GetCapabilities()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
return _capabilities ?? LoadCapabilities();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates text content in the index.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the content.</param>
|
||||
/// <param name="text">The text content to index.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult IndexText(string id, string text)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(text);
|
||||
|
||||
try
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] IndexText failed. Id={id}", ex);
|
||||
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates multiple text contents in the index.
|
||||
/// </summary>
|
||||
/// <param name="items">A collection of id-text pairs to index.</param>
|
||||
/// <returns>A result indicating success or failure with error details. Contains the first error encountered if any.</returns>
|
||||
public SearchOperationResult IndexTextBatch(IEnumerable<(string Id, string Text)> items)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
SearchError? firstError = null;
|
||||
|
||||
foreach (var (id, text) in items)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] IndexTextBatch item failed. Id={id}", ex);
|
||||
firstError ??= SearchError.IndexingFailed(id, ex.Message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstError != null)
|
||||
{
|
||||
LastError = firstError;
|
||||
return SearchOperationResult.Failure(firstError);
|
||||
}
|
||||
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates image content in the index.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the image.</param>
|
||||
/// <param name="bitmap">The image bitmap to index.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult IndexImage(string id, SoftwareBitmap bitmap)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
try
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromBitmap(id, bitmap);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] IndexImage failed. Id={id}", ex);
|
||||
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes content from the index by its identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the content to remove.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult Remove(string id)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
|
||||
try
|
||||
{
|
||||
_indexer!.Remove(id);
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] Remove failed. Id={id}", ex);
|
||||
LastError = SearchError.Unexpected("Remove", ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all content from the index.
|
||||
/// </summary>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult RemoveAll()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
try
|
||||
{
|
||||
_indexer!.RemoveAll();
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] RemoveAll failed.", ex);
|
||||
LastError = SearchError.Unexpected("RemoveAll", ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for text content in the index.
|
||||
/// </summary>
|
||||
/// <param name="searchText">The text to search for.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <returns>A result containing search results or error details.</returns>
|
||||
public SearchOperationResultT SearchText(string searchText, SemanticSearchOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
|
||||
|
||||
options ??= new SemanticSearchOptions();
|
||||
|
||||
try
|
||||
{
|
||||
var queryOptions = new TextQueryOptions
|
||||
{
|
||||
MatchScope = ConvertMatchScope(options.MatchScope),
|
||||
TextMatchType = ConvertTextMatchType(options.TextMatchType),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.Language))
|
||||
{
|
||||
queryOptions.Language = options.Language;
|
||||
}
|
||||
|
||||
var query = _indexer!.CreateTextQuery(searchText, queryOptions);
|
||||
var matches = query.GetNextMatches(options.MaxResults);
|
||||
|
||||
return SearchOperationResultT.Success(ConvertTextMatches(matches));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] SearchText failed. Query={searchText}", ex);
|
||||
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
|
||||
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for image content in the index using text.
|
||||
/// </summary>
|
||||
/// <param name="searchText">The text to search for in images.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <returns>A result containing search results or error details.</returns>
|
||||
public SearchOperationResultT SearchImages(string searchText, SemanticSearchOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
|
||||
|
||||
options ??= new SemanticSearchOptions();
|
||||
|
||||
try
|
||||
{
|
||||
var queryOptions = new ImageQueryOptions
|
||||
{
|
||||
MatchScope = ConvertMatchScope(options.MatchScope),
|
||||
ImageOcrTextMatchType = ConvertTextMatchType(options.TextMatchType),
|
||||
};
|
||||
|
||||
var query = _indexer!.CreateImageQuery(searchText, queryOptions);
|
||||
var matches = query.GetNextMatches(options.MaxResults);
|
||||
|
||||
return SearchOperationResultT.Success(ConvertImageMatches(matches));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] SearchImages failed. Query={searchText}", ex);
|
||||
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
|
||||
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_indexer != null)
|
||||
{
|
||||
_indexer.Listener.IndexCapabilitiesChanged -= OnIndexCapabilitiesChanged;
|
||||
_indexer.Dispose();
|
||||
_indexer = null;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private SemanticSearchCapabilities LoadCapabilities()
|
||||
{
|
||||
var capabilities = _indexer!.GetIndexCapabilities();
|
||||
|
||||
return new SemanticSearchCapabilities
|
||||
{
|
||||
TextLexicalAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextLexical),
|
||||
TextSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextSemantic),
|
||||
ImageSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageSemantic),
|
||||
ImageOcrAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageOcr),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCapabilityInitialized(IndexCapabilities capabilities, IndexCapability capability)
|
||||
{
|
||||
var state = capabilities.GetCapabilityState(capability);
|
||||
return state.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
}
|
||||
|
||||
private void OnIndexCapabilitiesChanged(AppContentIndexer indexer, IndexCapabilities capabilities)
|
||||
{
|
||||
_capabilities = LoadCapabilities();
|
||||
Logger.LogInfo($"[SemanticSearchIndex] Capabilities changed. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
|
||||
CapabilitiesChanged?.Invoke(this, _capabilities);
|
||||
}
|
||||
|
||||
private static QueryMatchScope ConvertMatchScope(SemanticSearchMatchScope scope)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
SemanticSearchMatchScope.Unconstrained => QueryMatchScope.Unconstrained,
|
||||
SemanticSearchMatchScope.Region => QueryMatchScope.Region,
|
||||
SemanticSearchMatchScope.ContentItem => QueryMatchScope.ContentItem,
|
||||
_ => QueryMatchScope.Unconstrained,
|
||||
};
|
||||
}
|
||||
|
||||
private static TextLexicalMatchType ConvertTextMatchType(SemanticSearchTextMatchType matchType)
|
||||
{
|
||||
return matchType switch
|
||||
{
|
||||
SemanticSearchTextMatchType.Fuzzy => TextLexicalMatchType.Fuzzy,
|
||||
SemanticSearchTextMatchType.Exact => TextLexicalMatchType.Exact,
|
||||
_ => TextLexicalMatchType.Fuzzy,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SemanticSearchResult> ConvertTextMatches(IReadOnlyList<TextQueryMatch> matches)
|
||||
{
|
||||
var results = new List<SemanticSearchResult>();
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Text);
|
||||
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedText &&
|
||||
match is AppManagedTextQueryMatch textMatch)
|
||||
{
|
||||
result.TextOffset = textMatch.TextOffset;
|
||||
result.TextLength = textMatch.TextLength;
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SemanticSearchResult> ConvertImageMatches(IReadOnlyList<ImageQueryMatch> matches)
|
||||
{
|
||||
var results = new List<SemanticSearchResult>();
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Image);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private void ThrowIfNotInitialized()
|
||||
{
|
||||
if (_indexer == null)
|
||||
{
|
||||
throw new InvalidOperationException("Search engine is not initialized. Call InitializeAsync() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the scope for semantic search matching.
|
||||
/// </summary>
|
||||
public enum SemanticSearchMatchScope
|
||||
{
|
||||
/// <summary>
|
||||
/// No constraints, uses both Lexical and Semantic matching.
|
||||
/// </summary>
|
||||
Unconstrained,
|
||||
|
||||
/// <summary>
|
||||
/// Restrict matching to a specific region.
|
||||
/// </summary>
|
||||
Region,
|
||||
|
||||
/// <summary>
|
||||
/// Restrict matching to a single content item.
|
||||
/// </summary>
|
||||
ContentItem,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring semantic search queries.
|
||||
/// </summary>
|
||||
public class SemanticSearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the language for the search query (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the match scope for the search.
|
||||
/// </summary>
|
||||
public SemanticSearchMatchScope MatchScope { get; set; } = SemanticSearchMatchScope.Unconstrained;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text match type for lexical matching.
|
||||
/// </summary>
|
||||
public SemanticSearchTextMatchType TextMatchType { get; set; } = SemanticSearchTextMatchType.Fuzzy;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result from the semantic search engine.
|
||||
/// </summary>
|
||||
public class SemanticSearchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contentId">The unique identifier of the matched content.</param>
|
||||
/// <param name="contentKind">The kind of content matched (text or image).</param>
|
||||
public SemanticSearchResult(string contentId, SemanticSearchContentKind contentKind)
|
||||
{
|
||||
ContentId = contentId;
|
||||
ContentKind = contentKind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of the matched content.
|
||||
/// </summary>
|
||||
public string ContentId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of content that was matched.
|
||||
/// </summary>
|
||||
public SemanticSearchContentKind ContentKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text offset where the match was found (for text matches only).
|
||||
/// </summary>
|
||||
public int TextOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the length of the matched text (for text matches only).
|
||||
/// </summary>
|
||||
public int TextLength { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of text matching for lexical searches.
|
||||
/// </summary>
|
||||
public enum SemanticSearchTextMatchType
|
||||
{
|
||||
/// <summary>
|
||||
/// Fuzzy matching allows spelling errors and approximate words.
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// Exact matching requires exact text matches.
|
||||
/// </summary>
|
||||
Exact,
|
||||
}
|
||||
@@ -614,166 +614,4 @@ void AlwaysOnTop::RefreshBorders()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Only allow transparency changes on pinned windows
|
||||
if (!IsPinned(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
|
||||
void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
|
||||
{
|
||||
HWND targetWindow = ResolveTransparencyTargetWindow(window);
|
||||
if (!targetWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int currentTransparency = Settings::maxTransparencyPercentage;
|
||||
LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE);
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr))
|
||||
{
|
||||
currentTransparency = (alpha * 100) / 255;
|
||||
}
|
||||
}
|
||||
|
||||
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
|
||||
|
||||
if (newTransparency != currentTransparency)
|
||||
{
|
||||
ApplyWindowAlpha(targetWindow, newTransparency);
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
{
|
||||
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
|
||||
}
|
||||
|
||||
Logger::debug(L"Transparency adjusted to {}%", newTransparency);
|
||||
}
|
||||
}
|
||||
|
||||
void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
percentage = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, percentage));
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
|
||||
|
||||
// Cache original state on first transparency application
|
||||
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
|
||||
{
|
||||
WindowLayeredState state;
|
||||
state.hadLayeredStyle = isCurrentlyLayered;
|
||||
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
COLORREF colorKey = 0;
|
||||
DWORD flags = 0;
|
||||
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
|
||||
{
|
||||
state.originalAlpha = alpha;
|
||||
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
|
||||
state.colorKey = colorKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_windowOriginalLayeredState[window] = state;
|
||||
}
|
||||
|
||||
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
|
||||
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::RestoreWindowAlpha(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
auto it = m_windowOriginalLayeredState.find(window);
|
||||
|
||||
if (it != m_windowOriginalLayeredState.end())
|
||||
{
|
||||
const auto& originalState = it->second;
|
||||
|
||||
if (originalState.hadLayeredStyle)
|
||||
{
|
||||
// Window originally had WS_EX_LAYERED - restore original attributes
|
||||
// Clear and re-add to ensure clean state
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
|
||||
// Restore original alpha and/or color key
|
||||
DWORD flags = LWA_ALPHA;
|
||||
if (originalState.usedColorKey)
|
||||
{
|
||||
flags |= LWA_COLORKEY;
|
||||
}
|
||||
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Window originally didn't have WS_EX_LAYERED - remove it completely
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
m_windowOriginalLayeredState.erase(it);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: no cached state, just remove layered style
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
||||
|
||||
@@ -107,7 +107,7 @@ public partial class AliasManager : ObservableObject
|
||||
}
|
||||
|
||||
// Look for the alias belonging to another command, and remove it
|
||||
if (newAlias is not null && kv.Value.Alias == newAlias.Alias)
|
||||
if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId)
|
||||
{
|
||||
toRemove.Add(kv.Value);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <Sddl.h>
|
||||
#include <sstream>
|
||||
#include <aclapi.h>
|
||||
#include <shobjidl.h>
|
||||
|
||||
#include "powertoy_module.h"
|
||||
#include <common/interop/two_way_pipe_message_ipc.h>
|
||||
@@ -64,6 +65,74 @@ json::JsonObject get_all_settings()
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr wchar_t SettingsApplicationId[] = L"PowerToys.SettingsUI";
|
||||
constexpr wchar_t SparseAppFamilyDev[] = L"Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8";
|
||||
constexpr wchar_t SparseAppFamilyStore[] = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe";
|
||||
|
||||
bool try_activate_settings_with_identity(const std::wstring& arguments, PROCESS_INFORMATION& process_info)
|
||||
{
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
const bool should_uninit = SUCCEEDED(hr);
|
||||
if (hr == RPC_E_CHANGED_MODE)
|
||||
{
|
||||
hr = S_OK;
|
||||
}
|
||||
|
||||
if (FAILED(hr))
|
||||
{
|
||||
Logger::warn(L"Settings: CoInitializeEx failed. hr=0x{:x}", hr);
|
||||
return false;
|
||||
}
|
||||
|
||||
IApplicationActivationManager* activation_manager = nullptr;
|
||||
hr = CoCreateInstance(CLSID_ApplicationActivationManager,
|
||||
nullptr,
|
||||
CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(&activation_manager));
|
||||
if (FAILED(hr))
|
||||
{
|
||||
Logger::warn(L"Settings: CoCreateInstance(ApplicationActivationManager) failed. hr=0x{:x}", hr);
|
||||
if (should_uninit)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
auto try_activate = [&](const wchar_t* family) -> bool {
|
||||
std::wstring aumid = std::wstring(family) + L"!" + SettingsApplicationId;
|
||||
DWORD pid = 0;
|
||||
HRESULT hr_activate = activation_manager->ActivateApplication(aumid.c_str(),
|
||||
arguments.c_str(),
|
||||
AO_NONE,
|
||||
&pid);
|
||||
if (SUCCEEDED(hr_activate) && pid != 0)
|
||||
{
|
||||
process_info = {};
|
||||
process_info.dwProcessId = pid;
|
||||
process_info.hProcess = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
Logger::info(L"Settings: Activated via AUMID {} (pid {}).", aumid, pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
Logger::warn(L"Settings: ActivateApplication failed for {}. hr=0x{:x}", aumid, hr_activate);
|
||||
return false;
|
||||
};
|
||||
|
||||
const bool activated = try_activate(SparseAppFamilyDev) || try_activate(SparseAppFamilyStore);
|
||||
|
||||
activation_manager->Release();
|
||||
if (should_uninit)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
return activated;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::wstring> dispatch_json_action_to_module(const json::JsonObject& powertoys_configs)
|
||||
{
|
||||
std::optional<std::wstring> result;
|
||||
@@ -522,15 +591,34 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op
|
||||
settings_showOobe,
|
||||
settings_showScoobe,
|
||||
settings_containsSettingsWindow);
|
||||
std::wstring activation_args = fmt::format(L"{} {} {} {} {} {} {} {} {}",
|
||||
powertoys_pipe_name,
|
||||
settings_pipe_name,
|
||||
std::to_wstring(powertoys_pid),
|
||||
settings_theme,
|
||||
settings_elevatedStatus,
|
||||
settings_isUserAnAdmin,
|
||||
settings_showOobe,
|
||||
settings_showScoobe,
|
||||
settings_containsSettingsWindow);
|
||||
|
||||
if (settings_window.has_value())
|
||||
{
|
||||
executable_args.append(L" ");
|
||||
executable_args.append(settings_window.value());
|
||||
activation_args.append(L" ");
|
||||
activation_args.append(settings_window.value());
|
||||
}
|
||||
|
||||
BOOL process_created = false;
|
||||
|
||||
// Prefer activating via package identity so the package graph (framework deps) is applied.
|
||||
if (try_activate_settings_with_identity(activation_args, process_info))
|
||||
{
|
||||
process_created = true;
|
||||
g_isLaunchInProgress = false;
|
||||
}
|
||||
|
||||
// Commented out to fix #22659
|
||||
// Running settings non-elevated and modules elevated when PowerToys is running elevated results
|
||||
// in settings making changes in one file (non-elevated user dir) and modules are reading settings
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Common.Search;
|
||||
|
||||
namespace Settings.UI.Library
|
||||
{
|
||||
public enum EntryType
|
||||
@@ -11,7 +15,7 @@ namespace Settings.UI.Library
|
||||
SettingsExpander,
|
||||
}
|
||||
|
||||
public struct SettingEntry
|
||||
public struct SettingEntry : ISearchable
|
||||
{
|
||||
public EntryType Type { get; set; }
|
||||
|
||||
@@ -29,16 +33,23 @@ namespace Settings.UI.Library
|
||||
|
||||
public string Icon { get; set; }
|
||||
|
||||
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null)
|
||||
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string? parentElementName = null, string? description = null, string? icon = null)
|
||||
{
|
||||
Type = type;
|
||||
Header = header;
|
||||
PageTypeName = pageTypeName;
|
||||
ElementName = elementName;
|
||||
ElementUid = elementUid;
|
||||
ParentElementName = parentElementName;
|
||||
Description = description;
|
||||
Icon = icon;
|
||||
ParentElementName = parentElementName ?? string.Empty;
|
||||
Description = description ?? string.Empty;
|
||||
Icon = icon ?? string.Empty;
|
||||
}
|
||||
|
||||
// ISearchable implementation
|
||||
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
|
||||
|
||||
public readonly string SearchableText => Header ?? string.Empty;
|
||||
|
||||
public readonly string? SecondarySearchableText => Description;
|
||||
}
|
||||
}
|
||||
|
||||
25
src/settings-ui/Settings.UI.Library/SettingSearchResult.cs
Normal file
25
src/settings-ui/Settings.UI.Library/SettingSearchResult.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Common.Search;
|
||||
|
||||
namespace Settings.UI.Library
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a search result for a settings entry with scoring metadata.
|
||||
/// </summary>
|
||||
public sealed class SettingSearchResult
|
||||
{
|
||||
public required SettingEntry Entry { get; init; }
|
||||
|
||||
public required double Score { get; init; }
|
||||
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
|
||||
<ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" />
|
||||
<ProjectReference Include="..\..\common\Common.Search\Common.Search.csproj" />
|
||||
<ProjectReference Include="..\..\modules\powerdisplay\PowerDisplay.Lib\PowerDisplay.Lib.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Microsoft.PowerToys.Settings.UI.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.UnitTests.Services
|
||||
{
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class SettingsSearchTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void LoadIndexFromJson_ReturnsEntries()
|
||||
{
|
||||
const string json = @"
|
||||
[
|
||||
{
|
||||
""type"": 0,
|
||||
""header"": ""General"",
|
||||
""pageTypeName"": ""GeneralPage"",
|
||||
""elementName"": """",
|
||||
""elementUid"": ""General_Page"",
|
||||
""parentElementName"": """",
|
||||
""description"": ""General settings"",
|
||||
""icon"": """"
|
||||
},
|
||||
{
|
||||
""type"": 1,
|
||||
""header"": ""Mouse Utilities"",
|
||||
""pageTypeName"": ""MouseUtilsPage"",
|
||||
""elementName"": ""MouseUtilsSetting"",
|
||||
""elementUid"": ""MouseUtils_Setting"",
|
||||
""parentElementName"": """",
|
||||
""description"": ""Adjust mouse settings"",
|
||||
""icon"": """"
|
||||
}
|
||||
]
|
||||
";
|
||||
|
||||
var entries = SettingsSearch.LoadIndexFromJson(json);
|
||||
|
||||
Assert.AreEqual(2, entries.Count);
|
||||
Assert.AreEqual(EntryType.SettingsPage, entries[0].Type);
|
||||
Assert.AreEqual("GeneralPage", entries[0].PageTypeName);
|
||||
Assert.AreEqual("MouseUtilsPage", entries[1].PageTypeName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task InitializeIndexAsync_InitializesSearchAndReturnsResults()
|
||||
{
|
||||
var entries = new List<SettingEntry>
|
||||
{
|
||||
new(EntryType.SettingsPage, "General", "GeneralPage", string.Empty, "General_Page"),
|
||||
new(EntryType.SettingsCard, "Mouse Utilities", "MouseUtilsPage", "MouseUtilsSetting", "MouseUtils_Setting", description: "Adjust mouse settings"),
|
||||
};
|
||||
|
||||
using var search = new SettingsSearch(new FuzzSearchEngine<SettingEntry>());
|
||||
await search.InitializeIndexAsync(entries);
|
||||
|
||||
Assert.IsTrue(search.IsReady);
|
||||
|
||||
var results = await search.SearchAsync("mouse", options: null);
|
||||
|
||||
Assert.IsTrue(results.Count > 0);
|
||||
Assert.AreEqual("Mouse Utilities", results[0].Entry.Header);
|
||||
Assert.IsTrue(results[0].Score > 0);
|
||||
Assert.IsNotNull(results[0].MatchSpans);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
<Import Project="..\..\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="..\..\Common.SelfContained.props" />
|
||||
|
||||
<PropertyGroup Condition="'$(UseSparseIdentity)'==''">
|
||||
<UseSparseIdentity>true</UseSparseIdentity>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>Microsoft.PowerToys.Settings.UI</RootNamespace>
|
||||
@@ -14,11 +18,15 @@
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<ApplicationIcon>Assets\Settings\icon.ico</ApplicationIcon>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<!-- OutputPath looks like this because it has to be called both by settings and publish.cmd -->
|
||||
<OutputPath>..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
|
||||
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
|
||||
<ProjectPriFileName>PowerToys.Settings.pri</ProjectPriFileName>
|
||||
</PropertyGroup>
|
||||
<!-- Framework-dependent for sparse identity + no-op bootstrap when identity exists -->
|
||||
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Assets\Settings\Icons\Models\Azure.svg" />
|
||||
<None Remove="Assets\Settings\Icons\Models\FoundryLocal.svg" />
|
||||
@@ -219,4 +227,4 @@
|
||||
<Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" />
|
||||
<MSBuild Projects="..\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj" Targets="Build" Properties="Configuration=$(Configuration);Platform=Any CPU;TargetFramework=net9.0;XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\Views;GeneratedJsonFile=$(GeneratedJsonFile)" />
|
||||
</Target>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
public static class SearchIndexService
|
||||
{
|
||||
private static readonly object _lockObject = new();
|
||||
private static readonly Dictionary<string, string> _pageNameCache = [];
|
||||
private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
|
||||
private static readonly Dictionary<string, Type> _pageTypeCache = new();
|
||||
private static ImmutableArray<SettingEntry> _index = [];
|
||||
private static bool _isIndexBuilt;
|
||||
private static bool _isIndexBuilding;
|
||||
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
|
||||
private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public static ImmutableArray<SettingEntry> Index
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsIndexReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isIndexBuilt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void BuildIndex()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_isIndexBuilt || _isIndexBuilding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isIndexBuilding = true;
|
||||
|
||||
// Clear caches on rebuild
|
||||
_normalizedTextCache.Clear();
|
||||
_pageTypeCache.Clear();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SettingEntry>();
|
||||
LoadIndexFromPrebuiltData(builder);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_index = builder.ToImmutable();
|
||||
_isIndexBuilt = true;
|
||||
_isIndexBuilding = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}");
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilding = false;
|
||||
_isIndexBuilt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadIndexFromPrebuiltData(ImmutableArray<SettingEntry>.Builder builder)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
SettingEntry[] metadataList;
|
||||
|
||||
Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
|
||||
|
||||
try
|
||||
{
|
||||
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
|
||||
return;
|
||||
}
|
||||
|
||||
using StreamReader reader = new(stream);
|
||||
string json = reader.ReadToEnd();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
metadataList = JsonSerializer.Deserialize<SettingEntry[]>(json, _serializerOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadataList == null || metadataList.Length == 0)
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ref var metadata in metadataList.AsSpan())
|
||||
{
|
||||
if (metadata.Type == EntryType.SettingsPage)
|
||||
{
|
||||
(metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid);
|
||||
}
|
||||
else
|
||||
{
|
||||
(metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(metadata.Header))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(metadata);
|
||||
|
||||
// Cache the page name mapping for SettingsPage entries
|
||||
if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header))
|
||||
{
|
||||
_pageNameCache[metadata.PageTypeName] = metadata.Header;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}");
|
||||
}
|
||||
|
||||
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string header = GetString(resourceLoader, $"{elementUid}/Header");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/Description");
|
||||
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
header = GetString(resourceLoader, $"{elementUid}/Content");
|
||||
}
|
||||
|
||||
return (header, description);
|
||||
}
|
||||
|
||||
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
|
||||
|
||||
return (title, description);
|
||||
}
|
||||
|
||||
private static string GetString(ResourceLoader rl, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
string value = rl.GetString(key);
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<SettingEntry> Search(string query)
|
||||
{
|
||||
return Search(query, CancellationToken.None);
|
||||
}
|
||||
|
||||
public static List<SettingEntry> Search(string query, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var currentIndex = Index;
|
||||
if (currentIndex.IsEmpty)
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] Search called but index is empty.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = token,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Parallel.ForEach(currentIndex, po, entry =>
|
||||
{
|
||||
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
|
||||
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
|
||||
double score = captionScoreResult.Score;
|
||||
|
||||
if (!string.IsNullOrEmpty(descNorm))
|
||||
{
|
||||
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
|
||||
if (descriptionScoreResult.Success)
|
||||
{
|
||||
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0)
|
||||
{
|
||||
var pageType = GetPageTypeFromName(entry.PageTypeName);
|
||||
if (pageType != null)
|
||||
{
|
||||
bag.Add((entry, score));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Select(r => r.Hit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Type GetPageTypeFromName(string pageTypeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pageTypeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var assembly = typeof(GeneralPage).Assembly;
|
||||
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
|
||||
_pageTypeCache[pageTypeName] = type;
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
|
||||
{
|
||||
if (entry.ElementUid == null && entry.Header == null)
|
||||
{
|
||||
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
|
||||
}
|
||||
|
||||
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedTextCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
var headerNorm = NormalizeString(entry.Header);
|
||||
var descNorm = NormalizeString(entry.Description);
|
||||
lock (_lockObject)
|
||||
{
|
||||
_normalizedTextCache[key] = (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
return (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
private static string NormalizeString(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public static string GetLocalizedPageName(string pageTypeName)
|
||||
{
|
||||
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
434
src/settings-ui/Settings.UI/Services/SettingsSearch.cs
Normal file
434
src/settings-ui/Settings.UI/Services/SettingsSearch.cs
Normal file
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search;
|
||||
using Common.Search.SemanticSearch;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
public sealed class SettingsSearch : IDisposable
|
||||
{
|
||||
private static readonly Lazy<SettingsSearch> DefaultInstance = new(() => new SettingsSearch());
|
||||
|
||||
private readonly object _lockObject = new();
|
||||
private readonly Dictionary<string, string> _pageNameCache = [];
|
||||
private readonly Dictionary<string, Type> _pageTypeCache = new();
|
||||
private readonly ISearchEngine<SettingEntry> _searchEngine;
|
||||
private ImmutableArray<SettingEntry> _index = [];
|
||||
private bool _isIndexBuilt;
|
||||
private bool _isIndexBuilding;
|
||||
private bool _disposed;
|
||||
private Task _buildTask;
|
||||
|
||||
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private const string DefaultIndexName = "PowerToys.Settings";
|
||||
|
||||
public SettingsSearch()
|
||||
: this(new SemanticSearchEngine<SettingEntry>(DefaultIndexName))
|
||||
{
|
||||
}
|
||||
|
||||
public SettingsSearch(ISearchEngine<SettingEntry> searchEngine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(searchEngine);
|
||||
_searchEngine = searchEngine;
|
||||
}
|
||||
|
||||
public static SettingsSearch Default => DefaultInstance.Value;
|
||||
|
||||
public ImmutableArray<SettingEntry> Index
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isIndexBuilt && _searchEngine.IsReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task BuildIndexAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_isIndexBuilt)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index already built.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (_isIndexBuilding)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index build already in progress.");
|
||||
return _buildTask ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (_buildTask != null)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: build task already scheduled.");
|
||||
return _buildTask;
|
||||
}
|
||||
|
||||
Logger.LogInfo("[SettingsSearch] BuildIndexAsync started.");
|
||||
_buildTask = BuildIndexInternalAsync(cancellationToken);
|
||||
return _buildTask;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitializeIndexAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var builtIndex = entries is ImmutableArray<SettingEntry> immutableEntries
|
||||
? immutableEntries
|
||||
: ImmutableArray.CreateRange(entries);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilding = true;
|
||||
_isIndexBuilt = false;
|
||||
_index = builtIndex;
|
||||
_pageNameCache.Clear();
|
||||
_pageTypeCache.Clear();
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Initializing index. Entries={builtIndex.Length}, Engine={_searchEngine.GetType().Name}.");
|
||||
CachePageNames(builtIndex);
|
||||
|
||||
try
|
||||
{
|
||||
if (_searchEngine.IsReady)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] Clearing existing search engine index.");
|
||||
await _searchEngine.ClearAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] Initializing search engine.");
|
||||
await _searchEngine.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!_searchEngine.IsReady)
|
||||
{
|
||||
Logger.LogWarning("[SettingsSearch] Search engine not ready after initialization. Skipping indexing.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _searchEngine.IndexBatchAsync(builtIndex, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilt = true;
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Index initialized. Entries={builtIndex.Length}, EngineReady={_searchEngine.IsReady}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] CRITICAL ERROR initializing search engine: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilding = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SettingSearchResult>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions options = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Array.Empty<SettingSearchResult>();
|
||||
}
|
||||
|
||||
if (!IsReady)
|
||||
{
|
||||
Logger.LogWarning("[SettingsSearch] Search called but index is not ready.");
|
||||
return Array.Empty<SettingSearchResult>();
|
||||
}
|
||||
|
||||
var effectiveOptions = options ?? new SearchOptions
|
||||
{
|
||||
MaxResults = Index.Length,
|
||||
IncludeMatchSpans = true,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
Logger.LogDebug($"[SettingsSearch] Search start. QueryLength={query.Length}, MaxResults={effectiveOptions.MaxResults}.");
|
||||
var results = await Task.Run(
|
||||
() => _searchEngine.SearchAsync(query, effectiveOptions, token),
|
||||
token).ConfigureAwait(false);
|
||||
var filtered = FilterValidPageTypes(results);
|
||||
sw.Stop();
|
||||
Logger.LogDebug($"[SettingsSearch] Search complete. Results={filtered.Count}, ElapsedMs={sw.ElapsedMilliseconds}.");
|
||||
return filtered;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] Search canceled.");
|
||||
return Array.Empty<SettingSearchResult>();
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SettingEntry> LoadIndexFromJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return Array.Empty<SettingEntry>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SettingEntry[]>(json, SerializerOptions) ?? Array.Empty<SettingEntry>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] ERROR: Failed to load index from json: {ex.Message}", ex);
|
||||
return Array.Empty<SettingEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
public string GetLocalizedPageName(string pageTypeName)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_searchEngine.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private async Task BuildIndexInternalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var entries = LoadIndexFromPrebuiltData();
|
||||
Logger.LogInfo($"[SettingsSearch] Prebuilt index loaded. Entries={entries.Length}.");
|
||||
await InitializeIndexAsync(entries, cancellationToken).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
Logger.LogInfo($"[SettingsSearch] BuildIndexAsync finished. ElapsedMs={sw.ElapsedMilliseconds}, Ready={IsReady}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] CRITICAL ERROR building search index: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_buildTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CachePageNames(ImmutableArray<SettingEntry> entries)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(entry.Header))
|
||||
{
|
||||
_pageNameCache[entry.PageTypeName] = entry.Header;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ImmutableArray<SettingEntry> LoadIndexFromPrebuiltData()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
using StreamReader reader = new(stream);
|
||||
json = reader.ReadToEnd();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] ERROR: Failed to read prebuilt index: {ex.Message}", ex);
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
Logger.LogError("[SettingsSearch] ERROR: Embedded resource was empty.");
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
var metadataList = LoadIndexFromJson(json);
|
||||
if (metadataList == null || metadataList.Count == 0)
|
||||
{
|
||||
Logger.LogWarning("[SettingsSearch] Prebuilt index is empty or deserialization failed.");
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<SettingEntry>(metadataList.Count);
|
||||
|
||||
foreach (var metadata in metadataList)
|
||||
{
|
||||
var entry = metadata;
|
||||
if (entry.Type == EntryType.SettingsPage)
|
||||
{
|
||||
(entry.Header, entry.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, entry.ElementUid);
|
||||
}
|
||||
else
|
||||
{
|
||||
(entry.Header, entry.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, entry.ElementUid);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Header))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(entry);
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Finished loading index. Total entries: {builder.Count}");
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string header = GetString(resourceLoader, $"{elementUid}/Header");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/Description");
|
||||
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
header = GetString(resourceLoader, $"{elementUid}/Content");
|
||||
}
|
||||
|
||||
return (header, description);
|
||||
}
|
||||
|
||||
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
|
||||
|
||||
return (title, description);
|
||||
}
|
||||
|
||||
private static string GetString(ResourceLoader rl, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
string value = rl.GetString(key);
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<SettingSearchResult> FilterValidPageTypes(IReadOnlyList<SearchResult<SettingEntry>> results)
|
||||
{
|
||||
var filtered = new List<SettingSearchResult>(results.Count);
|
||||
foreach (var result in results)
|
||||
{
|
||||
var entry = result.Item;
|
||||
if (GetPageTypeFromName(entry.PageTypeName) != null)
|
||||
{
|
||||
filtered.Add(new SettingSearchResult
|
||||
{
|
||||
Entry = entry,
|
||||
Score = result.Score,
|
||||
MatchKind = result.MatchKind,
|
||||
MatchSpans = result.MatchSpans,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private Type GetPageTypeFromName(string pageTypeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pageTypeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var assembly = typeof(GeneralPage).Assembly;
|
||||
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
|
||||
_pageTypeCache[pageTypeName] = type;
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,12 @@
|
||||
IsTabStop="False"
|
||||
ItemsSource="{x:Bind ViewModel.ModuleResults, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:SettingEntry">
|
||||
<DataTemplate x:DataType="models:SettingSearchResult">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Click="ModuleButton_Click"
|
||||
Header="{x:Bind Header}"
|
||||
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
Header="{x:Bind Entry.Header}"
|
||||
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
IsClickEnabled="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -52,19 +52,19 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsGroup">
|
||||
<controls:SettingsGroup Header="{x:Bind GroupName}">
|
||||
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:SettingEntry">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Click="SettingButton_Click"
|
||||
Description="{x:Bind Description}"
|
||||
Header="{x:Bind Header}"
|
||||
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
IsClickEnabled="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:SettingSearchResult">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Click="SettingButton_Click"
|
||||
Description="{x:Bind Entry.Description}"
|
||||
Header="{x:Bind Entry.Header}"
|
||||
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
IsClickEnabled="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</controls:SettingsGroup>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -44,17 +44,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
|
||||
private void ModuleButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
|
||||
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
|
||||
{
|
||||
NavigateToModule(tagEntry);
|
||||
NavigateToModule(tagResult.Entry);
|
||||
}
|
||||
}
|
||||
|
||||
private void SettingButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
|
||||
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
|
||||
{
|
||||
NavigateToSetting(tagEntry);
|
||||
NavigateToSetting(tagResult.Entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
{
|
||||
public string Query { get; set; }
|
||||
|
||||
public List<SettingEntry> Results { get; set; }
|
||||
public List<SettingSearchResult> Results { get; set; }
|
||||
|
||||
public SearchResultsNavigationParams(string query, List<SettingEntry> results)
|
||||
public SearchResultsNavigationParams(string query, List<SettingSearchResult> results)
|
||||
{
|
||||
Query = query;
|
||||
Results = results;
|
||||
|
||||
@@ -8,7 +8,6 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search;
|
||||
using Common.Search.FuzzSearch;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Controls;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
@@ -385,11 +384,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
|
||||
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Task.Run(() =>
|
||||
{
|
||||
SearchIndexService.BuildIndex();
|
||||
})
|
||||
.ContinueWith(_ => { });
|
||||
_ = Task.Run(() => SettingsSearch.Default.BuildIndexAsync());
|
||||
}
|
||||
|
||||
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
|
||||
@@ -427,7 +422,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0);
|
||||
}
|
||||
|
||||
private List<SettingEntry> _lastSearchResults = new();
|
||||
private List<SettingSearchResult> _lastSearchResults = new();
|
||||
private string _lastQueryText = string.Empty;
|
||||
|
||||
private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
|
||||
@@ -470,11 +465,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
|
||||
// Query the index on a background thread to avoid blocking UI
|
||||
List<SettingEntry> results = null;
|
||||
List<SettingSearchResult> results = null;
|
||||
try
|
||||
{
|
||||
// If the token is already canceled before scheduling, the task won't start.
|
||||
results = await Task.Run(() => SearchIndexService.Search(query, token), token);
|
||||
results = (await SettingsSearch.Default.SearchAsync(query, options: null, token)).ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -578,7 +572,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
|
||||
// Centralized suggestion projection logic used by TextChanged & GotFocus restore.
|
||||
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results)
|
||||
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingSearchResult> results)
|
||||
{
|
||||
results ??= new();
|
||||
if (results.Count == 0)
|
||||
@@ -601,16 +595,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
};
|
||||
}
|
||||
|
||||
var list = results.Take(5).Select(e =>
|
||||
var list = results.Take(5).Select(result =>
|
||||
{
|
||||
var entry = result.Entry;
|
||||
string subtitle = string.Empty;
|
||||
if (e.Type != EntryType.SettingsPage)
|
||||
if (entry.Type != EntryType.SettingsPage)
|
||||
{
|
||||
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
|
||||
subtitle = SettingsSearch.Default.GetLocalizedPageName(entry.PageTypeName);
|
||||
if (string.IsNullOrEmpty(subtitle))
|
||||
{
|
||||
subtitle = SearchIndexService.Index
|
||||
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
|
||||
subtitle = SettingsSearch.Default.Index
|
||||
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == entry.PageTypeName)
|
||||
.Select(x => x.Header)
|
||||
.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
@@ -618,12 +613,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
|
||||
return new SuggestionItem
|
||||
{
|
||||
Header = e.Header,
|
||||
Icon = e.Icon,
|
||||
PageTypeName = e.PageTypeName,
|
||||
ElementName = e.ElementName,
|
||||
ParentElementName = e.ParentElementName,
|
||||
Header = entry.Header,
|
||||
Icon = entry.Icon,
|
||||
PageTypeName = entry.PageTypeName,
|
||||
ElementName = entry.ElementName,
|
||||
ParentElementName = entry.ParentElementName,
|
||||
Subtitle = subtitle,
|
||||
Score = result.Score,
|
||||
MatchKind = result.MatchKind,
|
||||
MatchSpans = result.MatchSpans ?? Array.Empty<MatchSpan>(),
|
||||
IsShowAll = false,
|
||||
};
|
||||
}).ToList();
|
||||
@@ -655,7 +653,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
// Prefer cached results (from live search); if empty, perform a fresh search
|
||||
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
|
||||
? _lastSearchResults
|
||||
: await Task.Run(() => SearchIndexService.Search(queryText));
|
||||
: (await SettingsSearch.Default.SearchAsync(queryText, options: null)).ToList();
|
||||
|
||||
var searchParams = new SearchResultsNavigationParams(queryText, matched);
|
||||
NavigationService.Navigate<SearchResultsPage>(searchParams);
|
||||
|
||||
@@ -14,11 +14,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
public class SearchResultsViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private ObservableCollection<SettingEntry> _moduleResults = new();
|
||||
private ObservableCollection<SettingSearchResult> _moduleResults = new();
|
||||
private ObservableCollection<SettingsGroup> _groupedSettingsResults = new();
|
||||
private bool _hasNoResults;
|
||||
|
||||
public ObservableCollection<SettingEntry> ModuleResults
|
||||
public ObservableCollection<SettingSearchResult> ModuleResults
|
||||
{
|
||||
get => _moduleResults;
|
||||
set
|
||||
@@ -48,7 +48,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSearchResults(string query, List<SettingEntry> results)
|
||||
public void SetSearchResults(string query, List<SettingSearchResult> results)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
{
|
||||
@@ -61,8 +61,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
HasNoResults = false;
|
||||
|
||||
// Separate modules and settings
|
||||
var modules = results.Where(r => r.Type == EntryType.SettingsPage).ToList();
|
||||
var settings = results.Where(r => r.Type == EntryType.SettingsCard).ToList();
|
||||
var modules = results.Where(r => r.Entry.Type == EntryType.SettingsPage).ToList();
|
||||
var settings = results.Where(r => r.Entry.Type == EntryType.SettingsCard).ToList();
|
||||
|
||||
// Update module results
|
||||
ModuleResults.Clear();
|
||||
@@ -73,11 +73,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
// Group settings by their page/module
|
||||
var groupedSettings = settings
|
||||
.GroupBy(s => SearchIndexService.GetLocalizedPageName(s.PageTypeName))
|
||||
.GroupBy(s => SettingsSearch.Default.GetLocalizedPageName(s.Entry.PageTypeName))
|
||||
.Select(g => new SettingsGroup
|
||||
{
|
||||
GroupName = g.Key,
|
||||
Settings = new ObservableCollection<SettingEntry>(g),
|
||||
Settings = new ObservableCollection<SettingSearchResult>(g),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -101,7 +101,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
#pragma warning restore SA1402 // File may only contain a single type
|
||||
{
|
||||
private string _groupName;
|
||||
private ObservableCollection<SettingEntry> _settings;
|
||||
private ObservableCollection<SettingSearchResult> _settings;
|
||||
|
||||
public string GroupName
|
||||
{
|
||||
@@ -113,7 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<SettingEntry> Settings
|
||||
public ObservableCollection<SettingSearchResult> Settings
|
||||
{
|
||||
get => _settings;
|
||||
set
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Common.Search;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
public sealed partial class SuggestionItem
|
||||
@@ -18,6 +21,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public string Subtitle { get; init; }
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public SearchMatchKind? MatchKind { get; init; }
|
||||
|
||||
public IReadOnlyList<MatchSpan> MatchSpans { get; init; }
|
||||
|
||||
public bool IsShowAll { get; init; }
|
||||
|
||||
public bool IsNoResults { get; init; }
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="PowerToys.Settings.app"/>
|
||||
|
||||
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
|
||||
packageName="Microsoft.PowerToys.SparseApp"
|
||||
applicationId="PowerToys.SettingsUI"
|
||||
publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" />
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<!-- The combination of below two tags have the following effect:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.IO;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace SettingsSearchEvaluation.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class EvaluationDataLoaderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void LoadEntriesFromJson_NormalizesHeaderAndDetectsDuplicates()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": null,
|
||||
"pageTypeName": "ColorPickerPage",
|
||||
"elementName": "",
|
||||
"elementUid": "Activation_Shortcut",
|
||||
"parentElementName": "",
|
||||
"description": null,
|
||||
"icon": null
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"header": null,
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "Activation_Shortcut",
|
||||
"parentElementName": "",
|
||||
"description": null,
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
|
||||
Assert.AreEqual(2, entries.Count);
|
||||
Assert.AreEqual("Activation Shortcut", entries[0].Header);
|
||||
Assert.AreEqual(1, diagnostics.DuplicateIdBucketCount);
|
||||
Assert.IsTrue(diagnostics.DuplicateIdCounts.TryGetValue("Activation_Shortcut", out var count));
|
||||
Assert.AreEqual(2, count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadCases_GeneratesFallbackCases_WhenNoCasesFileSpecified()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
var cases = EvaluationDataLoader.LoadCases(null, entries);
|
||||
|
||||
Assert.AreEqual(1, cases.Count);
|
||||
Assert.AreEqual("Fancy Zones", cases[0].Query);
|
||||
Assert.AreEqual("FancyZones", cases[0].ExpectedIds[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadCases_LoadsAndNormalizesCasesFile()
|
||||
{
|
||||
const string entriesJson = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
const string casesJson = """
|
||||
[
|
||||
{
|
||||
"query": " fancy zones ",
|
||||
"expectedIds": [ "FancyZones", " fancyzones ", "" ],
|
||||
"notes": "normalization test"
|
||||
},
|
||||
{
|
||||
"query": "",
|
||||
"expectedIds": [ "FancyZones" ]
|
||||
},
|
||||
{
|
||||
"query": "missing expected",
|
||||
"expectedIds": [ "" ]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(entriesJson);
|
||||
var casesFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(casesFile, casesJson);
|
||||
var cases = EvaluationDataLoader.LoadCases(casesFile, entries);
|
||||
|
||||
Assert.AreEqual(1, cases.Count);
|
||||
Assert.AreEqual("fancy zones", cases[0].Query);
|
||||
Assert.AreEqual(1, cases[0].ExpectedIds.Count);
|
||||
Assert.AreEqual("FancyZones", cases[0].ExpectedIds[0]);
|
||||
Assert.AreEqual("normalization test", cases[0].Notes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(casesFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
tools/SettingsSearchEvaluation.Tests/EvaluationMathTests.cs
Normal file
50
tools/SettingsSearchEvaluation.Tests/EvaluationMathTests.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace SettingsSearchEvaluation.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class EvaluationMathTests
|
||||
{
|
||||
private static readonly double[] LatencySamples = { 10.0, 20.0, 30.0, 40.0, 50.0 };
|
||||
|
||||
[TestMethod]
|
||||
public void FindBestRank_ReturnsExpectedRank()
|
||||
{
|
||||
var ranked = new[] { "a", "b", "c", "d" };
|
||||
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "c" };
|
||||
|
||||
var rank = EvaluationMath.FindBestRank(ranked, expected);
|
||||
|
||||
Assert.AreEqual(3, rank);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindBestRank_ReturnsZero_WhenMissing()
|
||||
{
|
||||
var ranked = new[] { "a", "b", "c", "d" };
|
||||
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "x", "y" };
|
||||
|
||||
var rank = EvaluationMath.FindBestRank(ranked, expected);
|
||||
|
||||
Assert.AreEqual(0, rank);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ComputeLatencySummary_ComputesQuantiles()
|
||||
{
|
||||
var summary = EvaluationMath.ComputeLatencySummary(LatencySamples);
|
||||
|
||||
Assert.AreEqual(5, summary.Samples);
|
||||
Assert.AreEqual(10.0, summary.MinMs);
|
||||
Assert.AreEqual(30.0, summary.P50Ms);
|
||||
Assert.AreEqual(50.0, summary.P95Ms);
|
||||
Assert.AreEqual(50.0, summary.MaxMs);
|
||||
Assert.AreEqual(30.0, summary.AverageMs, 0.0001);
|
||||
}
|
||||
}
|
||||
69
tools/SettingsSearchEvaluation.Tests/EvaluatorTests.cs
Normal file
69
tools/SettingsSearchEvaluation.Tests/EvaluatorTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace SettingsSearchEvaluation.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class EvaluatorTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task RunAsync_BasicEngine_ReturnsExpectedMetricsForExactSingleEntry()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
var cases = new[]
|
||||
{
|
||||
new EvaluationCase
|
||||
{
|
||||
Query = "Fancy Zones",
|
||||
ExpectedIds = new[] { "FancyZones" },
|
||||
Notes = "Exact query should be rank 1.",
|
||||
},
|
||||
};
|
||||
|
||||
var options = new RunnerOptions
|
||||
{
|
||||
IndexJsonPath = "test-index.json",
|
||||
CasesJsonPath = null,
|
||||
Engines = new[] { SearchEngineKind.Basic },
|
||||
MaxResults = 5,
|
||||
TopK = 5,
|
||||
Iterations = 1,
|
||||
WarmupIterations = 0,
|
||||
SemanticIndexTimeout = TimeSpan.FromSeconds(1),
|
||||
OutputJsonPath = null,
|
||||
};
|
||||
|
||||
var report = await Evaluator.RunAsync(options, entries, diagnostics, cases);
|
||||
|
||||
Assert.AreEqual(1, report.Engines.Count);
|
||||
var engine = report.Engines[0];
|
||||
Assert.AreEqual(SearchEngineKind.Basic, engine.Engine);
|
||||
Assert.IsTrue(engine.IsAvailable);
|
||||
Assert.AreEqual(1, engine.QueryCount);
|
||||
Assert.AreEqual(1.0, engine.RecallAtK, 0.0001);
|
||||
Assert.AreEqual(1.0, engine.Mrr, 0.0001);
|
||||
Assert.AreEqual(1, engine.CaseResults.Count);
|
||||
Assert.IsTrue(engine.CaseResults[0].HitAtK);
|
||||
Assert.AreEqual(1, engine.CaseResults[0].BestRank);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputPath>..\..\$(Configuration)\$(Platform)\tests\SettingsSearchEvaluationTests\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SettingsSearchEvaluation\SettingsSearchEvaluation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
26
tools/SettingsSearchEvaluation/DatasetDiagnostics.cs
Normal file
26
tools/SettingsSearchEvaluation/DatasetDiagnostics.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class DatasetDiagnostics
|
||||
{
|
||||
public required int TotalEntries { get; init; }
|
||||
|
||||
public required int DistinctIds { get; init; }
|
||||
|
||||
public required int DuplicateIdBucketCount { get; init; }
|
||||
|
||||
public required IReadOnlyDictionary<string, int> DuplicateIdCounts { get; init; }
|
||||
|
||||
public static DatasetDiagnostics Empty { get; } = new()
|
||||
{
|
||||
TotalEntries = 0,
|
||||
DistinctIds = 0,
|
||||
DuplicateIdBucketCount = 0,
|
||||
DuplicateIdCounts = new ReadOnlyDictionary<string, int>(new Dictionary<string, int>()),
|
||||
};
|
||||
}
|
||||
30
tools/SettingsSearchEvaluation/EngineEvaluationReport.cs
Normal file
30
tools/SettingsSearchEvaluation/EngineEvaluationReport.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class EngineEvaluationReport
|
||||
{
|
||||
public required SearchEngineKind Engine { get; init; }
|
||||
|
||||
public required bool IsAvailable { get; init; }
|
||||
|
||||
public string? AvailabilityError { get; init; }
|
||||
|
||||
public string? CapabilitiesSummary { get; init; }
|
||||
|
||||
public int IndexedEntries { get; init; }
|
||||
|
||||
public int QueryCount { get; init; }
|
||||
|
||||
public double IndexingTimeMs { get; init; }
|
||||
|
||||
public double RecallAtK { get; init; }
|
||||
|
||||
public double Mrr { get; init; }
|
||||
|
||||
public required LatencySummary SearchLatencyMs { get; init; }
|
||||
|
||||
public required IReadOnlyList<QueryEvaluationResult> CaseResults { get; init; }
|
||||
}
|
||||
14
tools/SettingsSearchEvaluation/EvaluationCase.cs
Normal file
14
tools/SettingsSearchEvaluation/EvaluationCase.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class EvaluationCase
|
||||
{
|
||||
public required string Query { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> ExpectedIds { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
180
tools/SettingsSearchEvaluation/EvaluationDataLoader.cs
Normal file
180
tools/SettingsSearchEvaluation/EvaluationDataLoader.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static partial class EvaluationDataLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromFile(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
var json = File.ReadAllText(path);
|
||||
return LoadEntriesFromJson(json);
|
||||
}
|
||||
|
||||
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return (Array.Empty<SettingEntry>(), DatasetDiagnostics.Empty);
|
||||
}
|
||||
|
||||
var rawEntries = JsonSerializer.Deserialize<List<RawSettingEntry>>(json, JsonOptions) ?? new List<RawSettingEntry>();
|
||||
var normalized = new List<SettingEntry>(rawEntries.Count);
|
||||
|
||||
foreach (var raw in rawEntries)
|
||||
{
|
||||
var pageType = raw.PageTypeName?.Trim() ?? string.Empty;
|
||||
var elementName = raw.ElementName?.Trim() ?? string.Empty;
|
||||
var elementUid = raw.ElementUid?.Trim() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(elementUid))
|
||||
{
|
||||
elementUid = $"{pageType}|{elementName}";
|
||||
}
|
||||
|
||||
var header = raw.Header?.Trim();
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
header = BuildFallbackHeader(elementUid, elementName, pageType);
|
||||
}
|
||||
|
||||
var description = raw.Description?.Trim() ?? string.Empty;
|
||||
var parent = raw.ParentElementName?.Trim() ?? string.Empty;
|
||||
var icon = raw.Icon?.Trim() ?? string.Empty;
|
||||
|
||||
normalized.Add(new SettingEntry(
|
||||
raw.Type,
|
||||
header,
|
||||
pageType,
|
||||
elementName,
|
||||
elementUid,
|
||||
parent,
|
||||
description,
|
||||
icon));
|
||||
}
|
||||
|
||||
return (normalized, BuildDiagnostics(normalized));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<EvaluationCase> LoadCases(string? casesPath, IReadOnlyList<SettingEntry> entries)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(casesPath))
|
||||
{
|
||||
var json = File.ReadAllText(casesPath);
|
||||
var parsed = JsonSerializer.Deserialize<List<RawEvaluationCase>>(json, JsonOptions) ?? new List<RawEvaluationCase>();
|
||||
var normalized = parsed
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Query))
|
||||
.Select(c => new EvaluationCase
|
||||
{
|
||||
Query = c.Query!.Trim(),
|
||||
ExpectedIds = c.ExpectedIds?
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>(),
|
||||
Notes = c.Notes,
|
||||
})
|
||||
.Where(c => c.ExpectedIds.Count > 0)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count > 0)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return GenerateFallbackCases(entries);
|
||||
}
|
||||
|
||||
private static DatasetDiagnostics BuildDiagnostics(IReadOnlyList<SettingEntry> entries)
|
||||
{
|
||||
var duplicateBuckets = entries
|
||||
.GroupBy(x => x.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(group => group.Count() > 1)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new DatasetDiagnostics
|
||||
{
|
||||
TotalEntries = entries.Count,
|
||||
DistinctIds = entries.Select(x => x.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
DuplicateIdBucketCount = duplicateBuckets.Count,
|
||||
DuplicateIdCounts = new ReadOnlyDictionary<string, int>(duplicateBuckets),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EvaluationCase> GenerateFallbackCases(IReadOnlyList<SettingEntry> entries)
|
||||
{
|
||||
return entries
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.Header) && !string.IsNullOrWhiteSpace(entry.Id))
|
||||
.GroupBy(entry => entry.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.Take(40)
|
||||
.Select(entry => new EvaluationCase
|
||||
{
|
||||
Query = entry.Header,
|
||||
ExpectedIds = new[] { entry.Id },
|
||||
Notes = "Autogenerated case from index entry header.",
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildFallbackHeader(string elementUid, string elementName, string pageTypeName)
|
||||
{
|
||||
var candidate = !string.IsNullOrWhiteSpace(elementUid)
|
||||
? elementUid
|
||||
: (!string.IsNullOrWhiteSpace(elementName) ? elementName : pageTypeName);
|
||||
|
||||
candidate = candidate.Replace('_', ' ').Trim();
|
||||
candidate = ConsecutiveWhitespaceRegex().Replace(candidate, " ");
|
||||
candidate = CamelBoundaryRegex().Replace(candidate, "$1 $2");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex ConsecutiveWhitespaceRegex();
|
||||
|
||||
[GeneratedRegex("([a-z0-9])([A-Z])")]
|
||||
private static partial Regex CamelBoundaryRegex();
|
||||
|
||||
private sealed class RawSettingEntry
|
||||
{
|
||||
public EntryType Type { get; init; }
|
||||
|
||||
public string? Header { get; init; }
|
||||
|
||||
public string? PageTypeName { get; init; }
|
||||
|
||||
public string? ElementName { get; init; }
|
||||
|
||||
public string? ElementUid { get; init; }
|
||||
|
||||
public string? ParentElementName { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? Icon { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RawEvaluationCase
|
||||
{
|
||||
public string? Query { get; init; }
|
||||
|
||||
public List<string>? ExpectedIds { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
}
|
||||
65
tools/SettingsSearchEvaluation/EvaluationMath.cs
Normal file
65
tools/SettingsSearchEvaluation/EvaluationMath.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static class EvaluationMath
|
||||
{
|
||||
public static int FindBestRank(IReadOnlyList<string> rankedResultIds, IReadOnlySet<string> expectedIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rankedResultIds);
|
||||
ArgumentNullException.ThrowIfNull(expectedIds);
|
||||
|
||||
if (expectedIds.Count == 0 || rankedResultIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int index = 0; index < rankedResultIds.Count; index++)
|
||||
{
|
||||
if (expectedIds.Contains(rankedResultIds[index]))
|
||||
{
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static LatencySummary ComputeLatencySummary(IReadOnlyList<double> samplesMs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(samplesMs);
|
||||
|
||||
if (samplesMs.Count == 0)
|
||||
{
|
||||
return LatencySummary.Empty;
|
||||
}
|
||||
|
||||
var sorted = samplesMs.OrderBy(x => x).ToArray();
|
||||
var total = samplesMs.Sum();
|
||||
|
||||
return new LatencySummary
|
||||
{
|
||||
Samples = sorted.Length,
|
||||
MinMs = sorted[0],
|
||||
P50Ms = Percentile(sorted, 0.50),
|
||||
P95Ms = Percentile(sorted, 0.95),
|
||||
MaxMs = sorted[^1],
|
||||
AverageMs = total / sorted.Length,
|
||||
};
|
||||
}
|
||||
|
||||
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
|
||||
{
|
||||
if (sortedSamples.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(percentile, 0, 1);
|
||||
var rank = (int)Math.Ceiling(clamped * sortedSamples.Count) - 1;
|
||||
rank = Math.Clamp(rank, 0, sortedSamples.Count - 1);
|
||||
return sortedSamples[rank];
|
||||
}
|
||||
}
|
||||
18
tools/SettingsSearchEvaluation/EvaluationReport.cs
Normal file
18
tools/SettingsSearchEvaluation/EvaluationReport.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class EvaluationReport
|
||||
{
|
||||
public required DateTimeOffset GeneratedAtUtc { get; init; }
|
||||
|
||||
public required string IndexJsonPath { get; init; }
|
||||
|
||||
public required DatasetDiagnostics Dataset { get; init; }
|
||||
|
||||
public required int CaseCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<EngineEvaluationReport> Engines { get; init; }
|
||||
}
|
||||
292
tools/SettingsSearchEvaluation/Evaluator.cs
Normal file
292
tools/SettingsSearchEvaluation/Evaluator.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Common.Search;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Common.Search.SemanticSearch;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static class Evaluator
|
||||
{
|
||||
public static async Task<EvaluationReport> RunAsync(
|
||||
RunnerOptions options,
|
||||
IReadOnlyList<SettingEntry> entries,
|
||||
DatasetDiagnostics dataset,
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(dataset);
|
||||
ArgumentNullException.ThrowIfNull(cases);
|
||||
|
||||
var reports = new List<EngineEvaluationReport>(options.Engines.Count);
|
||||
foreach (var engine in options.Engines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
reports.Add(engine switch
|
||||
{
|
||||
SearchEngineKind.Basic => await EvaluateBasicAsync(options, entries, cases, cancellationToken),
|
||||
SearchEngineKind.Semantic => await EvaluateSemanticAsync(options, entries, cases, cancellationToken),
|
||||
_ => throw new InvalidOperationException($"Unsupported engine '{engine}'."),
|
||||
});
|
||||
}
|
||||
|
||||
return new EvaluationReport
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
IndexJsonPath = options.IndexJsonPath,
|
||||
Dataset = dataset,
|
||||
CaseCount = cases.Count,
|
||||
Engines = reports,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<EngineEvaluationReport> EvaluateBasicAsync(
|
||||
RunnerOptions options,
|
||||
IReadOnlyList<SettingEntry> entries,
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var engine = new FuzzSearchEngine<SettingEntry>();
|
||||
|
||||
var indexingStopwatch = Stopwatch.StartNew();
|
||||
await engine.InitializeAsync(cancellationToken);
|
||||
await engine.IndexBatchAsync(entries, cancellationToken);
|
||||
indexingStopwatch.Stop();
|
||||
|
||||
var metrics = await EvaluateQueryLoopAsync(
|
||||
cases,
|
||||
options,
|
||||
(query, searchOptions, token) => engine.SearchAsync(query, searchOptions, token),
|
||||
cancellationToken);
|
||||
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Basic,
|
||||
IsAvailable = true,
|
||||
AvailabilityError = null,
|
||||
CapabilitiesSummary = "Fuzzy text search engine",
|
||||
IndexedEntries = entries.Count,
|
||||
QueryCount = cases.Count,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = metrics.RecallAtK,
|
||||
Mrr = metrics.Mrr,
|
||||
SearchLatencyMs = metrics.Latency,
|
||||
CaseResults = metrics.CaseResults,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<EngineEvaluationReport> EvaluateSemanticAsync(
|
||||
RunnerOptions options,
|
||||
IReadOnlyList<SettingEntry> entries,
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var indexName = $"PowerToys.Settings.Eval.{Environment.ProcessId}.{Guid.NewGuid():N}";
|
||||
using var engine = new SemanticSearchEngine<SettingEntry>(indexName);
|
||||
|
||||
var initResult = await engine.InitializeWithResultAsync(cancellationToken);
|
||||
if (initResult.IsFailure || !engine.IsReady)
|
||||
{
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = false,
|
||||
AvailabilityError = FormatError(initResult.Error) ?? "Semantic engine is not ready.",
|
||||
CapabilitiesSummary = null,
|
||||
IndexedEntries = 0,
|
||||
QueryCount = 0,
|
||||
IndexingTimeMs = 0,
|
||||
RecallAtK = 0,
|
||||
Mrr = 0,
|
||||
SearchLatencyMs = LatencySummary.Empty,
|
||||
CaseResults = Array.Empty<QueryEvaluationResult>(),
|
||||
};
|
||||
}
|
||||
|
||||
var indexingStopwatch = Stopwatch.StartNew();
|
||||
var indexResult = await engine.IndexBatchWithResultAsync(entries, cancellationToken);
|
||||
if (indexResult.IsFailure)
|
||||
{
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = false,
|
||||
AvailabilityError = FormatError(indexResult.Error) ?? "Semantic indexing failed.",
|
||||
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
|
||||
IndexedEntries = 0,
|
||||
QueryCount = 0,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = 0,
|
||||
Mrr = 0,
|
||||
SearchLatencyMs = LatencySummary.Empty,
|
||||
CaseResults = Array.Empty<QueryEvaluationResult>(),
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await engine.WaitForIndexingCompleteAsync(options.SemanticIndexTimeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = false,
|
||||
AvailabilityError = $"Semantic indexing did not become idle: {ex.Message}",
|
||||
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
|
||||
IndexedEntries = 0,
|
||||
QueryCount = 0,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = 0,
|
||||
Mrr = 0,
|
||||
SearchLatencyMs = LatencySummary.Empty,
|
||||
CaseResults = Array.Empty<QueryEvaluationResult>(),
|
||||
};
|
||||
}
|
||||
|
||||
indexingStopwatch.Stop();
|
||||
var metrics = await EvaluateQueryLoopAsync(
|
||||
cases,
|
||||
options,
|
||||
async (query, searchOptions, token) =>
|
||||
{
|
||||
var result = await engine.SearchWithResultAsync(query, searchOptions, token);
|
||||
return result.Value ?? Array.Empty<SearchResult<SettingEntry>>();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = true,
|
||||
AvailabilityError = null,
|
||||
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
|
||||
IndexedEntries = entries.Count,
|
||||
QueryCount = cases.Count,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = metrics.RecallAtK,
|
||||
Mrr = metrics.Mrr,
|
||||
SearchLatencyMs = metrics.Latency,
|
||||
CaseResults = metrics.CaseResults,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<QueryRunMetrics> EvaluateQueryLoopAsync(
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
RunnerOptions options,
|
||||
Func<string, SearchOptions, CancellationToken, Task<IReadOnlyList<SearchResult<SettingEntry>>>> searchAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var caseResults = new List<QueryEvaluationResult>(cases.Count);
|
||||
var latencySamples = new List<double>(Math.Max(1, cases.Count * options.Iterations));
|
||||
|
||||
var hits = 0;
|
||||
var reciprocalRankSum = 0.0;
|
||||
var searchOptions = new SearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
IncludeMatchSpans = false,
|
||||
};
|
||||
|
||||
foreach (var queryCase in cases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
for (int warmup = 0; warmup < options.WarmupIterations; warmup++)
|
||||
{
|
||||
_ = await searchAsync(queryCase.Query, searchOptions, cancellationToken);
|
||||
}
|
||||
|
||||
IReadOnlyList<SearchResult<SettingEntry>> firstMeasuredResult = Array.Empty<SearchResult<SettingEntry>>();
|
||||
for (int iteration = 0; iteration < options.Iterations; iteration++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var queryResult = await searchAsync(queryCase.Query, searchOptions, cancellationToken);
|
||||
sw.Stop();
|
||||
latencySamples.Add(sw.Elapsed.TotalMilliseconds);
|
||||
|
||||
if (iteration == 0)
|
||||
{
|
||||
firstMeasuredResult = queryResult;
|
||||
}
|
||||
}
|
||||
|
||||
var rankedIds = firstMeasuredResult.Select(result => result.Item.Id).ToArray();
|
||||
var expected = new HashSet<string>(queryCase.ExpectedIds, StringComparer.OrdinalIgnoreCase);
|
||||
var bestRank = EvaluationMath.FindBestRank(rankedIds, expected);
|
||||
var hit = bestRank > 0 && bestRank <= options.TopK;
|
||||
|
||||
if (hit)
|
||||
{
|
||||
hits++;
|
||||
}
|
||||
|
||||
if (bestRank > 0)
|
||||
{
|
||||
reciprocalRankSum += 1.0 / bestRank;
|
||||
}
|
||||
|
||||
caseResults.Add(new QueryEvaluationResult
|
||||
{
|
||||
Query = queryCase.Query,
|
||||
ExpectedIds = queryCase.ExpectedIds,
|
||||
TopResultIds = rankedIds.Take(options.TopK).ToArray(),
|
||||
BestRank = bestRank,
|
||||
HitAtK = hit,
|
||||
Notes = queryCase.Notes,
|
||||
});
|
||||
}
|
||||
|
||||
var totalCases = Math.Max(1, cases.Count);
|
||||
return new QueryRunMetrics
|
||||
{
|
||||
CaseResults = caseResults,
|
||||
RecallAtK = hits / (double)totalCases,
|
||||
Mrr = reciprocalRankSum / totalCases,
|
||||
Latency = EvaluationMath.ComputeLatencySummary(latencySamples),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatError(SearchError? error)
|
||||
{
|
||||
if (error == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error.Details))
|
||||
{
|
||||
return $"{error.Message} ({error.Details})";
|
||||
}
|
||||
|
||||
return error.Message;
|
||||
}
|
||||
|
||||
private static string BuildCapabilitiesSummary(SemanticSearchCapabilities? capabilities)
|
||||
{
|
||||
if (capabilities == null)
|
||||
{
|
||||
return "Capabilities unavailable";
|
||||
}
|
||||
|
||||
return $"TextLexical={capabilities.TextLexicalAvailable}, TextSemantic={capabilities.TextSemanticAvailable}, ImageSemantic={capabilities.ImageSemanticAvailable}, ImageOcr={capabilities.ImageOcrAvailable}";
|
||||
}
|
||||
|
||||
private sealed class QueryRunMetrics
|
||||
{
|
||||
public required IReadOnlyList<QueryEvaluationResult> CaseResults { get; init; }
|
||||
|
||||
public required double RecallAtK { get; init; }
|
||||
|
||||
public required double Mrr { get; init; }
|
||||
|
||||
public required LatencySummary Latency { get; init; }
|
||||
}
|
||||
}
|
||||
22
tools/SettingsSearchEvaluation/LatencySummary.cs
Normal file
22
tools/SettingsSearchEvaluation/LatencySummary.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class LatencySummary
|
||||
{
|
||||
public int Samples { get; init; }
|
||||
|
||||
public double MinMs { get; init; }
|
||||
|
||||
public double P50Ms { get; init; }
|
||||
|
||||
public double P95Ms { get; init; }
|
||||
|
||||
public double MaxMs { get; init; }
|
||||
|
||||
public double AverageMs { get; init; }
|
||||
|
||||
public static LatencySummary Empty { get; } = new();
|
||||
}
|
||||
361
tools/SettingsSearchEvaluation/Program.cs
Normal file
361
tools/SettingsSearchEvaluation/Program.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly JsonSerializerOptions OutputJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return MainAsync(args).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Unhandled error: {ex.Message}");
|
||||
return 99;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> MainAsync(string[] args)
|
||||
{
|
||||
if (args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!TryParseArgs(args, out var options, out var parseError))
|
||||
{
|
||||
Console.Error.WriteLine(parseError);
|
||||
Console.Error.WriteLine();
|
||||
PrintUsage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!File.Exists(options.IndexJsonPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Index file not found: {options.IndexJsonPath}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.CasesJsonPath) && !File.Exists(options.CasesJsonPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Cases file not found: {options.CasesJsonPath}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
var (entries, dataset) = EvaluationDataLoader.LoadEntriesFromFile(options.IndexJsonPath);
|
||||
var cases = EvaluationDataLoader.LoadCases(options.CasesJsonPath, entries);
|
||||
if (cases.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("No valid evaluation cases were found.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded {entries.Count} entries from '{options.IndexJsonPath}'.");
|
||||
Console.WriteLine($"Cases: {cases.Count}");
|
||||
Console.WriteLine($"Duplicate id buckets: {dataset.DuplicateIdBucketCount}");
|
||||
if (dataset.DuplicateIdBucketCount > 0)
|
||||
{
|
||||
var largest = dataset.DuplicateIdCounts
|
||||
.OrderByDescending(x => x.Value)
|
||||
.Take(5)
|
||||
.Select(x => $"{x.Key} x{x.Value}");
|
||||
Console.WriteLine($"Top duplicate ids: {string.Join(", ", largest)}");
|
||||
}
|
||||
|
||||
var report = await Evaluator.RunAsync(options, entries, dataset, cases);
|
||||
PrintSummary(report, options.TopK);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.OutputJsonPath))
|
||||
{
|
||||
var outputDirectory = Path.GetDirectoryName(options.OutputJsonPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(report, OutputJsonOptions);
|
||||
File.WriteAllText(options.OutputJsonPath, json);
|
||||
Console.WriteLine($"Wrote report to '{options.OutputJsonPath}'.");
|
||||
}
|
||||
|
||||
return report.Engines.Any(engine => engine.IsAvailable) ? 0 : 4;
|
||||
}
|
||||
|
||||
private static bool TryParseArgs(string[] args, out RunnerOptions options, out string error)
|
||||
{
|
||||
string defaultIndex = GetDefaultIndexPath();
|
||||
string? indexPath = null;
|
||||
string? casesPath = null;
|
||||
string? outputPath = null;
|
||||
var maxResults = 10;
|
||||
var topK = 5;
|
||||
var iterations = 5;
|
||||
var warmup = 1;
|
||||
var semanticTimeoutMs = 15000;
|
||||
IReadOnlyList<SearchEngineKind> engines = new[] { SearchEngineKind.Basic, SearchEngineKind.Semantic };
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
switch (arg.ToLowerInvariant())
|
||||
{
|
||||
case "--index-json":
|
||||
if (!TryReadValue(args, ref i, out indexPath))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --index-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--cases-json":
|
||||
if (!TryReadValue(args, ref i, out casesPath))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --cases-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--output-json":
|
||||
if (!TryReadValue(args, ref i, out outputPath))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --output-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--engine":
|
||||
if (!TryReadValue(args, ref i, out var engineText))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --engine";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseEngines(engineText!, out engines))
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --engine value. Allowed values: basic, semantic, both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--max-results":
|
||||
if (!TryReadInt(args, ref i, out maxResults) || maxResults <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --max-results value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--top-k":
|
||||
if (!TryReadInt(args, ref i, out topK) || topK <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --top-k value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--iterations":
|
||||
if (!TryReadInt(args, ref i, out iterations) || iterations <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --iterations value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--warmup":
|
||||
if (!TryReadInt(args, ref i, out warmup) || warmup < 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --warmup value. Must be a non-negative integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--semantic-timeout-ms":
|
||||
if (!TryReadInt(args, ref i, out semanticTimeoutMs) || semanticTimeoutMs <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --semantic-timeout-ms value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
options = null!;
|
||||
error = $"Unknown argument: {arg}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
options = new RunnerOptions
|
||||
{
|
||||
IndexJsonPath = Path.GetFullPath(indexPath ?? defaultIndex),
|
||||
CasesJsonPath = string.IsNullOrWhiteSpace(casesPath) ? null : Path.GetFullPath(casesPath),
|
||||
Engines = engines,
|
||||
MaxResults = maxResults,
|
||||
TopK = topK,
|
||||
Iterations = iterations,
|
||||
WarmupIterations = warmup,
|
||||
SemanticIndexTimeout = TimeSpan.FromMilliseconds(semanticTimeoutMs),
|
||||
OutputJsonPath = string.IsNullOrWhiteSpace(outputPath) ? null : Path.GetFullPath(outputPath),
|
||||
};
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GetDefaultIndexPath()
|
||||
{
|
||||
var repoRoot = FindRepoRoot(AppContext.BaseDirectory) ?? Environment.CurrentDirectory;
|
||||
return Path.GetFullPath(Path.Combine(repoRoot, "src", "settings-ui", "Settings.UI", "Assets", "Settings", "search.index.json"));
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot(string startingDirectory)
|
||||
{
|
||||
var current = new DirectoryInfo(startingDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
var markerPath = Path.Combine(current.FullName, "PowerToys.slnx");
|
||||
if (File.Exists(markerPath))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseEngines(string value, out IReadOnlyList<SearchEngineKind> engines)
|
||||
{
|
||||
if (string.Equals(value, "both", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
engines = new[] { SearchEngineKind.Basic, SearchEngineKind.Semantic };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "basic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
engines = new[] { SearchEngineKind.Basic };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "semantic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
engines = new[] { SearchEngineKind.Semantic };
|
||||
return true;
|
||||
}
|
||||
|
||||
engines = Array.Empty<SearchEngineKind>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryReadValue(string[] args, ref int index, out string? value)
|
||||
{
|
||||
if (index + 1 >= args.Length)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
index++;
|
||||
value = args[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadInt(string[] args, ref int index, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!TryReadValue(args, ref index, out var text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(text, out value);
|
||||
}
|
||||
|
||||
private static void PrintSummary(EvaluationReport report, int topK)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Evaluation Summary ===");
|
||||
Console.WriteLine($"Generated: {report.GeneratedAtUtc:O}");
|
||||
Console.WriteLine($"Dataset entries: {report.Dataset.TotalEntries} ({report.Dataset.DistinctIds} distinct ids)");
|
||||
Console.WriteLine($"Cases: {report.CaseCount}");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var engine in report.Engines)
|
||||
{
|
||||
Console.WriteLine($"[{engine.Engine}]");
|
||||
if (!engine.IsAvailable)
|
||||
{
|
||||
Console.WriteLine($" Unavailable: {engine.AvailabilityError}");
|
||||
Console.WriteLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($" Capabilities: {engine.CapabilitiesSummary}");
|
||||
Console.WriteLine($" Indexed entries: {engine.IndexedEntries}");
|
||||
Console.WriteLine($" Indexing time (ms): {engine.IndexingTimeMs:F2}");
|
||||
Console.WriteLine($" Recall@{topK}: {engine.RecallAtK:F4}");
|
||||
Console.WriteLine($" MRR: {engine.Mrr:F4}");
|
||||
Console.WriteLine($" Search latency ms (avg/p50/p95/max): {engine.SearchLatencyMs.AverageMs:F2}/{engine.SearchLatencyMs.P50Ms:F2}/{engine.SearchLatencyMs.P95Ms:F2}/{engine.SearchLatencyMs.MaxMs:F2}");
|
||||
|
||||
var misses = engine.CaseResults
|
||||
.Where(result => !result.HitAtK)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
if (misses.Count > 0)
|
||||
{
|
||||
Console.WriteLine(" Sample misses:");
|
||||
foreach (var miss in misses)
|
||||
{
|
||||
var top = miss.TopResultIds.Count == 0 ? "(none)" : string.Join(", ", miss.TopResultIds);
|
||||
Console.WriteLine($" Query='{miss.Query}', expected='{string.Join("|", miss.ExpectedIds)}', top='{top}'");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("SettingsSearchEvaluation");
|
||||
Console.WriteLine("Evaluates basic and semantic settings search for recall and performance.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" SettingsSearchEvaluation [options]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Options:");
|
||||
Console.WriteLine(" --index-json <path> Path to settings search index JSON.");
|
||||
Console.WriteLine(" --cases-json <path> Optional path to evaluation cases JSON.");
|
||||
Console.WriteLine(" --engine <basic|semantic|both> Engine selection. Default: both.");
|
||||
Console.WriteLine(" --max-results <n> Maximum returned results per query. Default: 10.");
|
||||
Console.WriteLine(" --top-k <n> Recall cut-off K. Default: 5.");
|
||||
Console.WriteLine(" --iterations <n> Measured runs per query. Default: 5.");
|
||||
Console.WriteLine(" --warmup <n> Warmup runs per query. Default: 1.");
|
||||
Console.WriteLine(" --semantic-timeout-ms <n> Semantic index idle wait timeout in ms. Default: 15000.");
|
||||
Console.WriteLine(" --output-json <path> Optional output report file.");
|
||||
Console.WriteLine(" --help Show this help.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("SettingsSearchEvaluation.Tests")]
|
||||
20
tools/SettingsSearchEvaluation/QueryEvaluationResult.cs
Normal file
20
tools/SettingsSearchEvaluation/QueryEvaluationResult.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class QueryEvaluationResult
|
||||
{
|
||||
public required string Query { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> ExpectedIds { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> TopResultIds { get; init; }
|
||||
|
||||
public required int BestRank { get; init; }
|
||||
|
||||
public required bool HitAtK { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
51
tools/SettingsSearchEvaluation/README.md
Normal file
51
tools/SettingsSearchEvaluation/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Settings Search Evaluation
|
||||
|
||||
This tool evaluates Settings search quality and latency for:
|
||||
|
||||
- `basic` search (`FuzzSearchEngine`)
|
||||
- `semantic` search (`SemanticSearchEngine`)
|
||||
|
||||
It reports:
|
||||
|
||||
- `Recall@K`
|
||||
- `MRR` (mean reciprocal rank)
|
||||
- Search latency (`avg`, `p50`, `p95`, `max`)
|
||||
- Dataset diagnostics including duplicate `SettingEntry.Id` buckets
|
||||
|
||||
## Run
|
||||
|
||||
Build with Visual Studio `MSBuild.exe` (the project references native components):
|
||||
|
||||
```powershell
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$msbuild = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
|
||||
& $msbuild tools\SettingsSearchEvaluation\SettingsSearchEvaluation.csproj `
|
||||
/t:Build /p:Configuration=Debug /p:Platform=arm64 /m:1 /nologo
|
||||
```
|
||||
|
||||
Run the built executable:
|
||||
|
||||
```powershell
|
||||
.\tools\SettingsSearchEvaluation\bin\arm64\Debug\net9.0-windows10.0.26100.0\SettingsSearchEvaluation.exe `
|
||||
--index-json src/settings-ui/Settings.UI/Assets/Settings/search.index.json `
|
||||
--cases-json tools/SettingsSearchEvaluation/cases/settings-search-cases.sample.json `
|
||||
--engine both `
|
||||
--top-k 5 `
|
||||
--iterations 5 `
|
||||
--warmup 1 `
|
||||
--output-json tools/SettingsSearchEvaluation/artifacts/report.json
|
||||
```
|
||||
|
||||
## Case file format
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"query": "color picker",
|
||||
"expectedIds": ["ColorPicker"],
|
||||
"notes": "Module entry"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
If `--cases-json` is not provided, fallback cases are auto-generated from the index headers.
|
||||
26
tools/SettingsSearchEvaluation/RunnerOptions.cs
Normal file
26
tools/SettingsSearchEvaluation/RunnerOptions.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class RunnerOptions
|
||||
{
|
||||
public required string IndexJsonPath { get; init; }
|
||||
|
||||
public string? CasesJsonPath { get; init; }
|
||||
|
||||
public required IReadOnlyList<SearchEngineKind> Engines { get; init; }
|
||||
|
||||
public int MaxResults { get; init; } = 10;
|
||||
|
||||
public int TopK { get; init; } = 5;
|
||||
|
||||
public int Iterations { get; init; } = 5;
|
||||
|
||||
public int WarmupIterations { get; init; } = 1;
|
||||
|
||||
public TimeSpan SemanticIndexTimeout { get; init; } = TimeSpan.FromSeconds(15);
|
||||
|
||||
public string? OutputJsonPath { get; init; }
|
||||
}
|
||||
11
tools/SettingsSearchEvaluation/SearchEngineKind.cs
Normal file
11
tools/SettingsSearchEvaluation/SearchEngineKind.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal enum SearchEngineKind
|
||||
{
|
||||
Basic,
|
||||
Semantic,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>SettingsSearchEvaluation</RootNamespace>
|
||||
<AssemblyName>SettingsSearchEvaluation</AssemblyName>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);artifacts\**\*;bin\**\*;obj\**\*</DefaultItemExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\common\Common.Search\Common.Search.csproj" />
|
||||
<ProjectReference Include="..\..\src\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
[
|
||||
{
|
||||
"query": "always on top",
|
||||
"expectedIds": [ "AlwaysOnTop" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "awake mode",
|
||||
"expectedIds": [ "Awake_ModeSettingsCard" ],
|
||||
"notes": "Feature setting"
|
||||
},
|
||||
{
|
||||
"query": "color picker",
|
||||
"expectedIds": [ "ColorPicker" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "fancy zones",
|
||||
"expectedIds": [ "FancyZones" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "launch cmd pal",
|
||||
"expectedIds": [ "CmdPal_Launch" ],
|
||||
"notes": "CmdPal launch setting"
|
||||
},
|
||||
{
|
||||
"query": "image resizer",
|
||||
"expectedIds": [ "ImageResizer" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "keyboard manager",
|
||||
"expectedIds": [ "KeyboardManager" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "mouse jump",
|
||||
"expectedIds": [ "MouseUtils_Enable_MouseJump" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "mouse without borders",
|
||||
"expectedIds": [ "MouseWithoutBorders" ],
|
||||
"notes": "Module setting"
|
||||
},
|
||||
{
|
||||
"query": "peek",
|
||||
"expectedIds": [ "Peek" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "power rename",
|
||||
"expectedIds": [ "PowerRename" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "power toys run",
|
||||
"expectedIds": [ "PowerLauncher" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "registry preview",
|
||||
"expectedIds": [ "RegistryPreview" ],
|
||||
"notes": "Module entry"
|
||||
},
|
||||
{
|
||||
"query": "workspaces",
|
||||
"expectedIds": [ "Workspaces_EnableToggleControl_HeaderText" ],
|
||||
"notes": "Module setting"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user