@unson/brainbase-mcp 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -5
- package/contracts/judgment-dag/digest.json +4 -4
- package/contracts/judgment-dag/source-lock.json +65 -1
- package/contracts/judgment-value-proof/fixture.json +54 -0
- package/contracts/judgment-value-proof/schema.json +163 -0
- package/dist/cli.js +17 -0
- package/dist/embedding-provider.d.ts +72 -0
- package/dist/embedding-provider.js +391 -0
- package/dist/graph-retrieval.d.ts +94 -0
- package/dist/graph-retrieval.js +443 -0
- package/dist/judgment-dag-artifact-store.d.ts +29 -0
- package/dist/judgment-dag-artifact-store.js +416 -0
- package/dist/judgment-dag-replay-evaluation.d.ts +108 -0
- package/dist/judgment-dag-replay-evaluation.js +424 -0
- package/dist/judgment-dag.d.ts +4 -0
- package/dist/judgment-dag.js +2 -0
- package/dist/judgment-value-proof.d.ts +92 -0
- package/dist/judgment-value-proof.js +229 -0
- package/dist/organization-graph.d.ts +76 -0
- package/dist/organization-graph.js +331 -0
- package/dist/portable-graph.d.ts +39 -0
- package/dist/portable-graph.js +240 -0
- package/dist/server.d.ts +42 -3
- package/dist/server.js +52 -7
- package/docs/management/judgment-dag-milestones.md +31 -1
- package/package.json +22 -6
- package/ui/icons/mana/LICENSE.tabler.txt +21 -0
- package/ui/icons/mana/affiliate.svg +20 -0
- package/ui/icons/mana/brand-github.svg +15 -0
- package/ui/icons/mana/brand-google-drive.svg +17 -0
- package/ui/icons/mana/brand-slack.svg +18 -0
- package/ui/icons/mana/cpu.svg +24 -0
- package/ui/outcome-knowledge.css +365 -0
- package/ui/outcome-knowledge.js +1570 -0
- package/ui/outcome-mana.css +255 -0
- package/ui/outcome-mana.js +2325 -0
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Brainbase knowledge outcome surface.
|
|
3
|
+
*
|
|
4
|
+
* This file is intentionally independent from app.js. The host application
|
|
5
|
+
* provides the same-origin API functions and project/session context; this
|
|
6
|
+
* module owns only the knowledge workflow's DOM and local state. Every
|
|
7
|
+
* mutation is followed by an explicit readback check before it is labelled
|
|
8
|
+
* verified.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const UNKNOWN = '未確認';
|
|
12
|
+
const EMPTY = 'empty';
|
|
13
|
+
const READY = 'ready';
|
|
14
|
+
const KNOWLEDGE_RELATION_TYPES = Object.freeze([
|
|
15
|
+
['related_to', '関連する'],
|
|
16
|
+
['references', '参照する'],
|
|
17
|
+
['depends_on', '依存する'],
|
|
18
|
+
['derived_from', '派生元'],
|
|
19
|
+
['supersedes', '置き換える'],
|
|
20
|
+
['applies_to', '適用する'],
|
|
21
|
+
['evidenced_by', '根拠とする'],
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const KNOWLEDGE_STORY_IDS = Object.freeze([
|
|
25
|
+
'story-brainbase-outcome-knowledge-discovery',
|
|
26
|
+
'story-brainbase-outcome-knowledge-capture',
|
|
27
|
+
'story-brainbase-outcome-knowledge-canonical-save',
|
|
28
|
+
'story-brainbase-outcome-knowledge-lifecycle',
|
|
29
|
+
'story-brainbase-outcome-knowledge-codex-use',
|
|
30
|
+
'story-brainbase-outcome-knowledge-preview',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export const KNOWLEDGE_ROUTES = Object.freeze({
|
|
34
|
+
foundation: 'foundation',
|
|
35
|
+
items: 'items',
|
|
36
|
+
drafts: 'drafts',
|
|
37
|
+
captureProposal: 'capture/proposal',
|
|
38
|
+
lifecycle: 'lifecycle',
|
|
39
|
+
supersessions: 'supersessions',
|
|
40
|
+
history: 'history',
|
|
41
|
+
preview: 'preview',
|
|
42
|
+
documentSourceRegistration: 'document-source-registration',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const STATE_LABELS = Object.freeze({
|
|
46
|
+
loading: '読み込み中',
|
|
47
|
+
ready: '取得済み',
|
|
48
|
+
results: '検索結果',
|
|
49
|
+
empty: '該当なし',
|
|
50
|
+
permission_denied: '権限不足',
|
|
51
|
+
forbidden: '権限不足',
|
|
52
|
+
error_retryable: '取得失敗',
|
|
53
|
+
error: '取得失敗',
|
|
54
|
+
unknown: '確認できません',
|
|
55
|
+
draft: '下書き',
|
|
56
|
+
candidate: '未確定候補',
|
|
57
|
+
saving: '保存中',
|
|
58
|
+
saved_unverified: '保存済み・未確認',
|
|
59
|
+
verified: '正本と一致',
|
|
60
|
+
conflict: '版の競合',
|
|
61
|
+
unregistered: '未登録',
|
|
62
|
+
stale: '旧版',
|
|
63
|
+
isolated: '隔離実行',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const TYPE_LABELS = Object.freeze({
|
|
67
|
+
fact: '事実',
|
|
68
|
+
decision: '判断',
|
|
69
|
+
principle: '原則',
|
|
70
|
+
procedure: '手順',
|
|
71
|
+
term: '用語',
|
|
72
|
+
policy: '方針',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
function getDocument() {
|
|
76
|
+
if (typeof document === 'undefined' || !document?.createElement) throw new Error('document_unavailable');
|
|
77
|
+
return document;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeElement(tag, options = {}) {
|
|
81
|
+
const element = getDocument().createElement(tag);
|
|
82
|
+
if (options.className) element.className = options.className;
|
|
83
|
+
if (options.text !== undefined && options.text !== null) element.textContent = String(options.text);
|
|
84
|
+
for (const [name, value] of Object.entries(options.attrs ?? {})) {
|
|
85
|
+
if (value === undefined || value === null || value === false) continue;
|
|
86
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
87
|
+
}
|
|
88
|
+
if (options.value !== undefined && 'value' in element) element.value = String(options.value);
|
|
89
|
+
if (options.checked !== undefined && 'checked' in element) element.checked = Boolean(options.checked);
|
|
90
|
+
if (options.disabled !== undefined) element.disabled = Boolean(options.disabled);
|
|
91
|
+
if (options.hidden !== undefined) element.hidden = Boolean(options.hidden);
|
|
92
|
+
return element;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function append(parent, ...children) {
|
|
96
|
+
parent.append(...children.filter((child) => child !== null && child !== undefined));
|
|
97
|
+
return parent;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function clear(parent) {
|
|
101
|
+
parent.replaceChildren();
|
|
102
|
+
return parent;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function text(value, fallback = UNKNOWN) {
|
|
106
|
+
if (value === null || value === undefined) return fallback;
|
|
107
|
+
if (typeof value === 'string') return value.trim() || fallback;
|
|
108
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
109
|
+
return fallback;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function optionalText(value) {
|
|
113
|
+
if (value === null || value === undefined) return '';
|
|
114
|
+
return typeof value === 'string' ? value : String(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function asArray(value) {
|
|
118
|
+
return Array.isArray(value) ? value : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function typeLabel(value) {
|
|
122
|
+
const normalized = optionalText(value).toLowerCase();
|
|
123
|
+
return TYPE_LABELS[normalized] ?? text(value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stateLabel(value) {
|
|
127
|
+
return STATE_LABELS[value] ?? (value ? text(value) : UNKNOWN);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normaliseStatus(value) {
|
|
131
|
+
const status = optionalText(value).toLowerCase();
|
|
132
|
+
if (['permission_denied', 'forbidden', 'permission_insufficient', 'not_allowed'].includes(status)) return 'permission_denied';
|
|
133
|
+
if (['loading', 'ready', 'results', 'empty', 'error_retryable', 'error', 'unknown'].includes(status)) return status;
|
|
134
|
+
if (['saved', 'persisted', 'saved_unverified'].includes(status)) return 'saved_unverified';
|
|
135
|
+
if (['verified', 'readback_verified', 'committed_verified'].includes(status)) return 'verified';
|
|
136
|
+
if (['candidate', 'draft'].includes(status)) return status;
|
|
137
|
+
if (['conflict', 'version_conflict'].includes(status)) return 'conflict';
|
|
138
|
+
return status || 'unknown';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function safeHref(value) {
|
|
142
|
+
const candidate = optionalText(value).trim();
|
|
143
|
+
return /^https?:\/\//i.test(candidate) ? candidate : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function displayDateTime(value) {
|
|
147
|
+
const candidate = optionalText(value).trim();
|
|
148
|
+
if (!candidate) return UNKNOWN;
|
|
149
|
+
const date = new Date(candidate);
|
|
150
|
+
return Number.isNaN(date.getTime()) ? candidate : date.toLocaleString('ja-JP');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function toLocalDateTimeValue(value) {
|
|
154
|
+
const candidate = optionalText(value).trim();
|
|
155
|
+
if (!candidate) return '';
|
|
156
|
+
const date = new Date(candidate);
|
|
157
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
158
|
+
const pad = (part) => String(part).padStart(2, '0');
|
|
159
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function fromLocalDateTimeValue(value, originalValue = null) {
|
|
163
|
+
const candidate = optionalText(value).trim();
|
|
164
|
+
if (!candidate) return null;
|
|
165
|
+
if (candidate === toLocalDateTimeValue(originalValue)) return originalValue;
|
|
166
|
+
const date = new Date(candidate);
|
|
167
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fieldValue(source, ...keys) {
|
|
171
|
+
for (const key of keys) {
|
|
172
|
+
if (source && Object.hasOwn(source, key) && source[key] !== null && source[key] !== undefined) return source[key];
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function ownerValue(owner) {
|
|
178
|
+
if (typeof owner === 'string') return owner;
|
|
179
|
+
if (!owner || typeof owner !== 'object') return UNKNOWN;
|
|
180
|
+
return text(fieldValue(owner, 'name', 'display_name', 'label', 'id'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function sourceState(source) {
|
|
184
|
+
const value = optionalText(fieldValue(source, 'content_state', 'contentState', 'state')).toLowerCase();
|
|
185
|
+
if (['fetched', 'retrieved', 'available', 'present'].includes(value)) return 'fetched';
|
|
186
|
+
if (['pointer_only', 'pointer-only', 'reference_only'].includes(value)) return 'pointer_only';
|
|
187
|
+
return value || 'unknown';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sourceLabel(source) {
|
|
191
|
+
if (!source || typeof source !== 'object') return UNKNOWN;
|
|
192
|
+
return text(fieldValue(source, 'kind', 'label', 'type', 'name'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function renderKnowledgeDestination(root, model = {}, callbacks = {}) {
|
|
196
|
+
clear(root);
|
|
197
|
+
const section = makeElement('section', { className: 'knowledge-destination' });
|
|
198
|
+
section.append(makeElement('h3', { text: '知識文書の保存先' }));
|
|
199
|
+
const registration = model.registration && typeof model.registration === 'object' ? model.registration : null;
|
|
200
|
+
if (registration) {
|
|
201
|
+
section.append(makeElement('p', {
|
|
202
|
+
className: 'knowledge-inline-status',
|
|
203
|
+
text: `現在の登録: ${text(registration.repository_owner)} / ${text(registration.repository_name)} / ${text(registration.branch)} / ${text(registration.path_scope)}`,
|
|
204
|
+
}));
|
|
205
|
+
} else if (model.state === 'unregistered') {
|
|
206
|
+
section.append(makeElement('p', { className: 'knowledge-notice warning', text: '保存先は未登録です。リポジトリ、ブランチ、保存範囲を確認して登録してください。', attrs: { role: 'status' } }));
|
|
207
|
+
} else if (model.state === 'loading') {
|
|
208
|
+
section.append(makeElement('p', { className: 'knowledge-inline-status', text: '保存先を読み込んでいます。', attrs: { role: 'status' } }));
|
|
209
|
+
}
|
|
210
|
+
if (model.state === 'conflict') {
|
|
211
|
+
section.append(statusNotice({ state: 'conflict', detail: model.message || '別の変更が先に保存されました。入力は保持しています。' }, { retry: Boolean(callbacks.onReload), onRetry: callbacks.onReload }));
|
|
212
|
+
} else if (['permission_denied', 'error_retryable', 'unknown'].includes(model.state)) {
|
|
213
|
+
section.append(statusNotice(model, { retry: Boolean(callbacks.onReload), onRetry: callbacks.onReload }));
|
|
214
|
+
} else if (model.state === 'verified') {
|
|
215
|
+
section.append(makeElement('p', { className: 'knowledge-success-copy', text: '登録内容を再取得し、保存先と版が一致しました。', attrs: { role: 'status' } }));
|
|
216
|
+
}
|
|
217
|
+
if (!callbacks.canEdit) { root.append(section); return section; }
|
|
218
|
+
const draft = model.draft && typeof model.draft === 'object' && Object.keys(model.draft).length
|
|
219
|
+
? model.draft
|
|
220
|
+
: registration ?? {};
|
|
221
|
+
const form = makeElement('form', { className: 'knowledge-destination-form' });
|
|
222
|
+
const controls = {};
|
|
223
|
+
for (const [name, labelText, placeholder] of [
|
|
224
|
+
['repository_owner', 'リポジトリ所有者', '例: Unson-LLC'],
|
|
225
|
+
['repository_name', 'リポジトリ名', '確認済みの名前を入力'],
|
|
226
|
+
['branch', 'ブランチ', '既定値は自動入力しません'],
|
|
227
|
+
['path_scope', '保存範囲', 'リポジトリ相対パス'],
|
|
228
|
+
]) {
|
|
229
|
+
const label = makeElement('label', { className: 'knowledge-field' });
|
|
230
|
+
label.append(makeElement('span', { text: labelText }));
|
|
231
|
+
const input = makeElement('input', { className: 'knowledge-control', value: draft[name] ?? '', attrs: { name, maxlength: '240', required: 'required', placeholder } });
|
|
232
|
+
controls[name] = input; label.append(input); form.append(label);
|
|
233
|
+
}
|
|
234
|
+
const submit = makeElement('button', { className: 'knowledge-button primary', text: model.state === 'saving' ? '保存中…' : registration ? '保存先を変更' : '保存先を登録', attrs: { type: 'submit' }, disabled: model.state === 'saving' });
|
|
235
|
+
form.append(submit);
|
|
236
|
+
form.addEventListener('submit', (event) => {
|
|
237
|
+
event.preventDefault();
|
|
238
|
+
callbacks.onSave?.({
|
|
239
|
+
repository_owner: controls.repository_owner.value,
|
|
240
|
+
repository_name: controls.repository_name.value,
|
|
241
|
+
branch: controls.branch.value,
|
|
242
|
+
path_scope: controls.path_scope.value,
|
|
243
|
+
expected_revision: model.revision ?? registration?.revision ?? null,
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
section.append(form); root.append(section); return section;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function sourcePointer(source) {
|
|
250
|
+
if (!source || typeof source !== 'object') return null;
|
|
251
|
+
return fieldValue(source, 'pointer', 'url', 'ref', 'reference', 'path');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normaliseSource(source) {
|
|
255
|
+
if (!source || typeof source !== 'object') return { state: 'unknown', kind: UNKNOWN, pointer: null, refs: null };
|
|
256
|
+
const refs = asArray(fieldValue(source, 'refs', 'references', 'evidence_refs', 'evidenceRefs'));
|
|
257
|
+
return {
|
|
258
|
+
state: sourceState(source),
|
|
259
|
+
kind: sourceLabel(source),
|
|
260
|
+
pointer: sourcePointer(source),
|
|
261
|
+
refs,
|
|
262
|
+
excerpt: fieldValue(source, 'excerpt', 'quote', 'text'),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normaliseApplicability(value) {
|
|
267
|
+
if (!value || typeof value !== 'object') return { state: 'unknown', conditions: null };
|
|
268
|
+
const state = optionalText(fieldValue(value, 'state', 'status')).toLowerCase();
|
|
269
|
+
return {
|
|
270
|
+
state: state || 'unknown',
|
|
271
|
+
conditions: fieldValue(value, 'conditions', 'when', 'description'),
|
|
272
|
+
project: fieldValue(value, 'project', 'project_code', 'projectCode'),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function normaliseLifecycle(item) {
|
|
277
|
+
const lifecycle = item?.lifecycle && typeof item.lifecycle === 'object' ? item.lifecycle : {};
|
|
278
|
+
return {
|
|
279
|
+
status: text(fieldValue(lifecycle, 'status') ?? fieldValue(item, 'status'), UNKNOWN),
|
|
280
|
+
applicable: fieldValue(lifecycle, 'applicable', 'is_applicable'),
|
|
281
|
+
effective_at: fieldValue(lifecycle, 'effective_at', 'effectiveAt'),
|
|
282
|
+
expires_at: fieldValue(lifecycle, 'expires_at', 'expiresAt'),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function normalizeKnowledgeItem(item) {
|
|
287
|
+
if (!item || typeof item !== 'object') return null;
|
|
288
|
+
const source = normaliseSource(fieldValue(item, 'source', 'provenance'));
|
|
289
|
+
const applicability = normaliseApplicability(fieldValue(item, 'applicability', 'applicable_when'));
|
|
290
|
+
const relations = asArray(fieldValue(item, 'relations', 'related', 'edges'));
|
|
291
|
+
const version = fieldValue(item, 'version', 'revision', 'revision_number');
|
|
292
|
+
return {
|
|
293
|
+
id: fieldValue(item, 'id', 'knowledge_id', 'canonical_id') ?? null,
|
|
294
|
+
type: fieldValue(item, 'type', 'kind', 'entity_type') ?? null,
|
|
295
|
+
title: fieldValue(item, 'title', 'name', 'subject') ?? null,
|
|
296
|
+
summary: fieldValue(item, 'summary', 'statement', 'body') ?? null,
|
|
297
|
+
content: fieldValue(item, 'content', 'canonical_content') ?? null,
|
|
298
|
+
scope: fieldValue(item, 'scope', 'visibility') ?? null,
|
|
299
|
+
owner: fieldValue(item, 'owner', 'responsible', 'owner_id', 'owner_person_id') ?? null,
|
|
300
|
+
source,
|
|
301
|
+
applicability,
|
|
302
|
+
lifecycle: normaliseLifecycle(item),
|
|
303
|
+
version: (typeof version === 'string' && version.trim()) || Number.isFinite(version) ? version : null,
|
|
304
|
+
updated_at: fieldValue(item, 'updated_at', 'updatedAt', 'modified_at') ?? null,
|
|
305
|
+
relations,
|
|
306
|
+
usage: fieldValue(item, 'usage', 'codex_usage', 'answer_evidence') ?? null,
|
|
307
|
+
raw: item,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function normalizeKnowledgeCollection(payload) {
|
|
312
|
+
const body = payload && typeof payload === 'object' ? (payload.data && typeof payload.data === 'object' ? payload.data : payload) : {};
|
|
313
|
+
const recordsValue = fieldValue(body, 'records', 'items', 'results');
|
|
314
|
+
const explicitState = normaliseStatus(fieldValue(body, 'state', 'status'));
|
|
315
|
+
const absenceConfirmed = body.absence_confirmed === true;
|
|
316
|
+
const records = Array.isArray(recordsValue) ? recordsValue.map(normalizeKnowledgeItem).filter(Boolean) : null;
|
|
317
|
+
let state = explicitState;
|
|
318
|
+
if (records === null) state = explicitState === EMPTY ? EMPTY : explicitState === 'permission_denied' ? 'permission_denied' : 'unknown';
|
|
319
|
+
else if (records.length > 0) state = READY;
|
|
320
|
+
else state = explicitState === EMPTY || absenceConfirmed ? EMPTY : 'unknown';
|
|
321
|
+
return {
|
|
322
|
+
state,
|
|
323
|
+
records,
|
|
324
|
+
absence_confirmed: absenceConfirmed,
|
|
325
|
+
searched_scope: Array.isArray(body.searched_scope) ? body.searched_scope : null,
|
|
326
|
+
query: fieldValue(body, 'query', 'q') ?? null,
|
|
327
|
+
warnings: Array.isArray(body.warnings) ? body.warnings : null,
|
|
328
|
+
raw: payload,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function errorFromPayload(payload, status = null) {
|
|
333
|
+
const source = payload && typeof payload === 'object' ? payload.error ?? payload : {};
|
|
334
|
+
const code = optionalText(fieldValue(source, 'code', 'error_code', 'type')) || (status === 403 ? 'permission_denied' : 'unknown_upstream_error');
|
|
335
|
+
const error = new Error(code);
|
|
336
|
+
error.code = code;
|
|
337
|
+
error.status = status ?? fieldValue(source, 'status', 'http_status');
|
|
338
|
+
error.detail = fieldValue(source, 'message', 'detail', 'reason');
|
|
339
|
+
return error;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function unwrapResponse(response) {
|
|
343
|
+
if (response && typeof response.json === 'function') {
|
|
344
|
+
const payload = await response.json();
|
|
345
|
+
if (response.ok === false) throw errorFromPayload(payload, response.status);
|
|
346
|
+
return payload;
|
|
347
|
+
}
|
|
348
|
+
if (response && typeof response === 'object' && (response.ok === false || response.error)) throw errorFromPayload(response, response.status);
|
|
349
|
+
return response;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function callKnowledgeApi(api, path, init = {}) {
|
|
353
|
+
if (typeof api !== 'function') throw errorFromPayload({ code: 'api_unavailable' });
|
|
354
|
+
try {
|
|
355
|
+
return await unwrapResponse(await api(path, init));
|
|
356
|
+
} catch (caught) {
|
|
357
|
+
if (caught?.code) throw caught;
|
|
358
|
+
const error = new Error(caught instanceof Error ? caught.message : String(caught));
|
|
359
|
+
error.code = 'unknown_upstream_error';
|
|
360
|
+
error.cause = caught;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function projectCodeOf(project) {
|
|
366
|
+
const code = typeof project === 'string' ? project : fieldValue(project, 'code', 'project_code', 'projectCode');
|
|
367
|
+
if (!optionalText(code).trim()) throw errorFromPayload({ code: 'project_context_missing' });
|
|
368
|
+
return String(code).trim();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const DESTINATION_REGISTRATION_FIELDS = Object.freeze([
|
|
372
|
+
'repository_owner',
|
|
373
|
+
'repository_name',
|
|
374
|
+
'branch',
|
|
375
|
+
'path_scope',
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
export function normalizeKnowledgeDestinationRegistration(payload, projectCode) {
|
|
379
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || payload.status !== 'registered') return null;
|
|
380
|
+
const registration = payload.registration;
|
|
381
|
+
if (!registration || typeof registration !== 'object' || Array.isArray(registration)) return null;
|
|
382
|
+
if (registration.project_code !== projectCode) return null;
|
|
383
|
+
if (typeof registration.tenant_id !== 'string' || !registration.tenant_id.trim()) return null;
|
|
384
|
+
if (registration.organization_id !== registration.tenant_id) return null;
|
|
385
|
+
if (registration.source_class !== 'owning_repo' || registration.content_type !== 'team_document') return null;
|
|
386
|
+
if (registration.registration_status !== 'active') return null;
|
|
387
|
+
if (!Number.isInteger(registration.revision) || registration.revision < 1) return null;
|
|
388
|
+
if (DESTINATION_REGISTRATION_FIELDS.some((field) => typeof registration[field] !== 'string' || !registration[field].trim())) return null;
|
|
389
|
+
return registration;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function knowledgePath(project, suffix = '') {
|
|
393
|
+
const base = `/api/projects/${encodeURIComponent(projectCodeOf(project))}/knowledge`;
|
|
394
|
+
return suffix ? `${base}/${suffix.replace(/^\/+/, '')}` : base;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function buildKnowledgeItemsPath(project, filters = {}) {
|
|
398
|
+
const query = new URLSearchParams();
|
|
399
|
+
const q = optionalText(filters.q ?? filters.query).trim();
|
|
400
|
+
const scope = optionalText(filters.scope).trim();
|
|
401
|
+
const status = optionalText(filters.status).trim();
|
|
402
|
+
if (q) query.set('q', q);
|
|
403
|
+
if (scope) query.set('scope', scope);
|
|
404
|
+
if (status) query.set('status', status);
|
|
405
|
+
if (filters.limit !== undefined && filters.limit !== null) query.set('limit', String(filters.limit));
|
|
406
|
+
const encoded = query.toString();
|
|
407
|
+
return `${knowledgePath(project, KNOWLEDGE_ROUTES.items)}${encoded ? `?${encoded}` : ''}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function operationKey(name, id, version, body = {}) {
|
|
411
|
+
const input = `${name}:${id ?? 'new'}:${version ?? 'unknown'}:${JSON.stringify(body)}`;
|
|
412
|
+
let hash = 2166136261;
|
|
413
|
+
for (let index = 0; index < input.length; index += 1) hash = Math.imul(hash ^ input.charCodeAt(index), 16777619);
|
|
414
|
+
return `knowledge-${name}-${Math.abs(hash >>> 0).toString(16)}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function apiErrorText(error) {
|
|
418
|
+
const code = optionalText(error?.code || error?.message) || 'unknown_upstream_error';
|
|
419
|
+
const messages = {
|
|
420
|
+
authentication_required: 'ログインが必要です。セッションを確認してください。',
|
|
421
|
+
project_not_allowed: 'このプロジェクトを確認する権限がありません。',
|
|
422
|
+
knowledge_project_not_accessible: 'このプロジェクトの知識を確認する権限がありません。',
|
|
423
|
+
permission_denied: '知識の変更権限がありません。現在の役割では実行できません。',
|
|
424
|
+
forbidden: '知識の変更権限がありません。現在の役割では実行できません。',
|
|
425
|
+
version_conflict: '最新の版と競合しました。入力内容を残したまま、最新情報を再取得してください。',
|
|
426
|
+
readback_mismatch: '保存後の正本確認が一致しませんでした。未完了として再確認してください。',
|
|
427
|
+
upstream_unavailable: '知識APIに接続できません。入力を保持して再試行してください。',
|
|
428
|
+
unknown_upstream_error: '知識APIの結果を確認できません。入力を保持して再試行してください。',
|
|
429
|
+
api_unavailable: '知識APIが未接続です。接続を確認してください。',
|
|
430
|
+
project_context_missing: 'プロジェクトが選択されていません。',
|
|
431
|
+
};
|
|
432
|
+
return messages[code] ?? `知識APIを確認できません(${code})。入力を保持して再試行してください。`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function isVersionConflict(error) {
|
|
436
|
+
return ['version_conflict', 'knowledge_draft_revision_conflict', 'knowledge_lifecycle_version_conflict', 'knowledge_save_idempotency_conflict', 'knowledge_draft_not_editable'].includes(error?.code);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function statusNotice(model, options = {}) {
|
|
440
|
+
const state = normaliseStatus(model?.state ?? model?.status);
|
|
441
|
+
const noticeClass = ['permission_denied', 'error', 'error_retryable', 'conflict'].includes(state) ? 'knowledge-notice error' : state === 'unknown' ? 'knowledge-notice warning' : 'knowledge-notice';
|
|
442
|
+
const notice = makeElement('div', { className: noticeClass, attrs: { role: ['permission_denied', 'error', 'error_retryable', 'conflict'].includes(state) ? 'alert' : 'status' } });
|
|
443
|
+
const title = stateLabel(state);
|
|
444
|
+
const detail = model?.message || model?.error || model?.reason;
|
|
445
|
+
append(notice, makeElement('strong', { text: title }), makeElement('span', { text: detail || options.detail || '' }));
|
|
446
|
+
if (options.retry && typeof options.onRetry === 'function') {
|
|
447
|
+
const retry = makeElement('button', { className: 'knowledge-button secondary', text: '再試行', attrs: { type: 'button' } });
|
|
448
|
+
retry.addEventListener('click', () => options.onRetry());
|
|
449
|
+
notice.append(retry);
|
|
450
|
+
}
|
|
451
|
+
return notice;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function skeletonRow() {
|
|
455
|
+
const row = makeElement('div', { className: 'knowledge-skeleton-row', attrs: { 'aria-hidden': 'true' } });
|
|
456
|
+
append(row, makeElement('span', { className: 'knowledge-skeleton-line short' }), makeElement('span', { className: 'knowledge-skeleton-line' }), makeElement('span', { className: 'knowledge-skeleton-line tiny' }));
|
|
457
|
+
return row;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function renderDefinitionList(parent, pairs) {
|
|
461
|
+
const definition = makeElement('dl', { className: 'knowledge-definition' });
|
|
462
|
+
for (const [term, value] of pairs) append(definition, makeElement('dt', { text: term }), makeElement('dd', { text: value }));
|
|
463
|
+
parent.append(definition);
|
|
464
|
+
return definition;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function renderSource(parent, source) {
|
|
468
|
+
const section = makeElement('section', { className: 'knowledge-subsection' });
|
|
469
|
+
append(section, makeElement('h4', { text: '出典' }));
|
|
470
|
+
const pointer = safeHref(source?.pointer);
|
|
471
|
+
const pointerNode = pointer
|
|
472
|
+
? makeElement('a', { className: 'knowledge-source-link', text: source.pointer, attrs: { href: pointer, target: '_blank', rel: 'noopener noreferrer' } })
|
|
473
|
+
: makeElement('span', { className: 'knowledge-source-link', text: source?.pointer || UNKNOWN });
|
|
474
|
+
append(section, renderDefinitionList(makeElement('div'), [
|
|
475
|
+
['種別', text(source?.kind)],
|
|
476
|
+
['本文', source?.state === 'fetched' ? '取得済み' : source?.state === 'pointer_only' ? '参照先のみ(本文未取得)' : '本文取得状態未確認'],
|
|
477
|
+
]));
|
|
478
|
+
const pointerRow = makeElement('p', { className: 'knowledge-source-pointer' });
|
|
479
|
+
append(pointerRow, makeElement('strong', { text: '参照先: ' }), pointerNode);
|
|
480
|
+
section.append(pointerRow);
|
|
481
|
+
if (source?.state === 'fetched' && source.excerpt) append(section, makeElement('p', { className: 'knowledge-long-text', text: source.excerpt }));
|
|
482
|
+
if (Array.isArray(source?.refs) && source.refs.length) {
|
|
483
|
+
const refs = makeElement('ul', { className: 'knowledge-reference-list' });
|
|
484
|
+
for (const ref of source.refs) append(refs, makeElement('li', { text: typeof ref === 'string' ? ref : text(ref?.id ?? ref?.pointer ?? ref?.url) }));
|
|
485
|
+
section.append(refs);
|
|
486
|
+
}
|
|
487
|
+
parent.append(section);
|
|
488
|
+
return section;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function renderDiscoveryFilters(root, filters = {}, callbacks = {}) {
|
|
492
|
+
clear(root);
|
|
493
|
+
const form = makeElement('form', { className: 'knowledge-filters', attrs: { 'aria-label': '知識を探す条件' } });
|
|
494
|
+
const queryLabel = makeElement('label', { text: 'キーワード' });
|
|
495
|
+
const query = makeElement('input', { className: 'knowledge-control', value: filters.q ?? filters.query ?? '', attrs: { type: 'search', name: 'q', maxlength: '200', placeholder: 'タイトル・要旨を検索' } });
|
|
496
|
+
queryLabel.append(query);
|
|
497
|
+
const scopeLabel = makeElement('label', { text: '範囲' });
|
|
498
|
+
const scope = makeElement('select', { className: 'knowledge-control', attrs: { name: 'scope' } });
|
|
499
|
+
for (const [value, label] of [['project', 'このプロジェクト'], ['organization', '組織から継承'], ['all', '両方']]) append(scope, makeElement('option', { text: label, value, attrs: { value, ...(filters.scope === value ? { selected: 'selected' } : {}) } }));
|
|
500
|
+
scope.value = filters.scope || 'all';
|
|
501
|
+
scopeLabel.append(scope);
|
|
502
|
+
const statusLabel = makeElement('label', { text: '状態' });
|
|
503
|
+
const status = makeElement('select', { className: 'knowledge-control', attrs: { name: 'status' } });
|
|
504
|
+
for (const [value, label] of [['active', '有効'], ['inactive', '失効を含む'], ['all', 'すべて']]) append(status, makeElement('option', { text: label, value, attrs: { value, ...(filters.status === value ? { selected: 'selected' } : {}) } }));
|
|
505
|
+
status.value = filters.status || 'active';
|
|
506
|
+
statusLabel.append(status);
|
|
507
|
+
const submit = makeElement('button', { className: 'knowledge-button primary', text: '探す', attrs: { type: 'submit' } });
|
|
508
|
+
const reset = makeElement('button', { className: 'knowledge-button secondary', text: '条件をクリア', attrs: { type: 'button' } });
|
|
509
|
+
append(form, queryLabel, scopeLabel, statusLabel, makeElement('div', { className: 'knowledge-filter-actions' }));
|
|
510
|
+
form.lastChild.append(submit, reset);
|
|
511
|
+
form.addEventListener('submit', (event) => {
|
|
512
|
+
event.preventDefault();
|
|
513
|
+
callbacks.onChange?.({ q: query.value.trim(), scope: scope.value, status: status.value });
|
|
514
|
+
});
|
|
515
|
+
reset.addEventListener('click', () => {
|
|
516
|
+
query.value = '';
|
|
517
|
+
scope.value = 'all';
|
|
518
|
+
status.value = 'active';
|
|
519
|
+
callbacks.onChange?.({ q: '', scope: 'all', status: 'active' });
|
|
520
|
+
});
|
|
521
|
+
root.append(form);
|
|
522
|
+
return form;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function renderKnowledgeList(root, model = {}, callbacks = {}) {
|
|
526
|
+
clear(root);
|
|
527
|
+
const collection = model.records === undefined && (model.items || model.results || model.state) ? normalizeKnowledgeCollection(model) : model;
|
|
528
|
+
const section = makeElement('section', { className: 'knowledge-list-panel', attrs: { 'aria-labelledby': 'knowledge-list-title' } });
|
|
529
|
+
const header = makeElement('div', { className: 'knowledge-panel-head' });
|
|
530
|
+
append(header, makeElement('div', {},), makeElement('span', { className: 'knowledge-count', text: Array.isArray(collection.records) ? `${collection.records.length}件` : UNKNOWN }));
|
|
531
|
+
header.firstChild.append(makeElement('h3', { text: '知識・判断', attrs: { id: 'knowledge-list-title' } }), makeElement('p', { text: collection.searched_scope ? `検索範囲: ${collection.searched_scope.join(' / ')}` : '検索範囲: 未確認' }));
|
|
532
|
+
section.append(header);
|
|
533
|
+
const state = normaliseStatus(collection.state);
|
|
534
|
+
if (state === 'loading') {
|
|
535
|
+
const skeleton = makeElement('div', { className: 'knowledge-list-rows', attrs: { 'aria-label': '知識を読み込み中' } });
|
|
536
|
+
for (let index = 0; index < 4; index += 1) skeleton.append(skeletonRow());
|
|
537
|
+
section.append(skeleton);
|
|
538
|
+
} else if (state === EMPTY) {
|
|
539
|
+
section.append(makeElement('div', { className: 'knowledge-empty', text: 'この条件に一致する知識はありません。自然文から登録案を作成できます。' }));
|
|
540
|
+
} else if (state === 'permission_denied') {
|
|
541
|
+
section.append(statusNotice({ state, detail: '必要なプロジェクト権限を確認してください。' }, { retry: true, onRetry: callbacks.onRetry }));
|
|
542
|
+
} else if (['error', 'error_retryable'].includes(state)) {
|
|
543
|
+
section.append(statusNotice(collection, { retry: true, onRetry: callbacks.onRetry }));
|
|
544
|
+
} else if (state === 'unknown' || !Array.isArray(collection.records)) {
|
|
545
|
+
section.append(statusNotice({ state: 'unknown', detail: '結果の有無を確認できません。空の一覧とは扱いません。' }, { retry: true, onRetry: callbacks.onRetry }));
|
|
546
|
+
} else {
|
|
547
|
+
const rows = makeElement('div', { className: 'knowledge-list-rows' });
|
|
548
|
+
if (!collection.records.length) rows.append(makeElement('div', { className: 'knowledge-empty', text: '検索結果を確認できません。空の一覧とは扱いません。' }));
|
|
549
|
+
for (const item of collection.records) {
|
|
550
|
+
const row = makeElement('button', { className: 'knowledge-list-row', attrs: { type: 'button', 'aria-pressed': String(item.id === callbacks.selectedId) } });
|
|
551
|
+
const title = item.title ? text(item.title) : 'タイトル未確認';
|
|
552
|
+
append(row,
|
|
553
|
+
makeElement('span', { className: 'knowledge-row-title', text: title }),
|
|
554
|
+
makeElement('span', { className: 'knowledge-row-type', text: item.type ? typeLabel(item.type) : UNKNOWN }),
|
|
555
|
+
makeElement('span', { className: 'knowledge-row-scope', text: item.scope ? text(item.scope) : UNKNOWN }),
|
|
556
|
+
makeElement('span', { className: 'knowledge-row-state', text: item.lifecycle.status !== UNKNOWN ? text(item.lifecycle.status) : UNKNOWN }),
|
|
557
|
+
);
|
|
558
|
+
row.addEventListener('click', () => callbacks.onSelect?.(item));
|
|
559
|
+
rows.append(row);
|
|
560
|
+
}
|
|
561
|
+
section.append(rows);
|
|
562
|
+
}
|
|
563
|
+
root.append(section);
|
|
564
|
+
return section;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function renderKnowledgeDetail(root, item, callbacks = {}) {
|
|
568
|
+
clear(root);
|
|
569
|
+
const section = makeElement('section', { className: 'knowledge-detail-panel', attrs: { 'aria-labelledby': 'knowledge-detail-title' } });
|
|
570
|
+
if (!item) {
|
|
571
|
+
append(section, makeElement('h3', { text: '詳細', attrs: { id: 'knowledge-detail-title' } }), makeElement('p', { className: 'knowledge-detail-empty', text: '一覧から知識を選ぶと、由来・適用範囲・履歴を確認できます。' }));
|
|
572
|
+
root.append(section);
|
|
573
|
+
return section;
|
|
574
|
+
}
|
|
575
|
+
append(section,
|
|
576
|
+
makeElement('span', { className: 'knowledge-eyebrow', text: item.type ? typeLabel(item.type) : UNKNOWN }),
|
|
577
|
+
makeElement('h3', { text: item.title ? text(item.title) : 'タイトル未確認', attrs: { id: 'knowledge-detail-title' } }),
|
|
578
|
+
);
|
|
579
|
+
const summary = makeElement('p', { className: 'knowledge-long-text', text: item.summary ? text(item.summary) : '要旨未確認' });
|
|
580
|
+
section.append(summary);
|
|
581
|
+
renderDefinitionList(section, [
|
|
582
|
+
['正本ID', item.id ? text(item.id) : UNKNOWN],
|
|
583
|
+
['版', item.version !== null && item.version !== undefined && String(item.version).trim() !== '' ? String(item.version) : UNKNOWN],
|
|
584
|
+
['範囲', item.scope ? text(item.scope) : UNKNOWN],
|
|
585
|
+
['責任者', ownerValue(item.owner)],
|
|
586
|
+
['有効状態', item.lifecycle.status],
|
|
587
|
+
['適用', item.applicability.state ? text(item.applicability.state) : UNKNOWN],
|
|
588
|
+
['発効', displayDateTime(item.lifecycle.effective_at)],
|
|
589
|
+
['失効', displayDateTime(item.lifecycle.expires_at)],
|
|
590
|
+
['更新', displayDateTime(item.updated_at)],
|
|
591
|
+
]);
|
|
592
|
+
const applicability = makeElement('section', { className: 'knowledge-subsection' });
|
|
593
|
+
append(applicability, makeElement('h4', { text: '適用条件' }), makeElement('p', { className: 'knowledge-long-text', text: item.applicability.conditions ? text(item.applicability.conditions) : UNKNOWN }));
|
|
594
|
+
section.append(applicability);
|
|
595
|
+
renderSource(section, item.source);
|
|
596
|
+
const relations = makeElement('section', { className: 'knowledge-subsection' });
|
|
597
|
+
append(relations, makeElement('h4', { text: '関連先' }));
|
|
598
|
+
if (Array.isArray(item.relations) && item.relations.length) {
|
|
599
|
+
const list = makeElement('ul', { className: 'knowledge-reference-list' });
|
|
600
|
+
for (const relation of item.relations) append(list, makeElement('li', { text: typeof relation === 'string' ? relation : text(relation?.id ?? relation?.target_id ?? relation?.type) }));
|
|
601
|
+
relations.append(list);
|
|
602
|
+
} else append(relations, makeElement('p', { text: item.relations === null ? '関連先を確認できません。' : '関連先は登録されていません。' }));
|
|
603
|
+
section.append(relations);
|
|
604
|
+
if (item.usage) renderCodexUseEvidence(section, item.usage);
|
|
605
|
+
const actions = makeElement('div', { className: 'knowledge-detail-actions' });
|
|
606
|
+
if (callbacks.onPreview) {
|
|
607
|
+
const preview = makeElement('button', { className: 'knowledge-button secondary', text: 'この版を試験する', attrs: { type: 'button' } });
|
|
608
|
+
preview.addEventListener('click', () => callbacks.onPreview(item));
|
|
609
|
+
actions.append(preview);
|
|
610
|
+
}
|
|
611
|
+
section.append(actions);
|
|
612
|
+
root.append(section);
|
|
613
|
+
return section;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function formField(labelText, control, hint = '') {
|
|
617
|
+
const label = makeElement('label', { className: 'knowledge-field', text: labelText });
|
|
618
|
+
label.append(control);
|
|
619
|
+
if (hint) label.append(makeElement('small', { text: hint }));
|
|
620
|
+
return label;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function renderDraftCapture(root, model = {}, callbacks = {}) {
|
|
624
|
+
clear(root);
|
|
625
|
+
const draft = model.draft ?? model;
|
|
626
|
+
const section = makeElement('section', { className: 'knowledge-capture-panel', attrs: { 'aria-labelledby': 'knowledge-capture-title' } });
|
|
627
|
+
append(section, makeElement('span', { className: 'knowledge-eyebrow', text: '登録案を作る' }), makeElement('h3', { text: '自然文から未確定候補を作成', attrs: { id: 'knowledge-capture-title' } }), makeElement('p', { text: '入力した文章や許可資料から提案を作ります。提案は確認が終わるまで正本へ保存されません。' }));
|
|
628
|
+
const form = makeElement('form', { className: 'knowledge-capture-form', attrs: { 'aria-busy': ['loading', 'saving'].includes(normaliseStatus(draft.state)) ? 'true' : 'false' } });
|
|
629
|
+
const resumeId = makeElement('input', { className: 'knowledge-control', value: draft.resume_id ?? '', attrs: { name: 'resume_id', maxlength: '200', placeholder: '保存済み下書きID' } });
|
|
630
|
+
const resume = makeElement('button', { className: 'knowledge-button secondary', text: '下書きを再開', attrs: { type: 'button' } });
|
|
631
|
+
resume.addEventListener('click', () => callbacks.onResume?.(resumeId.value.trim()));
|
|
632
|
+
const resumeRow = makeElement('div', { className: 'knowledge-form-actions' });
|
|
633
|
+
resumeRow.append(resume);
|
|
634
|
+
const sourceText = optionalText(draft.input_text ?? draft.text ?? model.input_text);
|
|
635
|
+
const input = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: sourceText, attrs: { name: 'input_text', rows: '8', maxlength: '20000', required: 'required', placeholder: '例: 顧客への初回提案では、まず利用目的と判断期限を確認する。' } });
|
|
636
|
+
const sourcePointerInput = makeElement('input', { className: 'knowledge-control', value: draft.source?.pointer ?? draft.source_pointer ?? '', attrs: { name: 'source_pointer', maxlength: '2048', placeholder: '許可された資料のURLまたはrepo相対参照(任意)' } });
|
|
637
|
+
const sourceKindInput = makeElement('input', { className: 'knowledge-control', value: draft.source?.kind ?? draft.source_kind ?? '', attrs: { name: 'source_kind', maxlength: '120', placeholder: '資料種別(任意)' } });
|
|
638
|
+
append(form,
|
|
639
|
+
formField('保存済み下書きを再開', resumeId, '一覧APIはないため、保存時に表示された下書きIDを入力してください。'),
|
|
640
|
+
resumeRow,
|
|
641
|
+
formField('自然文', input, '資料中の命令は権限や保存操作として実行しません。'),
|
|
642
|
+
formField('許可資料の参照先', sourcePointerInput, '本文取得済みとは限らないため、参照先と本文状態は別に表示します。'),
|
|
643
|
+
formField('資料種別', sourceKindInput),
|
|
644
|
+
);
|
|
645
|
+
const savedId = draft.draft_id ?? draft.id;
|
|
646
|
+
if (savedId) form.append(makeElement('p', { className: 'knowledge-inline-status', text: `保存済み下書きID: ${savedId}${draft.revision ?? draft.version ? ` / 版: ${draft.revision ?? draft.version}` : ''}` }));
|
|
647
|
+
if (savedId && draft.resume_url) form.append(makeElement('a', { className: 'knowledge-button secondary', text: 'この下書きの再開リンク', attrs: { href: draft.resume_url } }));
|
|
648
|
+
const actions = makeElement('div', { className: 'knowledge-form-actions' });
|
|
649
|
+
const submit = makeElement('button', { className: 'knowledge-button primary', text: normaliseStatus(draft.state) === 'saving' ? 'AIで整理中…' : 'AIで整理', attrs: { type: 'submit' }, disabled: normaliseStatus(draft.state) === 'saving' });
|
|
650
|
+
const discard = makeElement('button', { className: 'knowledge-button secondary', text: '入力を破棄', attrs: { type: 'button' } });
|
|
651
|
+
discard.addEventListener('click', () => callbacks.onDiscard?.());
|
|
652
|
+
actions.append(submit, discard);
|
|
653
|
+
form.append(actions);
|
|
654
|
+
if (['error', 'error_retryable', 'permission_denied', 'conflict', 'unknown'].includes(normaliseStatus(draft.state))) form.append(statusNotice({ state: draft.state, detail: draft.message || draft.error }, { retry: Boolean(callbacks.onRetry), onRetry: callbacks.onRetry }));
|
|
655
|
+
if (draft.state === 'saving') form.append(makeElement('p', { className: 'knowledge-inline-status', text: '候補生成の応答を待っています。入力は保持されます。', attrs: { role: 'status' } }));
|
|
656
|
+
form.addEventListener('submit', (event) => {
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
if (typeof form.reportValidity === 'function' && !form.reportValidity()) return;
|
|
659
|
+
callbacks.onSubmit?.({
|
|
660
|
+
input_text: input.value,
|
|
661
|
+
source: { pointer: sourcePointerInput.value.trim(), kind: sourceKindInput.value.trim() },
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
section.append(form);
|
|
665
|
+
root.append(section);
|
|
666
|
+
return form;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function candidateShape(model) {
|
|
670
|
+
const candidate = model?.candidate ?? model?.proposal ?? model?.draft?.candidate ?? model ?? {};
|
|
671
|
+
return candidate && typeof candidate === 'object' ? candidate : {};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function renderCandidateReview(root, model = {}, callbacks = {}) {
|
|
675
|
+
clear(root);
|
|
676
|
+
const candidate = candidateShape(model);
|
|
677
|
+
const section = makeElement('section', { className: 'knowledge-review-panel', attrs: { 'aria-labelledby': 'knowledge-review-title' } });
|
|
678
|
+
append(section, makeElement('span', { className: 'knowledge-eyebrow', text: '確認が必要です' }), makeElement('h3', { text: '未確定候補を確認', attrs: { id: 'knowledge-review-title' } }), makeElement('p', { text: '候補はAIの提案です。根拠のない責任者・事実を確定せず、原文と提案の差分を確認してください。' }));
|
|
679
|
+
if (model.canonical === false || model.persisted === false || model.readback?.state === 'proposal_only') {
|
|
680
|
+
section.append(makeElement('p', { className: 'knowledge-isolation-badge unknown', text: '未承認・未保存の提案' }));
|
|
681
|
+
}
|
|
682
|
+
const comparison = makeElement('div', { className: 'knowledge-comparison' });
|
|
683
|
+
const original = makeElement('article', { className: 'knowledge-source-column' });
|
|
684
|
+
append(original, makeElement('h4', { text: '入力原文' }), makeElement('p', { className: 'knowledge-long-text', text: text(model.input_text ?? model.source_text ?? model.draft?.input_text) }));
|
|
685
|
+
const source = normaliseSource(model.source ?? candidate.source);
|
|
686
|
+
renderSource(original, source);
|
|
687
|
+
const proposal = makeElement('article', { className: 'knowledge-proposal-column' });
|
|
688
|
+
append(proposal, makeElement('h4', { text: '提案(未確定)' }));
|
|
689
|
+
const form = makeElement('form', { className: 'knowledge-review-form' });
|
|
690
|
+
const proposalApplied = model.proposal_applied === true;
|
|
691
|
+
const type = makeElement('select', { className: 'knowledge-control', attrs: { name: 'type', required: 'required' } });
|
|
692
|
+
for (const [value, label] of [['fact', '事実'], ['decision', '判断'], ['principle', '原則'], ['procedure', '手順'], ['term', '用語'], ['policy', '方針']]) append(type, makeElement('option', { text: label, attrs: { value, ...(candidate.type === value ? { selected: 'selected' } : {}) } }));
|
|
693
|
+
type.value = candidate.type ?? candidate.kind ?? '';
|
|
694
|
+
const title = makeElement('input', { className: 'knowledge-control', value: candidate.title ?? '', attrs: { name: 'title', maxlength: '240', required: 'required' } });
|
|
695
|
+
const summary = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: candidate.summary ?? candidate.statement ?? '', attrs: { name: 'summary', rows: '5', maxlength: '10000', required: 'required' } });
|
|
696
|
+
const scope = makeElement('select', { className: 'knowledge-control', attrs: { name: 'scope', required: 'required' } });
|
|
697
|
+
for (const [value, label] of [['project', 'このプロジェクト'], ['organization', '組織共通']]) append(scope, makeElement('option', { text: label, attrs: { value, ...(candidate.scope === value ? { selected: 'selected' } : {}) } }));
|
|
698
|
+
scope.value = candidate.scope ?? '';
|
|
699
|
+
const decisionDomain = makeElement('select', { className: 'knowledge-control', attrs: { name: 'decision_domain', required: 'required' } });
|
|
700
|
+
decisionDomain.append(makeElement('option', { text: '判断権限を選択', attrs: { value: '' } }));
|
|
701
|
+
for (const domain of model.decision_domains ?? []) {
|
|
702
|
+
const value = typeof domain === 'string' ? domain : domain?.id ?? domain?.domain;
|
|
703
|
+
if (value) decisionDomain.append(makeElement('option', { text: typeof domain === 'string' ? domain : domain?.label ?? value, attrs: { value } }));
|
|
704
|
+
}
|
|
705
|
+
decisionDomain.value = candidate.decision_domain ?? '';
|
|
706
|
+
const ownerCandidate = candidate.owner_candidate ?? null;
|
|
707
|
+
const owner = makeElement('input', { className: 'knowledge-control', value: candidate.owner_id ?? (typeof candidate.owner === 'string' ? candidate.owner : candidate.owner?.id) ?? '', attrs: { name: 'owner_id', maxlength: '240', placeholder: '確認済みの責任者ID(未確認なら空欄)' } });
|
|
708
|
+
const applicability = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: candidate.applicability?.conditions ?? candidate.conditions ?? '', attrs: { name: 'applicability', rows: '4', maxlength: '6000', placeholder: 'どの場面で適用するか' } });
|
|
709
|
+
const existingRelations = Array.isArray(candidate.relations) ? candidate.relations : [];
|
|
710
|
+
const relationType = makeElement('select', { className: 'knowledge-control', attrs: { name: 'relation_type' } });
|
|
711
|
+
relationType.append(makeElement('option', { text: '関係を選択', attrs: { value: '' } }));
|
|
712
|
+
for (const [value, label] of KNOWLEDGE_RELATION_TYPES) relationType.append(makeElement('option', { text: label, attrs: { value } }));
|
|
713
|
+
relationType.value = existingRelations.find((relation) => relation && typeof relation === 'object')?.relation ?? '';
|
|
714
|
+
const relations = makeElement('input', { className: 'knowledge-control', value: existingRelations.map((relation) => typeof relation === 'object' ? relation.to_id ?? '' : '').filter(Boolean).join(', '), attrs: { name: 'relations', maxlength: '2000', placeholder: '関連先ID(カンマ区切り、任意)' } });
|
|
715
|
+
append(form,
|
|
716
|
+
formField('種別', type),
|
|
717
|
+
formField('タイトル', title),
|
|
718
|
+
formField('要旨', summary),
|
|
719
|
+
formField('適用範囲', scope),
|
|
720
|
+
formField('判断権限', decisionDomain, (model.decision_domains ?? []).length ? 'Graph RACIで確認できた権限だけを選べます。' : 'Graph RACIの判断権限を確認できません。'),
|
|
721
|
+
formField('責任者', owner, ownerCandidate ? `AI候補: ${ownerCandidate}。確認後に入力してください。自動確定しません。` : '責任者候補は未確認です。推測で補完しません。'),
|
|
722
|
+
formField('適用条件', applicability),
|
|
723
|
+
formField('関係の意味', relationType, 'Ontologyで許可された関係だけを選べます。'),
|
|
724
|
+
formField('関連先', relations),
|
|
725
|
+
);
|
|
726
|
+
const reuseLabel = makeElement('label', { className: 'knowledge-choice' });
|
|
727
|
+
const reuse = makeElement('input', { attrs: { type: 'radio', name: 'selection', value: 'reuse', checked: 'checked' } });
|
|
728
|
+
reuse.checked = model.selection === 'create' ? false : true;
|
|
729
|
+
reuseLabel.append(reuse, makeElement('span', { text: '既存の正本候補を再利用' }));
|
|
730
|
+
const createLabel = makeElement('label', { className: 'knowledge-choice' });
|
|
731
|
+
const create = makeElement('input', { attrs: { type: 'radio', name: 'selection', value: 'create' } });
|
|
732
|
+
create.checked = model.selection === 'create';
|
|
733
|
+
createLabel.append(create, makeElement('span', { text: '別の正本項目として作成' }));
|
|
734
|
+
const canonicalId = makeElement('input', { className: 'knowledge-control', value: candidate.canonical_id ?? model.canonical_id ?? '', attrs: { name: 'canonical_id', maxlength: '240', placeholder: '再利用する正本ID' } });
|
|
735
|
+
const canonicalVersion = makeElement('input', { className: 'knowledge-control', value: candidate.canonical_version ?? candidate.expected_version ?? model.canonical_version ?? '', attrs: { name: 'canonical_version', maxlength: '240', placeholder: '再利用する正本の現在版' } });
|
|
736
|
+
for (const control of [type, title, summary, scope, decisionDomain, owner, applicability, relationType, relations, reuse, create, canonicalId, canonicalVersion]) control.disabled = !proposalApplied;
|
|
737
|
+
append(form, makeElement('fieldset', { className: 'knowledge-choice-group' }));
|
|
738
|
+
form.lastChild.append(makeElement('legend', { text: '保存方法' }), reuseLabel, createLabel);
|
|
739
|
+
form.append(formField('再利用する正本', canonicalId, '既存の正本候補を再利用する場合は正本IDが必要です。'));
|
|
740
|
+
form.append(formField('再利用する正本の版', canonicalVersion, '並行更新を見逃さないよう、確認した現在版を指定します。'));
|
|
741
|
+
const actions = makeElement('div', { className: 'knowledge-form-actions' });
|
|
742
|
+
const apply = makeElement('button', { className: 'knowledge-button secondary', text: proposalApplied ? '入力欄へ反映済み' : '入力欄に反映', attrs: { type: 'button' }, disabled: proposalApplied || !callbacks.onApply });
|
|
743
|
+
apply.addEventListener('click', () => callbacks.onApply?.());
|
|
744
|
+
const save = makeElement('button', { className: 'knowledge-button secondary', text: '下書き保存', attrs: { type: 'submit' }, disabled: !proposalApplied || !callbacks.onSave });
|
|
745
|
+
const commit = makeElement('button', { className: 'knowledge-button primary', text: '確認して正本へ保存', attrs: { type: 'button' }, disabled: !callbacks.onCommit });
|
|
746
|
+
actions.append(apply, save, commit); form.append(actions);
|
|
747
|
+
if (model.state && !['candidate', 'ready'].includes(normaliseStatus(model.state))) form.append(statusNotice(model, { retry: Boolean(callbacks.onRetry), onRetry: callbacks.onRetry }));
|
|
748
|
+
const collect = () => ({
|
|
749
|
+
type: type.value,
|
|
750
|
+
title: title.value,
|
|
751
|
+
summary: summary.value,
|
|
752
|
+
scope: scope.value,
|
|
753
|
+
decision_domain: decisionDomain.value,
|
|
754
|
+
owner_id: owner.value.trim() || null,
|
|
755
|
+
applicability: { conditions: applicability.value },
|
|
756
|
+
relations: relations.value.split(',').map((value) => value.trim()).filter(Boolean).map((to_id) => ({ relation: relationType.value, to_id })),
|
|
757
|
+
selection: create.checked ? 'create' : 'reuse',
|
|
758
|
+
canonical_id: canonicalId.value.trim() || null,
|
|
759
|
+
canonical_version: canonicalVersion.value.trim() || null,
|
|
760
|
+
});
|
|
761
|
+
const collectValid = () => {
|
|
762
|
+
if (relations.value.trim() && !relationType.value) {
|
|
763
|
+
relationType.setCustomValidity?.('関係の意味を選択してください。');
|
|
764
|
+
form.reportValidity?.();
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
relationType.setCustomValidity?.('');
|
|
768
|
+
return collect();
|
|
769
|
+
};
|
|
770
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); if (typeof form.reportValidity === 'function' && !form.reportValidity()) return; const values = collectValid(); if (values) callbacks.onSave?.(values); });
|
|
771
|
+
commit.addEventListener('click', () => { const values = collectValid(); if (values) callbacks.onCommit?.(values); });
|
|
772
|
+
proposal.append(form); comparison.append(original, proposal); section.append(comparison);
|
|
773
|
+
const evidence = asArray(model.evidence);
|
|
774
|
+
const exclusions = asArray(model.exclusions);
|
|
775
|
+
if (evidence || exclusions) {
|
|
776
|
+
const evidenceSection = makeElement('section', { className: 'knowledge-subsection' });
|
|
777
|
+
append(evidenceSection, makeElement('h4', { text: '提案の根拠' }));
|
|
778
|
+
if (evidence?.length) {
|
|
779
|
+
const list = makeElement('ul', { className: 'knowledge-reference-list' });
|
|
780
|
+
for (const entry of evidence) list.append(makeElement('li', { text: `${text(entry?.id ?? entry?.source_ref)} / 版 ${entry?.version ?? UNKNOWN}${entry?.source_ref ? ` / ${text(entry.source_ref)}` : ''}` }));
|
|
781
|
+
evidenceSection.append(list);
|
|
782
|
+
} else evidenceSection.append(makeElement('p', { className: 'knowledge-unknown-copy', text: '採用された根拠を確認できません。' }));
|
|
783
|
+
if (exclusions?.length) {
|
|
784
|
+
const list = makeElement('ul', { className: 'knowledge-reference-list' });
|
|
785
|
+
for (const entry of exclusions) list.append(makeElement('li', { text: `除外: ${text(entry?.id)} / ${text(entry?.reason)}` }));
|
|
786
|
+
evidenceSection.append(list);
|
|
787
|
+
}
|
|
788
|
+
section.append(evidenceSection);
|
|
789
|
+
}
|
|
790
|
+
root.append(section);
|
|
791
|
+
return { form, collect };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function renderCommitState(root, model = {}, callbacks = {}) {
|
|
795
|
+
clear(root);
|
|
796
|
+
const state = normaliseStatus(model.state ?? model.status);
|
|
797
|
+
const section = makeElement('section', { className: `knowledge-commit-state ${state}`, attrs: { 'aria-labelledby': 'knowledge-commit-title' } });
|
|
798
|
+
append(section, makeElement('h3', { text: '正本への保存', attrs: { id: 'knowledge-commit-title' } }), makeElement('p', { text: stateLabel(state) }));
|
|
799
|
+
if (state === 'saving') section.append(makeElement('p', { className: 'knowledge-inline-status', text: '保存応答を受け取り、正本と関係を再取得しています。', attrs: { role: 'status' } }));
|
|
800
|
+
else if (state === 'saved_unverified') {
|
|
801
|
+
section.append(makeElement('p', { className: 'knowledge-warning-copy', text: '保存応答は受け取りましたが、正本のreadbackが一致していません。検索反映済みとは扱いません。' }));
|
|
802
|
+
if (callbacks.onReadback) {
|
|
803
|
+
const retry = makeElement('button', { className: 'knowledge-button secondary', text: '正本を再取得', attrs: { type: 'button' } });
|
|
804
|
+
retry.addEventListener('click', () => callbacks.onReadback()); section.append(retry);
|
|
805
|
+
}
|
|
806
|
+
} else if (state === 'verified') section.append(makeElement('p', { className: 'knowledge-success-copy', text: '正本と関係のreadbackが一致しました。検索反映の状態は別に確認します。', attrs: { role: 'status' } }));
|
|
807
|
+
else if (state === 'conflict') section.append(statusNotice({ state, detail: '最新の版を取得して差分を確認してください。' }, { retry: Boolean(callbacks.onReadback), onRetry: callbacks.onReadback }));
|
|
808
|
+
else if (['error', 'error_retryable', 'permission_denied', 'unknown'].includes(state)) section.append(statusNotice(model, { retry: Boolean(callbacks.onReadback), onRetry: callbacks.onReadback }));
|
|
809
|
+
renderDefinitionList(section, [
|
|
810
|
+
['正本ID', model.item_id ?? model.item?.id ?? UNKNOWN],
|
|
811
|
+
['版', model.version !== null && model.version !== undefined && String(model.version).trim() !== ''
|
|
812
|
+
? String(model.version)
|
|
813
|
+
: model.item?.version !== null && model.item?.version !== undefined && String(model.item.version).trim() !== '' ? String(model.item.version) : UNKNOWN],
|
|
814
|
+
['検索反映', model.search_index_state ? stateLabel(model.search_index_state) : UNKNOWN],
|
|
815
|
+
]);
|
|
816
|
+
root.append(section);
|
|
817
|
+
return section;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function renderLifecycle(root, item, callbacks = {}) {
|
|
821
|
+
clear(root);
|
|
822
|
+
const section = makeElement('section', { className: 'knowledge-lifecycle-panel', attrs: { 'aria-labelledby': 'knowledge-lifecycle-title' } });
|
|
823
|
+
append(section, makeElement('h3', { text: '版と失効', attrs: { id: 'knowledge-lifecycle-title' } }), makeElement('p', { text: '改訂・置換・失効は履歴を残し、正本のreadback確認後に状態を更新します。' }));
|
|
824
|
+
if (!item) { section.append(makeElement('p', { text: '対象の判断を選択してください。' })); root.append(section); return section; }
|
|
825
|
+
renderDefinitionList(section, [['現在の版', item.version !== null && item.version !== undefined ? String(item.version) : UNKNOWN], ['状態', item.lifecycle?.status ?? UNKNOWN], ['正本ID', item.id ? text(item.id) : UNKNOWN]]);
|
|
826
|
+
const canEdit = callbacks.canEdit !== false && (typeof callbacks.canEdit !== 'function' || callbacks.canEdit(item));
|
|
827
|
+
const actions = makeElement('div', { className: 'knowledge-lifecycle-actions' });
|
|
828
|
+
if (canEdit && callbacks.onRevision) {
|
|
829
|
+
const revisionDraft = callbacks.revisionDraft && typeof callbacks.revisionDraft === 'object' ? callbacks.revisionDraft : null;
|
|
830
|
+
const draftValue = (key, fallback) => revisionDraft && Object.prototype.hasOwnProperty.call(revisionDraft, key) ? revisionDraft[key] : fallback;
|
|
831
|
+
const revisionDetails = makeElement('details', { className: 'knowledge-action-details' });
|
|
832
|
+
const summary = makeElement('summary', { text: 'この判断を改訂' });
|
|
833
|
+
const form = makeElement('form', { className: 'knowledge-lifecycle-form' });
|
|
834
|
+
const title = makeElement('input', { className: 'knowledge-control', value: draftValue('title', item.title ?? ''), attrs: { name: 'title', maxlength: '240', required: 'required' } });
|
|
835
|
+
const body = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: draftValue('content', item.content ?? ''), attrs: { name: 'content', rows: '8', maxlength: '10000', required: 'required' } });
|
|
836
|
+
const currentScope = draftValue('scope', item.scope ?? '');
|
|
837
|
+
const scope = makeElement('select', { className: 'knowledge-control', attrs: { name: 'scope', required: 'required' } });
|
|
838
|
+
append(scope, makeElement('option', { text: currentScope && !['project', 'organization'].includes(currentScope) ? `選択してください(現在値: ${currentScope})` : '選択してください', attrs: { value: '' } }), ...[['project', 'このプロジェクト'], ['organization', '組織共通']].map(([value, label]) => makeElement('option', { text: label, attrs: { value } })));
|
|
839
|
+
scope.value = ['project', 'organization'].includes(currentScope) ? currentScope : '';
|
|
840
|
+
const ownerPersonId = makeElement('select', { className: 'knowledge-control', attrs: { name: 'owner_person_id', required: 'required' } });
|
|
841
|
+
const currentOwnerId = typeof item.owner === 'object' ? item.owner?.id ?? item.owner?.person_id ?? '' : item.owner ?? '';
|
|
842
|
+
const ownerCandidates = asArray(callbacks.ownerCandidates) ?? [];
|
|
843
|
+
const availableOwners = [...ownerCandidates];
|
|
844
|
+
if (currentOwnerId && !availableOwners.some((candidate) => candidate.id === currentOwnerId)) availableOwners.unshift({ id: currentOwnerId, name: ownerValue(item.owner) === currentOwnerId ? '現在の責任者(氏名未確認)' : ownerValue(item.owner) });
|
|
845
|
+
append(ownerPersonId, makeElement('option', { text: '選択してください', attrs: { value: '' } }), ...availableOwners.map((candidate) => makeElement('option', { text: text(candidate.name, '氏名未確認'), attrs: { value: candidate.id } })));
|
|
846
|
+
ownerPersonId.value = draftValue('owner_person_id', currentOwnerId) || '';
|
|
847
|
+
const effectiveBaseline = draftValue('effective_at', item.lifecycle?.effective_at);
|
|
848
|
+
const expiresBaseline = draftValue('expires_at', item.lifecycle?.expires_at);
|
|
849
|
+
const effectiveAt = makeElement('input', { className: 'knowledge-control', value: toLocalDateTimeValue(effectiveBaseline), attrs: { name: 'effective_at', type: 'datetime-local' } });
|
|
850
|
+
const expiresAt = makeElement('input', { className: 'knowledge-control', value: toLocalDateTimeValue(expiresBaseline), attrs: { name: 'expires_at', type: 'datetime-local' } });
|
|
851
|
+
const reason = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: draftValue('reason', ''), attrs: { name: 'reason', rows: '3', maxlength: '3000', required: 'required', placeholder: '変更理由' } });
|
|
852
|
+
append(form, formField('タイトル', title), formField('本文', body), formField('適用範囲', scope), formField('責任者', ownerPersonId), formField('有効開始', effectiveAt), formField('有効期限', expiresAt), formField('変更理由', reason));
|
|
853
|
+
const submit = makeElement('button', { className: 'knowledge-button primary', text: '改訂を保存', attrs: { type: 'submit' } });
|
|
854
|
+
form.append(submit);
|
|
855
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); if (typeof form.reportValidity === 'function' && !form.reportValidity()) return; callbacks.onRevision?.({ title: title.value, content: body.value, scope: scope.value, owner_person_id: ownerPersonId.value, effective_at: fromLocalDateTimeValue(effectiveAt.value, effectiveBaseline), expires_at: fromLocalDateTimeValue(expiresAt.value, expiresBaseline), reason: reason.value, expected_version: item.version }); });
|
|
856
|
+
revisionDetails.append(summary, form); actions.append(revisionDetails);
|
|
857
|
+
}
|
|
858
|
+
if (canEdit && callbacks.onSupersede) {
|
|
859
|
+
const supersessionDraft = callbacks.supersessionDraft && typeof callbacks.supersessionDraft === 'object' ? callbacks.supersessionDraft : null;
|
|
860
|
+
const candidates = (asArray(callbacks.supersessionCandidates) ?? []).filter((candidate) => candidate?.id && candidate.id !== item.id && candidate.version !== null);
|
|
861
|
+
const supersedeDetails = makeElement('details', { className: 'knowledge-action-details' });
|
|
862
|
+
const supersedeSummary = makeElement('summary', { text: 'この判断で古い判断を置換' });
|
|
863
|
+
const form = makeElement('form', { className: 'knowledge-supersession-form' });
|
|
864
|
+
const target = makeElement('select', { className: 'knowledge-control', attrs: { name: 'superseded_id', required: 'required' } });
|
|
865
|
+
append(target, makeElement('option', { text: candidates.length ? '置換する判断を選択' : '検索結果に置換対象がありません', attrs: { value: '' } }), ...candidates.map((candidate) => makeElement('option', { text: `${text(candidate.title, 'タイトル未確認')}(版 ${candidate.version})`, attrs: { value: candidate.id } })));
|
|
866
|
+
target.value = supersessionDraft?.superseded_id ?? '';
|
|
867
|
+
const effectiveBaseline = supersessionDraft?.effective_at ?? null;
|
|
868
|
+
const effectiveAt = makeElement('input', { className: 'knowledge-control', value: toLocalDateTimeValue(effectiveBaseline), attrs: { name: 'effective_at', type: 'datetime-local', required: 'required' } });
|
|
869
|
+
const reason = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: supersessionDraft?.reason ?? '', attrs: { name: 'reason', rows: '3', maxlength: '3000', required: 'required', placeholder: '置換理由' } });
|
|
870
|
+
append(form, makeElement('p', { text: '現在の検索結果から古い判断を選びます。置換後も履歴と根拠参照は保持されます。' }), formField('置換する判断', target), formField('置換の発効日時', effectiveAt), formField('置換理由', reason));
|
|
871
|
+
const submit = makeElement('button', { className: 'knowledge-button primary', text: '置換を保存', attrs: { type: 'submit' }, disabled: candidates.length === 0 });
|
|
872
|
+
form.append(submit);
|
|
873
|
+
form.addEventListener('submit', (event) => {
|
|
874
|
+
event.preventDefault();
|
|
875
|
+
if (typeof form.reportValidity === 'function' && !form.reportValidity()) return;
|
|
876
|
+
const candidate = candidates.find((entry) => entry.id === target.value);
|
|
877
|
+
if (!candidate) return;
|
|
878
|
+
callbacks.onSupersede?.({ superseded_id: candidate.id, superseded_expected_version: candidate.version, replacement_expected_version: item.version, effective_at: fromLocalDateTimeValue(effectiveAt.value, effectiveBaseline), reason: reason.value });
|
|
879
|
+
});
|
|
880
|
+
supersedeDetails.append(supersedeSummary, form); actions.append(supersedeDetails);
|
|
881
|
+
}
|
|
882
|
+
if (canEdit && callbacks.onRetire) {
|
|
883
|
+
const retireDetails = makeElement('details', { className: 'knowledge-action-details danger' });
|
|
884
|
+
const summary = makeElement('summary', { text: 'この判断を失効' });
|
|
885
|
+
const form = makeElement('form', { className: 'knowledge-lifecycle-form' });
|
|
886
|
+
const reason = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', attrs: { name: 'reason', rows: '3', maxlength: '3000', required: 'required', placeholder: '失効理由' } });
|
|
887
|
+
append(form, makeElement('p', { text: '失効しても履歴と根拠参照は保持されます。正本自体を削除する操作ではありません。' }), formField('失効理由', reason));
|
|
888
|
+
const submit = makeElement('button', { className: 'knowledge-button danger', text: '失効を確認', attrs: { type: 'submit' } });
|
|
889
|
+
form.append(submit);
|
|
890
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); if (typeof form.reportValidity === 'function' && !form.reportValidity()) return; callbacks.onRetire?.({ reason: reason.value, expected_version: item.version }); });
|
|
891
|
+
retireDetails.append(summary, form); actions.append(retireDetails);
|
|
892
|
+
}
|
|
893
|
+
if (!canEdit) actions.append(makeElement('p', { className: 'knowledge-permission-copy', text: '版の変更は、知識を管理する役割でのみ実行できます。' }));
|
|
894
|
+
section.append(actions);
|
|
895
|
+
const revisions = asArray(item.history ?? item.raw?.revisions ?? item.revisions);
|
|
896
|
+
if (revisions) {
|
|
897
|
+
const history = makeElement('section', { className: 'knowledge-subsection' });
|
|
898
|
+
append(history, makeElement('h4', { text: '履歴' }));
|
|
899
|
+
if (!revisions.length) history.append(makeElement('p', { text: '履歴は登録されていません。' }));
|
|
900
|
+
else {
|
|
901
|
+
const list = makeElement('ol', { className: 'knowledge-history-list' });
|
|
902
|
+
for (const revision of revisions) append(list, makeElement('li', { text: `版 ${text(revision?.version)}: ${text(revision?.reason ?? revision?.change_reason)}` }));
|
|
903
|
+
history.append(list);
|
|
904
|
+
}
|
|
905
|
+
section.append(history);
|
|
906
|
+
} else section.append(makeElement('p', { className: 'knowledge-unknown-copy', text: '履歴を確認できません。' }));
|
|
907
|
+
root.append(section);
|
|
908
|
+
return section;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function renderResultEvidence(parent, result) {
|
|
912
|
+
const row = makeElement('li', { className: 'knowledge-preview-result' });
|
|
913
|
+
const item = normalizeKnowledgeItem(result?.item ?? result);
|
|
914
|
+
const adopted = result?.adopted === true || result?.selected === true || result?.used === true;
|
|
915
|
+
append(row,
|
|
916
|
+
makeElement('strong', { text: item?.title ? text(item.title) : text(result?.id ?? result?.item_id) }),
|
|
917
|
+
makeElement('span', { className: adopted ? 'knowledge-evidence-adopted' : 'knowledge-evidence-excluded', text: adopted ? '回答へ採用' : '除外' }),
|
|
918
|
+
makeElement('span', { text: `版: ${item?.version ?? result?.version ?? UNKNOWN}` }),
|
|
919
|
+
);
|
|
920
|
+
if (!adopted && result?.excluded_reason) row.append(makeElement('p', { text: `除外理由: ${result.excluded_reason}` }));
|
|
921
|
+
if (result?.evidence_ref || result?.evidence_refs) {
|
|
922
|
+
const refs = Array.isArray(result.evidence_refs) ? result.evidence_refs : [result.evidence_ref];
|
|
923
|
+
row.append(makeElement('p', { text: `取得根拠: ${refs.map((ref) => typeof ref === 'string' ? ref : text(ref?.id ?? ref?.pointer)).join(', ')}` }));
|
|
924
|
+
}
|
|
925
|
+
return row;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function renderCodexUseEvidence(root, evidence) {
|
|
929
|
+
const section = makeElement('section', { className: 'knowledge-use-evidence', attrs: { 'aria-labelledby': 'knowledge-use-evidence-title' } });
|
|
930
|
+
append(section, makeElement('h4', { text: 'Codexでの利用', attrs: { id: 'knowledge-use-evidence-title' } }));
|
|
931
|
+
if (!evidence || typeof evidence !== 'object') {
|
|
932
|
+
section.append(makeElement('p', { className: 'knowledge-unknown-copy', text: '利用状況は未確認です。登録済みであることだけでは回答への採用を示しません。' }));
|
|
933
|
+
root.append(section);
|
|
934
|
+
return section;
|
|
935
|
+
}
|
|
936
|
+
const retrieval = evidence.retrieval ?? evidence.retrieval_state;
|
|
937
|
+
const answer = evidence.answer ?? evidence.answer_state;
|
|
938
|
+
renderDefinitionList(section, [['参照解決', retrieval ? stateLabel(normaliseStatus(retrieval)) : UNKNOWN], ['本文取得・採用', answer ? stateLabel(normaliseStatus(answer)) : UNKNOWN], ['回答証拠', evidence.evidence_id ?? evidence.receipt_id ?? UNKNOWN]]);
|
|
939
|
+
if (evidence.reason) section.append(makeElement('p', { text: `理由: ${evidence.reason}` }));
|
|
940
|
+
root.append(section);
|
|
941
|
+
return section;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export function renderIsolatedPreview(root, model = {}, callbacks = {}) {
|
|
945
|
+
clear(root);
|
|
946
|
+
const preview = model.preview ?? model;
|
|
947
|
+
const section = makeElement('section', { className: 'knowledge-preview-panel', attrs: { 'aria-labelledby': 'knowledge-preview-title' } });
|
|
948
|
+
append(section, makeElement('span', { className: 'knowledge-eyebrow', text: '公開前テスト' }), makeElement('h3', { text: '隔離した質問試験', attrs: { id: 'knowledge-preview-title' } }), makeElement('p', { text: '下書き候補を通常のチーム検索へ混ぜず、質問への使われ方だけを確認します。' }));
|
|
949
|
+
const form = makeElement('form', { className: 'knowledge-preview-form', attrs: { 'aria-busy': preview.state === 'loading' ? 'true' : 'false' } });
|
|
950
|
+
const question = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: preview.question ?? '', attrs: { name: 'question', rows: '4', maxlength: '6000', required: 'required', placeholder: '試したい質問を入力' } });
|
|
951
|
+
const context = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: preview.context ?? preview.scenario ?? '', attrs: { name: 'context', rows: '3', maxlength: '6000', placeholder: '想定場面(任意)' } });
|
|
952
|
+
const sampleAnswer = makeElement('textarea', { className: 'knowledge-control knowledge-textarea', value: preview.sample_answer ?? '', attrs: { name: 'sample_answer', rows: '3', maxlength: '10000', placeholder: '比較したい回答(任意)' } });
|
|
953
|
+
append(form, formField('質問', question), formField('想定場面', context), formField('比較用の回答', sampleAnswer, '実行で生成される回答とは別に比較します。'));
|
|
954
|
+
const submit = makeElement('button', { className: 'knowledge-button primary', text: preview.state === 'loading' ? '試験中…' : '隔離して試験', attrs: { type: 'submit' }, disabled: preview.state === 'loading' });
|
|
955
|
+
form.append(submit);
|
|
956
|
+
if (['error', 'error_retryable', 'permission_denied', 'unknown'].includes(normaliseStatus(preview.state))) form.append(statusNotice(preview, { retry: Boolean(callbacks.onRetry), onRetry: callbacks.onRetry }));
|
|
957
|
+
form.addEventListener('submit', (event) => {
|
|
958
|
+
event.preventDefault(); if (typeof form.reportValidity === 'function' && !form.reportValidity()) return;
|
|
959
|
+
callbacks.onSubmit?.({ question: question.value, context: context.value, sample_answer: sampleAnswer.value });
|
|
960
|
+
});
|
|
961
|
+
section.append(form);
|
|
962
|
+
const isolationConfirmed = preview.isolated === true || preview.isolation === 'draft_only' || preview.isolation?.state === 'isolated';
|
|
963
|
+
const isolation = isolationConfirmed ? '隔離実行' : '隔離状態未確認';
|
|
964
|
+
append(section, makeElement('p', { className: isolationConfirmed ? 'knowledge-isolation-badge confirmed' : 'knowledge-isolation-badge unknown', text: isolation }));
|
|
965
|
+
const citations = asArray(preview.citations ?? preview.executed_result?.citations) ?? [];
|
|
966
|
+
const evidence = asArray(preview.evidence ?? preview.executed_result?.evidence) ?? [];
|
|
967
|
+
const exclusions = asArray(preview.exclusions ?? preview.executed_result?.exclusions) ?? [];
|
|
968
|
+
const candidates = asArray(preview.candidates);
|
|
969
|
+
const results = asArray(preview.results ?? preview.retrieval_results ?? preview.matches) ?? (candidates ? [
|
|
970
|
+
...candidates.map((candidate) => {
|
|
971
|
+
const citation = citations.find((entry) => entry?.id === candidate?.id && String(entry?.version) === String(candidate?.version));
|
|
972
|
+
const excluded = exclusions.find((entry) => entry?.id === candidate?.id && (!entry?.version || String(entry.version) === String(candidate?.version)));
|
|
973
|
+
const evidenceRefs = evidence.filter((entry) => entry?.id === candidate?.id && (!entry?.version || String(entry.version) === String(candidate?.version))).map((entry) => entry?.source_ref).filter(Boolean);
|
|
974
|
+
return { ...candidate, adopted: Boolean(citation), excluded_reason: excluded?.reason ?? (citation ? null : '回答で未採用'), evidence_refs: evidenceRefs };
|
|
975
|
+
}),
|
|
976
|
+
...exclusions.filter((entry) => !candidates.some((candidate) => candidate?.id === entry?.id)).map((entry) => ({ ...entry, adopted: false, excluded_reason: entry.reason })),
|
|
977
|
+
] : null);
|
|
978
|
+
if (preview.state === 'ready' || preview.state === 'completed' || results) {
|
|
979
|
+
const executed = makeElement('section', { className: 'knowledge-preview-executed' });
|
|
980
|
+
append(executed, makeElement('h4', { text: '実行した検索結果' }));
|
|
981
|
+
if (results === null) executed.append(makeElement('p', { className: 'knowledge-unknown-copy', text: '検索結果を確認できません。' }));
|
|
982
|
+
else if (!results.length) executed.append(makeElement('p', { text: '候補に一致する検索結果はありませんでした。' }));
|
|
983
|
+
else {
|
|
984
|
+
const list = makeElement('ul', { className: 'knowledge-preview-results' });
|
|
985
|
+
for (const result of results) list.append(renderResultEvidence(list, result));
|
|
986
|
+
executed.append(list);
|
|
987
|
+
}
|
|
988
|
+
const executedResult = preview.executed_result && typeof preview.executed_result === 'object' ? preview.executed_result : {};
|
|
989
|
+
if (preview.answer !== undefined || preview.answer_text !== undefined || executedResult.answer !== undefined || executedResult.answer_text !== undefined) executed.append(makeElement('div', { className: 'knowledge-preview-answer' }),);
|
|
990
|
+
const answer = executed.lastChild;
|
|
991
|
+
if (answer?.className === 'knowledge-preview-answer') append(answer, makeElement('h4', { text: '実行結果の回答' }), makeElement('p', { className: 'knowledge-long-text', text: preview.answer ?? preview.answer_text ?? executedResult.answer ?? executedResult.answer_text ?? UNKNOWN }));
|
|
992
|
+
if (executedResult.state && !['completed', 'ready'].includes(executedResult.state)) executed.append(makeElement('p', { className: 'knowledge-warning-copy', text: `実行結果: ${text(executedResult.state)}${executedResult.reason ? `(${text(executedResult.reason)})` : ''}` }));
|
|
993
|
+
section.append(executed);
|
|
994
|
+
}
|
|
995
|
+
if (preview.sample_answer !== undefined || preview.example_answer !== undefined || preview.fixed_answer !== undefined) {
|
|
996
|
+
const example = makeElement('section', { className: 'knowledge-preview-example' });
|
|
997
|
+
append(example, makeElement('h4', { text: preview.sample_answer !== undefined ? '比較用の回答(実行結果とは別)' : '固定の回答例(実行結果とは別)' }), makeElement('p', { className: 'knowledge-long-text', text: preview.sample_answer ?? preview.example_answer ?? preview.fixed_answer ?? UNKNOWN }));
|
|
998
|
+
section.append(example);
|
|
999
|
+
}
|
|
1000
|
+
const testedVersion = preview.draft_version ?? preview.version?.draft?.version ?? preview.candidate_version;
|
|
1001
|
+
if (testedVersion !== undefined && preview.item_version !== undefined && String(testedVersion) !== String(preview.item_version)) section.append(makeElement('p', { className: 'knowledge-warning-copy', text: `この試験は旧版です(試験: ${testedVersion} / 現在: ${preview.item_version})。` }));
|
|
1002
|
+
root.append(section);
|
|
1003
|
+
return section;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export function canManageKnowledge(session) {
|
|
1007
|
+
const value = typeof session === 'function' ? session() : session;
|
|
1008
|
+
if (!value || typeof value !== 'object') return false;
|
|
1009
|
+
if (value.canManageKnowledge === true || value.can_manage_knowledge === true) return true;
|
|
1010
|
+
return ['owner', 'admin', 'gm', 'ceo'].includes(optionalText(value.role).toLowerCase());
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function defaultDraftBody(input) {
|
|
1014
|
+
const pointer = input.source?.pointer ?? input.source_pointer ?? null;
|
|
1015
|
+
return {
|
|
1016
|
+
kind: 'decision',
|
|
1017
|
+
title: '',
|
|
1018
|
+
content: input.input_text,
|
|
1019
|
+
applicability: {},
|
|
1020
|
+
source_pointer: pointer ? { uri: pointer } : null,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function defaultCandidateBody(values, context = {}) {
|
|
1025
|
+
const pointer = context.source?.pointer ?? context.source_pointer ?? null;
|
|
1026
|
+
const applicabilityConditions = typeof values.applicability === 'object'
|
|
1027
|
+
? values.applicability?.conditions ?? null
|
|
1028
|
+
: values.applicability ?? null;
|
|
1029
|
+
return {
|
|
1030
|
+
kind: values.type ?? values.kind ?? 'decision',
|
|
1031
|
+
type: values.type ?? values.kind ?? 'decision',
|
|
1032
|
+
title: values.title ?? '',
|
|
1033
|
+
content: context.input_text ?? values.content ?? '',
|
|
1034
|
+
summary: values.summary ?? '',
|
|
1035
|
+
applicability: {
|
|
1036
|
+
scope: values.scope ?? 'project',
|
|
1037
|
+
...(applicabilityConditions ? { conditions: applicabilityConditions } : {}),
|
|
1038
|
+
},
|
|
1039
|
+
owner_id: values.owner_id ?? null,
|
|
1040
|
+
relations: Array.isArray(values.relations) ? values.relations : [],
|
|
1041
|
+
selection: values.selection ?? 'create',
|
|
1042
|
+
source_pointer: pointer ? { uri: pointer } : null,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export function createKnowledgeOutcomeController(options = {}) {
|
|
1047
|
+
const { root, api, apiMutation, project, session, onStateChange, onCommitted, onRetired, resolveDecisionDomain, serializeDraftInput, serializeCandidate, serializeRevision, serializeRetire, serializePreview } = options;
|
|
1048
|
+
if (!root || typeof root.replaceChildren !== 'function') throw new Error('knowledge_root_unavailable');
|
|
1049
|
+
const code = projectCodeOf(project);
|
|
1050
|
+
const projectValue = typeof project === 'function' ? project() : project;
|
|
1051
|
+
const ownerCandidates = [];
|
|
1052
|
+
for (const candidate of [projectValue?.owner, ...(projectValue?.members?.items ?? [])]) {
|
|
1053
|
+
const id = fieldValue(candidate, 'id', 'person_id', 'personId', 'user_id', 'userId');
|
|
1054
|
+
if (!id || ownerCandidates.some((owner) => owner.id === id)) continue;
|
|
1055
|
+
ownerCandidates.push({ id, name: fieldValue(candidate, 'name', 'display_name', 'displayName', 'label') ?? '氏名未確認' });
|
|
1056
|
+
}
|
|
1057
|
+
const state = {
|
|
1058
|
+
view: 'discovery',
|
|
1059
|
+
filters: { q: '', scope: 'all', status: 'active' },
|
|
1060
|
+
list: { state: 'loading', records: null, absence_confirmed: false, searched_scope: null },
|
|
1061
|
+
selected: null,
|
|
1062
|
+
detail: { state: 'idle' },
|
|
1063
|
+
draft: { state: 'idle', input_text: '' },
|
|
1064
|
+
candidate: { state: 'idle' },
|
|
1065
|
+
commit: { state: 'idle' },
|
|
1066
|
+
lifecycle: { state: 'idle' },
|
|
1067
|
+
preview: { state: 'idle', question: '', context: '' },
|
|
1068
|
+
authority: { state: 'loading', domains: null },
|
|
1069
|
+
destination: { state: 'unknown', registration: null, draft: {}, message: '保存先APIの契約を確認できていません。' },
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
function resumeUrl(draftId) {
|
|
1073
|
+
const location = options.location ?? globalThis.location;
|
|
1074
|
+
if (!location?.href || !draftId) return null;
|
|
1075
|
+
const url = new URL(location.href);
|
|
1076
|
+
url.searchParams.set('knowledge_project', code);
|
|
1077
|
+
url.searchParams.set('knowledge_draft', draftId);
|
|
1078
|
+
return url.toString();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function notify() {
|
|
1082
|
+
onStateChange?.(state);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function render() {
|
|
1086
|
+
clear(root);
|
|
1087
|
+
const shell = makeElement('div', { className: 'knowledge-outcome' });
|
|
1088
|
+
const header = makeElement('header', { className: 'knowledge-outcome-head' });
|
|
1089
|
+
const headerMain = makeElement('div', { className: 'knowledge-header-main' });
|
|
1090
|
+
append(headerMain,
|
|
1091
|
+
makeElement('span', { className: 'knowledge-eyebrow', text: 'Brainbase Outcome' }),
|
|
1092
|
+
makeElement('h2', { text: '知識・判断を成果へつなぐ' }),
|
|
1093
|
+
makeElement('p', { text: '正本を探し、由来と状態を確認して、必要なときだけ更新します。' }),
|
|
1094
|
+
);
|
|
1095
|
+
const headerMeta = makeElement('div', { className: 'knowledge-header-meta' });
|
|
1096
|
+
headerMeta.append(makeElement('span', { className: 'knowledge-project-context', text: `プロジェクト: ${code}` }));
|
|
1097
|
+
append(header, headerMain, headerMeta);
|
|
1098
|
+
const nav = makeElement('nav', { className: 'knowledge-outcome-nav', attrs: { 'aria-label': '知識の操作' } });
|
|
1099
|
+
for (const [value, label] of [['discovery', '探す'], ['capture', '登録案を作る'], ['preview', '公開前テスト'], ['destination', '保存先']]) {
|
|
1100
|
+
const button = makeElement('button', { className: `knowledge-tab${state.view === value ? ' active' : ''}`, text: label, attrs: { type: 'button', 'aria-current': state.view === value ? 'page' : 'false' } });
|
|
1101
|
+
button.addEventListener('click', () => { state.view = value; render(); }); nav.append(button);
|
|
1102
|
+
}
|
|
1103
|
+
header.append(nav); shell.append(header);
|
|
1104
|
+
const body = makeElement('div', { className: 'knowledge-outcome-body knowledge-workspace', attrs: { 'data-view': state.view } });
|
|
1105
|
+
const primary = makeElement('main', { className: 'knowledge-outcome-primary knowledge-master', attrs: { 'aria-label': state.view === 'discovery' ? '知識の一覧' : '知識の操作' } });
|
|
1106
|
+
const discovery = makeElement('section', { className: 'knowledge-discovery-view', hidden: state.view !== 'discovery', attrs: { 'aria-labelledby': 'knowledge-discovery-title' } });
|
|
1107
|
+
const discoveryHeader = makeElement('div', { className: 'knowledge-view-heading' });
|
|
1108
|
+
append(discoveryHeader,
|
|
1109
|
+
makeElement('div', {},),
|
|
1110
|
+
makeElement('span', { className: 'knowledge-view-kicker', text: 'Discovery' }),
|
|
1111
|
+
);
|
|
1112
|
+
discoveryHeader.firstChild.append(makeElement('h3', { id: 'knowledge-discovery-title', text: '知識・判断を探す' }), makeElement('p', { text: '検索結果を選ぶと、右側に正本とライフサイクルを表示します。' }));
|
|
1113
|
+
discovery.append(discoveryHeader);
|
|
1114
|
+
const filters = makeElement('div', { className: 'knowledge-filter-region knowledge-discovery-toolbar' });
|
|
1115
|
+
renderDiscoveryFilters(filters, state.filters, { onChange: (next) => { state.filters = next; void loadItems(); }, });
|
|
1116
|
+
discovery.append(filters);
|
|
1117
|
+
const list = makeElement('div', { className: 'knowledge-discovery-list' });
|
|
1118
|
+
renderKnowledgeList(list, state.list, { selectedId: state.selected?.id, onSelect: (item) => { state.selected = item; state.detail = { state: 'ready', item }; state.lifecycle = { state: 'ready', item }; render(); }, onRetry: () => loadItems() });
|
|
1119
|
+
discovery.append(list);
|
|
1120
|
+
primary.append(discovery);
|
|
1121
|
+
const captureWorkspace = makeElement('section', { className: 'knowledge-focused-view knowledge-capture-view', hidden: state.view !== 'capture', attrs: { 'aria-labelledby': 'knowledge-capture-workspace-title' } });
|
|
1122
|
+
const captureHeading = makeElement('div', { className: 'knowledge-view-heading' });
|
|
1123
|
+
append(captureHeading, makeElement('div', {},), makeElement('span', { className: 'knowledge-view-kicker', text: 'Capture' }));
|
|
1124
|
+
captureHeading.firstChild.append(makeElement('h3', { id: 'knowledge-capture-workspace-title', text: '登録案を作る' }), makeElement('p', { text: '自然文を候補へ整理し、正本へ保存する前に内容と根拠を確認します。' }));
|
|
1125
|
+
captureWorkspace.append(captureHeading);
|
|
1126
|
+
const capture = makeElement('div', { className: 'knowledge-capture-form-region' });
|
|
1127
|
+
renderDraftCapture(capture, state.draft, { onSubmit: createDraft, onResume: resumeDraft, onDiscard: discardDraft, onRetry: () => createDraft({ input_text: state.draft.input_text, source: state.draft.source }) });
|
|
1128
|
+
captureWorkspace.append(capture);
|
|
1129
|
+
const review = makeElement('div', { className: 'knowledge-review-region', hidden: state.view !== 'capture' || state.candidate.state === 'idle' });
|
|
1130
|
+
if (state.candidate.state !== 'idle') {
|
|
1131
|
+
const commitLocked = state.commit.state === 'saving' || Boolean(state.commit.pending_save) || ['verified', 'saved_unverified'].includes(state.commit.state);
|
|
1132
|
+
renderCandidateReview(review, { ...state.candidate, decision_domains: state.authority.domains ?? [] }, {
|
|
1133
|
+
onApply: commitLocked ? null : applyProposal,
|
|
1134
|
+
onSave: commitLocked ? null : saveCandidate,
|
|
1135
|
+
onCommit: commitLocked || !(state.candidate.draft_id ?? state.draft.id) ? null : commitCandidate,
|
|
1136
|
+
onRetry: state.commit.pending_save ? retryPendingSave : () => createDraft({ input_text: state.draft.input_text, source: state.draft.source }),
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
captureWorkspace.append(review);
|
|
1140
|
+
primary.append(captureWorkspace);
|
|
1141
|
+
const preview = makeElement('section', { className: 'knowledge-focused-view knowledge-preview-view', hidden: state.view !== 'preview', attrs: { 'aria-labelledby': 'knowledge-preview-workspace-title' } });
|
|
1142
|
+
const previewHeading = makeElement('div', { className: 'knowledge-view-heading' });
|
|
1143
|
+
append(previewHeading, makeElement('div', {},), makeElement('span', { className: 'knowledge-view-kicker', text: 'Preview' }));
|
|
1144
|
+
previewHeading.firstChild.append(makeElement('h3', { id: 'knowledge-preview-workspace-title', text: '公開前テスト' }), makeElement('p', { text: '隔離した質問で候補の使われ方と根拠を確認します。' }));
|
|
1145
|
+
preview.append(previewHeading);
|
|
1146
|
+
const previewContent = makeElement('div', { className: 'knowledge-preview-content' });
|
|
1147
|
+
renderIsolatedPreview(previewContent, state.preview, { onSubmit: runPreview, onRetry: () => runPreview({ question: state.preview.question, context: state.preview.context }) });
|
|
1148
|
+
preview.append(previewContent);
|
|
1149
|
+
primary.append(preview);
|
|
1150
|
+
const destination = makeElement('section', { className: 'knowledge-focused-view knowledge-destination-view', hidden: state.view !== 'destination', attrs: { 'aria-labelledby': 'knowledge-destination-workspace-title' } });
|
|
1151
|
+
const destinationHeading = makeElement('div', { className: 'knowledge-view-heading' });
|
|
1152
|
+
append(destinationHeading, makeElement('div', {},), makeElement('span', { className: 'knowledge-view-kicker', text: 'Source' }));
|
|
1153
|
+
destinationHeading.firstChild.append(makeElement('h3', { id: 'knowledge-destination-workspace-title', text: '知識文書の保存先' }), makeElement('p', { text: '正本ドキュメントの場所を登録し、保存後の状態を再取得して確認します。' }));
|
|
1154
|
+
destination.append(destinationHeading);
|
|
1155
|
+
const destinationContent = makeElement('div', { className: 'knowledge-destination-content' });
|
|
1156
|
+
renderKnowledgeDestination(destinationContent, state.destination, {
|
|
1157
|
+
canEdit: canManageKnowledge(session),
|
|
1158
|
+
onReload: loadDestination,
|
|
1159
|
+
onSave: saveDestination,
|
|
1160
|
+
});
|
|
1161
|
+
destination.append(destinationContent);
|
|
1162
|
+
primary.append(destination);
|
|
1163
|
+
body.append(primary);
|
|
1164
|
+
const detail = makeElement('aside', { className: 'knowledge-outcome-detail knowledge-inspector', attrs: { 'aria-label': '選択した知識の詳細' } });
|
|
1165
|
+
const inspectorHeading = makeElement('div', { className: 'knowledge-inspector-heading' });
|
|
1166
|
+
append(inspectorHeading, makeElement('span', { className: 'knowledge-view-kicker', text: 'Inspector' }), makeElement('span', { className: 'knowledge-inspector-state', text: state.selected ? '選択中' : '未選択' }));
|
|
1167
|
+
detail.append(inspectorHeading);
|
|
1168
|
+
const detailContent = makeElement('div', { className: 'knowledge-inspector-detail' });
|
|
1169
|
+
renderKnowledgeDetail(detailContent, state.selected, { onPreview: (item) => { state.preview = { state: 'idle', question: '', context: '', item_id: item.id, candidate_version: item.version, item_version: item.version }; state.view = 'preview'; render(); } });
|
|
1170
|
+
detail.append(detailContent);
|
|
1171
|
+
const lifecycle = makeElement('div', { className: 'knowledge-inspector-lifecycle' });
|
|
1172
|
+
renderLifecycle(lifecycle, state.selected, { canEdit: canManageKnowledge(session), ownerCandidates, revisionDraft: state.lifecycle.revisionDraft, supersessionDraft: state.lifecycle.supersessionDraft, supersessionCandidates: state.list.records, onRevision: reviseItem, onSupersede: supersedeItem, onRetire: retireItem }); detail.append(lifecycle);
|
|
1173
|
+
if (state.commit.state !== 'idle') { const commit = makeElement('div'); renderCommitState(commit, state.commit, { onReadback: state.commit.pending_save ? retryPendingSave : readbackCommit }); detail.append(commit); }
|
|
1174
|
+
body.append(detail); shell.append(body); root.append(shell); notify();
|
|
1175
|
+
return shell;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function loadItems() {
|
|
1179
|
+
state.list = { ...state.list, state: 'loading' }; render();
|
|
1180
|
+
try { state.list = normalizeKnowledgeCollection(await callKnowledgeApi(api, buildKnowledgeItemsPath(code, state.filters))); }
|
|
1181
|
+
catch (error) { state.list = { state: error.code === 'permission_denied' || error.status === 403 ? 'permission_denied' : 'error_retryable', records: null, message: apiErrorText(error), error: error.code }; }
|
|
1182
|
+
render(); return state.list;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function destinationPath() {
|
|
1186
|
+
const configured = typeof options.knowledgeDestinationPath === 'function'
|
|
1187
|
+
? options.knowledgeDestinationPath(code)
|
|
1188
|
+
: options.knowledgeDestinationPath;
|
|
1189
|
+
return optionalText(configured).trim() || knowledgePath(code, KNOWLEDGE_ROUTES.documentSourceRegistration);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function loadDestination() {
|
|
1193
|
+
const path = destinationPath();
|
|
1194
|
+
if (!path) {
|
|
1195
|
+
state.destination = { ...state.destination, state: 'unknown', message: '保存先APIの契約を確認できていません。入力値は保持します。' };
|
|
1196
|
+
render(); return state.destination;
|
|
1197
|
+
}
|
|
1198
|
+
state.destination = { ...state.destination, state: 'loading' }; render();
|
|
1199
|
+
try {
|
|
1200
|
+
const payload = await callKnowledgeApi(api, path);
|
|
1201
|
+
const registration = normalizeKnowledgeDestinationRegistration(payload, code);
|
|
1202
|
+
state.destination = registration
|
|
1203
|
+
? { state: 'ready', registration, revision: registration.revision, draft: state.destination.draft ?? {}, message: null }
|
|
1204
|
+
: { state: 'unknown', registration: null, revision: null, draft: state.destination.draft ?? {}, message: '保存先APIの契約を確認できません。入力値は保持します。' };
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
const unregistered = error.status === 404 || error.code === 'knowledge_document_source_registration_not_found';
|
|
1207
|
+
state.destination = { ...state.destination, state: unregistered ? 'unregistered' : error.status === 403 ? 'permission_denied' : 'error_retryable', registration: unregistered ? null : state.destination.registration, message: unregistered ? null : apiErrorText(error), error: error.code };
|
|
1208
|
+
}
|
|
1209
|
+
render(); return state.destination;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
async function saveDestination(input) {
|
|
1213
|
+
const path = destinationPath();
|
|
1214
|
+
state.destination = { ...state.destination, state: path ? 'saving' : 'unknown', draft: { ...input }, message: path ? null : '保存先APIの契約を確認できていません。入力値は保持します。' };
|
|
1215
|
+
render();
|
|
1216
|
+
if (!path) return state.destination;
|
|
1217
|
+
try {
|
|
1218
|
+
const mutation = await callKnowledgeApi(apiMutation, path, { method: 'PUT', body: input, key: operationKey('knowledge-destination', code, input.expected_revision, input) });
|
|
1219
|
+
const payload = await callKnowledgeApi(api, path);
|
|
1220
|
+
const registration = normalizeKnowledgeDestinationRegistration(payload, code);
|
|
1221
|
+
const savedRegistration = normalizeKnowledgeDestinationRegistration(mutation, code);
|
|
1222
|
+
const matches = registration
|
|
1223
|
+
&& savedRegistration
|
|
1224
|
+
&& DESTINATION_REGISTRATION_FIELDS.every((key) => registration[key] === input[key] && savedRegistration[key] === input[key])
|
|
1225
|
+
&& registration.revision === savedRegistration.revision;
|
|
1226
|
+
state.destination = {
|
|
1227
|
+
state: matches ? 'verified' : 'unknown',
|
|
1228
|
+
registration: matches ? registration : null,
|
|
1229
|
+
revision: matches ? registration.revision : null,
|
|
1230
|
+
draft: matches ? {} : { ...input },
|
|
1231
|
+
message: matches ? null : '保存後の再取得結果が保存先の契約と一致しません。入力内容は保持します。',
|
|
1232
|
+
};
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
state.destination = { ...state.destination, state: isVersionConflict(error) || error.status === 409 ? 'conflict' : error.status === 403 ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code, draft: { ...input } };
|
|
1235
|
+
}
|
|
1236
|
+
render(); return state.destination;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function selectItem(itemOrId) {
|
|
1240
|
+
const id = typeof itemOrId === 'object' ? itemOrId?.id : itemOrId;
|
|
1241
|
+
const listItem = typeof itemOrId === 'object' ? itemOrId : state.list.records?.find((item) => item.id === id);
|
|
1242
|
+
state.selected = listItem ?? null; state.detail = { state: 'loading' }; render();
|
|
1243
|
+
if (!id) { state.detail = { state: 'unknown', message: '正本IDを確認できません。' }; render(); return null; }
|
|
1244
|
+
try {
|
|
1245
|
+
const payload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(id)}`));
|
|
1246
|
+
const historyPayload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(id)}/${KNOWLEDGE_ROUTES.history}`));
|
|
1247
|
+
const item = normalizeKnowledgeItem(payload?.item ?? payload?.record ?? payload?.data ?? payload) ?? listItem;
|
|
1248
|
+
if (item) item.history = Array.isArray(historyPayload?.entries) ? historyPayload.entries : null;
|
|
1249
|
+
state.selected = item; state.detail = { state: 'ready', item }; state.lifecycle = { state: 'ready', item }; render(); return item;
|
|
1250
|
+
} catch (error) { state.detail = { state: error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; render(); return null; }
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function createDraft(input) {
|
|
1254
|
+
const preserved = { input_text: optionalText(input?.input_text), source: input?.source ?? null };
|
|
1255
|
+
state.draft = { ...state.draft, ...preserved, state: 'saving' }; state.view = 'capture'; render();
|
|
1256
|
+
try {
|
|
1257
|
+
const body = { content: preserved.input_text, ...(preserved.source ? { source: preserved.source } : {}), ...(Array.isArray(input?.source_refs) ? { source_refs: input.source_refs } : {}) };
|
|
1258
|
+
const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, KNOWLEDGE_ROUTES.captureProposal), { method: 'POST', body, key: operationKey('capture-proposal', 'new', null, body) });
|
|
1259
|
+
const proposed = payload?.proposal ?? {};
|
|
1260
|
+
state.draft = { ...state.draft, ...preserved, state: 'candidate' };
|
|
1261
|
+
state.candidate = { ...payload, candidate: proposed, input_text: preserved.input_text, source: preserved.source, state: 'candidate', proposal_applied: false, draft_id: null, version: null };
|
|
1262
|
+
} catch (error) { state.draft = { ...state.draft, state: error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
|
|
1263
|
+
render(); return state.candidate;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function resumeDraft(draftId) {
|
|
1267
|
+
const id = optionalText(draftId).trim();
|
|
1268
|
+
if (!id) { state.draft = { ...state.draft, state: 'unknown', message: '再開する下書きIDを入力してください。' }; render(); return null; }
|
|
1269
|
+
state.draft = { ...state.draft, resume_id: id, state: 'loading' }; state.view = 'capture'; render();
|
|
1270
|
+
try {
|
|
1271
|
+
const payload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(id)}`));
|
|
1272
|
+
const draft = payload?.draft ?? payload?.data ?? payload;
|
|
1273
|
+
const resolvedId = draft?.draft_id ?? draft?.id ?? id;
|
|
1274
|
+
const revision = draft?.revision ?? draft?.version ?? null;
|
|
1275
|
+
state.draft = { ...draft, id: resolvedId, draft_id: resolvedId, revision, input_text: draft?.content ?? '', resume_id: resolvedId, resume_url: resumeUrl(resolvedId), state: 'candidate' };
|
|
1276
|
+
state.candidate = { ...draft, candidate: draft?.candidate ?? draft, input_text: state.draft.input_text, draft_id: resolvedId, version: revision, proposal_applied: true, state: 'candidate' };
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
const failureState = error.code === 'permission_denied' || error.status === 403
|
|
1279
|
+
? 'permission_denied'
|
|
1280
|
+
: error.code === 'knowledge_draft_not_found' || error.status === 404
|
|
1281
|
+
? 'unknown'
|
|
1282
|
+
: error.status === 409 || isVersionConflict(error) ? 'conflict' : 'error_retryable';
|
|
1283
|
+
state.draft = { ...state.draft, state: failureState, message: apiErrorText(error), error: error.code };
|
|
1284
|
+
}
|
|
1285
|
+
render(); return state.candidate;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
async function discardDraft() {
|
|
1289
|
+
const draftId = state.candidate.draft_id ?? state.draft.draft_id ?? state.draft.id;
|
|
1290
|
+
if (!draftId) { state.draft = { state: 'idle', input_text: '' }; state.candidate = { state: 'idle' }; render(); return state.draft; }
|
|
1291
|
+
const revision = state.candidate.version ?? state.draft.revision ?? state.draft.version;
|
|
1292
|
+
const preservedDraft = state.draft; const preservedCandidate = state.candidate;
|
|
1293
|
+
state.draft = { ...state.draft, state: 'saving' }; render();
|
|
1294
|
+
try {
|
|
1295
|
+
await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}/discard`), { method: 'POST', body: { revision }, key: operationKey('draft-discard', draftId, revision, { revision }) });
|
|
1296
|
+
state.draft = { state: 'idle', input_text: '' }; state.candidate = { state: 'idle' };
|
|
1297
|
+
} catch (error) {
|
|
1298
|
+
state.draft = { ...preservedDraft, state: isVersionConflict(error) ? 'conflict' : 'error_retryable', message: apiErrorText(error), error: error.code };
|
|
1299
|
+
state.candidate = preservedCandidate;
|
|
1300
|
+
}
|
|
1301
|
+
render(); return state.draft;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function applyProposal() {
|
|
1305
|
+
state.candidate = { ...state.candidate, proposal_applied: true, state: 'candidate' };
|
|
1306
|
+
render();
|
|
1307
|
+
return state.candidate;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
async function loadAuthorityDomains() {
|
|
1311
|
+
state.authority = { state: 'loading', domains: null };
|
|
1312
|
+
try {
|
|
1313
|
+
const payload = await callKnowledgeApi(api, knowledgePath(code, 'authority-domains'));
|
|
1314
|
+
state.authority = Array.isArray(payload?.domains)
|
|
1315
|
+
? { state: 'ready', domains: payload.domains }
|
|
1316
|
+
: { state: 'unknown', domains: null, message: '判断権限の応答を確認できません。' };
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
state.authority = { state: error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', domains: null, message: apiErrorText(error) };
|
|
1319
|
+
}
|
|
1320
|
+
render(); return state.authority;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
async function saveCandidate(values) {
|
|
1324
|
+
const draftId = state.candidate.draft_id ?? state.draft.id;
|
|
1325
|
+
if (!state.candidate.proposal_applied) { state.candidate = { ...state.candidate, state: 'unknown', message: 'AI提案を入力欄へ反映してから下書きを保存してください。' }; render(); return null; }
|
|
1326
|
+
const editedCandidate = { ...(state.candidate.candidate ?? {}), ...values };
|
|
1327
|
+
state.candidate = { ...state.candidate, candidate: editedCandidate, state: 'saving', input_text: state.draft.input_text }; render();
|
|
1328
|
+
try {
|
|
1329
|
+
const candidateBody = serializeCandidate ? serializeCandidate(values, state.candidate) : defaultCandidateBody(values, state.draft);
|
|
1330
|
+
const revision = state.candidate.version ?? state.draft.revision ?? state.draft.version;
|
|
1331
|
+
const body = draftId ? { ...candidateBody, revision } : candidateBody;
|
|
1332
|
+
const path = draftId ? `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}` : KNOWLEDGE_ROUTES.drafts;
|
|
1333
|
+
const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, path), { method: draftId ? 'PATCH' : 'POST', body, key: operationKey(draftId ? 'draft-update' : 'draft', draftId ?? 'new', revision, body) });
|
|
1334
|
+
const draft = payload?.draft ?? payload?.data ?? payload;
|
|
1335
|
+
state.candidate = { ...state.candidate, ...draft, candidate: { ...editedCandidate, ...(draft?.candidate ?? {}) }, state: 'candidate', proposal_applied: true, draft_id: draft?.draft_id ?? draft?.id ?? draftId, version: draft?.revision ?? draft?.version ?? state.candidate.version };
|
|
1336
|
+
const savedId = draft?.draft_id ?? draft?.id ?? draftId;
|
|
1337
|
+
state.draft = { ...state.draft, ...draft, id: savedId, draft_id: savedId, resume_url: resumeUrl(savedId), state: 'candidate' };
|
|
1338
|
+
} catch (error) { state.candidate = { ...state.candidate, state: isVersionConflict(error) ? 'conflict' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
|
|
1339
|
+
render(); return state.candidate;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async function commitCandidate(values) {
|
|
1343
|
+
if (state.commit.state === 'saving' || state.commit.pending_save || ['verified', 'saved_unverified'].includes(state.commit.state)) return state.commit;
|
|
1344
|
+
const draftId = state.candidate.draft_id ?? state.draft.id;
|
|
1345
|
+
if (!draftId) { state.commit = { state: 'unknown', message: '下書きIDを確認できません。' }; render(); return null; }
|
|
1346
|
+
state.commit = { state: 'saving', item_id: null, version: null }; render();
|
|
1347
|
+
try {
|
|
1348
|
+
const resolvedDomain = await resolveDecisionDomain?.({ project, session: typeof session === 'function' ? session() : session, candidate: values });
|
|
1349
|
+
const decisionDomain = resolvedDomain ?? values.decision_domain ?? null;
|
|
1350
|
+
const allowedDomains = (state.authority.domains ?? []).map((domain) => typeof domain === 'string' ? domain : domain?.id ?? domain?.domain).filter(Boolean);
|
|
1351
|
+
if (!decisionDomain) {
|
|
1352
|
+
state.commit = { state: 'unknown', message: 'Graph RACIの判断権限ドメインを確認できません。適用範囲から推測して保存しません。' };
|
|
1353
|
+
render(); return state.commit;
|
|
1354
|
+
}
|
|
1355
|
+
if (!resolvedDomain && !allowedDomains.includes(decisionDomain)) {
|
|
1356
|
+
state.commit = { state: 'permission_denied', message: '選択した判断権限は現在のGraph RACIで確認できません。' };
|
|
1357
|
+
render(); return state.commit;
|
|
1358
|
+
}
|
|
1359
|
+
const candidateBody = serializeCandidate ? serializeCandidate(values, state.candidate) : defaultCandidateBody(values, state.draft);
|
|
1360
|
+
const selection = values.selection === 'reuse' ? 'reuse' : 'create';
|
|
1361
|
+
const canonicalId = optionalText(values.canonical_id).trim();
|
|
1362
|
+
const canonicalVersion = optionalText(values.canonical_version ?? values.expected_version).trim();
|
|
1363
|
+
if (selection === 'reuse' && (!canonicalId || !canonicalVersion)) {
|
|
1364
|
+
state.commit = { state: 'unknown', message: '再利用する正本IDと現在版を確認できません。' };
|
|
1365
|
+
render(); return state.commit;
|
|
1366
|
+
}
|
|
1367
|
+
const revision = state.candidate.version ?? state.draft.revision ?? state.draft.version ?? null;
|
|
1368
|
+
const updated = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}`), { method: 'PATCH', body: { ...candidateBody, revision }, key: operationKey('draft-update', draftId, revision, candidateBody) });
|
|
1369
|
+
const savedRevision = updated?.revision ?? revision;
|
|
1370
|
+
state.candidate = { ...state.candidate, ...updated, candidate: updated?.candidate ?? values, draft_id: updated?.draft_id ?? updated?.id ?? draftId, version: savedRevision };
|
|
1371
|
+
state.draft = { ...state.draft, ...updated, id: updated?.draft_id ?? updated?.id ?? draftId, revision: savedRevision };
|
|
1372
|
+
const body = { revision: savedRevision, decision_domain: decisionDomain, ...(selection === 'reuse' ? { canonical_id: canonicalId, expected_version: canonicalVersion } : {}) };
|
|
1373
|
+
const action = selection === 'reuse' ? 'reuse' : 'save';
|
|
1374
|
+
const key = operationKey(action, draftId, savedRevision, body);
|
|
1375
|
+
state.commit = { ...state.commit, pending_save: { draftId, body, key, action, expected_relations: candidateBody.relations } };
|
|
1376
|
+
const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(draftId)}/${action}`), { method: 'POST', body, key });
|
|
1377
|
+
const readback = await verifyMutationReadback(payload, candidateBody.relations, action);
|
|
1378
|
+
state.commit = readback;
|
|
1379
|
+
if (readback.item) { state.selected = readback.item; state.detail = { state: 'ready', item: readback.item }; state.lifecycle = { state: 'ready', item: readback.item }; }
|
|
1380
|
+
onCommitted?.(readback);
|
|
1381
|
+
} catch (error) { state.commit = { ...state.commit, state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
|
|
1382
|
+
render(); return state.commit;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
async function retryPendingSave() {
|
|
1386
|
+
const pending = state.commit.pending_save;
|
|
1387
|
+
if (!pending) return readbackCommit();
|
|
1388
|
+
state.commit = { ...state.commit, state: 'saving' }; render();
|
|
1389
|
+
try {
|
|
1390
|
+
const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.drafts}/${encodeURIComponent(pending.draftId)}/${pending.action ?? 'save'}`), { method: 'POST', body: pending.body, key: pending.key });
|
|
1391
|
+
state.commit = await verifyMutationReadback(payload, pending.expected_relations, pending.action ?? 'save');
|
|
1392
|
+
if (state.commit.item) state.selected = state.commit.item;
|
|
1393
|
+
onCommitted?.(state.commit);
|
|
1394
|
+
} catch (error) {
|
|
1395
|
+
state.commit = { ...state.commit, pending_save: pending, state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code };
|
|
1396
|
+
}
|
|
1397
|
+
render(); return state.commit;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function expectedRelationsMatch(actualRelations, expectedRelations) {
|
|
1401
|
+
const actual = Array.isArray(actualRelations) ? actualRelations : [];
|
|
1402
|
+
return (Array.isArray(expectedRelations) ? expectedRelations : []).every((expected) => actual.some((edge) => {
|
|
1403
|
+
const sameEdge = edge?.relation === expected?.relation
|
|
1404
|
+
&& (edge?.to_id ?? edge?.target_id ?? edge?.id) === (expected?.to_id ?? expected?.target_id ?? expected?.id);
|
|
1405
|
+
if (!sameEdge) return false;
|
|
1406
|
+
return expected?.payload === undefined || JSON.stringify(edge?.payload) === JSON.stringify(expected.payload);
|
|
1407
|
+
}));
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
async function verifyMutationReadback(payload, expectedRelations = [], action = 'save') {
|
|
1411
|
+
const readback = payload?.readback;
|
|
1412
|
+
const itemId = payload?.canonical?.id ?? payload?.item?.id ?? payload?.item_id ?? readback?.item_id ?? payload?.id;
|
|
1413
|
+
const expectedVersion = payload?.expected_version ?? readback?.version ?? payload?.canonical?.version ?? payload?.item?.version ?? payload?.version ?? null;
|
|
1414
|
+
const expectedHash = payload?.canonical_content_hash ?? readback?.content_hash ?? payload?.content_hash ?? payload?.item?.content_hash ?? null;
|
|
1415
|
+
if (!itemId) return { state: 'saved_unverified', item_id: null, version: expectedVersion, expected_hash: expectedHash };
|
|
1416
|
+
try {
|
|
1417
|
+
const current = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(itemId)}`));
|
|
1418
|
+
const item = normalizeKnowledgeItem(current?.item ?? current?.record ?? current?.data ?? current);
|
|
1419
|
+
const actualHash = current?.canonical_content_hash ?? current?.readback?.content_hash ?? current?.content_hash ?? item?.raw?.canonical_content_hash ?? item?.raw?.content_hash ?? null;
|
|
1420
|
+
const canonicalSaved = action === 'reuse'
|
|
1421
|
+
? payload?.persistence?.readback_verified === true && payload?.canonical?.id === itemId
|
|
1422
|
+
: payload?.persistence?.event_saved === true || current?.readback?.canonical_saved === true || current?.canonical_saved === true;
|
|
1423
|
+
const graphSaved = payload?.persistence?.graph_saved === true || current?.readback?.graph_saved === true || current?.graph_saved === true;
|
|
1424
|
+
const sourceFetched = item?.source?.state === 'fetched';
|
|
1425
|
+
const relationsMatch = expectedRelationsMatch(item?.relations, expectedRelations);
|
|
1426
|
+
const matches = payload?.persistence?.readback_verified === true && item?.id === itemId && expectedVersion !== null && item.version === expectedVersion
|
|
1427
|
+
&& expectedHash !== null && actualHash === expectedHash && canonicalSaved && graphSaved && sourceFetched && relationsMatch;
|
|
1428
|
+
return { state: matches ? 'verified' : 'saved_unverified', action, item_id: itemId, version: item?.version ?? expectedVersion, expected_version: expectedVersion, expected_hash: expectedHash, expected_relations: expectedRelations, relations_match: relationsMatch, receipt_verified: payload?.persistence?.readback_verified === true && canonicalSaved && graphSaved, item };
|
|
1429
|
+
} catch { return { state: 'saved_unverified', action, item_id: itemId, version: expectedVersion, expected_version: expectedVersion, expected_hash: expectedHash, expected_relations: expectedRelations, receipt_verified: false }; }
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function readbackCommit() {
|
|
1433
|
+
if (!state.commit.item_id) { state.commit = { ...state.commit, state: 'unknown', message: 'readback対象IDを確認できません。' }; render(); return null; }
|
|
1434
|
+
try { const payload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.commit.item_id)}`)); const item = normalizeKnowledgeItem(payload?.item ?? payload?.record ?? payload?.data ?? payload); const actualHash = payload?.canonical_content_hash ?? payload?.readback?.content_hash ?? payload?.content_hash ?? item?.raw?.canonical_content_hash ?? item?.raw?.content_hash ?? null; const relationsMatch = expectedRelationsMatch(item?.relations, state.commit.expected_relations); const matches = state.commit.receipt_verified === true && item?.id === state.commit.item_id && state.commit.expected_version !== null && item?.version === state.commit.expected_version && state.commit.expected_hash !== null && actualHash === state.commit.expected_hash && item?.source?.state === 'fetched' && relationsMatch; state.commit = { ...state.commit, state: matches ? 'verified' : 'saved_unverified', relations_match: relationsMatch, item, version: item?.version ?? state.commit.version }; if (item) state.selected = item; }
|
|
1435
|
+
catch (error) { state.commit = { ...state.commit, state: 'saved_unverified', message: apiErrorText(error) }; }
|
|
1436
|
+
render(); return state.commit;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
async function reviseItem(change) {
|
|
1440
|
+
if (!state.selected?.id) return null;
|
|
1441
|
+
const supplied = serializeRevision ? serializeRevision(change, state.selected) : change;
|
|
1442
|
+
if (typeof supplied.content !== 'string' || !supplied.content.trim()) {
|
|
1443
|
+
state.lifecycle = { state: 'error_retryable', action: 'revision', revisionDraft: supplied, message: '正本の本文を取得してから改訂してください。要旨だけでは保存できません。', error: 'canonical_content_missing' };
|
|
1444
|
+
render(); return state.lifecycle;
|
|
1445
|
+
}
|
|
1446
|
+
state.lifecycle = { ...state.lifecycle, state: 'saving', action: 'revision', revisionDraft: supplied }; render();
|
|
1447
|
+
try {
|
|
1448
|
+
const body = { expected_version: supplied.expected_version ?? state.selected.version, reason: supplied.reason, content: supplied.content,
|
|
1449
|
+
...(supplied.title ? { title: supplied.title } : {}), ...(supplied.scope !== undefined ? { scope: supplied.scope } : {}),
|
|
1450
|
+
...(supplied.owner_person_id !== undefined ? { owner_person_id: supplied.owner_person_id } : {}),
|
|
1451
|
+
...(supplied.effective_at !== undefined ? { effective_at: supplied.effective_at } : {}),
|
|
1452
|
+
...(supplied.expires_at !== undefined ? { expires_at: supplied.expires_at } : {}) };
|
|
1453
|
+
const mutation = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.selected.id)}/revisions`), { method: 'POST', body, key: operationKey('revision', state.selected.id, state.selected.version, body) });
|
|
1454
|
+
const expectedRaw = mutation?.item ?? mutation?.record ?? mutation?.data ?? mutation;
|
|
1455
|
+
const expected = normalizeKnowledgeItem(expectedRaw);
|
|
1456
|
+
const expectedHash = mutation?.canonical_content_hash ?? mutation?.readback?.content_hash ?? mutation?.content_hash ?? expected?.raw?.canonical_content_hash ?? expected?.raw?.content_hash ?? null;
|
|
1457
|
+
const readbackPayload = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.selected.id)}`));
|
|
1458
|
+
const item = normalizeKnowledgeItem(readbackPayload?.item ?? readbackPayload?.record ?? readbackPayload?.data ?? readbackPayload);
|
|
1459
|
+
const actualHash = readbackPayload?.canonical_content_hash ?? readbackPayload?.readback?.content_hash ?? readbackPayload?.content_hash ?? item?.raw?.canonical_content_hash ?? item?.raw?.content_hash ?? null;
|
|
1460
|
+
const expectedSource = expected?.raw ?? {};
|
|
1461
|
+
const sameReturnedFields = ['title', 'summary', 'content', 'scope', 'owner', 'applicability', 'lifecycle'].every((key) => !Object.prototype.hasOwnProperty.call(expectedSource, key) || JSON.stringify(item?.[key]) === JSON.stringify(expected?.[key]));
|
|
1462
|
+
const matches = expected?.id === state.selected.id && expected.version !== null && item?.id === expected.id && item?.version === expected.version && expectedHash !== null && actualHash === expectedHash && sameReturnedFields;
|
|
1463
|
+
if (item) { state.selected = item; state.detail = { state: 'ready', item }; }
|
|
1464
|
+
state.lifecycle = { state: matches ? 'verified' : 'saved_unverified', action: 'revision', item_id: expected?.id ?? state.selected?.id, version: item?.version, expected_version: expected?.version, expected_hash: expectedHash, item };
|
|
1465
|
+
} catch (error) { state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', action: 'revision', revisionDraft: supplied, message: apiErrorText(error), error: error.code }; }
|
|
1466
|
+
render(); return state.lifecycle;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
async function retireItem(change) {
|
|
1470
|
+
if (!state.selected?.id) return null;
|
|
1471
|
+
state.lifecycle = { ...state.lifecycle, state: 'saving' }; render();
|
|
1472
|
+
try {
|
|
1473
|
+
const supplied = serializeRetire ? serializeRetire(change, state.selected) : change;
|
|
1474
|
+
const body = { reason: supplied.reason, state: 'retired', expected_version: state.selected.version };
|
|
1475
|
+
await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.selected.id)}/${KNOWLEDGE_ROUTES.lifecycle}`), { method: 'POST', body, key: operationKey('retire', state.selected.id, state.selected.version, body) });
|
|
1476
|
+
const current = await callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.selected.id)}`));
|
|
1477
|
+
const item = normalizeKnowledgeItem(current?.item ?? current?.record ?? current?.data ?? current);
|
|
1478
|
+
const result = { state: item?.id === state.selected.id && item.lifecycle.status === 'retired' ? 'verified' : 'saved_unverified', item_id: state.selected.id, version: item?.version ?? state.selected.version, item };
|
|
1479
|
+
state.lifecycle = { ...result, action: 'retire' }; if (result.item) { state.selected = result.item; state.detail = { state: 'ready', item: result.item }; } onRetired?.(result);
|
|
1480
|
+
} catch (error) { state.lifecycle = { state: error.code === 'version_conflict' ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
|
|
1481
|
+
render(); return state.lifecycle;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
async function supersedeItem(change) {
|
|
1485
|
+
if (!state.selected?.id) return null;
|
|
1486
|
+
const supplied = { ...change };
|
|
1487
|
+
state.lifecycle = { ...state.lifecycle, state: 'saving', action: 'supersession', supersessionDraft: supplied }; render();
|
|
1488
|
+
try {
|
|
1489
|
+
const body = { superseded_id: supplied.superseded_id, replacement_expected_version: supplied.replacement_expected_version ?? state.selected.version,
|
|
1490
|
+
superseded_expected_version: supplied.superseded_expected_version, effective_at: supplied.effective_at, reason: supplied.reason };
|
|
1491
|
+
const mutation = await callKnowledgeApi(apiMutation, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.selected.id)}/${KNOWLEDGE_ROUTES.supersessions}`), { method: 'POST', body, key: operationKey('supersession', state.selected.id, state.selected.version, body) });
|
|
1492
|
+
const expectedReplacement = normalizeKnowledgeItem(mutation?.replacement);
|
|
1493
|
+
const expectedSuperseded = normalizeKnowledgeItem(mutation?.superseded);
|
|
1494
|
+
const [replacementPayload, supersededPayload] = await Promise.all([
|
|
1495
|
+
callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(state.selected.id)}`)),
|
|
1496
|
+
callKnowledgeApi(api, knowledgePath(code, `${KNOWLEDGE_ROUTES.items}/${encodeURIComponent(supplied.superseded_id)}`)),
|
|
1497
|
+
]);
|
|
1498
|
+
const replacement = normalizeKnowledgeItem(replacementPayload?.item ?? replacementPayload?.record ?? replacementPayload?.data ?? replacementPayload);
|
|
1499
|
+
const superseded = normalizeKnowledgeItem(supersededPayload?.item ?? supersededPayload?.record ?? supersededPayload?.data ?? supersededPayload);
|
|
1500
|
+
const relationMatches = replacement?.relations?.some((edge) => edge?.relation === 'supersedes' && edge?.from_id === replacement.id && edge?.to_id === superseded.id && edge?.effective_at === supplied.effective_at);
|
|
1501
|
+
const matches = expectedReplacement?.version !== null && expectedSuperseded?.version !== null
|
|
1502
|
+
&& replacement?.version === expectedReplacement.version && superseded?.version === expectedSuperseded.version
|
|
1503
|
+
&& replacement?.lifecycle?.effective_at === supplied.effective_at && superseded?.lifecycle?.expires_at === supplied.effective_at && relationMatches;
|
|
1504
|
+
if (replacement) { state.selected = replacement; state.detail = { state: 'ready', item: replacement }; }
|
|
1505
|
+
state.lifecycle = { state: matches ? 'verified' : 'saved_unverified', action: 'supersession', item_id: replacement?.id, version: replacement?.version, superseded_item_id: superseded?.id, item: replacement };
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
state.lifecycle = { state: isVersionConflict(error) ? 'conflict' : error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', action: 'supersession', supersessionDraft: supplied, message: apiErrorText(error), error: error.code };
|
|
1508
|
+
}
|
|
1509
|
+
render(); return state.lifecycle;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async function runPreview(input) {
|
|
1513
|
+
state.preview = { ...state.preview, ...input, state: 'loading' }; state.view = 'preview'; render();
|
|
1514
|
+
try {
|
|
1515
|
+
const savedDraftId = state.candidate.draft_id ?? state.draft.draft_id ?? state.draft.id;
|
|
1516
|
+
const candidate = state.candidate.candidate ?? state.candidate;
|
|
1517
|
+
const selectedItem = state.preview.item_id && state.selected?.id === state.preview.item_id ? state.selected : null;
|
|
1518
|
+
const draftId = savedDraftId ?? selectedItem?.id;
|
|
1519
|
+
const draftVersion = savedDraftId
|
|
1520
|
+
? state.draft.revision ?? state.draft.version ?? state.candidate.draft_revision ?? state.candidate.version
|
|
1521
|
+
: selectedItem?.version;
|
|
1522
|
+
const draftContent = savedDraftId
|
|
1523
|
+
? state.draft.content ?? candidate.content ?? candidate.summary ?? state.draft.input_text
|
|
1524
|
+
: selectedItem?.content;
|
|
1525
|
+
if (!draftId || draftVersion === null || draftVersion === undefined || !String(draftVersion).trim() || !optionalText(draftContent).trim()) {
|
|
1526
|
+
state.preview = { ...state.preview, state: 'unknown', message: selectedItem && !optionalText(selectedItem.content).trim()
|
|
1527
|
+
? '試験対象の正本全文を確認できません。要旨だけでは試験しません。'
|
|
1528
|
+
: '試験対象のID・版・全文を確認できません。' };
|
|
1529
|
+
render(); return null;
|
|
1530
|
+
}
|
|
1531
|
+
const previewVersion = typeof draftVersion === 'string' ? draftVersion : String(draftVersion);
|
|
1532
|
+
const serializedBody = serializePreview ? serializePreview(input, state.candidate) : {
|
|
1533
|
+
question: input.question,
|
|
1534
|
+
scenario: input.context ?? input.scenario ?? null,
|
|
1535
|
+
draft_version: previewVersion,
|
|
1536
|
+
draft: {
|
|
1537
|
+
id: draftId, version: previewVersion,
|
|
1538
|
+
title: savedDraftId ? candidate.title ?? state.draft.title ?? '' : selectedItem.title ?? '',
|
|
1539
|
+
summary: savedDraftId ? candidate.summary ?? state.draft.summary ?? '' : selectedItem.summary ?? '',
|
|
1540
|
+
content: draftContent,
|
|
1541
|
+
applicability_conditions: savedDraftId ? candidate.applicability ?? state.draft.applicability ?? null : selectedItem.applicability?.conditions ?? null,
|
|
1542
|
+
},
|
|
1543
|
+
};
|
|
1544
|
+
const body = {
|
|
1545
|
+
...serializedBody,
|
|
1546
|
+
...(input.sample_answer !== undefined ? { sample_answer: input.sample_answer } : {}),
|
|
1547
|
+
draft_version: previewVersion,
|
|
1548
|
+
draft: { ...(serializedBody?.draft ?? {}), id: serializedBody?.draft?.id ?? draftId, version: previewVersion },
|
|
1549
|
+
};
|
|
1550
|
+
const payload = await callKnowledgeApi(apiMutation, knowledgePath(code, KNOWLEDGE_ROUTES.preview), { method: 'POST', body, key: operationKey('preview', draftId, previewVersion, body) });
|
|
1551
|
+
state.preview = { ...state.preview, ...(payload?.preview ?? payload?.data ?? payload), state: 'ready' };
|
|
1552
|
+
} catch (error) { state.preview = { ...state.preview, state: error.code === 'permission_denied' ? 'permission_denied' : 'error_retryable', message: apiErrorText(error), error: error.code }; }
|
|
1553
|
+
render(); return state.preview;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const controller = { state, render, loadItems, loadAuthorityDomains, loadDestination, saveDestination, selectItem, createDraft, resumeDraft, discardDraft, applyProposal, saveCandidate, commitCandidate, retryPendingSave, readbackCommit, reviseItem, supersedeItem, retireItem, runPreview, paths: { items: () => buildKnowledgeItemsPath(code, state.filters), drafts: () => knowledgePath(code, KNOWLEDGE_ROUTES.drafts), destination: destinationPath } };
|
|
1557
|
+
render();
|
|
1558
|
+
const initialLocation = options.location ?? globalThis.location;
|
|
1559
|
+
if (initialLocation?.href) {
|
|
1560
|
+
const initialUrl = new URL(initialLocation.href);
|
|
1561
|
+
const initialDraftId = initialUrl.searchParams.get('knowledge_draft');
|
|
1562
|
+
if (initialDraftId && initialUrl.searchParams.get('knowledge_project') === code) void resumeDraft(initialDraftId);
|
|
1563
|
+
}
|
|
1564
|
+
if (options.autoLoad !== false) void loadItems();
|
|
1565
|
+
if (options.autoLoad !== false) void loadAuthorityDomains();
|
|
1566
|
+
if (options.autoLoad !== false && destinationPath()) void loadDestination();
|
|
1567
|
+
return controller;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
export const createKnowledgeOutcomeModule = createKnowledgeOutcomeController;
|