@kontourai/survey 1.4.0 → 1.5.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.
@@ -96,6 +96,30 @@ export interface UtteranceStatementRecords {
96
96
  candidate: Candidate;
97
97
  candidateSet: CandidateSet;
98
98
  }
99
+ interface BuildUtteranceRecordsParams {
100
+ sourceId: string;
101
+ utterance: string;
102
+ extracted: ExtractedStatement[];
103
+ extractorName: string;
104
+ observedAt: string;
105
+ }
106
+ interface UtteranceRecordsResult {
107
+ records: UtteranceStatementRecords[];
108
+ extractions: Extraction[];
109
+ candidateSets: CandidateSet[];
110
+ }
111
+ /**
112
+ * Build the full set of Survey records for every extracted statement in one
113
+ * utterance: per-statement Extractions/Candidates plus per-target grouped
114
+ * Candidate Sets. This is the shared orchestrator both `utteranceToSurveyInput`
115
+ * and `surveyAgentUtterance` call, so both callers derive these records
116
+ * identically — Slice 1's "single derivation path" invariant, preserved.
117
+ *
118
+ * `records[idx]` corresponds to `extracted[idx]` for every idx — `items` and
119
+ * `records` are both built via `.map` over the same `extracted[]` array in
120
+ * the same order; only `candidateSets` is deduped/grouped by target.
121
+ */
122
+ export declare function buildUtteranceRecords(params: BuildUtteranceRecordsParams): UtteranceRecordsResult;
99
123
  /**
100
124
  * Project an agent utterance and its extracted statements into the standard
101
125
  * SurveyInput shape so they can flow into buildSurveyTrustBundle.
@@ -164,3 +188,4 @@ export declare function surveyAgentUtterance(utterance: string, extractor: Utter
164
188
  * alone in this reference implementation.
165
189
  */
166
190
  export declare const referenceUtteranceExtractor: UtteranceClaimExtractor;
191
+ export {};
@@ -18,6 +18,177 @@
18
18
  */
19
19
  import { resolveInquiry } from "@kontourai/surface";
20
20
  import { lookupMapping, resolveQuestion } from "./inquiry-mapping.js";
