feat(FN-3783): add review panel plugin for fusion reports with type definit
The merge delivers the **Reports Review Panel** (FN-3783) as a multi-step plugin feature — defining review panel types, implementing orchestration logic, and wiring them through the fusion-plugin-reports entry point — alongside a collection of complementary improvements: section-aware prompt compact Fusion-Task-Id: FN-3783
This commit is contained in:
5
.changeset/fn-3783-reports-review-panel.md
Normal file
5
.changeset/fn-3783-reports-review-panel.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a multi-agent report review panel flow to the bundled reports plugin, including parallel reviewer orchestration, structured feedback parsing with retry, deterministic aggregation, and documented timeout/failure semantics.
|
||||||
@@ -251,8 +251,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 10px 8px;
|
padding: var(--space-sm) var(--space-sm);
|
||||||
gap: 2px;
|
gap: calc(var(--space-xs) / 2);
|
||||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -1195,7 +1195,7 @@
|
|||||||
|
|
||||||
.auth-section-hint {
|
.auth-section-hint {
|
||||||
padding: var(--space-md) var(--space-lg);
|
padding: var(--space-md) var(--space-lg);
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--space-md);
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1203,7 +1203,7 @@
|
|||||||
border-left: 3px solid var(--text-muted);
|
border-left: 3px solid var(--text-muted);
|
||||||
}
|
}
|
||||||
.auth-provider-group {
|
.auth-provider-group {
|
||||||
margin-bottom: 10px;
|
margin-bottom: calc(var(--space-sm) + var(--space-xs) / 2);
|
||||||
}
|
}
|
||||||
.auth-group-label {
|
.auth-group-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1211,14 +1211,14 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--space-xs);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
.auth-provider-card {
|
.auth-provider-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--space-xs);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
}
|
}
|
||||||
@@ -1236,7 +1236,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: var(--space-sm) var(--space-md);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.auth-provider-info {
|
.auth-provider-info {
|
||||||
@@ -1273,7 +1273,7 @@
|
|||||||
.auth-apikey-section {
|
.auth-apikey-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: var(--space-xs);
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -1514,11 +1514,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 4px;
|
gap: var(--space-xs);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
padding: 6px 12px;
|
padding: calc(var(--space-xs) + var(--space-xs) / 2) var(--space-md);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,7 +1593,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
padding: 0 14px;
|
padding: 0 calc(var(--space-md) + var(--space-xs) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-scope-banner {
|
.settings-scope-banner {
|
||||||
@@ -1638,25 +1638,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-section-hint {
|
.auth-section-hint {
|
||||||
margin: 0 14px 12px;
|
margin: 0 calc(var(--space-md) + var(--space-xs) / 2) var(--space-md);
|
||||||
padding: 10px 14px;
|
padding: calc(var(--space-sm) + var(--space-xs) / 2) calc(var(--space-md) + var(--space-xs) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-provider-group {
|
.auth-provider-group {
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-group-label {
|
.auth-group-label {
|
||||||
padding: 0 14px;
|
padding: 0 calc(var(--space-md) + var(--space-xs) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-provider-card {
|
.auth-provider-card {
|
||||||
margin: 0 14px 8px;
|
margin: 0 calc(var(--space-md) + var(--space-xs) / 2) var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-provider-header {
|
.auth-provider-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 10px 14px;
|
padding: calc(var(--space-sm) + var(--space-xs) / 2) calc(var(--space-md) + var(--space-xs) / 2);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1698,7 +1698,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-hint {
|
.auth-hint {
|
||||||
padding: 12px 14px 0;
|
padding: var(--space-md) calc(var(--space-md) + var(--space-xs) / 2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings modal: model preset controls stack cleanly on narrow screens */
|
/* Settings modal: model preset controls stack cleanly on narrow screens */
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
.card.file-drop-target {
|
.card.file-drop-target {
|
||||||
border: 2px dashed var(--todo);
|
border: 2px dashed var(--todo);
|
||||||
background: rgba(88, 166, 255, 0.08);
|
background: color-mix(in srgb, var(--todo) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Agent-active glow: animated border glow when an agent is actively working on a task.
|
/* Agent-active glow: animated border glow when an agent is actively working on a task.
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
.card.agent-active {
|
.card.agent-active {
|
||||||
border-color: var(--in-progress);
|
border-color: var(--in-progress);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 8px rgba(var(--in-progress-rgb), 0.4),
|
0 0 8px color-mix(in srgb, var(--in-progress) 40%, transparent),
|
||||||
0 0 20px rgba(var(--in-progress-rgb), 0.15);
|
0 0 20px color-mix(in srgb, var(--in-progress) 15%, transparent);
|
||||||
animation: agent-glow 2.5s ease-in-out infinite;
|
animation: agent-glow 2.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,13 +50,13 @@
|
|||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 8px rgba(var(--in-progress-rgb), 0.4),
|
0 0 8px color-mix(in srgb, var(--in-progress) 40%, transparent),
|
||||||
0 0 20px rgba(var(--in-progress-rgb), 0.15);
|
0 0 20px color-mix(in srgb, var(--in-progress) 15%, transparent);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 12px rgba(var(--in-progress-rgb), 0.6),
|
0 0 12px color-mix(in srgb, var(--in-progress) 60%, transparent),
|
||||||
0 0 28px rgba(var(--in-progress-rgb), 0.25);
|
0 0 28px color-mix(in srgb, var(--in-progress) 25%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
|
|
||||||
.card-status-badge.paused {
|
.card-status-badge.paused {
|
||||||
background: var(--status-done-bg-deep);
|
background: var(--status-done-bg-deep);
|
||||||
color: var(--text-muted, #8b949e);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-status-badge.awaiting-approval {
|
.card-status-badge.awaiting-approval {
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
}
|
}
|
||||||
.card-status-badge--archived {
|
.card-status-badge--archived {
|
||||||
background: var(--status-archived-bg);
|
background: var(--status-archived-bg);
|
||||||
color: var(--text-muted, #8b949e);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.stuck {
|
.card.stuck {
|
||||||
@@ -344,23 +344,23 @@
|
|||||||
Distinct from agent-active (purple glow), failed (red left border + error block), and paused (dimmed opacity). */
|
Distinct from agent-active (purple glow), failed (red left border + error block), and paused (dimmed opacity). */
|
||||||
.card.awaiting-approval {
|
.card.awaiting-approval {
|
||||||
border-left: 3px solid var(--triage);
|
border-left: 3px solid var(--triage);
|
||||||
background: rgba(210, 153, 34, 0.06);
|
background: color-mix(in srgb, var(--triage) 6%, transparent);
|
||||||
animation: approval-pulse 3s ease-in-out infinite;
|
animation: approval-pulse 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes approval-pulse {
|
@keyframes approval-pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
background: rgba(210, 153, 34, 0.06);
|
background: color-mix(in srgb, var(--triage) 6%, transparent);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
background: rgba(210, 153, 34, 0.12);
|
background: color-mix(in srgb, var(--triage) 12%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.paused {
|
.card.paused {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
border-left: 3px solid var(--text-secondary, #888);
|
border-left: 3px solid var(--text-muted);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,88 @@
|
|||||||
|
|
||||||
Generates HTML system activity reports with multi-agent review.
|
Generates HTML system activity reports with multi-agent review.
|
||||||
|
|
||||||
> Scaffold-only package delivered in FN-3778. Runtime scheduling, report generation, review orchestration, publishing, and dashboard view behavior are implemented in follow-up tasks FN-3779 through FN-3787.
|
## Review Panel
|
||||||
|
|
||||||
|
The plugin exposes `runReviewPanel()` / `runGeneratedReportReview()` to fan out a generated report draft to multiple reviewer agents in parallel.
|
||||||
|
|
||||||
|
### Panel member settings shape
|
||||||
|
|
||||||
|
Each reviewer uses this contract:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
perspective: string;
|
||||||
|
promptTemplateId?: string;
|
||||||
|
provider?: string;
|
||||||
|
modelId?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `perspective` is appended to the reviewer system prompt.
|
||||||
|
- `promptTemplateId` selects a template from `settings.reviewPromptTemplates[templateId]` when present.
|
||||||
|
- `provider` + `modelId` optionally override model selection per reviewer.
|
||||||
|
|
||||||
|
### Prompt template contract
|
||||||
|
|
||||||
|
`runReviewPanel` resolves reviewer templates in this order:
|
||||||
|
|
||||||
|
1. `settings.reviewPromptTemplates[promptTemplateId ?? id]`
|
||||||
|
2. `settings.reviewPrompt`
|
||||||
|
3. Built-in fallback (`DEFAULT_REVIEW_PROMPT`)
|
||||||
|
|
||||||
|
This is the temporary compatibility contract until FN-3782 lands shared review-template helpers.
|
||||||
|
|
||||||
|
### Individual review shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
memberId: string;
|
||||||
|
memberName: string;
|
||||||
|
perspective: string;
|
||||||
|
verdict: "approve" | "revise" | "reject";
|
||||||
|
summary: string;
|
||||||
|
highlights: string[];
|
||||||
|
lowlights: string[];
|
||||||
|
suggestions: string[];
|
||||||
|
rawText: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Combined review shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
overallVerdict: "approve" | "revise" | "reject";
|
||||||
|
consensusSummary: string;
|
||||||
|
mergedHighlights: string[];
|
||||||
|
mergedLowlights: string[];
|
||||||
|
mergedSuggestions: string[];
|
||||||
|
individual: IndividualReview[];
|
||||||
|
failures: ReviewFailure[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggregation is deterministic:
|
||||||
|
|
||||||
|
- verdict precedence: `approve < revise < reject`
|
||||||
|
- merged arrays are case-insensitive de-duped, first-seen order, max 25 items each
|
||||||
|
- consensus summary is generated locally from reviewer summaries (no second AI call)
|
||||||
|
|
||||||
|
### Timeout and failure semantics
|
||||||
|
|
||||||
|
- Each reviewer has a hard timeout (`120_000ms`).
|
||||||
|
- A single reviewer failure never aborts the full panel.
|
||||||
|
- Failures are returned as:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
memberId: string;
|
||||||
|
reason: "timeout" | "parse_error" | "session_unavailable" | "exception";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- If all reviewers fail, combined verdict is `reject` with an explicit consensus summary describing panel failure.
|
||||||
|
|||||||
166
plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts
Normal file
166
plugins/fusion-plugin-reports/src/__tests__/review-panel.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CreateAiSessionFactory, PluginContext } from "@fusion/core";
|
||||||
|
import { __setCreateAiSessionFactory, combineReviews, runReviewPanel } from "../review-panel.js";
|
||||||
|
import type { ReviewPanelMember } from "../review-types.js";
|
||||||
|
import { ReviewPanelError } from "../review-types.js";
|
||||||
|
|
||||||
|
function createContext(createAiSession?: CreateAiSessionFactory): PluginContext {
|
||||||
|
return {
|
||||||
|
pluginId: "fusion-plugin-reports",
|
||||||
|
taskStore: {} as PluginContext["taskStore"],
|
||||||
|
settings: {},
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
emitEvent: vi.fn(),
|
||||||
|
createAiSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel: ReviewPanelMember[] = [
|
||||||
|
{ id: "qa", name: "QA", perspective: "Quality" },
|
||||||
|
{ id: "ops", name: "Ops", perspective: "Operations" },
|
||||||
|
{ id: "pm", name: "PM", perspective: "Product" },
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__setCreateAiSessionFactory(undefined);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runReviewPanel", () => {
|
||||||
|
it("returns individual and combined reviews for a 3-member panel", async () => {
|
||||||
|
const createAiSession = vi.fn(async ({ systemPrompt }) => ({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn(async () => {}),
|
||||||
|
state: {
|
||||||
|
messages: [{ role: "assistant", content: JSON.stringify({ verdict: "approve", summary: systemPrompt, highlights: ["A"], lowlights: ["B"], suggestions: ["C"] }) }],
|
||||||
|
},
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await runReviewPanel({
|
||||||
|
reportDraft: "draft",
|
||||||
|
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
|
||||||
|
panel,
|
||||||
|
cwd: "/tmp",
|
||||||
|
}, createContext(createAiSession));
|
||||||
|
|
||||||
|
expect(result.individual).toHaveLength(3);
|
||||||
|
expect(result.failures).toEqual([]);
|
||||||
|
expect(result.overallVerdict).toBe("approve");
|
||||||
|
expect(result.consensusSummary).toContain("Quality:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates merged arrays in first-seen order", () => {
|
||||||
|
const merged = combineReviews([
|
||||||
|
{ memberId: "a", memberName: "A", perspective: "P1", verdict: "approve", summary: "ok", highlights: [" Alpha ", "Beta"], lowlights: ["Lag"], suggestions: ["Fix docs"], rawText: "", durationMs: 1 },
|
||||||
|
{ memberId: "b", memberName: "B", perspective: "P2", verdict: "approve", summary: "ok", highlights: ["alpha", "Gamma"], lowlights: [" lag "], suggestions: ["fix docs", "Add tests"], rawText: "", durationMs: 1 },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
expect(merged.mergedHighlights).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||||
|
expect(merged.mergedLowlights).toEqual(["Lag"]);
|
||||||
|
expect(merged.mergedSuggestions).toEqual(["Fix docs", "Add tests"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies verdict precedence", () => {
|
||||||
|
const revise = combineReviews([
|
||||||
|
{ memberId: "a", memberName: "A", perspective: "P1", verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
|
||||||
|
{ memberId: "b", memberName: "B", perspective: "P2", verdict: "revise", summary: "rev", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
|
||||||
|
], []);
|
||||||
|
expect(revise.overallVerdict).toBe("revise");
|
||||||
|
|
||||||
|
const reject = combineReviews([
|
||||||
|
{ memberId: "a", memberName: "A", perspective: "P1", verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
|
||||||
|
{ memberId: "b", memberName: "B", perspective: "P2", verdict: "reject", summary: "no", highlights: [], lowlights: [], suggestions: [], rawText: "", durationMs: 1 },
|
||||||
|
], []);
|
||||||
|
expect(reject.overallVerdict).toBe("reject");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records timeout failure without aborting other reviewers", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const createAiSession = vi.fn(async ({ systemPrompt }) => {
|
||||||
|
if (systemPrompt.includes("Operations")) {
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn(async () => new Promise(() => {})),
|
||||||
|
state: { messages: [] },
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn(async () => {}),
|
||||||
|
state: { messages: [{ role: "assistant", content: JSON.stringify({ verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [] }) }] },
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const promise = runReviewPanel({
|
||||||
|
reportDraft: "draft",
|
||||||
|
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
|
||||||
|
panel,
|
||||||
|
cwd: "/tmp",
|
||||||
|
}, createContext(createAiSession));
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
expect(result.individual).toHaveLength(2);
|
||||||
|
expect(result.failures).toHaveLength(1);
|
||||||
|
expect(result.failures[0]?.reason).toBe("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries malformed JSON once then reports parse_error", async () => {
|
||||||
|
const prompt = vi.fn(async () => {});
|
||||||
|
const createAiSession = vi.fn(async () => ({
|
||||||
|
session: {
|
||||||
|
prompt,
|
||||||
|
state: { messages: [{ role: "assistant", content: "not-json" }] },
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await runReviewPanel({
|
||||||
|
reportDraft: "draft",
|
||||||
|
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
|
||||||
|
panel: [panel[0]],
|
||||||
|
cwd: "/tmp",
|
||||||
|
}, createContext(createAiSession));
|
||||||
|
|
||||||
|
expect(prompt).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.individual).toEqual([]);
|
||||||
|
expect(result.failures).toHaveLength(1);
|
||||||
|
expect(result.failures[0]?.reason).toBe("parse_error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ReviewPanelError when createAiSession is unavailable", async () => {
|
||||||
|
await expect(runReviewPanel({
|
||||||
|
reportDraft: "draft",
|
||||||
|
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
|
||||||
|
panel,
|
||||||
|
cwd: "/tmp",
|
||||||
|
}, createContext(undefined))).rejects.toBeInstanceOf(ReviewPanelError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards provider/modelId to createAiSession per member", async () => {
|
||||||
|
const createAiSession = vi.fn(async () => ({
|
||||||
|
session: {
|
||||||
|
prompt: vi.fn(async () => {}),
|
||||||
|
state: { messages: [{ role: "assistant", content: JSON.stringify({ verdict: "approve", summary: "ok", highlights: [], lowlights: [], suggestions: [] }) }] },
|
||||||
|
dispose: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await runReviewPanel({
|
||||||
|
reportDraft: "draft",
|
||||||
|
reportMetadata: { reportId: "r-1", cadence: "daily", periodStart: "2026-01-01", periodEnd: "2026-01-02" },
|
||||||
|
panel: [{ id: "m1", name: "M1", perspective: "P", provider: "anthropic", modelId: "claude" }],
|
||||||
|
cwd: "/tmp",
|
||||||
|
}, createContext(createAiSession));
|
||||||
|
|
||||||
|
expect(createAiSession).toHaveBeenCalledWith(expect.objectContaining({ defaultProvider: "anthropic", defaultModelId: "claude" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import type { PluginContext } from "@fusion/core";
|
||||||
import { definePlugin } from "@fusion/plugin-sdk";
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
|
import { runReviewPanel } from "./review-panel.js";
|
||||||
|
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
|
||||||
import { settingsSchema } from "./settings.js";
|
import { settingsSchema } from "./settings.js";
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
@@ -15,6 +18,24 @@ const plugin = definePlugin({
|
|||||||
hooks: {},
|
hooks: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface RunGeneratedReportReviewInput {
|
||||||
|
reportDraft: string;
|
||||||
|
reportMetadata: RunReviewPanelInput["reportMetadata"];
|
||||||
|
panel: ReviewPanelMember[];
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runGeneratedReportReview(input: RunGeneratedReportReviewInput, ctx: PluginContext): Promise<CombinedReview> {
|
||||||
|
return runReviewPanel({
|
||||||
|
reportDraft: input.reportDraft,
|
||||||
|
reportMetadata: input.reportMetadata,
|
||||||
|
panel: input.panel,
|
||||||
|
cwd: input.cwd,
|
||||||
|
}, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
export default plugin;
|
export default plugin;
|
||||||
|
|
||||||
export * from "./settings.js";
|
export * from "./settings.js";
|
||||||
|
export * from "./review-types.js";
|
||||||
|
export * from "./review-panel.js";
|
||||||
|
|||||||
294
plugins/fusion-plugin-reports/src/review-panel.ts
Normal file
294
plugins/fusion-plugin-reports/src/review-panel.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import type { CreateAiSessionFactory, PluginContext } from "@fusion/core";
|
||||||
|
import { DEFAULT_REVIEW_PROMPT } from "./settings.js";
|
||||||
|
import type {
|
||||||
|
CombinedReview,
|
||||||
|
IndividualReview,
|
||||||
|
ReviewFailure,
|
||||||
|
ReviewPanelMember,
|
||||||
|
ReviewVerdict,
|
||||||
|
RunReviewPanelInput,
|
||||||
|
} from "./review-types.js";
|
||||||
|
import { ReviewPanelError, ReviewParseError, ReviewTimeoutError } from "./review-types.js";
|
||||||
|
|
||||||
|
let injectedCreateAiSession: CreateAiSessionFactory | undefined;
|
||||||
|
|
||||||
|
const MAX_PARSE_RETRIES = 1;
|
||||||
|
const MAX_MERGED_ITEMS = 25;
|
||||||
|
export const REVIEW_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
interface AgentMessage {
|
||||||
|
role: string;
|
||||||
|
content?: string | Array<{ type: string; text: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFactory(ctx: PluginContext): CreateAiSessionFactory | undefined {
|
||||||
|
return injectedCreateAiSession ?? ctx.createAiSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReviewPromptTemplate(reviewerId: string, settings: Record<string, unknown>): string {
|
||||||
|
const templates = settings.reviewPromptTemplates;
|
||||||
|
if (templates && typeof templates === "object" && !Array.isArray(templates)) {
|
||||||
|
const candidate = (templates as Record<string, unknown>)[reviewerId];
|
||||||
|
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||||
|
}
|
||||||
|
const reviewPrompt = settings.reviewPrompt;
|
||||||
|
if (typeof reviewPrompt === "string" && reviewPrompt.trim()) return reviewPrompt.trim();
|
||||||
|
return DEFAULT_REVIEW_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSystemPrompt(member: ReviewPanelMember, settings: Record<string, unknown>): string {
|
||||||
|
const templateId = member.promptTemplateId ?? member.id;
|
||||||
|
const template = getReviewPromptTemplate(templateId, settings);
|
||||||
|
return `${template}\n\nReviewer perspective: ${member.perspective}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJsonCandidate(text: string): string | null {
|
||||||
|
if (!text || !text.trim()) return null;
|
||||||
|
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||||
|
const source = codeBlockMatch?.[1]?.trim() ?? text.trim();
|
||||||
|
|
||||||
|
const startIndex = source.indexOf("{");
|
||||||
|
if (startIndex < 0) return null;
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = startIndex; index < source.length; index++) {
|
||||||
|
const char = source[index];
|
||||||
|
if (inString) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (char === "\\") escaped = true;
|
||||||
|
else if (char === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "{") depth++;
|
||||||
|
if (char === "}") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return source.slice(startIndex, index + 1).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.slice(startIndex).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReviewPayload(text: string): Omit<IndividualReview, "memberId" | "memberName" | "perspective" | "durationMs" | "rawText"> {
|
||||||
|
const candidate = extractJsonCandidate(text);
|
||||||
|
if (!candidate) throw new ReviewParseError("No JSON object found in reviewer response");
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(candidate);
|
||||||
|
} catch {
|
||||||
|
throw new ReviewParseError("Reviewer response is not valid JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new ReviewParseError("Reviewer response must be a JSON object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = parsed as Record<string, unknown>;
|
||||||
|
const verdict = row.verdict;
|
||||||
|
const summary = row.summary;
|
||||||
|
if (verdict !== "approve" && verdict !== "revise" && verdict !== "reject") {
|
||||||
|
throw new ReviewParseError("Reviewer verdict must be approve, revise, or reject");
|
||||||
|
}
|
||||||
|
if (typeof summary !== "string" || !summary.trim()) {
|
||||||
|
throw new ReviewParseError("Reviewer summary must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
verdict,
|
||||||
|
summary: summary.trim(),
|
||||||
|
highlights: asStringArray(row.highlights),
|
||||||
|
lowlights: asStringArray(row.lowlights),
|
||||||
|
suggestions: asStringArray(row.suggestions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantText(messages: AgentMessage[]): string {
|
||||||
|
const lastMessage = messages.filter((message) => message.role === "assistant").pop();
|
||||||
|
if (!lastMessage?.content) return "";
|
||||||
|
if (typeof lastMessage.content === "string") return lastMessage.content;
|
||||||
|
return lastMessage.content
|
||||||
|
.filter((chunk): chunk is { type: "text"; text: string } => chunk.type === "text")
|
||||||
|
.map((chunk) => chunk.text)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFailure(memberId: string, error: unknown): ReviewFailure {
|
||||||
|
if (error instanceof ReviewTimeoutError) {
|
||||||
|
return { memberId, reason: "timeout", message: error.message };
|
||||||
|
}
|
||||||
|
if (error instanceof ReviewParseError) {
|
||||||
|
return { memberId, reason: "parse_error", message: error.message };
|
||||||
|
}
|
||||||
|
if (error instanceof ReviewPanelError && error.reason === "session_unavailable") {
|
||||||
|
return { memberId, reason: "session_unavailable", message: error.message };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
memberId,
|
||||||
|
reason: "exception",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeoutAfter(ms: number, memberName: string): Promise<never> {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
globalThis.setTimeout(() => {
|
||||||
|
reject(new ReviewTimeoutError(`Review timed out for ${memberName} after ${ms}ms`));
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeMerged(items: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(trimmed);
|
||||||
|
if (merged.length >= MAX_MERGED_ITEMS) break;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVerdictRank(verdict: ReviewVerdict): number {
|
||||||
|
if (verdict === "reject") return 2;
|
||||||
|
if (verdict === "revise") return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combineReviews(individual: IndividualReview[], failures: ReviewFailure[]): CombinedReview {
|
||||||
|
if (individual.length === 0) {
|
||||||
|
return {
|
||||||
|
overallVerdict: "reject",
|
||||||
|
consensusSummary: "Review panel could not produce feedback because all reviewers failed.",
|
||||||
|
mergedHighlights: [],
|
||||||
|
mergedLowlights: [],
|
||||||
|
mergedSuggestions: [],
|
||||||
|
individual,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const worstVerdict = individual.reduce<ReviewVerdict>((current, review) =>
|
||||||
|
getVerdictRank(review.verdict) > getVerdictRank(current) ? review.verdict : current,
|
||||||
|
"approve");
|
||||||
|
|
||||||
|
const consensusSummary = individual
|
||||||
|
.map((review) => `${review.perspective}: ${review.summary}`)
|
||||||
|
.join(" | ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
overallVerdict: worstVerdict,
|
||||||
|
consensusSummary,
|
||||||
|
mergedHighlights: dedupeMerged(individual.flatMap((review) => review.highlights)),
|
||||||
|
mergedLowlights: dedupeMerged(individual.flatMap((review) => review.lowlights)),
|
||||||
|
mergedSuggestions: dedupeMerged(individual.flatMap((review) => review.suggestions)),
|
||||||
|
individual,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSingleReview(member: ReviewPanelMember, input: RunReviewPanelInput, createAiSession: CreateAiSessionFactory, settings: Record<string, unknown>): Promise<IndividualReview> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const userPrompt = [
|
||||||
|
"Review the following generated report draft.",
|
||||||
|
"Return only strict JSON with keys: verdict, summary, highlights, lowlights, suggestions.",
|
||||||
|
"Do not include markdown fences or extra commentary.",
|
||||||
|
"",
|
||||||
|
`reportId: ${input.reportMetadata.reportId}`,
|
||||||
|
`cadence: ${input.reportMetadata.cadence}`,
|
||||||
|
`periodStart: ${input.reportMetadata.periodStart}`,
|
||||||
|
`periodEnd: ${input.reportMetadata.periodEnd}`,
|
||||||
|
"",
|
||||||
|
"reportDraft:",
|
||||||
|
input.reportDraft,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const response = await Promise.race([
|
||||||
|
(async () => {
|
||||||
|
const agent = await createAiSession({
|
||||||
|
cwd: input.cwd,
|
||||||
|
systemPrompt: buildSystemPrompt(member, settings),
|
||||||
|
tools: "readonly",
|
||||||
|
...(member.provider && member.modelId ? { defaultProvider: member.provider, defaultModelId: member.modelId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agent.session.prompt(userPrompt);
|
||||||
|
let text = getAssistantText(agent.session.state.messages as AgentMessage[]);
|
||||||
|
|
||||||
|
let parsed: Omit<IndividualReview, "memberId" | "memberName" | "perspective" | "durationMs" | "rawText"> | undefined;
|
||||||
|
let lastParseError: Error | undefined;
|
||||||
|
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
parsed = parseReviewPayload(text);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastParseError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
if (attempt === MAX_PARSE_RETRIES) break;
|
||||||
|
await agent.session.prompt("Your previous response was not valid JSON. Respond with only a valid JSON object.");
|
||||||
|
text = getAssistantText(agent.session.state.messages as AgentMessage[]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
throw new ReviewParseError(`Failed to parse reviewer response: ${lastParseError?.message ?? "unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
memberId: member.id,
|
||||||
|
memberName: member.name,
|
||||||
|
perspective: member.perspective,
|
||||||
|
rawText: text,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
...parsed,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
(agent.session as { dispose?: () => void }).dispose?.();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
timeoutAfter(REVIEW_TIMEOUT_MS, member.name),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runReviewPanel(input: RunReviewPanelInput, ctx: PluginContext): Promise<CombinedReview> {
|
||||||
|
const createAiSession = pickFactory(ctx);
|
||||||
|
if (!createAiSession) {
|
||||||
|
throw new ReviewPanelError("session_unavailable", "AI session factory is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const settled = await Promise.allSettled(input.panel.map((member) => runSingleReview(member, input, createAiSession, ctx.settings)));
|
||||||
|
|
||||||
|
const individual: IndividualReview[] = [];
|
||||||
|
const failures: ReviewFailure[] = [];
|
||||||
|
for (let index = 0; index < settled.length; index++) {
|
||||||
|
const result = settled[index];
|
||||||
|
const member = input.panel[index];
|
||||||
|
if (result.status === "fulfilled") individual.push(result.value);
|
||||||
|
else failures.push(toFailure(member.id, result.reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
return combineReviews(individual, failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function __setCreateAiSessionFactory(factory: CreateAiSessionFactory | undefined): void {
|
||||||
|
injectedCreateAiSession = factory;
|
||||||
|
}
|
||||||
75
plugins/fusion-plugin-reports/src/review-types.ts
Normal file
75
plugins/fusion-plugin-reports/src/review-types.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
export type ReviewVerdict = "approve" | "revise" | "reject";
|
||||||
|
|
||||||
|
export interface ReviewPanelMember {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
perspective: string;
|
||||||
|
promptTemplateId?: string;
|
||||||
|
provider?: string;
|
||||||
|
modelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndividualReview {
|
||||||
|
memberId: string;
|
||||||
|
memberName: string;
|
||||||
|
perspective: string;
|
||||||
|
verdict: ReviewVerdict;
|
||||||
|
summary: string;
|
||||||
|
highlights: string[];
|
||||||
|
lowlights: string[];
|
||||||
|
suggestions: string[];
|
||||||
|
rawText: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewFailure {
|
||||||
|
memberId: string;
|
||||||
|
reason: "timeout" | "parse_error" | "session_unavailable" | "exception";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombinedReview {
|
||||||
|
overallVerdict: ReviewVerdict;
|
||||||
|
consensusSummary: string;
|
||||||
|
mergedHighlights: string[];
|
||||||
|
mergedLowlights: string[];
|
||||||
|
mergedSuggestions: string[];
|
||||||
|
individual: IndividualReview[];
|
||||||
|
failures: ReviewFailure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunReviewPanelInput {
|
||||||
|
reportDraft: string;
|
||||||
|
reportMetadata: {
|
||||||
|
reportId: string;
|
||||||
|
cadence: "daily" | "weekly";
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
};
|
||||||
|
panel: ReviewPanelMember[];
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewPanelError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly reason: "session_unavailable" | "exception",
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ReviewPanelError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ReviewParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReviewTimeoutError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ReviewTimeoutError";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user