pi-background-tasks 0.4.0 → 0.7.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.
@@ -0,0 +1,362 @@
1
+ import { parseJsonText, type JsonObject } from '../common.js';
2
+ import {
3
+ FUSION_CANDIDATE_IDS,
4
+ FUSION_EVALUATION_SCHEMA_VERSION,
5
+ FusionError,
6
+ type CandidateAssessment,
7
+ type FusionCandidateId,
8
+ type FusionConflict,
9
+ type FusionConflictPosition,
10
+ type FusionEvaluationV1,
11
+ type FusionSynthesisContribution,
12
+ type FusionSynthesisPlan,
13
+ } from './types.js';
14
+
15
+ const MAX_REPAIR_ERROR_CHARS = 500;
16
+ const MAX_REPAIR_ERROR_COUNT = 24;
17
+ const MAX_REPAIR_ERROR_TOTAL_CHARS = 4000;
18
+
19
+ export type FusionEvaluationValidationResult =
20
+ | { ok: true; value: FusionEvaluationV1 }
21
+ | { ok: false; errors: readonly string[] };
22
+
23
+ function isRecord(value: unknown): value is JsonObject {
24
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
25
+ }
26
+
27
+ function closed(
28
+ record: JsonObject,
29
+ keys: readonly string[],
30
+ label: string,
31
+ errors: string[],
32
+ ): void {
33
+ const expected = new Set(keys);
34
+ for (const key of Object.keys(record)) {
35
+ if (!expected.has(key)) errors.push(`${label} contains unknown key ${key}`);
36
+ }
37
+ for (const key of keys) {
38
+ if (!Object.prototype.hasOwnProperty.call(record, key))
39
+ errors.push(`${label} is missing key ${key}`);
40
+ }
41
+ }
42
+
43
+ function nonBlankString(value: unknown, label: string, errors: string[]): string | undefined {
44
+ if (typeof value !== 'string') {
45
+ errors.push(`${label} must be a string`);
46
+ return undefined;
47
+ }
48
+ if (value.trim().length === 0) {
49
+ errors.push(`${label} must be non-blank`);
50
+ return undefined;
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function stringList(
56
+ value: unknown,
57
+ label: string,
58
+ errors: string[],
59
+ ): readonly string[] | undefined {
60
+ if (!Array.isArray(value)) {
61
+ errors.push(`${label} must be an array`);
62
+ return undefined;
63
+ }
64
+ const out: string[] = [];
65
+ for (const [index, item] of value.entries()) {
66
+ const parsed = nonBlankString(item, `${label}[${String(index)}]`, errors);
67
+ if (parsed !== undefined) out.push(parsed);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ function candidateId(
73
+ value: unknown,
74
+ label: string,
75
+ errors: string[],
76
+ ): FusionCandidateId | undefined {
77
+ if (value === 'A' || value === 'B' || value === 'C') return value;
78
+ errors.push(`${label} must be A, B, or C`);
79
+ return undefined;
80
+ }
81
+
82
+ function tuple3<T>(
83
+ items: readonly T[],
84
+ label: string,
85
+ errors: string[],
86
+ ): readonly [T, T, T] | undefined {
87
+ if (items.length !== 3) {
88
+ errors.push(`${label} must contain exactly three entries`);
89
+ return undefined;
90
+ }
91
+ const first = items[0];
92
+ const second = items[1];
93
+ const third = items[2];
94
+ if (first === undefined || second === undefined || third === undefined) {
95
+ errors.push(`${label} must not contain empty positions`);
96
+ return undefined;
97
+ }
98
+ return [first, second, third];
99
+ }
100
+
101
+ function parseAssessment(
102
+ value: unknown,
103
+ label: string,
104
+ errors: string[],
105
+ ): CandidateAssessment | undefined {
106
+ if (!isRecord(value)) {
107
+ errors.push(`${label} must be an object`);
108
+ return undefined;
109
+ }
110
+ closed(
111
+ value,
112
+ ['candidate_id', 'summary', 'strengths', 'limitations', 'useful_contributions', 'risks'],
113
+ label,
114
+ errors,
115
+ );
116
+ const id = candidateId(value['candidate_id'], `${label}.candidate_id`, errors);
117
+ const summary = nonBlankString(value['summary'], `${label}.summary`, errors);
118
+ const strengths = stringList(value['strengths'], `${label}.strengths`, errors);
119
+ const limitations = stringList(value['limitations'], `${label}.limitations`, errors);
120
+ const useful = stringList(value['useful_contributions'], `${label}.useful_contributions`, errors);
121
+ const risks = stringList(value['risks'], `${label}.risks`, errors);
122
+ if (
123
+ id === undefined ||
124
+ summary === undefined ||
125
+ strengths === undefined ||
126
+ limitations === undefined ||
127
+ useful === undefined ||
128
+ risks === undefined
129
+ ) {
130
+ return undefined;
131
+ }
132
+ return { candidate_id: id, summary, strengths, limitations, useful_contributions: useful, risks };
133
+ }
134
+
135
+ function parsePosition(
136
+ value: unknown,
137
+ label: string,
138
+ errors: string[],
139
+ ): FusionConflictPosition | undefined {
140
+ if (!isRecord(value)) {
141
+ errors.push(`${label} must be an object`);
142
+ return undefined;
143
+ }
144
+ closed(value, ['candidate_id', 'position'], label, errors);
145
+ const id = candidateId(value['candidate_id'], `${label}.candidate_id`, errors);
146
+ const position = nonBlankString(value['position'], `${label}.position`, errors);
147
+ if (id === undefined || position === undefined) return undefined;
148
+ return { candidate_id: id, position };
149
+ }
150
+
151
+ function parseConflict(
152
+ value: unknown,
153
+ label: string,
154
+ errors: string[],
155
+ ): FusionConflict | undefined {
156
+ if (!isRecord(value)) {
157
+ errors.push(`${label} must be an object`);
158
+ return undefined;
159
+ }
160
+ closed(value, ['topic', 'positions', 'resolution'], label, errors);
161
+ const topic = nonBlankString(value['topic'], `${label}.topic`, errors);
162
+ const positionsRaw = value['positions'];
163
+ const positions: FusionConflictPosition[] = [];
164
+ if (!Array.isArray(positionsRaw)) {
165
+ errors.push(`${label}.positions must be an array`);
166
+ } else {
167
+ for (const [index, item] of positionsRaw.entries()) {
168
+ const parsed = parsePosition(item, `${label}.positions[${String(index)}]`, errors);
169
+ if (parsed !== undefined) positions.push(parsed);
170
+ }
171
+ const distinctIds = new Set(positions.map((position) => position.candidate_id));
172
+ if (distinctIds.size < 2)
173
+ errors.push(`${label}.positions must include at least two distinct candidates`);
174
+ if (distinctIds.size !== positions.length)
175
+ errors.push(`${label}.positions candidate_id values must be unique`);
176
+ }
177
+ const resolution = nonBlankString(value['resolution'], `${label}.resolution`, errors);
178
+ if (topic === undefined || resolution === undefined || !Array.isArray(positionsRaw))
179
+ return undefined;
180
+ return { topic, positions, resolution };
181
+ }
182
+
183
+ function parseContribution(
184
+ value: unknown,
185
+ label: string,
186
+ errors: string[],
187
+ ): FusionSynthesisContribution | undefined {
188
+ if (!isRecord(value)) {
189
+ errors.push(`${label} must be an object`);
190
+ return undefined;
191
+ }
192
+ closed(value, ['candidate_id', 'contribution'], label, errors);
193
+ const id = candidateId(value['candidate_id'], `${label}.candidate_id`, errors);
194
+ const contribution = nonBlankString(value['contribution'], `${label}.contribution`, errors);
195
+ if (id === undefined || contribution === undefined) return undefined;
196
+ return { candidate_id: id, contribution };
197
+ }
198
+
199
+ function parseContributionList(
200
+ value: unknown,
201
+ label: string,
202
+ errors: string[],
203
+ ): readonly FusionSynthesisContribution[] | undefined {
204
+ if (!Array.isArray(value)) {
205
+ errors.push(`${label} must be an array`);
206
+ return undefined;
207
+ }
208
+ const out: FusionSynthesisContribution[] = [];
209
+ for (const [index, item] of value.entries()) {
210
+ const parsed = parseContribution(item, `${label}[${String(index)}]`, errors);
211
+ if (parsed !== undefined) out.push(parsed);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ function parseSynthesisPlan(
217
+ value: unknown,
218
+ label: string,
219
+ errors: string[],
220
+ ): FusionSynthesisPlan | undefined {
221
+ if (!isRecord(value)) {
222
+ errors.push(`${label} must be an object`);
223
+ return undefined;
224
+ }
225
+ closed(value, ['must_include', 'must_resolve', 'must_avoid'], label, errors);
226
+ const include = parseContributionList(value['must_include'], `${label}.must_include`, errors);
227
+ const resolve = stringList(value['must_resolve'], `${label}.must_resolve`, errors);
228
+ const avoid = stringList(value['must_avoid'], `${label}.must_avoid`, errors);
229
+ if (include === undefined || resolve === undefined || avoid === undefined) return undefined;
230
+ return { must_include: include, must_resolve: resolve, must_avoid: avoid };
231
+ }
232
+
233
+ function parseAssessmentList(
234
+ value: unknown,
235
+ label: string,
236
+ errors: string[],
237
+ ): readonly [CandidateAssessment, CandidateAssessment, CandidateAssessment] | undefined {
238
+ if (!Array.isArray(value)) {
239
+ errors.push(`${label} must be an array`);
240
+ return undefined;
241
+ }
242
+ const parsed: CandidateAssessment[] = [];
243
+ for (const [index, item] of value.entries()) {
244
+ const assessment = parseAssessment(item, `${label}[${String(index)}]`, errors);
245
+ if (assessment !== undefined) parsed.push(assessment);
246
+ }
247
+ const ids = new Set(parsed.map((assessment) => assessment.candidate_id));
248
+ for (const id of FUSION_CANDIDATE_IDS) {
249
+ if (!ids.has(id)) errors.push(`${label} must contain candidate ${id}`);
250
+ }
251
+ if (ids.size !== parsed.length) errors.push(`${label} candidate_id values must be unique`);
252
+ return tuple3(parsed, label, errors);
253
+ }
254
+
255
+ function parseConflictList(
256
+ value: unknown,
257
+ label: string,
258
+ errors: string[],
259
+ ): readonly FusionConflict[] | undefined {
260
+ if (!Array.isArray(value)) {
261
+ errors.push(`${label} must be an array`);
262
+ return undefined;
263
+ }
264
+ const out: FusionConflict[] = [];
265
+ for (const [index, item] of value.entries()) {
266
+ const parsed = parseConflict(item, `${label}[${String(index)}]`, errors);
267
+ if (parsed !== undefined) out.push(parsed);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ export function validateFusionEvaluation(value: unknown): FusionEvaluationValidationResult {
273
+ const errors: string[] = [];
274
+ if (!isRecord(value)) return { ok: false, errors: ['evaluation must be a JSON object'] };
275
+ closed(
276
+ value,
277
+ ['schema_version', 'candidate_assessments', 'agreements', 'conflicts', 'synthesis_plan'],
278
+ 'evaluation',
279
+ errors,
280
+ );
281
+ if (value['schema_version'] !== FUSION_EVALUATION_SCHEMA_VERSION) {
282
+ errors.push('evaluation.schema_version mismatch');
283
+ }
284
+ const assessments = parseAssessmentList(
285
+ value['candidate_assessments'],
286
+ 'evaluation.candidate_assessments',
287
+ errors,
288
+ );
289
+ const agreements = stringList(value['agreements'], 'evaluation.agreements', errors);
290
+ const conflicts = parseConflictList(value['conflicts'], 'evaluation.conflicts', errors);
291
+ const plan = parseSynthesisPlan(value['synthesis_plan'], 'evaluation.synthesis_plan', errors);
292
+ if (
293
+ errors.length > 0 ||
294
+ assessments === undefined ||
295
+ agreements === undefined ||
296
+ conflicts === undefined ||
297
+ plan === undefined
298
+ ) {
299
+ return { ok: false, errors };
300
+ }
301
+ return {
302
+ ok: true,
303
+ value: {
304
+ schema_version: FUSION_EVALUATION_SCHEMA_VERSION,
305
+ candidate_assessments: assessments,
306
+ agreements,
307
+ conflicts,
308
+ synthesis_plan: plan,
309
+ },
310
+ };
311
+ }
312
+
313
+ export function parseFusionEvaluation(text: string): FusionEvaluationV1 {
314
+ let parsed: unknown;
315
+ try {
316
+ parsed = parseJsonText(text);
317
+ } catch (error) {
318
+ throw new FusionError(
319
+ `evaluation output must be JSON only: ${error instanceof Error ? error.message : String(error)}`,
320
+ {
321
+ code: 'evaluation_invalid',
322
+ stage: 'evaluation',
323
+ },
324
+ );
325
+ }
326
+ const result = validateFusionEvaluation(parsed);
327
+ if (!result.ok) {
328
+ throw new FusionError(
329
+ `evaluation output failed schema validation: ${formatEvaluationErrors(result.errors)}`,
330
+ {
331
+ code: 'evaluation_invalid',
332
+ stage: 'evaluation',
333
+ },
334
+ );
335
+ }
336
+ return result.value;
337
+ }
338
+
339
+ export function boundedEvaluationErrors(errors: readonly string[]): readonly string[] {
340
+ const bounded: string[] = [];
341
+ let total = 0;
342
+ for (const error of errors) {
343
+ if (bounded.length >= MAX_REPAIR_ERROR_COUNT) break;
344
+ const perError =
345
+ error.length <= MAX_REPAIR_ERROR_CHARS
346
+ ? error
347
+ : `${error.slice(0, MAX_REPAIR_ERROR_CHARS - 1)}…`;
348
+ const remaining = MAX_REPAIR_ERROR_TOTAL_CHARS - total;
349
+ if (remaining <= 0) break;
350
+ const next =
351
+ perError.length <= remaining ? perError : `${perError.slice(0, Math.max(0, remaining - 1))}…`;
352
+ bounded.push(next);
353
+ total += next.length;
354
+ }
355
+ if (errors.length > bounded.length)
356
+ bounded.push(`… ${String(errors.length - bounded.length)} more validation errors omitted`);
357
+ return bounded;
358
+ }
359
+
360
+ export function formatEvaluationErrors(errors: readonly string[]): string {
361
+ return boundedEvaluationErrors(errors).join('; ');
362
+ }