@alexeiled/pi-fusion 0.5.2 → 0.6.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.
package/src/report.ts CHANGED
@@ -1,6 +1,18 @@
1
- import type { FailedPanelSummary, PanelOutput } from "./run-builder.js";
1
+ import {
2
+ buildBlindLabelMap,
3
+ type FailedPanelSummary,
4
+ type PanelOutput,
5
+ } from "./run-builder.js";
2
6
  import { summarizeProviderFailures } from "./run-observations.js";
3
- import type { FusionRun, ProviderFailure, RunObservation } from "./types.js";
7
+ import {
8
+ memberLabel,
9
+ panelItemLabel,
10
+ type FusionRun,
11
+ type FusionSynthesisMode,
12
+ type PanelMemberConfig,
13
+ type ProviderFailure,
14
+ type RunObservation,
15
+ } from "./types.js";
4
16
 
5
17
  type ReportRun = Pick<
6
18
  FusionRun,
@@ -19,6 +31,9 @@ export interface RenderPanelFailureReportInput {
19
31
  failures: readonly FailedPanelSummary[];
20
32
  error?: string;
21
33
  judgeModel?: string;
34
+ synthesis?: FusionSynthesisMode;
35
+ /** Configured members, so a merge failure can name the uncovered facets. */
36
+ panel?: readonly PanelMemberConfig[];
22
37
  }
23
38
 
