@kontourai/survey 0.1.4 → 0.1.5

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 CHANGED
@@ -198,6 +198,103 @@ Producer metadata is preserved. Producers still own item semantics,
198
198
  validation, candidate ranking, review policy, and whether a value should be
199
199
  verified, proposed, rejected, or assumed.
200
200
 
201
+ ## Candidate review records
202
+
203
+ Use `candidateReviewRecord` when a producer has multiple candidate observations
204
+ for the same target and wants Survey to assemble the shared candidate set,
205
+ candidate links, and optional review outcome.
206
+
207
+ ```ts
208
+ import {
209
+ candidateReviewRecord,
210
+ fieldObservation,
211
+ SurveyInputBuilder,
212
+ } from "@kontourai/survey";
213
+
214
+ const observations = [
215
+ fieldObservation({
216
+ id: "entity-123.status.registry",
217
+ field: "registrationStatus",
218
+ value: "ACTIVE",
219
+ rawSource: {
220
+ kind: "api-record",
221
+ sourceRef: "example-records://entity/entity-123",
222
+ observedAt: new Date().toISOString(),
223
+ locatorScheme: "structured-field",
224
+ },
225
+ extraction: {
226
+ confidence: 0.97,
227
+ locator: "json:$.registrationStatus",
228
+ extractor: "example-extractor",
229
+ extractedAt: new Date().toISOString(),
230
+ },
231
+ candidate: { id: "candidate.registry", confidence: 0.97 },
232
+ claim: {
233
+ id: "claim.entity-123.status.registry",
234
+ subjectType: "public-record.entity",
235
+ subjectId: "entity-123",
236
+ surface: "example.profile",
237
+ claimType: "public-data.field",
238
+ status: "verified",
239
+ impactLevel: "medium",
240
+ collectedBy: "example-extractor",
241
+ },
242
+ }),
243
+ fieldObservation({
244
+ id: "entity-123.status.archive",
245
+ field: "registrationStatus",
246
+ value: "INACTIVE",
247
+ rawSource: {
248
+ kind: "web-page",
249
+ sourceRef: "https://records.example.test/entity-123",
250
+ observedAt: new Date().toISOString(),
251
+ locatorScheme: "html",
252
+ },
253
+ extraction: {
254
+ confidence: 0.71,
255
+ locator: "css:#registration-status",
256
+ extractor: "example-crawler",
257
+ extractedAt: new Date().toISOString(),
258
+ },
259
+ candidate: { id: "candidate.archive", confidence: 0.71 },
260
+ claim: {
261
+ id: "claim.entity-123.status.archive",
262
+ subjectType: "public-record.entity",
263
+ subjectId: "entity-123",
264
+ surface: "example.profile",
265
+ claimType: "public-data.field",
266
+ status: "superseded",
267
+ impactLevel: "medium",
268
+ collectedBy: "example-crawler",
269
+ },
270
+ }),
271
+ ];
272
+
273
+ const surveyInput = new SurveyInputBuilder({ source: "example-producer:run-1" })
274
+ .addClaimRecords(candidateReviewRecord({
275
+ id: "candidate-set.entity-123.registration-status",
276
+ target: "registrationStatus",
277
+ selectedCandidateId: "candidate.registry",
278
+ status: "resolved",
279
+ rationale: "Registry source wins over archive source.",
280
+ reviewOutcome: {
281
+ status: "verified",
282
+ actor: "records-operator",
283
+ reviewedAt: new Date().toISOString(),
284
+ },
285
+ observations,
286
+ }))
287
+ .build();
288
+ ```
289
+
290
+ `candidateReviewRecord` does not choose the winning candidate or status. The
291
+ producer still supplies candidate ids, selected candidate id, claim ids, review
292
+ status, rationale, and all domain policy. Survey only assembles the generic
293
+ record graph and tolerates repeated references to identical raw sources or the
294
+ shared candidate set while rejecting conflicting duplicate ids. Duplicate
295
+ conflict checks assume Survey records are JSON-shaped data, which is the same
296
+ shape expected by Surface validation and reports.
297
+
201
298
  ## Product Boundary
