diff --git a/docs/specification/01_system_overview.md b/docs/specification/01_system_overview.md index c8d7ea8..0d53ba6 100644 --- a/docs/specification/01_system_overview.md +++ b/docs/specification/01_system_overview.md @@ -20,7 +20,7 @@ ## 3. 主要機能 - **セマンティック検索**: クエリをベクトル化し、類似度でランキング。結果は文書単位で結合して返すオプションあり。 -- **MCP サーバー**: ポート 3001 で SSE。`search_text`・`add_item_text`・`update_item`・`delete_item`・`get_item_by_id`・`get_document_count`・`lsa_retrain`(RE-INDEX)等。 +- **MCP サーバー**: ポート 3001 で SSE。`search_text`・`add_item_text`・`update_item`・`delete_item`・`get_item_by_id`・`list_documents`・`list_categories`・`get_document_count`・`get_document`・`delete_document`・`lsa_retrain`(RE-INDEX)等。 - **セルフヒーリング**: テキストと FTS/ベクトルの不整合を検出し、起動時・手動 heal で同期。 - **常駐 UI**: システムトレイ常駐。検索・文書管理・設定。エディション表示(Community / Pro)。 diff --git a/docs/specification/04_mcp_api_specification.md b/docs/specification/04_mcp_api_specification.md index 2895710..dc5652b 100644 --- a/docs/specification/04_mcp_api_specification.md +++ b/docs/specification/04_mcp_api_specification.md @@ -12,35 +12,80 @@ | ツール名 | 役割 | |----------|------| -| `search_text` | 意味・全文ハイブリッド検索。結果は文書単位で結合可能。 | -| `add_item_text` | テキストをチャンクとして登録。ベクトル化はエディションに応じて LSA または埋め込み。 | +| `search_text` | 意味・全文ハイブリッド検索。結果は文書単位で結合可能。カテゴリで絞り込み可。 | +| `add_item_text` | テキストをチャンクとして登録。オプションで category を指定可能。ベクトル化はエディションに応じて LSA または埋め込み。 | | `update_item` | 指定 ID のチャンクを更新。ベクトル再計算。 | | `delete_item` | 指定 ID のチャンクを削除。 | -| `get_item_by_id` | 指定 ID のメタデータ・本文を取得。 | +| `get_item_by_id` | 指定 ID のメタデータ・本文・category を取得。 | +| `list_documents` | ドキュメント一覧をページングで取得(id, path, mime, chunk_count, category 等)。`limit`・`page` で指定。戻りに `items`・`total_count`・`total_pages`・`page` を含む。 | +| `list_categories` | ドキュメントに設定されているカテゴリ名の一覧(重複なし)。`search_text` の category フィルタ用。 | | `get_document_count` | 文書(documents)の総件数。 | +| `get_document` | 文書 ID で全文・チャンク一覧を取得。 | +| `delete_document` | 文書とその全チャンクを削除。 | | `lsa_retrain` | RE-INDEX。FTS/ベクトルを再構築(Community: LSA 再学習、Pro: vec_items 再投入・HNSW 再構築)。 | ## 3. search_text パラメータ | パラメータ | 型 | 必須 | 既定値 | 説明 | |------------|-----|------|--------|------| -| `content` | string | ○ | — | 検索クエリ。短い語句・自然文どちらも可。 | -| `limit` | integer | — | 10 | 返却最大件数(1〜100)。 | +| `content` | string | ○ | — | 検索クエリ。短い語句・自然文どちらも可。空の場合は `items: []` で返す。 | +| `limit` | integer | — | 5 | 返却最大件数。 | | `min_score` | number | — | 0.3 | 類似度の足切り(0〜1)。 | | `group_by_document` | boolean | — | true | true のとき文書単位で結合して返す。 | +| `category` | string | — | — | 指定時はそのカテゴリのドキュメントのみに絞り込む。`list_categories` で取得した値を使用。 | - 結果の `similarity` は 0〜1。ベクトルと FTS のスコアの大きい方を採用。 -## 4. HTTP API(補助) +### 3.1 search_text レスポンス形式(統一) + +成功時は常に次の形で返す。**マッチ 0 件・空クエリ・内部エラー時もエラーにせず、同一形式で返す**(Issue #11 対応)。 + +- `content[0].text` は JSON 文字列。パースすると: + - `items`: 配列。各要素は `id`, `document_id`, `path`, `mime`, `category`, `content`, `similarity` を持つ。0 件のときは `[]`。 + - `vector_search_used`: boolean。ベクトル検索が使われたか。 + +クライアントは `parsed.items` が常に配列であることを前提に処理できる。 + +## 4. list_documents パラメータ・戻り値(ページング) + +全件取得は避け、ページングで取得する。 + +| パラメータ | 型 | 必須 | 既定値 | 説明 | +|------------|-----|------|--------|------| +| `limit` | integer | — | 20 | 1 ページあたりの件数(1〜100)。 | +| `page` | integer | — | 1 | ページ番号(1 始まり)。範囲外のときは `items: []` のまま返す。 | + +**戻り値**(`content[0].text` を JSON パースしたオブジェクト): + +| キー | 型 | 説明 | +|------|-----|------| +| `items` | array | そのページのドキュメント配列。範囲外ページは `[]`。 | +| `total_count` | number | ドキュメント総数。 | +| `total_pages` | number | 総ページ数(0 件のとき 0)。 | +| `page` | number | 現在のページ番号(1 始まり)。 | + +## 5. add_item_text パラメータ(補足) + +| パラメータ | 型 | 必須 | 説明 | +|------------|-----|------|------| +| `content` | string | ○ | 登録する本文。 | +| `path` | string | ○ | ドキュメントの論理パス(一意)。 | +| `mime` | string | — | MIME タイプ。未指定時は拡張子から推測。 | +| `category` | string | — | カテゴリラベル。フォルダ監視で割り当てたものと合わせて利用可能。 | + +## 6. HTTP API(補助) | メソッド | パス | 説明 | |----------|------|------| -| GET | `/edition` | 起動中エディション(`community` / `pro`)。 | +| GET | `/edition` | 起動中エディション(`community` / `pro`)。Pro 時は `embedding_loaded` も含む。 | | GET | `/version` | アプリバージョン(例: `{"version":"0.3.3"}`)。 | | GET | `/heal` | FTS 同期実行。`{"synced": n}`。 | -| GET | `/model_name` | Pro 時は埋め込みモデル名等。 | +| GET | `/model_name` | エディション名・Pro 時は埋め込みモデル名等。 | +| GET | `/settings` | 設定取得(monitor_paths, run_on_login, min_score, limit 等)。 | +| POST | `/settings` | 設定保存。フォルダ監視・カテゴリ割り当ての更新を含む。 | +| GET | `/indexing_status` | インデックス状態(idle / training 等)。 | -## 5. レスポンス形式 +## 7. レスポンス形式 ツール呼び出しは JSON-RPC 2.0。結果は MCP の `content` 配列(`type: "text"`, `text` に JSON 文字列)で返します。 diff --git a/docs/specification/14_folder_monitor.md b/docs/specification/14_folder_monitor.md index a817877..27b26f2 100644 --- a/docs/specification/14_folder_monitor.md +++ b/docs/specification/14_folder_monitor.md @@ -24,7 +24,7 @@ | **対象ファイル** | 拡張子でフィルタ。デフォルト: `txt`, `md`, `json`, `html`, `css`, `js`, `mjs`, `ts`, `rs`。設定で変更可能。 | | **動作タイミング** | アプリ起動中の常時監視。設定でモニター先を空にすることで実質オフにできる。 | | **デフォルト** | モニター先フォルダなし(`monitor_paths: []`)。 | -| **MCP との関係** | 能動的なプッシュ通知はしない。クライアントは `search_text` や `get_document_count` 等で現在状態をプルする。 | +| **MCP との関係** | 能動的なプッシュ通知はしない。クライアントは `search_text` や `get_document_count` 等で現在状態をプルする。`list_categories` でカテゴリ一覧を取得し、`search_text` の `category` パラメータで絞り込み可能。 | | **対象エディション** | Community 版・Pro 版の両方で利用可能。 | ## 3. 技術構成 diff --git a/package-lock.json b/package-lock.json index fc79a4b..f983af2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "telos-db", - "version": "0.3.3", + "version": "0.3.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "telos-db", - "version": "0.3.3", + "version": "0.3.3.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "@tauri-apps/api": "^2.10.1", diff --git a/package.json b/package.json index b54fa2d..9949a05 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "telos-db", "private": true, - "version": "0.3.3", + "version": "0.3.3.1", "type": "module", "scripts": { "tauri": "tauri", diff --git a/src/backend/Cargo.toml b/src/backend/Cargo.toml index 556d8f1..36f273f 100644 --- a/src/backend/Cargo.toml +++ b/src/backend/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "app" -version = "0.3.3" +version = "0.3.3.1" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src/backend/src/mcp/mod.rs b/src/backend/src/mcp/mod.rs index 1356465..6330275 100644 --- a/src/backend/src/mcp/mod.rs +++ b/src/backend/src/mcp/mod.rs @@ -230,7 +230,7 @@ log::info!("MCP Request: {} (Actual: {}, Session: {:?})", method, actual_method, query.session_id); // tools/call をブロードキャストして UI の MCP ACTIVITY に通知 - if method == "tools/call" || matches!(actual_method, "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") { + if method == "tools/call" || matches!(actual_method, "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "list_categories" | "get_document_count" | "get_document" | "delete_document" | "lsa_retrain") { let _ = state.tx.send(format!("mcp:call:{}", actual_method)); } @@ -262,7 +262,7 @@ "resources/list" => Some(serde_json::json!({ "resources": [] })), "prompts/list" => Some(serde_json::json!({ "prompts": [] })), "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" => { + "tools/call" | "get_item_by_id" | "add_item_text" | "search_text" | "lsa_search" | "update_item" | "delete_item" | "list_documents" | "list_categories" | "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/tools/items.rs b/src/backend/src/mcp/tools/items.rs index 9b4a85a..ffa0a1f 100644 --- a/src/backend/src/mcp/tools/items.rs +++ b/src/backend/src/mcp/tools/items.rs @@ -422,16 +422,51 @@ // Document-level API (list, get full content, delete by document) // ---------------------------------------------------------------------------- +/// ドキュメント一覧をページングで返す。limit(1ページあたり件数)・page(1始まり)を指定。範囲外のページは items: [] で返す。 pub async fn handle_list_documents( state: &AppState, + args: &serde_json::Map, ) -> Option { + let limit = args + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(20) + .clamp(1, 100); + let page = args + .get("page") + .and_then(|v| v.as_i64()) + .unwrap_or(1) + .max(1); + let offset = (page - 1) * limit; + + let total_count: i64 = match sqlx::query_scalar("SELECT COUNT(*) FROM documents") + .fetch_one(&state.db_pool) + .await + { + Ok(n) => n, + Err(e) => { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Failed to count documents: {}", e) }], + "isError": true + })); + } + }; + + let total_pages = if total_count == 0 { + 0 + } else { + ((total_count as f64) / (limit as f64)).ceil() as i64 + }; + let rows = match sqlx::query( "SELECT d.id, d.path, d.mime, d.updated_at, (SELECT COUNT(*) FROM items i WHERE i.document_id = d.id) AS chunk_count, (SELECT substr(i.content, 1, 15) FROM items i WHERE i.document_id = d.id ORDER BY i.chunk_index ASC LIMIT 1) AS chunk0_preview, COALESCE(d.category, '') AS category - FROM documents d ORDER BY d.path" + FROM documents d ORDER BY d.path LIMIT ? OFFSET ?", ) + .bind(limit) + .bind(offset) .fetch_all(&state.db_pool) .await { @@ -444,7 +479,7 @@ } }; - let list: Vec = rows.iter().map(|row| { + let items: Vec = rows.iter().map(|row| { let id: i64 = row.get(0); let path: String = row.get(1); let mime: Option = row.get(2); @@ -463,7 +498,35 @@ }) }).collect(); - let text = serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".to_string()); + let payload = serde_json::json!({ + "items": items, + "total_count": total_count, + "total_pages": total_pages, + "page": page + }); + let text = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()); + Some(serde_json::json!({ + "content": [{ "type": "text", "text": text }] + })) +} + +/// ドキュメントに設定されているカテゴリ名の一覧(重複なし・空でないもの)を返す。 +pub async fn handle_list_categories(state: &AppState) -> Option { + let rows = match sqlx::query_scalar::<_, String>( + "SELECT DISTINCT COALESCE(category, '') FROM documents WHERE COALESCE(category, '') != '' ORDER BY category", + ) + .fetch_all(&state.db_pool) + .await + { + Ok(r) => r, + Err(e) => { + return Some(serde_json::json!({ + "content": [{ "type": "text", "text": format!("Failed to list categories: {}", e) }], + "isError": true + })); + } + }; + let text = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| "[]".to_string()); Some(serde_json::json!({ "content": [{ "type": "text", "text": text }] })) diff --git a/src/backend/src/mcp/tools/mod.rs b/src/backend/src/mcp/tools/mod.rs index eae3688..90e4922 100644 --- a/src/backend/src/mcp/tools/mod.rs +++ b/src/backend/src/mcp/tools/mod.rs @@ -15,10 +15,14 @@ match actual_method { "get_item_by_id" => items::handle_get_item_by_id(state, args).await, "add_item_text" => items::handle_add_item_text(state, args).await, - "search_text" | "lsa_search" => search::handle_search_text(state, actual_method, args).await, + "search_text" | "lsa_search" => search::handle_search_text(state, actual_method, args).await + .or_else(|| Some(serde_json::json!({ + "content": [{ "type": "text", "text": r#"{"items":[],"vector_search_used":false}"# }] + }))), "update_item" => items::handle_update_item(state, args).await, "delete_item" => items::handle_delete_item(state, args).await, - "list_documents" => items::handle_list_documents(state).await, + "list_documents" => items::handle_list_documents(state, args).await, + "list_categories" => items::handle_list_categories(state).await, "get_document_count" => items::handle_get_document_count(state).await, "get_document" => items::handle_get_document(state, args).await, "delete_document" => items::handle_delete_document(state, args).await, diff --git a/src/backend/src/mcp/tools/registry.rs b/src/backend/src/mcp/tools/registry.rs index 911e530..faf6058 100644 --- a/src/backend/src/mcp/tools/registry.rs +++ b/src/backend/src/mcp/tools/registry.rs @@ -60,7 +60,18 @@ }), serde_json::json!({ "name": "list_documents", - "description": "List all documents (path, mime, chunk count)", + "description": "List documents with paging (path, mime, chunk count, category). Returns items, total_count, total_pages, page.", + "inputSchema": { + "type": "object", + "properties": { + "limit": { "type": "integer", "default": 20, "description": "Items per page (1–100). Default 20." }, + "page": { "type": "integer", "default": 1, "description": "1-based page number. Out-of-range returns empty items." } + } + } + }), + serde_json::json!({ + "name": "list_categories", + "description": "List distinct category names assigned to documents (for search_text category filter)", "inputSchema": { "type": "object", "properties": {} } }), serde_json::json!({ @@ -110,6 +121,7 @@ "update_item", "delete_item", "list_documents", + "list_categories", "get_document_count", "get_document", "delete_document", diff --git a/src/backend/src/mcp/tools/search.rs b/src/backend/src/mcp/tools/search.rs index 2d4e857..2d2215f 100644 --- a/src/backend/src/mcp/tools/search.rs +++ b/src/backend/src/mcp/tools/search.rs @@ -67,8 +67,13 @@ if search_content.is_empty() { log::info!("[search] query=empty -> skipped"); + let empty_payload = serde_json::json!({ + "items": [], + "vector_search_used": false + }); + let empty_text = serde_json::to_string_pretty(&empty_payload).unwrap_or_else(|_| r#"{"items":[],"vector_search_used":false}"#.to_string()); return Some(serde_json::json!({ - "content": [{ "type": "text", "text": "Empty search query provided." }] + "content": [{ "type": "text", "text": empty_text }] })); } diff --git a/src/backend/tauri.conf.json b/src/backend/tauri.conf.json index ecd20d7..1d3919e 100644 --- a/src/backend/tauri.conf.json +++ b/src/backend/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../../node_modules/@tauri-apps/cli/config.schema.json", "productName": "TelosDB", - "version": "0.3.3", + "version": "0.3.3.1", "identifier": "com.telosdb.app", "build": { "frontendDist": "../frontend", diff --git a/src/backend/tests/search_api.rs b/src/backend/tests/search_api.rs index 166496d..b226998 100644 --- a/src/backend/tests/search_api.rs +++ b/src/backend/tests/search_api.rs @@ -44,3 +44,34 @@ } } } + +/// Issue #11: 空クエリでもエラーにせず、統一形式 { items: [], vector_search_used: false } で返ることの検証。 +#[tokio::test] +#[ignore = "requires MCP server on 127.0.0.1:3001; run with: cargo test --test search_api -- --ignored"] +async fn test_search_text_empty_query_returns_unified_shape() { + let client = Client::new(); + let req = json!({ + "jsonrpc": "2.0", + "method": "search_text", + "params": { "content": "", "limit": 5 }, + "id": 2 + }); + let resp = client + .post("http://127.0.0.1:3001/messages") + .json(&req) + .send() + .await + .expect("API呼び出し失敗"); + assert!(resp.status().is_success(), "空クエリでも 200 であること"); + let body: serde_json::Value = resp.json().await.expect("JSONデコード失敗"); + assert!(body.get("error").is_none(), "JSON-RPC error が無いこと"); + let text = body["result"]["content"][0]["text"].as_str().expect("content[0].text が存在すること"); + let parsed: serde_json::Value = serde_json::from_str(text).expect("content[0].text は JSON であること"); + let obj = parsed.as_object().expect("トップレベルはオブジェクト"); + assert!(obj.contains_key("items"), "items キーがあること"); + assert!(obj.contains_key("vector_search_used"), "vector_search_used キーがあること"); + let items = &obj["items"]; + assert!(items.is_array(), "items は配列"); + assert_eq!(items.as_array().unwrap().len(), 0, "空クエリでは items は空配列"); + assert_eq!(obj["vector_search_used"], false, "vector_search_used は false"); +} diff --git a/src/frontend/components/main-panel.js b/src/frontend/components/main-panel.js index 637be51..b4cfbc3 100644 --- a/src/frontend/components/main-panel.js +++ b/src/frontend/components/main-panel.js @@ -508,6 +508,8 @@ let docsEditorInstance = null; const docsListEl = this.querySelector('#docs-list'); + const DOCS_PAGE_SIZE = 20; + let docsCurrentPage = 1; const docsEditModal = this.querySelector('#docs-edit-modal'); const docsFormArea = this.querySelector('#docs-form-area'); const docsPathEl = this.querySelector('#docs-path'); @@ -522,17 +524,36 @@ if (!docsListEl) return; docsListEl.innerHTML = '
一覧を読み込み中...
'; try { - const result = await callMcp('list_documents', {}); - const list = parseResultText(result); + const result = await callMcp('list_documents', { limit: DOCS_PAGE_SIZE, page: docsCurrentPage }); + const raw = parseResultText(result); + const list = Array.isArray(raw) ? raw : (raw?.items ?? []); + const totalPages = Math.max(0, raw?.total_pages ?? 1); + const totalCount = raw?.total_count ?? list.length; + const currentPage = raw?.page ?? docsCurrentPage; + docsCurrentPage = currentPage; + if (!Array.isArray(list)) { docsListEl.innerHTML = '
一覧の取得に失敗しました
'; return; } if (list.length === 0) { - docsListEl.innerHTML = '
登録された文書はありません
'; + docsListEl.innerHTML = totalCount > 0 + ? '
このページには文書がありません
' + : '
登録された文書はありません
'; return; } - docsListEl.innerHTML = ` + + const prevDisabled = currentPage <= 1; + const nextDisabled = currentPage >= totalPages; + const pagerHtml = totalPages > 1 + ? `
+ + ${currentPage} / ${totalPages} ページ(全 ${totalCount} 件) + +
` + : (totalCount > 0 ? `

