From d499e085b4f920d1eb17092d5eff5ff27fe0d08b Mon Sep 17 00:00:00 2001
From: kumagallium
Date: Fri, 18 Sep 2026 15:26:45 +0900
Subject: [PATCH 1/5] =?UTF-8?q?feat(knowledge):=20Knowledge=20Schema?=
=?UTF-8?q?=E3=82=92=E5=B0=8E=E5=85=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
docs/DATA_MODEL.md | 10 ++
manual/ai-chat-and-ask.md | 2 +
manual/ja/ai-chat-and-ask.md | 2 +
manual/ja/knowledge-layer.md | 6 +
manual/ja/settings.md | 2 +
manual/knowledge-layer.md | 6 +
manual/settings.md | 2 +
src/features/ai-assistant/api.ts | 14 ++-
.../document-provenance/activity-label.ts | 3 +
.../tracker.human-edit.test.ts | 5 +-
src/features/document-provenance/tracker.ts | 2 +
src/features/document-provenance/types.ts | 6 +
src/features/skill/SkillListView.tsx | 7 +-
src/features/skill/skill-service.ts | 31 +++++
src/features/skill/skill-versioning.test.ts | 29 +++++
src/features/skill/system-skills.ts | 39 +++++-
src/features/wiki/topic-stage.ts | 31 ++++-
.../wiki/wiki-service.extract-claims.test.ts | 6 +-
.../wiki-service.knowledge-schema.test.ts | 115 ++++++++++++++++++
src/features/wiki/wiki-service.ts | 31 ++++-
src/hooks/use-file-manager.ts | 67 +++++++---
src/i18n/en.ts | 5 +
src/i18n/ja.ts | 5 +
src/note-app.tsx | 37 ++++--
src/server/routes/agent.ts | 12 ++
.../knowledge-schema-validation.test.ts | 63 ++++++++++
src/server/routes/wiki.ts | 53 +++++++-
27 files changed, 552 insertions(+), 39 deletions(-)
create mode 100644 src/features/wiki/wiki-service.knowledge-schema.test.ts
create mode 100644 src/server/routes/knowledge-schema-validation.test.ts
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 84ff99249..10a78bc33 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -56,6 +56,16 @@ 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.
+
Each note is a single JSON file (or one row in IndexedDB for the `local`
provider). The top-level shape:
diff --git a/manual/ai-chat-and-ask.md b/manual/ai-chat-and-ask.md
index bedce904f..854980191 100644
--- a/manual/ai-chat-and-ask.md
+++ b/manual/ai-chat-and-ask.md
@@ -186,3 +186,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. 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 d8fca3338..ce2dc84df 100644
--- a/manual/ja/ai-chat-and-ask.md
+++ b/manual/ja/ai-chat-and-ask.md
@@ -186,3 +186,5 @@ AI の返信には、それぞれ下にアクションボタンが付きます
| **ナレッジ化時に自動適用する** | オンにすると、Graphium がノートからナレッジを生成するとき、そのスキルのプロンプトが指示として自動で追加されます。AI チャットの会話でも同じように使われます |
スキルはふつうのドキュメントです。クリックすれば、ノートと同じようにプロンプト本文を編集できます。
+
+組み込みの**ナレッジスキーマ**は自動適用 Skill とは別です。アシスタントがナレッジ生成を支援するとき、および Topic・Answer・Claim の生成時に、保存済み本文を独立した指示として渡します。文体ではなく構造、引用、改訂、点検を定義し、system Skill と同様に編集・Reset できますが削除はできません。
diff --git a/manual/ja/knowledge-layer.md b/manual/ja/knowledge-layer.md
index 399831f58..f5b3e6f3a 100644
--- a/manual/ja/knowledge-layer.md
+++ b/manual/ja/knowledge-layer.md
@@ -6,6 +6,12 @@
ナレッジ層は AI バックエンドがあって初めて動きます。バックエンドは[デスクトップアプリ](/ja/desktop-app)に同梱されており、モデルが 1 つ以上登録されている必要があります。[AI のセットアップ](/ja/ai-setup)を参照してください。ブラウザ版のプレビューでは、**ナレッジ** セクションにナレッジの中身ではなくデスクトップ版への案内が表示されます。
:::
+## ナレッジスキーマ
+
+**ナレッジスキーマ**は、**スキル**一覧にある1件だけの編集可能な組み込み文書です。Topic・Answer・Claim の生成、出典引用、段階的な改訂、点検の規約を定義します。Writing Voice とは分離されており、コード側の引用照合、構造化出力検証、安全規則は変更しません。
+
+Schema は Skill 保存領域に1件だけ作られます。通常ノートでもナレッジページでもないため、検索、Knowledge 件数、グラフには含まれません。編集は以後の生成だけに効き、既存ナレッジを自動再生成しません。組み込み Skill と同様に削除できず、未編集ならアプリ更新時に新しい既定版へ同期されます。編集済みなら更新通知だけを表示し、**デフォルトに戻す**を選ぶまで本文は変わりません。編集、既定版同期、Reset はすべて履歴に残ります。
+
## ノートとナレッジ
実験ノートは「火曜日に何が起きたか」に答えます。ナレッジページは「いま自分は何を知っているか」に答えます。Graphium は両方を保ち、一方向にリンクします。ナレッジページの末尾には必ず **References** セクションがあり、その **Source:** の項目が蒸留元のノートへ戻るリンクになっています。いつでも元の文脈に降りていけます。
diff --git a/manual/ja/settings.md b/manual/ja/settings.md
index f605bf885..2d94a142c 100644
--- a/manual/ja/settings.md
+++ b/manual/ja/settings.md
@@ -77,6 +77,8 @@ AI 関連のすべて。まず状態表示、次にモデルの登録、任意
## ナレッジ管理
+編集可能な**ナレッジスキーマ**はこのタブではなく、サイドバーの**スキル**一覧にあります。これは1件だけの組み込み文書であり、Knowledge 件数、検索、グラフには含まれません。保存した規約は以後の Topic・Answer・Claim 生成へ適用され、編集や Reset で既存ページが自動再生成されることはありません。
+
[ナレッジ層](/ja/knowledge-layer)のメンテナンス作業です。いずれも LLM を呼ぶので、各ジョブは実行前に確認を求め、トークンのコストは[使用量](#使用量)タブに記録されます。
| グループ | 何ができるか |
diff --git a/manual/knowledge-layer.md b/manual/knowledge-layer.md
index 94fe376ef..26696ad89 100644
--- a/manual/knowledge-layer.md
+++ b/manual/knowledge-layer.md
@@ -6,6 +6,12 @@ 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. 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; an edited Schema shows an update badge and remains unchanged until you choose **Reset to default**. Editing, default synchronization, and Reset leave 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.
diff --git a/manual/settings.md b/manual/settings.md
index c46138b83..40bbb26a3 100644
--- a/manual/settings.md
+++ b/manual/settings.md
@@ -77,6 +77,8 @@ 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. Its saved rules apply to later Topic, Answer, and Claim generation; changing 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 |
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..014ebfa5d 100644
--- a/src/features/document-provenance/activity-label.ts
+++ b/src/features/document-provenance/activity-label.ts
@@ -19,6 +19,9 @@ 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 "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..d5c9d0237 100644
--- a/src/features/document-provenance/tracker.human-edit.test.ts
+++ b/src/features/document-provenance/tracker.human-edit.test.ts
@@ -8,10 +8,12 @@ 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("snapshot_restore")).toBe(true);
expect(isHumanActivityType("proposal_adopt")).toBe(true);
});
@@ -27,6 +29,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..6af4335bc 100644
--- a/src/features/document-provenance/tracker.ts
+++ b/src/features/document-provenance/tracker.ts
@@ -25,6 +25,8 @@ export function isHumanActivityType(activityType: EditActivityType): boolean {
activityType === "human_edit" ||
activityType === "human_derivation" ||
activityType === "derive_source" ||
+ activityType === "knowledge_schema_edit" ||
+ activityType === "knowledge_schema_reset" ||
activityType === "snapshot_restore" ||
activityType === "proposal_adopt"
);
diff --git a/src/features/document-provenance/types.ts b/src/features/document-provenance/types.ts
index 32ece5654..3aede4834 100644
--- a/src/features/document-provenance/types.ts
+++ b/src/features/document-provenance/types.ts
@@ -82,6 +82,12 @@ 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"
/** 手動で残した版(スナップショット)への復元。ユーザー操作なので human agent に紐づく */
| "snapshot_restore"
/** 共有された「変更の提案」の取り込み。ユーザー操作なので human agent に紐づく。
diff --git a/src/features/skill/SkillListView.tsx b/src/features/skill/SkillListView.tsx
index b7ac8a501..d579aeed7 100644
--- a/src/features/skill/SkillListView.tsx
+++ b/src/features/skill/SkillListView.tsx
@@ -63,10 +63,11 @@ export function SkillListView({
.filter((f) => 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..017801351 100644
--- a/src/features/skill/skill-service.ts
+++ b/src/features/skill/skill-service.ts
@@ -2,6 +2,9 @@
import type { GraphiumDocument, SkillMeta } from "../../lib/document-types";
import type { SystemSkillDefinition } from "./system-skills";
+import { getSystemSkillById } from "./system-skills";
+
+export const KNOWLEDGE_SCHEMA_SYSTEM_SKILL_ID = "knowledge-schema";
/**
* skillMetas Map のエントリ型。
@@ -159,6 +162,34 @@ 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}`;
+}
+
/**
* システム同梱スキルから Skill ドキュメントを生成する。
* 初回ブートストラップ時・リセット時・自動更新時に使用する。
diff --git a/src/features/skill/skill-versioning.test.ts b/src/features/skill/skill-versioning.test.ts
index 9be12e51d..bdc6aa0a7 100644
--- a/src/features/skill/skill-versioning.test.ts
+++ b/src/features/skill/skill-versioning.test.ts
@@ -9,6 +9,9 @@ import {
buildSystemSkillDocument,
extractSkillPrompt,
decideSkillSync,
+ extractKnowledgeSchemaPrompt,
+ loadKnowledgeSchemaPrompt,
+ buildKnowledgeSchemaPromptSection,
} from "./skill-service";
import { SYSTEM_SKILLS, type SystemSkillDefinition } from "./system-skills";
@@ -77,6 +80,32 @@ describe("decideSkillSync", () => {
expect(decideSkillSync(TEST_DEF, {}, "whatever")).toBe("migrate_meta");
});
+ describe("Knowledge Schema", () => {
+ 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("Voice/Skill とは独立したプロンプト section を構築する", () => {
+ expect(buildKnowledgeSchemaPromptSection("schema body")).toBe("\n\n## Knowledge Schema\n\nschema body");
+ });
+ });
+
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..294a8fbc9 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,34 @@ 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.
`;
+const KNOWLEDGE_SCHEMA_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, samples, numbers, 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.`;
+
export const SYSTEM_SKILLS: SystemSkillDefinition[] = [
{
id: "default-voice-ja",
@@ -110,6 +138,15 @@ export const SYSTEM_SKILLS: SystemSkillDefinition[] = [
availableForIngest: true,
prompt: VOICE_EN_PROMPT,
},
+ {
+ 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_PROMPT,
+ },
];
export function getSystemSkillById(id: SystemSkillId): SystemSkillDefinition | undefined {
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 4ab5c684c..797a3f9b7 100644
--- a/src/features/wiki/wiki-service.ts
+++ b/src/features/wiki/wiki-service.ts
@@ -105,6 +105,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 のとき
@@ -136,6 +138,7 @@ export async function ingestNote(
provSummary,
...(model ? { model } : {}),
...(skills && skills.length > 0 ? { skills } : {}),
+ ...(knowledgeSchema ? { knowledgeSchema } : {}),
}),
...(signal ? { signal } : {}),
});
@@ -359,6 +362,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);
@@ -389,6 +393,7 @@ export async function rewriteAndMerge(
language: existingDoc.wikiMeta?.language ?? language ?? "en",
...(model ? { model } : wikiBodyModel()),
...(skills && skills.length > 0 ? { skills } : {}),
+ ...(knowledgeSchema ? { knowledgeSchema } : {}),
}),
});
@@ -1072,6 +1077,7 @@ export async function ingestFromUrl(
url: string,
existingWikis: ExistingWikiInfo[],
language: string,
+ knowledgeSchema: string,
/** 知見(Claims)抽出を行うかどうか(既定 true)。false のときは HTML 取得・本文抽出
* だけ行い、/api/wiki/ingest は呼ばない(トピック段は呼び出し側が sourceText で走らせる)。 */
extractClaims: boolean = true,
@@ -1116,6 +1122,7 @@ export async function ingestFromUrl(
noteTitle: urlData.title || url,
existingWikiTitles: existingWikis,
language,
+ knowledgeSchema,
...wikiBodyModel(),
}),
});
@@ -1143,6 +1150,7 @@ export async function ingestFromPdf(
sourceNoteId: string,
existingWikis: ExistingWikiInfo[],
language: string,
+ knowledgeSchema: string,
/** 知見(Claims)抽出を行うかどうか(既定 true)。false のときは PDF テキスト抽出
* だけ行い、/api/wiki/ingest は呼ばない。 */
extractClaims: boolean = true,
@@ -1191,6 +1199,7 @@ export async function ingestFromPdf(
noteTitle,
existingWikiTitles: existingWikis,
language,
+ knowledgeSchema,
...wikiBodyModel(),
}),
});
@@ -1215,6 +1224,7 @@ export async function ingestFromDocx(
sourceNoteId: string,
existingWikis: ExistingWikiInfo[],
language: string,
+ knowledgeSchema: string,
/** 知見(Claims)抽出を行うかどうか(既定 true)。false のときは Word 本文抽出
* だけ行い、/api/wiki/ingest は呼ばない。 */
extractClaims: boolean = true,
@@ -1253,6 +1263,7 @@ export async function ingestFromDocx(
noteTitle,
existingWikiTitles: existingWikis,
language,
+ knowledgeSchema,
...wikiBodyModel(),
}),
});
@@ -1295,6 +1306,7 @@ export async function ingestFromMultiSource(
wikiId: string,
existingWikis: ExistingWikiInfo[],
language: string,
+ knowledgeSchema: string,
model?: string,
skills?: { title: string; prompt: string }[],
): Promise {
@@ -1330,6 +1342,7 @@ export async function ingestFromMultiSource(
language,
...(model ? { model } : wikiBodyModel()),
...(skills && skills.length > 0 ? { skills } : {}),
+ knowledgeSchema,
}),
});
@@ -1349,6 +1362,7 @@ export async function ingestFromChat(
chatTitle: string,
existingWikis: ExistingWikiInfo[],
language: string,
+ knowledgeSchema: string,
/** 知見(Claims)抽出を行うかどうか(既定 true)。false のときはメッセージの
* テキスト化だけ行い、/api/wiki/ingest は呼ばない。 */
extractClaims: boolean = true,
@@ -1374,6 +1388,7 @@ export async function ingestFromChat(
noteTitle: `Chat: ${chatTitle}`,
existingWikiTitles: existingWikis,
language,
+ knowledgeSchema,
...wikiBodyModel(),
}),
});
@@ -2183,6 +2198,7 @@ export async function consolidateTopics(
existingTopics: ExistingTopicRef[],
language: string,
model?: string,
+ knowledgeSchema?: string,
): Promise> {
if (proposedTitles.length === 0) return {};
try {
@@ -2195,6 +2211,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) {
@@ -2540,11 +2557,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) {
@@ -2573,6 +2591,7 @@ export async function reviseTopicFromSource(
/** このページが回答ページ(answer)か。true のとき「問いに答え続ける」規則を追加する */
isAnswer?: boolean,
signal?: AbortSignal,
+ knowledgeSchema?: string,
): Promise {
try {
const res = await fetch(`${API_BASE}/revise-topic`, {
@@ -2583,6 +2602,7 @@ export async function reviseTopicFromSource(
...(model ? { model } : {}),
...(previouslyCited ? { previouslyCited } : {}),
...(isAnswer ? { isAnswer } : {}),
+ ...(knowledgeSchema ? { knowledgeSchema } : {}),
}),
...(signal ? { signal } : {}),
});
@@ -2609,13 +2629,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.ts b/src/hooks/use-file-manager.ts
index 558b49a3e..f7b1125cd 100644
--- a/src/hooks/use-file-manager.ts
+++ b/src/hooks/use-file-manager.ts
@@ -3,12 +3,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GraphiumFile, GraphiumDocument, WikiKind, WikiMetaSummary } from "../lib/document-types";
+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 { loadKnowledgeSchemaPrompt, type SkillMetaSummary } from "../features/skill/skill-service";
import { promoteClaimStatusIfCorroborated, unlinkClaimFromTopic } from "../features/wiki/wiki-service";
/** Wiki 保存・新規作成時のリビジョン記録オプション */
@@ -131,9 +132,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 非対応のストレージプロバイダーです");
@@ -353,7 +354,9 @@ export function useFileManager(authenticated: boolean) {
if (provider.saveSkillFile) {
for (const def of SYSTEM_SKILLS) {
if (!existingSystemIds.has(def.id)) {
- const newId = crypto.randomUUID();
+ // 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, {
@@ -403,7 +406,12 @@ export function useFileManager(authenticated: boolean) {
},
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));
@@ -3028,16 +3036,20 @@ export function useFileManager(authenticated: boolean) {
const handleSaveSkillFile = useCallback(
async (skillId: string, doc: GraphiumDocument) => {
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"
+ ? 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 +3069,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}`) {
@@ -3084,7 +3101,7 @@ 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);
+ const def = getSystemSkillById(meta.systemSkillId as import("../features/skill/system-skills").SystemSkillId);
if (!def) return;
const prevDoc = docCacheRef.current.get(`skill:${skillId}`) ?? await loadSkillFile(skillId).catch(() => undefined);
let doc = await buildSystemSkillDocument(def);
@@ -3098,7 +3115,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 +3150,18 @@ export function useFileManager(authenticated: boolean) {
[skillMetas, 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 => {
@@ -3300,6 +3338,7 @@ export function useFileManager(authenticated: boolean) {
handleSaveSkillFile,
handleDeleteSkillFile,
handleResetSystemSkill,
+ getKnowledgeSchemaPrompt,
handleCreateSkillFile,
handleUpdateSkillMeta,
};
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index e07b06290..587d860b6 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -277,6 +277,9 @@ 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.snapshotRestore": "Version restore",
"history.type.proposalAdopt": "Proposal adopted",
"history.sources": "Sources",
@@ -2192,6 +2195,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/ja.ts b/src/i18n/ja.ts
index 65432eb9c..8f3a90c6e 100644
--- a/src/i18n/ja.ts
+++ b/src/i18n/ja.ts
@@ -277,6 +277,9 @@ export const ja: Record = {
"history.type.wikiAtomize": "洞察抽出",
"history.type.wikiReinforce": "支持追加",
"history.type.skillDefaultUpdate": "デフォルト同期",
+ "history.type.knowledgeSchemaEdit": "ナレッジスキーマを編集",
+ "history.type.knowledgeSchemaDefaultUpdate": "ナレッジスキーマのデフォルト同期",
+ "history.type.knowledgeSchemaReset": "ナレッジスキーマをリセット",
"history.type.snapshotRestore": "版を復元",
"history.type.proposalAdopt": "提案を取り込み",
"history.sources": "取り込み元",
@@ -2191,6 +2194,8 @@ export const ja: Record = {
"skill.newerDefaultTooltip": "アプリ同梱のデフォルト内容が新しくなっています。「デフォルトに戻す」を押すと、編集中の内容を新しいデフォルトに置き換えます。",
"skill.noMatching": "一致するスキルがありません",
"skill.empty": "スキルはまだありません。作成すると、ナレッジ化のときに AI へ渡せます。",
+ "skill.knowledgeSchema": "ナレッジスキーマ",
+ "skill.knowledgeSchemaDescription": "Topic・Answer・Claim、引用、改訂、点検の生成規約",
// エディタ・共有メニュー
"editor.saveFailed": "保存に失敗しました。再度お試しください。",
diff --git a/src/note-app.tsx b/src/note-app.tsx
index 54e360bc6..c16f18e1d 100644
--- a/src/note-app.tsx
+++ b/src/note-app.tsx
@@ -1030,6 +1030,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 を返す。
* トピックと同じ出典つきページの組み立て。未指定ならチャットに「ナレッジに残す」ボタンは出ない。
@@ -1519,6 +1521,7 @@ function NoteEditorInner({
onCreateLinkedNote,
onAiDeriveNote,
onCreateKnowledgeNote,
+ getKnowledgeSchemaPrompt,
onCreateAnswerNote,
onNavigateNote,
onOpenMedia,
@@ -4566,6 +4569,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(
@@ -4573,6 +4577,7 @@ function NoteEditorInner({
noteTitle,
existingWikis,
getLocale(),
+ knowledgeSchema,
);
const claimOutputs = ingestRes.wikis.filter((w) => w.kind === "claim");
if (claimOutputs.length === 0) return [];
@@ -4647,7 +4652,7 @@ function NoteEditorInner({
return [...claimCandidates, ...atomCandidates];
},
- [collectCitedNotes, fileId, initialDoc?.title, noteIndex],
+ [collectCitedNotes, fileId, getKnowledgeSchemaPrompt, initialDoc?.title, noteIndex],
);
// 選択された候補を保存する。候補ごとに 1 ノート(onCreateKnowledgeNote が
@@ -7509,6 +7514,7 @@ export function NoteApp() {
messages: [...history, { role: "user", content: text }],
...(disabledTools.length > 0 ? { disabled_tools: disabledTools } : {}),
...(wikiContext ? { wiki_context: wikiContext } : {}),
+ knowledge_schema: await fm.getKnowledgeSchemaPrompt(),
language: getLocale(),
options: { max_turns: 5, ...(selectedModel && { model: selectedModel }) },
};
@@ -9318,6 +9324,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,
@@ -9326,6 +9333,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;
@@ -9488,10 +9496,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 をそのまま使う(再取得しない)。
@@ -9533,7 +9542,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,
@@ -10010,7 +10019,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()) {
@@ -10057,7 +10067,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;
@@ -10104,7 +10115,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;
@@ -10521,7 +10533,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;
@@ -10725,6 +10738,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,
@@ -10734,6 +10748,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) {
@@ -10885,12 +10900,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,
);
@@ -12700,6 +12717,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}
@@ -13005,7 +13023,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);
@@ -13317,6 +13336,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,
@@ -13328,6 +13348,7 @@ export function NoteApp() {
locale: getLocale(),
// テーマどうしの統合可否はチャットモデルで判断する
model: getChatSynthesisModelName() || undefined,
+ knowledgeSchema,
log: (...args: unknown[]) => console.warn(...args),
});
diff --git a/src/server/routes/agent.ts b/src/server/routes/agent.ts
index 9b8aee88c..c017b8cfc 100644
--- a/src/server/routes/agent.ts
+++ b/src/server/routes/agent.ts
@@ -14,9 +14,14 @@ import { getRegistryUrl, getRegistryKey, getManualMcpServers } from "../services
import { buildLabeledOutputInstruction } from "../../features/ai-assistant/label-markers.js";
import { forcesWebSearch, type GroundingScope } from "../../lib/grounding-scope.js";
import { noModelRegisteredBody, errorBody } from "../../lib/ai-error-codes.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;
+}
+
/**
* ノート本文の取得先に関するガードレール。
*
@@ -64,6 +69,8 @@ app.post("/run", async (c) => {
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 d8f3e6431..a833f988c 100644
--- a/src/server/routes/wiki.ts
+++ b/src/server/routes/wiki.ts
@@ -79,9 +79,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<{
@@ -98,11 +103,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 });
@@ -120,12 +130,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 も無い)なら添えても情報がないので省略。
@@ -322,11 +333,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 });
@@ -334,7 +350,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,
@@ -379,11 +396,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 });
@@ -391,7 +413,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 {
@@ -435,11 +458,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 });
@@ -447,7 +475,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 {
@@ -494,11 +523,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 });
@@ -506,7 +540,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 {
@@ -599,11 +634,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 });
@@ -611,7 +651,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 {
From 7011c54ed239d1085756fbcbb5589b1e141a8c23 Mon Sep 17 00:00:00 2001
From: kumagallium
Date: Fri, 18 Sep 2026 16:38:13 +0900
Subject: [PATCH 2/5] =?UTF-8?q?feat(knowledge):=20Schema=E3=81=AE=E6=97=A5?=
=?UTF-8?q?=E6=9C=AC=E8=AA=9E=E5=88=9D=E6=9C=9F=E8=A8=AD=E5=AE=9A=E3=81=AB?=
=?UTF-8?q?=E5=AF=BE=E5=BF=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
docs/DATA_MODEL.md | 8 +
manual/ai-chat-and-ask.md | 2 +-
manual/ja/ai-chat-and-ask.md | 2 +-
manual/ja/knowledge-layer.md | 4 +-
manual/ja/settings.md | 2 +-
manual/knowledge-layer.md | 4 +-
manual/settings.md | 2 +-
src/features/skill/SkillDialog.test.tsx | 56 ++++
src/features/skill/SkillDialog.tsx | 44 ++--
src/features/skill/skill-service.ts | 110 +++++++-
src/features/skill/skill-versioning.test.ts | 278 +++++++++++++++++++-
src/features/skill/system-skills.ts | 81 +++++-
src/hooks/use-file-manager.test.tsx | 128 +++++++++
src/hooks/use-file-manager.ts | 149 +++++++----
src/note-app.tsx | 1 +
15 files changed, 792 insertions(+), 79 deletions(-)
create mode 100644 src/features/skill/SkillDialog.test.tsx
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 10a78bc33..8508a8347 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -66,6 +66,14 @@ default version and prompt hash fields, and the existing
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 English v1 Schema
+auto-updates to the English v2 default, while an edited v1 Schema only gets the
+standard newer-default notification.
+
Each note is a single JSON file (or one row in IndexedDB for the `local`
provider). The top-level shape:
diff --git a/manual/ai-chat-and-ask.md b/manual/ai-chat-and-ask.md
index 854980191..aa9c699b3 100644
--- a/manual/ai-chat-and-ask.md
+++ b/manual/ai-chat-and-ask.md
@@ -187,4 +187,4 @@ Each skill's **Edit** dialog controls:
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. 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.
+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, then stays in that saved language even if you later switch the UI. 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 ce2dc84df..b96e1c494 100644
--- a/manual/ja/ai-chat-and-ask.md
+++ b/manual/ja/ai-chat-and-ask.md
@@ -187,4 +187,4 @@ AI の返信には、それぞれ下にアクションボタンが付きます
スキルはふつうのドキュメントです。クリックすれば、ノートと同じようにプロンプト本文を編集できます。
-組み込みの**ナレッジスキーマ**は自動適用 Skill とは別です。アシスタントがナレッジ生成を支援するとき、および Topic・Answer・Claim の生成時に、保存済み本文を独立した指示として渡します。文体ではなく構造、引用、改訂、点検を定義し、system Skill と同様に編集・Reset できますが削除はできません。
+組み込みの**ナレッジスキーマ**は自動適用 Skill とは別です。アシスタントがナレッジ生成を支援するとき、および Topic・Answer・Claim の生成時に、保存済み本文を独立した指示として渡します。初回保存される既定本文はその時点の UI 言語に従い、その後は UI 言語を変えても保存言語のまま使われます。文体ではなく構造、引用、改訂、点検を定義し、system Skill と同様に編集・Reset できますが削除はできません。
diff --git a/manual/ja/knowledge-layer.md b/manual/ja/knowledge-layer.md
index f5b3e6f3a..e3a2361bd 100644
--- a/manual/ja/knowledge-layer.md
+++ b/manual/ja/knowledge-layer.md
@@ -10,7 +10,9 @@
**ナレッジスキーマ**は、**スキル**一覧にある1件だけの編集可能な組み込み文書です。Topic・Answer・Claim の生成、出典引用、段階的な改訂、点検の規約を定義します。Writing Voice とは分離されており、コード側の引用照合、構造化出力検証、安全規則は変更しません。
-Schema は Skill 保存領域に1件だけ作られます。通常ノートでもナレッジページでもないため、検索、Knowledge 件数、グラフには含まれません。編集は以後の生成だけに効き、既存ナレッジを自動再生成しません。組み込み Skill と同様に削除できず、未編集ならアプリ更新時に新しい既定版へ同期されます。編集済みなら更新通知だけを表示し、**デフォルトに戻す**を選ぶまで本文は変わりません。編集、既定版同期、Reset はすべて履歴に残ります。
+Schema は Skill 保存領域に1件だけ作られます。初回作成時だけ、保存される既定本文はその時点の UI 言語に従います。日本語 UI なら日本語本文、英語 UI なら英語本文です。その後は Schema 自身の保存言語が `skillMeta.language` に固定され、UI 言語を後から変えても本文は自動翻訳・上書き・切り替えされません。
+
+通常ノートでもナレッジページでもないため、検索、Knowledge 件数、グラフには含まれません。編集は以後の生成だけに効き、既存ナレッジを自動再生成しません。組み込み Skill と同様に削除できず、未編集なら保存言語と同じ言語の新しい既定版へ同期されます。編集済みなら更新通知だけを表示し、**デフォルトに戻す**を選ぶまで本文は変わりません。Reset も保存言語と同じ言語の既定本文を使います。編集、既定版同期、Reset はすべて履歴に残ります。
## ノートとナレッジ
diff --git a/manual/ja/settings.md b/manual/ja/settings.md
index 2d94a142c..bd27996d1 100644
--- a/manual/ja/settings.md
+++ b/manual/ja/settings.md
@@ -77,7 +77,7 @@ AI 関連のすべて。まず状態表示、次にモデルの登録、任意
## ナレッジ管理
-編集可能な**ナレッジスキーマ**はこのタブではなく、サイドバーの**スキル**一覧にあります。これは1件だけの組み込み文書であり、Knowledge 件数、検索、グラフには含まれません。保存した規約は以後の Topic・Answer・Claim 生成へ適用され、編集や Reset で既存ページが自動再生成されることはありません。
+編集可能な**ナレッジスキーマ**はこのタブではなく、サイドバーの**スキル**一覧にあります。これは1件だけの組み込み文書であり、Knowledge 件数、検索、グラフには含まれません。初回作成時だけ、その時点の UI 言語に合わせた既定本文が保存されます。その後は Schema 自身の保存言語が固定され、UI 言語を変えても自動翻訳・上書きされません。保存した規約は以後の Topic・Answer・Claim 生成へ適用され、編集や Reset で既存ページが自動再生成されることはありません。
[ナレッジ層](/ja/knowledge-layer)のメンテナンス作業です。いずれも LLM を呼ぶので、各ジョブは実行前に確認を求め、トークンのコストは[使用量](#使用量)タブに記録されます。
diff --git a/manual/knowledge-layer.md b/manual/knowledge-layer.md
index 26696ad89..7f4545134 100644
--- a/manual/knowledge-layer.md
+++ b/manual/knowledge-layer.md
@@ -10,7 +10,9 @@ The Knowledge layer runs only with an AI backend, which ships inside the [deskto
**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. 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; an edited Schema shows an update badge and remains unchanged until you choose **Reset to default**. Editing, default synchronization, and Reset leave entries in the document History.
+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, the saved Schema's own language is fixed in `skillMeta.language`; changing the UI language later does not translate, overwrite, or switch the Schema body.
+
+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, and Reset leave entries in the document History.
## Notes vs. knowledge
diff --git a/manual/settings.md b/manual/settings.md
index 40bbb26a3..08487fa75 100644
--- a/manual/settings.md
+++ b/manual/settings.md
@@ -77,7 +77,7 @@ 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. Its saved rules apply to later Topic, Answer, and Claim generation; changing or resetting it does not automatically regenerate existing pages.
+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; after that, the Schema's saved language is fixed and later UI language changes do not translate or overwrite it. Its saved rules apply to later Topic, Answer, and Claim generation; changing 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.
diff --git a/src/features/skill/SkillDialog.test.tsx b/src/features/skill/SkillDialog.test.tsx
new file mode 100644
index 000000000..38bd550d2
--- /dev/null
+++ b/src/features/skill/SkillDialog.test.tsx
@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { LocaleProvider, syncLocale } from "../../i18n";
+import { SkillDialog } from "./SkillDialog";
+
+describe("SkillDialog", () => {
+ beforeEach(() => {
+ syncLocale("ja");
+ });
+
+ it("Knowledge Schemaでは言語選択を表示せず保存言語を維持する", () => {
+ const onSubmit = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByRole("combobox")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ language: "ja" }));
+ });
+
+ it("通常Skillでは言語選択を編集できる", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("combobox")).toBeTruthy();
+ });
+});
diff --git a/src/features/skill/SkillDialog.tsx b/src/features/skill/SkillDialog.tsx
index 1adbaa540..8380acc64 100644
--- a/src/features/skill/SkillDialog.tsx
+++ b/src/features/skill/SkillDialog.tsx
@@ -18,11 +18,12 @@ type Props = {
mode: "create" | "edit";
/** 編集時の初期値。create のときは未指定でよい。 */
initial?: SkillFormValues;
+ systemSkillId?: string;
onClose: () => void;
onSubmit: (values: SkillFormValues) => void;
};
-export function SkillDialog({ mode, initial, onClose, onSubmit }: Props) {
+export function SkillDialog({ mode, initial, systemSkillId, onClose, onSubmit }: Props) {
const t = useT();
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
@@ -31,6 +32,7 @@ export function SkillDialog({ mode, initial, onClose, onSubmit }: Props) {
const [language, setLanguage] = useState<"all" | "ja" | "en">(initial?.language ?? "all");
const isEdit = mode === "edit";
+ const isKnowledgeSchema = systemSkillId === "knowledge-schema";
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -39,7 +41,9 @@ export function SkillDialog({ mode, initial, onClose, onSubmit }: Props) {
title: title.trim(),
description: description.trim(),
availableForIngest,
- language: language === "all" ? undefined : language,
+ language: isKnowledgeSchema
+ ? initial?.language
+ : language === "all" ? undefined : language,
});
};
@@ -81,23 +85,25 @@ export function SkillDialog({ mode, initial, onClose, onSubmit }: Props) {
{t("skill.descriptionHelp")}
-
-
-
-
- {t("skill.languageHelp")}
-
-
+ {!isKnowledgeSchema && (
+
+
+
+
+ {t("skill.languageHelp")}
+
+
+ )}
,
+): 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 のエントリ型。
* use-file-manager の state / SkillListView / pickActiveSkills で共用する。
@@ -20,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 ドキュメントを構築する
*/
@@ -190,6 +263,41 @@ 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 bdc6aa0a7..91af92c14 100644
--- a/src/features/skill/skill-versioning.test.ts
+++ b/src/features/skill/skill-versioning.test.ts
@@ -7,13 +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",
@@ -25,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, samples, numbers, 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";
@@ -81,6 +124,202 @@ describe("decideSkillSync", () => {
});
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].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 [
+ "安全に変更してよい",
+ "分野用語",
+ "必ず残す条件",
+ "引用粒度",
+ "Schemaで変更不可",
+ "[[source:id]]",
+ "人間所有文書",
+ "材料科学",
+ "温度",
+ "圧力",
+ "試料",
+ "Topic",
+ "Answer",
+ "Claim",
+ "引用",
+ "根拠",
+ "不確実性",
+ "改訂",
+ "lint",
+ "upkeep",
+ ]) {
+ expect(prompt).toContain(word);
+ }
+ });
+
+ 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",
+ "materials science",
+ "temperature",
+ "pressure",
+ "sample",
+ ]) {
+ expect(prompt).toContain(word);
+ }
+ });
+
it("同梱 Schema を保存可能な system Skill として生成・抽出する", async () => {
const definition = SYSTEM_SKILLS.find((skill) => skill.id === "knowledge-schema");
expect(definition).toBeDefined();
@@ -101,9 +340,46 @@ describe("decideSkillSync", () => {
})).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: 2,
+ 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("v1 英語 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", () => {
diff --git a/src/features/skill/system-skills.ts b/src/features/skill/system-skills.ts
index 294a8fbc9..addfd1c42 100644
--- a/src/features/skill/system-skills.ts
+++ b/src/features/skill/system-skills.ts
@@ -91,10 +91,18 @@ 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.
`;
-const KNOWLEDGE_SCHEMA_PROMPT = `## Knowledge Schema
+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 customization:** in materials science, require temperature, pressure, atmosphere, sample composition, processing route, and measurement conditions to stay attached to every claim; cite at the sentence or sub-result level when one paper reports multiple samples or parameter sweeps.
+- 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.
@@ -107,17 +115,74 @@ This document defines the structure and maintenance conventions for Graphium's K
- 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
+## Citation and evidence
- 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.
+- Preserve quotations, measurements, methods, provenance, and source-local distinctions at the granularity needed to audit the statement.
- Do not fabricate sources, URLs, quotations, measurements, or provenance.
-## Upkeep
+## Conditions and uncertainty
+
+- Keep conditions, sample boundaries, parameter ranges, and 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, and unresolved questions for review.
-- Do not make destructive maintenance decisions or overwrite human-owned content without the corresponding Graphium workflow.`;
+- 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 のナレッジ層の構造と保守規約を定義します。Writing Voice とは独立しています。この文書を編集しても、コード側の安全規則、構造化出力の検証、引用照合、ガードレールは必ず維持されます。
+
+## 編集ガイド
+
+- **安全に変更してよい:** 分野用語、必ず残す条件、引用粒度、ページ構成、点検観点。
+- **Schemaで変更不可・コードが守る:** JSON の形、保存経路、\`[[source:id]]\` 引用構文、人間所有文書を明示ワークフローなしに更新しないこと。
+- **具体的な日本語変更例:** 材料科学では、温度、圧力、雰囲気、試料組成、作製プロセス、測定条件を各主張に必ず残す。1 本の論文が複数試料やパラメータ掃引を報告している場合は、文単位または小さな結果単位まで引用粒度を細かくする。
+- 編集は運用できる具体的な指示にしてください。保存された本文はそのまま AI prompt に渡されるため、以後の Topic・Answer・Claim 生成に守らせたい規約として書きます。
+
+## Topic and Answer
+
+- Topic は 1 つの概念について、出典に根拠づけられたページです。Answer はタイトルの問いに答え続ける、出典に根拠づけられたページです。一般的な概説へ変えないでください。
+- 現在の本文と新しい出典からページ全体を改訂します。条件、試料、数値、スコープが異なる出典固有の記述は混ぜずに分けます。
+- 事実を述べる文にはすべて文中引用が必要です。出典に留保、不確実性、矛盾、未解決の問いがある場合は残します。引用、機構、一般化を捏造しないでください。
+
+## Claim
+
+- Claim は出典に支えられた、転用可能な 1 つの命題です。要約や教科書的な穴埋めではありません。
+- Claim は原子的に保ちます。独立して使える命題は分け、無関係な出典の根拠を 1 つの主張に混ぜないでください。
+- 条件、根拠の限界、確からしさの強さを残します。分類は出典が支える範囲だけから行います。
+
+## 引用と根拠
+
+- Graphium が渡した正確な引用 ID を使います。引用は、その文末にある文を支えていなければなりません。
+- 記述を監査できる粒度で、引用、測定値、方法、来歴、出典内の区別を残します。
+- 出典、URL、引用文、測定値、来歴を捏造しないでください。
+
+## 条件と不確実性
+
+- 条件、試料境界、パラメータ範囲、否定的結果や差が出なかった結果は、主張の成立範囲に影響するなら残します。
+- 不確実性、不一致、根拠不足、未解決の問いを、なめらかに消さず明示します。
+
+## 改訂
+
+- 改訂では、新しい根拠を取り込みつつ、根拠のある既存記述を黙って消しません。
+- 明示的な矛盾は、衝突や未解決の問いとして置きます。矛盾を隠す平均的な記述にしないでください。
+- 人間が所有している文言や判断を、対応する Graphium ワークフローなしに上書きしないでください。
+
+## lint と upkeep
+
+- 広いが弱いページより、精密で追跡できるページを優先します。
+- 根拠不足、古くなった記述、重複、矛盾、引用漏れ、未解決の問いを点検対象として表面化します。
+- 破壊的な保守判断を勝手に行わず、人間所有文書を対応する Graphium ワークフローなしに上書きしないでください。`,
+};
export const SYSTEM_SKILLS: SystemSkillDefinition[] = [
{
@@ -140,12 +205,12 @@ export const SYSTEM_SKILLS: SystemSkillDefinition[] = [
},
{
id: "knowledge-schema",
- version: 1,
+ version: 2,
title: "Knowledge Schema / ナレッジスキーマ",
description: "Conventions for Topics, Answers, Claims, citations, revision, and upkeep / ナレッジ生成・引用・改訂の規約",
language: "en",
availableForIngest: false,
- prompt: KNOWLEDGE_SCHEMA_PROMPT,
+ prompt: KNOWLEDGE_SCHEMA_PROMPTS.en,
},
];
diff --git a/src/hooks/use-file-manager.test.tsx b/src/hooks/use-file-manager.test.tsx
index 332bd83e9..ad9210585 100644
--- a/src/hooks/use-file-manager.test.tsx
+++ b/src/hooks/use-file-manager.test.tsx
@@ -22,6 +22,8 @@ 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 {
buildProcessEntry,
clearLatestProcessIndex,
@@ -222,6 +224,43 @@ 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 };
+ 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 {
+ 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 +793,95 @@ 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"));
+ 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("安全に変更してよい");
+ });
+});
+
// ---------------------------------------------------------------------------
// 不変条件 3: 空の保管庫でも mediaIndex は確定する
// ---------------------------------------------------------------------------
diff --git a/src/hooks/use-file-manager.ts b/src/hooks/use-file-manager.ts
index f7b1125cd..d1de1f4d3 100644
--- a/src/hooks/use-file-manager.ts
+++ b/src/hooks/use-file-manager.ts
@@ -3,13 +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 { loadKnowledgeSchemaPrompt, 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 保存・新規作成時のリビジョン記録オプション */
@@ -296,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 ?? "",
@@ -317,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());
@@ -350,10 +416,12 @@ 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)) {
+ 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();
@@ -372,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);
@@ -384,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);
@@ -399,11 +468,11 @@ 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(
@@ -3100,10 +3169,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 import("../features/skill/system-skills").SystemSkillId);
- 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 操作として記録する)
@@ -3198,16 +3268,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) => {
@@ -3217,7 +3278,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;
});
diff --git a/src/note-app.tsx b/src/note-app.tsx
index c16f18e1d..1b49b68d7 100644
--- a/src/note-app.tsx
+++ b/src/note-app.tsx
@@ -13423,6 +13423,7 @@ export function NoteApp() {
return (
Date: Fri, 18 Sep 2026 17:25:22 +0900
Subject: [PATCH 3/5] =?UTF-8?q?feat(knowledge):=20Schema=E3=81=AE=E8=A1=A8?=
=?UTF-8?q?=E7=A4=BA=E8=A8=80=E8=AA=9E=E3=82=92=E5=88=87=E6=9B=BF=E5=8F=AF?=
=?UTF-8?q?=E8=83=BD=E3=81=AB=E3=81=99=E3=82=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
docs/DATA_MODEL.md | 10 ++
manual/ai-chat-and-ask.md | 2 +-
manual/ja/ai-chat-and-ask.md | 2 +-
manual/ja/knowledge-layer.md | 4 +-
manual/ja/settings.md | 2 +-
manual/knowledge-layer.md | 4 +-
manual/settings.md | 2 +-
.../document-provenance/activity-label.ts | 1 +
.../tracker.human-edit.test.ts | 1 +
src/features/document-provenance/tracker.ts | 1 +
src/features/document-provenance/types.ts | 2 +
src/features/skill/SkillBanner.test.tsx | 89 ++++++++++++++
src/features/skill/SkillBanner.tsx | 48 +++++++-
.../version-snapshots/snapshot-store.test.ts | 56 +++++++++
.../version-snapshots/snapshot-store.ts | 12 +-
src/hooks/use-file-manager.test.tsx | 111 +++++++++++++++++-
src/hooks/use-file-manager.ts | 80 ++++++++++++-
src/i18n/en.ts | 6 +
src/i18n/ja.ts | 6 +
src/note-app.tsx | 11 +-
20 files changed, 432 insertions(+), 18 deletions(-)
create mode 100644 src/features/skill/SkillBanner.test.tsx
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index 8508a8347..4480ffd13 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -74,6 +74,16 @@ from the saved `skillMeta.language`, so an untouched English v1 Schema
auto-updates to the English v2 default, while an edited v1 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 bundled v2 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:
diff --git a/manual/ai-chat-and-ask.md b/manual/ai-chat-and-ask.md
index aa9c699b3..20624d576 100644
--- a/manual/ai-chat-and-ask.md
+++ b/manual/ai-chat-and-ask.md
@@ -187,4 +187,4 @@ Each skill's **Edit** dialog controls:
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, then stays in that saved language even if you later switch the UI. 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.
+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 b96e1c494..c2dfd6f29 100644
--- a/manual/ja/ai-chat-and-ask.md
+++ b/manual/ja/ai-chat-and-ask.md
@@ -187,4 +187,4 @@ AI の返信には、それぞれ下にアクションボタンが付きます
スキルはふつうのドキュメントです。クリックすれば、ノートと同じようにプロンプト本文を編集できます。
-組み込みの**ナレッジスキーマ**は自動適用 Skill とは別です。アシスタントがナレッジ生成を支援するとき、および Topic・Answer・Claim の生成時に、保存済み本文を独立した指示として渡します。初回保存される既定本文はその時点の UI 言語に従い、その後は UI 言語を変えても保存言語のまま使われます。文体ではなく構造、引用、改訂、点検を定義し、system Skill と同様に編集・Reset できますが削除はできません。
+組み込みの**ナレッジスキーマ**は自動適用 Skill とは別です。アシスタントがナレッジ生成を支援するとき、および Topic・Answer・Claim の生成時に、保存済み本文を独立した指示として渡します。初回保存される既定本文はその時点の UI 言語に従い、その後に UI 言語を変えても自動では切り替わりません。意図して置換するときだけ、Schema を開いて確認付きの **日本語版に切替** または **英語版に切替** を使います。置換前の本文は履歴から復元できます。文体ではなく構造、引用、改訂、点検を定義し、system Skill と同様に編集・Reset できますが削除はできません。
diff --git a/manual/ja/knowledge-layer.md b/manual/ja/knowledge-layer.md
index e3a2361bd..0a17595e7 100644
--- a/manual/ja/knowledge-layer.md
+++ b/manual/ja/knowledge-layer.md
@@ -10,9 +10,9 @@
**ナレッジスキーマ**は、**スキル**一覧にある1件だけの編集可能な組み込み文書です。Topic・Answer・Claim の生成、出典引用、段階的な改訂、点検の規約を定義します。Writing Voice とは分離されており、コード側の引用照合、構造化出力検証、安全規則は変更しません。
-Schema は Skill 保存領域に1件だけ作られます。初回作成時だけ、保存される既定本文はその時点の UI 言語に従います。日本語 UI なら日本語本文、英語 UI なら英語本文です。その後は Schema 自身の保存言語が `skillMeta.language` に固定され、UI 言語を後から変えても本文は自動翻訳・上書き・切り替えされません。
+Schema は Skill 保存領域に1件だけ作られます。初回作成時だけ、保存される既定本文はその時点の UI 言語に従います。日本語 UI なら日本語本文、英語 UI なら英語本文です。その後に UI 言語を変えても、本文は自動翻訳・上書き・切り替えされません。意図して変更する場合はナレッジスキーマを開き、バナーの **日本語版に切替** または **英語版に切替** を選びます。Graphium は確認後に対象言語の同梱既定本文へ置換し、置換前の本文は履歴から復元できます。
-通常ノートでもナレッジページでもないため、検索、Knowledge 件数、グラフには含まれません。編集は以後の生成だけに効き、既存ナレッジを自動再生成しません。組み込み Skill と同様に削除できず、未編集なら保存言語と同じ言語の新しい既定版へ同期されます。編集済みなら更新通知だけを表示し、**デフォルトに戻す**を選ぶまで本文は変わりません。Reset も保存言語と同じ言語の既定本文を使います。編集、既定版同期、Reset はすべて履歴に残ります。
+通常ノートでもナレッジページでもないため、検索、Knowledge 件数、グラフには含まれません。編集は以後の生成だけに効き、既存ナレッジを自動再生成しません。組み込み Skill と同様に削除できず、未編集なら保存言語と同じ言語の新しい既定版へ同期されます。編集済みなら更新通知だけを表示し、**デフォルトに戻す**を選ぶまで本文は変わりません。Reset も保存言語と同じ言語の既定本文を使います。編集、既定版同期、Reset、明示的な言語切替は、それぞれ区別して履歴に残ります。
## ノートとナレッジ
diff --git a/manual/ja/settings.md b/manual/ja/settings.md
index bd27996d1..2722f5d28 100644
--- a/manual/ja/settings.md
+++ b/manual/ja/settings.md
@@ -77,7 +77,7 @@ AI 関連のすべて。まず状態表示、次にモデルの登録、任意
## ナレッジ管理
-編集可能な**ナレッジスキーマ**はこのタブではなく、サイドバーの**スキル**一覧にあります。これは1件だけの組み込み文書であり、Knowledge 件数、検索、グラフには含まれません。初回作成時だけ、その時点の UI 言語に合わせた既定本文が保存されます。その後は Schema 自身の保存言語が固定され、UI 言語を変えても自動翻訳・上書きされません。保存した規約は以後の Topic・Answer・Claim 生成へ適用され、編集や Reset で既存ページが自動再生成されることはありません。
+編集可能な**ナレッジスキーマ**はこのタブではなく、サイドバーの**スキル**一覧にあります。これは1件だけの組み込み文書であり、Knowledge 件数、検索、グラフには含まれません。初回作成時だけ、その時点の UI 言語に合わせた既定本文が保存されます。その後に UI 言語を変えても自動翻訳・上書きされません。意図して別言語の同梱既定本文へ置換するときは、Schema を開いて確認付きの言語切替を使います。保存した規約は以後の Topic・Answer・Claim 生成へ適用され、編集、言語切替、Reset で既存ページが自動再生成されることはありません。
[ナレッジ層](/ja/knowledge-layer)のメンテナンス作業です。いずれも LLM を呼ぶので、各ジョブは実行前に確認を求め、トークンのコストは[使用量](#使用量)タブに記録されます。
diff --git a/manual/knowledge-layer.md b/manual/knowledge-layer.md
index 7f4545134..45200e056 100644
--- a/manual/knowledge-layer.md
+++ b/manual/knowledge-layer.md
@@ -10,9 +10,9 @@ The Knowledge layer runs only with an AI backend, which ships inside the [deskto
**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, the saved Schema's own language is fixed in `skillMeta.language`; changing the UI language later does not translate, overwrite, or switch the Schema body.
+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, and Reset leave entries in the document 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
diff --git a/manual/settings.md b/manual/settings.md
index 08487fa75..008fec94a 100644
--- a/manual/settings.md
+++ b/manual/settings.md
@@ -77,7 +77,7 @@ 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; after that, the Schema's saved language is fixed and later UI language changes do not translate or overwrite it. Its saved rules apply to later Topic, Answer, and Claim generation; changing or resetting it does not automatically regenerate existing pages.
+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.
diff --git a/src/features/document-provenance/activity-label.ts b/src/features/document-provenance/activity-label.ts
index 014ebfa5d..fc91f3d0a 100644
--- a/src/features/document-provenance/activity-label.ts
+++ b/src/features/document-provenance/activity-label.ts
@@ -22,6 +22,7 @@ export function activityTypeLabelKey(type: string): string | null {
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 d5c9d0237..9e4b2661e 100644
--- a/src/features/document-provenance/tracker.human-edit.test.ts
+++ b/src/features/document-provenance/tracker.human-edit.test.ts
@@ -14,6 +14,7 @@ describe("isHumanActivityType", () => {
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);
});
diff --git a/src/features/document-provenance/tracker.ts b/src/features/document-provenance/tracker.ts
index 6af4335bc..cb13d8c16 100644
--- a/src/features/document-provenance/tracker.ts
+++ b/src/features/document-provenance/tracker.ts
@@ -27,6 +27,7 @@ export function isHumanActivityType(activityType: EditActivityType): boolean {
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 3aede4834..1c91c4789 100644
--- a/src/features/document-provenance/types.ts
+++ b/src/features/document-provenance/types.ts
@@ -88,6 +88,8 @@ export type EditActivityType =
| "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..395bce91d
--- /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("復元可能なRevision(版)"));
+ 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 && (
+
+ )}