202
299
 
203
300
  Survey does not crawl pages, parse PDFs, rank candidates, decide review policy,
@@ -37,6 +37,19 @@ export interface SurveyObservationInput {
37
37
  id?: string;
38
38
  };
39
39
  }
40
+ export interface CandidateReviewRecordInput {
41
+ id: string;
42
+ target: string;
43
+ observations: SurveyObservationInput[];
44
+ selectedCandidateId?: string;
45
+ status?: CandidateSet["status"];
46
+ rationale?: string;
47
+ metadata?: Record<string, unknown>;
48
+ reviewOutcome?: Omit<ReviewOutcome, "id" | "candidateSetId"> & {
49
+ id?: string;
50
+ candidateId?: string;
51
+ };
52
+ }
40
53
  export declare class SurveyInputBuilder {
41
54
  private readonly source;
42
55
  private readonly generatedAt;
@@ -58,4 +71,7 @@ export declare class SurveyInputBuilder {
58
71
  addObservation(observation: SurveyObservationInput): this;
59
72
  addObservations(observations: SurveyObservationInput[]): this;
60
73
  build(): SurveyInput;
74
+ private addRecordCandidateSet;
75
+ private addRecordRawSource;
61
76
  }
77
+ export declare function candidateReviewRecord(input: CandidateReviewRecordInput): SurveyClaimRecord[];
@@ -36,9 +36,9 @@ export class SurveyInputBuilder {
36
36
  return this;
37
37
  }
38
38
  addClaimRecord(record) {
39
- this.addRawSource(record.rawSource);
39
+ this.addRecordRawSource(record.rawSource);
40
40
  this.addExtraction(record.extraction);
41
- this.addCandidateSet(record.candidateSet);
41
+ this.addRecordCandidateSet(record.candidateSet);
42
42
  if (record.reviewOutcome)
43
43
  this.addReviewOutcome(record.reviewOutcome);
44
44
  this.addClaim(record.claim);
@@ -69,6 +69,77 @@ export class SurveyInputBuilder {
69
69
  derivedClaims: [...this.derivedClaims.values()],
70
70
  };
71
71
  }
72
+ addRecordCandidateSet(candidateSet) {
73
+ addIdempotent(this.candidateSets, candidateSet, "candidate set");
74
+ }
75
+ addRecordRawSource(rawSource) {
76
+ addIdempotent(this.rawSources, rawSource, "raw source");
77
+ }
78
+ }
79
+ export function candidateReviewRecord(input) {
80
+ if (input.observations.length === 0) {
81
+ throw new Error(`Candidate review record ${input.id} needs at least one observation`);
82
+ }
83
+ const records = input.observations.map((observation) => observationToClaimRecord(observation));
84
+ const candidateSetId = input.id;
85
+ const selectedCandidateId = input.selectedCandidateId ?? input.reviewOutcome?.candidateId;
86
+ const candidates = records.map((record) => record.candidateSet.candidates[0]);
87
+ assertUniqueCandidateIds(candidates, candidateSetId);
88
+ assertCandidateIdExists(candidates, selectedCandidateId, candidateSetId, "selected");
89
+ if (input.selectedCandidateId && input.reviewOutcome?.candidateId && input.selectedCandidateId !== input.reviewOutcome.candidateId) {
90
+ throw new Error(`Candidate review record ${candidateSetId} has conflicting selected and review candidate ids`);
91
+ }
92
+ if (input.reviewOutcome && !selectedCandidateId) {
93
+ throw new Error(`Candidate review record ${candidateSetId} needs a selected candidate id for review outcome`);
94
+ }
95
+ const candidateSet = {
96
+ id: candidateSetId,
97
+ target: input.target,
98
+ candidates,
99
+ selectedCandidateId,
100
+ status: input.status ?? candidateSetStatusFor(input.reviewOutcome?.status),
101
+ rationale: input.rationale,
102
+ metadata: input.metadata,
103
+ };
104
+ const reviewCandidateId = input.reviewOutcome?.candidateId ?? selectedCandidateId;
105
+ const reviewOutcome = input.reviewOutcome
106
+ ? {
107
+ id: input.reviewOutcome.id ?? `${candidateSetId}.review`,
108
+ candidateSetId,
109
+ ...input.reviewOutcome,
110
+ candidateId: reviewCandidateId,
111
+ }
112
+ : undefined;
113
+ assertCandidateIdExists(candidates, reviewCandidateId, candidateSetId, "review");
114
+ return records.map((record) => {
115
+ const candidate = record.candidateSet.candidates[0];
116
+ return {
117
+ ...record,
118
+ candidateSet,
119
+ reviewOutcome: candidate.id === reviewCandidateId ? reviewOutcome : undefined,
120
+ claim: {
121
+ ...record.claim,
122
+ candidateSetId,
123
+ candidateId: candidate.id,
124
+ },
125
+ };
126
+ });
127
+ }
128
+ function assertUniqueCandidateIds(candidates, candidateSetId) {
129
+ const seen = new Set();
130
+ for (const candidate of candidates) {
131
+ if (seen.has(candidate.id)) {
132
+ throw new Error(`Candidate review record ${candidateSetId} has duplicate candidate id: ${candidate.id}`);
133
+ }
134
+ seen.add(candidate.id);
135
+ }
136
+ }
137
+ function assertCandidateIdExists(candidates, candidateId, candidateSetId, role) {
138
+ if (!candidateId)
139
+ return;
140
+ if (!candidates.some((candidate) => candidate.id === candidateId)) {
141
+ throw new Error(`Candidate review record ${candidateSetId} does not contain ${role} candidate ${candidateId}`);
142
+ }
72
143
  }