24
39
  export interface RenderSinglePanelReportInput {
@@ -35,9 +50,19 @@ export interface RenderJudgeReportInput {
35
50
  failures?: readonly FailedPanelSummary[];
36
51
  judgeModel?: string;
37
52
  judgeObservation?: RunObservation;
53
+ /**
54
+ * Set when the judge was shown neutral candidate names. The report always
55
+ * shows real member names, so the judge's prose is rewritten before parsing.
56
+ */
57
+ blindPanelLabels?: boolean;
58
+ /** Selects which synthesis sections the report renders. Defaults to `select`. */
59
+ synthesis?: FusionSynthesisMode;
38
60
  }
39
61
 
40
62
  export interface RenderFailureReportInput {
63
+ synthesis?: FusionSynthesisMode;
64
+ /** Configured members, so a merge failure can name the uncovered facets. */
65
+ panel?: readonly PanelMemberConfig[];
41
66
  run: ReportRun;
42
67
  error: string;
43
68
  panelOutputs?: readonly PanelOutput[];
@@ -46,6 +71,9 @@ export interface RenderFailureReportInput {
46
71
  }
47
72
 
48
73
  export interface RenderCancelledReportInput {
74
+ synthesis?: FusionSynthesisMode;
75
+ /** Configured members, so a merge failure can name the uncovered facets. */
76
+ panel?: readonly PanelMemberConfig[];
49
77
  run: ReportRun;
50
78
  method: "stop" | "interrupt" | "local";
51
79
  targetRunId?: string;
@@ -59,8 +87,13 @@ type ReportSectionTitle =
59
87
  | "Agent Status"
60
88
  | "Consensus"
61
89
  | "Disagreements"
90
+ | "Contested Claims"
62
91
  | "Unique Insights"
63
92
  | "Blind Spots"
93
+ | "Coverage Map"
94
+ | "Combined Answer"
95
+ | "Gaps"
96
+ | "Conflicts At Seams"
64
97
  | "Recommendation"
65
98
  | "Risks"
66
99
  | "Next Step"
@@ -78,11 +111,86 @@ interface AgentStatusOptions {
78
111
  judgeStatus: string;
79
112
  judgeModel?: string;
80
113
  extra?: readonly string[];
114
+ /** Names the synthesis step for the reader. Merge runs the composer. */
115
+ synthesis?: FusionSynthesisMode;
116
+ }
117
+
118
+ /** Lists the facets that no panelist covered, for a merge-mode failure. */
119
+ function formatUncoveredFacets(
120
+ panel?: readonly PanelMemberConfig[],
121
+ ): string | string[] {
122
+ if (!panel?.length) return "Every configured facet is uncovered.";
123
+ return panel.map((member) => {
124
+ const facet =
125
+ member.question?.trim() ?? member.role?.trim() ?? "the whole task";
126
+ return `- ${memberLabel(member)}: ${facet} (uncovered)`;
127
+ });
128
+ }
129
+
130
+ /**
131
+ * The synthesis-shaped sections for a report that has no synthesis output:
132
+ * failure, cancellation, or an all-failed panel. Select and merge need
133
+ * different section names, so every such renderer goes through here rather than
134
+ * hardcoding one shape.
135
+ */
136
+ function emptySynthesisSections(input: {
137
+ synthesis?: FusionSynthesisMode;
138
+ reason: string;
139
+ /** Select-mode wording, passed verbatim so each caller keeps its own text. */
140
+ select: {
141
+ consensus: string;
142
+ disagreements: string;
143
+ uniqueInsights: string;
144
+ blindSpots: string;
145
+ };
146
+ panel?: readonly PanelMemberConfig[];
147
+ }): ReportSection[] {
148
+ if (input.synthesis === "merge") {
149
+ return [
150
+ {
151
+ title: "Coverage Map",
152
+ content: `Nothing was covered because ${input.reason}.`,
153
+ },
154
+ { title: "Combined Answer", content: "No answer is available." },
155
+ { title: "Gaps", content: formatUncoveredFacets(input.panel) },
156
+ {
157
+ title: "Conflicts At Seams",
158
+ content: "No answers were produced, so no seams could conflict.",
159
+ },
160
+ ];
161
+ }
162
+ return [
163
+ { title: "Consensus", content: input.select.consensus },
164
+ { title: "Disagreements", content: input.select.disagreements },
165
+ { title: "Unique Insights", content: input.select.uniqueInsights },
166
+ { title: "Blind Spots", content: input.select.blindSpots },
167
+ ];
168
+ }
169
+
170
+ /** What actually ran in the synthesis slot, for report labels. */
171
+ function synthesisLabel(synthesis?: FusionSynthesisMode): string {
172
+ return synthesis === "merge" ? "Composer" : "Judge";
81
173
  }
82
174
 
83
175
  export function renderPanelFailureReport(
84
176
  input: RenderPanelFailureReportInput,
85
177
  ): string {
178
+ const synthesisName = synthesisLabel(input.synthesis);
179
+ // Under merge the select sections are meaningless: nobody answered the same
180
+ // question, so there is no consensus to be absent. What the reader needs is
181
+ // which facets went uncovered.
182
+ const emptySections = emptySynthesisSections({
183
+ ...(input.synthesis ? { synthesis: input.synthesis } : {}),
184
+ reason: "all panelists failed",
185
+ select: {
186
+ consensus: "No consensus was available because all panelists failed.",
187
+ disagreements: `No disagreements were synthesized because the ${synthesisName.toLowerCase()} did not run.`,
188
+ uniqueInsights: "No panel output was available to summarize.",
189
+ blindSpots:
190
+ "All panelists failed, so the report may be missing every intended review perspective.",
191
+ },
192
+ ...(input.panel ? { panel: input.panel } : {}),
193
+ });
86
194
  return renderReport([
87
195
  {
88
196
  title: "Summary",
@@ -96,26 +204,10 @@ export function renderPanelFailureReport(
96
204
  failures: input.failures,
97
205
  judgeStatus: "not run - no successful panelists",
98
206
  ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
207
+ ...(input.synthesis ? { synthesis: input.synthesis } : {}),
99
208
  }),
100
209
  },
101
- {
102
- title: "Consensus",
103
- content: "No consensus was available because all panelists failed.",
104
- },
105
- {
106
- title: "Disagreements",
107
- content:
108
- "No disagreements were synthesized because the judge did not run.",
109
- },
110
- {
111
- title: "Unique Insights",
112
- content: "No panel output was available to summarize.",
113
- },
114
- {
115
- title: "Blind Spots",
116
- content:
117
- "All panelists failed, so the report may be missing every intended review perspective.",
118
- },
210
+ ...emptySections,
119
211
  { title: "Recommendation", content: "No recommendation is available." },
120
212
  {
121
213
  title: "Risks",
@@ -195,15 +287,97 @@ export function renderSinglePanelReport(
195
287
  return renderReport(sections);
196
288
  }
197
289
 
290
+ /**
291
+ * Rewrites the neutral names the judge saw back to the configured member names.
292
+ * Longest label first so "Candidate AA" is not partly replaced by "Candidate A".
293
+ */
294
+ function restoreBlindLabels(
295
+ judgeOutput: string,
296
+ panelOutputs: readonly PanelOutput[],
297
+ failures: readonly FailedPanelSummary[],
298
+ ): string {
299
+ const items = [...panelOutputs, ...failures];
300
+ const blindLabels = buildBlindLabelMap(items);
301
+ const realNames = new Map(
302
+ items.map((item) => [item.index, panelItemLabel(item)]),
303
+ );
304
+
305
+ let restored = judgeOutput;
306
+ const ordered = [...blindLabels.entries()].sort(
307
+ (left, right) => right[1].length - left[1].length,
308
+ );
309
+ for (const [index, blindLabel] of ordered) {
310
+ const realName = realNames.get(index);
311
+ if (!realName) continue;
312
+ restored = restored.replaceAll(blindLabel, realName);
313
+ }
314
+ return restored;
315
+ }
316
+
198
317
  export function renderJudgeReport(input: RenderJudgeReportInput): string {
199
318
  const panelOutputs = input.panelOutputs ?? [];
200
319
  const failures = input.failures ?? [];
201
- const sections = parseMarkdownSections(input.judgeOutput);
202
- const unsectionedOutput = stripReportTitle(input.judgeOutput);
320
+ const judgeOutput = input.blindPanelLabels
321
+ ? restoreBlindLabels(input.judgeOutput, panelOutputs, failures)
322
+ : input.judgeOutput;
323
+ const sections = parseMarkdownSections(judgeOutput);
324
+ const unsectionedOutput = stripReportTitle(judgeOutput);
203
325
  const recommendationFallback =
204
326
  sections.size === 0 && unsectionedOutput
205
327
  ? unsectionedOutput
206
- : "Judge completed without a recommendation.";
328
+ : `${synthesisLabel(input.synthesis)} completed without a recommendation.`;
329
+
330
+ // Section titles differ by synthesis mode; everything around them - agent
331
+ // status, metadata, run details - is shared.
332
+ const merging = input.synthesis === "merge";
333
+ const synthesisSections: ReportSection[] = merging
334
+ ? [
335
+ {
336
+ title: "Coverage Map",
337
+ content:
338
+ sections.get("Coverage Map") ?? "Not specified by the composer.",
339
+ },
340
+ {
341
+ title: "Combined Answer",
342
+ content:
343
+ sections.get("Combined Answer") ?? "Not specified by the composer.",
344
+ },
345
+ {
346
+ title: "Gaps",
347
+ content: sections.get("Gaps") ?? "Not specified by the composer.",
348
+ },
349
+ {
350
+ title: "Conflicts At Seams",
351
+ content:
352
+ sections.get("Conflicts At Seams") ??
353
+ "Not specified by the composer.",
354
+ },
355
+ ]
356
+ : [
357
+ {
358
+ title: "Consensus",
359
+ content: sections.get("Consensus") ?? "Not specified by the judge.",
360
+ },
361
+ {
362
+ title: "Disagreements",
363
+ content:
364
+ sections.get("Disagreements") ?? "Not specified by the judge.",
365
+ },
366
+ {
367
+ title: "Contested Claims",
368
+ content:
369
+ sections.get("Contested Claims") ?? "Not specified by the judge.",
370
+ },
371
+ {
372
+ title: "Unique Insights",
373
+ content:
374
+ sections.get("Unique Insights") ?? "Not specified by the judge.",
375
+ },
376
+ {
377
+ title: "Blind Spots",
378
+ content: sections.get("Blind Spots") ?? "Not specified by the judge.",
379
+ },
380
+ ];
207
381
 
208
382
  const reportSections: ReportSection[] = [
209
383
  {
@@ -217,24 +391,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
217
391
  failures,
218
392
  judgeStatus: "succeeded",
219
393
  ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
394
+ ...(input.synthesis ? { synthesis: input.synthesis } : {}),
220
395
  }),
221
396
  },
222
- {
223
- title: "Consensus",
224
- content: sections.get("Consensus") ?? "Not specified by the judge.",
225
- },
226
- {
227
- title: "Disagreements",
228
- content: sections.get("Disagreements") ?? "Not specified by the judge.",
229
- },
230
- {
231
- title: "Unique Insights",
232
- content: sections.get("Unique Insights") ?? "Not specified by the judge.",
233
- },
234
- {
235
- title: "Blind Spots",
236
- content: sections.get("Blind Spots") ?? "Not specified by the judge.",
237
- },
397
+ ...synthesisSections,
238
398
  {
239
399
  title: "Recommendation",
240
400
  content: sections.get("Recommendation") ?? recommendationFallback,
@@ -258,6 +418,7 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
258
418
  ...(input.judgeObservation
259
419
  ? { judgeObservation: input.judgeObservation }
260
420
  : {}),
421
+ ...(input.synthesis ? { synthesis: input.synthesis } : {}),
261
422
  });
262
423
  if (runDetails) reportSections.splice(-1, 0, runDetails);
263
424
  return renderReport(reportSections);
@@ -282,23 +443,20 @@ export function renderFailureReport(input: RenderFailureReportInput): string {
282
443
  extra: [`- Phase: ${phase}`],
283
444
  }),
284
445
  },
285
- {
286
- title: "Consensus",
287
- content: "No consensus was available because fusion failed.",
288
- },
289
- {
290
- title: "Disagreements",
291
- content: "No disagreements were synthesized because fusion failed.",
292
- },
293
- {
294
- title: "Unique Insights",
295
- content: "No unique insights were synthesized because fusion failed.",
296
- },
297
- {
298
- title: "Blind Spots",
299
- content:
300
- "The failure may hide panel disagreements, missing evidence, or provider-specific errors.",
301
- },
446
+ ...emptySynthesisSections({
447
+ ...(input.synthesis ? { synthesis: input.synthesis } : {}),
448
+ reason: "fusion failed",
449
+ select: {
450
+ consensus: "No consensus was available because fusion failed.",
451
+ disagreements:
452
+ "No disagreements were synthesized because fusion failed.",
453
+ uniqueInsights:
454
+ "No unique insights were synthesized because fusion failed.",
455
+ blindSpots:
456
+ "The failure may hide panel disagreements, missing evidence, or provider-specific errors.",
457
+ },
458
+ ...(input.panel ? { panel: input.panel } : {}),
459
+ }),
302
460
  { title: "Recommendation", content: "No recommendation is available." },
303
461
  {
304
462
  title: "Risks",
@@ -334,25 +492,21 @@ export function renderCancelledReport(
334
492
  ],
335
493
  }),
336
494
  },
337
- {
338
- title: "Consensus",
339
- content: "No final consensus was available because fusion was cancelled.",
340
- },
341
- {
342
- title: "Disagreements",
343
- content:
344
- "No final disagreements were synthesized because fusion was cancelled.",
345
- },
346
- {
347
- title: "Unique Insights",
348
- content:
349
- "No final unique insights were synthesized because fusion was cancelled.",
350
- },
351
- {
352
- title: "Blind Spots",
353
- content:
354
- "Cancellation may leave in-flight panel or judge output incomplete.",
355
- },
495
+ ...emptySynthesisSections({
496
+ ...(input.synthesis ? { synthesis: input.synthesis } : {}),
497
+ reason: "fusion was cancelled",
498
+ select: {
499
+ consensus:
500
+ "No final consensus was available because fusion was cancelled.",
501
+ disagreements:
502
+ "No final disagreements were synthesized because fusion was cancelled.",
503
+ uniqueInsights:
504
+ "No final unique insights were synthesized because fusion was cancelled.",
505
+ blindSpots:
506
+ "Cancellation may leave in-flight panel or judge output incomplete.",
507
+ },
508
+ ...(input.panel ? { panel: input.panel } : {}),
509
+ }),
356
510
  { title: "Recommendation", content: "No recommendation is available." },
357
511
  {
358
512
  title: "Risks",
@@ -387,6 +541,7 @@ interface RunDetailsInput {
387
541
  failures: readonly FailedPanelSummary[];
388
542
  judgeModel?: string;
389
543
  judgeObservation?: RunObservation;
544
+ synthesis?: FusionSynthesisMode;
390
545
  }
391
546
 
392
547
  function formatRunDetails(input: RunDetailsInput): ReportSection | undefined {
@@ -406,7 +561,7 @@ function formatRunDetails(input: RunDetailsInput): ReportSection | undefined {
406
561
  ];
407
562
  if (input.judgeObservation) {
408
563
  entries.push({
409
- label: "Judge",
564
+ label: synthesisLabel(input.synthesis),
410
565
  status: "completed",
411
566
  configuredModel: input.judgeModel,
412
567
  observation: input.judgeObservation,
@@ -518,7 +673,7 @@ function formatAgentStatus(options: AgentStatusOptions): string[] {
518
673
  lines.push("- Panel status: not available");
519
674
  }
520
675
 
521
- lines.push(`- Judge: ${options.judgeStatus}`);
676
+ lines.push(`- ${synthesisLabel(options.synthesis)}: ${options.judgeStatus}`);
522
677
  if (options.judgeModel) {
523
678
  lines.push(` Configured model: ${options.judgeModel}`);
524
679
  }
@@ -638,7 +793,7 @@ function plural(count: number, singular: string): string {
638
793
  function formatPanelName(
639
794
  item: Pick<PanelOutput, "index" | "id" | "label">,
640
795
  ): string {
641
- return item.label ?? item.id ?? `Panelist ${item.index + 1}`;
796
+ return panelItemLabel(item);
642
797
  }
643
798
 
644
799
  function comparePanelItems(