全 ${totalCount} 件

` : ''); + + docsListEl.innerHTML = pagerHtml + ` @@ -553,6 +574,17 @@
パスMIMEチャンク数先頭(chunk0)操作
`; + + docsListEl.querySelectorAll('[data-action="prev"]').forEach(btn => { + btn.addEventListener('click', () => { + if (docsCurrentPage > 1) { docsCurrentPage--; loadDocsList(); } + }); + }); + docsListEl.querySelectorAll('[data-action="next"]').forEach(btn => { + btn.addEventListener('click', () => { + if (docsCurrentPage < totalPages) { docsCurrentPage++; loadDocsList(); } + }); + }); docsListEl.querySelectorAll('.docs-btn-edit').forEach(btn => { btn.addEventListener('click', () => openDocEdit(btn.dataset.id)); }); diff --git a/src/frontend/index.html b/src/frontend/index.html index c237e35..2e540b1 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -274,9 +274,13 @@ } else if (parsed && Array.isArray(parsed.items)) { vectorSearchUsed = parsed.vector_search_used === true; results = parsed.items; + } else { + // 統一形式でないレスポンス(items が無い/配列でない)は 0 件として扱う + results = []; } } catch (e) { - // 単なるテキストメッセージ(エラー等)の場合はそのまま表示へ + // パース失敗時も 0 件として扱い、エラーにしない + results = []; } } diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 8c06de4..8575343 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -441,6 +441,35 @@ overflow: auto; margin-bottom: 16px; } +.docs-pager { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.docs-pager-btn { + padding: 6px 14px; + font-size: 0.9rem; + background: var(--bg-surface); + border: 1px solid var(--border-base); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; +} +.docs-pager-btn:hover:not(:disabled) { + background: var(--border-base); + border-color: var(--accent-blue); + color: var(--text-primary); +} +.docs-pager-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.docs-pager-info { + color: var(--text-dim); + font-size: 0.9rem; +} .docs-table { width: 100%; border-collapse: collapse; diff --git a/tests/test_mcp_client.mjs b/tests/test_mcp_client.mjs index 8f30f4d..7cd7896 100644 --- a/tests/test_mcp_client.mjs +++ b/tests/test_mcp_client.mjs @@ -134,6 +134,47 @@ console.log(" Pro vectorization E2E: vector_search_used=true, スコア差あり (OK)"); } +/** + * Issue #11: 検索結果が取得できないときはエラーにせず、統一形式 { items: [], vector_search_used } で返すことの検証。 + */ +async function testSearchEmptyAndNoMatchReturnsUnifiedShape(axiosInstance) { + // 空クエリ: 必ず items: [] の統一形式で返ること + const emptyRes = await postMessage(axiosInstance, "tools/call", { + name: "search_text", + arguments: { content: "", limit: 5 } + }); + if (emptyRes.error) throw new Error("Issue #11 empty query: JSON-RPC error " + JSON.stringify(emptyRes.error)); + const emptyText = emptyRes.result?.content?.[0]?.text; + if (!emptyText) throw new Error("Issue #11 empty query: no content[0].text"); + let parsed; + try { + parsed = JSON.parse(emptyText); + } catch (e) { + throw new Error("Issue #11 empty query: content[0].text is not JSON: " + emptyText.slice(0, 80)); + } + if (!Array.isArray(parsed.items)) throw new Error("Issue #11 empty query: parsed.items is not array"); + if (typeof parsed.vector_search_used !== "boolean") throw new Error("Issue #11 empty query: vector_search_used is not boolean"); + if (parsed.items.length !== 0) throw new Error("Issue #11 empty query: expected items.length 0, got " + parsed.items.length); + console.log(" Empty query: 200, items=[], vector_search_used=" + parsed.vector_search_used + " (OK)"); + + // マッチしないクエリ: 同じく統一形式で返り、items は配列であること(0件可) + const noMatchRes = await postMessage(axiosInstance, "tools/call", { + name: "search_text", + arguments: { content: "xyznonexistent_phrase_12345", limit: 5 } + }); + if (noMatchRes.error) throw new Error("Issue #11 no-match: JSON-RPC error " + JSON.stringify(noMatchRes.error)); + const noMatchText = noMatchRes.result?.content?.[0]?.text; + if (!noMatchText) throw new Error("Issue #11 no-match: no content[0].text"); + try { + parsed = JSON.parse(noMatchText); + } catch (e) { + throw new Error("Issue #11 no-match: content[0].text is not JSON"); + } + if (!Array.isArray(parsed.items)) throw new Error("Issue #11 no-match: parsed.items is not array"); + if (typeof parsed.vector_search_used !== "boolean") throw new Error("Issue #11 no-match: vector_search_used is not boolean"); + console.log(" No-match query: 200, items.length=" + parsed.items.length + ", vector_search_used=" + parsed.vector_search_used + " (OK)"); +} + function parseResultText(result) { const text = result?.result?.content?.[0]?.text; if (!text) return null; @@ -164,9 +205,9 @@ console.log("\n[7] add_item_text → list_documents → get_document → get_item_by_id → update_item → delete_item → delete_document..."); await postMessage(axiosInstance, "tools/call", { name: "add_item_text", arguments: { path, content } }); - const listRes = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: {} }); + const listRes = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: { limit: 100, page: 1 } }); const list = parseResultText(listRes); - const docList = Array.isArray(list) ? list : []; + const docList = Array.isArray(list) ? list : (list?.items ?? []); const doc = docList.find((d) => d.path === path); if (!doc || doc.id == null) throw new Error("list_documents: added doc not found or no id. list=" + JSON.stringify(docList).slice(0, 200)); @@ -188,9 +229,9 @@ await postMessage(axiosInstance, "tools/call", { name: "delete_item", arguments: { id: chunkId } }); await postMessage(axiosInstance, "tools/call", { name: "delete_document", arguments: { document_id: doc.id } }); - const listRes2 = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: {} }); + const listRes2 = await postMessage(axiosInstance, "tools/call", { name: "list_documents", arguments: { limit: 100, page: 1 } }); const list2 = parseResultText(listRes2); - const docList2 = Array.isArray(list2) ? list2 : []; + const docList2 = Array.isArray(list2) ? list2 : (list2?.items ?? []); if (docList2.some((d) => d.path === path)) throw new Error("delete_document: document still in list"); console.log(" CRUD flow OK."); } @@ -230,6 +271,9 @@ const content = searchResult.result?.content?.[0]?.text; console.log(" Result:\n", content || " No results"); + console.log("\n[2b] Issue #11: search_text empty / no-match returns 200 with items[]..."); + await testSearchEmptyAndNoMatchReturnsUnifiedShape(axiosInstance); + console.log("\n[3] Pro: assert vector search contributes (not all scores 0.4)..."); await assertProVectorScoresNotAllFallback(axiosInstance); diff --git a/tools/scripts/sync_issues.mjs b/tools/scripts/sync_issues.mjs index 4310165..2f17b9e 100644 --- a/tools/scripts/sync_issues.mjs +++ b/tools/scripts/sync_issues.mjs @@ -1,29 +1,15 @@ +/** + * GitBucket / GitHub 互換 API で Issue を docs/issues/ と同期する。 + * 環境変数(.env): GITBUCKET_TOKEN(必須), GITBUCKET_API_BASE, GITBUCKET_REPO(任意・未設定時は dtmoyaji/TelosDB) + */ import fs from 'fs'; import path from 'path'; +import { loadEnv, getToken, getIssuesApiBase } from './gitbucket_env.mjs'; -// If running Node < 20.6, we might need dotenv, but let's try to load .env manually for zero-dependency -function loadEnv() { - const envPath = path.join(process.cwd(), '.env'); - if (fs.existsSync(envPath)) { - const content = fs.readFileSync(envPath, 'utf-8'); - content.split('\n').forEach(line => { - const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); - if (match) { - const key = match[1]; - let value = match[2] || ''; - if (value.length > 0 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') { - value = value.replace(/\\n/gm, '\n'); - } - value = value.replace(/(^['"]|['"]$)/g, '').trim(); - process.env[key] = value; - } - }); - } -} loadEnv(); -const TOKEN = process.env.GITBUCKET_TOKEN; -const API_BASE = "https://gitbucket.tmworks.club/api/v3/repos/dtmoyaji/TelosDB/issues"; +const TOKEN = getToken(); +const API_BASE = getIssuesApiBase(); const ISSUES_DIR = path.join(process.cwd(), 'docs', 'issues'); if (!fs.existsSync(ISSUES_DIR)) { @@ -89,7 +75,7 @@ // 公式 GitBucket では Issue の PATCH(編集)API は全バージョンで未実装のため 404 になる。 // 代替: Issue にコメントを投稿する API(Create an issue comment)は対応している。 -const COMMENTS_BASE = "https://gitbucket.tmworks.club/api/v3/repos/dtmoyaji/TelosDB/issues"; +const COMMENTS_BASE = API_BASE; async function postIssueComment(issueNumber, commentBody) { if (!TOKEN) { @@ -110,7 +96,9 @@ return await apiCall(`${API_BASE}/${number}`, 'PATCH', { title, body, state }); } catch (err) { if (err.message.includes('404')) { - const htmlUrl = `https://gitbucket.tmworks.club/dtmoyaji/TelosDB/issues/${number}`; + const repo = process.env.GITBUCKET_REPO || 'dtmoyaji/TelosDB'; + const base = (process.env.GITBUCKET_API_BASE || 'https://gitbucket.tmworks.club/api/v3').replace(/\/api\/v3\/?$/, ''); + const htmlUrl = `${base}/${repo}/issues/${number}`; console.warn(`\n[Info] GitBucket API (PATCH) returned 404. Issue edit is not implemented in official GitBucket.`); console.warn(`Posting current body as a comment instead...`); const posted = await postIssueComment(number, body);