73
144
  function observationToClaimRecord(observation) {
74
145
  const ids = observationIds(observation.id, observation);
@@ -138,3 +209,25 @@ function addUnique(map, item, label) {
138
209
  throw new Error(`Duplicate ${label} id: ${item.id}`);
139
210
  map.set(item.id, item);
140
211
  }
212
+ function addIdempotent(map, item, label) {
213
+ const existing = map.get(item.id);
214
+ if (existing) {
215
+ if (stableStringify(existing) !== stableStringify(item)) {
216
+ throw new Error(`Conflicting ${label} id: ${item.id}`);
217
+ }
218
+ return;
219
+ }
220
+ map.set(item.id, item);
221
+ }
222
+ function stableStringify(value) {
223
+ return JSON.stringify(sortValue(value));
224
+ }
225
+ function sortValue(value) {
226
+ if (Array.isArray(value))
227
+ return value.map(sortValue);
228
+ if (!value || typeof value !== "object")
229
+ return value;
230
+ return Object.fromEntries(Object.entries(value)
231
+ .sort(([left], [right]) => left.localeCompare(right))
232
+ .map(([key, item]) => [key, sortValue(item)]));
233
+ }
@@ -1,6 +1,6 @@
1
1
  export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, DerivedClaimTarget, Extraction, LocatorScheme, RawSource, RawSourceKind, ReviewOutcome, ReviewStatus, SurveyInput, } from "./types.js";
2
- export { SurveyInputBuilder } from "./builder.js";
3
- export type { SurveyClaimRecord, SurveyInputBuilderArgs, SurveyObservationInput } from "./builder.js";
2
+ export { candidateReviewRecord, SurveyInputBuilder } from "./builder.js";
3
+ export type { CandidateReviewRecordInput, SurveyClaimRecord, SurveyInputBuilderArgs, SurveyObservationInput, } from "./builder.js";
4
4
  export { buildSurveyTrustInput } from "./to-surface.js";
5
5
  export { fieldObservation } from "./field-observation.js";
6
6
  export type { FieldObservationInput } from "./field-observation.js";
package/dist/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { SurveyInputBuilder } from "./builder.js";
1
+ export { candidateReviewRecord, SurveyInputBuilder } from "./builder.js";
2
2
  export { buildSurveyTrustInput } from "./to-surface.js";
3
3
  export { fieldObservation } from "./field-observation.js";
4
4
  export { repeatedObservation } from "./repeated-observation.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",