@unson/brainbase-mcp 0.17.1 → 0.18.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/dist/local-web-host.js +4 -1
- package/dist/value-proof-review-http.js +8 -4
- package/package.json +4 -2
- package/ui/judgment-history.css +215 -0
- package/ui/judgment-history.js +823 -0
- package/ui/local-web-shell.js +4 -4
package/dist/local-web-host.js
CHANGED
|
@@ -164,7 +164,10 @@ export function createValueProofModule(context) {
|
|
|
164
164
|
});
|
|
165
165
|
return {
|
|
166
166
|
id: 'value-proofs',
|
|
167
|
-
|
|
167
|
+
// The history screen is the local shell's current entry point. Keep the
|
|
168
|
+
// legacy assets listed as well: the old public UI export remains a
|
|
169
|
+
// supported compatibility route for downstream consumers.
|
|
170
|
+
uiFiles: ['judgment-history.js', 'judgment-history.css', 'value-proof-review.js', 'value-proof-review.css'],
|
|
168
171
|
handle: handler
|
|
169
172
|
};
|
|
170
173
|
}
|
|
@@ -14,6 +14,8 @@ const UI_FILES = Object.freeze({
|
|
|
14
14
|
'/ui/brainbase-tokens.css': { file: 'brainbase-tokens.css', type: 'text/css; charset=utf-8' },
|
|
15
15
|
'/ui/workspace-kit.js': { file: 'workspace-kit.js', type: 'text/javascript; charset=utf-8' },
|
|
16
16
|
'/ui/workspace-kit.css': { file: 'workspace-kit.css', type: 'text/css; charset=utf-8' },
|
|
17
|
+
'/ui/judgment-history.js': { file: 'judgment-history.js', type: 'text/javascript; charset=utf-8' },
|
|
18
|
+
'/ui/judgment-history.css': { file: 'judgment-history.css', type: 'text/css; charset=utf-8' },
|
|
17
19
|
'/ui/value-proof-review.js': { file: 'value-proof-review.js', type: 'text/javascript; charset=utf-8' },
|
|
18
20
|
'/ui/value-proof-review.css': { file: 'value-proof-review.css', type: 'text/css; charset=utf-8' }
|
|
19
21
|
});
|
|
@@ -182,9 +184,10 @@ function shellHtml(token, basePath) {
|
|
|
182
184
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
183
185
|
<meta name="brainbase-review-token" content="${escapeAttribute(token)}">
|
|
184
186
|
<meta name="brainbase-review-base-path" content="${escapeAttribute(basePath)}">
|
|
185
|
-
<title>Brainbase
|
|
187
|
+
<title>Brainbase 判断の履歴</title>
|
|
186
188
|
<link rel="stylesheet" href="/ui/brainbase-tokens.css">
|
|
187
189
|
<link rel="stylesheet" href="/ui/workspace-kit.css">
|
|
190
|
+
<link rel="stylesheet" href="/ui/judgment-history.css">
|
|
188
191
|
<link rel="stylesheet" href="/ui/value-proof-review.css">
|
|
189
192
|
</head>
|
|
190
193
|
<body>
|
|
@@ -194,12 +197,13 @@ function shellHtml(token, basePath) {
|
|
|
194
197
|
</html>
|
|
195
198
|
`;
|
|
196
199
|
}
|
|
197
|
-
const BOOTSTRAP_JS = `import {
|
|
200
|
+
const BOOTSTRAP_JS = `import { createJudgmentHistoryUI } from '/ui/judgment-history.js';
|
|
198
201
|
const meta = (name) => document.querySelector(\`meta[name="\${name}"]\`)?.getAttribute('content') ?? '';
|
|
199
|
-
|
|
202
|
+
createJudgmentHistoryUI({
|
|
200
203
|
root: document.getElementById('brainbase-value-proof-review'),
|
|
201
204
|
basePath: meta('brainbase-review-base-path'),
|
|
202
|
-
token: meta('brainbase-review-token')
|
|
205
|
+
token: meta('brainbase-review-token'),
|
|
206
|
+
page: { crumbs: ['あなたのBrainbase', '判断の履歴'], source: '判断の記録' }
|
|
203
207
|
});
|
|
204
208
|
`;
|
|
205
209
|
/** Local single-owner host. The caller owns `listen()` and must bind to a loopback address. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -219,6 +219,8 @@
|
|
|
219
219
|
"./ui/objective-editor.css": "./ui/objective-editor.css",
|
|
220
220
|
"./ui/value-proof-review": "./ui/value-proof-review.js",
|
|
221
221
|
"./ui/value-proof-review.css": "./ui/value-proof-review.css",
|
|
222
|
+
"./ui/judgment-history": "./ui/judgment-history.js",
|
|
223
|
+
"./ui/judgment-history.css": "./ui/judgment-history.css",
|
|
222
224
|
"./ui/objective-editor-http-port": "./ui/objective-editor-http-port.js",
|
|
223
225
|
"./ui/world-model-view": "./ui/world-model-view.js",
|
|
224
226
|
"./ui/world-model-view.css": "./ui/world-model-view.css",
|
|
@@ -359,5 +361,5 @@
|
|
|
359
361
|
"engines": {
|
|
360
362
|
"node": ">=20"
|
|
361
363
|
},
|
|
362
|
-
"gitHead": "
|
|
364
|
+
"gitHead": "0913d58555fe29ce69b49bb85bf6e21b6c9ed6d9"
|
|
363
365
|
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/* Judgment history uses the organization workspace kit and shared tokens. */
|
|
2
|
+
|
|
3
|
+
.bb-jh {
|
|
4
|
+
min-width: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.bb-jh-controls {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-wrap: wrap;
|
|
10
|
+
align-items: center;
|
|
11
|
+
justify-content: space-between;
|
|
12
|
+
gap: 14px 20px;
|
|
13
|
+
margin: 28px 0 20px;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.bb-jh-reference-note {
|
|
17
|
+
margin: -4px 0 18px;
|
|
18
|
+
color: var(--bb-color-muted);
|
|
19
|
+
font-size: 12px;
|
|
20
|
+
line-height: 1.55;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.bb-jh-periods {
|
|
24
|
+
display: flex;
|
|
25
|
+
flex-wrap: wrap;
|
|
26
|
+
gap: 6px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.bb-jh-search {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex: 1 1 230px;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 9px;
|
|
34
|
+
max-width: 330px;
|
|
35
|
+
color: var(--bb-color-muted);
|
|
36
|
+
font-size: 12px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.bb-jh-search span {
|
|
40
|
+
position: absolute;
|
|
41
|
+
width: 1px;
|
|
42
|
+
height: 1px;
|
|
43
|
+
overflow: hidden;
|
|
44
|
+
clip: rect(0 0 0 0);
|
|
45
|
+
clip-path: inset(50%);
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.bb-jh-search input {
|
|
50
|
+
width: 100%;
|
|
51
|
+
min-height: 38px;
|
|
52
|
+
border: 1px solid var(--bb-color-border-strong);
|
|
53
|
+
border-radius: var(--bb-radius-sm);
|
|
54
|
+
padding: 8px 12px;
|
|
55
|
+
background: var(--bb-color-panel);
|
|
56
|
+
color: var(--bb-color-ink);
|
|
57
|
+
font: inherit;
|
|
58
|
+
outline: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.bb-jh-search input:focus {
|
|
62
|
+
border-color: var(--bb-color-accent);
|
|
63
|
+
box-shadow: 0 0 0 2px var(--bb-color-accent-soft);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.bb-jh .bb-ws-summary {
|
|
67
|
+
margin-top: 22px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.bb-jh-ledger {
|
|
71
|
+
border-top-color: var(--bb-color-border-strong);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.bb-jh-ledger .bb-ws-ledger-row {
|
|
75
|
+
grid-template-columns: 124px minmax(190px, 1.05fr) minmax(260px, 1.8fr);
|
|
76
|
+
align-items: stretch;
|
|
77
|
+
min-height: 76px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.bb-jh-ledger .bb-ws-ledger-head {
|
|
81
|
+
align-items: center;
|
|
82
|
+
min-height: 38px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.bb-jh-ledger .bb-ws-ledger-row > span {
|
|
86
|
+
display: flex;
|
|
87
|
+
min-width: 0;
|
|
88
|
+
align-items: center;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
white-space: normal;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.bb-jh-ledger .bb-ws-ledger-head > span {
|
|
94
|
+
align-items: center;
|
|
95
|
+
white-space: nowrap;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.bb-jh-ledger .bb-ws-ledger-row > span:first-child {
|
|
99
|
+
padding-left: 8px;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.bb-jh-date {
|
|
103
|
+
color: var(--bb-color-muted);
|
|
104
|
+
font-variant-numeric: tabular-nums;
|
|
105
|
+
white-space: nowrap !important;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.bb-jh-reference-summary,
|
|
109
|
+
.bb-jh-decision {
|
|
110
|
+
display: grid;
|
|
111
|
+
min-width: 0;
|
|
112
|
+
gap: 4px;
|
|
113
|
+
line-height: 1.45;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.bb-jh-reference-summary strong,
|
|
117
|
+
.bb-jh-decision strong {
|
|
118
|
+
overflow-wrap: anywhere;
|
|
119
|
+
color: var(--bb-color-ink);
|
|
120
|
+
font-weight: 680;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.bb-jh-reference-summary small,
|
|
124
|
+
.bb-jh-decision small {
|
|
125
|
+
color: var(--bb-color-muted);
|
|
126
|
+
font-size: 11px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.bb-jh-decision.is-delegated small {
|
|
130
|
+
color: var(--bb-color-accent-strong);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.bb-jh-unrecorded {
|
|
134
|
+
color: var(--bb-color-quiet);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.bb-jh-reference-list {
|
|
138
|
+
display: grid;
|
|
139
|
+
gap: 12px;
|
|
140
|
+
margin: 0;
|
|
141
|
+
padding: 0;
|
|
142
|
+
list-style: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.bb-jh-reference-list li {
|
|
146
|
+
display: grid;
|
|
147
|
+
gap: 3px;
|
|
148
|
+
min-width: 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.bb-jh-reference-list strong {
|
|
152
|
+
color: var(--bb-color-ink);
|
|
153
|
+
font-size: 13px;
|
|
154
|
+
overflow-wrap: anywhere;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.bb-jh-reference-list code {
|
|
158
|
+
color: var(--bb-color-quiet);
|
|
159
|
+
font-family: var(--bb-font-mono);
|
|
160
|
+
font-size: 10px;
|
|
161
|
+
overflow-wrap: anywhere;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.bb-jh-reference-list span {
|
|
165
|
+
color: var(--bb-color-muted);
|
|
166
|
+
font-size: 12px;
|
|
167
|
+
line-height: 1.5;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.bb-jh .bb-ws-definition dd > span {
|
|
171
|
+
display: block;
|
|
172
|
+
line-height: 1.5;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (max-width: 760px) {
|
|
176
|
+
.bb-jh-controls {
|
|
177
|
+
align-items: stretch;
|
|
178
|
+
flex-direction: column;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.bb-jh-search {
|
|
182
|
+
max-width: none;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.bb-jh-ledger .bb-ws-ledger-head {
|
|
186
|
+
display: none;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.bb-jh-ledger .bb-ws-ledger-row {
|
|
190
|
+
grid-template-columns: minmax(0, 1fr);
|
|
191
|
+
gap: 0;
|
|
192
|
+
min-height: 0;
|
|
193
|
+
padding: 10px 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.bb-jh-ledger .bb-ws-ledger-row > span,
|
|
197
|
+
.bb-jh-ledger .bb-ws-ledger-row > span:first-child {
|
|
198
|
+
min-height: 0;
|
|
199
|
+
padding: 6px 12px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.bb-jh-ledger .bb-ws-ledger-row > span::before {
|
|
203
|
+
flex: 0 0 82px;
|
|
204
|
+
color: var(--bb-color-quiet);
|
|
205
|
+
content: '';
|
|
206
|
+
font-size: 10px;
|
|
207
|
+
font-weight: 700;
|
|
208
|
+
letter-spacing: .06em;
|
|
209
|
+
text-transform: uppercase;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.bb-jh-ledger .bb-ws-ledger-row > span:nth-child(1)::before { content: '日時'; }
|
|
213
|
+
.bb-jh-ledger .bb-ws-ledger-row > span:nth-child(2)::before { content: '使った参照'; }
|
|
214
|
+
.bb-jh-ledger .bb-ws-ledger-row > span:nth-child(3)::before { content: '行った判断'; }
|
|
215
|
+
}
|
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Read-only judgment history for the local and organization workspaces.
|
|
3
|
+
*
|
|
4
|
+
* The host already owns the journal read model. This module deliberately
|
|
5
|
+
* projects that model in the browser instead of creating another ledger. It
|
|
6
|
+
* keeps the projection pure (`aggregateJudgmentHistory`) and keeps the DOM
|
|
7
|
+
* controller small enough to use from both the local shell and the standalone
|
|
8
|
+
* review host.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
makeWorkspaceElement,
|
|
13
|
+
workspaceButton,
|
|
14
|
+
workspaceDefinition,
|
|
15
|
+
workspaceDetailEmpty,
|
|
16
|
+
workspaceLedger,
|
|
17
|
+
workspaceMetrics,
|
|
18
|
+
workspaceNotice,
|
|
19
|
+
workspacePageHeader,
|
|
20
|
+
workspaceRailBlock,
|
|
21
|
+
workspaceRailHead,
|
|
22
|
+
} from './workspace-kit.js';
|
|
23
|
+
|
|
24
|
+
export const JUDGMENT_HISTORY_UI_CONTRACT_VERSION = 'brainbase.judgment-history-ui.v1';
|
|
25
|
+
|
|
26
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const JST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
28
|
+
const PERIODS = Object.freeze({ week: 'week', past30days: 'past30days', all: 'all' });
|
|
29
|
+
const PERIOD_LABELS = Object.freeze({ week: '今週', past30days: '過去30日', all: '全期間' });
|
|
30
|
+
const SECTION_KEYS = Object.freeze(['needs_human', 'blocked', 'continued', 'other']);
|
|
31
|
+
|
|
32
|
+
function isRecord(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function text(value) {
|
|
37
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findFirst(node, predicate) {
|
|
41
|
+
if (!node) return null;
|
|
42
|
+
if (predicate(node)) return node;
|
|
43
|
+
for (const child of node.children ?? []) {
|
|
44
|
+
const match = findFirst(child, predicate);
|
|
45
|
+
if (match) return match;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function replaceNode(nextNode, previousNode) {
|
|
51
|
+
const parent = nextNode?.parentNode;
|
|
52
|
+
if (!parent || !previousNode) return false;
|
|
53
|
+
if (typeof parent.replaceChild === 'function') {
|
|
54
|
+
parent.replaceChild(previousNode, nextNode);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
const children = parent.children;
|
|
58
|
+
const index = children && typeof children.indexOf === 'function' ? children.indexOf(nextNode) : -1;
|
|
59
|
+
if (index < 0) return false;
|
|
60
|
+
children[index] = previousNode;
|
|
61
|
+
previousNode.parentNode = parent;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function attributeValue(node, name) {
|
|
66
|
+
if (!node) return null;
|
|
67
|
+
if (typeof node.getAttribute === 'function') return node.getAttribute(name);
|
|
68
|
+
const attributes = node.attributes;
|
|
69
|
+
if (!attributes) return null;
|
|
70
|
+
if (typeof attributes.getNamedItem === 'function') return attributes.getNamedItem(name)?.value ?? null;
|
|
71
|
+
return attributes[name] ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function nonNegativeInteger(value) {
|
|
75
|
+
return Number.isInteger(value) && value >= 0 ? value : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function timestamp(value) {
|
|
79
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
80
|
+
const ms = Date.parse(value);
|
|
81
|
+
return Number.isFinite(ms) ? ms : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeCoverage(coverage, status) {
|
|
85
|
+
const source = isRecord(coverage) ? coverage : {};
|
|
86
|
+
const state = text(source.status ?? source.state).toLowerCase();
|
|
87
|
+
const complete = source.complete !== false && state !== 'partial' && state !== 'unknown';
|
|
88
|
+
const saved = nonNegativeInteger(source.saved ?? source.read ?? source.count);
|
|
89
|
+
const rejected = nonNegativeInteger(source.rejected ?? source.rejected_count);
|
|
90
|
+
return {
|
|
91
|
+
saved,
|
|
92
|
+
rejected,
|
|
93
|
+
latestRecordedAt: text(source.latest_recorded_at ?? source.latestRecordedAt) || null,
|
|
94
|
+
possiblyStalled: source.possibly_stalled === true || source.possiblyStalled === true,
|
|
95
|
+
complete: status !== 'partial' && complete,
|
|
96
|
+
reason: text(source.reason ?? source.message) || null,
|
|
97
|
+
state: state || (status === 'partial' ? 'partial' : 'complete'),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isProof(value) {
|
|
102
|
+
return isRecord(value)
|
|
103
|
+
&& text(value.intent_id)
|
|
104
|
+
&& text(value.decision_attempt_id)
|
|
105
|
+
&& timestamp(value.recorded_at) !== null
|
|
106
|
+
&& isRecord(value.interruption)
|
|
107
|
+
&& isRecord(value.decision);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeSectionItems(sections) {
|
|
111
|
+
if (!isRecord(sections)) return null;
|
|
112
|
+
const result = [];
|
|
113
|
+
let rawCount = 0;
|
|
114
|
+
let invalidCount = 0;
|
|
115
|
+
for (const key of SECTION_KEYS) {
|
|
116
|
+
if (!Object.prototype.hasOwnProperty.call(sections, key) || !Array.isArray(sections[key])) return null;
|
|
117
|
+
const entries = sections[key];
|
|
118
|
+
rawCount += entries.length;
|
|
119
|
+
for (const item of entries) {
|
|
120
|
+
const proof = isRecord(item) && isProof(item.proof) ? item.proof : isProof(item) ? item : null;
|
|
121
|
+
if (!proof) {
|
|
122
|
+
invalidCount += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
result.push({
|
|
126
|
+
section: text(item?.section) || key,
|
|
127
|
+
proof,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { items: result, rawCount, invalidCount };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Validate the small public read model used by the history screen.
|
|
136
|
+
*
|
|
137
|
+
* Invalid records are ignored at the projection boundary. A malformed home
|
|
138
|
+
* response remains `invalid`, so the UI can explain that it could not read the
|
|
139
|
+
* data instead of turning the situation into a reassuring zero.
|
|
140
|
+
*/
|
|
141
|
+
export function normalizeJudgmentHistoryHome(payload) {
|
|
142
|
+
if (!isRecord(payload)) return { status: 'invalid', reason: 'response_not_object' };
|
|
143
|
+
if (payload.status === 'unavailable') {
|
|
144
|
+
return { status: 'unavailable', root: text(payload.root) || null, reason: text(payload.reason) || 'judgment_journal_unavailable' };
|
|
145
|
+
}
|
|
146
|
+
if (payload.status !== 'available' && payload.status !== 'partial') {
|
|
147
|
+
return { status: 'invalid', reason: 'unsupported_status' };
|
|
148
|
+
}
|
|
149
|
+
const sectionResult = normalizeSectionItems(payload.sections);
|
|
150
|
+
if (!sectionResult) return { status: 'invalid', reason: 'sections_missing' };
|
|
151
|
+
const rawCoverage = isRecord(payload.coverage) ? payload.coverage : {};
|
|
152
|
+
const saved = nonNegativeInteger(rawCoverage.saved);
|
|
153
|
+
const rejectedCount = nonNegativeInteger(rawCoverage.rejected);
|
|
154
|
+
if (saved === null || rejectedCount === null) return { status: 'invalid', reason: 'coverage_incomplete' };
|
|
155
|
+
const coverageState = text(rawCoverage.status ?? rawCoverage.state).toLowerCase();
|
|
156
|
+
const coverageMismatch = saved !== sectionResult.rawCount || rejectedCount !== (Array.isArray(payload.rejected) ? payload.rejected.length : 0);
|
|
157
|
+
const status = payload.status === 'partial' || rawCoverage.complete === false || coverageState === 'partial' || coverageState === 'unknown'
|
|
158
|
+
|| rejectedCount > 0 || coverageMismatch || sectionResult.invalidCount > 0
|
|
159
|
+
? 'partial'
|
|
160
|
+
: 'available';
|
|
161
|
+
const rejected = Array.isArray(payload.rejected)
|
|
162
|
+
? payload.rejected.filter(isRecord).map((entry) => ({ file: text(entry.file) || null, reason: text(entry.reason) || 'record_rejected' }))
|
|
163
|
+
: [];
|
|
164
|
+
return {
|
|
165
|
+
status,
|
|
166
|
+
root: text(payload.root) || null,
|
|
167
|
+
coverage: { ...normalizeCoverage(rawCoverage, status), saved, rejected: rejectedCount },
|
|
168
|
+
items: sectionResult.items,
|
|
169
|
+
rejected,
|
|
170
|
+
invalidCount: sectionResult.invalidCount,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function jstDayStart(nowMs) {
|
|
175
|
+
const jst = new Date(nowMs + JST_OFFSET_MS);
|
|
176
|
+
return Date.UTC(jst.getUTCFullYear(), jst.getUTCMonth(), jst.getUTCDate()) - JST_OFFSET_MS;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function periodStart(period, nowMs) {
|
|
180
|
+
const today = jstDayStart(nowMs);
|
|
181
|
+
if (period === PERIODS.all) return Number.NEGATIVE_INFINITY;
|
|
182
|
+
if (period === PERIODS.week) {
|
|
183
|
+
const jst = new Date(today + JST_OFFSET_MS);
|
|
184
|
+
return today - jst.getUTCDay() * DAY_MS + (jst.getUTCDay() === 0 ? -6 * DAY_MS : DAY_MS);
|
|
185
|
+
}
|
|
186
|
+
return today - 29 * DAY_MS;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function referenceId(value) {
|
|
190
|
+
return text(value?.entity_id ?? value?.ref ?? value?.id ?? value?.entityId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function referenceVersion(value) {
|
|
194
|
+
return text(value?.entity_version ?? value?.version ?? value?.revision ?? value?.source?.version) || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function addReference(map, candidate) {
|
|
198
|
+
if (!candidate.id) return;
|
|
199
|
+
const key = `${candidate.id}\u0000${candidate.version ?? ''}`;
|
|
200
|
+
const previous = map.get(key);
|
|
201
|
+
if (!previous) {
|
|
202
|
+
map.set(key, {
|
|
203
|
+
...candidate,
|
|
204
|
+
applications: candidate.application ? [candidate.application] : [],
|
|
205
|
+
labels: candidate.label ? [candidate.label] : [],
|
|
206
|
+
kinds: [candidate.kind],
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// A basis entry may name the application while its inherited source only
|
|
211
|
+
// has a label. Merge both so projection never loses useful context.
|
|
212
|
+
if (candidate.application && !previous.applications.includes(candidate.application)) previous.applications.push(candidate.application);
|
|
213
|
+
if (candidate.label && !previous.labels.includes(candidate.label)) previous.labels.push(candidate.label);
|
|
214
|
+
if (!previous.application && candidate.application) previous.application = candidate.application;
|
|
215
|
+
if (!previous.label && candidate.label) previous.label = candidate.label;
|
|
216
|
+
if (!previous.layer && candidate.layer) previous.layer = candidate.layer;
|
|
217
|
+
if (!previous.kinds.includes(candidate.kind)) previous.kinds.push(candidate.kind);
|
|
218
|
+
previous.kind = previous.kinds.length === 1 ? previous.kinds[0] : 'basis+inheritance';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function referencesForProof(proof) {
|
|
222
|
+
const map = new Map();
|
|
223
|
+
const decision = isRecord(proof.decision) ? proof.decision : {};
|
|
224
|
+
const basis = Array.isArray(decision.basis) ? decision.basis : [];
|
|
225
|
+
for (const entry of basis) {
|
|
226
|
+
if (!isRecord(entry)) continue;
|
|
227
|
+
const id = referenceId(entry);
|
|
228
|
+
const application = text(entry.application);
|
|
229
|
+
addReference(map, {
|
|
230
|
+
id,
|
|
231
|
+
version: referenceVersion(entry),
|
|
232
|
+
application: application || null,
|
|
233
|
+
label: application || id || '参照',
|
|
234
|
+
layer: text(entry.layer) || null,
|
|
235
|
+
kind: 'basis',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const inheritance = isRecord(decision.inheritance) ? decision.inheritance : {};
|
|
239
|
+
const sources = Array.isArray(inheritance.sources) ? inheritance.sources : [];
|
|
240
|
+
for (const entry of sources) {
|
|
241
|
+
if (!isRecord(entry)) continue;
|
|
242
|
+
const id = referenceId(entry);
|
|
243
|
+
const label = text(entry.label ?? entry.application);
|
|
244
|
+
addReference(map, {
|
|
245
|
+
id,
|
|
246
|
+
version: referenceVersion(entry),
|
|
247
|
+
application: label || null,
|
|
248
|
+
label: label || id || '継承した参照',
|
|
249
|
+
layer: text(entry.layer) || null,
|
|
250
|
+
kind: text(entry.kind) || 'inheritance',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return [...map.values()];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function proofSearchText(proof, references) {
|
|
257
|
+
const decision = isRecord(proof.decision) ? proof.decision : {};
|
|
258
|
+
const interruption = isRecord(proof.interruption) ? proof.interruption : {};
|
|
259
|
+
const humanDecision = isRecord(proof.human_decision) ? proof.human_decision : {};
|
|
260
|
+
const execution = isRecord(proof.execution) ? proof.execution : {};
|
|
261
|
+
const outcome = isRecord(proof.outcome) ? proof.outcome : {};
|
|
262
|
+
const options = Array.isArray(humanDecision.options) ? humanDecision.options : [];
|
|
263
|
+
const parts = [
|
|
264
|
+
proof.intent_id,
|
|
265
|
+
proof.decision_attempt_id,
|
|
266
|
+
proof.recorded_at,
|
|
267
|
+
interruption.resolution,
|
|
268
|
+
interruption.reason_code,
|
|
269
|
+
interruption.question_display_text,
|
|
270
|
+
interruption.human_reason,
|
|
271
|
+
decision.summary,
|
|
272
|
+
decision.work_impact,
|
|
273
|
+
humanDecision.question,
|
|
274
|
+
humanDecision.why_human,
|
|
275
|
+
...options.flatMap((option) => (isRecord(option) ? [option.label, option.impact] : [])),
|
|
276
|
+
execution.summary,
|
|
277
|
+
outcome.summary,
|
|
278
|
+
...references.flatMap((ref) => [
|
|
279
|
+
ref.id,
|
|
280
|
+
ref.version,
|
|
281
|
+
ref.application,
|
|
282
|
+
ref.label,
|
|
283
|
+
...(ref.applications ?? []),
|
|
284
|
+
...(ref.labels ?? []),
|
|
285
|
+
ref.layer,
|
|
286
|
+
ref.kind,
|
|
287
|
+
]),
|
|
288
|
+
];
|
|
289
|
+
return parts.filter(Boolean).join(' ').toLocaleLowerCase('ja-JP');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sortItems(items) {
|
|
293
|
+
return [...items].sort((left, right) => {
|
|
294
|
+
const at = timestamp(right.proof.recorded_at) - timestamp(left.proof.recorded_at);
|
|
295
|
+
if (at !== 0) return at;
|
|
296
|
+
const attempt = text(right.proof.decision_attempt_id).localeCompare(text(left.proof.decision_attempt_id));
|
|
297
|
+
if (attempt !== 0) return attempt;
|
|
298
|
+
return text(right.proof.intent_id).localeCompare(text(left.proof.intent_id));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function normalizedPeriod(period) {
|
|
303
|
+
return Object.values(PERIODS).includes(period) ? period : PERIODS.past30days;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function normalizedNow(now) {
|
|
307
|
+
const candidate = now instanceof Date ? now.getTime() : new Date(now ?? Date.now()).getTime();
|
|
308
|
+
return Number.isFinite(candidate) ? candidate : Date.now();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isDelegated(proof) {
|
|
312
|
+
return proof?.interruption?.resolution === 'continued_without_human' && Boolean(text(proof?.decision?.summary));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Project a home read model into one newest-first judgment history.
|
|
317
|
+
* `now`, `period` and `query` are options so the same data can be tested and
|
|
318
|
+
* rendered consistently by a host.
|
|
319
|
+
*/
|
|
320
|
+
export function aggregateJudgmentHistory(home, { now = new Date(), period = PERIODS.past30days, query = '' } = {}) {
|
|
321
|
+
const normalized = home?.items && (home.status === 'available' || home.status === 'partial')
|
|
322
|
+
? home
|
|
323
|
+
: normalizeJudgmentHistoryHome(home);
|
|
324
|
+
const empty = {
|
|
325
|
+
rows: [],
|
|
326
|
+
stats: {
|
|
327
|
+
judgments: null,
|
|
328
|
+
references: null,
|
|
329
|
+
equivalent: null,
|
|
330
|
+
delegated: null,
|
|
331
|
+
judgmentCount: null,
|
|
332
|
+
referenceCount: null,
|
|
333
|
+
equivalentJudgments: null,
|
|
334
|
+
},
|
|
335
|
+
visibleCount: 0,
|
|
336
|
+
totalCount: 0,
|
|
337
|
+
period: normalizedPeriod(period),
|
|
338
|
+
query: text(query),
|
|
339
|
+
status: normalized.status,
|
|
340
|
+
coverage: normalized.coverage ?? null,
|
|
341
|
+
rejected: normalized.rejected ?? [],
|
|
342
|
+
reason: normalized.reason ?? null,
|
|
343
|
+
};
|
|
344
|
+
if (normalized.status !== 'available' && normalized.status !== 'partial') return empty;
|
|
345
|
+
|
|
346
|
+
const nowMs = normalizedNow(now);
|
|
347
|
+
const sorted = sortItems(normalized.items);
|
|
348
|
+
const latestByIntent = new Map();
|
|
349
|
+
for (const item of sorted) {
|
|
350
|
+
const recorded = timestamp(item.proof.recorded_at);
|
|
351
|
+
if (recorded === null) continue;
|
|
352
|
+
const intentId = text(item.proof.intent_id);
|
|
353
|
+
if (!intentId || latestByIntent.has(intentId)) continue;
|
|
354
|
+
latestByIntent.set(intentId, item);
|
|
355
|
+
}
|
|
356
|
+
const start = periodStart(normalizedPeriod(period), nowMs);
|
|
357
|
+
const needle = text(query).toLocaleLowerCase('ja-JP');
|
|
358
|
+
const rows = [];
|
|
359
|
+
for (const item of latestByIntent.values()) {
|
|
360
|
+
const recorded = timestamp(item.proof.recorded_at);
|
|
361
|
+
if (recorded === null || recorded < start || recorded > nowMs) continue;
|
|
362
|
+
const references = referencesForProof(item.proof);
|
|
363
|
+
const searchText = proofSearchText(item.proof, references);
|
|
364
|
+
if (needle && !searchText.includes(needle)) continue;
|
|
365
|
+
rows.push({
|
|
366
|
+
key: `${item.proof.intent_id}\u0000${item.proof.decision_attempt_id}`,
|
|
367
|
+
intentId: item.proof.intent_id,
|
|
368
|
+
decisionAttemptId: item.proof.decision_attempt_id,
|
|
369
|
+
recordedAt: item.proof.recorded_at,
|
|
370
|
+
proof: item.proof,
|
|
371
|
+
section: item.section,
|
|
372
|
+
delegated: isDelegated(item.proof),
|
|
373
|
+
references,
|
|
374
|
+
searchText,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
rows.sort((left, right) => {
|
|
378
|
+
const date = timestamp(right.recordedAt) - timestamp(left.recordedAt);
|
|
379
|
+
return date || right.decisionAttemptId.localeCompare(left.decisionAttemptId);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const delegatedRows = rows.filter((row) => row.delegated);
|
|
383
|
+
const references = new Map();
|
|
384
|
+
for (const row of delegatedRows) {
|
|
385
|
+
for (const ref of row.references) {
|
|
386
|
+
const key = `${ref.id}\u0000${ref.version ?? ''}`;
|
|
387
|
+
if (!references.has(key)) references.set(key, ref);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// A partial read with no valid records cannot establish that the owner has
|
|
391
|
+
// zero judgments. Keep the filtered rows empty, but leave the headline
|
|
392
|
+
// metrics unknown until the reader can confirm an empty result.
|
|
393
|
+
const metricsUnconfirmed = normalized.status === 'partial' && normalized.items.length === 0;
|
|
394
|
+
const judgments = metricsUnconfirmed ? null : delegatedRows.length;
|
|
395
|
+
const referenceCount = metricsUnconfirmed ? null : references.size;
|
|
396
|
+
return {
|
|
397
|
+
...empty,
|
|
398
|
+
rows,
|
|
399
|
+
stats: {
|
|
400
|
+
judgments,
|
|
401
|
+
references: referenceCount,
|
|
402
|
+
equivalent: judgments,
|
|
403
|
+
delegated: judgments,
|
|
404
|
+
judgmentCount: judgments,
|
|
405
|
+
referenceCount,
|
|
406
|
+
equivalentJudgments: judgments,
|
|
407
|
+
},
|
|
408
|
+
visibleCount: rows.length,
|
|
409
|
+
totalCount: latestByIntent.size,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Friendly aliases for consumers that describe the operation as a projection.
|
|
414
|
+
export const projectJudgmentHistory = aggregateJudgmentHistory;
|
|
415
|
+
export const deriveJudgmentHistory = aggregateJudgmentHistory;
|
|
416
|
+
|
|
417
|
+
function formatDate(value) {
|
|
418
|
+
const date = new Date(value);
|
|
419
|
+
if (!Number.isFinite(date.getTime())) return '日時未記録';
|
|
420
|
+
try {
|
|
421
|
+
return new Intl.DateTimeFormat('ja-JP', {
|
|
422
|
+
timeZone: 'Asia/Tokyo', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false,
|
|
423
|
+
}).format(date);
|
|
424
|
+
} catch {
|
|
425
|
+
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function makeReferenceSummary(doc, references) {
|
|
430
|
+
if (!references.length) return makeWorkspaceElement(doc, 'span', { className: 'bb-jh-unrecorded', text: '参照は未記録' });
|
|
431
|
+
const wrap = makeWorkspaceElement(doc, 'span', { className: 'bb-jh-reference-summary' });
|
|
432
|
+
const first = references.slice(0, 2).map((ref) => ref.label || ref.id).join('、');
|
|
433
|
+
wrap.append(makeWorkspaceElement(doc, 'strong', { text: first }));
|
|
434
|
+
if (references.length > 2) wrap.append(makeWorkspaceElement(doc, 'small', { text: `ほか${references.length - 2}件` }));
|
|
435
|
+
return wrap;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function makeDecisionSummary(doc, row) {
|
|
439
|
+
const summary = text(row.proof?.decision?.summary);
|
|
440
|
+
const wrap = makeWorkspaceElement(doc, 'span', { className: `bb-jh-decision${row.delegated ? ' is-delegated' : ''}` });
|
|
441
|
+
wrap.append(makeWorkspaceElement(doc, 'strong', { text: summary || (row.proof?.interruption?.resolution === 'continued_without_human' ? '判断内容未記録' : '本人に確認') }));
|
|
442
|
+
if (row.delegated) wrap.append(makeWorkspaceElement(doc, 'small', { text: 'Brainbaseが代わりに判断' }));
|
|
443
|
+
else if (row.proof?.interruption?.resolution !== 'continued_without_human') wrap.append(makeWorkspaceElement(doc, 'small', { text: '本人に戻した判断' }));
|
|
444
|
+
return wrap;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function appendReferenceList(doc, references) {
|
|
448
|
+
if (!references.length) return makeWorkspaceElement(doc, 'p', { className: 'bb-jh-unrecorded', text: '参照は記録されていません。' });
|
|
449
|
+
const list = makeWorkspaceElement(doc, 'ul', { className: 'bb-jh-reference-list' });
|
|
450
|
+
for (const ref of references) {
|
|
451
|
+
const item = makeWorkspaceElement(doc, 'li');
|
|
452
|
+
item.append(makeWorkspaceElement(doc, 'strong', { text: ref.label || ref.id }));
|
|
453
|
+
item.append(makeWorkspaceElement(doc, 'code', { text: `${ref.id}${ref.version ? `@${ref.version}` : ''}` }));
|
|
454
|
+
if (ref.application && ref.application !== ref.label) item.append(makeWorkspaceElement(doc, 'span', { text: ref.application }));
|
|
455
|
+
const alternateLabels = (ref.labels ?? []).filter((label) => label && label !== ref.label);
|
|
456
|
+
if (alternateLabels.length) item.append(makeWorkspaceElement(doc, 'span', { text: `別名: ${alternateLabels.join('、')}` }));
|
|
457
|
+
list.append(item);
|
|
458
|
+
}
|
|
459
|
+
return list;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function appendSupplement(doc, proof) {
|
|
463
|
+
const execution = isRecord(proof.execution) ? proof.execution : {};
|
|
464
|
+
const outcome = isRecord(proof.outcome) ? proof.outcome : {};
|
|
465
|
+
const evidence = Array.isArray(outcome.evidence_refs) ? outcome.evidence_refs.filter(Boolean).join('、') : '';
|
|
466
|
+
return workspaceDefinition(doc, [
|
|
467
|
+
['実行', [text(execution.status) || '未記録', text(execution.summary)]],
|
|
468
|
+
['結果', [text(outcome.status) || '未記録', text(outcome.summary), evidence ? `証拠: ${evidence}` : '']],
|
|
469
|
+
['記録ID', proof.decision_attempt_id || null],
|
|
470
|
+
]);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function appendTextParts(doc, values) {
|
|
474
|
+
const wrap = makeWorkspaceElement(doc, 'div');
|
|
475
|
+
for (const value of values) {
|
|
476
|
+
if (text(value)) wrap.append(makeWorkspaceElement(doc, 'p', { text: value }));
|
|
477
|
+
}
|
|
478
|
+
return wrap;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function renderRail(doc, rail, row, onClose) {
|
|
482
|
+
if (!rail) return;
|
|
483
|
+
rail.replaceChildren();
|
|
484
|
+
if (!row) {
|
|
485
|
+
rail.append(workspaceDetailEmpty(doc, {
|
|
486
|
+
mark: '↗',
|
|
487
|
+
title: '履歴を選択',
|
|
488
|
+
text: '行を選ぶと、使った参照と行った判断を確認できます。',
|
|
489
|
+
}));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const proof = row.proof;
|
|
493
|
+
rail.append(workspaceRailHead(doc, {
|
|
494
|
+
kicker: row.delegated ? 'Brainbaseの判断' : '判断履歴',
|
|
495
|
+
title: text(proof.decision?.summary) || (row.delegated ? '判断内容未記録' : '本人に確認'),
|
|
496
|
+
sub: formatDate(row.recordedAt),
|
|
497
|
+
}));
|
|
498
|
+
if (typeof onClose === 'function') {
|
|
499
|
+
rail.append(workspaceButton(doc, {
|
|
500
|
+
text: '詳細を閉じる',
|
|
501
|
+
variant: 'quiet',
|
|
502
|
+
onClick: onClose,
|
|
503
|
+
attrs: { 'aria-label': '判断の詳細を閉じる' },
|
|
504
|
+
}));
|
|
505
|
+
}
|
|
506
|
+
rail.append(workspaceRailBlock(doc, {
|
|
507
|
+
title: '使った参照',
|
|
508
|
+
content: appendReferenceList(doc, row.references),
|
|
509
|
+
}));
|
|
510
|
+
rail.append(workspaceRailBlock(doc, {
|
|
511
|
+
title: '行った判断',
|
|
512
|
+
content: appendTextParts(doc, [
|
|
513
|
+
text(proof.decision?.summary) || '判断内容は未記録です。',
|
|
514
|
+
row.delegated ? 'この判断は本人に戻さず進めました。' : 'この判断は本人に戻しています。',
|
|
515
|
+
]),
|
|
516
|
+
}));
|
|
517
|
+
rail.append(workspaceRailBlock(doc, { title: '実行と結果', content: appendSupplement(doc, proof) }));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function safeDocument(explicit) {
|
|
521
|
+
const doc = explicit ?? (typeof document === 'undefined' ? null : document);
|
|
522
|
+
if (!doc || typeof doc.createElement !== 'function') throw new Error('document_unavailable');
|
|
523
|
+
return doc;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function statusNotice(doc, phase, error, home, onRetry) {
|
|
527
|
+
if (phase === 'loading') return workspaceNotice(doc, { label: '読み込み中', text: '判断履歴を確認しています。', tone: 'info' });
|
|
528
|
+
if (phase === 'error') return workspaceNotice(doc, {
|
|
529
|
+
label: '取得できません',
|
|
530
|
+
text: [`判断の記録を確認できません。${error ? `理由: ${error}` : ''} `, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
|
|
531
|
+
tone: 'danger',
|
|
532
|
+
role: 'alert',
|
|
533
|
+
});
|
|
534
|
+
if (home?.status === 'unavailable') return workspaceNotice(doc, {
|
|
535
|
+
label: '未接続',
|
|
536
|
+
text: ['保存された判断に接続できません。データが0件とは確認できていません。', workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
|
|
537
|
+
tone: 'warning',
|
|
538
|
+
role: 'alert',
|
|
539
|
+
});
|
|
540
|
+
if (home?.status === 'invalid') return workspaceNotice(doc, {
|
|
541
|
+
label: '未確認',
|
|
542
|
+
text: ['判断の記録の応答を読み取れません。データが0件とは確認できていません。', workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
|
|
543
|
+
tone: 'danger',
|
|
544
|
+
role: 'alert',
|
|
545
|
+
});
|
|
546
|
+
if (home?.status === 'partial') return workspaceNotice(doc, {
|
|
547
|
+
label: '取得できた範囲',
|
|
548
|
+
text: [`読み取れた判断だけを表示しています。${home.coverage?.rejected ? `読み取れなかった記録は${home.coverage.rejected}件あります。` : ''}`, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
|
|
549
|
+
tone: 'warning',
|
|
550
|
+
role: 'status',
|
|
551
|
+
});
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function periodButtons(doc, selected, onSelect) {
|
|
556
|
+
const wrap = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-periods', attrs: { role: 'group', 'aria-label': '期間' } });
|
|
557
|
+
for (const period of [PERIODS.past30days, PERIODS.week, PERIODS.all]) {
|
|
558
|
+
wrap.append(workspaceButton(doc, {
|
|
559
|
+
text: PERIOD_LABELS[period],
|
|
560
|
+
variant: period === selected ? 'primary' : 'quiet',
|
|
561
|
+
onClick: () => onSelect(period),
|
|
562
|
+
attrs: { 'aria-pressed': period === selected ? 'true' : 'false', 'data-period': period },
|
|
563
|
+
}));
|
|
564
|
+
}
|
|
565
|
+
return wrap;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function createSearch(doc, value, onInput) {
|
|
569
|
+
const label = makeWorkspaceElement(doc, 'label', { className: 'bb-jh-search' });
|
|
570
|
+
label.append(makeWorkspaceElement(doc, 'span', { text: '参照・判断を検索' }));
|
|
571
|
+
const input = makeWorkspaceElement(doc, 'input', {
|
|
572
|
+
attrs: { type: 'search', value, placeholder: '参照・判断を検索', 'aria-label': '参照・判断を検索' },
|
|
573
|
+
});
|
|
574
|
+
input.value = value;
|
|
575
|
+
input.addEventListener('input', (event) => onInput(event?.target?.value ?? input.value ?? ''));
|
|
576
|
+
label.append(input);
|
|
577
|
+
return label;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function makeRows(doc, aggregate, selectedKey, onSelect) {
|
|
581
|
+
return aggregate.rows.map((row) => ({
|
|
582
|
+
key: row.key,
|
|
583
|
+
label: `${formatDate(row.recordedAt)} ${text(row.proof?.decision?.summary) || '判断履歴'}`,
|
|
584
|
+
selected: row.key === selectedKey,
|
|
585
|
+
className: `bb-jh-row${row.delegated ? ' is-delegated' : ' is-returned'}`,
|
|
586
|
+
onSelect,
|
|
587
|
+
cells: [
|
|
588
|
+
makeWorkspaceElement(doc, 'span', { className: 'bb-jh-date', text: formatDate(row.recordedAt) }),
|
|
589
|
+
makeReferenceSummary(doc, row.references),
|
|
590
|
+
makeDecisionSummary(doc, row),
|
|
591
|
+
],
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Create the shared, read-only judgment history controller. */
|
|
596
|
+
export function createJudgmentHistoryUI({
|
|
597
|
+
root,
|
|
598
|
+
rail = null,
|
|
599
|
+
document: explicitDocument,
|
|
600
|
+
fetcher,
|
|
601
|
+
token,
|
|
602
|
+
basePath = '/api/value-proofs',
|
|
603
|
+
page,
|
|
604
|
+
autoLoad = true,
|
|
605
|
+
pageHeader = true,
|
|
606
|
+
now = new Date(),
|
|
607
|
+
} = {}) {
|
|
608
|
+
if (!root) throw new TypeError('root is required');
|
|
609
|
+
const doc = safeDocument(explicitDocument);
|
|
610
|
+
const request = typeof fetcher === 'function'
|
|
611
|
+
? fetcher
|
|
612
|
+
: typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
|
|
613
|
+
const normalizedBasePath = String(basePath).replace(/\/+$/u, '');
|
|
614
|
+
const readNow = () => (typeof now === 'function' ? now() : now);
|
|
615
|
+
const state = {
|
|
616
|
+
phase: autoLoad ? 'loading' : 'idle',
|
|
617
|
+
error: null,
|
|
618
|
+
home: null,
|
|
619
|
+
aggregate: null,
|
|
620
|
+
period: PERIODS.past30days,
|
|
621
|
+
query: '',
|
|
622
|
+
selectedKey: null,
|
|
623
|
+
disposed: false,
|
|
624
|
+
generation: 0,
|
|
625
|
+
focusSearch: false,
|
|
626
|
+
lastFocusedKey: null,
|
|
627
|
+
pendingDecisionId: null,
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
function closeSelected() {
|
|
631
|
+
if (!state.selectedKey || state.disposed) return false;
|
|
632
|
+
state.lastFocusedKey = state.selectedKey;
|
|
633
|
+
state.selectedKey = null;
|
|
634
|
+
render();
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function handleKeyDown(event) {
|
|
639
|
+
if (event?.key === 'Escape') closeSelected();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
root.addEventListener?.('keydown', handleKeyDown);
|
|
643
|
+
rail?.addEventListener?.('keydown', handleKeyDown);
|
|
644
|
+
|
|
645
|
+
function currentPage() {
|
|
646
|
+
const crumbs = Array.isArray(page?.crumbs) ? page.crumbs : ['あなたのBrainbase', '判断の履歴'];
|
|
647
|
+
return {
|
|
648
|
+
crumbs,
|
|
649
|
+
source: page?.source ?? '判断の記録',
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function render() {
|
|
654
|
+
if (state.disposed) return;
|
|
655
|
+
const previousSearchInput = findFirst(root, (node) => node.tagName === 'INPUT');
|
|
656
|
+
const previousSearchSelection = previousSearchInput && Number.isInteger(previousSearchInput.selectionStart)
|
|
657
|
+
? { start: previousSearchInput.selectionStart, end: previousSearchInput.selectionEnd ?? previousSearchInput.selectionStart }
|
|
658
|
+
: null;
|
|
659
|
+
const previousSearchFocused = previousSearchInput && doc.activeElement === previousSearchInput;
|
|
660
|
+
const aggregate = state.aggregate ?? {
|
|
661
|
+
status: state.home?.status ?? 'unavailable', rows: [], visibleCount: 0, stats: {}, coverage: state.home?.coverage ?? null,
|
|
662
|
+
};
|
|
663
|
+
const wrapper = makeWorkspaceElement(doc, 'div', { className: 'bb-jh', attrs: { 'data-contract-version': JUDGMENT_HISTORY_UI_CONTRACT_VERSION } });
|
|
664
|
+
if (pageHeader) {
|
|
665
|
+
const context = currentPage();
|
|
666
|
+
wrapper.append(workspacePageHeader(doc, {
|
|
667
|
+
crumbs: context.crumbs,
|
|
668
|
+
title: '判断の履歴',
|
|
669
|
+
lead: 'Brainbaseにどんな参照が入り、どんな判断を代わりに行ったかを見返せます。',
|
|
670
|
+
source: context.source,
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
const notice = statusNotice(doc, state.phase, state.error, state.home, () => void load());
|
|
674
|
+
if (notice) wrapper.append(notice);
|
|
675
|
+
if (state.phase === 'loading' || state.phase === 'error' || state.home?.status === 'unavailable' || state.home?.status === 'invalid') {
|
|
676
|
+
wrapper.append(workspaceDetailEmpty(doc, { mark: '…', title: state.phase === 'loading' ? '判断履歴を読み込んでいます' : '判断履歴を確認できません', text: '再試行すると最新の状態を確認します。' }));
|
|
677
|
+
root.replaceChildren(wrapper);
|
|
678
|
+
renderRail(doc, rail, null);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const controls = makeWorkspaceElement(doc, 'div', { className: 'bb-jh-controls' });
|
|
682
|
+
controls.append(periodButtons(doc, state.period, (period) => {
|
|
683
|
+
state.period = period;
|
|
684
|
+
state.selectedKey = null;
|
|
685
|
+
state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
|
|
686
|
+
render();
|
|
687
|
+
}));
|
|
688
|
+
controls.append(createSearch(doc, state.query, (query) => {
|
|
689
|
+
state.query = String(query);
|
|
690
|
+
state.focusSearch = true;
|
|
691
|
+
state.selectedKey = null;
|
|
692
|
+
state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
|
|
693
|
+
render();
|
|
694
|
+
}));
|
|
695
|
+
controls.append(workspaceButton(doc, {
|
|
696
|
+
text: '再読み込み',
|
|
697
|
+
variant: 'quiet',
|
|
698
|
+
onClick: () => void load(),
|
|
699
|
+
attrs: { 'aria-label': '判断履歴を再読み込み', 'data-action': 'reload' },
|
|
700
|
+
}));
|
|
701
|
+
wrapper.append(controls);
|
|
702
|
+
wrapper.append(makeWorkspaceElement(doc, 'p', {
|
|
703
|
+
className: 'bb-jh-reference-note',
|
|
704
|
+
text: '判断に残った参照だけを表示しています。実行結果の証拠は参照件数に含めません。',
|
|
705
|
+
}));
|
|
706
|
+
if (state.home?.status === 'partial') wrapper.append(statusNotice(doc, 'ready', null, state.home, () => void load()));
|
|
707
|
+
const stats = aggregate.stats ?? {};
|
|
708
|
+
const metricCount = (value, suffix) => value === null || value === undefined ? '未確認' : `${value}${suffix}`;
|
|
709
|
+
wrapper.append(workspaceMetrics(doc, [
|
|
710
|
+
{ label: 'Brainbaseが代わりに判断した件数', value: metricCount(stats.judgments, '件'), note: 'この期間の履歴' },
|
|
711
|
+
{ label: 'そこで使った参照', value: metricCount(stats.references, '件'), note: '代理判断に含まれる一意の参照' },
|
|
712
|
+
{ label: '判断回数にすると', value: metricCount(stats.equivalent, '回分相当'), note: '代理判断1件を1回分として表示' },
|
|
713
|
+
], { ariaLabel: '判断履歴の集計' }));
|
|
714
|
+
const emptyText = state.home?.status === 'partial'
|
|
715
|
+
? 'この期間に取得できた判断履歴はありません。'
|
|
716
|
+
: 'この期間の判断履歴はありません。';
|
|
717
|
+
wrapper.append(workspaceLedger(doc, {
|
|
718
|
+
className: 'bb-jh-ledger',
|
|
719
|
+
ariaLabel: '判断履歴',
|
|
720
|
+
columns: ['日時', '使った参照', '行った判断'],
|
|
721
|
+
rows: makeRows(doc, aggregate, state.selectedKey, (key) => {
|
|
722
|
+
state.lastFocusedKey = key;
|
|
723
|
+
state.selectedKey = key;
|
|
724
|
+
render();
|
|
725
|
+
}),
|
|
726
|
+
empty: emptyText,
|
|
727
|
+
}));
|
|
728
|
+
const nextSearchInput = findFirst(wrapper, (node) => node.tagName === 'INPUT');
|
|
729
|
+
if (previousSearchInput && nextSearchInput) {
|
|
730
|
+
previousSearchInput.value = state.query;
|
|
731
|
+
replaceNode(nextSearchInput, previousSearchInput);
|
|
732
|
+
}
|
|
733
|
+
root.replaceChildren(wrapper);
|
|
734
|
+
if (state.focusSearch || previousSearchFocused) {
|
|
735
|
+
const input = findFirst(root, (node) => node.tagName === 'INPUT');
|
|
736
|
+
input?.focus?.();
|
|
737
|
+
const start = previousSearchSelection?.start ?? state.query.length;
|
|
738
|
+
const end = previousSearchSelection?.end ?? start;
|
|
739
|
+
input?.setSelectionRange?.(start, end);
|
|
740
|
+
state.focusSearch = false;
|
|
741
|
+
}
|
|
742
|
+
const selected = aggregate.rows.find((row) => row.key === state.selectedKey) ?? null;
|
|
743
|
+
renderRail(doc, rail, selected, selected ? closeSelected : null);
|
|
744
|
+
if (state.lastFocusedKey) {
|
|
745
|
+
const row = findFirst(root, (node) => attributeValue(node, 'data-key') === state.lastFocusedKey);
|
|
746
|
+
row?.focus?.();
|
|
747
|
+
state.lastFocusedKey = null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function load() {
|
|
752
|
+
if (state.disposed) return null;
|
|
753
|
+
const generation = ++state.generation;
|
|
754
|
+
state.phase = 'loading';
|
|
755
|
+
state.error = null;
|
|
756
|
+
state.home = null;
|
|
757
|
+
state.aggregate = null;
|
|
758
|
+
render();
|
|
759
|
+
if (!request) {
|
|
760
|
+
if (generation !== state.generation || state.disposed) return null;
|
|
761
|
+
state.phase = 'error';
|
|
762
|
+
state.error = 'fetch_unavailable';
|
|
763
|
+
render();
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
766
|
+
try {
|
|
767
|
+
const response = await request(`${normalizedBasePath}/home`, {
|
|
768
|
+
method: 'GET',
|
|
769
|
+
headers: token ? { 'x-brainbase-review-token': token } : undefined,
|
|
770
|
+
});
|
|
771
|
+
if (generation !== state.generation || state.disposed) return null;
|
|
772
|
+
if (!response || response.ok !== true) throw new Error(`http_${response?.status ?? 0}`);
|
|
773
|
+
const payload = await response.json();
|
|
774
|
+
if (generation !== state.generation || state.disposed) return null;
|
|
775
|
+
state.home = normalizeJudgmentHistoryHome(payload);
|
|
776
|
+
state.phase = 'ready';
|
|
777
|
+
state.aggregate = aggregateJudgmentHistory(state.home, { now: readNow(), period: state.period, query: state.query });
|
|
778
|
+
if (state.pendingDecisionId) {
|
|
779
|
+
const pending = state.aggregate.rows.find((row) => row.decisionAttemptId === state.pendingDecisionId);
|
|
780
|
+
state.pendingDecisionId = null;
|
|
781
|
+
if (pending) state.selectedKey = pending.key;
|
|
782
|
+
}
|
|
783
|
+
render();
|
|
784
|
+
return state.aggregate;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (generation !== state.generation || state.disposed) return null;
|
|
787
|
+
state.phase = 'error';
|
|
788
|
+
state.error = error instanceof Error ? error.message : String(error);
|
|
789
|
+
render();
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function openDecision(decisionAttemptId) {
|
|
795
|
+
const id = text(decisionAttemptId);
|
|
796
|
+
if (!id || state.disposed) return false;
|
|
797
|
+
if (!state.aggregate) {
|
|
798
|
+
state.pendingDecisionId = id;
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
const row = state.aggregate?.rows?.find((candidate) => candidate.decisionAttemptId === id);
|
|
802
|
+
if (!row) return false;
|
|
803
|
+
state.selectedKey = row.key;
|
|
804
|
+
state.lastFocusedKey = row.key;
|
|
805
|
+
render();
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function dispose() {
|
|
810
|
+
if (state.disposed) return;
|
|
811
|
+
state.disposed = true;
|
|
812
|
+
state.generation += 1;
|
|
813
|
+
root.removeEventListener?.('keydown', handleKeyDown);
|
|
814
|
+
rail?.removeEventListener?.('keydown', handleKeyDown);
|
|
815
|
+
root.replaceChildren();
|
|
816
|
+
rail?.replaceChildren();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const controller = { load, render, dispose, openDecision, get state() { return { ...state }; } };
|
|
820
|
+
if (autoLoad) void load();
|
|
821
|
+
else render();
|
|
822
|
+
return controller;
|
|
823
|
+
}
|
package/ui/local-web-shell.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createGraphProjectsView } from './graph-projects-view.js';
|
|
|
19
19
|
import { createGraphRegistryView } from './graph-registry-view.js';
|
|
20
20
|
import { createObjectiveEditorController } from './objective-editor.js';
|
|
21
21
|
import { createObjectiveEditorHttpPort } from './objective-editor-http-port.js';
|
|
22
|
-
import {
|
|
22
|
+
import { createJudgmentHistoryUI } from './judgment-history.js';
|
|
23
23
|
import { createWorldModelView } from './world-model-view.js';
|
|
24
24
|
import { createGraphClient } from './graph-view-shared.js';
|
|
25
25
|
|
|
@@ -145,14 +145,14 @@ function makePage(container, context) {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
function mountToday(container, context) {
|
|
148
|
-
return
|
|
148
|
+
return createJudgmentHistoryUI({
|
|
149
149
|
root: makePage(container, context),
|
|
150
150
|
rail: context.rail,
|
|
151
151
|
page: context.page,
|
|
152
152
|
document: context.document,
|
|
153
153
|
fetcher: context.fetcher,
|
|
154
154
|
token: context.token,
|
|
155
|
-
|
|
155
|
+
basePath: '/api/value-proofs',
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
158
|
|
|
@@ -220,7 +220,7 @@ function mountGraphScreen(screenId, createView) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
export const LOCAL_WEB_SCREENS = Object.freeze([
|
|
223
|
-
Object.freeze({ id: 'today', label: '今日', usesGraph: false, rail: true, source: '
|
|
223
|
+
Object.freeze({ id: 'today', label: '今日', usesGraph: false, rail: true, source: '判断の記録', mount: mountToday }),
|
|
224
224
|
Object.freeze({ id: 'objectives', label: '目的と現状', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
|
|
225
225
|
Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
|
|
226
226
|
Object.freeze({ id: 'graph', label: '情報と関係', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView) }),
|