diff --git a/docs/README.md b/docs/README.md index 78be9ec..d22c708 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,6 @@ - **plans/auto_start/** — ユーザーログイン時に自動起動する仕組み。検討事項別に分割(スコープ・技術方針・UI 改造・実装ステップ・注意事項・参照)。 - **plans/folder_monitor/** — 指定フォルダ監視(追加・削除・更新の検出→DB取り込み・ベクトル化) -- **plans/lda/** — LDA(潜在的ディリクレ配分)の扱い。**v0.3.3 Community 版**で実装。LSA と LDA を切り替え、規定 128 次元・ユーザー指定で再構成。検討事項別に分割(スコープ・技術・**UI 改造**・実装ステップ・注意事項・参照)。 - **folder_monitor.md** — 計画トップ(目的・目次) - **folder_monitor_01_scope.md** — 01 スコープ - **folder_monitor_02_tech.md** — 02 技術方針(notify・デバウンス・ネットワーク・Config 等) @@ -41,5 +40,6 @@ - **folder_monitor_04_phases.md** — 04 実装ステップ(Phase 1〜5) - **folder_monitor_05_considerations.md** — 05 注意事項・未決定 - **folder_monitor_06_references.md** — 06 参照・調査元 +- **plans/lda/** — LDA(潜在的ディリクレ配分)の扱い。**v0.3.3 Community 版**で実装。LSA と LDA を切り替え、規定 128 次元・ユーザー指定で再構成。検討事項別に分割(スコープ・技術・**UI 改造**・実装ステップ・注意事項・参照)。 運用ルール(Issue 管理・Git 運用など)は `.agent/rules/` にあります。 diff --git a/docs/plans/auto_start/auto_start.md b/docs/plans/auto_start/auto_start.md index a9565d2..76e8e81 100644 --- a/docs/plans/auto_start/auto_start.md +++ b/docs/plans/auto_start/auto_start.md @@ -24,6 +24,6 @@ | 01 | **スコープ** | [auto_start_01_scope.md](auto_start_01_scope.md) | 対応プラットフォーム、トリガー、起動後挙動、設定保存。 | | 02 | **技術方針** | [auto_start_02_tech.md](auto_start_02_tech.md) | tauri-plugin-autostart、API、設定との連携。 | | 03 | **UI 改造** | [auto_start_03_ui.md](auto_start_03_ui.md) | 設定パネルにトグル追加、配置・ラベル、仕様書更新。 | -| 04 | **実装ステップ** | [auto_start_04_phases.md](auto_start_04_phases.md) | Phase 1〜4 の実装順序。 | +| 04 | **実装ステップ** | [auto_start_04_phases.md](auto_start_04_phases.md) | Phase 1〜4 の実装順序。**テスト・検証**(単体・結合・UI/E2E・手動・リリース前)を含む。 | | 05 | **注意事項・未決定** | [auto_start_05_considerations.md](auto_start_05_considerations.md) | トレイ起動、権限、競合。 | | 06 | **参照** | [auto_start_06_references.md](auto_start_06_references.md) | プラグイン・Tauri 公式・06 仕様。 | diff --git a/docs/plans/auto_start/auto_start_01_scope.md b/docs/plans/auto_start/auto_start_01_scope.md index fac2f4d..cd6d42d 100644 --- a/docs/plans/auto_start/auto_start_01_scope.md +++ b/docs/plans/auto_start/auto_start_01_scope.md @@ -9,6 +9,8 @@ | 項目 | 内容 | |------|------| | **対応プラットフォーム** | **Windows**(現行)、**macOS**、**Linux**。Tauri 2 の対応 OS に合わせて自動起動も各 OS で有効にする。 | -| **トリガー** | ユーザーが OS にログイン(サインイン)したとき。 | +| **トリガー** | ユーザーが OS に**ログイン(サインイン)**したとき。ロック画面からの解除やスリープ復帰では発火しない。 | +| **デフォルト** | 設定キーが無い場合(初回・既存ユーザーのアップデート後)は**自動起動はオフ**とする。 | | **起動後の挙動** | 通常起動と同様(ウィンドウまたはトレイで待機)。設定で「自動起動」のオン・オフを切り替え可能とする。 | | **設定の保存** | 自動起動の有無はアプリ設定に永続化し、次回起動時にも反映する。 | +| **対象エディション** | **Community 版・Pro 版の両方**で利用可能とする。エディションによる差異は設けず、同一の設定項目・プラグイン API で扱う。 | diff --git a/docs/plans/auto_start/auto_start_02_tech.md b/docs/plans/auto_start/auto_start_02_tech.md index da1cf21..c5e5f00 100644 --- a/docs/plans/auto_start/auto_start_02_tech.md +++ b/docs/plans/auto_start/auto_start_02_tech.md @@ -13,6 +13,8 @@ - **macOS**: Launch Agent(`~/Library/LaunchAgents/` に plist を配置)。 - **Linux**: XDG Autostart(`~/.config/autostart/*.desktop` に .desktop ファイルを配置)。 +- **起動引数(任意)**: プラグインの `Builder` で `.args([...])` を指定できる。自動起動時に「トレイのみで起動」など振る舞いを変えたい場合は、ここで引数(例: `--minimized`)を渡し、アプリ起動時に解釈する。 + ```mermaid flowchart TB subgraph プラットフォーム別の実体 @@ -26,7 +28,14 @@ U[ユーザー: enable/disable] --> P ``` -## 3.2 API(フロント/バック) +## 3.2 導入の手順(Phase 1 で行うこと) + +- **Cargo.toml**: `tauri-plugin-autostart` を依存に追加。 +- **Rust**: `tauri::Builder` に `.plugin(tauri_plugin_autostart::init(...))` を登録。必要なら `.args([...])` で起動引数を指定。 +- **Tauri 設定**: プラグインが `tauri.conf.json` の `plugins` や capability を要求する場合は、ドキュメントに従い permission を追加。 +- **フロント**: `@tauri-apps/plugin-autostart` の `enable` / `disable` / `isEnabled` を呼ぶ。Tauri コマンド経由でも可。 + +## 3.3 API(フロント/バック) - **enable()**: 自動起動を有効にする。 - **disable()**: 自動起動を無効にする。 @@ -34,12 +43,13 @@ 設定 UI のトグルや起動時の読み込みで上記を呼び出す。設定の永続化(localStorage や Tauri の app 設定)と連携し、「自動起動オン」のときだけプラグインの `enable()` を呼ぶ。 -## 3.3 設定との連携 +## 3.4 設定との連携 - 設定項目「ログイン時に自動起動する」を追加する(現状は [06 UI 仕様](../../specification/06_ui_design_spec.md) 上で非表示のため、本機能実装時に設定パネルに表示する)。 - オンにした場合: 設定を保存し、`tauri-plugin-autostart` の `enable()` を実行。 - オフにした場合: 設定を保存し、`disable()` を実行。 - アプリ起動時: 保存済み設定を読み、`isEnabled()` と一致していなければ `enable()` / `disable()` で同期する(他ツールでレジストリ等をいじった場合のずれを吸収)。 +- **アップデート後**: 再インストールで実行ファイルのパスが変わると、既存の Run キー等は無効になる。次回起動時の「設定読み込み+isEnabled() との同期」で、設定がオンの場合は `enable()` を呼び直し、新しいパスで登録し直す。 ```mermaid stateDiagram-v2 diff --git a/docs/plans/auto_start/auto_start_03_ui.md b/docs/plans/auto_start/auto_start_03_ui.md index 51e4086..31a9680 100644 --- a/docs/plans/auto_start/auto_start_03_ui.md +++ b/docs/plans/auto_start/auto_start_03_ui.md @@ -14,6 +14,8 @@ | **配置** | 設定パネル内。検索まわり(スコア足切り・取得件数)の上または下に、明確に区切って配置する。 | | **ラベル** | 短く分かりやすい文言(例: 「ログイン時に自動起動する」)。必要なら補足テキスト(「OS にサインインしたときに TelosDB を起動します」)を小さく表示。 | | **見た目** | 既存の [06 コンセプト](../../specification/06_ui_design_spec.md)(ハイコントラスト・ミニマリズム)に合わせる。装飾を抑え、トグルは既存の設定項目とスタイルを統一する。 | +| **トグルの表示値** | 設定パネルを**開いたとき**に、保存済み設定だけでなく **`isEnabled()` の結果**も参照し、実状態に合わせてトグルを表示する。起動時同期のあとなら実状態と保存値は一致している想定だが、他ツールで登録を外した場合などに備える。 | +| **デフォルト** | 設定が無い場合は**オフ**表示([01 スコープ](auto_start_01_scope.md))。 | ## 4.2 画面フロー diff --git a/docs/plans/auto_start/auto_start_04_phases.md b/docs/plans/auto_start/auto_start_04_phases.md index cf4a476..9be7e1c 100644 --- a/docs/plans/auto_start/auto_start_04_phases.md +++ b/docs/plans/auto_start/auto_start_04_phases.md @@ -8,7 +8,21 @@ | 段階 | 内容 | |------|------| -| **Phase 1** | `tauri-plugin-autostart` を導入。Rust でプラグイン登録、フロントまたは Tauri コマンドから `enable` / `disable` / `isEnabled` を呼べるようにする。手動で有効化したときに OS ログイン後に実際に自動起動することを確認。 | +| **Phase 1** | `tauri-plugin-autostart` を導入。[02 技術方針の「導入の手順」](auto_start_02_tech.md#32-導入の手順phase-1-で行うこと)(Cargo.toml、プラグイン登録、capability 必要なら追加)を実施。フロントまたは Tauri コマンドから `enable` / `disable` / `isEnabled` を呼べるようにする。**動作確認**: 有効化後に OS をログアウト→ログイン(または再起動)し、TelosDB が自動起動することを手動で確認する。 | | **Phase 2** | 設定の永続化と連携。「ログイン時自動起動」のオン・オフを保存し、起動時に設定を読み込んでプラグインの状態と同期する。 | | **Phase 3** | **UI 改造**([03 UI 改造](auto_start_03_ui.md))。設定パネルに「ログイン時に自動起動する」トグルを追加し、表示・トグル操作で enable/disable と設定保存を行う。06 仕様の「非表示」を自動起動分だけ「表示」に更新する。 | -| **Phase 4(任意)** | macOS / Linux ビルドで自動起動の動作確認。 | +| **Phase 4(任意)** | macOS / Linux ビルドで自動起動の動作確認。各 OS でもログアウト→ログイン(または再起動)で自動起動することを手動確認する。 | + +**動作確認の注意**: 自動起動の検証は**手動**で行う(ログアウト→ログインまたは再起動)。CI での自動テストは難しいため、リリース前チェックリストに「自動起動オンでログインし直し、アプリが起動することを確認」を入れる。 + +--- + +## テスト・検証 + +| 種別 | 内容 | +|------|------| +| **単体** | Tauri コマンドまたはフロントから `enable()` / `disable()` / `isEnabled()` を呼び、戻り値やレジストリ・LaunchAgents の書き換えが期待どおりか検証する。プラグインをモックに差し替えて「設定保存と API 呼び出しの対応」をテストするのも可。 | +| **結合** | 起動時に保存済み設定を読み、`isEnabled()` と不一致なら `enable`/`disable` で同期する流れをテスト。設定をオフ→オン→オフと変更したときに毎回正しく API が呼ばれるか。 | +| **UI / E2E** | 既存の [E2E(WebdriverIO)](https://github.com/tauri-apps/webdriver-example) や `e2e-tests/` を使い、設定パネルを開いて「ログイン時自動起動」トグルを操作し、表示が変わること・保存されることを検証する([12 UI テスト方針](../../specification/12_ui_testing_options.md) 参照)。 | +| **手動** | 自動起動をオンにし、OS をログアウト→ログイン(または再起動)して TelosDB が起動することを確認。オフにした場合は起動しないことを確認。インストーラでインストールしたバイナリでも同様に確認する。 | +| **リリース前** | 上記に加え、アンインストール後に Run キー(または LaunchAgents / autostart)にエントリが残っていないことを確認するチェックを入れる。 | diff --git a/docs/plans/auto_start/auto_start_05_considerations.md b/docs/plans/auto_start/auto_start_05_considerations.md index 33930f9..8f94459 100644 --- a/docs/plans/auto_start/auto_start_05_considerations.md +++ b/docs/plans/auto_start/auto_start_05_considerations.md @@ -9,3 +9,8 @@ - **トレイ起動**: 自動起動時にウィンドウを表示しない「トレイのみ」で起動するか、通常どおりウィンドウを出すか。仕様で決める。 - **権限・ユーザー操作**: Windows では Run キーはユーザー単位。macOS の Launch Agent もユーザー単位。管理者権限は不要の想定。 - **競合**: 既に別の方法(タスクスケジューラ等)で同じアプリをログイン時に起動している場合、二重起動にならないか確認する。プラグインは一般的な 1 エントリ追加なので、他で同じ実行ファイルを登録していなければ通常は 1 つだけ起動する。 +- **enable/disable の失敗**: 権限不足やプラグイン未初期化などで `enable()` / `disable()` が失敗した場合、トグル操作時にエラーメッセージを表示し、設定と実状態の不整合を防ぐ。 +- **アンインストール時**: アプリ削除時にレジストリ Run や LaunchAgents のエントリが残らないか確認する。NSIS 等のアンインストーラで Run キーを削除する処理が必要な場合、インストーラ設定に含める。 +- **リリース目標**: 本機能をどのバージョン(例: 0.3.3)に含めるか、他計画(フォルダ監視・LDA)との優先度と合わせて決める。 +- **同一マシンで Community と Pro の両方**: 両方インストールし、両方で「自動起動オン」にした場合は、ログイン時に**両方**起動する。通常は片方のみ使う想定だが、挙動として明記しておく。 +- **動作確認**: 自動起動の有無はログアウト→ログインまたは再起動でしか確認できない。テスト手順に「手動でログインし直して起動を確認」を含める([04 実装ステップ](auto_start_04_phases.md))。 diff --git a/docs/plans/auto_start/auto_start_06_references.md b/docs/plans/auto_start/auto_start_06_references.md index 23c784a..237ca5e 100644 --- a/docs/plans/auto_start/auto_start_06_references.md +++ b/docs/plans/auto_start/auto_start_06_references.md @@ -6,6 +6,7 @@ ## 参照 -- [tauri-plugin-autostart](https://github.com/tauri-apps/tauri-plugin-autostart) — 公式プラグイン +- [tauri-plugin-autostart](https://github.com/tauri-apps/tauri-plugin-autostart) — 公式プラグイン(Builder の args / app_name、各 OS の登録先) - [Tauri v2 Autostart プラグイン](https://v2.tauri.app/plugin/autostart/) +- Tauri 2 の **capability / permission**: プラグイン利用に必要な権限を `capabilities` に記載する必要がある場合は、プラグインのドキュメントを確認する。 - 既存仕様: `docs/specification/06_ui_design_spec.md`(設定パネル・ログイン時起動の「非表示」記載) diff --git a/docs/plans/folder_monitor/folder_monitor.md b/docs/plans/folder_monitor/folder_monitor.md index afa7699..2a47b71 100644 --- a/docs/plans/folder_monitor/folder_monitor.md +++ b/docs/plans/folder_monitor/folder_monitor.md @@ -23,6 +23,6 @@ | 01 | **スコープ** | [folder_monitor_01_scope.md](folder_monitor_01_scope.md) | 対応プラットフォーム、監視対象の種類・パス・深さ、対象ファイル、動作タイミング。 | | 02 | **技術方針** | [folder_monitor_02_tech.md](folder_monitor_02_tech.md) | notify、デバウンス、ネットワーク・フォールバック、プラットフォーム別注意点、Config、既存機能連携、設定保存。 | | 03 | **実装方針(OS・プロトコル別)** | [folder_monitor_03_os_protocol.md](folder_monitor_03_os_protocol.md) | OS × ファイルプロトコル別の Watcher 選択、判定、共通処理。 | -| 04 | **実装ステップ** | [folder_monitor_04_phases.md](folder_monitor_04_phases.md) | Phase 1〜5 の実装順序。 | +| 04 | **実装ステップ** | [folder_monitor_04_phases.md](folder_monitor_04_phases.md) | Phase 1〜5 の実装順序。**テスト・検証**(単体・結合・手動・UI/E2E・Phase 別)を含む。 | | 05 | **注意事項・未決定** | [folder_monitor_05_considerations.md](folder_monitor_05_considerations.md) | 権限、パス正規化、ネットワーク制約、大容量フォルダ、UI。 | | 06 | **参照・調査元** | [folder_monitor_06_references.md](folder_monitor_06_references.md) | notify / debouncer / inotify / macOS 等の公式ドキュメント・リンク。 | diff --git a/docs/plans/folder_monitor/folder_monitor_04_phases.md b/docs/plans/folder_monitor/folder_monitor_04_phases.md index 1b46424..338acb8 100644 --- a/docs/plans/folder_monitor/folder_monitor_04_phases.md +++ b/docs/plans/folder_monitor/folder_monitor_04_phases.md @@ -15,3 +15,15 @@ | **Phase 5(任意)** | macOS / Linux ビルドでの動作確認と、ネットワークフォルダ監視の検証。ネイティブ通知が使えない場合のポーリングフォールバック実装。[03 OS・プロトコル別方針](folder_monitor_03_os_protocol.md)のマトリクスに沿った Watcher 切り替えの実装。 | 以降 | **v0.3.3 の範囲**: Windows 上で Phase 1〜3 までを実装・出荷する。ローカルフォルダを RecommendedWatcher(ReadDirectoryChangesW)で監視し、設定 UI と DB 連携まで含める。 + +--- + +## テスト・検証 + +| 種別 | 内容 | +|------|------| +| **単体** | `notify` の `watch` / `unwatch` およびデバウンス(`notify-debouncer-mini`)が、指定ディレクトリ配下の Create/Modify/Remove を期待どおり集約してコールバックに渡すかを、一時ディレクトリとテスト用ファイルで検証する。 | +| **結合** | イベント受信後に DB 連携(追加・更新・削除のハンドラ)が呼ばれることを、テスト用 DB またはインメモリ DB で検証。既存の文書取り込み API をモックにしてもよい。 | +| **手動** | 監視対象フォルダにファイルを追加・編集・削除し、ログまたは UI でイベントが検知されること、および DB に文書が反映される(または削除される)ことを確認する。デバウンス時間内の連続変更が 1 回にまとまることも確認。 | +| **UI / E2E** | 設定パネルで監視フォルダパスを指定し、オン/オフを切り替えて保存できることを、既存 [E2E](https://v2.tauri.app/develop/tests/webdriver/) や [12 UI テスト方針](../../specification/12_ui_testing_options.md) に沿って検証する(実装後にスペック追加)。 | +| **Phase 別** | Phase 1: イベント検知の単体テスト。Phase 2: イベント→DB 連携の結合テスト。Phase 3: 設定 UI の表示・永続化・ワッチャー再起動のテスト。 | diff --git a/docs/plans/lda/lda.md b/docs/plans/lda/lda.md index 58a5782..093a59d 100644 --- a/docs/plans/lda/lda.md +++ b/docs/plans/lda/lda.md @@ -35,6 +35,6 @@ | 01 | **スコープ** | [lda_01_scope.md](lda_01_scope.md) | 対象エディション、役割、LSA/LDA 切り替え、LDA 次元数(規定 128・ユーザー再構成)。 | | 02 | **技術方針** | [lda_02_tech.md](lda_02_tech.md) | LDA の性質、Rust 実装候補、ストレージ(items_lda)。 | | 03 | **UI 改造** | [lda_03_ui.md](lda_03_ui.md) | 設定パネルにベクトル化切り替え・LDA 次元数 K・再構成の追加。 | -| 04 | **実装ステップ** | [lda_04_phases.md](lda_04_phases.md) | Phase 1〜5 の実装順序。v0.3.3 に含める範囲。 | +| 04 | **実装ステップ** | [lda_04_phases.md](lda_04_phases.md) | Phase 1〜5 の実装順序。v0.3.3 に含める範囲。**テスト・検証**(単体・結合・手動・UI/E2E・既存 KPI)を含む。 | | 05 | **注意事項・未決定** | [lda_05_considerations.md](lda_05_considerations.md) | K の範囲、学習コスト、切り替え時再学習、Pro 版方針。 | | 06 | **参照** | [lda_06_references.md](lda_06_references.md) | LDA 論文、Rust クレート、既存コード参照。 | diff --git a/docs/plans/lda/lda_04_phases.md b/docs/plans/lda/lda_04_phases.md index 010e847..cfa5373 100644 --- a/docs/plans/lda/lda_04_phases.md +++ b/docs/plans/lda/lda_04_phases.md @@ -15,3 +15,15 @@ | **Phase 5(任意)** | トピックラベル表示(各トピックの代表語から φ で取得)、文書の主トピック表示。 | 以降 | **v0.3.3 の範囲**: Community 版で Phase 1〜4 までを実装する。LSA と LDA の切り替え、規定 128 次元・ユーザー指定での再構成、およびそれに必要な UI 改造を含める。 + +--- + +## テスト・検証 + +| 種別 | 内容 | +|------|------| +| **単体** | LDA モジュール(学習・θ/φ 取得・クエリの θ 推定・類似度計算)を、既存の LSA テスト(`src/backend/src/utils/lsa.rs` の `test_lsa_variance` 相当)と同様にユニットテストする。固定 BoW で学習し、クエリを投げて類似度の順序や範囲が妥当か検証。 | +| **結合** | `items_lda` への θ 保存と、検索パスで「LDA 選択時」に items_lda を参照してランキングする流れを、テスト用 DB で検証。LSA 切り替え時は従来どおり items_lsa/vec_items が使われることも確認。 | +| **手動** | Community 版で「ベクトル化: LDA」に切り替え、再学習(再構成)後に検索クエリを実行し、結果が返ること・類似度が付与されることを確認。K を変更して再構成したあとも同様に動作することを確認。 | +| **UI / E2E** | 設定パネルで「LSA / LDA」の切り替えと「LDA 次元数 K」「再構成」ボタンが表示・操作できることを、[12 UI テスト方針](../../specification/12_ui_testing_options.md) や既存 E2E に沿って検証する(実装後にスペック追加)。 | +| **既存 KPI との関係** | Community の既存テスト(`test-and-heal`、検索ヒット確認)が LDA 導入後も壊れないことを確認。LSA 選択時は従来どおりのパスを通るため、LSA の KPI はそのまま使える。LDA 選択時用の KPI を追加するかは必要に応じて検討。 | diff --git a/docs/specification/02_architecture_design.md b/docs/specification/02_architecture_design.md index e21daba..ec695eb 100644 --- a/docs/specification/02_architecture_design.md +++ b/docs/specification/02_architecture_design.md @@ -61,4 +61,14 @@ | **Application** | `src/backend`(Rust) | MCP ハンドラ、LSA/埋め込み、DB オーケストレーション、トレイ。 | | **Infrastructure** | SQLite(`telos.db`), vec0, FTS5 | 永続化・ベクトル検索・全文検索。 | +## 5. バックエンド構成(リファクタ後) + +- **MCP**(`src/backend/src/mcp/`) + - **ルーティング**: `create_mcp_app(state)` で Axum Router を組み立て。 + - **状態構築**: `build_app_state(...)` で AppState を生成。トークナイザ・ブロードキャスト・エディション別のモデル(LSA / 埋め込み)を保持。 + - **起動オーケストレーション**: `run_server` が状態構築 → LSA/HNSW 同期完了待ち → listen の順で実行。 + - **ツール一覧**: `tools/registry.rs` の `tool_list()` で MCP ツール定義を一覧管理。ツール呼び出しは `tools::dispatch_tool` でディスパッチ。 +- **検索**: クエリ文字列のベクトル化は `mcp/tools/search.rs` の `get_query_vector(state, text)` に集約。Community は LSA、Pro は埋め込みモデルで 1 箇所で切り替え。 +- **DB アクセス**: `db/mod.rs` に `get_document_count`・`get_item_content_with_doc`・`get_document_id_by_path`・`heal_items_fts` 等のラップ関数を用意。呼び出し側は SQL を直接書かずこれらを利用。 + 詳細な開発時モノリシック化の経緯・KPI は **10_monolithic_dev.md** を参照。 diff --git a/docs/specification/05_development_guide.md b/docs/specification/05_development_guide.md index 65173b3..313f7ca 100644 --- a/docs/specification/05_development_guide.md +++ b/docs/specification/05_development_guide.md @@ -32,10 +32,14 @@ ## 3. バックエンド開発 -- **DB**: sqlite-vec(vec0)でベクトル、FTS5 で全文検索。次元は Community 50 / Pro 768。 -- **MCP**: `src/backend/src/mcp/mod.rs` でルーティング・ツール登録。 -- **LSA**: `lsa.rs` で SVD による 50 次元ベクトル化(Community)。 -- **埋め込み**: Pro は `utils/embedding_pro.rs` 等。ONNX(tract または ort)で 768 次元。詳細は **07_embedding_tract.md**。 +- **DB**(`src/backend/src/db/`): sqlite-vec(vec0)でベクトル、FTS5 で全文検索。次元は Community 50 / Pro 768。 + - 件数・アイテム取得は `get_document_count`・`get_item_content_with_doc`・`get_document_id_by_path` 等のラップ関数を利用。FTS 同期は `heal_items_fts`。 +- **MCP**(`src/backend/src/mcp/`): + - ルーティングは `mod.rs` の `create_mcp_app(state)`。起動は `build_app_state` で状態を組み立てたあと `run_server` で LSA/HNSW 同期 → listen。 + - ツール定義は `tools/registry.rs` の `tool_list()`。新規ツール追加時は registry と `tools::dispatch_tool` の match の両方に追加する。 + - 検索時のクエリベクトルは `tools/search.rs` の `get_query_vector(state, text)` で取得(LSA/埋め込みの切り替えはここで完結)。 +- **LSA**: `utils/lsa.rs` で SVD による 50 次元ベクトル化(Community)。 +- **埋め込み**: Pro は `utils/embedding_pro.rs`。ONNX(tract または ort)で 768 次元。詳細は **07_embedding_tract.md**。 ## 4. フロントエンド開発 diff --git "a/journals/202603-012-\343\203\252\343\203\225\343\202\241\343\202\257\343\202\277\343\203\252\343\203\263\343\202\260\345\256\237\346\226\275\343\201\250\344\273\225\346\247\230\345\217\215\346\230\240\343\203\273\350\250\210\347\224\273\345\211\212\351\231\244.md" "b/journals/202603-012-\343\203\252\343\203\225\343\202\241\343\202\257\343\202\277\343\203\252\343\203\263\343\202\260\345\256\237\346\226\275\343\201\250\344\273\225\346\247\230\345\217\215\346\230\240\343\203\273\350\250\210\347\224\273\345\211\212\351\231\244.md" new file mode 100644 index 0000000..8f2fbc5 --- /dev/null +++ "b/journals/202603-012-\343\203\252\343\203\225\343\202\241\343\202\257\343\202\277\343\203\252\343\203\263\343\202\260\345\256\237\346\226\275\343\201\250\344\273\225\346\247\230\345\217\215\346\230\240\343\203\273\350\250\210\347\224\273\345\211\212\351\231\244.md" @@ -0,0 +1,41 @@ +# 2026年 リファクタリング実施と仕様反映・計画削除 + +## サマリー + +- **リファクタリング実施**: 計画に沿って Phase 1〜5 を実行。MCP 状態構築の分離、doc_count 共通化、ツール一覧の registry 化、DB ラップ、検索クエリベクトル取得の 1 箇所集約を行った。 +- **仕様への反映**: 実施結果を `02_architecture_design.md`(バックエンド構成)と `05_development_guide.md`(バックエンド開発)に追記。 +- **計画の削除**: `docs/plans/refactor/` を削除し、`docs/README.md` から refactor 計画の記載を除去。 + +--- + +## リファクタリング内容 + +| Phase | 内容 | +|-------|------| +| 1 | **MCP 状態構築の分離**: `build_app_state(...)` を切り出し、`run_server` はオーケストレーション(状態構築 → LSA/HNSW 同期 → listen)に限定。 | +| 2 | **doc_count 共通化**: `db::get_document_count(pool)` を追加。handlers・tools/items・system で利用。 | +| 3 | **ツール一覧の一覧化**: `mcp/tools/registry.rs` を新設し `tool_list()` で MCP ツール定義を一元管理。`tools/list` はここから生成。 | +| 4 | **DB ラップ**: `get_item_content_with_doc`・`get_document_id_by_path` を db に追加。search の 3 箇所と items の get/add で利用。 | +| 5 | **検索クエリベクトル集約**: `get_query_vector(state, text)` を search.rs に追加。Community は LSA、Pro は埋め込みで 1 箇所で切り替え。ベクトル検索処理を共通化。 | + +--- + +## 主な変更ファイル + +| 種別 | パス | +|------|------| +| 仕様 | docs/specification/02_architecture_design.md(§5 バックエンド構成追加), 05_development_guide.md(§3 更新) | +| 索引 | docs/README.md(refactor 計画の記載削除) | +| MCP | src/backend/src/mcp/mod.rs(build_app_state, run_server), handlers.rs(doc_count を db 経由に), tools/mod.rs(registry 利用・コメント), tools/registry.rs(新規), tools/items.rs(get_item_content_with_doc, get_document_id_by_path 利用), tools/search.rs(get_query_vector, DB ラップ利用) | +| DB | src/backend/src/db/mod.rs(get_document_count, get_item_content_with_doc, get_document_id_by_path) | +| その他 | src/backend/src/mcp/system.rs(get_document_count 利用), Cargo.lock | +| 削除 | docs/plans/refactor/(refactor.md, refactor_01〜06) | + +--- + +## 計画の現状 + +- **plans/auto_start/** — ログイン時自動起動(変更はテスト・注意事項の追記等) +- **plans/folder_monitor/** — 指定フォルダ監視 +- **plans/lda/** — LDA 対応(v0.3.3 Community) +- **plans/refactor/** — 削除済み(内容は仕様に反映済み) diff --git a/src/backend/Cargo.lock b/src/backend/Cargo.lock index 96cdac0..0a43104 100644 --- a/src/backend/Cargo.lock +++ b/src/backend/Cargo.lock @@ -180,7 +180,7 @@ [[package]] name = "app" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "axum", diff --git a/src/backend/src/db/mod.rs b/src/backend/src/db/mod.rs index b5a819e..11cbb03 100644 --- a/src/backend/src/db/mod.rs +++ b/src/backend/src/db/mod.rs @@ -175,6 +175,45 @@ Ok(updated.rows_affected()) } +/// documents テーブルの件数を返す。ハンドラ・ツール・LSA 再学習閾値で共通利用。 +pub async fn get_document_count(pool: &SqlitePool) -> Result { + sqlx::query_scalar("SELECT COUNT(*) FROM documents") + .fetch_one(pool) + .await + .map_err(|e| e.to_string()) +} + +/// 指定 id の item の content, path, mime, document_id を取得。検索結果の行構築で共通利用。 +pub async fn get_item_content_with_doc( + pool: &SqlitePool, + item_id: i64, +) -> Result, i64)>, String> { + let row = sqlx::query( + "SELECT i.content, d.path, d.mime, i.document_id FROM items i JOIN documents d ON i.document_id = d.id WHERE i.id = ?", + ) + .bind(item_id) + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + Ok(row.map(|r| { + ( + r.get::(0), + r.get::(1), + r.get::, _>(2), + r.get::(3), + ) + })) +} + +/// path で document の id を取得。無ければ None。 +pub async fn get_document_id_by_path(pool: &SqlitePool, path: &str) -> Result, String> { + let row = sqlx::query_scalar("SELECT id FROM documents WHERE path = ?") + .bind(path) + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + Ok(row) +} async fn check_and_init_vector_table(pool: &SqlitePool, dimension: usize) -> Result<(), String> { // 現在のテーブル定義を確認 diff --git a/src/backend/src/mcp/handlers.rs b/src/backend/src/mcp/handlers.rs index de04da8..be643ec 100644 --- a/src/backend/src/mcp/handlers.rs +++ b/src/backend/src/mcp/handlers.rs @@ -12,10 +12,7 @@ use crate::mcp::types::AppState; pub async fn doc_count_handler(State(state): State) -> impl IntoResponse { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents") - .fetch_one(&state.db_pool) - .await - .unwrap_or(0); + let count = crate::db::get_document_count(&state.db_pool).await.unwrap_or(0); Json(serde_json::json!({ "count": count })) } diff --git a/src/backend/src/mcp/mod.rs b/src/backend/src/mcp/mod.rs index 639faae..9edbb2e 100644 --- a/src/backend/src/mcp/mod.rs +++ b/src/backend/src/mcp/mod.rs @@ -34,17 +34,14 @@ .with_state(state) } -pub async fn run_server( - port: u16, +/// MCP 用の AppState を構築する。ルーティング・listen とは分離し、テストや起動オーケストレーションで再利用する。 +pub fn build_app_state( app_data_dir: std::path::PathBuf, db_pool: sqlx::SqlitePool, model_name: String, edition: String, #[cfg(feature = "pro")] embedding_model_dir: Option, -) { - let mcp_start = std::time::Instant::now(); - log::info!("[BOOT] MCP: run_server started (port={})", port); - +) -> AppState { let (tx, _rx) = broadcast::channel(100); let tokenizer = Arc::new(crate::utils::tokenizer::JapaneseTokenizer::new().unwrap()); @@ -70,7 +67,7 @@ Arc::new(RwLock::new(model)) }; - let state = AppState { + AppState { app_data_dir, db_pool, tx, @@ -88,7 +85,25 @@ changes_since_train: Arc::new(AtomicU64::new(0)), retrain_scheduled: Arc::new(AtomicBool::new(false)), indexing_status: Arc::new(RwLock::new("idle".to_string())), - }; + } +} + +/// 起動オーケストレーション: 状態構築 → LSA/HNSW 完了待ち → listen。 +pub async fn run_server( + port: u16, + app_data_dir: std::path::PathBuf, + db_pool: sqlx::SqlitePool, + model_name: String, + edition: String, + #[cfg(feature = "pro")] embedding_model_dir: Option, +) { + let mcp_start = std::time::Instant::now(); + log::info!("[BOOT] MCP: run_server started (port={})", port); + + #[cfg(feature = "community")] + let state = build_app_state(app_data_dir, db_pool, model_name, edition); + #[cfg(feature = "pro")] + let state = build_app_state(app_data_dir, db_pool, model_name, edition, embedding_model_dir); // 近似近傍検索を使うため、listen 前にインデックス構築を完了させる(spawn だと検索時に HNSW が空で 0.4 固定になる) system::train_lsa_and_sync_hnsw(state.clone()).await; @@ -184,107 +199,7 @@ } "resources/list" => Some(serde_json::json!({ "resources": [] })), "prompts/list" => Some(serde_json::json!({ "prompts": [] })), - "tools/list" => Some(serde_json::json!({ - "tools": [ - { - "name": "get_item_by_id", - "description": "Get a specific document chunk by ID", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "integer" } - }, - "required": ["id"] - } - }, - { - "name": "add_item_text", - "description": "Add or overweight a document path. Chunks are generated automatically.", - "inputSchema": { - "type": "object", - "properties": { - "content": { "type": "string" }, - "path": { "type": "string" }, - "mime": { "type": "string" } - }, - "required": ["content", "path"] - } - }, - { - "name": "search_text", - "description": "Search document chunks using Hybrid Hybrid/Vector search (BM25 + LSA/HNSW)", - "inputSchema": { - "type": "object", - "properties": { - "content": { "type": "string" }, - "limit": { "type": "integer", "default": 10 }, - "min_score": { "type": "number", "default": 0.3, "description": "Minimum similarity (0-1). Results below this are dropped. Default 0.3." } - }, - "required": ["content"] - } - }, - { - "name": "update_item", - "description": "Update an existing chunk's content", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "integer" }, - "content": { "type": "string" } - }, - "required": ["id", "content"] - } - }, - { - "name": "delete_item", - "description": "Delete a chunk by ID", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "integer" } - }, - "required": ["id"] - } - }, - { - "name": "list_documents", - "description": "List all documents (path, mime, chunk count)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "get_document_count", - "description": "Get the total count of documents stored in the database", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "get_document", - "description": "Get full document content by document ID", - "inputSchema": { - "type": "object", - "properties": { - "document_id": { "type": "integer" }, - "id": { "type": "integer" } - } - } - }, - { - "name": "delete_document", - "description": "Delete a document and all its chunks", - "inputSchema": { - "type": "object", - "properties": { - "document_id": { "type": "integer" }, - "id": { "type": "integer" } - } - } - }, - { - "name": "lsa_retrain", - "description": "Manually trigger LSA model retraining and vector rebuild", - "inputSchema": { "type": "object", "properties": {} } - } - ] - })), + "tools/list" => Some(serde_json::json!({ "tools": tools::registry::tool_list() })), "tools/call" | "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain" => { let empty_map = serde_json::Map::new(); let mut args = req.params.as_ref().and_then(|p| p.as_object()).unwrap_or(&empty_map); diff --git a/src/backend/src/mcp/system.rs b/src/backend/src/mcp/system.rs index e885d17..58a58e5 100644 --- a/src/backend/src/mcp/system.rs +++ b/src/backend/src/mcp/system.rs @@ -392,8 +392,7 @@ const MIN_THRESHOLD: u64 = 1; const DEBOUNCE_SECS: u64 = 90; - let doc_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM documents") - .fetch_one(&state.db_pool) + let doc_count: i64 = crate::db::get_document_count(&state.db_pool) .await .unwrap_or(0); let by_ratio = (doc_count as f64 * CHANGE_RATIO).ceil() as u64; diff --git a/src/backend/src/mcp/tools/items.rs b/src/backend/src/mcp/tools/items.rs index f5263c9..7af23c8 100644 --- a/src/backend/src/mcp/tools/items.rs +++ b/src/backend/src/mcp/tools/items.rs @@ -9,28 +9,17 @@ args: &serde_json::Map, ) -> Option { let id = args.get("id").and_then(|v| v.as_i64()).unwrap_or(0); - let row: Option = sqlx::query( - "SELECT i.content, d.path, d.mime FROM items i JOIN documents d ON i.document_id = d.id WHERE i.id = ?", - ) - .bind(id) - .fetch_optional(&state.db_pool) - .await - .unwrap_or(None); - if let Some(row) = row { - let content: String = row.get("content"); - let path: String = row.get("path"); - let mime: Option = row.get("mime"); - Some(serde_json::json!({ + match crate::db::get_item_content_with_doc(&state.db_pool, id).await { + Ok(Some((content, path, mime, _document_id))) => Some(serde_json::json!({ "id": id, "content": content, "path": path, "mime": mime - })) - } else { - Some(serde_json::json!({ + })), + _ => Some(serde_json::json!({ "content": [{ "type": "text", "text": format!("Item not found: {}", id) }], "isError": true - })) + })), } } @@ -77,13 +66,8 @@ let mut results = Vec::new(); // 1. ドキュメントレコードの取得または作成 - let doc_id_res = match sqlx::query("SELECT id FROM documents WHERE path = ?") - .bind(path_str) - .fetch_optional(&state.db_pool) - .await - { - Ok(Some(row)) => { - let id = row.get::(0); + let doc_id_res = match crate::db::get_document_id_by_path(&state.db_pool, path_str).await { + Ok(Some(id)) => { if let Some(m) = &mime_str { let _ = sqlx::query("UPDATE documents SET mime = ? WHERE id = ? AND (mime IS NULL OR mime != ?)") .bind(m) @@ -406,10 +390,7 @@ // ---------------------------------------------------------------------------- pub async fn handle_get_document_count(state: &AppState) -> Option { - let count: i64 = match sqlx::query_scalar("SELECT COUNT(*) FROM documents") - .fetch_one(&state.db_pool) - .await - { + let count: i64 = match crate::db::get_document_count(&state.db_pool).await { Ok(c) => c, Err(e) => { return Some(serde_json::json!({ diff --git a/src/backend/src/mcp/tools/mod.rs b/src/backend/src/mcp/tools/mod.rs index 61408d3..eae3688 100644 --- a/src/backend/src/mcp/tools/mod.rs +++ b/src/backend/src/mcp/tools/mod.rs @@ -1,9 +1,11 @@ pub mod items; +pub mod registry; pub mod search; pub mod system; use crate::mcp::types::AppState; +/// ツール名で実装をディスパッチ。新規ツール追加時は registry::tool_list() にも定義を追加すること。 pub async fn dispatch_tool( state: &AppState, _method: &str, diff --git a/src/backend/src/mcp/tools/registry.rs b/src/backend/src/mcp/tools/registry.rs new file mode 100644 index 0000000..29ea976 --- /dev/null +++ b/src/backend/src/mcp/tools/registry.rs @@ -0,0 +1,91 @@ +//! MCP ツールの一覧定義。ツール追加時はここと dispatch_tool の match の両方に追加すること。 + +/// tools/list で返すツール定義の配列。 +pub fn tool_list() -> Vec { + vec![ + serde_json::json!({ + "name": "get_item_by_id", + "description": "Get a specific document chunk by ID", + "inputSchema": { + "type": "object", + "properties": { "id": { "type": "integer" } }, + "required": ["id"] + } + }), + serde_json::json!({ + "name": "add_item_text", + "description": "Add or overweight a document path. Chunks are generated automatically.", + "inputSchema": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "path": { "type": "string" }, + "mime": { "type": "string" } + }, + "required": ["content", "path"] + } + }), + serde_json::json!({ + "name": "search_text", + "description": "Search document chunks using Hybrid Hybrid/Vector search (BM25 + LSA/HNSW)", + "inputSchema": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "limit": { "type": "integer", "default": 10 }, + "min_score": { "type": "number", "default": 0.3, "description": "Minimum similarity (0-1). Results below this are dropped. Default 0.3." } + }, + "required": ["content"] + } + }), + serde_json::json!({ + "name": "update_item", + "description": "Update an existing chunk's content", + "inputSchema": { + "type": "object", + "properties": { "id": { "type": "integer" }, "content": { "type": "string" } }, + "required": ["id", "content"] + } + }), + serde_json::json!({ + "name": "delete_item", + "description": "Delete a chunk by ID", + "inputSchema": { + "type": "object", + "properties": { "id": { "type": "integer" } }, + "required": ["id"] + } + }), + serde_json::json!({ + "name": "list_documents", + "description": "List all documents (path, mime, chunk count)", + "inputSchema": { "type": "object", "properties": {} } + }), + serde_json::json!({ + "name": "get_document_count", + "description": "Get the total count of documents stored in the database", + "inputSchema": { "type": "object", "properties": {} } + }), + serde_json::json!({ + "name": "get_document", + "description": "Get full document content by document ID", + "inputSchema": { + "type": "object", + "properties": { "document_id": { "type": "integer" }, "id": { "type": "integer" } } + } + }), + serde_json::json!({ + "name": "delete_document", + "description": "Delete a document and all its chunks", + "inputSchema": { + "type": "object", + "properties": { "document_id": { "type": "integer" }, "id": { "type": "integer" } } + } + }), + serde_json::json!({ + "name": "lsa_retrain", + "description": "Manually trigger LSA model retraining and vector rebuild", + "inputSchema": { "type": "object", "properties": {} } + }), + ] +} diff --git a/src/backend/src/mcp/tools/search.rs b/src/backend/src/mcp/tools/search.rs index f610495..5ff666d 100644 --- a/src/backend/src/mcp/tools/search.rs +++ b/src/backend/src/mcp/tools/search.rs @@ -2,6 +2,42 @@ use sqlx::Row; use crate::mcp::types::AppState; +/// 検索クエリ文字列をベクトルに変換する。エディションに応じて LSA または埋め込みモデルを使用(1 箇所で切り替え)。 +#[cfg(feature = "community")] +async fn get_query_vector(state: &AppState, text: &str) -> Option> { + let lsa_guard = state.lsa_model.read().await; + let model = lsa_guard.as_ref()?; + let mut query_counts = HashMap::new(); + let tokens = state.tokenizer.tokenize_to_vec(text).unwrap_or_default(); + for token in tokens { + if let Some(&tid) = model.vocabulary.get(&token) { + *query_counts.entry(tid).or_insert(0.0) += 1.0; + } + } + if query_counts.is_empty() { + return None; + } + let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); + for (tid, count) in query_counts { + query_vec[tid] = count; + } + let query_lsa = model.project_query(&query_vec).ok()?; + let mut out: Vec = query_lsa.iter().map(|&x| x as f32).collect(); + if out.len() < 50 { + out.resize(50, 0.0); + } else { + out.truncate(50); + } + Some(out) +} + +#[cfg(feature = "pro")] +async fn get_query_vector(state: &AppState, text: &str) -> Option> { + let guard = state.embedding_model.read().await; + let model = guard.as_ref()?; + model.encode(text).ok() +} + pub async fn handle_search_text( state: &AppState, actual_method: &str, @@ -70,157 +106,84 @@ } log::info!("[search] FTS hits={}", fts_results.len()); - // 2. Vector Search (LSA/HNSW or Pro embedding) + // 2. Vector Search(クエリベクトルは get_query_vector で 1 箇所に集約) let mut final_results: HashMap = HashMap::new(); let mut vector_search_used = false; - #[cfg(feature = "pro")] - { - let guard = state.embedding_model.read().await; - if let Some(ref model) = *guard { - if let Ok(query_embedding) = model.encode(search_content) { - let mut vector_hits = Vec::new(); - let hnsw_is_some = { - let hnsw_idx_guard = state.hnsw_index.read().await; - if let Some(h_ptr) = hnsw_idx_guard.as_ref() { - let neighbors = h_ptr.search(&query_embedding, (search_limit * 2) as usize, 100); - for n in neighbors { - vector_hits.push((n.d_id as i64, 1.0f32 - n.distance)); - } - true - } else { - false - } - }; - if vector_hits.is_empty() { - if let Ok(rows) = sqlx::query( - "SELECT id, distance FROM vec_items WHERE embedding MATCH ? AND k = ?" - ) - .bind(serde_json::to_string(&query_embedding).unwrap_or("[]".to_string())) - .bind(search_limit * 2).fetch_all(&state.db_pool).await { - for r in rows { - let id: i64 = r.get(0); - let dist: f64 = r.get(1); - vector_hits.push((id, (1.0 - (dist / 2.0)) as f32)); - } - } + if let Some(query_embedding) = get_query_vector(state, search_content).await { + let mut vector_hits = Vec::new(); + let _hnsw_is_some = { + let hnsw_idx_guard = state.hnsw_index.read().await; + if let Some(h_ptr) = hnsw_idx_guard.as_ref() { + let neighbors = h_ptr.search(&query_embedding, (search_limit * 2) as usize, 100); + for n in neighbors { + vector_hits.push((n.d_id as i64, 1.0f32 - n.distance)); } - if vector_hits.is_empty() { - let vec_count: i64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM vec_items") - .fetch_one(&state.db_pool) - .await - .unwrap_or(0); - log::warn!( - "[search] Pro: 近似近傍が 0 件 (HNSW={} vec_items={})。RE-INDEX を実行するか、バックグラウンドで再構築を試行します。", - if hnsw_is_some { "あり" } else { "なし" }, - vec_count - ); - // HNSW が無いときは 1 回だけ遅延構築を試す(起動時バックフィルが反映されなかった場合の救済) - use std::sync::atomic::Ordering; - if !hnsw_is_some - && state - .pro_hnsw_rebuild_requested - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_ok() - { - let state_clone = state.clone(); - tokio::spawn(async move { - log::info!("[search] Pro: HNSW 遅延構築を開始します(vec_items 再読込・HNSW 構築)"); - crate::mcp::system::train_lsa_and_sync_hnsw(state_clone).await; - log::info!("[search] Pro: HNSW 遅延構築完了。次回検索から近似近傍が使われます。"); - }); - } - } else { - vector_search_used = true; - log::info!("[search] Pro vector hits={}", vector_hits.len()); - } - for (id, v_sim) in vector_hits { - let f_sim = fts_results.get(&id).cloned().unwrap_or(0.0); - let final_sim = v_sim.max(f_sim); - if let Ok(row) = sqlx::query( - "SELECT i.content, d.path, d.mime, i.document_id FROM items i JOIN documents d ON i.document_id = d.id WHERE i.id = ?" - ).bind(id).fetch_one(&state.db_pool).await { - final_results.insert(id, serde_json::json!({ - "id": id, - "document_id": row.get::(3), - "content": row.get::(0), - "path": row.get::(1), - "mime": row.get::, _>(2), - "similarity": final_sim.clamp(0.0, 1.0) - })); - } - } + true } else { - log::warn!("[search] Pro: クエリの埋め込みに失敗しました"); + false + } + }; + if vector_hits.is_empty() { + if let Ok(rows) = sqlx::query( + "SELECT id, distance FROM vec_items WHERE embedding MATCH ? AND k = ?", + ) + .bind(serde_json::to_string(&query_embedding).unwrap_or_else(|_| "[]".to_string())) + .bind(search_limit * 2) + .fetch_all(&state.db_pool) + .await + { + for r in rows { + let id: i64 = r.get(0); + let dist: f64 = r.get(1); + vector_hits.push((id, (1.0 - (dist / 2.0)) as f32)); + } + } + } + if vector_hits.is_empty() { + #[cfg(feature = "pro")] + { + let vec_count: i64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM vec_items") + .fetch_one(&state.db_pool) + .await + .unwrap_or(0); + log::warn!( + "[search] Pro: 近似近傍が 0 件 (HNSW={} vec_items={})。RE-INDEX を実行するか、バックグラウンドで再構築を試行します。", + if _hnsw_is_some { "あり" } else { "なし" }, + vec_count + ); + use std::sync::atomic::Ordering; + if !_hnsw_is_some + && state + .pro_hnsw_rebuild_requested + .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + let state_clone = state.clone(); + tokio::spawn(async move { + log::info!("[search] Pro: HNSW 遅延構築を開始します(vec_items 再読込・HNSW 構築)"); + crate::mcp::system::train_lsa_and_sync_hnsw(state_clone).await; + log::info!("[search] Pro: HNSW 遅延構築完了。次回検索から近似近傍が使われます。"); + }); + } } } else { - log::warn!("[search] Pro: 埋め込みモデル未ロードのため近似近傍検索をスキップしています"); + vector_search_used = true; + log::info!("[search] vector hits={}", vector_hits.len()); } - } - #[cfg(feature = "community")] - { - let lsa_guard = state.lsa_model.read().await; - if let Some(model) = lsa_guard.as_ref() { - let mut query_counts = HashMap::new(); - let tokens = state.tokenizer.tokenize_to_vec(search_content).unwrap_or_default(); - for token in tokens { - if let Some(&tid) = model.vocabulary.get(&token) { - *query_counts.entry(tid).or_insert(0.0) += 1.0; - } - } - if !query_counts.is_empty() { - let mut query_vec = ndarray::Array1::zeros(model.vocabulary.len()); - for (tid, count) in query_counts { - query_vec[tid] = count; - } - - if let Ok(query_lsa) = model.project_query(&query_vec) { - let mut query_lsa_f32: Vec = query_lsa.iter().map(|&x| x as f32).collect(); - if query_lsa_f32.len() < 50 { query_lsa_f32.resize(50, 0.0); } else { query_lsa_f32.truncate(50); } - - let mut vector_hits = Vec::new(); - let hnsw_idx_guard = state.hnsw_index.read().await; - if let Some(h_ptr) = hnsw_idx_guard.as_ref() { - let neighbors = h_ptr.search(&query_lsa_f32, (search_limit * 2) as usize, 100); - for n in neighbors { - vector_hits.push((n.d_id as i64, 1.0f32 - n.distance)); - } - } - - if vector_hits.is_empty() { - if let Ok(rows) = sqlx::query( - "SELECT id, distance FROM vec_items WHERE embedding MATCH ? AND k = ?" - ) - .bind(serde_json::to_string(&query_lsa_f32).unwrap_or("[]".to_string())) - .bind(search_limit * 2).fetch_all(&state.db_pool).await { - for r in rows { - let id: i64 = r.get(0); - let dist: f64 = r.get(1); - vector_hits.push((id, (1.0 - (dist / 2.0)) as f32)); - } - } - } - - if !vector_hits.is_empty() { - vector_search_used = true; - } - log::info!("[search] Community LSA vector hits={}", vector_hits.len()); - for (id, v_sim) in vector_hits { - let f_sim = fts_results.get(&id).cloned().unwrap_or(0.0); - let final_sim = v_sim.max(f_sim); - if let Ok(row) = sqlx::query( - "SELECT i.content, d.path, d.mime, i.document_id FROM items i JOIN documents d ON i.document_id = d.id WHERE i.id = ?" - ).bind(id).fetch_one(&state.db_pool).await { - final_results.insert(id, serde_json::json!({ - "id": id, - "document_id": row.get::(3), - "content": row.get::(0), - "path": row.get::(1), - "mime": row.get::, _>(2), - "similarity": final_sim.clamp(0.0, 1.0) - })); - } - } - } + for (id, v_sim) in vector_hits { + let f_sim = fts_results.get(&id).cloned().unwrap_or(0.0); + let final_sim = v_sim.max(f_sim); + if let Ok(Some((content, path, mime, document_id))) = + crate::db::get_item_content_with_doc(&state.db_pool, id).await + { + final_results.insert(id, serde_json::json!({ + "id": id, + "document_id": document_id, + "content": content, + "path": path, + "mime": mime, + "similarity": final_sim.clamp(0.0, 1.0) + })); } } } @@ -228,15 +191,15 @@ // 3. Add FTS results (and for Community, remaining FTS not in vector results) for (id, f_sim) in fts_results { if !final_results.contains_key(&id) { - if let Ok(row) = sqlx::query( - "SELECT i.content, d.path, d.mime, i.document_id FROM items i JOIN documents d ON i.document_id = d.id WHERE i.id = ?" - ).bind(id).fetch_one(&state.db_pool).await { + if let Ok(Some((content, path, mime, document_id))) = + crate::db::get_item_content_with_doc(&state.db_pool, id).await + { final_results.insert(id, serde_json::json!({ "id": id, - "document_id": row.get::(3), - "content": row.get::(0), - "path": row.get::(1), - "mime": row.get::, _>(2), + "document_id": document_id, + "content": content, + "path": path, + "mime": mime, "similarity": f_sim.clamp(0.0, 1.0) })); }