21
+ import { projectProposalsToCandidateSet } from "./producer-profile.js";
22
+ /**
23
+ * The Candidate Conflict comparison key for an utterance-proposed value.
24
+ *
25
+ * Neither extractor (the reference extractor below, nor the Anthropic-backed
26
+ * one in `./anthropic.js`) normalizes `value` before it reaches
27
+ * `ExtractedStatement` — case and internal formatting are preserved
28
+ * verbatim. String values are the only case where "representation noise"
29
+ * (leading/trailing whitespace from excerpt boundaries, incidental case
30
+ * differences like "Healthy" vs "healthy") is plausible given the two
31
+ * extractors' actual output, so this key trims + lowercases STRING values
32
+ * in the COMPARISON KEY ONLY — the stored `Candidate.value`/`Extraction.value`
33
+ * stay byte-for-byte verbatim; this function only feeds `equivalenceKey`,
34
+ * never `value`. Non-string values (number, boolean, null — the other types
35
+ * the Anthropic tool schema permits) compare via exact canonical
36
+ * `JSON.stringify`, so there is no cross-type coercion that could silently
37
+ * equate e.g. "5" and 5, or lose a genuine numeric disagreement (5 vs 6 is
38
+ * never noise). This mirrors the Producer Profile core's established
39
+ * pattern: each profile decides its own narrow equivalence definition
40
+ * (see `./producer-profile.js`); the core itself does not own this decision.
41
+ */
42
+ function utteranceEquivalenceKey(value) {
43
+ const normalized = value ?? null;
44
+ if (typeof normalized === "string") {
45
+ return `str:${normalized.trim().toLowerCase()}`;
46
+ }
47
+ return `json:${JSON.stringify(normalized)}`;
48
+ }
49
+ /**
50
+ * Build the Extraction and CandidateSetProposal for a single extracted
51
+ * statement. This centralizes the Source Locator rule (span-first,
52
+ * excerpt-fallback — the single locator rule this module guarantees) and
53
+ * hands the resulting proposal off to `groupUtteranceExtractionsByTarget`
54
+ * for per-target projection through the Producer Profile core.
55
+ */
56
+ function buildUtteranceExtraction(params) {
57
+ const { sourceId, idx, statement, utterance, extractorName, observedAt } = params;
58
+ const statementId = `${sourceId}.statement.${idx}`;
59
+ const extractionId = `${statementId}.extraction`;
60
+ const candidateId = `${statementId}.candidate`;
61
+ // Compute locator — required for non-manual-entry sources
62
+ // (assertProducerDiscipline throws without it). Source Locator rule:
63
+ // span-first, excerpt-fallback — UNCHANGED from Slice 1.
64
+ const locator = spanToLocator(statement.span) ?? excerptLocator(utterance, statement.excerpt);
65
+ const extraction = {
66
+ id: extractionId,
67
+ sourceId,
68
+ target: canonicalTargetKey(statement.target),
69
+ value: statement.value ?? null,
70
+ confidence: statement.confidence,
71
+ locator,
72
+ excerpt: statement.excerpt,
73
+ extractor: extractorName,
74
+ extractedAt: observedAt,
75
+ metadata: {
76
+ agentUtterance: {
77
+ span: statement.span,
78
+ excerpt: statement.excerpt,
79
+ extractorName,
80
+ confidence: statement.confidence,
81
+ },
82
+ },
83
+ };
84
+ const proposal = {
85
+ candidateId,
86
+ extractionId,
87
+ value: statement.value ?? null,
88
+ confidence: statement.confidence,
89
+ equivalenceKey: utteranceEquivalenceKey(statement.value),
90
+ metadata: {
91
+ span: statement.span,
92
+ excerpt: statement.excerpt,
93
+ extractorName,
94
+ confidence: statement.confidence,
95
+ },
96
+ };
97
+ return { extraction, proposal };
98
+ }
99
+ /**
100
+ * Group extraction/proposal pairs by canonical target and project each
101
+ * group through the Producer Profile core's `projectProposalsToCandidateSet`
102
+ * — one Candidate Set per target, carrying every statement's Candidate for
103
+ * that target. Status is `"conflict"` when the group's statements disagree
104
+ * under `utteranceEquivalenceKey`, `"needs-review"` otherwise (including the
105
+ * common single-statement case, which reproduces Slice 1's exact prior
106
+ * per-statement behavior).
107
+ *
108
+ * `Map` preserves insertion order, so the returned groups (and therefore the
109
+ * `candidateSets` array `buildUtteranceRecords` derives from them) are in
110
+ * deterministic first-occurrence-of-target order across the utterance's
111
+ * statements.
112
+ */
113
+ function groupUtteranceExtractionsByTarget(sourceId, items) {
114
+ const order = [];
115
+ const byTarget = new Map();
116
+ for (const item of items) {
117
+ const key = canonicalTargetKey(item.statement.target);
118
+ if (!byTarget.has(key)) {
119
+ byTarget.set(key, []);
120
+ order.push(key);
121
+ }
122
+ byTarget.get(key).push(item);
123
+ }
124
+ const groups = new Map();
125
+ for (const targetKey of order) {
126
+ const groupItems = byTarget.get(targetKey);
127
+ const first = groupItems[0].statement.target;
128
+ const proposals = groupItems.map((i) => i.proposal);
129
+ const { candidateSet, candidates } = projectProposalsToCandidateSet(targetKey, proposals, {
130
+ candidateSetId: `${sourceId}.target.${targetKey}.candidate-set`,
131
+ candidateSetMetadata: {
132
+ agentUtterance: {
133
+ target: {
134
+ subjectType: first.subjectType,
135
+ subjectId: first.subjectId,
136
+ fieldOrBehavior: first.fieldOrBehavior,
137
+ },
138
+ statementCount: proposals.length,
139
+ },
140
+ },
141
+ candidateSetRationale: (status, groupProposals) => status === "conflict"
142
+ ? `${groupProposals.length} statement(s) disagree for ${targetKey}: ${[...new Set(groupProposals.map((p) => p.equivalenceKey))].join(", ")}`
143
+ : `${groupProposals.length} statement(s) agree for ${targetKey}.`,
144
+ });
145
+ // Mirrors schema-mapping's post-core convention: a winner only exists
146
+ // when the group agrees; a conflicting group has no selected candidate
147
+ // yet (nothing to select — that's the point of a Candidate Conflict,
148
+ // CONTEXT.md's "Candidate Conflict" entry). For a single-statement group
149
+ // this reproduces Slice 1's exact old behavior (selectedCandidateId ===
150
+ // the one candidate's id).
151
+ candidateSet.selectedCandidateId = candidateSet.status !== "conflict" ? candidates[0]?.id : undefined;
152
+ groups.set(targetKey, { targetKey, candidateSet, candidates });
153
+ }
154
+ return groups;
155
+ }
156
+ /**
157
+ * Build the full set of Survey records for every extracted statement in one
158
+ * utterance: per-statement Extractions/Candidates plus per-target grouped
159
+ * Candidate Sets. This is the shared orchestrator both `utteranceToSurveyInput`
160
+ * and `surveyAgentUtterance` call, so both callers derive these records
161
+ * identically — Slice 1's "single derivation path" invariant, preserved.
162
+ *
163
+ * `records[idx]` corresponds to `extracted[idx]` for every idx — `items` and
164
+ * `records` are both built via `.map` over the same `extracted[]` array in
165
+ * the same order; only `candidateSets` is deduped/grouped by target.
166
+ */
167
+ export function buildUtteranceRecords(params) {
168
+ const { sourceId, utterance, extracted, extractorName, observedAt } = params;
169
+ const items = extracted.map((statement, idx) => {
170
+ const { extraction, proposal } = buildUtteranceExtraction({
171
+ sourceId,
172
+ idx,
173
+ statement,
174
+ utterance,
175
+ extractorName,
176
+ observedAt,
177
+ });
178
+ return { statement, extraction, proposal };
179
+ });
180
+ const groups = groupUtteranceExtractionsByTarget(sourceId, items);
181
+ const records = items.map((item) => {
182
+ const group = groups.get(canonicalTargetKey(item.statement.target));
183
+ const candidate = group.candidates.find((c) => c.id === item.proposal.candidateId);
184
+ return { extraction: item.extraction, candidate, candidateSet: group.candidateSet };
185
+ });
186
+ return {
187
+ records,
188
+ extractions: items.map((i) => i.extraction),
189
+ candidateSets: [...groups.values()].map((g) => g.candidateSet),
190
+ };
191
+ }
21
192
  // ---------------------------------------------------------------------------
