diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index d42774b02..a6e07907a 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -12,8 +12,8 @@ write, migrate, or interoperate with Graphium files. > > | UI label (EN / JA) | On-disk `WikiKind` | > |---|---| -> | Topics / 話題 | `topic` | -> | Q&A / 問答 | `answer` | +> | Topics / トピック | `topic` | +> | Answers / 問答(Q&A) | `answer` | > | Summaries / 要約 | `summary` | > | Claims / 知見 | `claim` | > | Insights / 洞察 | `atom` | @@ -56,6 +56,34 @@ A few invariants underpin every schema in this document. ## 2. The note: `GraphiumDocument` +### Knowledge Schema system Skill + +The Knowledge Schema is a single additive `GraphiumDocument` with `source: +"skill"` and `skillMeta.systemSkillId: "knowledge-schema"`. Its storage ID is +also the fixed string `knowledge-schema`. It uses the existing `SkillMeta` +default version and prompt hash fields, and the existing +`DocumentProvenance` revision chain for edits, default synchronization, and +Reset. It is not a `WikiKind`, is never projected to the note index, and does +not require a document-version or index-schema-version change. + +On first creation only, the bundled Schema body is chosen from the current +Graphium UI locale (`ja` or `en`) and that language is stored in +`skillMeta.language`. Later locale changes do not translate or overwrite the +saved Schema. Version synchronization and Reset resolve the bundled default +from the saved `skillMeta.language`, so an untouched older English Schema +auto-updates to the current English default, while an edited older Schema only gets the +standard newer-default notification. + +The only operation allowed to change the saved Schema language is the explicit, +confirmed language switch shown while the fixed `knowledge-schema` document is +open. It atomically replaces the body with the selected current bundled default and +updates `skillMeta.language`, version, and default prompt hash while preserving +the document version. Before replacement, the prior full document is stored in +the existing version-snapshot store so it can be restored, and the replacement +is recorded separately as a `knowledge_schema_language_switch` +revision/activity. Ordinary Skill metadata updates still preserve the existing +Schema language. + Each note is a single JSON file (or one row in IndexedDB for the `local` provider). The top-level shape: @@ -315,9 +343,10 @@ type TableSource = { (`.txt` / `.dat` / `.csv`) are turned into ordinary tables, and the conversion parameters — which lines were read, with which delimiter — are kept here rather than thrown away. Dropping them would make the numbers untraceable back to the - raw file, which is exactly the thing this app exists to prevent. The preamble a - converter normally discards (`# Device Model: ENV-MONITOR-X9`) is kept as - `meta`, because for a lab note those lines *are* the measurement conditions. + raw file, which is exactly the thing this app exists to prevent. A preamble a + converter normally discards (for example `# Source: service-export-v4`) is kept + as `meta`, because those lines may carry essential runtime, collection, or + decision context in any domain. When the file was also registered as an asset, `fileId` links the table back to it and the import can be re-run with the stored settings. - **Delimited imports become a `dataTable` block by default.** The block @@ -1020,7 +1049,7 @@ only then created as new. There is no separate ingest-time "consolidation" step and no target member-count per topic — those would be thresholds nobody could justify. Instead, near-duplicate or over-fragmented topics (wording -variants, particle differences, per-sample slices that should share a +variants, particle differences, per-case slices that should share a page) are caught later by two Karpathy-style *lint* mechanisms that look at the whole topic corpus at once, rather than one Claim at a time: diff --git a/manual/ai-chat-and-ask.md b/manual/ai-chat-and-ask.md index d665a8b4f..8771c0f89 100644 --- a/manual/ai-chat-and-ask.md +++ b/manual/ai-chat-and-ask.md @@ -21,7 +21,7 @@ By default the chat can see the note you have open. To focus it on a specific pa | Select multiple blocks → **Ask AI about selection** | Exactly the blocks you selected | | Select text → **Ask AI about selection** in the floating toolbar | The selected text | -Whichever starting point you use, the note you have open is passed as background as well. A quoted sentence that leans on the lines before it — a pronoun, an abbreviation, "at this temperature" — is still understood. The answer stays on the quoted part; the rest of the note is there only for context. It is re-read on every message, so edits you make mid-conversation are picked up. +Whichever starting point you use, the note you have open is passed as background as well. A quoted sentence that leans on the lines before it — a pronoun, an abbreviation, "under these conditions" — is still understood. The answer stays on the quoted part; the rest of the note is there only for context. It is re-read on every message, so edits you make mid-conversation are picked up. When a chat starts from blocks or a selection, the passed content stays visible at the top of the panel under **Quote**, so you always know what the conversation is about. @@ -191,3 +191,5 @@ Each skill's **Edit** dialog controls: | **Auto-apply on Ingest** | When on, the skill's prompt is automatically appended as instructions when Graphium generates knowledge from your notes — and in AI chat conversations | Skills are ordinary documents: click one to edit its prompt body like any note. + +The built-in **Knowledge Schema** is the exception to auto-apply: Graphium always supplies its saved body as a separate instruction when an assistant helps with Knowledge, or when Topics, Answers, and Claims are generated. The first saved default follows the UI language at creation time and does not change when you later switch the UI. To replace it deliberately, open the Schema and use the confirmed **Switch to Japanese** or **Switch to English** action; the previous body stays recoverable in History. It defines structure, citations, revision, and upkeep rather than writing voice; it can be edited and reset like a system skill, but cannot be deleted. diff --git a/manual/ja/ai-chat-and-ask.md b/manual/ja/ai-chat-and-ask.md index 1bc798074..0f2886e97 100644 --- a/manual/ja/ai-chat-and-ask.md +++ b/manual/ja/ai-chat-and-ask.md @@ -21,7 +21,7 @@ | 複数ブロックを選択 → **選択範囲を AI に聞く** | 選択したブロックだけ | | テキストを選択 → フローティングツールバーの **選択範囲を AI に聞く** | 選択したテキスト | -どの起点で始めても、開いているノートの本文が背景として一緒に渡ります。引用した一文が前の行に寄りかかっている場合 — 指示語、略語、「この温度で」といった書き方 — でも話が通じます。回答の主題は引用した部分のままで、残りの本文は背景として添えられるだけです。本文はメッセージのたびに読み直されるので、会話の途中で加えた編集も反映されます。 +どの起点で始めても、開いているノートの本文が背景として一緒に渡ります。引用した一文が前の行に寄りかかっている場合 — 指示語、略語、「この条件で」といった書き方 — でも話が通じます。回答の主題は引用した部分のままで、残りの本文は背景として添えられるだけです。本文はメッセージのたびに読み直されるので、会話の途中で加えた編集も反映されます。 ブロックや選択範囲からチャットを始めると、渡した内容がパネル上部の **引用** の下に表示され続けます。何についての会話なのかがいつでも分かります。 @@ -122,15 +122,15 @@ AI の返信には、それぞれ下にアクションボタンが付きます | **置換** | 元のブロックを回答で置き換えます(特定のブロックからチャットを始めた場合に表示されます) | | **別ノートとして派生** | 質問と回答から新しいノートを作り、元ノートへの来歴リンクを張ってサイドピークで開きます | | **ナレッジにする** | 回答からナレッジ候補を抽出します(下記) | -| **ナレッジに残す** | 回答をそのまま、ナレッジ層の問答ページとして保存します(下記) | +| **ナレッジに残す** | 回答をナレッジ層の問答(Q&A)ページとして保存します(下記) | ### チャットからナレッジを取り出す **ナレッジにする** は、良い回答を[ナレッジ層](/ja/knowledge-layer)のエントリに変えます。選んでいないものは何も保存されません。Graphium は「知見を抽出中…」、続いて「洞察を生成中…」と表示したあと、**ナレッジ候補(選んで保存)** というピッカーを出します。各候補には **知見** または **洞察** のバッジが付きます。**すべて選択** / **選択解除** で調整して **選択した n 件を保存** を押し、何も残したくなければ **キャンセル** で閉じます。ナレッジページになるのは、選んだ候補だけです。 -### 問答をまるごと残す +### 問答(Q&A)をまるごと残す -回答がすでに 1 つのまとまった説明として読めて、知見・洞察に分ける必要が無いなら、代わりに **ナレッジに残す** を押します。Graphium はまず AI をもう一度呼び、それまでの会話を渡して、その問答を単体で読める [問答ページ](/ja/knowledge-layer#種類) に書き起こします — 会話に寄りかかった回答で省かれていた前提を補い、タイトルも依頼の言い回しから離して書き直します。回答に表示されていた引用は、書き起こした文にそのまま引き継がれます。保存前に見直す画面はありません。書き起こしに失敗したとき、または元の回答にあった出典が 1 つも残らなかったときは、Graphium はメッセージの文章とタイトル(尋ねた質問)でそのまま保存します。保存が終わるとボタンは、新しいページへの **開く** リンク付きの確認表示に変わります。保存後はトピックと同じように保守され、資料が回答を更新・反証したときに改訂され、点検・出典照合の対象にもなります。詳しくは [ナレッジ層](/ja/knowledge-layer#問答をまるごと残す) を参照してください。 +回答がすでに 1 つのまとまった説明として読めて、知見・洞察に分ける必要が無いなら、代わりに **ナレッジに残す** を押します。Graphium はまず AI をもう一度呼び、それまでの会話を渡して、その問答を単体で読める [問答(Q&A)ページ](/ja/knowledge-layer#save-answer-page) に書き起こします — 会話に寄りかかった回答で省かれていた前提を補い、タイトルも依頼の言い回しから離して書き直します。回答に表示されていた引用は、書き起こした文にそのまま引き継がれます。保存前に見直す画面はありません。書き起こしに失敗したとき、または元の回答にあった出典が 1 つも残らなかったときは、Graphium はメッセージの文章とタイトル(尋ねた質問)でそのまま保存します。保存が終わるとボタンは、新しいページへの **開く** リンク付きの確認表示に変わります。保存後はトピックと同じように保守され、資料が回答を更新・反証したときに改訂され、点検・出典照合の対象にもなります。詳しくは [ナレッジ層](/ja/knowledge-layer#save-answer-page) を参照してください。 ## Composer(⌘K) @@ -191,3 +191,5 @@ AI の返信には、それぞれ下にアクションボタンが付きます | **ナレッジ化時に自動適用する** | オンにすると、Graphium がノートからナレッジを生成するとき、そのスキルのプロンプトが指示として自動で追加されます。AI チャットの会話でも同じように使われます | スキルはふつうのドキュメントです。クリックすれば、ノートと同じようにプロンプト本文を編集できます。 + +組み込みの**ナレッジスキーマ**は自動適用スキルとは別です。アシスタントがナレッジ生成を支援するとき、およびトピック・問答(Q&A)・知見の生成時に、保存済み本文を独立した指示として渡します。初回保存される既定本文はその時点の UI 言語に従い、その後に UI 言語を変えても自動では切り替わりません。意図して置換するときだけ、ナレッジスキーマを開いて確認付きの **日本語版に切替** または **英語版に切替** を使います。置換前の本文は履歴から復元できます。文体ではなく構造、引用、改訂、点検を定義し、組み込みスキルと同様に編集・リセットできますが削除はできません。 diff --git a/manual/ja/knowledge-layer.md b/manual/ja/knowledge-layer.md index d88e63ca1..4af163b8a 100644 --- a/manual/ja/knowledge-layer.md +++ b/manual/ja/knowledge-layer.md @@ -6,6 +6,14 @@ ナレッジ層は AI バックエンドがあって初めて動きます。バックエンドは[デスクトップアプリ](/ja/desktop-app)に同梱されており、モデルが 1 つ以上登録されている必要があります。[AI のセットアップ](/ja/ai-setup)を参照してください。ブラウザ版のプレビューでは、**ナレッジ** セクションにナレッジの中身ではなくデスクトップ版への案内が表示されます。 ::: +## ナレッジスキーマ + +**ナレッジスキーマ**は、**スキル**一覧にある1件だけの編集可能な組み込み文書です。トピック・問答(Q&A)・知見の生成、出典引用、段階的な改訂、点検の規約を定義します。文体(Voice)とは分離されており、コード側の引用照合、構造化出力検証、安全規則は変更しません。 + +ナレッジスキーマはスキル保存領域に1件だけ作られます。初回作成時だけ、保存される既定本文はその時点の UI 言語に従います。日本語 UI なら日本語本文、英語 UI なら英語本文です。その後に UI 言語を変えても、本文は自動翻訳・上書き・切り替えされません。意図して変更する場合はナレッジスキーマを開き、バナーの **日本語版に切替** または **英語版に切替** を選びます。Graphium は確認後に対象言語の同梱既定本文へ置換し、置換前の本文は履歴から復元できます。 + +通常ノートでもナレッジページでもないため、検索、ナレッジ件数、グラフには含まれません。編集は以後の生成だけに効き、既存ナレッジを自動再生成しません。組み込みスキルと同様に削除できず、未編集なら保存言語と同じ言語の新しい既定版へ同期されます。編集済みなら更新通知だけを表示し、**デフォルトに戻す**を選ぶまで本文は変わりません。リセットも保存言語と同じ言語の既定本文を使います。編集、既定版同期、リセット、明示的な言語切替は、それぞれ区別して履歴に残ります。 + ## ノートとナレッジ 実験ノートは「火曜日に何が起きたか」に答えます。ナレッジページは「いま自分は何を知っているか」に答えます。Graphium は両方を保ち、一方向にリンクします。ナレッジページの末尾には必ず **References** セクションがあり、その **Source:** の項目が蒸留元のノートへ戻るリンクになっています。いつでも元の文脈に降りていけます。 @@ -14,12 +22,12 @@ ## 種類 -Graphium のナレッジは砂時計の形をたどります。文脈の濃いノートが短い一般的な記述へと絞られ、そこからまた自分の仕事全体へと外向きにつながっていきます — **ノート → 知見 → 洞察** です。この背後にある推論のモデルは [Inference types in Graphium](https://github.com/kumagallium/Graphium/blob/main/docs/inference-types.md) にまとめてあります。トピック(Topic)と問答(Q&A)はこの砂時計の外側にあります。知見ではなく元の資料(問答の場合はチャットのやり取り)を直接読み、洞察の発見には渡されません。 +Graphium のナレッジは砂時計の形をたどります。文脈の濃いノートが短い一般的な記述へと絞られ、そこからまた自分の仕事全体へと外向きにつながっていきます — **ノート → 知見 → 洞察** です。この背後にある推論のモデルは [Inference types in Graphium](https://github.com/kumagallium/Graphium/blob/main/docs/inference-types.md) にまとめてあります。トピックと問答(Q&A)はこの砂時計の外側にあります。知見ではなく元の資料(問答(Q&A)の場合はチャットのやり取り)を直接読み、洞察の発見には渡されません。 | 種類 | 何であるか | |---|---| | **トピック** | 概念ごとに束ねる元の資料(ノート・PDF・Word・URL・チャット)から直接組み立てるページ。出典は 1 ホップ(トピック → 資料) | -| **問答** | [AI チャット](/ja/ai-chat-and-ask)の良い回答を 1 件残すページ。タイトルは尋ねた質問、出典は根拠にしたノート・素材 | +| **問答(Q&A)** | [AI チャット](/ja/ai-chat-and-ask)の良い回答を 1 件残すページ。タイトルは尋ねた質問、出典は根拠にしたノート・素材 | | **知見** | ノートや資料から取り出した命題(1 件に 1 つ) | | **洞察** | 知見から取り出した、他の分野でも通じる関係の型 | @@ -64,7 +72,7 @@ Graphium にはかつて 4 つ目の種類として **要約** — 1 つのノ ## ナレッジを見る -サイドバーには **ナレッジ** セクション(既定では畳まれています)があり、**トピック**・**問答**・**知見**・**洞察** が件数付きで並びます(以前の要約が残っていれば末尾に **以前の要約** も出ます)。種類を押すと一覧ビューが開き、次のことができます。 +サイドバーには **ナレッジ** セクション(既定では畳まれています)があり、**トピック**・**問答(Q&A)**・**知見**・**洞察** が件数付きで並びます(以前の要約が残っていれば末尾に **以前の要約** も出ます)。種類を押すと一覧ビューが開き、次のことができます。 - 列: **タイトル**、**種別**、**生成元**(生成元ノートの数。トピックでは、束ねている知見の件数になります)、**参照先** / **被参照**、**モデル**、**作成日**、**更新日**、**世界**(最新の[世界照合](/ja/ai-grounding)の判定) - 検索、列ごとの種別フィルタ、並べ替え、ドラッグまたは Shift+クリックでの範囲選択 @@ -89,7 +97,7 @@ Graphium にはかつて 4 つ目の種類として **要約** — 1 つのノ ## トピックの統合 -トピックは表記ゆれ・助詞の有無・細かすぎる試料単位のタイトルなどで、同じ概念なのに分かれてしまうことがあります。統合には 4 つの入口があり、コーパス全体をまとめて AI に判断させたい場合以外は設定を開く必要はありません。 +トピックは表記ゆれ・助詞の有無・細かすぎる事例単位のタイトルなどで、同じ概念なのに分かれてしまうことがあります。統合には 4 つの入口があり、コーパス全体をまとめて AI に判断させたい場合以外は設定を開く必要はありません。 - **トピックの一覧** — 2 件以上のトピックを選んで **統合** を押す。残すトピックを選びます。 - **トピックページ自身のバナー** — 近くに候補が見つかったときだけ **似たトピック** のチップが出ます(正規化タイトルの一致、または埋め込みモデル設定時は類似度による判定)。**統合** を押すと、今見ているページへ吸収します。 @@ -142,13 +150,13 @@ Graphium にはかつて 4 つ目の種類として **要約** — 1 つのノ [AI チャット](/ja/ai-chat-and-ask)の回答に残しておきたいものがあれば、回答の下の **ナレッジにする** を押します。返答をまるごと保存するのではなく、Graphium は **ナレッジ候補(選んで保存)** というピッカーに個別の候補を並べます。それぞれに **知見** か **洞察** のバッジ、タイトル、プレビューが付きます。欲しいものにチェックを入れ(または **すべて選択**)、**選択した 2 件を保存** を押します。ナレッジに入るのは、選んだものだけです。 -## 問答をまるごと残す +## 問答(Q&A)をまるごと残す {#save-answer-page} **ナレッジにする** は回答を知見・洞察へ分解します。そうではなく、回答がそのまま良い説明になっていて、尋ねた質問で後から見つけたい — というときは、同じメッセージの下にある **ナレッジに残す** を押します。保存する前に、Graphium は AI をもう一度呼び、それまでの会話を渡した上でその問答を単体で読める記事に書き起こします。「その 1 番目について、どのくらいから試すのが妥当ですか。短くお願いします。」のように会話に寄りかかった回答は、指示語や省略が補われ、タイトルも依頼の言い回し(「短くお願いします」など)を落として単体で意味の通る題になります。回答に表示されていた引用は、書き起こした文にそのまま引き継がれます。保存が終わると、**開く** リンク付きの確認が表示されます。 書き起こしに失敗したとき(AI が設定されていない、呼び出しでエラーになったなど)でも保存は止まりません — これまでどおり回答の文章とタイトル(尋ねた質問)でそのまま保存します。書き起こした結果、元の回答にあった出典が 1 つも残らなかったときも同様にそのまま保存にフォールバックします — 読みやすさのために出典を失う方を避けるためです。共有ノートや素材のチャットでは使えませんが、ノートエディタのチャットパネルに加えて、ノートを開かない[サイドバーのチャット](/ja/ai-chat-and-ask#ノートを開かずに話す)からも使えます。 -保存された後の問答ページはトピックと同じように保守されます。手入れの **点検**(空ページ・出典欠落・古さの各判定)の対象になり、出典照合の対象にもなり、資料が変わったときは **再生成** で資料から作り直せます。新しいノートが既存の問答ページを改訂するのは、そのノートが実際に回答を更新・反証するときだけです — それ以外は問答ページを話題から逸らさず、そのノート自身が新しいトピックになります。 +保存された後の問答(Q&A)ページはトピックと同じように保守されます。手入れの **点検**(空ページ・出典欠落・古さの各判定)の対象になり、出典照合の対象にもなり、資料が変わったときは **再生成** で資料から作り直せます。新しいノートが既存の問答(Q&A)ページを改訂するのは、そのノートが実際に回答を更新・反証するときだけです — それ以外は問答(Q&A)ページを話題から逸らさず、そのノート自身が新しいトピックになります。 ## 全体グラフのなかのナレッジ diff --git a/manual/ja/mcp-server.md b/manual/ja/mcp-server.md index 5f495b73d..6344682f5 100644 --- a/manual/ja/mcp-server.md +++ b/manual/ja/mcp-server.md @@ -111,11 +111,11 @@ pnpm bundle:mcp どちらも vault に新しいものを書き足しますが、置き場所が違う層で、Graphium がその後の扱い方も層ごとに変えます。 - **`create_note`** は**ノート**を作ります — 人が保守するものです。Graphium はその後、自分からは一切手を触れません。 -- **`save_answer`** は**問答ページ**を作ります — ナレッジ層のページです(アプリ内チャットの「ナレッジに残す」ボタンが作るのと同じ `answer` 種別)。作られたあとは、関連する資料が取り込まれるたびに Graphium が改訂し、ほかのナレッジ層のページと同じく点検・出典照合の対象になります。アプリ内のボタンとの違いが1つあります。「ナレッジに残す」は保存前に会話を渡して単体で読める記事に書き起こしますが、`save_answer` は外部クライアント側の会話に Graphium から手が届かないため、渡された `question`/`answer` をそのまま保存します。本文はあらかじめ単体で読める形にして渡してください。 +- **`save_answer`** は**問答(Q&A)ページ**を作ります — ナレッジ層のページです(アプリ内チャットの「ナレッジに残す」ボタンが作るのと同じ `answer` 種別)。作られたあとは、関連する資料が取り込まれるたびに Graphium が改訂し、ほかのナレッジ層のページと同じく点検・出典照合の対象になります。アプリ内のボタンとの違いが1つあります。「ナレッジに残す」は保存前に会話を渡して単体で読める記事に書き起こしますが、`save_answer` は外部クライアント側の会話に Graphium から手が届かないため、渡された `question`/`answer` をそのまま保存します。本文はあらかじめ単体で読める形にして渡してください。 ノートが増えるにつれて Graphium に面倒を見続けてほしいものは `save_answer`、自分で保守するつもりの一回きりの記録は `create_note` を使います。 -`save_answer` に渡す本文では、**根拠のある文の文末に `[[source:]]` を書いてください**(id は `citations` に渡すもの。同じことを複数の資料が述べているなら並べて書けます)。出典照合は文ごとに「その文が引いた資料に本当に書いてあるか」を照らすので、本文に引用が 1 つも無い問答ページは照合の対象になりません。引用が無いまま保存すると、ツールの返事でその旨を知らせます。 +`save_answer` に渡す本文では、**根拠のある文の文末に `[[source:]]` を書いてください**(id は `citations` に渡すもの。同じことを複数の資料が述べているなら並べて書けます)。出典照合は文ごとに「その文が引いた資料に本当に書いてあるか」を照らすので、本文に引用が 1 つも無い問答(Q&A)ページは照合の対象になりません。引用が無いまま保存すると、ツールの返事でその旨を知らせます。 `save_answer` の引数: @@ -182,13 +182,13 @@ Graphium はノートの上にナレッジ層を組み立てます。**知見(cl ## あえてやらないこと -**既存のノートを書き換えることはありません。** `create_note` は新しいノートを足すだけです。`save_answer` も新しい問答ページを足すだけで、あなたのノートには触れません(ただし作られた問答ページ自体は、ノートと違って Graphium のナレッジ層の保守によって後から改訂されます)。MCP 経由でアシスタントが行うどの操作も、あなたが書いたものを上書きしません。 +**既存のノートを書き換えることはありません。** `create_note` は新しいノートを足すだけです。`save_answer` も新しい問答(Q&A)ページを足すだけで、あなたのノートには触れません(ただし作られた問答(Q&A)ページ自体は、ノートと違って Graphium のナレッジ層の保守によって後から改訂されます)。MCP 経由でアシスタントが行うどの操作も、あなたが書いたものを上書きしません。 **来歴を作り出すことはしません。** 実験について会話すれば来歴グラフができあがる、と期待されるかもしれませんが、そうはしていません。これは意図的なものです。来歴は、実際に起きたことの記録です。会話から組み立て直したグラフは、見た目は同じでも意味が違います。それはあなたの手順についての推測であり、照らし合わせる相手がありません。Graphium は、エディタであなたが行ったことから来歴を記録します。モデルが「たぶんこうしたのだろう」と推測したものからは記録しません。 一方で、**書き込みが起きたという事実そのもの**は自動で記録されます。MCP 経由で作られたノートには、誰の依頼で、どのクライアントを通って、どのモデルが書いたのかが残ります。これは推測ではなく観測なので、記録として残せます。 -**MCP で作ったノート・問答ページは再読み込み後に現れます。** どちらの一覧もアプリ側が組み立てるため、Graphium を開いたまま書き込んだものは、次に再読み込みするか再起動したときに一覧に出ます。 +**MCP で作ったノート・問答(Q&A)ページは再読み込み後に現れます。** どちらの一覧もアプリ側が組み立てるため、Graphium を開いたまま書き込んだものは、次に再読み込みするか再起動したときに一覧に出ます。 ## うまくいかないとき diff --git a/manual/ja/roadmap.md b/manual/ja/roadmap.md index 62263e2d8..c5d6afba9 100644 --- a/manual/ja/roadmap.md +++ b/manual/ja/roadmap.md @@ -104,9 +104,9 @@ Graphium は 2026 年 3 月から小さなリリースを重ねてきました | **v0.76.0** | 2026-09-16 | 取り込みのレポートから [まとめてナレッジ化](/ja/materials-and-citations#bring-in-what-you-already-have) — 今回持ち込んだものをまとめてナレッジ化のパイプラインに流し、件別のトーストで進みます。[回答の根拠](/ja/ai-chat-and-ask#choosing-what-grounds-the-answer) はトピック・知見・洞察・ノート本文を区別して渡すようになり、引用がどの種別に基づくか分かります。[MCP サーバー](/ja/mcp-server) からもナレッジ層を読めるようになりました — 索引を返す `list_topics`、トピックとその知見・出どころノートを一度に返す `get_topic`、そして `search_notes`・`get_note`・`trace_lineage` もナレッジ層まで辿ります。 | | **v0.77.0** | 2026-09-17 | [出典照合](/ja/ai-grounding) — 知見とトピックの要点が、引いている出典に本当に書いてあるかを確かめられます。判定は「出典に書かれている」「出典と異なる」「出典に見当たらない」などに分かれ、原文からの引用つきで残ります。点検の画面は **ナレッジの手入れ** になり、出典照合のタブと、確認が要るものを溜める一覧が加わりました。AI の判定で知見が一覧から隠れることはなく、アーカイブするかはあなたが決めます。取り込みのたびに自動で照合する設定もあります(既定はオフ)。 | | **v0.78.0** | 2026-09-17 | [トピックが資料から作られる](/ja/knowledge-layer#種類)ようになりました(カーパシーの LLM Wiki と同じ方式)。ノート・PDF・Word・URL・チャットをナレッジに追加すると、AI が資料を読んで関係するトピックを選び、資料を直接引きながら本文を改訂します。統合しても引用はすべて残り、以前のトピックは実行前に AI の呼び出し回数を確かめてから[資料から作り直せ](/ja/knowledge-layer#トピックを資料から作り直す)ます。[知見と洞察](/ja/settings#ai)は ON/OFF できる拡張になり、新しく使い始めるときはオフです。裏で走る点検は AI を呼ばず、勝手に統合もしなくなりました。1 ノートあたり知見 3 件までの上限もなくなりました。 | -| **v0.79.0** | 2026-09-18 | [問答ページ](/ja/knowledge-layer#問答をまるごと残す) — AI チャットの良い回答を、尋ねた質問をタイトルにして 1 枚のナレッジページとして残せます。保存後はトピックと同じように AI が保守し、点検・出典照合・資料からの再生成の対象になります。[MCP サーバー](/ja/mcp-server)の `save_answer` を使えば、お使いのチャットからも同じページを作れます。ナレッジページを自分で編集したことがある場合、AI が書き換える直前に[版が自動で残る](/ja/notes-and-editor#history-and-versions)ようになりました。自動の点検を AI 解析(フル)にする[設定](/ja/settings#ai)も加わりました(既定はオフ)。 | +| **v0.79.0** | 2026-09-18 | [問答(Q&A)ページ](/ja/knowledge-layer#save-answer-page) — AI チャットの良い回答を、尋ねた質問をタイトルにして 1 枚のナレッジページとして残せます。保存後はトピックと同じように AI が保守し、点検・出典照合・資料からの再生成の対象になります。[MCP サーバー](/ja/mcp-server)の `save_answer` を使えば、お使いのチャットからも同じページを作れます。ナレッジページを自分で編集したことがある場合、AI が書き換える直前に[版が自動で残る](/ja/notes-and-editor#history-and-versions)ようになりました。自動の点検を AI 解析(フル)にする[設定](/ja/settings#ai)も加わりました(既定はオフ)。 | | **v0.80.0** | 2026-09-18 | [長い資料を窓に分けて読む](/ja/knowledge-layer#ノートとナレッジ) — これまで PDF は先頭 80,000 字までしか読まれず、100 ページ級の論文は後半がどの機能からも見えていませんでした。トピックは資料の全文を、少し重なる窓に分けて読み、窓ごとに振り分け・改訂します。進捗には窓の枚数が出て、**停止**は次の窓の境目で効きます。[出典照合](/ja/ai-grounding#出典照合-出典に実際に書いてあるか)も、引用が出典のどこにあったかを表示します(PDF は **12 ページ**、Word は **第 3 段落**)。照合がその場で読んだ原文から機械的に割り出し、モデルには書かせていません。 | -| **v0.81.0** | 2026-09-18 | [ノートを開かずに話せるチャット](/ja/ai-chat-and-ask#ノートを開かずに話す) — 左サイドバーの **チャット** から、どのノートについてでもない会話ができます。答えるときは、ノート内のチャットと同じように自分のノートとナレッジを横断して探すので、手元に書いたことを踏まえた答えが返ります。⌘K の **AI に質問** はノートを開いていなくても使えるようになり、押すとこのチャットで会話が始まります(ノートを開いていれば、そのノートが引用として添います)。一覧はノート一覧と同じ表で、行を選ぶとピーク、そこから全画面へ。会話には **編集して再実行**・**回答を生成し直す**・**ここから新しいチャット**が付き、良い回答は **ナレッジに残す** で[問答ページ](/ja/knowledge-layer#問答をまるごと残す)になります。問答ページは、会話の文脈を補って単体で読める形に書き起こしてから保存されます。ナレッジの **回答** は **問答** に名前が変わりました。 | +| **v0.81.0** | 2026-09-18 | [ノートを開かずに話せるチャット](/ja/ai-chat-and-ask#ノートを開かずに話す) — 左サイドバーの **チャット** から、どのノートについてでもない会話ができます。答えるときは、ノート内のチャットと同じように自分のノートとナレッジを横断して探すので、手元に書いたことを踏まえた答えが返ります。⌘K の **AI に質問** はノートを開いていなくても使えるようになり、押すとこのチャットで会話が始まります(ノートを開いていれば、そのノートが引用として添います)。一覧はノート一覧と同じ表で、行を選ぶとピーク、そこから全画面へ。会話には **編集して再実行**・**回答を生成し直す**・**ここから新しいチャット**が付き、良い回答は **ナレッジに残す** で[問答(Q&A)ページ](/ja/knowledge-layer#save-answer-page)になります。問答(Q&A)ページは、会話の文脈を補って単体で読める形に書き起こしてから保存されます。ナレッジの **回答** は **問答(Q&A)** に名前が変わりました。 | ## 要望をお寄せください diff --git a/manual/ja/settings.md b/manual/ja/settings.md index f605bf885..68e768c0f 100644 --- a/manual/ja/settings.md +++ b/manual/ja/settings.md @@ -77,13 +77,15 @@ AI 関連のすべて。まず状態表示、次にモデルの登録、任意 ## ナレッジ管理 +編集可能な**ナレッジスキーマ**はこのタブではなく、サイドバーの**スキル**一覧にあります。これは1件だけの組み込み文書であり、ナレッジ件数、検索、グラフには含まれません。初回作成時だけ、その時点の UI 言語に合わせた既定本文が保存されます。その後に UI 言語を変えても自動翻訳・上書きされません。意図して別言語の同梱既定本文へ置換するときは、ナレッジスキーマを開いて確認付きの言語切替を使います。保存した規約は以後のトピック・問答(Q&A)・知見の生成へ適用され、編集、言語切替、リセットで既存ページが自動再生成されることはありません。 + [ナレッジ層](/ja/knowledge-layer)のメンテナンス作業です。いずれも LLM を呼ぶので、各ジョブは実行前に確認を求め、トークンのコストは[使用量](#使用量)タブに記録されます。 | グループ | 何ができるか | |---|---| | **接続状態** | バックエンドの各コンポーネントの状態を表示します。デスクトップアプリでは **バックエンドを再起動** ボタンも出ます。 | | **全ナレッジの埋め込みを作り直す** | AI チャットの引用検索を支える埋め込みを作り直します。引用の検索が動かなくなったときに使います。索引に今選んでいる埋め込みモデルのベクトルが残っているかも(全件走査せず)軽く確認し、モデルが変わって作り直しが要りそうなときはここに 1 行の案内を出します——費用が API キーにかかるため、自動では作り直しません。 | -| **トピックを整理** | 同じ概念を指す既存のトピックどうし(表記ゆれ・助詞の有無・細かすぎる試料単位のトピックなど)を統合し、ページをまとめます。トピックページどうしの統合だけを行い、知見をトピックへ割り当てることはしません(2026-09-17 変更)。統合されたトピックは完全削除ではなくゴミ箱へ送られます。 | +| **トピックを整理** | 同じ概念を指す既存のトピックどうし(表記ゆれ・助詞の有無・細かすぎる事例単位のトピックなど)を統合し、ページをまとめます。トピックページどうしの統合だけを行い、知見をトピックへ割り当てることはしません(2026-09-17 変更)。統合されたトピックは完全削除ではなくゴミ箱へ送られます。 | | **ナレッジの一括再生成** | プロンプトやモデルを変えたあと、既存のナレッジページを作り直します。**対象の種類** のフィルタ(**トピック** / **知見** / **洞察** — 要約は新規生成を停止済みのため対象外)、任意のモデル指定、キャンセル、**失敗したナレッジだけ再試行** に対応しています。トピックは資料から作り直すため、トピックを含む一括実行はページ数ではなく資料数ぶん AI を呼びます — 対象件数の横に AI 呼び出し回数を出し、トピックを含むときは実行前に確認します。 | | **知見をまたぐ洞察を発見** | 知見を似たもの同士のクラスタに分けて順にスキャンし、知見の中にある関係の型を洞察として取り出します(同じ型の知見は 1 件にまとめます)。すべての知見が一度は視野に入るまで回り、必要な LLM 呼び出し回数は手持ちの知見から計算して実行前に表示されます。途中で停止もできます。 | diff --git a/manual/knowledge-layer.md b/manual/knowledge-layer.md index 17f621ae9..7565221ce 100644 --- a/manual/knowledge-layer.md +++ b/manual/knowledge-layer.md @@ -6,6 +6,14 @@ Notes are your working memory: dated, contextual, full of detail. The Knowledge The Knowledge layer runs only with an AI backend, which ships inside the [desktop app](/desktop-app), with at least one model registered. See [AI setup](/ai-setup). In the browser preview the **Knowledge** section shows an upgrade notice instead. ::: +## Knowledge Schema + +**Knowledge Schema** is one built-in, editable document in the **Skill** list. It defines the generation conventions for Topics, Answers, and Claims: source citations, incremental revision, and upkeep. It is separate from the writing Voice and does not change Graphium's code-enforced citation checks, structured output validation, or safety rules. + +Graphium creates exactly one Schema in the Skill storage area. On first creation only, the saved default follows the current UI language: Japanese UI gets the Japanese Schema body, English UI gets the English body. After that, changing the UI language does not automatically translate, overwrite, or switch the Schema body. To change it deliberately, open the Knowledge Schema and choose **Switch to Japanese** or **Switch to English** in its banner. Graphium asks for confirmation before replacing the body with that language's bundled default; the previous body remains recoverable from History. + +It is not a note or a knowledge page, so it is not included in search, Knowledge counts, or the graph. Edits affect later generation only; they never regenerate existing knowledge automatically. Like other built-in skills, it cannot be deleted. A new app default updates an untouched Schema automatically in the Schema's saved language; an edited Schema shows an update badge and remains unchanged until you choose **Reset to default**, which also uses that saved language. Editing, default synchronization, Reset, and explicit language switches leave distinct entries in the document History. + ## Notes vs. knowledge A lab note answers "what happened on Tuesday". A knowledge page answers "what do I now know". Graphium keeps both, linked in one direction: every knowledge page ends with a **References** section whose **Source:** entries link back to the notes it was distilled from, so you can always drop back into the original context. @@ -89,7 +97,7 @@ The body ends with a **References** section linking back to sources. Keep in min ## Merging topics -Topics can drift apart over wording, particles, or an overly narrow per-sample title even when they're the same concept. There are four ways to merge them, none of which need you to open Settings unless you want the AI to judge the whole corpus at once: +Topics can drift apart over wording, particles, or an overly narrow per-case title even when they're the same concept. There are four ways to merge them, none of which need you to open Settings unless you want the AI to judge the whole corpus at once: - **Topics list** — select 2 or more Topics and press **Merge**; pick which one to keep. - **A topic's own banner** — shows a **Similar topics** chip when a candidate is found nearby (same normalized title, or embedding similarity when an embedding model is set); press **Merge** to absorb it into the page you're viewing. diff --git a/manual/settings.md b/manual/settings.md index c46138b83..97487af45 100644 --- a/manual/settings.md +++ b/manual/settings.md @@ -77,13 +77,15 @@ A browser for the knowledge base that [world grounding](/ai-grounding) checks ag ## Knowledge +The editable **Knowledge Schema** lives in the sidebar's **Skill** list rather than this tab. It is one built-in document, excluded from Knowledge counts, search, and graphs. On first creation only, Graphium saves the default body in the current UI language; later UI language changes do not translate or overwrite it. Open the Schema and use the confirmed language-switch action when you deliberately want to replace it with the other bundled language default. Its saved rules apply to later Topic, Answer, and Claim generation; changing, switching, or resetting it does not automatically regenerate existing pages. + Maintenance jobs for the [Knowledge layer](/knowledge-layer). These run LLM calls, so each job asks for confirmation and reports token cost on the [Usage](#usage) tab. | Group | What it does | |---|---| | **Connection Status** | Shows the health of each backend component, with a **Restart backend** button on the desktop app. | | **Re-embed all Knowledge** | Rebuilds the embeddings behind AI chat citation search — use it if citation lookup stops working. Graphium also checks (cheaply, without a full scan) whether the index still has any vectors from the currently selected embedding model, and shows a one-line notice here if it looks like the model changed and the index needs a rebuild — nothing rebuilds automatically, since it spends your embedding API budget. | -| **Organize topics** | Consolidates existing topics that name the same concept (wording variants, particle differences, over-fragmented per-sample topics) and merges their pages. It only consolidates topic pages — it doesn't assign or reassign Claims to topics (changed 2026-09-17). Consolidated topics are sent to Trash, not deleted outright. | +| **Organize topics** | Consolidates existing topics that name the same concept (wording variants, particle differences, over-fragmented per-case topics) and merges their pages. It only consolidates topic pages — it doesn't assign or reassign Claims to topics (changed 2026-09-17). Consolidated topics are sent to Trash, not deleted outright. | | **Bulk regenerate Knowledge** | Rebuilds existing Knowledge pages after you change prompts or models, with **Target kinds** filters (**Topics** / **Claims** / **Insights** — Summaries are no longer generated and are not a regenerate target), an optional model override, cancel support, and **Retry failed only**. Topics are rebuilt from their sources, so a batch that includes Topics issues one AI call per source, not one per page — the AI-call total is shown next to the target count, and confirmed before running whenever Topics are included. | | **Discover Insights from Claims** | Scans your Claims cluster by cluster until every one has been in view at least once, pulling out the relationship patterns in them as Insights (Claims that share a pattern are folded into one). The number of LLM calls needed is measured from your corpus and shown before running; you can stop anytime. | diff --git a/src/features/ai-assistant/api.ts b/src/features/ai-assistant/api.ts index b5125ff73..a826d192f 100644 --- a/src/features/ai-assistant/api.ts +++ b/src/features/ai-assistant/api.ts @@ -5,6 +5,9 @@ import { apiBase, isTauri } from "../../lib/platform"; import type { GroundingScope } from "../../lib/grounding-scope"; import { aiErrorFromResponse } from "../../lib/ai-error"; import { getEnabledMcpServers, getDefaultLLMModel, getChatSynthesisLLMModel, getChatSynthesisModelName } from "../settings/store"; +import { getActiveProvider } from "../../lib/storage/registry"; +import { migrateToLatest } from "../../lib/document-migration"; +import { loadKnowledgeSchemaPrompt } from "../skill/skill-service"; /** * Registry URL・LLM API キーが設定されている場合はヘッダーに含める。 @@ -71,6 +74,8 @@ export type AgentRunRequest = { grounding_scope?: GroundingScope; /** 構造化出力用のシステムプロンプトに使う言語("en" | "ja") */ language?: string; + /** 保存済み Knowledge Schema。通常 Skill の custom_instructions とは別に注入する。 */ + knowledge_schema?: string; options?: { max_turns?: number; model?: string; @@ -184,10 +189,17 @@ export async function runAgent( req: AgentRunRequest, signal?: AbortSignal, ): Promise { + // Assistant の全経路で同じ保存済み Schema を使う。欠落・読み込み失敗時は + // 既定本文へ置換せず、呼び出し元の既存エラー表示へ伝播する。 + const knowledgeSchema = req.knowledge_schema ?? await loadKnowledgeSchemaPrompt(async (id) => { + const provider = getActiveProvider(); + if (!provider.loadSkillFile) throw new Error("Skill 非対応のストレージプロバイダーです"); + return migrateToLatest(await provider.loadSkillFile(id), id); + }); const res = await fetch(`${apiBase()}/agent/run`, { method: "POST", headers: apiHeaders("chat"), - body: JSON.stringify(req), + body: JSON.stringify({ ...req, knowledge_schema: knowledgeSchema }), signal, }); diff --git a/src/features/document-provenance/activity-label.ts b/src/features/document-provenance/activity-label.ts index 6253f8616..fc91f3d0a 100644 --- a/src/features/document-provenance/activity-label.ts +++ b/src/features/document-provenance/activity-label.ts @@ -19,6 +19,10 @@ export function activityTypeLabelKey(type: string): string | null { case "wiki_atomize": return "history.type.wikiAtomize"; case "wiki_reinforce": return "history.type.wikiReinforce"; case "skill_default_update": return "history.type.skillDefaultUpdate"; + case "knowledge_schema_edit": return "history.type.knowledgeSchemaEdit"; + case "knowledge_schema_default_update": return "history.type.knowledgeSchemaDefaultUpdate"; + case "knowledge_schema_reset": return "history.type.knowledgeSchemaReset"; + case "knowledge_schema_language_switch": return "history.type.knowledgeSchemaLanguageSwitch"; case "snapshot_restore": return "history.type.snapshotRestore"; case "proposal_adopt": return "history.type.proposalAdopt"; default: return null; diff --git a/src/features/document-provenance/tracker.human-edit.test.ts b/src/features/document-provenance/tracker.human-edit.test.ts index f1dc91dee..9e4b2661e 100644 --- a/src/features/document-provenance/tracker.human-edit.test.ts +++ b/src/features/document-provenance/tracker.human-edit.test.ts @@ -8,10 +8,13 @@ function makeActivity(type: EditActivity["type"]): EditActivity { } describe("isHumanActivityType", () => { - it("human_edit / human_derivation / derive_source / snapshot_restore / proposal_adopt は人間の操作", () => { + it("human-owned document の編集・Reset は人間の操作", () => { expect(isHumanActivityType("human_edit")).toBe(true); expect(isHumanActivityType("human_derivation")).toBe(true); expect(isHumanActivityType("derive_source")).toBe(true); + expect(isHumanActivityType("knowledge_schema_edit")).toBe(true); + expect(isHumanActivityType("knowledge_schema_reset")).toBe(true); + expect(isHumanActivityType("knowledge_schema_language_switch")).toBe(true); expect(isHumanActivityType("snapshot_restore")).toBe(true); expect(isHumanActivityType("proposal_adopt")).toBe(true); }); @@ -27,6 +30,7 @@ describe("isHumanActivityType", () => { expect(isHumanActivityType("wiki_atomize")).toBe(false); expect(isHumanActivityType("wiki_reinforce")).toBe(false); expect(isHumanActivityType("skill_default_update")).toBe(false); + expect(isHumanActivityType("knowledge_schema_default_update")).toBe(false); }); }); diff --git a/src/features/document-provenance/tracker.ts b/src/features/document-provenance/tracker.ts index 750a15469..cb13d8c16 100644 --- a/src/features/document-provenance/tracker.ts +++ b/src/features/document-provenance/tracker.ts @@ -25,6 +25,9 @@ export function isHumanActivityType(activityType: EditActivityType): boolean { activityType === "human_edit" || activityType === "human_derivation" || activityType === "derive_source" || + activityType === "knowledge_schema_edit" || + activityType === "knowledge_schema_reset" || + activityType === "knowledge_schema_language_switch" || activityType === "snapshot_restore" || activityType === "proposal_adopt" ); diff --git a/src/features/document-provenance/types.ts b/src/features/document-provenance/types.ts index 32ece5654..1c91c4789 100644 --- a/src/features/document-provenance/types.ts +++ b/src/features/document-provenance/types.ts @@ -82,6 +82,14 @@ export type EditActivityType = | "wiki_reinforce" /** システムスキルのデフォルト内容への同期(未編集スキルの自動更新 / Reset to default) */ | "skill_default_update" + /** Knowledge Schema の通常編集 */ + | "knowledge_schema_edit" + /** Knowledge Schema の同梱既定版への同期 */ + | "knowledge_schema_default_update" + /** Knowledge Schema の明示 Reset */ + | "knowledge_schema_reset" + /** ユーザーが Knowledge Schema の言語別既定本文へ明示的に切り替え */ + | "knowledge_schema_language_switch" /** 手動で残した版(スナップショット)への復元。ユーザー操作なので human agent に紐づく */ | "snapshot_restore" /** 共有された「変更の提案」の取り込み。ユーザー操作なので human agent に紐づく。 diff --git a/src/features/skill/SkillBanner.test.tsx b/src/features/skill/SkillBanner.test.tsx new file mode 100644 index 000000000..de54a9f5b --- /dev/null +++ b/src/features/skill/SkillBanner.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LocaleProvider, syncLocale } from "../../i18n"; +import { SkillBanner } from "./SkillBanner"; + +describe("SkillBanner", () => { + beforeEach(() => { + syncLocale("ja"); + vi.restoreAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it("Knowledge Schemaを開くと現在と反対の言語への切替を表示する", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "日本語版に切替" })).toBeTruthy(); + }); + + it("確認をキャンセルすると切替を実行しない", () => { + const onSwitch = vi.fn(); + vi.spyOn(window, "confirm").mockReturnValue(false); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "英語版に切替" })); + + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("復元可能な改訂版")); + expect(onSwitch).not.toHaveBeenCalled(); + }); + + it("確認すると対象言語への切替を実行する", async () => { + const onSwitch = vi.fn().mockResolvedValue(undefined); + vi.spyOn(window, "confirm").mockReturnValue(true); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "英語版に切替" })); + + await waitFor(() => expect(onSwitch).toHaveBeenCalledWith("en")); + }); + + it("通常Skillには言語切替を表示しない", () => { + render( + + + , + ); + + expect(screen.queryByText("英語版に切替")).toBeNull(); + }); +}); diff --git a/src/features/skill/SkillBanner.tsx b/src/features/skill/SkillBanner.tsx index 0c4cadbd3..4104d9730 100644 --- a/src/features/skill/SkillBanner.tsx +++ b/src/features/skill/SkillBanner.tsx @@ -1,17 +1,46 @@ // Skill バナー(エディタヘッダーに表示) // Wiki の WikiBanner と同等の役割 -import { Wrench, Zap, Pencil } from "lucide-react"; +import { useState } from "react"; +import { Languages, Wrench, Zap, Pencil } from "lucide-react"; import { useT } from "../../i18n"; type Props = { availableForIngest: boolean; + systemSkillId?: string; + language?: "ja" | "en"; /** 「編集」ボタン押下時。未指定なら編集ボタンを表示しない。 */ onEdit?: () => void; + onSwitchKnowledgeSchemaLanguage?: (language: "ja" | "en") => Promise; }; -export function SkillBanner({ availableForIngest, onEdit }: Props) { +export function SkillBanner({ + availableForIngest, + systemSkillId, + language, + onEdit, + onSwitchKnowledgeSchemaLanguage, +}: Props) { const t = useT(); + const [switching, setSwitching] = useState(false); + const targetLanguage = language === "ja" ? "en" : "ja"; + const canSwitchLanguage = + systemSkillId === "knowledge-schema" && + (language === "ja" || language === "en") && + onSwitchKnowledgeSchemaLanguage; + + const handleSwitchLanguage = async () => { + if (!canSwitchLanguage || switching) return; + const targetLabel = targetLanguage === "ja" ? t("skill.langJa") : t("skill.langEn"); + if (!window.confirm(t("skill.switchSchemaLanguageConfirm", { language: targetLabel }))) return; + setSwitching(true); + try { + await onSwitchKnowledgeSchemaLanguage(targetLanguage); + } finally { + setSwitching(false); + } + }; + return (
@@ -25,6 +54,21 @@ export function SkillBanner({ availableForIngest, onEdit }: Props) { {onEdit && ( <>
+ {canSwitchLanguage && ( + + )}
-
- - -

- {t("skill.languageHelp")} -

-
+ {!isKnowledgeSchema && ( +
+ + +

+ {t("skill.languageHelp")} +

+
+ )}
skillMetas.has(f.id)) .map((f) => { const meta = skillMetas.get(f.id)!; + const isKnowledgeSchema = meta.systemSkillId === "knowledge-schema"; return { id: f.id, - title: meta.title, - description: meta.description, + title: isKnowledgeSchema ? t("skill.knowledgeSchema") : meta.title, + description: isKnowledgeSchema ? t("skill.knowledgeSchemaDescription") : meta.description, availableForIngest: meta.availableForIngest, modifiedAt: f.modifiedTime, systemSkillId: meta.systemSkillId, @@ -81,7 +82,7 @@ export function SkillListView({ if (aSys !== bSys) return aSys - bSys; return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); }); - }, [skillFiles, skillMetas]); + }, [skillFiles, skillMetas, t]); const filtered = useMemo(() => { if (!searchQuery) return skillEntries; diff --git a/src/features/skill/skill-service.ts b/src/features/skill/skill-service.ts index e06f8e53d..323e36539 100644 --- a/src/features/skill/skill-service.ts +++ b/src/features/skill/skill-service.ts @@ -2,6 +2,55 @@ import type { GraphiumDocument, SkillMeta } from "../../lib/document-types"; import type { SystemSkillDefinition } from "./system-skills"; +import { getSystemSkillById, KNOWLEDGE_SCHEMA_PROMPTS } from "./system-skills"; + +export const KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID = "knowledge-schema"; + +export type SystemSkillFileCandidate = { + id: string; + modifiedAt: string; +}; + +export type SystemSkillNormalization = { + sourceId: string; + targetId: string; + deleteIds: string[]; +}; + +/** + * 固定 ID の Knowledge Schema が一覧にはあるのに、この refresh では読めなかったか。 + * 一時的な読込失敗を未作成と誤認して、既存本文を既定値で上書きしないために使う。 + */ +export function isKnowledgeSchemaListedButNotLoaded( + listedFileIds: readonly string[], + loadedFileIds: ReadonlySet, +): boolean { + return listedFileIds.includes(KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID) + && !loadedFileIds.has(KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID); +} + +/** + * systemSkillId が重複したときの保存元・survivor・削除対象を決める。 + * Knowledge Schema だけは最新内容を固定 storage ID へ集約し、それ以外は + * 従来どおり最新ファイル自身を survivor にする。 + */ +export function decideSystemSkillNormalization( + systemSkillId: string, + files: readonly SystemSkillFileCandidate[], +): SystemSkillNormalization | undefined { + if (files.length === 0) return undefined; + const newest = [...files].sort( + (a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(), + )[0]; + const targetId = systemSkillId === KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID + ? KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID + : newest.id; + return { + sourceId: newest.id, + targetId, + deleteIds: files.filter((file) => file.id !== targetId).map((file) => file.id), + }; +} /** * skillMetas Map のエントリ型。 @@ -17,6 +66,33 @@ export type SkillMetaSummary = { hasNewerDefault?: boolean; }; +export type SkillMetadataUpdate = { + title: string; + description: string; + availableForIngest: boolean; + language?: "ja" | "en"; +}; + +/** メタ情報更新時も Knowledge Schema の初期言語を固定する。 */ +export function applySkillMetadataUpdate( + base: GraphiumDocument, + values: SkillMetadataUpdate, +): GraphiumDocument { + const language = base.skillMeta?.systemSkillId === KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID + ? base.skillMeta.language + : values.language; + return { + ...base, + title: values.title, + skillMeta: { + ...(base.skillMeta ?? { createdAt: new Date().toISOString() }), + description: values.description, + availableForIngest: values.availableForIngest, + language, + }, + }; +} + /** * 新しい Skill ドキュメントを構築する */ @@ -159,6 +235,69 @@ export function pickActiveSkills( return result; } +/** 保存済みの Knowledge Schema だけを抽出し、通常 Skill / Voice と混在させない。 */ +export function extractKnowledgeSchemaPrompt(doc: GraphiumDocument): string { + if (doc.source !== "skill" || doc.skillMeta?.systemSkillId !== KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID) { + throw new Error("Knowledge Schema ではないスキル文書です"); + } + const prompt = extractSkillPrompt(doc).trim(); + if (!prompt) throw new Error("Knowledge Schema の本文が空です"); + return prompt; +} + +/** + * Knowledge Schema を固定 ID から読む共通 loader。 + * 取得失敗や不正な文書を既定本文へ黙って差し替えない。呼び出し元は AI 実行を中止して + * 既存のエラー表示経路へ渡す。 + */ +export async function loadKnowledgeSchemaPrompt( + load: (skillId: string) => Promise, +): Promise { + const definition = getSystemSkillById(KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID); + if (!definition) throw new Error("Knowledge Schema の組み込み定義がありません"); + return extractKnowledgeSchemaPrompt(await load(definition.id)); +} + +/** Knowledge Schema を Voice / Skill とは別の system prompt section にする。 */ +export function buildKnowledgeSchemaPromptSection(prompt: string): string { + return `\n\n## Knowledge Schema\n\n${prompt}`; +} + +/** Knowledge Schema の保存言語に対応する同梱デフォルト定義を返す。 */ +export function resolveSystemSkillDefinition( + def: SystemSkillDefinition, + language?: "ja" | "en", +): SystemSkillDefinition { + if (def.id !== KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID) return def; + const resolvedLanguage = language ?? def.language; + return { + ...def, + language: resolvedLanguage, + prompt: KNOWLEDGE_SCHEMA_PROMPTS[resolvedLanguage], + }; +} + +export function resolveSystemSkillDefinitionForDocument( + def: SystemSkillDefinition, + doc: Pick | undefined, +): SystemSkillDefinition { + return resolveSystemSkillDefinition(def, doc?.skillMeta?.language); +} + +/** migrate_meta / auto_update 共通で、解決済み定義の版・hash・言語を固定する。 */ +export function buildSyncedSystemSkillMeta( + meta: SkillMeta, + def: SystemSkillDefinition, + defaultPromptHash: string, +): SkillMeta { + return { + ...meta, + language: def.language, + systemSkillVersion: def.version, + defaultPromptHash, + }; +} + /** * システム同梱スキルから Skill ドキュメントを生成する。 * 初回ブートストラップ時・リセット時・自動更新時に使用する。 diff --git a/src/features/skill/skill-versioning.test.ts b/src/features/skill/skill-versioning.test.ts index 9be12e51d..83cba7e52 100644 --- a/src/features/skill/skill-versioning.test.ts +++ b/src/features/skill/skill-versioning.test.ts @@ -7,10 +7,20 @@ import { hashSkillPrompt, computeSystemSkillDefaultHash, buildSystemSkillDocument, + buildSkillDocument, extractSkillPrompt, decideSkillSync, + extractKnowledgeSchemaPrompt, + loadKnowledgeSchemaPrompt, + buildKnowledgeSchemaPromptSection, + applySkillMetadataUpdate, + buildSyncedSystemSkillMeta, + decideSystemSkillNormalization, + isKnowledgeSchemaListedButNotLoaded, + resolveSystemSkillDefinition, + resolveSystemSkillDefinitionForDocument, } from "./skill-service"; -import { SYSTEM_SKILLS, type SystemSkillDefinition } from "./system-skills"; +import { KNOWLEDGE_SCHEMA_PROMPTS, SYSTEM_SKILLS, type SystemSkillDefinition } from "./system-skills"; const TEST_DEF: SystemSkillDefinition = { id: "default-voice-ja", @@ -22,6 +32,42 @@ const TEST_DEF: SystemSkillDefinition = { prompt: "## Voice\n\n- 敬体で書く\n- **強い語彙**を避ける\n", }; +const KNOWLEDGE_SCHEMA_V1_DEF: SystemSkillDefinition = { + id: "knowledge-schema", + version: 1, + title: "Knowledge Schema / ナレッジスキーマ", + description: "Conventions for Topics, Answers, Claims, citations, revision, and upkeep / ナレッジ生成・引用・改訂の規約", + language: "en", + availableForIngest: false, + prompt: `## Knowledge Schema + +This document defines the structure and maintenance conventions for Graphium's Knowledge layer. It is independent from writing Voice. Code-level safety rules, structured-output validation, citation verification, and guardrails remain mandatory even when this document is edited. + +## Topic and Answer + +- A Topic is a source-grounded page about one concept. An Answer is a source-grounded page that keeps answering its title question; do not turn it into a general survey. +- Revise the complete page from the current body and the new source. Keep distinct source-specific statements separate when their conditions, cases, numbers, assumptions, or scope differ. +- Every factual sentence needs an inline source citation. Preserve a hedge, uncertainty, contradiction, and open question when the source has one. Do not invent a citation, mechanism, or generalization. + +## Claim + +- A Claim is one transferable proposition supported by its source, not a summary or textbook filler. +- Keep claims atomic: split independently useful propositions and do not combine evidence from unrelated sources into one assertion. +- Retain conditions, evidence limits, and epistemic strength. Classify only from what the source supports. + +## Citation and revision + +- Use the exact citation identifiers supplied by Graphium. Citations must support the sentence they end. +- A revision incorporates new evidence without silently erasing supported prior evidence. Explicit conflicts belong in a disagreement or open-question section. +- Do not fabricate sources, URLs, quotations, measurements, or provenance. + +## Upkeep + +- Prefer a precise, traceable page over a broad but weak one. +- Surface missing support, stale statements, duplicates, contradictions, and unresolved questions for review. +- Do not make destructive maintenance decisions or overwrite human-owned content without the corresponding Graphium workflow.`, +}; + describe("normalizeSkillPrompt", () => { it("空行と行頭行末の空白を無視する", () => { const a = "## Voice\n\n- 敬体で書く\n"; @@ -77,6 +123,279 @@ describe("decideSkillSync", () => { expect(decideSkillSync(TEST_DEF, {}, "whatever")).toBe("migrate_meta"); }); + describe("Knowledge Schema", () => { + it("SYSTEM_SKILLS 上の Schema 定義は固定 ID の 1 件だけ", () => { + const definitions = SYSTEM_SKILLS.filter((skill) => skill.id === "knowledge-schema"); + expect(definitions).toHaveLength(1); + expect(definitions[0].version).toBe(4); + expect(definitions[0].availableForIngest).toBe(false); + }); + + it("初回 locale ja/en に応じて Schema definition/document の本文と言語が変わる", async () => { + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const jaDef = resolveSystemSkillDefinition(base, "ja"); + const enDef = resolveSystemSkillDefinition(base, "en"); + + expect(jaDef).toMatchObject({ id: "knowledge-schema", language: "ja", prompt: KNOWLEDGE_SCHEMA_PROMPTS.ja }); + expect(enDef).toMatchObject({ id: "knowledge-schema", language: "en", prompt: KNOWLEDGE_SCHEMA_PROMPTS.en }); + expect(jaDef.prompt).not.toBe(enDef.prompt); + + const jaDoc = await buildSystemSkillDocument(jaDef); + const enDoc = await buildSystemSkillDocument(enDef); + expect(jaDoc.skillMeta?.language).toBe("ja"); + expect(enDoc.skillMeta?.language).toBe("en"); + expect(extractSkillPrompt(jaDoc)).toContain("## 編集ガイド"); + expect(extractSkillPrompt(enDoc)).toContain("## Editing guide"); + }); + + it("保存済み Schema の language が UI locale 変更より優先される", async () => { + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const savedJa = await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")); + const resolved = resolveSystemSkillDefinitionForDocument( + resolveSystemSkillDefinition(base, "en"), + savedJa, + ); + + expect(resolved.language).toBe("ja"); + expect(resolved.prompt).toBe(KNOWLEDGE_SCHEMA_PROMPTS.ja); + }); + + it("metadata更新で日本語Schemaのlanguageを英語へ変更しない", async () => { + const base = await buildSystemSkillDocument( + resolveSystemSkillDefinition( + SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!, + "ja", + ), + ); + const updated = applySkillMetadataUpdate(base, { + title: base.title, + description: "updated", + availableForIngest: false, + language: "en", + }); + + expect(updated.skillMeta?.language).toBe("ja"); + expect(updated.skillMeta?.description).toBe("updated"); + }); + + it("通常Skillのmetadata更新では選択したlanguageを保存する", async () => { + const base = await buildSystemSkillDocument(TEST_DEF); + const updated = applySkillMetadataUpdate(base, { + title: base.title, + description: "updated", + availableForIngest: true, + language: "en", + }); + + expect(updated.skillMeta?.language).toBe("en"); + }); + + it("language 未設定の旧 Schema は en として解決し、同期 metadata に固定する", () => { + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const resolved = resolveSystemSkillDefinitionForDocument(base, { + skillMeta: { + description: "legacy", + availableForIngest: false, + createdAt: "2026-01-01T00:00:00Z", + systemSkillId: "knowledge-schema", + }, + }); + const synced = buildSyncedSystemSkillMeta( + { + description: "legacy", + availableForIngest: false, + createdAt: "2026-01-01T00:00:00Z", + systemSkillId: "knowledge-schema", + }, + resolved, + "resolved-hash", + ); + + expect(resolved.language).toBe("en"); + expect(synced.language).toBe("en"); + expect(synced.defaultPromptHash).toBe("resolved-hash"); + }); + + it("日本語本文に編集ガイドの重要語・分野横断例・一般規約が含まれる", () => { + const prompt = KNOWLEDGE_SCHEMA_PROMPTS.ja; + for (const word of [ + "安全に変更してよい", + "分野用語", + "必ず残す条件", + "引用粒度", + "ナレッジスキーマでは変更不可", + "[[source:id]]", + "人間所有文書", + "ソフトウェア", + "企画", + "学習", + "実行環境", + "判断基準", + "確信度", + "トピック", + "問答(Q&A)", + "知見", + "引用", + "根拠", + "不確実性", + "改訂", + "点検", + "手入れ", + ]) { + expect(prompt).toContain(word); + } + for (const untranslated of ["Topic は", "Answer は", "## Claim", "Writing Voice", "AI prompt"]) { + expect(prompt).not.toContain(untranslated); + } + for (const specialized of ["材料科学", "試料組成", "測定条件"]) { + expect(prompt).not.toContain(specialized); + } + }); + + describe("decideSystemSkillNormalization", () => { + it("固定 ID が古く random ID が新しければ random を source、固定 ID を target にする", () => { + expect(decideSystemSkillNormalization("knowledge-schema", [ + { id: "knowledge-schema", modifiedAt: "2026-01-01T00:00:00Z" }, + { id: "random-new", modifiedAt: "2026-02-01T00:00:00Z" }, + ])).toEqual({ + sourceId: "random-new", + targetId: "knowledge-schema", + deleteIds: ["random-new"], + }); + }); + + it("固定 ID がなければ最新 random を固定 ID へ移し、全 random を削除する", () => { + expect(decideSystemSkillNormalization("knowledge-schema", [ + { id: "random-old", modifiedAt: "2026-01-01T00:00:00Z" }, + { id: "random-new", modifiedAt: "2026-02-01T00:00:00Z" }, + ])).toEqual({ + sourceId: "random-new", + targetId: "knowledge-schema", + deleteIds: ["random-old", "random-new"], + }); + }); + + it("他 system skill は従来どおり最新 ID 自身を survivor にする", () => { + expect(decideSystemSkillNormalization("default-voice-ja", [ + { id: "voice-old", modifiedAt: "2026-01-01T00:00:00Z" }, + { id: "voice-new", modifiedAt: "2026-02-01T00:00:00Z" }, + ])).toEqual({ + sourceId: "voice-new", + targetId: "voice-new", + deleteIds: ["voice-old"], + }); + }); + }); + + describe("isKnowledgeSchemaListedButNotLoaded", () => { + it("固定 ID が listed だが loaded でなければ true", () => { + expect(isKnowledgeSchemaListedButNotLoaded( + ["knowledge-schema", "random-duplicate"], + new Set(["random-duplicate"]), + )).toBe(true); + }); + + it("固定 ID が listed かつ loaded なら false", () => { + expect(isKnowledgeSchemaListedButNotLoaded( + ["knowledge-schema", "random-duplicate"], + new Set(["knowledge-schema", "random-duplicate"]), + )).toBe(false); + }); + + it("固定 ID が listed でなければ false", () => { + expect(isKnowledgeSchemaListedButNotLoaded( + ["random-duplicate"], + new Set(["random-duplicate"]), + )).toBe(false); + }); + }); + + it("英語本文にも等価な分野横断 editing guide が含まれる", () => { + const prompt = KNOWLEDGE_SCHEMA_PROMPTS.en; + for (const word of [ + "Safe to change", + "domain terminology", + "conditions that must always be retained", + "citation granularity", + "Not changeable", + "[[source:id]]", + "human-owned documents", + "software", + "planning", + "learning", + "runtime environments", + "decision criteria", + "confidence", + ]) { + expect(prompt).toContain(word); + } + for (const specialized of ["materials science", "sample composition", "measurement conditions"]) { + expect(prompt).not.toContain(specialized); + } + }); + + it("同梱 Schema を保存可能な system Skill として生成・抽出する", async () => { + const definition = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema"); + expect(definition).toBeDefined(); + const doc = await buildSystemSkillDocument(definition!); + expect(doc.source).toBe("skill"); + expect(doc.skillMeta?.systemSkillId).toBe("knowledge-schema"); + expect(extractKnowledgeSchemaPrompt(doc)).toContain("## Claim"); + }); + + it("Schema 以外の Skill を Knowledge Schema として抽出しない", async () => { + const doc = await buildSystemSkillDocument(TEST_DEF); + expect(() => extractKnowledgeSchemaPrompt(doc)).toThrow("Knowledge Schema"); + }); + + it("Schema の取得失敗を既定本文へフォールバックせず呼び出し元へ返す", async () => { + await expect(loadKnowledgeSchemaPrompt(async () => { + throw new Error("storage unavailable"); + })).rejects.toThrow("storage unavailable"); + }); + + it("保存済みの日本語自由編集本文を既定本文へ戻さず AI prompt 用に返す", async () => { + const customPrompt = "## ナレッジスキーマ\n\nソフトウェアではバージョンと再現手順を必ず残す。"; + const doc = buildSkillDocument( + "Knowledge Schema / ナレッジスキーマ", + "custom schema", + customPrompt, + false, + { + systemSkillId: "knowledge-schema", + language: "ja", + systemSkillVersion: 3, + defaultPromptHash: "edited", + }, + ); + + await expect(loadKnowledgeSchemaPrompt(async () => doc)).resolves.toBe(customPrompt); + }); + + it("Voice/Skill とは独立したプロンプト section を構築する", () => { + expect(buildKnowledgeSchemaPromptSection("schema body")).toBe("\n\n## Knowledge Schema\n\nschema body"); + }); + + it("旧版の英語 Schema が未編集なら auto_update、編集済みなら notify_newer", async () => { + const current = resolveSystemSkillDefinition(SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!, "en"); + const oldDoc = await buildSystemSkillDocument(KNOWLEDGE_SCHEMA_V1_DEF); + const oldHash = await hashSkillPrompt(extractSkillPrompt(oldDoc)); + + expect(decideSkillSync(current, oldDoc.skillMeta, oldHash)).toBe("auto_update"); + expect(decideSkillSync(current, oldDoc.skillMeta, await hashSkillPrompt(`${extractSkillPrompt(oldDoc)}\n\nUser edit`))).toBe("notify_newer"); + }); + + it("Schema の roundtrip hash が日英双方で成立する", async () => { + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + for (const language of ["ja", "en"] as const) { + const def = resolveSystemSkillDefinition(base, language); + const doc = await buildSystemSkillDocument(def); + const extracted = await hashSkillPrompt(extractSkillPrompt(doc)); + expect(extracted, `roundtrip mismatch: knowledge-schema ${language}`).toBe(await computeSystemSkillDefaultHash(def)); + } + }); + }); + it("版が同じか新しい文書は up_to_date", () => { expect(decideSkillSync(TEST_DEF, { systemSkillVersion: 3, defaultPromptHash: "h" }, "h")).toBe("up_to_date"); expect(decideSkillSync(TEST_DEF, { systemSkillVersion: 4, defaultPromptHash: "h" }, "x")).toBe("up_to_date"); diff --git a/src/features/skill/system-skills.ts b/src/features/skill/system-skills.ts index 44c47d2ae..91ffe75be 100644 --- a/src/features/skill/system-skills.ts +++ b/src/features/skill/system-skills.ts @@ -6,7 +6,7 @@ // プロンプトは BlockNote ブロックに変換されて保存されるため、 // `extractSkillPrompt` で抜き出される平文形式で書く(マークダウン互換)。 -export type SystemSkillId = "default-voice-ja" | "default-voice-en"; +export type SystemSkillId = "default-voice-ja" | "default-voice-en" | "knowledge-schema"; export type SystemSkillDefinition = { id: SystemSkillId; @@ -91,6 +91,99 @@ Write so a future reader wants to keep reading. Aim for the tone of a short note - For chat replies and personal notes, first-person ("I think", "I picked") is fine. `; +export const KNOWLEDGE_SCHEMA_PROMPTS: Record<"ja" | "en", string> = { + en: `## Knowledge Schema + +This document defines the structure and maintenance conventions for Graphium's Knowledge layer. It is independent from writing Voice. Code-level safety rules, structured-output validation, citation verification, and guardrails remain mandatory even when this document is edited. + +## Editing guide + +- **Safe to change:** domain terminology, conditions that must always be retained, citation granularity, page structure, and upkeep review criteria. +- **Not changeable in this Schema; enforced by code:** JSON shape, storage paths, the \`[[source:id]]\` citation syntax, and the rule that Graphium must not update human-owned documents without an explicit workflow. +- **Example customizations:** for software work, retain versions, runtime environments, and reproduction steps; for planning, retain owners, deadlines, dependencies, and decision criteria; for learning, retain the source, context, and confidence behind each explanation. In every domain, choose citation granularity fine enough to distinguish independently supported results or decisions. +- Keep edits operational and concrete. The saved body is passed directly into AI prompts, so write instructions you want future Topic, Answer, and Claim generation to follow. + +## Topic and Answer + +- A Topic is a source-grounded page about one concept. An Answer is a source-grounded page that keeps answering its title question; do not turn it into a general survey. +- Revise the complete page from the current body and the new source. Keep distinct source-specific statements separate when their conditions, cases, numbers, assumptions, or scope differ. +- Every factual sentence needs an inline source citation. Preserve a hedge, uncertainty, contradiction, and open question when the source has one. Do not invent a citation, mechanism, or generalization. + +## Claim + +- A Claim is one transferable proposition supported by its source, not a summary or textbook filler. +- Keep claims atomic: split independently useful propositions and do not combine evidence from unrelated sources into one assertion. +- Retain conditions, evidence limits, and epistemic strength. Classify only from what the source supports. + +## Citation and evidence + +- Use the exact citation identifiers supplied by Graphium. Citations must support the sentence they end. +- Preserve quotations, quantitative values, methods, decision grounds, provenance, and source-local distinctions at the granularity needed to audit the statement. +- Do not fabricate sources, URLs, quotations, quantitative values, decisions, or provenance. + +## Conditions and uncertainty + +- Keep conditions, case boundaries, parameter or assumption ranges, and failed, negative, or null results when they affect whether a statement holds. +- Mark uncertainty, disagreement, missing support, and open questions explicitly instead of smoothing them away. + +## Revision + +- A revision incorporates new evidence without silently erasing supported prior evidence. +- Explicit conflicts belong in a disagreement or open-question section, not in an averaged statement that hides the conflict. +- Do not overwrite human-owned wording or decisions without the corresponding Graphium workflow. + +## Lint and upkeep + +- Prefer a precise, traceable page over a broad but weak one. +- Surface missing support, stale statements, duplicates, contradictions, citation gaps, and unresolved questions for review. +- Do not make destructive maintenance decisions or overwrite human-owned content without the corresponding Graphium workflow.`, + ja: `## ナレッジスキーマ + +この文書は Graphium のナレッジ層の構造と保守規約を定義します。文体(Voice)とは独立しています。この文書を編集しても、コード側の安全規則、構造化出力の検証、引用照合、ガードレールは必ず維持されます。 + +## 編集ガイド + +- **安全に変更してよい:** 分野用語、必ず残す条件、引用粒度、ページ構成、点検観点。 +- **ナレッジスキーマでは変更不可・コードが守る:** JSON 形式、保存経路、\`[[source:id]]\` 引用構文、人間が所有する文書を明示的な操作手順なしに更新しないこと。 +- **具体的な変更例:** ソフトウェアではバージョン、実行環境、再現手順を残す。企画では担当者、期限、依存関係、判断基準を残す。学習では説明ごとの出典、文脈、確信度を残す。どの分野でも、独立して裏づけられた結果や判断を区別できる細かさで引用します。 +- 編集は運用できる具体的な指示にしてください。保存された本文はそのまま AI への指示に渡されるため、以後のトピック・問答(Q&A)・知見の生成に守らせたい規約として書きます。 + +## トピックと問答(Q&A) + +- トピックは 1 つの概念について、出典に根拠づけられたページです。問答(Q&A)はタイトルの問いに答え続ける、出典に根拠づけられたページです。一般的な概説へ変えないでください。 +- 現在の本文と新しい出典からページ全体を改訂します。条件、事例、数値、前提、スコープが異なる出典固有の記述は混ぜずに分けます。 +- 事実を述べる文にはすべて文中引用が必要です。出典に留保、不確実性、矛盾、未解決の問いがある場合は残します。引用、機構、一般化を捏造しないでください。 + +## 知見 + +- 知見は出典に支えられた、転用可能な 1 つの命題です。要約や教科書的な穴埋めではありません。 +- 知見は原子的に保ちます。独立して使える命題は分け、無関係な出典の根拠を 1 つの主張に混ぜないでください。 +- 条件、根拠の限界、確からしさの強さを残します。分類は出典が支える範囲だけから行います。 + +## 引用と根拠 + +- Graphium が渡した正確な引用 ID を使います。引用は、その文末にある文を支えていなければなりません。 +- 記述を監査できる粒度で、引用、定量値、方法、判断根拠、来歴、出典内の区別を残します。 +- 出典、URL、引用文、定量値、判断、来歴を捏造しないでください。 + +## 条件と不確実性 + +- 条件、事例の境界、パラメータや前提の範囲、失敗、否定的結果、差が出なかった結果は、主張の成立範囲に影響するなら残します。 +- 不確実性、不一致、根拠不足、未解決の問いを、なめらかに消さず明示します。 + +## 改訂 + +- 改訂では、新しい根拠を取り込みつつ、根拠のある既存記述を黙って消しません。 +- 明示的な矛盾は、衝突や未解決の問いとして置きます。矛盾を隠す平均的な記述にしないでください。 +- 人間が所有している文言や判断を、対応する Graphium ワークフローなしに上書きしないでください。 + +## 点検と手入れ + +- 広いが弱いページより、精密で追跡できるページを優先します。 +- 根拠不足、古くなった記述、重複、矛盾、引用漏れ、未解決の問いを点検対象として表面化します。 +- 破壊的な保守判断を勝手に行わず、人間所有文書を対応する Graphium ワークフローなしに上書きしないでください。`, +}; + export const SYSTEM_SKILLS: SystemSkillDefinition[] = [ { id: "default-voice-ja", @@ -110,6 +203,15 @@ export const SYSTEM_SKILLS: SystemSkillDefinition[] = [ availableForIngest: true, prompt: VOICE_EN_PROMPT, }, + { + id: "knowledge-schema", + version: 4, + title: "Knowledge Schema / ナレッジスキーマ", + description: "Conventions for Topics, Answers, Claims, citations, revision, and upkeep / ナレッジ生成・引用・改訂の規約", + language: "en", + availableForIngest: false, + prompt: KNOWLEDGE_SCHEMA_PROMPTS.en, + }, ]; export function getSystemSkillById(id: SystemSkillId): SystemSkillDefinition | undefined { diff --git a/src/features/version-snapshots/snapshot-store.test.ts b/src/features/version-snapshots/snapshot-store.test.ts index e2fc757c4..84456204c 100644 --- a/src/features/version-snapshots/snapshot-store.test.ts +++ b/src/features/version-snapshots/snapshot-store.test.ts @@ -74,6 +74,26 @@ describe("snapshot-store", () => { expect(await listSnapshots(provider, "note-1")).toHaveLength(1); }); + it("force指定なら第1ページが同一でも切替直前の全文を新しい版として残す", async () => { + const provider = makeProvider(); + const first = { ...makeDoc("同じ本文"), title: "初期タイトル" }; + const latest = { ...makeDoc("同じ本文"), title: "切替直前タイトル" }; + await takeSnapshot(provider, "knowledge-schema", first); + + const forced = await takeSnapshot( + provider, + "knowledge-schema", + latest, + "Knowledge Schema backup (ja)", + undefined, + true, + ); + + expect(forced.status).toBe("created"); + expect(forced.meta.version).toBe(2); + expect((await loadSnapshot(provider, forced.meta.id))?.title).toBe("切替直前タイトル"); + }); + it("ノートごとに採番が独立する", async () => { const provider = makeProvider(); await takeSnapshot(provider, "note-1", makeDoc("A")); @@ -142,4 +162,40 @@ describe("buildRestoredDocument", () => { expect(restored.skillMeta?.systemSkillVersion).toBe(3); expect(restored.skillMeta?.defaultPromptHash).toBe("new-hash"); }); + + it("Knowledge Schemaは本文と言語に対応する版同期情報もスナップショットから戻す", () => { + const snapshot: GraphiumDocument = { + ...makeDoc("日本語の既定本文"), + source: "skill", + skillMeta: { + description: "日本語", + availableForIngest: false, + createdAt: "2026-01-01T00:00:00Z", + systemSkillId: "knowledge-schema", + language: "ja", + systemSkillVersion: 2, + defaultPromptHash: "ja-hash", + }, + }; + const current: GraphiumDocument = { + ...makeDoc("English default"), + source: "skill", + skillMeta: { + description: "English", + availableForIngest: false, + createdAt: "2026-01-05T00:00:00Z", + systemSkillId: "knowledge-schema", + language: "en", + systemSkillVersion: 3, + defaultPromptHash: "en-hash", + }, + }; + + const restored = buildRestoredDocument(current, snapshot); + + expect(restored.skillMeta?.language).toBe("ja"); + expect(restored.skillMeta?.systemSkillVersion).toBe(2); + expect(restored.skillMeta?.defaultPromptHash).toBe("ja-hash"); + expect(restored.skillMeta?.createdAt).toBe("2026-01-05T00:00:00Z"); + }); }); diff --git a/src/features/version-snapshots/snapshot-store.ts b/src/features/version-snapshots/snapshot-store.ts index 80645170e..a7ff508e7 100644 --- a/src/features/version-snapshots/snapshot-store.ts +++ b/src/features/version-snapshots/snapshot-store.ts @@ -46,6 +46,7 @@ export type TakeSnapshotResult = /** * 現在の doc を新しい版として残す。 * 直近の版と内容ハッシュが同一なら版を作らず "unchanged"(=「変更がありません」)を返す。 + * force は本文以外のメタデータも含め、その時点の全文を必ず残す操作で使う。 * * 全文を先に書き、成功後にメタ index を更新する。こうすると index が指す全文が必ず存在する * (途中失敗で index だけ進んで実体が無い、という不整合を避ける)。逆に全文だけ残る孤児は @@ -57,6 +58,7 @@ export async function takeSnapshot( doc: GraphiumDocument, label?: string, origin?: SnapshotMeta["origin"], + force = false, ): Promise { if (!provider.writeAppData || !provider.readAppData) { throw new Error("この保存先は版の記録に対応していません"); @@ -64,7 +66,7 @@ export async function takeSnapshot( const metas = await listSnapshots(provider, noteId); const contentHash = await snapshotHash(doc); const last = metas[metas.length - 1]; - if (last && last.contentHash === contentHash) { + if (!force && last && last.contentHash === contentHash) { return { status: "unchanged", meta: last }; } const meta: SnapshotMeta = { @@ -98,7 +100,13 @@ export function buildRestoredDocument( sharedRef: current.sharedRef, modifiedAt: new Date().toISOString(), skillMeta: current.skillMeta && snapshot.skillMeta - ? { + ? current.skillMeta.systemSkillId === "knowledge-schema" && + snapshot.skillMeta.systemSkillId === "knowledge-schema" + ? { + ...snapshot.skillMeta, + createdAt: current.skillMeta.createdAt, + } + : { ...snapshot.skillMeta, createdAt: current.skillMeta.createdAt, // 版同期の管理情報は現在を維持する。復元した内容が同梱デフォルトと diff --git a/src/features/wiki/topic-stage.ts b/src/features/wiki/topic-stage.ts index 9e427d4e8..fbaa8a8df 100644 --- a/src/features/wiki/topic-stage.ts +++ b/src/features/wiki/topic-stage.ts @@ -114,6 +114,8 @@ export type ConsolidateExistingTopicsDeps = { noteIndex?: NoteIndex; locale: string; model?: string; + /** 保存済み Knowledge Schema。未指定は旧呼び出し元との互換用。 */ + knowledgeSchema?: string; log?: (...args: unknown[]) => void; }; @@ -207,7 +209,13 @@ export async function applyTopicMerges( targetDoc.wikiMeta.topicMarkdown as string, ...sourceDocs.map(({ doc }) => doc.wikiMeta!.topicMarkdown as string), ]; - const mergedBody = await mergeTopicBodies(targetDoc.title, bodies, deps.locale, deps.model); + const mergedBody = await mergeTopicBodies( + targetDoc.title, + bodies, + deps.locale, + deps.model, + deps.knowledgeSchema, + ); if (mergedBody) { const sourceRefs = await collectSourceRefs(refIds, deps); const rewritten = rebuildSourceTopicDocument(targetDoc, mergedBody, sourceRefs, deps.model ?? null, deps.noteIndex); @@ -255,6 +263,7 @@ export async function applyTopicMerges( noteIndex: deps.noteIndex, locale: deps.locale, model: deps.model, + knowledgeSchema: deps.knowledgeSchema, log: deps.log, }); if (rebuildResult.rebuilt) { @@ -307,6 +316,7 @@ export async function consolidateExistingTopics( [], deps.locale, deps.model, + deps.knowledgeSchema, ); } catch (err) { log("既存話題の統合(consolidate-topics)に失敗:", err); @@ -389,6 +399,8 @@ export type SourceTopicStageDeps = { existingTopicRefs: ExistingTopicRef[]; noteIndex?: NoteIndex; locale: string; + /** この実行で共通に使う、保存済み Knowledge Schema 本文 */ + knowledgeSchema?: string; /** * 資料 id から「タイトル + 全文」を解決する(出典照合の resolveSourceText と同じ入口を * 呼び出し側が注入する想定)。ゴミ箱・未検出などで読めない資料は undefined を返す @@ -496,6 +508,8 @@ export async function runSourceTopicStage( existingForRouter, deps.locale, source.model, + undefined, + deps.knowledgeSchema, ); } catch (err) { result.failed++; @@ -532,6 +546,8 @@ export async function runSourceTopicStage( source.model, previouslyCitedIds.has(topicId), isAnswer, + undefined, + deps.knowledgeSchema, ); if (!revisedBody) { result.failed++; @@ -571,6 +587,7 @@ export async function runSourceTopicStage( noteIndex: deps.noteIndex, locale: deps.locale, model: source.model, + knowledgeSchema: deps.knowledgeSchema, log: deps.log, }, ); @@ -598,6 +615,10 @@ export async function runSourceTopicStage( { id: source.id, title: source.title, text: source.text }, deps.locale, source.model, + undefined, + undefined, + undefined, + deps.knowledgeSchema, ); if (!revisedBody) { result.failed++; @@ -684,6 +705,7 @@ export async function runSourceTopicStage( deps.locale, source.model, deps.signal, + deps.knowledgeSchema, ); } catch (err) { // ユーザーの停止は失敗ではない(取り込みキューと同じ扱い)。その窓で打ち切り、 @@ -733,6 +755,7 @@ export async function runSourceTopicStage( noteIndex: deps.noteIndex, locale: deps.locale, model: source.model, + knowledgeSchema: deps.knowledgeSchema, log: deps.log, signal: deps.signal, }, @@ -762,6 +785,7 @@ export async function runSourceTopicStage( previouslyCited, topicDoc.wikiMeta.kind === "answer", deps.signal, + deps.knowledgeSchema, ); if (!revisedBody) { // 停止で改訂が返らなかった場合は失敗に数えない。 @@ -797,6 +821,7 @@ export async function runSourceTopicStage( false, false, deps.signal, + deps.knowledgeSchema, ); if (revisedBody && state) { touched.set(existingId, { ...state, body: revisedBody }); @@ -816,6 +841,7 @@ export async function runSourceTopicStage( undefined, undefined, deps.signal, + deps.knowledgeSchema, ); if (!revisedBody) { // 停止で改訂が返らなかった場合は失敗に数えない。 @@ -909,6 +935,8 @@ export type RebuildTopicFromSourcesDeps = { noteIndex?: NoteIndex; locale: string; model?: string; + /** 保存済み Knowledge Schema。未指定は旧呼び出し元との互換用。 */ + knowledgeSchema?: string; /** 資料の見取り図を作る(資料が複数窓に分かれたときだけ呼ぶ)。未指定なら見取り図なしで続行する */ surveySource?: ( source: { title: string; text: string }, @@ -984,6 +1012,7 @@ export async function rebuildTopicFromSources( undefined, isAnswer, deps.signal, + deps.knowledgeSchema, ); if (!revised) { log("トピック改訂に失敗し飛ばした:", sourceId, "窓", win.index); diff --git a/src/features/wiki/wiki-service.extract-claims.test.ts b/src/features/wiki/wiki-service.extract-claims.test.ts index 496674adb..065146478 100644 --- a/src/features/wiki/wiki-service.extract-claims.test.ts +++ b/src/features/wiki/wiki-service.extract-claims.test.ts @@ -22,6 +22,7 @@ describe("ingest 関数の extractClaims フラグ(知見の ON/OFF)", () => "テストチャット", [], "ja", + "schema", false, ); @@ -42,6 +43,7 @@ describe("ingest 関数の extractClaims フラグ(知見の ON/OFF)", () => "テストチャット", [], "ja", + "schema", ); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -55,7 +57,7 @@ describe("ingest 関数の extractClaims フラグ(知見の ON/OFF)", () => })); global.fetch = fetchMock as unknown as typeof fetch; - const result = await ingestFromUrl("https://example.com", [], "ja", false); + const result = await ingestFromUrl("https://example.com", [], "ja", "schema", false); expect(fetchMock).toHaveBeenCalledTimes(1); expect(result.wikis).toEqual([]); @@ -75,7 +77,7 @@ describe("ingest 関数の extractClaims フラグ(知見の ON/OFF)", () => }); global.fetch = fetchMock as unknown as typeof fetch; - const result = await ingestFromUrl("https://example.com", [], "ja"); + const result = await ingestFromUrl("https://example.com", [], "ja", "schema"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(result.model).toBe("test-model"); diff --git a/src/features/wiki/wiki-service.knowledge-schema.test.ts b/src/features/wiki/wiki-service.knowledge-schema.test.ts new file mode 100644 index 000000000..74e2f8ffc --- /dev/null +++ b/src/features/wiki/wiki-service.knowledge-schema.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ingestFromChat, + ingestFromDocx, + ingestFromMultiSource, + ingestFromPdf, + ingestFromUrl, +} from "./wiki-service"; + +vi.mock("./pdf-text-extractor", () => ({ + extractPdfText: vi.fn(async () => ({ + text: "PDFから抽出した十分に長いテスト本文です。".repeat(4), + title: "PDF title", + pageCount: 2, + })), + capForSingleCall: vi.fn((text: string) => text), +})); + +vi.mock("mammoth", () => ({ + extractRawText: vi.fn(async () => ({ + value: "DOCXから抽出した十分に長いテスト本文です。".repeat(4), + })), +})); + +describe("追加 ingest 経路の Knowledge Schema", () => { + const originalFetch = global.fetch; + const knowledgeSchema = " Required schema "; + const ingestResponse = { + ok: true, + json: async () => ({ + wikis: [], + tokenUsage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }, + model: "test-model", + }), + }; + + afterEach(() => { + global.fetch = originalFetch; + vi.clearAllMocks(); + }); + + function expectIngestSchema(fetchMock: ReturnType) { + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/api/wiki/ingest")); + expect(call).toBeDefined(); + expect(JSON.parse(String(call![1]?.body))).toMatchObject({ knowledgeSchema }); + } + + it("URL 経路で /ingest に Schema を含める", async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + title: "Example", + description: "", + text: "URL body", + url: "https://example.com", + }), + }) + .mockResolvedValueOnce(ingestResponse); + global.fetch = fetchMock as unknown as typeof fetch; + + await ingestFromUrl("https://example.com", [], "ja", knowledgeSchema); + + expectIngestSchema(fetchMock); + }); + + it("PDF 経路で /ingest に Schema を含める", async () => { + const fetchMock = vi.fn().mockResolvedValue(ingestResponse); + global.fetch = fetchMock as unknown as typeof fetch; + + await ingestFromPdf(new Blob(["pdf"]), "paper.pdf", "pdf:1", [], "ja", knowledgeSchema); + + expectIngestSchema(fetchMock); + }); + + it("DOCX 経路で /ingest に Schema を含める", async () => { + const fetchMock = vi.fn().mockResolvedValue(ingestResponse); + global.fetch = fetchMock as unknown as typeof fetch; + + await ingestFromDocx(new Blob(["docx"]), "paper.docx", "document:1", [], "ja", knowledgeSchema); + + expectIngestSchema(fetchMock); + }); + + it("複数ソース経路で /ingest に Schema を含める", async () => { + const fetchMock = vi.fn().mockResolvedValue(ingestResponse); + global.fetch = fetchMock as unknown as typeof fetch; + + await ingestFromMultiSource( + [{ sourceNoteId: "note:1", title: "Note", text: "body", kind: "note" }], + "Wiki", + "wiki:1", + [], + "ja", + knowledgeSchema, + ); + + expectIngestSchema(fetchMock); + }); + + it("チャット経路で /ingest に Schema を含める", async () => { + const fetchMock = vi.fn().mockResolvedValue(ingestResponse); + global.fetch = fetchMock as unknown as typeof fetch; + + await ingestFromChat( + [{ role: "user", content: "message" }], + "Chat", + [], + "ja", + knowledgeSchema, + ); + + expectIngestSchema(fetchMock); + }); +}); diff --git a/src/features/wiki/wiki-service.ts b/src/features/wiki/wiki-service.ts index db68d1613..158b7aa68 100644 --- a/src/features/wiki/wiki-service.ts +++ b/src/features/wiki/wiki-service.ts @@ -106,6 +106,8 @@ export async function ingestNote( model?: string, /** Ingest 時に適用する Skill(プロンプトテンプレート) */ skills?: { title: string; prompt: string }[], + /** 保存済み Knowledge Schema。Voice/Skill とは独立して送る。 */ + knowledgeSchema?: string, /** 中断シグナル。fetch を切るとサーバー側の LLM 呼び出しも止まる */ signal?: AbortSignal, /** 知見(Claims)抽出を行うかどうか(既定 true)。features.claims が OFF のとき @@ -137,6 +139,7 @@ export async function ingestNote( provSummary, ...(model ? { model } : {}), ...(skills && skills.length > 0 ? { skills } : {}), + ...(knowledgeSchema ? { knowledgeSchema } : {}), }), ...(signal ? { signal } : {}), }); @@ -360,6 +363,7 @@ export async function rewriteAndMerge( language?: string, noteIndex?: NoteIndex, skills?: { title: string; prompt: string }[], + knowledgeSchema?: string, ): Promise { const page = existingDoc.pages[0]; if (!page) return mergeIntoWikiDocument(existingDoc, ingesterOutput, sourceNoteId, model, noteIndex); @@ -390,6 +394,7 @@ export async function rewriteAndMerge( language: existingDoc.wikiMeta?.language ?? language ?? "en", ...(model ? { model } : wikiBodyModel()), ...(skills && skills.length > 0 ? { skills } : {}), + ...(knowledgeSchema ? { knowledgeSchema } : {}), }), }); @@ -1101,6 +1106,7 @@ export async function ingestFromUrl( url: string, existingWikis: ExistingWikiInfo[], language: string, + knowledgeSchema: string, /** 知見(Claims)抽出を行うかどうか(既定 true)。false のときは HTML 取得・本文抽出 * だけ行い、/api/wiki/ingest は呼ばない(トピック段は呼び出し側が sourceText で走らせる)。 */ extractClaims: boolean = true, @@ -1145,6 +1151,7 @@ export async function ingestFromUrl( noteTitle: urlData.title || url, existingWikiTitles: existingWikis, language, + knowledgeSchema, ...wikiBodyModel(), }), }); @@ -1172,6 +1179,7 @@ export async function ingestFromPdf( sourceNoteId: string, existingWikis: ExistingWikiInfo[], language: string, + knowledgeSchema: string, /** 知見(Claims)抽出を行うかどうか(既定 true)。false のときは PDF テキスト抽出 * だけ行い、/api/wiki/ingest は呼ばない。 */ extractClaims: boolean = true, @@ -1220,6 +1228,7 @@ export async function ingestFromPdf( noteTitle, existingWikiTitles: existingWikis, language, + knowledgeSchema, ...wikiBodyModel(), }), }); @@ -1244,6 +1253,7 @@ export async function ingestFromDocx( sourceNoteId: string, existingWikis: ExistingWikiInfo[], language: string, + knowledgeSchema: string, /** 知見(Claims)抽出を行うかどうか(既定 true)。false のときは Word 本文抽出 * だけ行い、/api/wiki/ingest は呼ばない。 */ extractClaims: boolean = true, @@ -1282,6 +1292,7 @@ export async function ingestFromDocx( noteTitle, existingWikiTitles: existingWikis, language, + knowledgeSchema, ...wikiBodyModel(), }), }); @@ -1324,6 +1335,7 @@ export async function ingestFromMultiSource( wikiId: string, existingWikis: ExistingWikiInfo[], language: string, + knowledgeSchema: string, model?: string, skills?: { title: string; prompt: string }[], ): Promise { @@ -1359,6 +1371,7 @@ export async function ingestFromMultiSource( language, ...(model ? { model } : wikiBodyModel()), ...(skills && skills.length > 0 ? { skills } : {}), + knowledgeSchema, }), }); @@ -1378,6 +1391,7 @@ export async function ingestFromChat( chatTitle: string, existingWikis: ExistingWikiInfo[], language: string, + knowledgeSchema: string, /** 知見(Claims)抽出を行うかどうか(既定 true)。false のときはメッセージの * テキスト化だけ行い、/api/wiki/ingest は呼ばない。 */ extractClaims: boolean = true, @@ -1403,6 +1417,7 @@ export async function ingestFromChat( noteTitle: `Chat: ${chatTitle}`, existingWikiTitles: existingWikis, language, + knowledgeSchema, ...wikiBodyModel(), }), }); @@ -2212,6 +2227,7 @@ export async function consolidateTopics( existingTopics: ExistingTopicRef[], language: string, model?: string, + knowledgeSchema?: string, ): Promise> { if (proposedTitles.length === 0) return {}; try { @@ -2224,6 +2240,7 @@ export async function consolidateTopics( existingTopics: existingTopics.map((t) => ({ id: t.id, title: t.title, oneLiner: t.oneLiner })), proposedTitles, ...(model ? { model } : wikiBodyModel("chatSynthesis")), + ...(knowledgeSchema ? { knowledgeSchema } : {}), }), }); if (!res.ok) { @@ -2576,11 +2593,12 @@ export async function routeTopicsForSource( language: string, model?: string, signal?: AbortSignal, + knowledgeSchema?: string, ): Promise<{ update: string[]; create: string[] }> { const res = await fetch(`${API_BASE}/route-topics`, { method: "POST", headers: wikiHeaders(), - body: JSON.stringify({ language, source, existingTopics, ...(model ? { model } : {}) }), + body: JSON.stringify({ language, source, existingTopics, ...(model ? { model } : {}), ...(knowledgeSchema ? { knowledgeSchema } : {}) }), ...(signal ? { signal } : {}), }); if (!res.ok) { @@ -2609,6 +2627,7 @@ export async function reviseTopicFromSource( /** このページが回答ページ(answer)か。true のとき「問いに答え続ける」規則を追加する */ isAnswer?: boolean, signal?: AbortSignal, + knowledgeSchema?: string, ): Promise { try { const res = await fetch(`${API_BASE}/revise-topic`, { @@ -2619,6 +2638,7 @@ export async function reviseTopicFromSource( ...(model ? { model } : {}), ...(previouslyCited ? { previouslyCited } : {}), ...(isAnswer ? { isAnswer } : {}), + ...(knowledgeSchema ? { knowledgeSchema } : {}), }), ...(signal ? { signal } : {}), }); @@ -2645,13 +2665,20 @@ export async function mergeTopicBodies( bodies: string[], language: string, model?: string, + knowledgeSchema?: string, ): Promise { if (bodies.length < 2) return null; try { const res = await fetch(`${API_BASE}/merge-topics`, { method: "POST", headers: wikiHeaders(), - body: JSON.stringify({ title, language, bodies, ...(model ? { model } : {}) }), + body: JSON.stringify({ + title, + language, + bodies, + ...(model ? { model } : {}), + ...(knowledgeSchema ? { knowledgeSchema } : {}), + }), }); if (!res.ok) { console.warn("mergeTopicBodies failed:", await aiErrorFromResponse(res, `merge-topics failed (${res.status})`)); diff --git a/src/hooks/use-file-manager.test.tsx b/src/hooks/use-file-manager.test.tsx index 332bd83e9..4781d7bee 100644 --- a/src/hooks/use-file-manager.test.tsx +++ b/src/hooks/use-file-manager.test.tsx @@ -22,6 +22,9 @@ import { renderHook, act, waitFor } from "@testing-library/react"; import { useFileManager } from "./use-file-manager"; import { registerProvider, setActiveProvider } from "../lib/storage/registry"; import { clearMediaIndexCache, type MediaIndexEntry } from "../features/asset-browser"; +import { buildSystemSkillDocument, resolveSystemSkillDefinition } from "../features/skill/skill-service"; +import { SYSTEM_SKILLS } from "../features/skill/system-skills"; +import { recordRevision } from "../features/document-provenance/tracker"; import { buildProcessEntry, clearLatestProcessIndex, @@ -222,6 +225,44 @@ function setupProvider(seed: Record = {}) { return mock; } +function enableSkillStorage( + mock: ReturnType, + seed: Record, +) { + const skills = new Map(Object.entries(seed).map(([id, doc]) => [id, structuredClone(doc)])); + const flags = { failListSkillFiles: false, failSaveSkillFile: false }; + const calls = { + saveSkillFile: [] as Array<{ id: string; doc: GraphiumDocument }>, + deleteSkillFile: [] as string[], + }; + Object.assign(mock.provider, { + async listSkillFiles(): Promise { + if (flags.failListSkillFiles) throw new Error("transient skill list failure"); + return Array.from(skills.entries()).map(([id, doc]) => ({ + id, + name: doc.title, + modifiedTime: doc.modifiedAt, + createdTime: doc.createdAt, + })); + }, + async loadSkillFile(id: string): Promise { + const doc = skills.get(id); + if (!doc) throw new Error(`skill not found: ${id}`); + return structuredClone(doc); + }, + async saveSkillFile(id: string, doc: GraphiumDocument): Promise { + if (flags.failSaveSkillFile) throw new Error("skill save failure"); + calls.saveSkillFile.push({ id, doc: structuredClone(doc) }); + skills.set(id, structuredClone(doc)); + }, + async deleteSkillFile(id: string): Promise { + calls.deleteSkillFile.push(id); + skills.delete(id); + }, + }); + return { skills, flags, calls }; +} + async function renderFileManager() { const hook = renderHook(() => useFileManager(true)); // マウント時の refreshFiles(一覧取得)が完了するまで待つ @@ -754,6 +795,200 @@ describe("useFileManager: refreshFiles は list 失敗時に files を空にし }); }); +describe("useFileManager: Skill一覧失敗時はsystem Skillを上書きしない", () => { + it("listSkillFilesが失敗したrefreshではKnowledge Schemaを作成・同期しない", async () => { + const mock = setupProvider(); + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const schema = { + ...await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")), + version: 6 as const, + }; + const skillStorage = enableSkillStorage(mock, { "knowledge-schema": schema }); + const hook = await renderFileManager(); + + await waitFor(() => { + expect(hook.result.current.skillMetas.get("knowledge-schema")?.language).toBe("ja"); + }); + const previousFiles = hook.result.current.skillFiles; + const previousMetas = hook.result.current.skillMetas; + skillStorage.calls.saveSkillFile.length = 0; + skillStorage.flags.failListSkillFiles = true; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await act(async () => { + await hook.result.current.refreshFiles(); + }); + + await waitFor(() => { + expect(warn).toHaveBeenCalledWith( + "listSkillFiles failed; keeping previous skill list:", + expect.any(Error), + ); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(hook.result.current.skillFiles).toEqual(previousFiles); + expect(hook.result.current.skillMetas).toEqual(previousMetas); + expect(skillStorage.calls.saveSkillFile).toEqual([]); + }); + + it("固定IDのload失敗時はSchemaの正規化・作成・同期を保留する", async () => { + const mock = setupProvider(); + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const duplicate = await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")); + const outdatedDuplicate: GraphiumDocument = { + ...duplicate, + modifiedAt: "2027-01-01T00:00:00Z", + skillMeta: { + ...duplicate.skillMeta!, + systemSkillVersion: 1, + }, + }; + const skillStorage = enableSkillStorage(mock, { + "knowledge-schema": duplicate, + "schema-duplicate": outdatedDuplicate, + }); + const originalLoad = mock.provider.loadSkillFile!.bind(mock.provider); + mock.provider.loadSkillFile = async (id: string) => { + if (id === "knowledge-schema") throw new Error("transient schema load failure"); + return originalLoad(id); + }; + + await renderFileManager(); + + await waitFor(() => { + expect(skillStorage.calls.saveSkillFile.some(({ id }) => id !== "knowledge-schema")).toBe(true); + }); + expect(skillStorage.calls.saveSkillFile.some(({ id }) => id === "knowledge-schema")).toBe(false); + expect(skillStorage.calls.saveSkillFile.some(({ id }) => id === "schema-duplicate")).toBe(false); + expect(skillStorage.calls.deleteSkillFile).not.toContain("schema-duplicate"); + }); + + it("Resetは保存済みの日本語を維持して日本語既定本文を保存する", async () => { + const mock = setupProvider(); + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const schema = await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")); + const skillStorage = enableSkillStorage(mock, { "knowledge-schema": schema }); + const hook = await renderFileManager(); + + await waitFor(() => { + expect(hook.result.current.skillMetas.get("knowledge-schema")?.language).toBe("ja"); + }); + skillStorage.calls.saveSkillFile.length = 0; + + await act(async () => { + await hook.result.current.handleResetSystemSkill("knowledge-schema"); + }); + + const saved = skillStorage.calls.saveSkillFile.find(({ id }) => id === "knowledge-schema")?.doc; + expect(saved?.skillMeta?.language).toBe("ja"); + expect(JSON.stringify(saved?.pages)).toContain("安全に変更してよい"); + }); + + it("明示切替は英語既定本文・metadata・専用Revisionを一括保存し、以後のResetも英語を使う", async () => { + const mock = setupProvider(); + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const schema = { + ...await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")), + version: 6 as const, + }; + const skillStorage = enableSkillStorage(mock, { "knowledge-schema": schema }); + const hook = await renderFileManager(); + + await waitFor(() => { + expect(hook.result.current.skillMetas.get("knowledge-schema")?.language).toBe("ja"); + }); + + await act(async () => { + await hook.result.current.handleSwitchKnowledgeSchemaLanguage("en"); + }); + + const switched = skillStorage.skills.get("knowledge-schema")!; + expect(switched.version).toBe(6); + expect(switched.skillMeta?.language).toBe("en"); + expect(switched.skillMeta?.systemSkillVersion).toBe(base.version); + expect(switched.skillMeta?.defaultPromptHash).toBeTruthy(); + expect(JSON.stringify(switched.pages)).toContain("Safe to change"); + const activities = switched.documentProvenance?.activities ?? []; + const activity = activities[activities.length - 1]; + expect(activity?.type).toBe("knowledge_schema_language_switch"); + expect(switched.documentProvenance?.agents.find((agent) => agent.id === activity?.wasAssociatedWith)?.label) + .toBe("user-language-switch:en"); + const revisions = switched.documentProvenance?.revisions ?? []; + expect(revisions[revisions.length - 1]?.summary.contentDiff?.some( + (diff) => diff.before?.includes("安全に変更してよい"), + )).toBe(true); + const snapshotIndex = mock.appData.get("snapshot-index:knowledge-schema") as Array<{ id: string; label?: string }>; + expect(snapshotIndex).toHaveLength(1); + expect(snapshotIndex[0].label).toBe("Knowledge Schema backup (ja)"); + const snapshot = mock.appData.get(`snapshot:${snapshotIndex[0].id}`) as GraphiumDocument; + expect(snapshot.version).toBe(6); + expect(snapshot.skillMeta?.language).toBe("ja"); + expect(JSON.stringify(snapshot.pages)).toContain("安全に変更してよい"); + expect(hook.result.current.skillMetas.get("knowledge-schema")?.language).toBe("en"); + + skillStorage.calls.saveSkillFile.length = 0; + await act(async () => { + await hook.result.current.handleResetSystemSkill("knowledge-schema"); + }); + const reset = skillStorage.calls.saveSkillFile[skillStorage.calls.saveSkillFile.length - 1]?.doc; + expect(reset?.skillMeta?.language).toBe("en"); + expect(JSON.stringify(reset?.pages)).toContain("Safe to change"); + }); + + it("言語切替の保存失敗時は本文・metadata・表示状態を更新しない", async () => { + const mock = setupProvider(); + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const schema = await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")); + const skillStorage = enableSkillStorage(mock, { "knowledge-schema": schema }); + const hook = await renderFileManager(); + + await waitFor(() => { + expect(hook.result.current.skillMetas.get("knowledge-schema")?.language).toBe("ja"); + }); + const previous = structuredClone(skillStorage.skills.get("knowledge-schema")); + skillStorage.flags.failSaveSkillFile = true; + vi.spyOn(console, "error").mockImplementation(() => {}); + + await act(async () => { + await expect(hook.result.current.handleSwitchKnowledgeSchemaLanguage("en")) + .rejects.toThrow("skill save failure"); + }); + + expect(skillStorage.skills.get("knowledge-schema")).toEqual(previous); + expect(hook.result.current.skillMetas.get("knowledge-schema")?.language).toBe("ja"); + expect(mock.appData.get("snapshot-index:knowledge-schema")).toHaveLength(1); + }); + + it("Schemaの版復元保存ではsnapshot_restoreに通常編集Revisionを重ねない", async () => { + const mock = setupProvider(); + const base = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema")!; + const schema = await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "ja")); + const english = await buildSystemSkillDocument(resolveSystemSkillDefinition(base, "en")); + const skillStorage = enableSkillStorage(mock, { "knowledge-schema": schema }); + const hook = await renderFileManager(); + const restored = await recordRevision( + { ...schema, pages: english.pages }, + schema.pages[0], + "snapshot_restore", + { force: true }, + ); + + await act(async () => { + await hook.result.current.handleSaveSkillFile( + "knowledge-schema", + restored, + { skipKnowledgeSchemaRevision: true }, + ); + }); + + const saved = skillStorage.skills.get("knowledge-schema")!; + expect(saved.documentProvenance?.activities.map((activity) => activity.type)) + .toEqual(["snapshot_restore"]); + }); +}); + // --------------------------------------------------------------------------- // 不変条件 3: 空の保管庫でも mediaIndex は確定する // --------------------------------------------------------------------------- diff --git a/src/hooks/use-file-manager.ts b/src/hooks/use-file-manager.ts index 558b49a3e..7fb1eb649 100644 --- a/src/hooks/use-file-manager.ts +++ b/src/hooks/use-file-manager.ts @@ -3,12 +3,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { GraphiumFile, GraphiumDocument, WikiKind, WikiMetaSummary } from "../lib/document-types"; +import { getLocale } from "../i18n"; +import { migrateToLatest } from "../lib/document-migration"; import { clearAppDataFileCache } from "../lib/storage/app-data-file"; import { getActiveProvider } from "../lib/storage/registry"; import { PROV_TEMPLATE } from "../lib/prov-template"; import { recordRevision } from "../features/document-provenance/tracker"; import type { EditActivityType } from "../features/document-provenance/types"; -import type { SkillMetaSummary } from "../features/skill/skill-service"; +import { + applySkillMetadataUpdate, + decideSystemSkillNormalization, + isKnowledgeSchemaListedButNotLoaded, + loadKnowledgeSchemaPrompt, + type SkillMetaSummary, +} from "../features/skill/skill-service"; import { promoteClaimStatusIfCorroborated, unlinkClaimFromTopic } from "../features/wiki/wiki-service"; /** Wiki 保存・新規作成時のリビジョン記録オプション */ @@ -26,7 +34,7 @@ import { buildDerivedDocument, appendDerivedNoteLink, } from "../features/derivation/clone-document"; -import { loadSnapshot } from "../features/version-snapshots/snapshot-store"; +import { loadSnapshot, takeSnapshot } from "../features/version-snapshots/snapshot-store"; import { snapshotBeforeAiRewrite } from "../features/version-snapshots/ai-rewrite"; import { findSnapshotsReferencingAsset } from "../features/version-snapshots/snapshot-refs"; import { registerPendingOcrFile } from "../features/media-ocr"; @@ -131,9 +139,9 @@ const deleteWikiFileFromStorage = (id: string) => { }; // Skill ドキュメント操作ヘルパー const listSkillFiles = () => storage().listSkillFiles?.() ?? Promise.resolve([]); -const loadSkillFile = (id: string) => { +const loadSkillFile = async (id: string) => { if (!storage().loadSkillFile) throw new Error("Skill 非対応のストレージプロバイダーです"); - return storage().loadSkillFile!(id); + return migrateToLatest(await storage().loadSkillFile!(id), id); }; const createSkillFile = (title: string, content: GraphiumDocument) => { if (!storage().createSkillFile) throw new Error("Skill 非対応のストレージプロバイダーです"); @@ -295,12 +303,15 @@ export function useFileManager(authenticated: boolean) { return { id: f.id, doc }; }) ).then(async (results) => { + if (skillSettled.status !== "fulfilled") return; const metas = new Map(); + const loadedSkillIds = new Set(); // systemSkillId ごとに、対応するファイル ID の配列(重複検出用) const systemSkillFiles = new Map(); for (const r of results) { if (r.status === "fulfilled") { const { id, doc } = r.value; + loadedSkillIds.add(id); metas.set(id, { title: doc.title, description: doc.skillMeta?.description ?? "", @@ -316,30 +327,86 @@ export function useFileManager(authenticated: boolean) { } } } + const unreadableKnowledgeSchema = isKnowledgeSchemaListedButNotLoaded( + skillResult.map((file) => file.id), + loadedSkillIds, + ); + if (unreadableKnowledgeSchema) { + console.warn( + "固定 ID の Knowledge Schema を読み込めなかったため、この refresh では作成・重複正規化・デフォルト同期を保留します", + ); + } - // 同じ systemSkillId を持つファイルが 2 つ以上あれば、最も新しいもの 1 つだけ残す + // system skill の重複を最新 1 件へ集約する。Knowledge Schema は最新内容を + // storage 固定 ID に保存してから、固定 ID 以外を削除する。 const provider = storage(); const removedIds: string[] = []; + const normalizedKnowledgeSchemaFiles: GraphiumFile[] = []; for (const [systemId, files] of systemSkillFiles.entries()) { - if (files.length <= 1) continue; - // modifiedAt 降順でソート、先頭以外を削除 - files.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); - for (const dup of files.slice(1)) { + if (systemId === "knowledge-schema" && unreadableKnowledgeSchema) continue; + const normalization = decideSystemSkillNormalization(systemId, files); + if (!normalization) continue; + + if (systemId === "knowledge-schema" && normalization.sourceId !== normalization.targetId) { + const sourceDoc = docCacheRef.current.get(`skill:${normalization.sourceId}`); + if (!sourceDoc || !provider.saveSkillFile) continue; try { - if (provider.deleteSkillFile) { - await provider.deleteSkillFile(dup.id); - } - metas.delete(dup.id); - docCacheRef.current.delete(`skill:${dup.id}`); - removedIds.push(dup.id); - console.info(`[bootstrap] 重複したシステムスキル ${systemId} (file ${dup.id}) を削除しました`); + // 固定 ID への保存が完了するまで元ファイルには一切触れない。 + await provider.saveSkillFile(normalization.targetId, sourceDoc); + const sourceMeta = metas.get(normalization.sourceId); + if (sourceMeta) metas.set(normalization.targetId, sourceMeta); + docCacheRef.current.set(`skill:${normalization.targetId}`, sourceDoc); + normalizedKnowledgeSchemaFiles.push({ + id: normalization.targetId, + name: sourceDoc.title, + modifiedTime: sourceDoc.modifiedAt, + createdTime: sourceDoc.createdAt, + }); } catch (err) { - console.warn("重複システムスキルの削除に失敗:", err); + console.warn("Knowledge Schema の固定 ID への正規化に失敗:", err); + continue; + } + } + + // 版同期は必ず正規化後の survivor を参照する。 + const survivorDoc = docCacheRef.current.get(`skill:${normalization.targetId}`); + if (survivorDoc) { + systemSkillFiles.set(systemId, [{ + id: normalization.targetId, + modifiedAt: survivorDoc.modifiedAt, + }]); + } + + for (const duplicateId of normalization.deleteIds) { + let deleted = !provider.deleteSkillFile; + if (provider.deleteSkillFile) { + try { + await provider.deleteSkillFile(duplicateId); + deleted = true; + console.info(`[bootstrap] 重複したシステムスキル ${systemId} (file ${duplicateId}) を削除しました`); + } catch (err) { + console.warn("重複システムスキルの削除に失敗:", err); + } + } + // Knowledge Schema は固定 ID の保存に成功した時点で、UI/cache 上の + // survivor を固定 ID に統一する。storage 削除失敗時は次回起動で再試行する。 + if (deleted || systemId === "knowledge-schema") { + metas.delete(duplicateId); + docCacheRef.current.delete(`skill:${duplicateId}`); + removedIds.push(duplicateId); } } } - if (removedIds.length > 0) { - setSkillFiles((prev) => prev.filter((f) => !removedIds.includes(f.id))); + if (removedIds.length > 0 || normalizedKnowledgeSchemaFiles.length > 0) { + setSkillFiles((prev) => { + const next = prev.filter((f) => !removedIds.includes(f.id)); + for (const normalized of normalizedKnowledgeSchemaFiles) { + const index = next.findIndex((f) => f.id === normalized.id); + if (index >= 0) next[index] = normalized; + else next.push(normalized); + } + return next; + }); } const existingSystemIds = new Set(systemSkillFiles.keys()); @@ -349,11 +416,15 @@ export function useFileManager(authenticated: boolean) { // 未編集なら新デフォルトへ自動更新、編集済みならバッジで知らせる。 try { const { SYSTEM_SKILLS } = await import("../features/skill/system-skills"); - const { buildSystemSkillDocument, decideSkillSync, hashSkillPrompt, extractSkillPrompt, computeSystemSkillDefaultHash } = await import("../features/skill/skill-service"); + const { buildSystemSkillDocument, buildSyncedSystemSkillMeta, decideSkillSync, hashSkillPrompt, extractSkillPrompt, computeSystemSkillDefaultHash, resolveSystemSkillDefinition, resolveSystemSkillDefinitionForDocument } = await import("../features/skill/skill-service"); if (provider.saveSkillFile) { - for (const def of SYSTEM_SKILLS) { - if (!existingSystemIds.has(def.id)) { - const newId = crypto.randomUUID(); + for (const baseDef of SYSTEM_SKILLS) { + if (baseDef.id === "knowledge-schema" && unreadableKnowledgeSchema) continue; + if (!existingSystemIds.has(baseDef.id)) { + const def = resolveSystemSkillDefinition(baseDef, baseDef.id === "knowledge-schema" ? getLocale() : undefined); + // Knowledge Schema は storage 上でも固定 ID を使う。Voice 等の既存 + // system Skill は過去のランダム ID 契約を維持する。 + const newId = def.id === "knowledge-schema" ? def.id : crypto.randomUUID(); const doc = await buildSystemSkillDocument(def); await provider.saveSkillFile(newId, doc); metas.set(newId, { @@ -369,9 +440,10 @@ export function useFileManager(authenticated: boolean) { } // 既存スキルの版同期(重複除去後に生き残った先頭ファイルが対象) - const survivor = (systemSkillFiles.get(def.id) ?? [])[0]; + const survivor = (systemSkillFiles.get(baseDef.id) ?? [])[0]; const doc = survivor ? docCacheRef.current.get(`skill:${survivor.id}`) : undefined; if (!survivor || !doc) continue; + const def = resolveSystemSkillDefinitionForDocument(baseDef, doc); const fileId = survivor.id; const currentHash = await hashSkillPrompt(extractSkillPrompt(doc)); const decision = decideSkillSync(def, doc.skillMeta, currentHash); @@ -381,11 +453,11 @@ export function useFileManager(authenticated: boolean) { // 版管理導入前の文書: 内容は触らず版情報だけ記録する const migrated: GraphiumDocument = { ...doc, - skillMeta: { - ...doc.skillMeta!, - systemSkillVersion: def.version, - defaultPromptHash: await computeSystemSkillDefaultHash(def), - }, + skillMeta: buildSyncedSystemSkillMeta( + doc.skillMeta!, + def, + await computeSystemSkillDefaultHash(def), + ), }; await provider.saveSkillFile(fileId, migrated); docCacheRef.current.set(`skill:${fileId}`, migrated); @@ -396,14 +468,19 @@ export function useFileManager(authenticated: boolean) { let updated: GraphiumDocument = { ...doc, pages: [{ ...fresh.pages[0], id: doc.pages[0]?.id ?? fresh.pages[0].id, title: doc.title }], - skillMeta: { - ...doc.skillMeta!, - systemSkillVersion: def.version, - defaultPromptHash: fresh.skillMeta?.defaultPromptHash, - }, + skillMeta: buildSyncedSystemSkillMeta( + doc.skillMeta!, + def, + fresh.skillMeta!.defaultPromptHash!, + ), modifiedAt: new Date().toISOString(), }; - updated = await recordRevision(updated, doc.pages[0] ?? null, "skill_default_update", { agentLabel: "system-default", force: true }); + updated = await recordRevision( + updated, + doc.pages[0] ?? null, + def.id === "knowledge-schema" ? "knowledge_schema_default_update" : "skill_default_update", + { agentLabel: "system-default", force: true }, + ); await provider.saveSkillFile(fileId, updated); docCacheRef.current.set(`skill:${fileId}`, updated); setSkillFiles((prev) => prev.map((f) => f.id === fileId ? { ...f, modifiedTime: updated.modifiedAt } : f)); @@ -3026,18 +3103,27 @@ export function useFileManager(authenticated: boolean) { // Skill を保存 const handleSaveSkillFile = useCallback( - async (skillId: string, doc: GraphiumDocument) => { + async ( + skillId: string, + doc: GraphiumDocument, + options?: { skipKnowledgeSchemaRevision?: boolean }, + ) => { try { - await saveSkillFile(skillId, doc); - docCacheRef.current.set(`skill:${skillId}`, doc); + const previous = docCacheRef.current.get(`skill:${skillId}`); + const savedDoc = doc.skillMeta?.systemSkillId === "knowledge-schema" && + !options?.skipKnowledgeSchemaRevision + ? await recordRevision(doc, previous?.pages[0] ?? null, "knowledge_schema_edit") + : doc; + await saveSkillFile(skillId, savedDoc); + docCacheRef.current.set(`skill:${skillId}`, savedDoc); setSkillMetas((prev) => { const next = new Map(prev); next.set(skillId, { - title: doc.title, - description: doc.skillMeta?.description ?? "", - availableForIngest: doc.skillMeta?.availableForIngest ?? true, - systemSkillId: doc.skillMeta?.systemSkillId, - language: doc.skillMeta?.language, + title: savedDoc.title, + description: savedDoc.skillMeta?.description ?? "", + availableForIngest: savedDoc.skillMeta?.availableForIngest ?? true, + systemSkillId: savedDoc.skillMeta?.systemSkillId, + language: savedDoc.skillMeta?.language, // 編集保存では新デフォルト通知は解消しない(Reset するまで残す) hasNewerDefault: prev.get(skillId)?.hasNewerDefault, }); @@ -3057,6 +3143,11 @@ export function useFileManager(authenticated: boolean) { const handleDeleteSkillFile = useCallback( async (skillId: string) => { try { + const doc = docCacheRef.current.get(`skill:${skillId}`) ?? await loadSkillFile(skillId); + if (doc.skillMeta?.systemSkillId) { + console.warn("システム同梱スキルは削除できません:", skillId); + return; + } docCacheRef.current.delete(`skill:${skillId}`); await deleteSkillFileFromStorage(skillId); if (activeFileId === `skill:${skillId}`) { @@ -3083,10 +3174,11 @@ export function useFileManager(authenticated: boolean) { } try { const { getSystemSkillById } = await import("../features/skill/system-skills"); - const { buildSystemSkillDocument } = await import("../features/skill/skill-service"); - const def = getSystemSkillById(meta.systemSkillId as any); - if (!def) return; + const { buildSystemSkillDocument, resolveSystemSkillDefinition } = await import("../features/skill/skill-service"); + const baseDef = getSystemSkillById(meta.systemSkillId as import("../features/skill/system-skills").SystemSkillId); + if (!baseDef) return; const prevDoc = docCacheRef.current.get(`skill:${skillId}`) ?? await loadSkillFile(skillId).catch(() => undefined); + const def = resolveSystemSkillDefinition(baseDef, prevDoc?.skillMeta?.language ?? meta.language); let doc = await buildSystemSkillDocument(def); // 内容はデフォルトへ完全に戻すが、documentProvenance と作成日時は引き継いで // 編集履歴のチェーンを切らない(リセットも来歴上の 1 操作として記録する) @@ -3098,7 +3190,16 @@ export function useFileManager(authenticated: boolean) { skillMeta: { ...doc.skillMeta!, createdAt: prevDoc.skillMeta?.createdAt ?? doc.skillMeta!.createdAt }, }; } - doc = await recordRevision(doc, prevDoc?.pages[0] ?? null, "skill_default_update", { agentLabel: "system-default", force: true }); + doc = await recordRevision( + doc, + prevDoc?.pages[0] ?? null, + meta.systemSkillId === "knowledge-schema" ? "knowledge_schema_reset" : "skill_default_update", + { + agentLabel: meta.systemSkillId === "knowledge-schema" ? "user" : "system-default", + force: true, + }, + ); + await saveSkillFile(skillId, doc); docCacheRef.current.set(`skill:${skillId}`, doc); setSkillMetas((prev) => { @@ -3124,6 +3225,86 @@ export function useFileManager(authenticated: boolean) { [skillMetas, activeFileId, setActiveDoc, setEditorKey] ); + const handleSwitchKnowledgeSchemaLanguage = useCallback( + async (language: "ja" | "en") => { + const skillId = "knowledge-schema"; + try { + const { getSystemSkillById } = await import("../features/skill/system-skills"); + const { buildSystemSkillDocument, resolveSystemSkillDefinition } = await import("../features/skill/skill-service"); + const baseDef = getSystemSkillById(skillId); + if (!baseDef) throw new Error("Knowledge Schema の同梱定義がありません"); + const previous = docCacheRef.current.get(`skill:${skillId}`) ?? await loadSkillFile(skillId); + if (previous.skillMeta?.systemSkillId !== skillId) { + throw new Error("固定 ID の文書が Knowledge Schema ではありません"); + } + + const definition = resolveSystemSkillDefinition(baseDef, language); + const fresh = await buildSystemSkillDocument(definition); + await takeSnapshot( + storage(), + skillId, + previous, + `Knowledge Schema backup (${previous.skillMeta.language ?? "unknown"})`, + undefined, + true, + ); + let updated: GraphiumDocument = { + ...previous, + title: fresh.title, + pages: fresh.pages, + modifiedAt: fresh.modifiedAt, + skillMeta: { + ...fresh.skillMeta!, + createdAt: previous.skillMeta.createdAt, + }, + }; + updated = await recordRevision( + updated, + previous.pages[0] ?? null, + "knowledge_schema_language_switch", + { agentLabel: `user-language-switch:${language}`, force: true }, + ); + + await saveSkillFile(skillId, updated); + docCacheRef.current.set(`skill:${skillId}`, updated); + setSkillFiles((prev) => prev.map((file) => ( + file.id === skillId ? { ...file, modifiedTime: updated.modifiedAt } : file + ))); + setSkillMetas((prev) => { + const next = new Map(prev); + next.set(skillId, { + title: updated.title, + description: updated.skillMeta?.description ?? "", + availableForIngest: updated.skillMeta?.availableForIngest ?? true, + systemSkillId: updated.skillMeta?.systemSkillId, + language: updated.skillMeta?.language, + }); + return next; + }); + if (activeFileId === `skill:${skillId}`) { + setActiveDoc(updated); + setEditorKey((key) => key + 1); + } + } catch (err) { + console.error("Knowledge Schema の言語切替に失敗:", err); + throw err; + } + }, + [activeFileId, setActiveDoc, setEditorKey], + ); + + // 生成経路はこの入口だけを使い、固定 Schema の欠落・破損を既定本文で隠さない。 + const getKnowledgeSchemaPrompt = useCallback( + async () => loadKnowledgeSchemaPrompt(async (id) => { + const cached = docCacheRef.current.get(`skill:${id}`); + if (cached) return cached; + const doc = await loadSkillFile(id); + docCacheRef.current.set(`skill:${id}`, doc); + return doc; + }), + [], + ); + // Skill の新規作成 const handleCreateSkillFile = useCallback( async (doc: GraphiumDocument): Promise => { @@ -3160,16 +3341,7 @@ export function useFileManager(authenticated: boolean) { ) => { try { const base = docCacheRef.current.get(`skill:${skillId}`) ?? (await loadSkillFile(skillId)); - const doc: GraphiumDocument = { - ...base, - title: values.title, - skillMeta: { - ...(base.skillMeta ?? { createdAt: new Date().toISOString() }), - description: values.description, - availableForIngest: values.availableForIngest, - language: values.language, - }, - }; + const doc = applySkillMetadataUpdate(base, values); await saveSkillFile(skillId, doc); docCacheRef.current.set(`skill:${skillId}`, doc); setSkillMetas((prev) => { @@ -3179,7 +3351,7 @@ export function useFileManager(authenticated: boolean) { description: values.description, availableForIngest: values.availableForIngest, systemSkillId: doc.skillMeta?.systemSkillId, - language: values.language, + language: doc.skillMeta?.language, }); return next; }); @@ -3300,6 +3472,8 @@ export function useFileManager(authenticated: boolean) { handleSaveSkillFile, handleDeleteSkillFile, handleResetSystemSkill, + handleSwitchKnowledgeSchemaLanguage, + getKnowledgeSchemaPrompt, handleCreateSkillFile, handleUpdateSkillMeta, }; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index fc3e87131..a619d00dc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -277,6 +277,10 @@ export const en: Record = { "history.type.wikiAtomize": "Insight discovery", "history.type.wikiReinforce": "Reinforced", "history.type.skillDefaultUpdate": "Default sync", + "history.type.knowledgeSchemaEdit": "Knowledge Schema edit", + "history.type.knowledgeSchemaDefaultUpdate": "Knowledge Schema default sync", + "history.type.knowledgeSchemaReset": "Knowledge Schema reset", + "history.type.knowledgeSchemaLanguageSwitch": "Knowledge Schema language switch", "history.type.snapshotRestore": "Version restore", "history.type.proposalAdopt": "Proposal adopted", "history.sources": "Sources", @@ -2180,6 +2184,11 @@ export const en: Record = { "skill.edit": "Edit", "skill.resetTooltip": "Reset this skill to its default content", "skill.resetToDefault": "Reset to default", + "skill.switchSchemaToJapanese": "Switch to Japanese", + "skill.switchSchemaToEnglish": "Switch to English", + "skill.switchSchemaLanguageTooltip": "Replace this Knowledge Schema with the bundled default in another language", + "skill.switchSchemaLanguageConfirm": "Replace the current Knowledge Schema body with the bundled {language} default? Your current body will be saved as a restorable Revision in History.", + "skill.switchSchemaLanguageFailed": "Could not switch the Knowledge Schema language. The current body and language were not changed.", "skill.descriptionHelp": "Shown as a label in the list (not sent to the AI — the AI reads the prompt body).", "skill.languageLabel": "Language", "skill.languageHelp": "Applied only when the generation language matches.", @@ -2196,6 +2205,8 @@ export const en: Record = { "skill.newerDefaultTooltip": "A newer default for this skill shipped with the app. \"Reset to default\" replaces your edited content with the new version.", "skill.noMatching": "No matching skills found", "skill.empty": "No skills yet. Create one and it gets handed to the AI on ingest.", + "skill.knowledgeSchema": "Knowledge Schema", + "skill.knowledgeSchemaDescription": "Rules for generated Topics, Answers, Claims, citations, revisions, and upkeep", // エディタ・共有メニュー "editor.saveFailed": "Failed to save. Please try again.", diff --git a/src/i18n/index.test.ts b/src/i18n/index.test.ts index 5a54d10f9..45c9b518e 100644 --- a/src/i18n/index.test.ts +++ b/src/i18n/index.test.ts @@ -119,6 +119,21 @@ describe("syncLocale() / getLocale()", () => { expect(getLocale()).toBe("en"); }); + describe("Knowledge layer terminology", () => { + it("日本語UIではanswerを問答(Q&A)として表示する", () => { + syncLocale("ja"); + expect(t("wikiList.kindTopic")).toBe("トピック"); + expect(t("wikiList.kindAnswer")).toBe("問答(Q&A)"); + expect(t("skill.knowledgeSchemaDescription")).toContain("トピック・問答(Q&A)・知見"); + }); + + it("英語UIではTopics/Q&Aを表示する", () => { + syncLocale("en"); + expect(t("wikiList.kindTopic")).toBe("Topics"); + expect(t("wikiList.kindAnswer")).toBe("Q&A"); + }); + }); + it("syncLocale('ja') 後に t() が日本語翻訳を返す", () => { syncLocale("ja"); expect(getLocale()).toBe("ja"); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3e562748a..5bd5ee982 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -135,7 +135,7 @@ export const ja: Record = { "sidebar.skill": "スキル", "sidebar.chat": "チャット", "sidebar.knowledge": "ナレッジ", - "sidebar.knowledgeHint": "AI がノートから抽出したナレッジ層(トピック・知見・洞察・問答)", + "sidebar.knowledgeHint": "AI がノートから抽出したナレッジ層(トピック・問答(Q&A)・知見・洞察)", // ナレッジ内の Log / 点検 ボタン "sidebar.wikiLog": "ログ", "sidebar.wikiLogHint": "ナレッジの変更履歴と生成元", @@ -277,6 +277,10 @@ export const ja: Record = { "history.type.wikiAtomize": "洞察抽出", "history.type.wikiReinforce": "支持追加", "history.type.skillDefaultUpdate": "デフォルト同期", + "history.type.knowledgeSchemaEdit": "ナレッジスキーマを編集", + "history.type.knowledgeSchemaDefaultUpdate": "ナレッジスキーマのデフォルト同期", + "history.type.knowledgeSchemaReset": "ナレッジスキーマをリセット", + "history.type.knowledgeSchemaLanguageSwitch": "ナレッジスキーマの言語を切替", "history.type.snapshotRestore": "版を復元", "history.type.proposalAdopt": "提案を取り込み", "history.sources": "取り込み元", @@ -1171,14 +1175,14 @@ export const ja: Record = { "wikiList.kindAtom": "洞察", "wikiList.kindSynthesis": "発想", "wikiList.kindTopic": "トピック", - "wikiList.kindAnswer": "問答", + "wikiList.kindAnswer": "問答(Q&A)", // 各 kind の説明(サイドバーの title ツールチップ用)。砂時計: ノート→知見→洞察→発想 "wikiList.kindSummaryHint": "ノート1件を AI が短くまとめたもの", "wikiList.kindClaimHint": "ノートや資料から取り出した命題(1 件に 1 つ)", "wikiList.kindAtomHint": "知見から取り出した、他の分野でも通じる関係の型", "wikiList.kindSynthesisHint": "知見・洞察を組み合わせて生まれる新しいアイデア", "wikiList.kindTopicHint": "資料を読んで概念ごとにまとめたページ", - "wikiList.kindAnswerHint": "AI チャットで得た良い回答を書き残したページ", + "wikiList.kindAnswerHint": "問いと、AI チャットで得た良い回答を一組で書き残したページ", // 提案 v4 Phase 1: 意味的な型のラベル(バッジ表示用) "wikiTypes.claimRole.finding": "発見・観察", "wikiTypes.claimRole.decision": "決定・選択", @@ -1455,9 +1459,9 @@ export const ja: Record = { "wikiLint.sourceCheck.title": "出典照合", "wikiLint.sourceCheck.help": "知見とトピックの要点が、引いている出典に書いてあるかを照合します。内容が正しいかではなく、出典に書いてあるかの判定です。自動で照合するかは設定で選べます(既定はオフ)。", "wikiLint.sourceCheck.targetLabel": "対象", - "wikiLint.sourceCheck.targetBoth": "知見・トピック・問答すべて", + "wikiLint.sourceCheck.targetBoth": "知見・トピック・問答(Q&A)すべて", "wikiLint.sourceCheck.targetClaim": "知見のみ", - "wikiLint.sourceCheck.targetTopic": "トピック・問答のみ", + "wikiLint.sourceCheck.targetTopic": "トピック・問答(Q&A)のみ", "wikiLint.sourceCheck.scopeLabel": "範囲", "wikiLint.sourceCheck.scopeUnchecked": "まだ照合していないもの", "wikiLint.sourceCheck.scopeStale": "本文が変わったもの", @@ -2179,6 +2183,11 @@ export const ja: Record = { "skill.edit": "編集", "skill.resetTooltip": "このスキルをデフォルト内容に戻します", "skill.resetToDefault": "デフォルトに戻す", + "skill.switchSchemaToJapanese": "日本語版に切替", + "skill.switchSchemaToEnglish": "英語版に切替", + "skill.switchSchemaLanguageTooltip": "ナレッジスキーマを別言語の同梱既定本文へ置き換えます", + "skill.switchSchemaLanguageConfirm": "現在のナレッジスキーマ本文を{language}の同梱既定本文で置き換えますか? 置換前の本文は履歴に復元可能な改訂版として保存されます。", + "skill.switchSchemaLanguageFailed": "ナレッジスキーマの言語を切り替えられませんでした。現在の本文と言語は変更されていません。", "skill.descriptionHelp": "一覧に表示されるラベルです(AI には渡りません。AI が読むのは本文のプロンプトです)。", "skill.languageLabel": "適用言語", "skill.languageHelp": "生成言語が一致するときだけ適用されます。", @@ -2195,6 +2204,8 @@ export const ja: Record = { "skill.newerDefaultTooltip": "アプリ同梱のデフォルト内容が新しくなっています。「デフォルトに戻す」を押すと、編集中の内容を新しいデフォルトに置き換えます。", "skill.noMatching": "一致するスキルがありません", "skill.empty": "スキルはまだありません。作成すると、ナレッジ化のときに AI へ渡せます。", + "skill.knowledgeSchema": "ナレッジスキーマ", + "skill.knowledgeSchemaDescription": "トピック・問答(Q&A)・知見、引用、改訂、点検の生成規約", // エディタ・共有メニュー "editor.saveFailed": "保存に失敗しました。再度お試しください。", diff --git a/src/note-app.tsx b/src/note-app.tsx index 2be8c2a7a..3dc0fc2d9 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -1056,6 +1056,8 @@ type NoteEditorProps = { onAiDeriveNote: (doc: GraphiumDocument) => Promise; /** knowledge ノート(claim/atom)を作成し、新ファイル ID を返す(R2 / Loop M2 の手動取り込み) */ onCreateKnowledgeNote?: (doc: GraphiumDocument, kind: "claim" | "atom") => Promise; + /** 保存済み Knowledge Schema のプロンプト本文を取得する */ + getKnowledgeSchemaPrompt: () => Promise; /** * チャットの回答をナレッジ層の「回答」ページ(answer)として作成し、新ファイル ID を返す。 * トピックと同じ出典つきページの組み立て。未指定ならチャットに「ナレッジに残す」ボタンは出ない。 @@ -1545,6 +1547,7 @@ function NoteEditorInner({ onCreateLinkedNote, onAiDeriveNote, onCreateKnowledgeNote, + getKnowledgeSchemaPrompt, onCreateAnswerNote, onNavigateNote, onOpenMedia, @@ -4578,6 +4581,7 @@ function NoteEditorInner({ const existingWikiTitles = existingWikis.map((w) => ({ id: w.id, title: w.title })); // verb が精査した引用知見(現ノートの reference リンク先)を PROV の素地として温存する。 const citedIds = collectCitedNotes().map((n) => n.id); + const knowledgeSchema = await getKnowledgeSchemaPrompt(); // 1) ingester で AI 回答から知見(claim)を抽出(chat: prefix で document-mode)。一度だけ。 const ingestRes = await ingestFromChat( @@ -4585,6 +4589,7 @@ function NoteEditorInner({ noteTitle, existingWikis, getLocale(), + knowledgeSchema, ); const claimOutputs = ingestRes.wikis.filter((w) => w.kind === "claim"); if (claimOutputs.length === 0) return []; @@ -4659,7 +4664,7 @@ function NoteEditorInner({ return [...claimCandidates, ...atomCandidates]; }, - [collectCitedNotes, fileId, initialDoc?.title, noteIndex], + [collectCitedNotes, fileId, getKnowledgeSchemaPrompt, initialDoc?.title, noteIndex], ); // 選択された候補を保存する。候補ごとに 1 ノート(onCreateKnowledgeNote が @@ -7713,6 +7718,7 @@ export function NoteApp() { messages: [...history, { role: "user", content: userMessageForModel }], ...(disabledTools.length > 0 ? { disabled_tools: disabledTools } : {}), ...(wikiContext ? { wiki_context: wikiContext } : {}), + knowledge_schema: await fm.getKnowledgeSchemaPrompt(), language: getLocale(), options: { max_turns: 5, ...(selectedModel && { model: selectedModel }) }, }; @@ -7781,7 +7787,7 @@ export function NoteApp() { } } }, - [fm.noteIndex, capture.captureIndex], + [fm.noteIndex, fm.getKnowledgeSchemaPrompt, capture.captureIndex], ); // 通常の入力欄からの送信: いま開いている会話を対象に送る。 @@ -9595,6 +9601,7 @@ export function NoteApp() { saveWikiFile: fm.handleSaveWikiFile, }); const sourceTitleById = new Map(sources.map((s) => [s.id, s.title])); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); const result = await runSourceTopicStage(sources, { loadDoc: fm.loadDoc, getCachedDoc: fm.getCachedDoc, @@ -9603,6 +9610,7 @@ export function NoteApp() { existingTopicRefs, noteIndex: buildNoteIndex(fm.noteIndex), locale: getLocale(), + knowledgeSchema, resolveSource: async (sourceId) => { const resolved = await resolveSourceText(sourceId, resolveDeps); if (!resolved.ok) return undefined; @@ -9765,10 +9773,11 @@ export function NoteApp() { (id) => fm.getCachedDoc(`skill:${id}`), getLocale(), ); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); // 設定で選んだ既定モデル名を渡す。Tauri モードではヘッダーに API キーを乗せないため、 // body.model 経由でサーバーに伝えないと models.json 先頭のモデルにフォールバックしてしまう。 - const result = await ingestNote(job.noteId, job.doc, existingWikis, getLocale(), getSelectedModel() || undefined, ingestSkills, signal, isClaimsEnabled()); + const result = await ingestNote(job.noteId, job.doc, existingWikis, getLocale(), getSelectedModel() || undefined, ingestSkills, knowledgeSchema, signal, isClaimsEnabled()); // トピックは知見の有無に関係なく資料そのものから作る(Karpathy 方式)。 // 資料本文は取り込みで既に持っている job.doc をそのまま使う(再取得しない)。 @@ -9810,7 +9819,7 @@ export function NoteApp() { const existingDoc = fm.getCachedDoc(`wiki:${wiki.mergeTargetId}`); if (existingDoc) { const nIdx = buildNoteIndex(fm.noteIndex); - const mergedDoc = await rewriteAndMerge(existingDoc, wiki, job.noteId, result.model, getLocale(), nIdx, ingestSkills); + const mergedDoc = await rewriteAndMerge(existingDoc, wiki, job.noteId, result.model, getLocale(), nIdx, ingestSkills, knowledgeSchema); await fm.handleSaveWikiFile(wiki.mergeTargetId, mergedDoc, { activityType: "wiki_merge", agentLabel: result.model ?? undefined, @@ -10287,7 +10296,8 @@ export function NoteApp() { setIngestToast((prev) => ({ items: (prev?.items ?? []).map((i: IngestToastItem) => i.id === toastId ? { ...i, status: "generating" as const, detail: "Fetching URL..." } : i) })); try { const existingWikis = buildExistingWikisForIngest(fm.noteIndex?.notes, fm.getCachedDoc); - const result = await ingestFromUrl(entry.url, existingWikis, getLocale(), isClaimsEnabled()); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); + const result = await ingestFromUrl(entry.url, existingWikis, getLocale(), knowledgeSchema, isClaimsEnabled()); // 知見(wiki)が 0 件でも、資料がトピック段に積める(sourceText がある)なら // 続行する — トピックは資料から作られるので知見の有無だけでは失敗にしない。 if (result.wikis.length === 0 && !result.sourceText.trim()) { @@ -10334,7 +10344,8 @@ export function NoteApp() { const blobUrl = await provider.getMediaBlobUrl(entry.fileId); const blob = await (await fetch(blobUrl)).blob(); const existingWikis = buildExistingWikisForIngest(fm.noteIndex?.notes, fm.getCachedDoc); - const result = await ingestFromPdf(blob, entry.name || "document.pdf", sourceNoteId, existingWikis, getLocale(), isClaimsEnabled()); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); + const result = await ingestFromPdf(blob, entry.name || "document.pdf", sourceNoteId, existingWikis, getLocale(), knowledgeSchema, isClaimsEnabled()); if (result.wikis.length === 0 && !result.sourceText.trim()) { setIngestToast((prev) => ({ items: (prev?.items ?? []).map((i: IngestToastItem) => i.id === toastId ? { ...i, status: "error" as const, result: tStatic("ingest.insufficientContent") } : i) })); return; @@ -10381,7 +10392,8 @@ export function NoteApp() { const blobUrl = await provider.getMediaBlobUrl(fileId); const blob = await (await fetch(blobUrl)).blob(); const existingWikis = buildExistingWikisForIngest(fm.noteIndex?.notes, fm.getCachedDoc); - const result = await ingestFromDocx(blob, entry.name || "document.docx", sourceNoteId, existingWikis, getLocale(), isClaimsEnabled()); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); + const result = await ingestFromDocx(blob, entry.name || "document.docx", sourceNoteId, existingWikis, getLocale(), knowledgeSchema, isClaimsEnabled()); if (result.wikis.length === 0 && !result.sourceText.trim()) { setIngestToast((prev) => ({ items: (prev?.items ?? []).map((i: IngestToastItem) => i.id === toastId ? { ...i, status: "error" as const, result: tStatic("ingest.insufficientContent") } : i) })); return; @@ -10798,7 +10810,8 @@ export function NoteApp() { })); try { const existingWikis = buildExistingWikisForIngest(fm.noteIndex?.notes, fm.getCachedDoc); - const result = await ingestFromChat(chatMessages, chatTitle, existingWikis, getLocale(), isClaimsEnabled()); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); + const result = await ingestFromChat(chatMessages, chatTitle, existingWikis, getLocale(), knowledgeSchema, isClaimsEnabled()); if (result.wikis.length === 0 && !result.sourceText.trim()) { setIngestToast((prev) => ({ items: (prev?.items ?? []).map((i: IngestToastItem) => i.id === jobId ? { ...i, status: "error" as const, result: tStatic("ingest.insufficientContent") } : i) })); return; @@ -11002,6 +11015,7 @@ export function NoteApp() { return { ok: false, error: errMsg }; } const { resolveSource, resolveSourceTitle } = buildTopicSourceResolvers(); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); const rebuildResult = await rebuildTopicFromSources(wikiId, [...sourceIds], { loadDoc: fm.loadDoc, getCachedDoc: fm.getCachedDoc, @@ -11011,6 +11025,7 @@ export function NoteApp() { noteIndex: buildNoteIndex(fm.noteIndex), locale: doc.wikiMeta.language ?? getLocale(), model: selectedModel, + knowledgeSchema, log: (...args: unknown[]) => console.warn(...args), }); if (!rebuildResult.rebuilt || !rebuildResult.doc) { @@ -11162,12 +11177,14 @@ export function NoteApp() { } const regenIngestSkills = pickActiveSkills(fm.skillMetas, (id) => fm.getCachedDoc(`skill:${id}`), getLocale()); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); const result = await ingestFromMultiSource( parts, wikiTitle, wikiId, [], getLocale(), + knowledgeSchema, selectedModel, regenIngestSkills, ); @@ -12897,6 +12914,15 @@ export function NoteApp() { {fm.activeDoc?.source === "skill" && fm.activeDoc?.skillMeta && ( { + try { + await fm.handleSwitchKnowledgeSchemaLanguage(language); + } catch { + alert(tStatic("skill.switchSchemaLanguageFailed")); + } + }} onEdit={() => { const id = fm.activeFileId?.replace(/^skill:/, ""); if (id) setEditingSkillId(id); @@ -12985,6 +13011,7 @@ export function NoteApp() { onProposalRequestHandled={() => setProposalOpenRequest(null)} fileId={fm.activeFileId?.replace("wiki:", "").replace("skill:", "") ?? fm.activeFileId} initialDoc={fm.activeDoc} + getKnowledgeSchemaPrompt={fm.getKnowledgeSchemaPrompt} noteFolderLookup={noteFolderLookup} onEditMediaContexts={fm.editMediaContexts} renderPeekWikiContext={renderPeekWikiContext} @@ -13100,7 +13127,7 @@ export function NoteApp() { const email = await provider.getUserEmail() ?? undefined; const author = loadAuthorIdentity() ?? undefined; restored = await recordRevision(restored, current.pages[0] ?? null, "snapshot_restore", { force: true, email, author }); - await fm.handleSaveSkillFile(skillId, restored); + await fm.handleSaveSkillFile(skillId, restored, { skipKnowledgeSchemaRevision: true }); // cache は保存で更新済みなので、開き直しでエディタを新内容で再マウントする fm.handleOpenSkillFile(skillId); } catch (e) { @@ -13290,7 +13317,8 @@ export function NoteApp() { })); try { const existingWikis = buildExistingWikisForIngest(fm.noteIndex?.notes, fm.getCachedDoc); - const result = await ingestFromUrl(url, existingWikis, getLocale(), isClaimsEnabled()); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); + const result = await ingestFromUrl(url, existingWikis, getLocale(), knowledgeSchema, isClaimsEnabled()); if (result.wikis.length === 0 && !result.sourceText.trim()) { setIngestToast((prev) => ({ items: (prev?.items ?? []).map((i) => i.id === jobId ? { ...i, status: "error" as const, result: tStatic("ingest.insufficientContent") } : i) })); ingestQueueRef.current = ingestQueueRef.current.filter((j) => j.noteId !== jobId); @@ -13627,6 +13655,7 @@ export function NoteApp() { memberClaimIds: fm.wikiMetas.get(wf.id)?.derivedFromClaims ?? [], })); const { resolveSource, resolveSourceTitle } = buildTopicSourceResolvers(); + const knowledgeSchema = await fm.getKnowledgeSchemaPrompt(); const mergeResult = await consolidateExistingTopics(existingTopics, { loadDoc: fm.loadDoc, getCachedDoc: fm.getCachedDoc, @@ -13638,6 +13667,7 @@ export function NoteApp() { locale: getLocale(), // テーマどうしの統合可否はチャットモデルで判断する model: getChatSynthesisModelName() || undefined, + knowledgeSchema, log: (...args: unknown[]) => console.warn(...args), }); @@ -13712,6 +13742,7 @@ export function NoteApp() { return ( { grounding_scope?: GroundingScope; /** 構造化出力(コンテキストラベル)の指示に使う言語 */ language?: string; + /** AI がナレッジ生成を案内するときに参照する保存済み Schema */ + knowledge_schema?: string; options?: { max_turns?: number; model?: string; @@ -73,6 +80,10 @@ app.post("/run", async (c) => { if (!body.message && (!body.messages || body.messages.length === 0)) { return c.json({ error: "message is required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledge_schema); + if (!knowledgeSchema) { + return c.json({ error: "knowledge_schema is required" }, 400); + } // モデル解決: ヘッダー → options.model → デフォルト const modelConfig = resolveModelConfig(c, { modelName: body.options?.model }); @@ -90,6 +101,7 @@ app.post("/run", async (c) => { if (body.custom_instructions) { systemPrompt += `\n\n${body.custom_instructions}`; } + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); // メッセージ構築 // フロントエンドから messages 配列が渡された場合はそれを使う diff --git a/src/server/routes/knowledge-schema-validation.test.ts b/src/server/routes/knowledge-schema-validation.test.ts new file mode 100644 index 000000000..3f3a3732e --- /dev/null +++ b/src/server/routes/knowledge-schema-validation.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../app.js"; + +describe("Knowledge Schema 必須 endpoint", () => { + const wikiCases = [ + { + path: "ingest", + body: { + noteId: "note-1", + noteContent: "content", + noteTitle: "title", + existingWikiTitles: [], + language: "ja", + }, + }, + { + path: "rewrite", + body: { existingSections: [], newSections: [], sourceNoteTitle: "source", language: "ja" }, + }, + { + path: "consolidate-topics", + body: { language: "ja", existingTopics: [], proposedTitles: ["Topic"] }, + }, + { + path: "route-topics", + body: { language: "ja", source: { id: "source-1", title: "Source", text: "body" }, existingTopics: [] }, + }, + { + path: "revise-topic", + body: { title: "Topic", currentBody: "", language: "ja", source: { id: "source-1", title: "Source", text: "body" } }, + }, + { + path: "merge-topics", + body: { title: "Topic", language: "ja", bodies: ["one", "two"] }, + }, + ] as const; + + for (const { path, body } of wikiCases) { + it(`POST /api/wiki/${path} は非文字列 Schema を 400 にする`, async () => { + const app = createApp({ mode: "node" }); + const res = await app.request(`http://localhost/api/wiki/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...body, knowledgeSchema: { invalid: true } }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "knowledgeSchema is required" }); + }); + } + + it("POST /api/agent/run は非文字列 Schema を 400 にする", async () => { + const app = createApp({ mode: "node" }); + const res = await app.request("http://localhost/api/agent/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hello", knowledge_schema: 123 }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "knowledge_schema is required" }); + }); +}); diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index e91584dae..f0c776ae3 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -84,9 +84,14 @@ import { type SourceCheckClaimInput, type SourceCheckSourceInput, } from "../services/source-check.js"; +import { buildKnowledgeSchemaPromptSection } from "../../features/skill/skill-service.js"; const app = new Hono(); +function requiredTrimmedString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + // ノートから Wiki を生成 app.post("/ingest", async (c) => { const body = await c.req.json<{ @@ -103,11 +108,16 @@ app.post("/ingest", async (c) => { provSummary?: unknown; model?: string; skills?: { title: string; prompt: string }[]; + knowledgeSchema?: string; }>(); if (!body.noteContent) { return c.json({ error: "noteContent is required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledgeSchema); + if (!knowledgeSchema) { + return c.json({ error: "knowledgeSchema is required" }, 400); + } // モデル解決: ヘッダー → body.model → デフォルト const modelConfig = resolveModelConfig(c, { modelName: body.model }); @@ -125,12 +135,13 @@ app.post("/ingest", async (c) => { // 「短くても着想 1 件の抽出を試みる」memo モードに切り替える。 const isMemo = /^memo:/.test(body.noteId ?? ""); - const systemPrompt = buildIngesterSystemPrompt( + let systemPrompt = buildIngesterSystemPrompt( body.language || "en", body.existingWikiTitles || [], body.skills, { isDocument, isMemo }, ); + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); // PROV 構造があれば user message の先頭にコンパクトに添える。 // 中身が空(activities も results も plan も無い)なら添えても情報がないので省略。 @@ -327,11 +338,16 @@ app.post("/rewrite", async (c) => { language: string; model?: string; skills?: { title: string; prompt: string }[]; + knowledgeSchema?: string; }>(); if (!body.existingSections || !body.newSections) { return c.json({ error: "existingSections and newSections are required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledgeSchema); + if (!knowledgeSchema) { + return c.json({ error: "knowledgeSchema is required" }, 400); + } const modelConfig = resolveModelConfig(c, { modelName: body.model }); @@ -339,7 +355,8 @@ app.post("/rewrite", async (c) => { return c.json(noModelRegisteredBody(), 400); } - const systemPrompt = buildRewriterSystemPrompt(body.language || "en", body.skills); + let systemPrompt = buildRewriterSystemPrompt(body.language || "en", body.skills); + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); const userMessage = buildRewriterUserMessage({ existingSections: body.existingSections, newSections: body.newSections, @@ -384,11 +401,16 @@ app.post("/consolidate-topics", async (c) => { existingTopics: TopicConsolidatorExistingRef[]; proposedTitles: string[]; model?: string; + knowledgeSchema?: string; }>(); if (!Array.isArray(body.proposedTitles) || body.proposedTitles.length === 0) { return c.json({ error: "proposedTitles are required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledgeSchema); + if (!knowledgeSchema) { + return c.json({ error: "knowledgeSchema is required" }, 400); + } const modelConfig = resolveModelConfig(c, { modelName: body.model }); @@ -396,7 +418,8 @@ app.post("/consolidate-topics", async (c) => { return c.json(noModelRegisteredBody(), 400); } - const systemPrompt = buildTopicConsolidatorSystemPrompt(body.language || "en"); + let systemPrompt = buildTopicConsolidatorSystemPrompt(body.language || "en"); + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); const userMessage = buildTopicConsolidatorUserMessage(body.existingTopics ?? [], body.proposedTitles); try { @@ -440,11 +463,16 @@ app.post("/route-topics", async (c) => { source: TopicRouterSource; existingTopics: TopicRouterExistingRef[]; model?: string; + knowledgeSchema?: string; }>(); if (!body.source || typeof body.source.text !== "string" || !body.source.text.trim()) { return c.json({ error: "source is required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledgeSchema); + if (!knowledgeSchema) { + return c.json({ error: "knowledgeSchema is required" }, 400); + } const modelConfig = resolveModelConfig(c, { modelName: body.model }); @@ -452,7 +480,8 @@ app.post("/route-topics", async (c) => { return c.json(noModelRegisteredBody(), 400); } - const systemPrompt = buildTopicRouterSystemPrompt(body.language || "en"); + let systemPrompt = buildTopicRouterSystemPrompt(body.language || "en"); + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); const userMessage = buildTopicRouterUserMessage(body.source, body.existingTopics ?? []); try { @@ -499,11 +528,16 @@ app.post("/revise-topic", async (c) => { previouslyCited?: boolean; /** このページが回答ページ(answer)か。true のとき「問いに答え続ける」規則を追加する */ isAnswer?: boolean; + knowledgeSchema?: string; }>(); if (!body.title || !body.source || typeof body.source.text !== "string" || !body.source.text.trim()) { return c.json({ error: "title and source are required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledgeSchema); + if (!knowledgeSchema) { + return c.json({ error: "knowledgeSchema is required" }, 400); + } const modelConfig = resolveModelConfig(c, { modelName: body.model }); @@ -511,7 +545,8 @@ app.post("/revise-topic", async (c) => { return c.json(noModelRegisteredBody(), 400); } - const systemPrompt = buildSourceTopicReviserSystemPrompt(body.language || "en", body.isAnswer); + let systemPrompt = buildSourceTopicReviserSystemPrompt(body.language || "en", body.isAnswer); + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); const userMessage = buildSourceTopicReviserUserMessage(body.title, body.currentBody || "", body.source, body.previouslyCited); try { @@ -604,11 +639,16 @@ app.post("/merge-topics", async (c) => { language: string; bodies: string[]; model?: string; + knowledgeSchema?: string; }>(); if (!body.title || !Array.isArray(body.bodies) || body.bodies.length < 2) { return c.json({ error: "title and at least 2 bodies are required" }, 400); } + const knowledgeSchema = requiredTrimmedString(body.knowledgeSchema); + if (!knowledgeSchema) { + return c.json({ error: "knowledgeSchema is required" }, 400); + } const modelConfig = resolveModelConfig(c, { modelName: body.model }); @@ -616,7 +656,8 @@ app.post("/merge-topics", async (c) => { return c.json(noModelRegisteredBody(), 400); } - const systemPrompt = buildTopicMergerSystemPrompt(body.language || "en"); + let systemPrompt = buildTopicMergerSystemPrompt(body.language || "en"); + systemPrompt += buildKnowledgeSchemaPromptSection(knowledgeSchema); const userMessage = buildTopicMergerUserMessage(body.title, body.bodies); try {