@kontourai/lookout 0.3.0 → 0.3.2
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 +189 -32
- package/dist/src/check-result.d.ts +2 -2
- package/dist/src/check-runner.d.ts +1 -1
- package/dist/src/check-runner.js +39 -26
- package/dist/src/drift-emission.js +2 -0
- package/dist/src/index.d.ts +8 -2
- package/dist/src/index.js +3 -1
- package/dist/src/observe-extract-diff.d.ts +90 -0
- package/dist/src/observe-extract-diff.js +154 -0
- package/dist/src/proposal-diff.d.ts +4 -0
- package/dist/src/proposal-diff.js +49 -4
- package/dist/src/registry.d.ts +16 -4
- package/dist/src/registry.js +107 -41
- package/dist/src/semantic-review-projection.d.ts +13 -0
- package/dist/src/semantic-review-projection.js +68 -0
- package/dist/src/semantic-review-types.d.ts +95 -0
- package/dist/src/semantic-review-types.js +1 -0
- package/dist/src/semantic-review-work.d.ts +5 -0
- package/dist/src/semantic-review-work.js +36 -0
- package/dist/src/snapshot-store.d.ts +11 -1
- package/dist/src/snapshot-store.js +18 -1
- package/package.json +5 -4
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { validatePreparedArtifact } from "@kontourai/traverse";
|
|
2
|
+
export function createObserveExtractDiff(options) {
|
|
3
|
+
return {
|
|
4
|
+
async observe(source) {
|
|
5
|
+
try {
|
|
6
|
+
let check;
|
|
7
|
+
try {
|
|
8
|
+
check = await options.acquisition.check(source);
|
|
9
|
+
}
|
|
10
|
+
catch (cause) {
|
|
11
|
+
return { ok: false, error: error("acquisition-threw", "Acquisition capability threw", cause) };
|
|
12
|
+
}
|
|
13
|
+
if (!isCheckResult(check)) {
|
|
14
|
+
return { ok: false, error: error("dependency-contract", "Acquisition capability returned an invalid check result") };
|
|
15
|
+
}
|
|
16
|
+
if (check.sourceId !== source.id || check.sourceUrl !== source.url) {
|
|
17
|
+
return { ok: false, error: error("dependency-contract", "Acquisition result does not identify the requested source") };
|
|
18
|
+
}
|
|
19
|
+
if (check.kind === "error") {
|
|
20
|
+
return record(options.recorder, baseObservation(source, check, "acquisition-error", null, null, null, null));
|
|
21
|
+
}
|
|
22
|
+
if (check.kind === "unchanged-304" || check.kind === "unchanged-hash") {
|
|
23
|
+
return record(options.recorder, baseObservation(source, check, "unchanged", snapshotFor(check), null, null, null));
|
|
24
|
+
}
|
|
25
|
+
const sourceSnapshot = snapshotFor(check);
|
|
26
|
+
let extraction;
|
|
27
|
+
try {
|
|
28
|
+
extraction = await options.extraction.extract({ source, snapshotRef: sourceSnapshot.currentSnapshotRef });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return record(options.recorder, baseObservation(source, check, "extraction-failure", sourceSnapshot, null, null, null));
|
|
32
|
+
}
|
|
33
|
+
if (!isExtractionResult(extraction)) {
|
|
34
|
+
return { ok: false, error: error("dependency-contract", "Extraction capability returned an invalid extraction result") };
|
|
35
|
+
}
|
|
36
|
+
const attempt = attemptFor(extraction);
|
|
37
|
+
const proposalSet = {
|
|
38
|
+
sourceId: source.id,
|
|
39
|
+
snapshotRef: sourceSnapshot.currentSnapshotRef,
|
|
40
|
+
observedAt: extraction.extractedAt,
|
|
41
|
+
proposals: extraction.proposals,
|
|
42
|
+
};
|
|
43
|
+
const outcome = outcomeFor(extraction);
|
|
44
|
+
if (outcome !== "extraction-failure") {
|
|
45
|
+
if (extraction.preparedArtifact === undefined) {
|
|
46
|
+
return { ok: false, error: error("dependency-contract", "Extraction result is missing its prepared artifact") };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (extraction.preparedArtifact !== undefined) {
|
|
50
|
+
const validation = validatePreparedArtifact(extraction.preparedArtifact);
|
|
51
|
+
if (validation.status !== "valid") {
|
|
52
|
+
return { ok: false, error: error("dependency-contract", `Extraction result has an invalid prepared artifact: ${validation.status}`) };
|
|
53
|
+
}
|
|
54
|
+
if (validation.artifact.sourceSnapshotRef !== sourceSnapshot.currentSnapshotRef) {
|
|
55
|
+
return { ok: false, error: error("dependency-contract", "Prepared artifact does not identify the current source snapshot") };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return record(options.recorder, baseObservation(source, check, outcome, sourceSnapshot, extraction.preparedArtifact ?? null, proposalSet, attempt));
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
return { ok: false, error: error("dependency-contract", "Observe-extract composition could not inspect a capability result", cause) };
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function baseObservation(source, check, outcome, sourceSnapshot, preparedArtifact, proposalSet, attempt) {
|
|
67
|
+
return {
|
|
68
|
+
source: { id: source.id, url: source.url, kind: source.kind },
|
|
69
|
+
check,
|
|
70
|
+
outcome,
|
|
71
|
+
sourceSnapshot,
|
|
72
|
+
preparedArtifact,
|
|
73
|
+
proposalSet,
|
|
74
|
+
attempt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function record(recorder, observation) {
|
|
78
|
+
try {
|
|
79
|
+
const identity = await recorder.record(observation);
|
|
80
|
+
if (!isIdentity(identity)) {
|
|
81
|
+
return { ok: false, error: error("dependency-contract", "Observation recorder returned an invalid identity"), observation };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
value: {
|
|
86
|
+
...observation,
|
|
87
|
+
observationId: identity.observationId,
|
|
88
|
+
priorObservationId: identity.priorObservationId,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
return { ok: false, error: error("recording-failed", "Observation recorder failed", cause), observation };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function snapshotFor(check) {
|
|
97
|
+
if (check.kind === "unchanged-304")
|
|
98
|
+
return { priorSnapshotRef: check.snapshotRef, currentSnapshotRef: check.snapshotRef };
|
|
99
|
+
return { priorSnapshotRef: check.priorSnapshotRef, currentSnapshotRef: check.currentSnapshotRef };
|
|
100
|
+
}
|
|
101
|
+
function attemptFor(result) {
|
|
102
|
+
return {
|
|
103
|
+
extractedAt: result.extractedAt,
|
|
104
|
+
providerCalls: result.providerCalls,
|
|
105
|
+
totalTokensUsed: result.totalTokensUsed,
|
|
106
|
+
...(result.partial === undefined ? {} : { partial: result.partial }),
|
|
107
|
+
...(result.providerFailures === undefined ? {} : {
|
|
108
|
+
providerFailures: result.providerFailures.map(({ kind, retryable }) => ({ kind, retryable })),
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function outcomeFor(result) {
|
|
113
|
+
const hasProviderFailures = (result.providerFailures?.length ?? 0) > 0;
|
|
114
|
+
if (result.partial !== undefined && hasProviderFailures)
|
|
115
|
+
return "partial-provider-failure";
|
|
116
|
+
if (hasProviderFailures)
|
|
117
|
+
return "provider-failure";
|
|
118
|
+
if (result.error !== undefined)
|
|
119
|
+
return "extraction-failure";
|
|
120
|
+
return result.partial === undefined ? "completed" : "partial";
|
|
121
|
+
}
|
|
122
|
+
function isCheckResult(value) {
|
|
123
|
+
if (typeof value !== "object" || value === null)
|
|
124
|
+
return false;
|
|
125
|
+
const result = value;
|
|
126
|
+
if (typeof result.sourceId !== "string" || typeof result.sourceUrl !== "string" ||
|
|
127
|
+
typeof result.checkedAt !== "string" || !Array.isArray(result.warnings))
|
|
128
|
+
return false;
|
|
129
|
+
if (result.kind === "unchanged-304")
|
|
130
|
+
return typeof result.snapshotRef === "string";
|
|
131
|
+
if (result.kind === "unchanged-hash")
|
|
132
|
+
return typeof result.priorSnapshotRef === "string" && typeof result.currentSnapshotRef === "string";
|
|
133
|
+
if (result.kind === "changed")
|
|
134
|
+
return (result.priorSnapshotRef === null || typeof result.priorSnapshotRef === "string") &&
|
|
135
|
+
typeof result.currentSnapshotRef === "string" && (result.changeBasis === "initial" || result.changeBasis === "hash");
|
|
136
|
+
return result.kind === "error" && (result.origin === "forage" || result.origin === "lookout") && result.error !== undefined;
|
|
137
|
+
}
|
|
138
|
+
function isExtractionResult(value) {
|
|
139
|
+
if (typeof value !== "object" || value === null)
|
|
140
|
+
return false;
|
|
141
|
+
const result = value;
|
|
142
|
+
return Array.isArray(result.proposals) && typeof result.extractedAt === "string" &&
|
|
143
|
+
Number.isFinite(result.providerCalls) && Number.isFinite(result.totalTokensUsed);
|
|
144
|
+
}
|
|
145
|
+
function isIdentity(value) {
|
|
146
|
+
if (typeof value !== "object" || value === null)
|
|
147
|
+
return false;
|
|
148
|
+
const identity = value;
|
|
149
|
+
return typeof identity.observationId === "string" && identity.observationId !== "" &&
|
|
150
|
+
(identity.priorObservationId === null || (typeof identity.priorObservationId === "string" && identity.priorObservationId !== ""));
|
|
151
|
+
}
|
|
152
|
+
function error(kind, text, cause) {
|
|
153
|
+
return { kind, message: text, ...(cause === undefined ? {} : { cause }) };
|
|
154
|
+
}
|
|
@@ -62,6 +62,10 @@ export interface ProposalSetFacts {
|
|
|
62
62
|
readonly removedProposalOccurrences: readonly ExtractionProposal[];
|
|
63
63
|
readonly provenanceChanges: readonly ProvenanceChangeFact[];
|
|
64
64
|
readonly removedEntities: readonly string[];
|
|
65
|
+
/** Exact observation-anchored evidence for every added proposal occurrence. */
|
|
66
|
+
readonly addedProposalEvidence?: readonly ProposalEvidence[];
|
|
67
|
+
/** Exact observation-anchored evidence for every removed proposal occurrence. */
|
|
68
|
+
readonly removedProposalEvidence?: readonly ProposalEvidence[];
|
|
65
69
|
}
|
|
66
70
|
export interface ProposalSetDiff {
|
|
67
71
|
readonly events: readonly ProposalDiffEvent[];
|
|
@@ -73,6 +73,26 @@ function changeKind(prior, current) {
|
|
|
73
73
|
const sameCategory = (prior === null ? "null" : typeof prior) === (current === null ? "null" : typeof current);
|
|
74
74
|
return { ok: true, value: sameCategory ? "value-updated" : "value-replaced" };
|
|
75
75
|
}
|
|
76
|
+
function semanticFieldOrder(items, fieldIdentity) {
|
|
77
|
+
const groups = new Map();
|
|
78
|
+
for (const [index, item] of items.entries()) {
|
|
79
|
+
const fieldKey = identity("fieldIdentity", () => fieldIdentity(item));
|
|
80
|
+
if (!fieldKey.ok)
|
|
81
|
+
return fieldKey;
|
|
82
|
+
let encoded;
|
|
83
|
+
try {
|
|
84
|
+
encoded = canonicalValueKey({ value: item.proposal.candidateValue, provenance: item.proposal.provenance });
|
|
85
|
+
}
|
|
86
|
+
catch (cause) {
|
|
87
|
+
return { ok: false, error: { kind: "unsupported-value", message: "Proposal semantic content could not be inspected", path: "$", cause } };
|
|
88
|
+
}
|
|
89
|
+
if (!encoded.ok)
|
|
90
|
+
return encoded;
|
|
91
|
+
groups.set(fieldKey.value, [...(groups.get(fieldKey.value) ?? []), { item, key: encoded.key, index }]);
|
|
92
|
+
}
|
|
93
|
+
const ordered = [...groups.values()].flatMap((group) => group.sort((left, right) => left.key.localeCompare(right.key) || left.index - right.index));
|
|
94
|
+
return { ok: true, value: ordered.map(({ item }) => item) };
|
|
95
|
+
}
|
|
76
96
|
export function diffProposalSets(input) {
|
|
77
97
|
if (input.prior.sourceId !== input.current.sourceId) {
|
|
78
98
|
return {
|
|
@@ -97,6 +117,8 @@ export function diffProposalSets(input) {
|
|
|
97
117
|
const removedProposalOccurrences = [];
|
|
98
118
|
const provenanceChanges = [];
|
|
99
119
|
const removedEntities = [];
|
|
120
|
+
const addedProposalEvidence = [];
|
|
121
|
+
const removedProposalEvidence = [];
|
|
100
122
|
for (const pair of entities.value.retained) {
|
|
101
123
|
const entityKeyResult = identity("entityIdentity", () => input.entityIdentity(pair.prior));
|
|
102
124
|
if (!entityKeyResult.ok)
|
|
@@ -116,9 +138,25 @@ export function diffProposalSets(input) {
|
|
|
116
138
|
retainedProposalOccurrences.push(...occurrences.value.retained);
|
|
117
139
|
addedProposalOccurrences.push(...occurrences.value.additions);
|
|
118
140
|
removedProposalOccurrences.push(...occurrences.value.removals);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
for (const proposal of occurrences.value.additions) {
|
|
142
|
+
const fieldKey = identity("fieldIdentity", () => input.fieldIdentity(pair.current, proposal));
|
|
143
|
+
if (!fieldKey.ok)
|
|
144
|
+
return fieldKey;
|
|
145
|
+
addedProposalEvidence.push(evidence(input.current, entityKey, fieldKey.value, proposal));
|
|
146
|
+
}
|
|
147
|
+
for (const proposal of occurrences.value.removals) {
|
|
148
|
+
const fieldKey = identity("fieldIdentity", () => input.fieldIdentity(pair.prior, proposal));
|
|
149
|
+
if (!fieldKey.ok)
|
|
150
|
+
return fieldKey;
|
|
151
|
+
removedProposalEvidence.push(evidence(input.prior, entityKey, fieldKey.value, proposal));
|
|
152
|
+
}
|
|
153
|
+
const priorFields = semanticFieldOrder(priorProposals.value.map((proposal) => ({ entity: pair.prior, proposal })), ({ entity, proposal }) => input.fieldIdentity(entity, proposal));
|
|
154
|
+
if (!priorFields.ok)
|
|
155
|
+
return priorFields;
|
|
156
|
+
const currentFields = semanticFieldOrder(currentProposals.value.map((proposal) => ({ entity: pair.current, proposal })), ({ entity, proposal }) => input.fieldIdentity(entity, proposal));
|
|
157
|
+
if (!currentFields.ok)
|
|
158
|
+
return currentFields;
|
|
159
|
+
const fields = diffKeyedMultiset(priorFields.value, currentFields.value, {
|
|
122
160
|
identity: ({ entity, proposal }) => input.fieldIdentity(entity, proposal),
|
|
123
161
|
});
|
|
124
162
|
if (!fields.ok)
|
|
@@ -184,6 +222,7 @@ export function diffProposalSets(input) {
|
|
|
184
222
|
if (!fieldKey.ok)
|
|
185
223
|
return fieldKey;
|
|
186
224
|
current.push(evidence(input.current, entityKeyResult.value, fieldKey.value, proposal));
|
|
225
|
+
addedProposalEvidence.push(evidence(input.current, entityKeyResult.value, fieldKey.value, proposal));
|
|
187
226
|
addedProposalOccurrences.push(proposal);
|
|
188
227
|
}
|
|
189
228
|
events.push({ kind: "new-entity-appeared", entityKey: entityKeyResult.value, current });
|
|
@@ -197,12 +236,18 @@ export function diffProposalSets(input) {
|
|
|
197
236
|
if (!proposals.ok)
|
|
198
237
|
return proposals;
|
|
199
238
|
removedProposalOccurrences.push(...proposals.value);
|
|
239
|
+
for (const proposal of proposals.value) {
|
|
240
|
+
const fieldKey = identity("fieldIdentity", () => input.fieldIdentity(entity, proposal));
|
|
241
|
+
if (!fieldKey.ok)
|
|
242
|
+
return fieldKey;
|
|
243
|
+
removedProposalEvidence.push(evidence(input.prior, entityKeyResult.value, fieldKey.value, proposal));
|
|
244
|
+
}
|
|
200
245
|
}
|
|
201
246
|
return {
|
|
202
247
|
ok: true,
|
|
203
248
|
value: {
|
|
204
249
|
events,
|
|
205
|
-
facts: { retainedProposalOccurrences, addedProposalOccurrences, removedProposalOccurrences, provenanceChanges, removedEntities },
|
|
250
|
+
facts: { retainedProposalOccurrences, addedProposalOccurrences, removedProposalOccurrences, provenanceChanges, removedEntities, addedProposalEvidence, removedProposalEvidence },
|
|
206
251
|
},
|
|
207
252
|
};
|
|
208
253
|
}
|
package/dist/src/registry.d.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
import type { TargetFieldSchema } from "@kontourai/traverse";
|
|
2
|
-
export type LookoutSourceKind = "web-page" | "api-record";
|
|
2
|
+
export type LookoutSourceKind = "web-page" | "api-record" | "structured-file";
|
|
3
|
+
export type StructuredFileFormat = "yaml" | "json" | "csv";
|
|
3
4
|
export type RenderPolicy = "never" | "on-shell-warning" | "always";
|
|
4
|
-
|
|
5
|
+
interface LookoutSourceBase {
|
|
5
6
|
id: string;
|
|
6
|
-
kind: LookoutSourceKind;
|
|
7
7
|
url: string;
|
|
8
|
-
targetSchema: TargetFieldSchema[];
|
|
9
8
|
cadenceHint: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ExtractableLookoutSource extends LookoutSourceBase {
|
|
11
|
+
kind: "web-page" | "api-record";
|
|
12
|
+
targetSchema: TargetFieldSchema[];
|
|
10
13
|
renderPolicy: RenderPolicy;
|
|
14
|
+
format?: never;
|
|
15
|
+
}
|
|
16
|
+
export interface StructuredFileLookoutSource extends LookoutSourceBase {
|
|
17
|
+
kind: "structured-file";
|
|
18
|
+
format: StructuredFileFormat;
|
|
19
|
+
targetSchema?: never;
|
|
20
|
+
renderPolicy?: never;
|
|
11
21
|
}
|
|
22
|
+
export type LookoutSource = ExtractableLookoutSource | StructuredFileLookoutSource;
|
|
12
23
|
export interface LookoutRegistryDocument {
|
|
13
24
|
version: 1;
|
|
14
25
|
sources: LookoutSource[];
|
|
@@ -27,3 +38,4 @@ export declare class LookoutRegistry {
|
|
|
27
38
|
}
|
|
28
39
|
export declare function loadRegistry(registryPath?: string): Promise<LookoutRegistry>;
|
|
29
40
|
export declare function parseRegistry(value: unknown): LookoutRegistry;
|
|
41
|
+
export {};
|
package/dist/src/registry.js
CHANGED
|
@@ -49,55 +49,120 @@ export function parseRegistry(value) {
|
|
|
49
49
|
const ids = new Map();
|
|
50
50
|
const sources = [];
|
|
51
51
|
value.sources.forEach((candidate, index) => {
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
const id = candidate.id;
|
|
58
|
-
const label = typeof id === "string" && id.trim() ? `${context} (id "${id}")` : context;
|
|
59
|
-
if (typeof id !== "string" || id.trim() === "") {
|
|
60
|
-
issues.push(`${context}.id must be a non-empty string`);
|
|
61
|
-
}
|
|
62
|
-
else {
|
|
63
|
-
const first = ids.get(id);
|
|
64
|
-
if (first !== undefined)
|
|
65
|
-
issues.push(`${label}.id duplicates sources[${first}].id`);
|
|
66
|
-
else
|
|
67
|
-
ids.set(id, index);
|
|
68
|
-
}
|
|
69
|
-
if (candidate.kind !== "web-page" && candidate.kind !== "api-record") {
|
|
70
|
-
issues.push(`${label}.kind must be "web-page" or "api-record"`);
|
|
71
|
-
}
|
|
72
|
-
if (!isHttpUrl(candidate.url)) {
|
|
73
|
-
issues.push(`${label}.url must be an absolute HTTP(S) URL`);
|
|
74
|
-
}
|
|
75
|
-
if (typeof candidate.cadenceHint !== "string" || candidate.cadenceHint.trim() === "") {
|
|
76
|
-
issues.push(`${label}.cadenceHint must be a non-empty string`);
|
|
77
|
-
}
|
|
78
|
-
if (candidate.renderPolicy !== "never" &&
|
|
79
|
-
candidate.renderPolicy !== "on-shell-warning" &&
|
|
80
|
-
candidate.renderPolicy !== "always") {
|
|
81
|
-
issues.push(`${label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
82
|
-
}
|
|
83
|
-
validateTargetSchema(candidate.targetSchema, label, issues);
|
|
84
|
-
if (typeof id === "string" && id.trim() !== "" &&
|
|
85
|
-
(candidate.kind === "web-page" || candidate.kind === "api-record") &&
|
|
86
|
-
isHttpUrl(candidate.url) &&
|
|
87
|
-
typeof candidate.cadenceHint === "string" && candidate.cadenceHint.trim() !== "" &&
|
|
88
|
-
(candidate.renderPolicy === "never" || candidate.renderPolicy === "on-shell-warning" || candidate.renderPolicy === "always") &&
|
|
89
|
-
Array.isArray(candidate.targetSchema)) {
|
|
90
|
-
sources.push(candidate);
|
|
91
|
-
}
|
|
52
|
+
const source = parseSource(candidate, index, ids, issues);
|
|
53
|
+
if (source !== undefined)
|
|
54
|
+
sources.push(source);
|
|
92
55
|
});
|
|
93
56
|
if (issues.length > 0)
|
|
94
57
|
throw new RegistryValidationError(issues);
|
|
95
58
|
return new LookoutRegistry(sources);
|
|
96
59
|
}
|
|
60
|
+
function parseSource(candidate, index, ids, issues) {
|
|
61
|
+
const context = `sources[${index}]`;
|
|
62
|
+
if (!isRecord(candidate)) {
|
|
63
|
+
issues.push(`${context} must be an object`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const firstIssue = issues.length;
|
|
67
|
+
const common = validateCommonSource(candidate, index, ids, issues);
|
|
68
|
+
const source = candidate.kind === "structured-file"
|
|
69
|
+
? parseStructuredFileSource(candidate, common, issues)
|
|
70
|
+
: candidate.kind === "web-page" || candidate.kind === "api-record"
|
|
71
|
+
? parseExtractableSource(candidate, candidate.kind, common, issues)
|
|
72
|
+
: invalidSourceKind(candidate, common.label, issues);
|
|
73
|
+
return issues.length === firstIssue ? source : undefined;
|
|
74
|
+
}
|
|
75
|
+
function validateCommonSource(candidate, index, ids, issues) {
|
|
76
|
+
const context = `sources[${index}]`;
|
|
77
|
+
const id = typeof candidate.id === "string" && candidate.id.trim() !== "" ? candidate.id : undefined;
|
|
78
|
+
const label = id === undefined ? context : `${context} (id "${id}")`;
|
|
79
|
+
if (id === undefined) {
|
|
80
|
+
issues.push(`${context}.id must be a non-empty string`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const first = ids.get(id);
|
|
84
|
+
if (first !== undefined)
|
|
85
|
+
issues.push(`${label}.id duplicates sources[${first}].id`);
|
|
86
|
+
else
|
|
87
|
+
ids.set(id, index);
|
|
88
|
+
}
|
|
89
|
+
const url = isHttpUrl(candidate.url) ? candidate.url : undefined;
|
|
90
|
+
if (url === undefined) {
|
|
91
|
+
issues.push(`${label}.url must be an absolute HTTP(S) URL`);
|
|
92
|
+
}
|
|
93
|
+
const cadenceHint = typeof candidate.cadenceHint === "string" && candidate.cadenceHint.trim() !== ""
|
|
94
|
+
? candidate.cadenceHint
|
|
95
|
+
: undefined;
|
|
96
|
+
if (cadenceHint === undefined) {
|
|
97
|
+
issues.push(`${label}.cadenceHint must be a non-empty string`);
|
|
98
|
+
}
|
|
99
|
+
return { id, url, cadenceHint, label };
|
|
100
|
+
}
|
|
101
|
+
function parseStructuredFileSource(candidate, common, issues) {
|
|
102
|
+
if (!isStructuredFileFormat(candidate.format)) {
|
|
103
|
+
issues.push(`${common.label}.format must be "yaml", "json", or "csv"`);
|
|
104
|
+
}
|
|
105
|
+
if (candidate.targetSchema !== undefined) {
|
|
106
|
+
issues.push(`${common.label}.targetSchema is not allowed for structured-file sources`);
|
|
107
|
+
}
|
|
108
|
+
if (candidate.renderPolicy !== undefined) {
|
|
109
|
+
issues.push(`${common.label}.renderPolicy is not allowed for structured-file sources`);
|
|
110
|
+
}
|
|
111
|
+
if (common.id === undefined || common.url === undefined || common.cadenceHint === undefined ||
|
|
112
|
+
!isStructuredFileFormat(candidate.format))
|
|
113
|
+
return undefined;
|
|
114
|
+
return {
|
|
115
|
+
id: common.id,
|
|
116
|
+
kind: "structured-file",
|
|
117
|
+
format: candidate.format,
|
|
118
|
+
url: common.url,
|
|
119
|
+
cadenceHint: common.cadenceHint,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function parseExtractableSource(candidate, kind, common, issues) {
|
|
123
|
+
if (!isRenderPolicy(candidate.renderPolicy)) {
|
|
124
|
+
issues.push(`${common.label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
125
|
+
}
|
|
126
|
+
const targetSchema = validateTargetSchema(candidate.targetSchema, common.label, issues)
|
|
127
|
+
? candidate.targetSchema
|
|
128
|
+
: undefined;
|
|
129
|
+
if (candidate.format !== undefined) {
|
|
130
|
+
issues.push(`${common.label}.format is only allowed for structured-file sources`);
|
|
131
|
+
}
|
|
132
|
+
if (common.id === undefined || common.url === undefined || common.cadenceHint === undefined ||
|
|
133
|
+
!isRenderPolicy(candidate.renderPolicy) || targetSchema === undefined)
|
|
134
|
+
return undefined;
|
|
135
|
+
return {
|
|
136
|
+
id: common.id,
|
|
137
|
+
kind,
|
|
138
|
+
url: common.url,
|
|
139
|
+
targetSchema,
|
|
140
|
+
cadenceHint: common.cadenceHint,
|
|
141
|
+
renderPolicy: candidate.renderPolicy,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function invalidSourceKind(candidate, label, issues) {
|
|
145
|
+
issues.push(`${label}.kind must be "web-page", "api-record", or "structured-file"`);
|
|
146
|
+
if (!isRenderPolicy(candidate.renderPolicy)) {
|
|
147
|
+
issues.push(`${label}.renderPolicy must be "never", "on-shell-warning", or "always"`);
|
|
148
|
+
}
|
|
149
|
+
validateTargetSchema(candidate.targetSchema, label, issues);
|
|
150
|
+
if (candidate.format !== undefined) {
|
|
151
|
+
issues.push(`${label}.format is only allowed for structured-file sources`);
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
function isStructuredFileFormat(value) {
|
|
156
|
+
return value === "yaml" || value === "json" || value === "csv";
|
|
157
|
+
}
|
|
158
|
+
function isRenderPolicy(value) {
|
|
159
|
+
return value === "never" || value === "on-shell-warning" || value === "always";
|
|
160
|
+
}
|
|
97
161
|
function validateTargetSchema(value, context, issues) {
|
|
162
|
+
const firstIssue = issues.length;
|
|
98
163
|
if (!Array.isArray(value)) {
|
|
99
164
|
issues.push(`${context}.targetSchema must be an array`);
|
|
100
|
-
return;
|
|
165
|
+
return false;
|
|
101
166
|
}
|
|
102
167
|
const allowedTypes = new Set(["string", "number", "boolean", "date", "enum", "array", "object"]);
|
|
103
168
|
value.forEach((field, index) => {
|
|
@@ -124,6 +189,7 @@ function validateTargetSchema(value, context, issues) {
|
|
|
124
189
|
issues.push(`${label}.inferenceType must be "explicit" or "inferred"`);
|
|
125
190
|
}
|
|
126
191
|
});
|
|
192
|
+
return issues.length === firstIssue;
|
|
127
193
|
}
|
|
128
194
|
function isRecord(value) {
|
|
129
195
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TargetFieldSchema } from "@kontourai/traverse";
|
|
2
|
+
import { type DiffResult } from "./canonical-value.js";
|
|
3
|
+
import type { ProposalSetDiff, ProposalSetObservation } from "./proposal-diff.js";
|
|
4
|
+
import { type SemanticClaimTarget, type SemanticObservationIdentity, type SemanticReviewChange, type SemanticReviewItem } from "./semantic-review-types.js";
|
|
5
|
+
export declare function reviewKey(value: unknown): DiffResult<string>;
|
|
6
|
+
export declare function sanitizeError<T>(result: DiffResult<T>): DiffResult<T>;
|
|
7
|
+
export declare function validateObservationIdentity(identity: SemanticObservationIdentity): DiffResult<SemanticObservationIdentity>;
|
|
8
|
+
export declare function collectSemanticChanges(input: {
|
|
9
|
+
prior: ProposalSetObservation;
|
|
10
|
+
current: ProposalSetObservation;
|
|
11
|
+
schema?: readonly TargetFieldSchema[];
|
|
12
|
+
}, diff: ProposalSetDiff): SemanticReviewChange[];
|
|
13
|
+
export declare function projectReviewItem(change: SemanticReviewChange, occurrence: number, transitionId: string, identities: SemanticObservationIdentity, prior: ProposalSetObservation, current: ProposalSetObservation, target: SemanticClaimTarget): DiffResult<SemanticReviewItem>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalValueKey } from "./canonical-value.js";
|
|
3
|
+
import { checkSchemaCoverage } from "./coverage.js";
|
|
4
|
+
import { semanticReviewApiVersion } from "./semantic-review-types.js";
|
|
5
|
+
export function reviewKey(value) {
|
|
6
|
+
const canonical = canonicalValueKey(value);
|
|
7
|
+
return canonical.ok ? { ok: true, value: createHash("sha256").update(canonical.key).digest("hex").slice(0, 24) } : sanitizeError(canonical);
|
|
8
|
+
}
|
|
9
|
+
export function sanitizeError(result) {
|
|
10
|
+
if (result.ok)
|
|
11
|
+
return result;
|
|
12
|
+
return { ok: false, error: { kind: result.error.kind, message: "Semantic review derivation failed" } };
|
|
13
|
+
}
|
|
14
|
+
export function validateObservationIdentity(identity) {
|
|
15
|
+
for (const [name, value] of Object.entries(identity))
|
|
16
|
+
if (typeof value !== "string" || value.length === 0 || /authorization\s*[:=]|bearer\s+|(?:token|secret|password|api[-_]?key)=/i.test(value)) {
|
|
17
|
+
return { ok: false, error: { kind: "unsupported-value", message: `${name} observation identity is invalid`, path: `$.observationIdentity.${name}` } };
|
|
18
|
+
}
|
|
19
|
+
return { ok: true, value: identity };
|
|
20
|
+
}
|
|
21
|
+
const incomplete = (evidence) => evidence.provenance.locator.length === 0 || evidence.provenance.excerpt.length === 0;
|
|
22
|
+
const sameEvidence = (left, right) => left.entityKey === right.entityKey && left.fieldKey === right.fieldKey && left.snapshotRef === right.snapshotRef && left.provenance.locator === right.provenance.locator && left.provenance.excerpt === right.provenance.excerpt;
|
|
23
|
+
const addedKind = (current) => incomplete(current) ? "provenance-gap" : "proposal-added";
|
|
24
|
+
export function collectSemanticChanges(input, diff) {
|
|
25
|
+
const changes = [];
|
|
26
|
+
for (const event of diff.events) {
|
|
27
|
+
if (event.kind === "new-entity-appeared")
|
|
28
|
+
for (const current of event.current)
|
|
29
|
+
changes.push({ kind: addedKind(current), fieldPath: current.fieldPath, entityKey: current.entityKey, current });
|
|
30
|
+
else if (event.prior && event.current)
|
|
31
|
+
changes.push({ kind: "proposal-value-changed", fieldPath: event.current.fieldPath, entityKey: event.entityKey, prior: event.prior, current: event.current });
|
|
32
|
+
else if (event.current)
|
|
33
|
+
changes.push({ kind: addedKind(event.current), fieldPath: event.current.fieldPath, entityKey: event.entityKey, current: event.current });
|
|
34
|
+
else if (event.prior)
|
|
35
|
+
changes.push({ kind: "proposal-removed", fieldPath: event.prior.fieldPath, entityKey: event.entityKey, prior: event.prior });
|
|
36
|
+
}
|
|
37
|
+
const representedPrior = changes.flatMap((change) => change.prior ? [change.prior] : []);
|
|
38
|
+
const representedCurrent = changes.flatMap((change) => change.current ? [change.current] : []);
|
|
39
|
+
for (const prior of diff.facts.removedProposalEvidence ?? [])
|
|
40
|
+
if (!diff.facts.provenanceChanges.some((item) => sameEvidence(item.prior, prior)) && !representedPrior.some((item) => sameEvidence(item, prior)))
|
|
41
|
+
changes.push({ kind: "proposal-removed", fieldPath: prior.fieldPath, entityKey: prior.entityKey, prior });
|
|
42
|
+
for (const current of diff.facts.addedProposalEvidence ?? [])
|
|
43
|
+
if (!diff.facts.provenanceChanges.some((item) => sameEvidence(item.current, current)) && !representedCurrent.some((item) => sameEvidence(item, current)))
|
|
44
|
+
changes.push({ kind: addedKind(current), fieldPath: current.fieldPath, entityKey: current.entityKey, current });
|
|
45
|
+
for (const change of diff.facts.provenanceChanges) {
|
|
46
|
+
const kind = incomplete(change.current) && !incomplete(change.prior) ? "provenance-gap" : change.prior.provenance.locator !== change.current.provenance.locator ? "proposal-moved" : "proposal-provenance-changed";
|
|
47
|
+
changes.push({ kind, fieldPath: change.current.fieldPath, entityKey: change.entityKey, prior: change.prior, current: change.current });
|
|
48
|
+
}
|
|
49
|
+
if (input.schema) {
|
|
50
|
+
const priorGaps = new Set(checkSchemaCoverage(input.schema, input.prior.proposals).gaps.map((gap) => gap.fieldPath));
|
|
51
|
+
for (const gap of checkSchemaCoverage(input.schema, input.current.proposals).gaps)
|
|
52
|
+
if (!priorGaps.has(gap.fieldPath))
|
|
53
|
+
changes.push({ kind: "coverage-gap", fieldPath: gap.fieldPath, entityKey: gap.fieldPath, gap });
|
|
54
|
+
}
|
|
55
|
+
return changes;
|
|
56
|
+
}
|
|
57
|
+
function candidate(id, role, observationId, evidence, fallback, target, fieldPath) {
|
|
58
|
+
const present = evidence !== undefined;
|
|
59
|
+
return { id: `${id}.${role}`, role, value: present ? evidence.value : null, ...(present ? { confidence: evidence.confidence } : {}), source: { sourceRef: evidence?.snapshotRef ?? fallback.snapshotRef, sourceId: evidence?.sourceId ?? fallback.sourceId, observedAt: evidence?.observedAt ?? fallback.observedAt, locatorScheme: "text-span" }, ...(present && !incomplete(evidence) ? { locator: { scheme: "text-span", locator: evidence.provenance.locator, excerpt: evidence.provenance.excerpt } } : {}), extraction: { extractionId: `${id}.${role}.extraction`, target: fieldPath, ...(present ? { confidence: evidence.confidence, extractor: evidence.extractor } : {}), extractedAt: evidence?.observedAt ?? fallback.observedAt }, claimTarget: target, producer: { "lookout.kontourai.io/semantic-transition": { observationId, evidenceState: present ? "present" : "absent" } } };
|
|
60
|
+
}
|
|
61
|
+
export function projectReviewItem(change, occurrence, transitionId, identities, prior, current, target) {
|
|
62
|
+
const itemIdentity = reviewKey({ transitionId, change, occurrence });
|
|
63
|
+
if (!itemIdentity.ok)
|
|
64
|
+
return itemIdentity;
|
|
65
|
+
const name = `lookout-semantic.${itemIdentity.value}`;
|
|
66
|
+
const candidates = [candidate(name, "current", identities.prior, change.prior, prior, target, change.fieldPath), candidate(name, "proposed", identities.current, change.current, current, target, change.fieldPath)];
|
|
67
|
+
return { ok: true, value: { apiVersion: semanticReviewApiVersion, kind: "ReviewItem", metadata: { name, producer: { "lookout.kontourai.io/semantic-transition": { semanticKind: change.kind, transitionId, priorObservationId: identities.prior, currentObservationId: identities.current } } }, spec: { target: change.fieldPath, candidates, candidateSetStatus: "needs-review", editable: false }, status: { observedCandidateCount: candidates.length } } };
|
|
68
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { TargetFieldSchema } from "@kontourai/traverse";
|
|
2
|
+
import type { SchemaCoverageGap } from "./coverage.js";
|
|
3
|
+
import type { ProposalEvidence, ProposalSetDiffInput, ProposalSetObservation } from "./proposal-diff.js";
|
|
4
|
+
export declare const semanticReviewApiVersion = "survey.kontourai.io/v1alpha1";
|
|
5
|
+
export type SemanticReviewKind = "proposal-added" | "proposal-removed" | "proposal-moved" | "proposal-provenance-changed" | "proposal-value-changed" | "coverage-gap" | "provenance-gap";
|
|
6
|
+
export interface SemanticObservationIdentity {
|
|
7
|
+
readonly prior: string;
|
|
8
|
+
readonly current: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SemanticClaimTarget {
|
|
11
|
+
readonly subjectType: string;
|
|
12
|
+
readonly subjectId: string;
|
|
13
|
+
readonly facet: string;
|
|
14
|
+
readonly claimType: string;
|
|
15
|
+
readonly fieldOrBehavior: string;
|
|
16
|
+
readonly impactLevel: "low" | "medium" | "high" | "critical";
|
|
17
|
+
readonly [key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface SemanticReviewCandidate {
|
|
20
|
+
readonly id: string;
|
|
21
|
+
readonly role: "current" | "proposed" | "source-version";
|
|
22
|
+
readonly value: unknown;
|
|
23
|
+
readonly confidence?: number;
|
|
24
|
+
readonly source: {
|
|
25
|
+
readonly sourceRef: string;
|
|
26
|
+
readonly sourceId: string;
|
|
27
|
+
readonly observedAt: string;
|
|
28
|
+
readonly locatorScheme: "text-span";
|
|
29
|
+
};
|
|
30
|
+
readonly locator?: {
|
|
31
|
+
readonly scheme: "text-span";
|
|
32
|
+
readonly locator: string;
|
|
33
|
+
readonly excerpt: string;
|
|
34
|
+
};
|
|
35
|
+
readonly extraction: {
|
|
36
|
+
readonly extractionId: string;
|
|
37
|
+
readonly target: string;
|
|
38
|
+
readonly confidence?: number;
|
|
39
|
+
readonly extractor?: string;
|
|
40
|
+
readonly extractedAt: string;
|
|
41
|
+
};
|
|
42
|
+
readonly claimTarget: SemanticClaimTarget;
|
|
43
|
+
readonly producer: {
|
|
44
|
+
readonly "lookout.kontourai.io/semantic-transition": {
|
|
45
|
+
readonly observationId: string;
|
|
46
|
+
readonly evidenceState: "present" | "absent";
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Structural match for Survey ReviewItem; Lookout deliberately imports no review package. */
|
|
51
|
+
export interface SemanticReviewItem {
|
|
52
|
+
readonly apiVersion: typeof semanticReviewApiVersion;
|
|
53
|
+
readonly kind: "ReviewItem";
|
|
54
|
+
readonly metadata: {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly producer: {
|
|
57
|
+
readonly "lookout.kontourai.io/semantic-transition": {
|
|
58
|
+
readonly semanticKind: SemanticReviewKind;
|
|
59
|
+
readonly transitionId: string;
|
|
60
|
+
readonly priorObservationId: string;
|
|
61
|
+
readonly currentObservationId: string;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
readonly spec: {
|
|
66
|
+
readonly target: string;
|
|
67
|
+
readonly candidates: SemanticReviewCandidate[];
|
|
68
|
+
readonly candidateSetStatus: "needs-review";
|
|
69
|
+
readonly editable: false;
|
|
70
|
+
};
|
|
71
|
+
readonly status: {
|
|
72
|
+
readonly observedCandidateCount: number;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export interface SemanticReviewChange {
|
|
76
|
+
readonly kind: SemanticReviewKind;
|
|
77
|
+
readonly fieldPath: string;
|
|
78
|
+
readonly entityKey: string;
|
|
79
|
+
readonly prior?: ProposalEvidence;
|
|
80
|
+
readonly current?: ProposalEvidence;
|
|
81
|
+
readonly gap?: SchemaCoverageGap;
|
|
82
|
+
}
|
|
83
|
+
export interface SemanticReviewWork {
|
|
84
|
+
readonly transitionId: string;
|
|
85
|
+
readonly priorObservationId: string;
|
|
86
|
+
readonly currentObservationId: string;
|
|
87
|
+
readonly items: readonly SemanticReviewItem[];
|
|
88
|
+
}
|
|
89
|
+
export interface BuildSemanticReviewWorkInput<E> extends Omit<ProposalSetDiffInput<E>, "prior" | "current"> {
|
|
90
|
+
readonly prior: ProposalSetObservation;
|
|
91
|
+
readonly current: ProposalSetObservation;
|
|
92
|
+
readonly observationIdentity: SemanticObservationIdentity;
|
|
93
|
+
readonly schema?: readonly TargetFieldSchema[];
|
|
94
|
+
readonly claimTarget: (change: SemanticReviewChange) => SemanticClaimTarget;
|
|
95
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const semanticReviewApiVersion = "survey.kontourai.io/v1alpha1";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { BuildSemanticReviewWorkInput, SemanticReviewWork } from "./semantic-review-types.js";
|
|
2
|
+
export { semanticReviewApiVersion } from "./semantic-review-types.js";
|
|
3
|
+
export type { BuildSemanticReviewWorkInput, SemanticClaimTarget, SemanticObservationIdentity, SemanticReviewCandidate, SemanticReviewChange, SemanticReviewItem, SemanticReviewKind, SemanticReviewWork } from "./semantic-review-types.js";
|
|
4
|
+
/** Project one genuine proposal-observation transition into deterministic review work. */
|
|
5
|
+
export declare function buildSemanticReviewWork<E>(input: BuildSemanticReviewWorkInput<E>): import("./canonical-value.js").DiffResult<SemanticReviewWork>;
|