22
193
  // SurveyInput projection
23
194
  // ---------------------------------------------------------------------------
@@ -60,80 +231,35 @@ export function utteranceToSurveyInput(utterance, extracted, context) {
60
231
  inlineText: utterance,
61
232
  metadata: { agentId },
62
233
  };
63
- const extractions = [];
64
- const candidateSets = [];
65
- const claims = [];
66
- for (let idx = 0; idx < extracted.length; idx++) {
67
- const statement = extracted[idx];
234
+ // Batched, per-target-grouped provenance construction (Producer Profile
235
+ // core) — replaces the old per-statement builder call. Claims below stay
236
+ // one-per-statement; `record.candidateSet.id`/`record.candidate.id` may be
237
+ // shared across several claims when statements share a target (legal).
238
+ const { records, extractions, candidateSets } = buildUtteranceRecords({
239
+ sourceId,
240
+ utterance,
241
+ extracted,
242
+ extractorName,
243
+ observedAt,
244
+ });
245
+ const claims = extracted.map((statement, idx) => {
68
246
  const statementId = `${sourceId}.statement.${idx}`;
69
- const extractionId = `${statementId}.extraction`;
70
- const candidateId = `${statementId}.candidate`;
71
- const candidateSetId = `${statementId}.candidate-set`;
72
247
  const claimId = `${statementId}.claim`;
73
- // Compute locator — required for non-manual-entry sources
74
- // (assertProducerDiscipline throws without it)
75
- const locator = spanToLocator(statement.span) ?? excerptLocator(utterance, statement.excerpt);
76
- const extraction = {
77
- id: extractionId,
78
- sourceId,
79
- target: canonicalTargetKey(statement.target),
80
- value: statement.value ?? null,
81
- confidence: statement.confidence,
82
- locator,
83
- excerpt: statement.excerpt,
84
- extractor: extractorName,
85
- extractedAt: observedAt,
86
- metadata: {
87
- agentUtterance: {
88
- span: statement.span,
89
- excerpt: statement.excerpt,
90
- extractorName,
91
- confidence: statement.confidence,
92
- },
93
- },
94
- };
95
- const candidate = {
96
- id: candidateId,
97
- extractionId,
98
- value: statement.value ?? null,
99
- confidence: statement.confidence,
100
- metadata: {
101
- agentUtterance: {
102
- span: statement.span,
103
- excerpt: statement.excerpt,
104
- extractorName,
105
- confidence: statement.confidence,
106
- },
107
- },
108
- };
109
- // needs-review status → statusFor returns "proposed" (no review outcome)
110
- const candidateSet = {
111
- id: candidateSetId,
112
- target: canonicalTargetKey(statement.target),
113
- candidates: [candidate],
114
- selectedCandidateId: candidateId,
115
- status: "needs-review",
116
- metadata: {
117
- agentUtterance: {
118
- subjectType: statement.target.subjectType,
119
- subjectId: statement.target.subjectId,
120
- fieldOrBehavior: statement.target.fieldOrBehavior,
121
- },
122
- },
123
- };
124
- // Unreviewed: status is omitted so statusFor() computes "proposed"
125
- // assertProducerDiscipline: no verified/assumed without review → compliant
126
- const claimTarget = {
248
+ const record = records[idx];
249
+ // Unreviewed: status is omitted so statusFor() computes "proposed" (or
250
+ // "disputed" for a claim whose shared candidateSet.status is "conflict").
251
+ // assertProducerDiscipline: no verified/assumed without review → compliant.
252
+ return {
127
253
  id: claimId,
128
- candidateSetId,
129
- candidateId,
254
+ candidateSetId: record.candidateSet.id,
255
+ candidateId: record.candidate.id,
130
256
  subjectType: statement.target.subjectType,
131
257
  subjectId: statement.target.subjectId,
132
258
  facet: "agent-utterance.profile",
133
259
  claimType: "agent-extraction",
134
260
  fieldOrBehavior: statement.target.fieldOrBehavior,
135
261
  value: statement.value,
136
- // status intentionally omitted → computed as "proposed" by statusFor
262
+ // status intentionally omitted → computed by statusFor
137
263
  impactLevel: "low",
138
264
  collectedBy: extractorName,
139
265
  metadata: {
@@ -144,15 +270,12 @@ export function utteranceToSurveyInput(utterance, extracted, context) {
144
270
  excerpt: statement.excerpt,
145
271
  span: statement.span,
146
272
  confidence: statement.confidence,
147
- locator,
273
+ locator: record.extraction.locator,
148
274
  },
149
275
  },
150
276
  },
151
277
  };
152
- extractions.push(extraction);
153
- candidateSets.push(candidateSet);
154
- claims.push(claimTarget);
155
- }
278
+ });
156
279
  return {
157
280
  source,
158
281
  generatedAt: observedAt,
@@ -196,31 +319,22 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
196
319
  };
