@tiangong-ai/cli 0.0.57 → 0.0.58
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/AGENTS.md +2 -2
- package/README.md +106 -3
- package/dist/research/orchestration.js +96 -26
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/workspace/acquisition-forecast.d.ts +18 -0
- package/dist/research/workspace/acquisition-forecast.js +116 -0
- package/dist/research/workspace/acquisition-forecast.js.map +1 -0
- package/dist/research/workspace/acquisition.d.ts +13 -1
- package/dist/research/workspace/acquisition.js +71 -62
- package/dist/research/workspace/acquisition.js.map +1 -1
- package/dist/research/workspace/artifacts.d.ts +18 -0
- package/dist/research/workspace/artifacts.js +40 -2
- package/dist/research/workspace/artifacts.js.map +1 -1
- package/dist/research/workspace/content-evidence.d.ts +37 -0
- package/dist/research/workspace/content-evidence.js +287 -137
- package/dist/research/workspace/content-evidence.js.map +1 -1
- package/dist/research/workspace/downloads.js +2 -2
- package/dist/research/workspace/downloads.js.map +1 -1
- package/dist/research/workspace/evidence-content-schema.d.ts +14 -0
- package/dist/research/workspace/evidence-content-schema.js +123 -0
- package/dist/research/workspace/evidence-content-schema.js.map +1 -0
- package/dist/research/workspace/evidence-ledger.d.ts +1 -1
- package/dist/research/workspace/evidence-ledger.js.map +1 -1
- package/dist/research/workspace/evidence-role-coverage.d.ts +35 -0
- package/dist/research/workspace/evidence-role-coverage.js +81 -0
- package/dist/research/workspace/evidence-role-coverage.js.map +1 -0
- package/dist/research/workspace/journal.d.ts +2 -0
- package/dist/research/workspace/journal.js +6 -0
- package/dist/research/workspace/journal.js.map +1 -1
- package/dist/research/workspace/preflight.d.ts +2 -0
- package/dist/research/workspace/preflight.js +14 -5
- package/dist/research/workspace/preflight.js.map +1 -1
- package/dist/research/workspace/projects.d.ts +7 -0
- package/dist/research/workspace/projects.js +65 -19
- package/dist/research/workspace/projects.js.map +1 -1
- package/dist/research/workspace/review-executor.d.ts +2 -0
- package/dist/research/workspace/review-executor.js +69 -1
- package/dist/research/workspace/review-executor.js.map +1 -1
- package/dist/research/workspace/runtime.d.ts +8 -1
- package/dist/research/workspace/runtime.js +54 -28
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/schemas.js +10 -2
- package/dist/research/workspace/schemas.js.map +1 -1
- package/dist/research/workspace/scientific-design.d.ts +2 -1
- package/dist/research/workspace/scientific-design.js +39 -2
- package/dist/research/workspace/scientific-design.js.map +1 -1
- package/dist/research/workspace/scientific-review-execution.d.ts +19 -0
- package/dist/research/workspace/scientific-review-execution.js +445 -0
- package/dist/research/workspace/scientific-review-execution.js.map +1 -0
- package/dist/research/workspace/scientific-review.d.ts +110 -3
- package/dist/research/workspace/scientific-review.js +113 -20
- package/dist/research/workspace/scientific-review.js.map +1 -1
- package/dist/research/workspace/setup-catalog.js +2 -2
- package/dist/research/workspace/types.d.ts +4 -1
- package/dist/research/workspace/types.js +14 -1
- package/dist/research/workspace/types.js.map +1 -1
- package/dist/research/workspace/workspace.js +13 -1
- package/dist/research/workspace/workspace.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,49 +1,56 @@
|
|
|
1
|
-
import { chmod, readFile, readdir } from "node:fs/promises";
|
|
1
|
+
import { chmod, lstat, readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { CliError } from "../../errors.js";
|
|
4
|
-
import { loadCurrentEvidenceSnapshot } from "./acquisition.js";
|
|
4
|
+
import { loadCurrentEvidenceSnapshot, loadVerifiedEvidencePreparationView } from "./acquisition.js";
|
|
5
5
|
import { loadEvidenceArtifactRecords } from "./artifacts.js";
|
|
6
6
|
import { loadBoundAcquisitionDesign } from "./acquisition-routes.js";
|
|
7
|
+
import { EVIDENCE_CONTENT_CLASSES, EVIDENCE_CONTENT_FUNCTIONS, EVIDENCE_CONTENT_IDENTIFIER, EVIDENCE_CONTENT_LIMITS, isEvidenceContentInputShape, } from "./evidence-content-schema.js";
|
|
7
8
|
import { appendEvidenceLedgerEvent, evidenceLedgerPath } from "./evidence-ledger.js";
|
|
8
|
-
import {
|
|
9
|
+
import { computeRoleCoverage } from "./evidence-role-coverage.js";
|
|
10
|
+
import { readJournal, readVerifiedJournal } from "./journal.js";
|
|
9
11
|
import { loadProject } from "./projects.js";
|
|
10
12
|
import { configuredResearchSecrets, sanitizeResearchText } from "./sanitization.js";
|
|
11
13
|
import { canonicalJson, ensureDirectory, isObject, pathExists, resolveContained, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
|
|
12
|
-
const IDENTIFIER =
|
|
14
|
+
const IDENTIFIER = new RegExp(EVIDENCE_CONTENT_IDENTIFIER);
|
|
13
15
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
14
|
-
const CONTENT_CLASSES = new Set(
|
|
15
|
-
|
|
16
|
-
"table-data",
|
|
17
|
-
"supplementary-data",
|
|
18
|
-
"structured-data",
|
|
19
|
-
"metadata",
|
|
20
|
-
"figure-text",
|
|
21
|
-
"code",
|
|
22
|
-
"container-index",
|
|
23
|
-
]);
|
|
24
|
-
const EVIDENCE_FUNCTIONS = new Set([
|
|
25
|
-
"support",
|
|
26
|
-
"counterevidence",
|
|
27
|
-
"definition",
|
|
28
|
-
"method",
|
|
29
|
-
"limitation",
|
|
30
|
-
"context",
|
|
31
|
-
]);
|
|
16
|
+
const CONTENT_CLASSES = new Set(EVIDENCE_CONTENT_CLASSES);
|
|
17
|
+
const EVIDENCE_FUNCTIONS = new Set(EVIDENCE_CONTENT_FUNCTIONS);
|
|
32
18
|
const PRODUCER_VISIBLE_MEDIA_TYPES = new Set([
|
|
33
19
|
"application/json",
|
|
34
20
|
"text/plain",
|
|
35
21
|
"text/markdown",
|
|
36
22
|
"text/csv",
|
|
37
23
|
]);
|
|
38
|
-
const MAX_EXCERPT_BYTES =
|
|
24
|
+
const MAX_EXCERPT_BYTES = EVIDENCE_CONTENT_LIMITS.maxExcerptBytes;
|
|
25
|
+
const MAX_BATCH_RECORDS = EVIDENCE_CONTENT_LIMITS.maxBatchRecords;
|
|
26
|
+
const MAX_BATCH_INPUT_BYTES = EVIDENCE_CONTENT_LIMITS.maxBatchInputBytes;
|
|
39
27
|
export async function recordArtifactDecomposition(input) {
|
|
40
|
-
await
|
|
28
|
+
const view = await loadContentPreparationView(input.root, input.projectId);
|
|
29
|
+
const record = prepareDecompositionRecord(input, view);
|
|
30
|
+
const existing = (await loadDecompositionRecords(input.root, input.projectId)).find((item) => item.sourceArtifactId === record.sourceArtifactId);
|
|
31
|
+
if (existing) {
|
|
32
|
+
assertSameContentRecord(existing.decompositionSha256, record.decompositionSha256, "decomposition");
|
|
33
|
+
return existing;
|
|
34
|
+
}
|
|
35
|
+
const destination = decompositionRecordPath(input.root, input.projectId, record.sourceArtifactId);
|
|
36
|
+
await writeJsonAtomic(destination, record, 0o444);
|
|
37
|
+
await chmod(destination, 0o444).catch(() => undefined);
|
|
38
|
+
await appendEvidenceLedgerEvent(input.root, input.projectId, "decomposition.recorded", {
|
|
39
|
+
decompositionId: record.decompositionId,
|
|
40
|
+
decompositionSha256: record.decompositionSha256,
|
|
41
|
+
sourceArtifactId: record.sourceArtifactId,
|
|
42
|
+
sourceArtifactSha256: record.sourceArtifactSha256,
|
|
43
|
+
candidateId: record.candidateId,
|
|
44
|
+
status: record.status,
|
|
45
|
+
outputArtifactIds: record.outputArtifactIds,
|
|
46
|
+
outputArtifactSha256s: record.outputArtifactSha256s,
|
|
47
|
+
contentClasses: record.contentClasses,
|
|
48
|
+
});
|
|
49
|
+
return record;
|
|
50
|
+
}
|
|
51
|
+
function prepareDecompositionRecord(input, view) {
|
|
41
52
|
const value = parseDecompositionInput(input.value);
|
|
42
|
-
const acquisition
|
|
43
|
-
const artifacts = new Map((await loadEvidenceArtifactRecords(input.root, input.projectId)).map((artifact) => [
|
|
44
|
-
artifact.artifactId,
|
|
45
|
-
artifact,
|
|
46
|
-
]));
|
|
53
|
+
const { acquisition, artifacts } = view;
|
|
47
54
|
const source = artifacts.get(value.sourceArtifactId);
|
|
48
55
|
if (!source ||
|
|
49
56
|
!acquisition.artifacts.some((artifact) => artifact.artifactId === source.artifactId)) {
|
|
@@ -81,48 +88,61 @@ export async function recordArtifactDecomposition(input) {
|
|
|
81
88
|
decompositionSha256,
|
|
82
89
|
recordedAt: new Date().toISOString(),
|
|
83
90
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
return record;
|
|
92
|
+
}
|
|
93
|
+
export async function registerEvidenceAtom(input) {
|
|
94
|
+
const view = await loadContentPreparationView(input.root, input.projectId);
|
|
95
|
+
const record = await prepareEvidenceAtom(input, view);
|
|
96
|
+
const existing = (await loadEvidenceAtomRecords(input.root, input.projectId)).find((item) => item.atomId === record.atomId);
|
|
97
|
+
if (existing) {
|
|
98
|
+
assertSameContentRecord(existing.atomSha256, record.atomSha256, "atom");
|
|
90
99
|
return existing;
|
|
91
100
|
}
|
|
101
|
+
const destination = atomRecordPath(input.root, input.projectId, record.atomId);
|
|
92
102
|
await writeJsonAtomic(destination, record, 0o444);
|
|
93
103
|
await chmod(destination, 0o444).catch(() => undefined);
|
|
94
|
-
await appendEvidenceLedgerEvent(input.root, input.projectId, "
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
await appendEvidenceLedgerEvent(input.root, input.projectId, "atom.registered", {
|
|
105
|
+
atomId: record.atomId,
|
|
106
|
+
atomSha256: record.atomSha256,
|
|
107
|
+
sourceId: record.sourceId,
|
|
108
|
+
candidateId: record.candidateId,
|
|
109
|
+
artifactId: record.artifactId,
|
|
110
|
+
artifactSha256: record.artifactSha256,
|
|
111
|
+
excerptSha256: record.excerptSha256,
|
|
112
|
+
evidenceRoleIds: record.evidenceRoleIds,
|
|
113
|
+
coverageDimensionIds: record.coverageDimensionIds,
|
|
114
|
+
evidenceFunction: record.evidenceFunction,
|
|
104
115
|
});
|
|
105
116
|
return record;
|
|
106
117
|
}
|
|
107
|
-
|
|
108
|
-
await assertContentPreparationWindow(input.root, input.projectId);
|
|
118
|
+
async function prepareEvidenceAtom(input, view) {
|
|
109
119
|
const value = parseAtomInput(input.value);
|
|
110
|
-
const acquisition =
|
|
120
|
+
const { acquisition } = view;
|
|
111
121
|
const source = acquisition.sources.find((candidate) => candidate.id === value.sourceId);
|
|
112
122
|
if (!source ||
|
|
113
123
|
!Array.isArray(source.artifactIds) ||
|
|
114
124
|
!source.artifactIds.includes(value.artifactId)) {
|
|
115
125
|
throw contentError("Evidence atom source and artifact must belong to the same frozen acquisition source.", "RESEARCH_EVIDENCE_ATOM_SOURCE_INVALID");
|
|
116
126
|
}
|
|
117
|
-
const artifact =
|
|
127
|
+
const artifact = view.artifacts.get(value.artifactId);
|
|
118
128
|
if (!artifact ||
|
|
119
129
|
artifact.candidateId !== value.candidateId ||
|
|
120
130
|
!PRODUCER_VISIBLE_MEDIA_TYPES.has(artifact.mediaType)) {
|
|
121
131
|
throw contentError("Evidence atoms may reference only producer-readable artifacts bound to the declared candidate.", "RESEARCH_EVIDENCE_ATOM_ARTIFACT_INVALID");
|
|
122
132
|
}
|
|
123
|
-
|
|
133
|
+
validateAtomTaxonomy(source, value, view.roleIds);
|
|
124
134
|
const artifactPath = resolveContained(workspacePaths(input.root).control, artifact.locator);
|
|
125
|
-
|
|
135
|
+
let document = view.texts.get(artifact.artifactId);
|
|
136
|
+
if (document === undefined) {
|
|
137
|
+
const text = await readFile(artifactPath, "utf8");
|
|
138
|
+
if (sha256Text(text) !== artifact.sha256) {
|
|
139
|
+
throw contentError("Evidence atom artifact changed during preparation.", "RESEARCH_ARTIFACT_DRIFT");
|
|
140
|
+
}
|
|
141
|
+
view.work.excerptFileReads += 1;
|
|
142
|
+
document = { text };
|
|
143
|
+
view.texts.set(artifact.artifactId, document);
|
|
144
|
+
}
|
|
145
|
+
const excerpt = extractAtomExcerptFromDocument(document, artifact.mediaType, value.locator);
|
|
126
146
|
assertSafeContent(excerpt, "Evidence atom excerpt contains sensitive material.");
|
|
127
147
|
const stable = {
|
|
128
148
|
schemaVersion: 1,
|
|
@@ -148,29 +168,202 @@ export async function registerEvidenceAtom(input) {
|
|
|
148
168
|
atomSha256,
|
|
149
169
|
registeredAt: new Date().toISOString(),
|
|
150
170
|
};
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
return record;
|
|
172
|
+
}
|
|
173
|
+
/** Caller holds one workspace mutation lock for the whole operation. */
|
|
174
|
+
export async function registerEvidenceContentBatch(input) {
|
|
175
|
+
const values = input.value.records;
|
|
176
|
+
if (input.value.schemaVersion !== 1 ||
|
|
177
|
+
Object.keys(input.value).some((key) => !["schemaVersion", "records"].includes(key)) ||
|
|
178
|
+
!Array.isArray(values) ||
|
|
179
|
+
values.length < 1 ||
|
|
180
|
+
values.length > MAX_BATCH_RECORDS ||
|
|
181
|
+
values.some((value) => !isObject(value)) ||
|
|
182
|
+
Buffer.byteLength(canonicalJson(input.value)) > MAX_BATCH_INPUT_BYTES ||
|
|
183
|
+
!isEvidenceContentInputShape(input.kind === "atom" ? "evidence-atom-batch" : "artifact-decomposition-batch", input.value)) {
|
|
184
|
+
throw contentError(`Evidence batch requires 1-${MAX_BATCH_RECORDS} records within ${MAX_BATCH_INPUT_BYTES} bytes.`, "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
185
|
+
}
|
|
186
|
+
const view = await loadContentPreparationView(input.root, input.projectId);
|
|
187
|
+
const existingRecords = input.kind === "atom"
|
|
188
|
+
? await loadEvidenceAtomRecords(input.root, input.projectId)
|
|
189
|
+
: await loadDecompositionRecords(input.root, input.projectId);
|
|
190
|
+
const existing = new Map(existingRecords.map((record) => [contentRecordId(record), record]));
|
|
191
|
+
const seen = new Set();
|
|
192
|
+
const records = [];
|
|
193
|
+
let added = 0;
|
|
194
|
+
// Group extraction by artifact, then restore caller order. At most one parsed
|
|
195
|
+
// document stays live, and each referenced artifact is read/parsed once.
|
|
196
|
+
const ordered = values.map((value, index) => ({
|
|
197
|
+
value: value,
|
|
198
|
+
index,
|
|
199
|
+
}));
|
|
200
|
+
if (input.kind === "atom")
|
|
201
|
+
ordered.sort((left, right) => String(left.value.artifactId).localeCompare(String(right.value.artifactId)));
|
|
202
|
+
let currentArtifact;
|
|
203
|
+
for (const { value, index } of ordered) {
|
|
204
|
+
if (value.artifactId !== currentArtifact) {
|
|
205
|
+
view.texts.clear();
|
|
206
|
+
currentArtifact = value.artifactId;
|
|
156
207
|
}
|
|
157
|
-
|
|
208
|
+
const prepared = input.kind === "atom"
|
|
209
|
+
? await prepareEvidenceAtom({ ...input, value: value }, view)
|
|
210
|
+
: prepareDecompositionRecord({ ...input, value: value }, view);
|
|
211
|
+
const id = contentRecordId(prepared);
|
|
212
|
+
if (seen.has(id)) {
|
|
213
|
+
throw contentError("Evidence batch contains duplicate record identities.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
214
|
+
}
|
|
215
|
+
seen.add(id);
|
|
216
|
+
const previous = existing.get(id);
|
|
217
|
+
if (previous)
|
|
218
|
+
assertSameContentRecord(contentRecordSha256(previous), contentRecordSha256(prepared), input.kind);
|
|
219
|
+
else
|
|
220
|
+
added += 1;
|
|
221
|
+
records[index] = previous ?? prepared;
|
|
158
222
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
223
|
+
const stable = {
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
projectId: input.projectId,
|
|
226
|
+
kind: input.kind,
|
|
227
|
+
records,
|
|
228
|
+
};
|
|
229
|
+
const batchSha256 = contentBatchHash(stable);
|
|
230
|
+
let batch = { ...stable, batchSha256 };
|
|
231
|
+
const committed = added === 0 &&
|
|
232
|
+
(await readVerifiedJournal(evidenceLedgerPath(input.root, input.projectId))).some((event) => event.type === "content.batch.registered" && event.payload.batchSha256 === batchSha256);
|
|
233
|
+
if (!committed) {
|
|
234
|
+
const path = contentBatchPath(input.root, input.projectId, batchSha256);
|
|
235
|
+
if (await pathExists(path)) {
|
|
236
|
+
// A crash before the commit event can leave a complete invisible envelope.
|
|
237
|
+
// Reuse only its verified bytes, preserving the original timestamps.
|
|
238
|
+
batch = await readContentBatch(path, input.projectId, batchSha256, input.kind);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
await writeJsonAtomic(path, batch, 0o444);
|
|
242
|
+
}
|
|
243
|
+
await appendEvidenceLedgerEvent(input.root, input.projectId, "content.batch.registered", {
|
|
244
|
+
batchSha256,
|
|
245
|
+
envelopeSha256: sha256Text(canonicalJson(batch)),
|
|
246
|
+
kind: input.kind,
|
|
247
|
+
recordCount: records.length,
|
|
248
|
+
});
|
|
249
|
+
view.work.ledgerAppends += 1;
|
|
250
|
+
}
|
|
251
|
+
return { ...batch, work: view.work };
|
|
252
|
+
}
|
|
253
|
+
async function loadContentPreparationView(root, projectId) {
|
|
254
|
+
const project = await assertContentPreparationWindow(root, projectId);
|
|
255
|
+
const work = {
|
|
256
|
+
acquisitionVerifications: 0,
|
|
257
|
+
artifactStoreScans: 0,
|
|
258
|
+
excerptFileReads: 0,
|
|
259
|
+
ledgerAppends: 0,
|
|
260
|
+
};
|
|
261
|
+
const verified = await loadVerifiedEvidencePreparationView(root, projectId);
|
|
262
|
+
work.acquisitionVerifications += 1;
|
|
263
|
+
work.artifactStoreScans += 1;
|
|
264
|
+
const roleIds = project.scientificDesign
|
|
265
|
+
? new Set((await loadBoundAcquisitionDesign(root, project)).evidenceRoles.map((role) => role.id))
|
|
266
|
+
: null;
|
|
267
|
+
return {
|
|
268
|
+
acquisition: verified.snapshot,
|
|
269
|
+
artifacts: new Map(verified.artifacts.map((artifact) => [artifact.artifactId, artifact])),
|
|
270
|
+
roleIds,
|
|
271
|
+
texts: new Map(),
|
|
272
|
+
work,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function contentRecordId(record) {
|
|
276
|
+
return "atomId" in record ? record.atomId : record.sourceArtifactId;
|
|
277
|
+
}
|
|
278
|
+
function contentRecordSha256(record) {
|
|
279
|
+
return "atomSha256" in record ? record.atomSha256 : record.decompositionSha256;
|
|
280
|
+
}
|
|
281
|
+
function assertSameContentRecord(previous, next, kind) {
|
|
282
|
+
if (previous !== next) {
|
|
283
|
+
throw contentError(`Evidence ${kind} identity already exists with different content.`, kind === "atom" ? "RESEARCH_EVIDENCE_ATOM_CONFLICT" : "RESEARCH_DECOMPOSITION_CONFLICT");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function contentBatchHash(batch) {
|
|
287
|
+
return sha256Text(canonicalJson({
|
|
288
|
+
schemaVersion: batch.schemaVersion,
|
|
289
|
+
projectId: batch.projectId,
|
|
290
|
+
kind: batch.kind,
|
|
291
|
+
records: batch.records.map((record) => ({
|
|
292
|
+
id: contentRecordId(record),
|
|
293
|
+
sha256: contentRecordSha256(record),
|
|
294
|
+
})),
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
function contentBatchPath(root, projectId, sha256) {
|
|
298
|
+
return resolveContained(workspacePaths(root).projects, `${projectId}/evidence/content-batches/${sha256}.json`);
|
|
299
|
+
}
|
|
300
|
+
async function readContentBatch(path, projectId, sha256, kind) {
|
|
301
|
+
const info = await lstat(path).catch(() => undefined);
|
|
302
|
+
if (!info?.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024) {
|
|
303
|
+
throw contentError("Committed evidence batch file is absent or invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
304
|
+
}
|
|
305
|
+
let value;
|
|
306
|
+
try {
|
|
307
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
throw contentError("Stored evidence batch is not valid JSON.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
311
|
+
}
|
|
312
|
+
if (!isObject(value) ||
|
|
313
|
+
value.schemaVersion !== 1 ||
|
|
314
|
+
value.projectId !== projectId ||
|
|
315
|
+
value.kind !== kind ||
|
|
316
|
+
value.batchSha256 !== sha256 ||
|
|
317
|
+
!Array.isArray(value.records) ||
|
|
318
|
+
value.records.length < 1 ||
|
|
319
|
+
value.records.length > MAX_BATCH_RECORDS) {
|
|
320
|
+
throw contentError("Stored evidence batch identity is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
321
|
+
}
|
|
322
|
+
const records = value.records.map((record) => kind === "atom" ? parseAtomRecord(record) : parseDecompositionRecord(record));
|
|
323
|
+
const batch = { schemaVersion: 1, projectId, kind, batchSha256: sha256, records };
|
|
324
|
+
if (records.some((record) => record.projectId !== projectId) ||
|
|
325
|
+
new Set(records.map(contentRecordId)).size !== records.length ||
|
|
326
|
+
contentBatchHash(batch) !== sha256) {
|
|
327
|
+
throw contentError("Stored evidence batch hash binding is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
328
|
+
}
|
|
329
|
+
return batch;
|
|
330
|
+
}
|
|
331
|
+
async function loadCommittedContentBatchRecords(root, projectId, kind) {
|
|
332
|
+
const ledger = evidenceLedgerPath(root, projectId);
|
|
333
|
+
const events = await readVerifiedJournal(ledger);
|
|
334
|
+
const records = [];
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
for (const event of events) {
|
|
337
|
+
if (event.type !== "content.batch.registered" || event.payload.kind !== kind)
|
|
338
|
+
continue;
|
|
339
|
+
const hash = event.payload.batchSha256;
|
|
340
|
+
if (event.scope !== projectId ||
|
|
341
|
+
typeof hash !== "string" ||
|
|
342
|
+
!SHA256.test(hash) ||
|
|
343
|
+
seen.has(hash)) {
|
|
344
|
+
throw contentError("Evidence batch commit event is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
345
|
+
}
|
|
346
|
+
seen.add(hash);
|
|
347
|
+
const batch = await readContentBatch(contentBatchPath(root, projectId, hash), projectId, hash, kind);
|
|
348
|
+
if (event.payload.recordCount !== batch.records.length ||
|
|
349
|
+
event.payload.envelopeSha256 !== sha256Text(canonicalJson(batch))) {
|
|
350
|
+
throw contentError("Evidence batch commit count is invalid.", "RESEARCH_EVIDENCE_BATCH_INVALID");
|
|
351
|
+
}
|
|
352
|
+
records.push(...batch.records);
|
|
353
|
+
}
|
|
354
|
+
return records;
|
|
355
|
+
}
|
|
356
|
+
function mergeContentRecords(records, kind) {
|
|
357
|
+
const merged = new Map();
|
|
358
|
+
for (const record of records) {
|
|
359
|
+
const id = contentRecordId(record);
|
|
360
|
+
const previous = merged.get(id);
|
|
361
|
+
if (previous)
|
|
362
|
+
assertSameContentRecord(contentRecordSha256(previous), contentRecordSha256(record), kind);
|
|
363
|
+
else
|
|
364
|
+
merged.set(id, record);
|
|
365
|
+
}
|
|
366
|
+
return [...merged.values()].sort((left, right) => contentRecordId(left).localeCompare(contentRecordId(right)));
|
|
174
367
|
}
|
|
175
368
|
export async function freezeEvidenceContentSnapshot(root, projectId) {
|
|
176
369
|
await assertContentPreparationWindow(root, projectId);
|
|
@@ -324,9 +517,9 @@ export async function loadCurrentEvidenceContentSnapshot(root, projectId) {
|
|
|
324
517
|
}
|
|
325
518
|
export async function loadDecompositionRecords(root, projectId) {
|
|
326
519
|
const directory = resolveContained(workspacePaths(root).projects, `${projectId}/evidence/decompositions`);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
520
|
+
const entries = (await pathExists(directory))
|
|
521
|
+
? await readdir(directory, { withFileTypes: true })
|
|
522
|
+
: [];
|
|
330
523
|
const records = [];
|
|
331
524
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
332
525
|
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
|
|
@@ -337,13 +530,14 @@ export async function loadDecompositionRecords(root, projectId) {
|
|
|
337
530
|
}
|
|
338
531
|
records.push(record);
|
|
339
532
|
}
|
|
340
|
-
|
|
533
|
+
const batched = await loadCommittedContentBatchRecords(root, projectId, "decomposition");
|
|
534
|
+
return mergeContentRecords([...records, ...batched], "decomposition");
|
|
341
535
|
}
|
|
342
536
|
export async function loadEvidenceAtomRecords(root, projectId) {
|
|
343
537
|
const directory = resolveContained(workspacePaths(root).projects, `${projectId}/evidence/atoms`);
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
538
|
+
const entries = (await pathExists(directory))
|
|
539
|
+
? await readdir(directory, { withFileTypes: true })
|
|
540
|
+
: [];
|
|
347
541
|
const records = [];
|
|
348
542
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
349
543
|
if (!entry.isFile() || entry.isSymbolicLink() || !entry.name.endsWith(".json"))
|
|
@@ -354,7 +548,8 @@ export async function loadEvidenceAtomRecords(root, projectId) {
|
|
|
354
548
|
}
|
|
355
549
|
records.push(record);
|
|
356
550
|
}
|
|
357
|
-
|
|
551
|
+
const batched = await loadCommittedContentBatchRecords(root, projectId, "atom");
|
|
552
|
+
return mergeContentRecords([...records, ...batched], "atom");
|
|
358
553
|
}
|
|
359
554
|
async function assertContentPreparationWindow(root, projectId) {
|
|
360
555
|
const project = await loadProject(root, projectId);
|
|
@@ -366,6 +561,7 @@ async function assertContentPreparationWindow(root, projectId) {
|
|
|
366
561
|
analyze.attempts !== 0) {
|
|
367
562
|
throw contentError("Evidence content preparation is allowed only after acquisition and before analysis starts.", "RESEARCH_EVIDENCE_CONTENT_STAGE_REQUIRED");
|
|
368
563
|
}
|
|
564
|
+
return project;
|
|
369
565
|
}
|
|
370
566
|
function parseDecompositionInput(value) {
|
|
371
567
|
const allowed = new Set([
|
|
@@ -377,7 +573,8 @@ function parseDecompositionInput(value) {
|
|
|
377
573
|
"contentClasses",
|
|
378
574
|
"limitations",
|
|
379
575
|
]);
|
|
380
|
-
if (
|
|
576
|
+
if (!isEvidenceContentInputShape("artifact-decomposition", value) ||
|
|
577
|
+
Object.keys(value).some((key) => !allowed.has(key)) ||
|
|
381
578
|
value.schemaVersion !== 1 ||
|
|
382
579
|
typeof value.sourceArtifactId !== "string" ||
|
|
383
580
|
!IDENTIFIER.test(value.sourceArtifactId) ||
|
|
@@ -420,7 +617,8 @@ function parseAtomInput(value) {
|
|
|
420
617
|
"scope",
|
|
421
618
|
"limitations",
|
|
422
619
|
]);
|
|
423
|
-
if (
|
|
620
|
+
if (!isEvidenceContentInputShape("evidence-atom", value) ||
|
|
621
|
+
Object.keys(value).some((key) => !allowed.has(key)) ||
|
|
424
622
|
value.schemaVersion !== 1 ||
|
|
425
623
|
!identifierValue(value.atomId) ||
|
|
426
624
|
!identifierValue(value.sourceId) ||
|
|
@@ -441,35 +639,31 @@ function parseAtomInput(value) {
|
|
|
441
639
|
}
|
|
442
640
|
return value;
|
|
443
641
|
}
|
|
444
|
-
|
|
642
|
+
function validateAtomTaxonomy(source, value, knownRoleIds) {
|
|
445
643
|
const sourceDimensions = new Set(Array.isArray(source.coverageDimensions)
|
|
446
644
|
? source.coverageDimensions.filter((item) => typeof item === "string")
|
|
447
645
|
: []);
|
|
448
646
|
if (value.coverageDimensionIds.some((dimension) => !sourceDimensions.has(dimension))) {
|
|
449
647
|
throw contentError("Evidence atom dimensions must be declared by its frozen acquisition source.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
|
|
450
648
|
}
|
|
451
|
-
|
|
452
|
-
if (!project.scientificDesign) {
|
|
649
|
+
if (!knownRoleIds) {
|
|
453
650
|
if (value.evidenceRoleIds.length) {
|
|
454
651
|
throw contentError("A project without a scientific design cannot declare evidence-role IDs.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
|
|
455
652
|
}
|
|
456
653
|
return;
|
|
457
654
|
}
|
|
458
|
-
const design = await loadBoundAcquisitionDesign(root, project);
|
|
459
|
-
const knownRoleIds = new Set(design.evidenceRoles.map((role) => role.id));
|
|
460
655
|
if (value.evidenceRoleIds.length < 1 ||
|
|
461
656
|
value.evidenceRoleIds.some((roleId) => !knownRoleIds.has(roleId))) {
|
|
462
657
|
throw contentError("Scientific evidence atoms must bind only declared evidence-role IDs.", "RESEARCH_EVIDENCE_ATOM_TAXONOMY_INVALID");
|
|
463
658
|
}
|
|
464
659
|
}
|
|
465
|
-
|
|
466
|
-
const text = await readFile(path, "utf8");
|
|
660
|
+
function extractAtomExcerptFromDocument(document, mediaType, locator) {
|
|
467
661
|
let excerpt;
|
|
468
662
|
if (locator.kind === "line-range") {
|
|
469
663
|
if (!["text/plain", "text/markdown", "text/csv"].includes(mediaType)) {
|
|
470
664
|
throw contentError("Line-range evidence atoms require a text or CSV artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
|
|
471
665
|
}
|
|
472
|
-
const lines = text.split(/\r\n|\n|\r/u);
|
|
666
|
+
const lines = (document.lines ??= document.text.split(/\r\n|\n|\r/u));
|
|
473
667
|
if (locator.endLine > lines.length) {
|
|
474
668
|
throw contentError("Evidence atom line range exceeds the artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
|
|
475
669
|
}
|
|
@@ -479,7 +673,9 @@ async function extractAtomExcerpt(path, mediaType, locator) {
|
|
|
479
673
|
if (mediaType !== "application/json") {
|
|
480
674
|
throw contentError("JSON Pointer evidence atoms require an application/json artifact.", "RESEARCH_EVIDENCE_ATOM_LOCATOR_INVALID");
|
|
481
675
|
}
|
|
482
|
-
|
|
676
|
+
if (!("json" in document))
|
|
677
|
+
document.json = JSON.parse(document.text);
|
|
678
|
+
let selected = document.json;
|
|
483
679
|
for (const segment of locator.pointer
|
|
484
680
|
.slice(1)
|
|
485
681
|
.split("/")
|
|
@@ -501,52 +697,6 @@ async function extractAtomExcerpt(path, mediaType, locator) {
|
|
|
501
697
|
}
|
|
502
698
|
return excerpt;
|
|
503
699
|
}
|
|
504
|
-
function computeRoleCoverage(roles, sources, atoms) {
|
|
505
|
-
const sourcesById = new Map(sources.map((source) => [String(source.id), source]));
|
|
506
|
-
return roles
|
|
507
|
-
.filter((role) => role.required)
|
|
508
|
-
.map((role) => {
|
|
509
|
-
const roleAtoms = atoms.filter((atom) => atom.evidenceRoleIds.includes(role.id));
|
|
510
|
-
const sourceIds = sortedUnique(roleAtoms.map((atom) => atom.sourceId)).filter((sourceId) => sourcesById.has(sourceId));
|
|
511
|
-
const fullTextSourceIds = sourceIds.filter((sourceId) => sourcesById.get(sourceId)?.fullTextAvailable === true);
|
|
512
|
-
const datedSourceIds = sourceIds.filter((sourceId) => typeof sourcesById.get(sourceId)?.publicationDate === "string");
|
|
513
|
-
const coverageDimensionIds = sortedUnique(roleAtoms.flatMap((atom) => atom.coverageDimensionIds));
|
|
514
|
-
const sourceTypes = sortedUnique(sourceIds.flatMap((sourceId) => {
|
|
515
|
-
const sourceType = sourcesById.get(sourceId)?.sourceType;
|
|
516
|
-
return typeof sourceType === "string" ? [sourceType] : [];
|
|
517
|
-
}));
|
|
518
|
-
const gaps = [];
|
|
519
|
-
if (sourceIds.length < role.minimumIndependentSources) {
|
|
520
|
-
gaps.push(`evidence role ${role.id} requires ${role.minimumIndependentSources} independent source(s), found ${sourceIds.length}`);
|
|
521
|
-
}
|
|
522
|
-
if (fullTextSourceIds.length < role.minimumFullText) {
|
|
523
|
-
gaps.push(`evidence role ${role.id} requires ${role.minimumFullText} full-text source(s), found ${fullTextSourceIds.length}`);
|
|
524
|
-
}
|
|
525
|
-
if (datedSourceIds.length < role.minimumDatedSources) {
|
|
526
|
-
gaps.push(`evidence role ${role.id} requires ${role.minimumDatedSources} dated source(s), found ${datedSourceIds.length}`);
|
|
527
|
-
}
|
|
528
|
-
for (const dimension of role.coverageDimensionIds) {
|
|
529
|
-
if (!coverageDimensionIds.includes(dimension)) {
|
|
530
|
-
gaps.push(`evidence role ${role.id} lacks atom coverage for dimension ${dimension}`);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
for (const sourceType of role.sourceTypeRequirements) {
|
|
534
|
-
if (!sourceTypes.includes(sourceType)) {
|
|
535
|
-
gaps.push(`evidence role ${role.id} lacks source type ${sourceType}`);
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return {
|
|
539
|
-
roleId: role.id,
|
|
540
|
-
sourceIds,
|
|
541
|
-
fullTextSourceIds,
|
|
542
|
-
datedSourceIds,
|
|
543
|
-
coverageDimensionIds,
|
|
544
|
-
sourceTypes,
|
|
545
|
-
decision: (gaps.length ? "insufficient" : "pass"),
|
|
546
|
-
gaps,
|
|
547
|
-
};
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
700
|
function artifactDescendsFrom(artifact, ancestorId, artifacts) {
|
|
551
701
|
const visited = new Set();
|
|
552
702
|
let current = artifact;
|