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:
@@ -2,4 +2,88 @@
|
||||
|
||||
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 { runReviewPanel } from "./review-panel.js";
|
||||
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
|
||||
import { settingsSchema } from "./settings.js";
|
||||
|
||||
const plugin = definePlugin({
|
||||
@@ -15,6 +18,24 @@ const plugin = definePlugin({
|
||||
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 * 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