197
320
  // Step 2: Extract statements
198
321
  const extracted = await Promise.resolve(extractor.extract(utterance));
322
+ // Batched, grouped provenance construction — kept for provenance/doc-comment
323
+ // fidelity across the whole utterance (grouping needs every statement of a
324
+ // target's group present at once; a per-statement call cannot compute it).
325
+ // Still not surfaced on UtteranceStatement/UtteranceTrustReport this slice
326
+ // (unchanged from Slice 1's own scoping note) — wiring is left for a future
327
+ // slice, same as before.
328
+ buildUtteranceRecords({
329
+ sourceId,
330
+ utterance,
331
+ extracted,
332
+ extractorName: extractor.name,
333
+ observedAt,
334
+ });
199
335
  // Step 3 & 4: Resolve each statement and build the report
200
336
  const statements = [];
201
337
  for (const statement of extracted) {
202
- const statementId = `${sourceId}.statement.${statements.length}`;
203
- // Build Survey records for provenance
204
- const extractionId = `${statementId}.extraction`;
205
- const extraction = {
206
- id: extractionId,
207
- sourceId,
208
- target: canonicalTargetKey(statement.target),
209
- value: statement.value ?? null,
210
- confidence: statement.confidence,
211
- locator: statement.span ? `text-span:${statement.span.start}-${statement.span.end}` : undefined,
212
- excerpt: statement.excerpt,
213
- extractor: extractor.name,
214
- extractedAt: observedAt,
215
- metadata: {
216
- agentUtterance: {
217
- span: statement.span,
218
- excerpt: statement.excerpt,
219
- extractorName: extractor.name,
220
- confidence: statement.confidence,
221
- },
222
- },
223
- };
224
338
  // Resolve the claim
225
339
  let inquiryRecord;
226
340
  if (mappings && mappings.length > 0) {
@@ -253,8 +367,6 @@ export async function surveyAgentUtterance(utterance, extractor, context) {
253
367
  inquiryRecord,
254
368
  badge,
255
369
  });
256
- // Suppress unused-variable warning for extraction
257
- void extraction;
258
370
  }
259
371
  return { source, statements };
260
372
  }
@@ -15,8 +15,8 @@ import { watch } from "node:fs";
15
15
  import { readFile as readFileAsync, writeFile as writeFileAsync, rename as renameAsync } from "node:fs/promises";
16
16
  import { resolve, join, dirname, extname, normalize, sep } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import { replayReviewSessionEvents, defaultReviewSessionName, } from "../review-workbench/review-workbench.js";
19
- import { createServerReviewSessionRecord, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
18
+ import { defaultReviewSessionName, } from "../review-workbench/review-workbench.js";
19
+ import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
20
20
  // ---------------------------------------------------------------------------
21
21
  // Constants
22
22
  // ---------------------------------------------------------------------------
@@ -63,10 +63,6 @@ async function writeSessionAtomic(path, content) {
63
63
  await writeFileAsync(tmp, JSON.stringify(content, null, 2), "utf8");
64
64
  await renameAsync(tmp, path);
65
65
  }
66
- function currentState(content) {
67
- const { snapshot, events } = content;
68
- return events.length > 0 ? replayReviewSessionEvents(snapshot, events) : snapshot;
69
- }
70
66
  // ---------------------------------------------------------------------------
71
67
  // SSE broadcaster
72
68
  // ---------------------------------------------------------------------------
@@ -496,7 +492,7 @@ export async function startReviewConsoleServer(options) {
496
492
  session: content.session,
497
493
  snapshot: content.snapshot,
498
494
  events: content.events,
499
- state: currentState(content),
495
+ state: currentSessionState(content.snapshot, content.events),
500
496
  });
501
497
  return;
502
498
  }
@@ -1,20 +1,6 @@
1
1
  import type { SurveyObservationInput } from "./builder.js";
2
- export interface FieldObservationInput<TValue> {
3
- id: string;
4
- field: string;
5
- value: TValue;
6
- rawSource: SurveyObservationInput["rawSource"];
7
- extraction: Omit<SurveyObservationInput["extraction"], "target" | "value" | "excerpt"> & {
8
- target?: string;
9
- excerpt?: string | null;
10
- };
11
- reviewOutcome?: SurveyObservationInput["reviewOutcome"];
12
- claim: Omit<SurveyObservationInput["claim"], "fieldOrBehavior" | "value"> & {
13
- fieldOrBehavior?: string;
14
- };
15
- candidate?: SurveyObservationInput["candidate"];
16
- candidateSet?: SurveyObservationInput["candidateSet"];
2
+ import { type ObservationAuthoringInput } from "./observation-helper.js";
3
+ export interface FieldObservationInput<TValue> extends ObservationAuthoringInput<TValue> {
17
4
  representation?: "scalar";
18
- metadata?: Record<string, unknown>;
19
5
  }
20
6
  export declare function fieldObservation<TValue>(input: FieldObservationInput<TValue>): SurveyObservationInput;
@@ -1,16 +1,4 @@
1
- import { buildObservation } from "./observation-helper.js";
1
+ import { buildFieldObservation } from "./observation-helper.js";
2
2
  export function fieldObservation(input) {
3
- const representation = input.representation ?? "scalar";
4
- return buildObservation({
5
- ...input,
6
- surveyMetadata: {
7
- field: { representation },
8
- },
9
- defaultExcerpt: `${input.field}: ${valueSummary(input.value)}`,
10
- });
11
- }
12
- function valueSummary(value) {
13
- if (value === null || value === undefined)
14
- return "<empty>";
15
- return String(value);
3
+ return buildFieldObservation(input);
16
4
  }
@@ -20,6 +20,7 @@
20
20
  import type { DerivationRule, InquiryRecord, TrustBundle } from "@kontourai/surface";
21
21
  import type { CanonicalClaimTarget } from "@kontourai/surface";
22
22
  import type { Candidate, CandidateSet, ReviewOutcome } from "./types.js";
23
+ import type { ReviewItem } from "./review-resource.js";
23
24
  /**
24
25
  * A single machine- or human-generated suggestion that a natural-language
25
26
  * question maps to a canonical claim target or a named derivation rule.
@@ -197,52 +198,7 @@ export declare function resolveQuestion(bundle: TrustBundle, question: string, o
197
198
  export declare function buildMappingReviewItems(candidateSets: Array<{
198
199
  candidateSet: CandidateSet;
199
200
  candidates: Candidate[];
200
- }>): Array<{
201
- apiVersion: "survey.kontourai.io/v1alpha1";
202
- kind: "ReviewItem";
203
- metadata: {
204
- name: string;
205
- labels?: Record<string, string>;
206
- };
207
- spec: {
208
- target: string;
209
- candidates: Array<{
210
- id: string;
211
- role: "proposed";
212
- value: unknown;
213
- confidence?: number;
214
- source: {
215
- sourceRef: string;
216
- kind: "inquiry-question";
217
- observedAt: string;
218
- locatorScheme: "text";
219
- };
220
- extraction: {
221
- target: string;
222
- confidence?: number;
223
- extractor: string;
224
- extractedAt: string;
225
- };
226
- claimTarget: {
227
- subjectType: string;
228
- subjectId: string;
229
- facet: string;
230
- claimType: string;
231
- fieldOrBehavior: string;
232
- impactLevel: "low";
233
- };
234
- projection?: {
235
- candidateSetId: string;
236
- candidateId: string;
237
- };
238
- }>;
239
- candidateSetStatus: "needs-review" | "conflict";
240
- rationale?: string;
241
- };
242
- status: {
243
- observedCandidateCount: number;
244
- };
245
- }>;
201
+ }>): ReviewItem[];
246
202
  /**
247
203
  * Reference MappingProposer for tests.
248
204
  *
@@ -18,6 +18,8 @@
18
18
  * and lives in the flow-agents repo.
19
19
  */
20
20
  import { resolveInquiry } from "@kontourai/surface";
21
+ import { AUTO_ACCEPT_ACTOR, AUTO_ACCEPT_WITHIN_COMFORT_ZONE, getProducerProposal, hasCandidateConflict, meetsAutoAcceptThreshold, projectProposalsToCandidateSet, } from "./producer-profile.js";
22
+ import { reviewResourceApiVersion } from "./review-resource.js";
21
23
  // ---------------------------------------------------------------------------
22
24
  // Question normalization
23
25
  // ---------------------------------------------------------------------------
@@ -42,9 +44,18 @@ export function normalizeQuestion(question) {
42
44
  .trim()
43
45
  .replace(/[.?!,;]+$/u, "");
44
46
  }
45
- // ---------------------------------------------------------------------------
46
- // Proposal → CandidateSet projection
47
- // ---------------------------------------------------------------------------
47
+ /**
48
+ * The Candidate Conflict comparison key for a single mapping proposal: keys
49
+ * by canonical claim target (subjectType/subjectId/fieldOrBehavior) or by
50
+ * derivation rule id. Two proposals with the same key "agree"; more than one
51
+ * distinct key across a group of proposals is a conflict (see
52
+ * hasCandidateConflict).
53
+ */
54
+ function mappingEquivalenceKey(proposal) {
55
+ return proposal.proposedTarget
56
+ ? `target:${proposal.proposedTarget.subjectType}/${proposal.proposedTarget.subjectId}/${proposal.proposedTarget.fieldOrBehavior}`
57
+ : `rule:${proposal.proposedRuleId}`;
58
+ }
48
59
  /**
49
60
  * Project an array of proposals for a single question into Survey's existing
50
61
  * Candidate / CandidateSet shapes so they flow through the existing review
@@ -60,46 +71,33 @@ export function normalizeQuestion(question) {
60
71
  */
61
72
  export function proposalsToCandidateSet(question, proposals) {
62
73
  const normalized = normalizeQuestion(question);
63
- const candidates = proposals.map((proposal) => ({
64
- id: `mapping-candidate.${proposal.id}`,
74
+ const candidateSetProposals = proposals.map((proposal) => ({
75
+ candidateId: `mapping-candidate.${proposal.id}`,
65
76
  extractionId: proposal.id,
66
77
  value: proposal.proposedTarget ?? proposal.proposedRuleId ?? null,
67
78
  confidence: proposal.confidence,
79
+ equivalenceKey: mappingEquivalenceKey(proposal),
68
80
  metadata: {
69
- mappingProposal: {
70
- proposalId: proposal.id,
71
- proposedTarget: proposal.proposedTarget,
72
- proposedRuleId: proposal.proposedRuleId,
73
- confidence: proposal.confidence,
74
- rationale: proposal.rationale,
75
- excerpt: proposal.excerpt,
76
- proposedBy: proposal.proposedBy,
77
- proposedAt: proposal.proposedAt,
78
- },
81
+ proposalId: proposal.id,
82
+ proposedTarget: proposal.proposedTarget,
83
+ proposedRuleId: proposal.proposedRuleId,
84
+ confidence: proposal.confidence,
85
+ rationale: proposal.rationale,
86
+ excerpt: proposal.excerpt,
87
+ proposedBy: proposal.proposedBy,
88
+ proposedAt: proposal.proposedAt,
79
89
  },
80
90
  }));
81
- // Determine status: conflict if proposals disagree on target/rule
82
- const status = proposals.length > 1 && proposalsDisagree(proposals) ? "conflict" : "needs-review";
83
- const candidateSet = {
84
- id: `mapping-candidate-set.${normalized}`,
85
- target: normalized,
86
- candidates,
87
- status,
88
- metadata: {
91
+ return projectProposalsToCandidateSet(normalized, candidateSetProposals, {
92
+ candidateSetId: `mapping-candidate-set.${normalized}`,
93
+ candidateSetMetadata: {
89
94
  inquiryMapping: {
90
95
  question,
91
96
  normalizedQuestion: normalized,
92
97
  kind: "inquiry-question",
93
98
  },
94
99
  },
95
- };
96
- return { candidateSet, candidates };
97
- }
98
- function proposalsDisagree(proposals) {
99
- const keys = new Set(proposals.map((p) => p.proposedTarget
100
- ? `target:${p.proposedTarget.subjectType}/${p.proposedTarget.subjectId}/${p.proposedTarget.fieldOrBehavior}`
101
- : `rule:${p.proposedRuleId}`));
102
- return keys.size > 1;
100
+ });
103
101
  }
104
102
  // ---------------------------------------------------------------------------
105
103
  // Review outcome → InquiryMapping
@@ -119,7 +117,7 @@ export function applyMappingReview(candidateSet, reviewOutcome) {
119
117
  if (!candidate) {
120
118
  throw new Error(`applyMappingReview: no candidate found for id ${candidateId ?? "<none>"}`);
121
119
  }
122
- const meta = candidate.metadata?.mappingProposal;
120
+ const meta = getProducerProposal(candidate);
123
121
  const proposalId = meta?.proposalId ?? candidate.extractionId;
124
122
  const status = reviewOutcome.status === "verified" || reviewOutcome.status === "assumed" || reviewOutcome.status === "rejected"
125
123
  ? reviewOutcome.status
@@ -152,20 +150,20 @@ export function applyAutoAcceptPolicy(proposals, policy) {
152
150
  if (proposals.length === 0)
153
151
  return [];
154
152
  // If proposals disagree, none can be auto-accepted
155
- if (proposals.length > 1 && proposalsDisagree(proposals))
153
+ if (hasCandidateConflict(proposals.map((p) => ({ equivalenceKey: mappingEquivalenceKey(p) }))))
156
154
  return [];
157
155
  return proposals
158
- .filter((p) => p.confidence >= policy.minConfidence)
156
+ .filter((p) => meetsAutoAcceptThreshold(p.confidence, policy.minConfidence))
159
157
  .map((proposal) => ({
160
158
  id: `inquiry-mapping.auto.${normalizeQuestion(proposal.question)}`,
161
159
  normalizedQuestion: normalizeQuestion(proposal.question),
162
160
  target: proposal.proposedTarget,
163
161
  ruleId: proposal.proposedRuleId,
164
162
  status: "assumed",
165
- reviewedBy: "auto-accept-policy",
163
+ reviewedBy: AUTO_ACCEPT_ACTOR,
166
164
  reviewedAt: proposal.proposedAt,
167
165
  rationale: `Auto-accepted: confidence ${proposal.confidence} >= threshold ${policy.minConfidence}. ${proposal.rationale}`,
168
- withinComfortZone: true,
166
+ withinComfortZone: AUTO_ACCEPT_WITHIN_COMFORT_ZONE,
169
167
  proposalId: proposal.id,
170
168
  }));
171
169
  }
@@ -266,7 +264,7 @@ export function resolveQuestion(bundle, question, options) {
266
264
  */
267
265
  export function buildMappingReviewItems(candidateSets) {
268
266
  return candidateSets.map(({ candidateSet, candidates }) => ({
269
- apiVersion: "survey.kontourai.io/v1alpha1",
267
+ apiVersion: reviewResourceApiVersion,
270
268
  kind: "ReviewItem",
271
269
  metadata: {
272
270
  name: candidateSet.id,
@@ -275,7 +273,7 @@ export function buildMappingReviewItems(candidateSets) {
275
273
  spec: {
276
274
  target: candidateSet.target,
277
275
  candidates: candidates.map((candidate) => {
278
- const meta = candidate.metadata?.mappingProposal;
276
+ const meta = getProducerProposal(candidate);
279
277
  const targetOrRule = meta?.proposedTarget
280
278
  ? `${meta.proposedTarget.subjectType}/${meta.proposedTarget.subjectId}/${meta.proposedTarget.fieldOrBehavior}`
281
279
  : `rule:${meta?.proposedRuleId ?? "unknown"}`;