Commit
debate engine and unit tests
commit
50143f5
29 changed files with +2733 and −0
Jump to a changed file
- src/core/anonymize.test.ts +128 −0
- src/core/anonymize.ts +0 −0
- src/core/config.ts +37 −0
- src/core/convergence.test.ts +44 −0
- src/core/convergence.ts +41 −0
- src/core/events.ts +92 −0
- src/core/index.ts +23 −0
- src/core/json-repair.test.ts +82 −0
- src/core/json-repair.ts +97 −0
- src/core/llm-client.ts +71 −0
- src/core/mock-client.ts +270 −0
- src/core/models.ts +22 −0
- src/core/orchestrator.test.ts +195 −0
- src/core/orchestrator.ts +662 −0
- src/core/prompts/answer.ts +27 −0
- src/core/prompts/convergence.ts +48 −0
- src/core/prompts/critique.ts +54 −0
- src/core/prompts/index.ts +40 −0
- src/core/prompts/repair.ts +29 −0
- src/core/prompts/revision.ts +67 −0
- src/core/prompts/synthesis.ts +67 −0
- src/core/schemas.ts +98 −0
- src/core/scoring.test.ts +72 −0
- src/core/scoring.ts +63 −0
- src/core/structured.test.ts +75 −0
- src/core/structured.ts +71 −0
- src/core/timeout.ts +37 −0
- src/core/types.ts +204 −0
- src/core/usage.ts +17 −0
added src/core/anonymize.test.ts +128 −0
| @@ -0,0 +1,128 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { | |
| 3 | + anonymizePeers, | |
| 4 | + hashSeed, | |
| 5 | + labelForIndex, | |
| 6 | + makeRng, | |
| 7 | + shuffle, | |
| 8 | +} from './anonymize'; | |
| 9 | + | |
| 10 | +describe('makeRng', () => { | |
| 11 | + it('is deterministic for a given seed', () => { | |
| 12 | + const a = makeRng(42); | |
| 13 | + const b = makeRng(42); | |
| 14 | + const seqA = [a(), a(), a(), a()]; | |
| 15 | + const seqB = [b(), b(), b(), b()]; | |
| 16 | + expect(seqA).toEqual(seqB); | |
| 17 | + }); | |
| 18 | + | |
| 19 | + it('produces values in [0, 1)', () => { | |
| 20 | + const rng = makeRng(7); | |
| 21 | + for (let i = 0; i < 1000; i++) { | |
| 22 | + const v = rng(); | |
| 23 | + expect(v).toBeGreaterThanOrEqual(0); | |
| 24 | + expect(v).toBeLessThan(1); | |
| 25 | + } | |
| 26 | + }); | |
| 27 | + | |
| 28 | + it('produces different sequences for different seeds', () => { | |
| 29 | + expect(makeRng(1)()).not.toEqual(makeRng(2)()); | |
| 30 | + }); | |
| 31 | +}); | |
| 32 | + | |
| 33 | +describe('hashSeed', () => { | |
| 34 | + it('is stable and order-sensitive', () => { | |
| 35 | + expect(hashSeed('debate', 1, 'critique', 'p0')).toBe(hashSeed('debate', 1, 'critique', 'p0')); | |
| 36 | + expect(hashSeed('debate', 1, 'critique', 'p0')).not.toBe(hashSeed('debate', 1, 'critique', 'p1')); | |
| 37 | + expect(hashSeed('a', 'b')).not.toBe(hashSeed('b', 'a')); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it('returns an unsigned 32-bit integer', () => { | |
| 41 | + const h = hashSeed('anything', 99); | |
| 42 | + expect(Number.isInteger(h)).toBe(true); | |
| 43 | + expect(h).toBeGreaterThanOrEqual(0); | |
| 44 | + expect(h).toBeLessThanOrEqual(0xffffffff); | |
| 45 | + }); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +describe('shuffle', () => { | |
| 49 | + const rng = () => 0.5; // fixed generator | |
| 50 | + | |
| 51 | + it('does not mutate the input', () => { | |
| 52 | + const input = [1, 2, 3, 4]; | |
| 53 | + const copy = [...input]; | |
| 54 | + shuffle(input, makeRng(1)); | |
| 55 | + expect(input).toEqual(copy); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it('returns a permutation (same multiset)', () => { | |
| 59 | + const input = ['a', 'b', 'c', 'd', 'e']; | |
| 60 | + const out = shuffle(input, makeRng(123)); | |
| 61 | + expect([...out].sort()).toEqual([...input].sort()); | |
| 62 | + expect(out).toHaveLength(input.length); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + it('is deterministic for a given rng seed', () => { | |
| 66 | + const input = [1, 2, 3, 4, 5, 6]; | |
| 67 | + expect(shuffle(input, makeRng(9))).toEqual(shuffle(input, makeRng(9))); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it('handles empty and singleton arrays', () => { | |
| 71 | + expect(shuffle([], rng)).toEqual([]); | |
| 72 | + expect(shuffle([42], rng)).toEqual([42]); | |
| 73 | + }); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +describe('labelForIndex', () => { | |
| 77 | + it('maps 0..25 to A..Z', () => { | |
| 78 | + expect(labelForIndex(0)).toBe('A'); | |
| 79 | + expect(labelForIndex(1)).toBe('B'); | |
| 80 | + expect(labelForIndex(25)).toBe('Z'); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + it('continues to AA, AB for larger councils', () => { | |
| 84 | + expect(labelForIndex(26)).toBe('AA'); | |
| 85 | + expect(labelForIndex(27)).toBe('AB'); | |
| 86 | + }); | |
| 87 | +}); | |
| 88 | + | |
| 89 | +describe('anonymizePeers', () => { | |
| 90 | + const peers = [ | |
| 91 | + { participantId: 'p0', content: 'answer zero' }, | |
| 92 | + { participantId: 'p1', content: 'answer one' }, | |
| 93 | + { participantId: 'p2', content: 'answer two' }, | |
| 94 | + ]; | |
| 95 | + | |
| 96 | + it('assigns unique sequential labels and a consistent labelMap', () => { | |
| 97 | + const { peers: out, labelMap } = anonymizePeers(peers, hashSeed('d', 1, 'p0')); | |
| 98 | + const labels = out.map((p) => p.label); | |
| 99 | + expect(new Set(labels).size).toBe(labels.length); | |
| 100 | + expect(labels).toEqual(['A', 'B', 'C']); | |
| 101 | + for (const p of out) { | |
| 102 | + expect(labelMap[p.label]).toBe(p.participantId); | |
| 103 | + } | |
| 104 | + }); | |
| 105 | + | |
| 106 | + it('preserves every participant exactly once (no dropping or duplication)', () => { | |
| 107 | + const { peers: out } = anonymizePeers(peers, 555); | |
| 108 | + expect(out.map((p) => p.participantId).sort()).toEqual(['p0', 'p1', 'p2']); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it('is deterministic for a given seed', () => { | |
| 112 | + const seed = hashSeed('debate', 1, 'critique', 'reviewerA'); | |
| 113 | + const a1 = anonymizePeers(peers, seed).peers.map((p) => p.participantId); | |
| 114 | + const a2 = anonymizePeers(peers, seed).peers.map((p) => p.participantId); | |
| 115 | + expect(a1).toEqual(a2); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + it('produces more than one ordering across reviewers (order is not shared)', () => { | |
| 119 | + // The point of per-reviewer shuffling is that different seeds generally | |
| 120 | + // yield different orderings. Sample many seeds and require real variation. | |
| 121 | + const orderings = new Set<string>(); | |
| 122 | + for (let i = 0; i < 50; i++) { | |
| 123 | + const seed = hashSeed('debate', 1, 'critique', `reviewer-${i}`); | |
| 124 | + orderings.add(anonymizePeers(peers, seed).peers.map((p) => p.participantId).join(',')); | |
| 125 | + } | |
| 126 | + expect(orderings.size).toBeGreaterThan(1); | |
| 127 | + }); | |
| 128 | +}); |
added src/core/anonymize.ts +0 −0
Line changes are not available for this file.
added src/core/config.ts +37 −0
| @@ -0,0 +1,37 @@ | ||
| 1 | +/** | |
| 2 | + * Turn validated API input into a fully-resolved `DebateConfig`, filling | |
| 3 | + * defaults (like the convergence model) the orchestrator requires. | |
| 4 | + */ | |
| 5 | +import { DEFAULT_CONVERGENCE_MODEL } from './models'; | |
| 6 | +import { debateConfigInputSchema, type DebateConfigInput } from './schemas'; | |
| 7 | +import { displayNameForModel, type DebateConfig, type Participant } from './types'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Build the stable participant list for a council. The `p{index}` id scheme is | |
| 11 | + * shared by the orchestrator and the persistence layer so labels line up. | |
| 12 | + */ | |
| 13 | +export function participantsFor(models: string[]): Participant[] { | |
| 14 | + return models.map((model, i) => ({ | |
| 15 | + id: `p${i}`, | |
| 16 | + model, | |
| 17 | + displayName: displayNameForModel(model), | |
| 18 | + })); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function resolveDebateConfig(input: DebateConfigInput): DebateConfig { | |
| 22 | + return { | |
| 23 | + question: input.question, | |
| 24 | + models: input.models, | |
| 25 | + chairmanModel: input.chairmanModel, | |
| 26 | + convergenceModel: input.convergenceModel ?? DEFAULT_CONVERGENCE_MODEL, | |
| 27 | + maxRounds: input.maxRounds, | |
| 28 | + convergenceThreshold: input.convergenceThreshold, | |
| 29 | + temperature: input.temperature, | |
| 30 | + perModelTimeoutMs: input.perModelTimeoutMs, | |
| 31 | + }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +/** Parse + resolve untrusted input in one step. Throws ZodError on bad input. */ | |
| 35 | +export function parseDebateConfig(raw: unknown): DebateConfig { | |
| 36 | + return resolveDebateConfig(debateConfigInputSchema.parse(raw)); | |
| 37 | +} |
added src/core/convergence.test.ts +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { shouldStop } from './convergence'; | |
| 3 | + | |
| 4 | +describe('shouldStop', () => { | |
| 5 | + const base = { threshold: 85, round: 1, maxRounds: 3, activeCount: 3 }; | |
| 6 | + | |
| 7 | + it('stops when the convergence score meets the threshold', () => { | |
| 8 | + expect(shouldStop({ ...base, score: 85 })).toEqual({ stop: true, reason: 'converged' }); | |
| 9 | + expect(shouldStop({ ...base, score: 92 })).toEqual({ stop: true, reason: 'converged' }); | |
| 10 | + }); | |
| 11 | + | |
| 12 | + it('continues when below threshold and rounds remain', () => { | |
| 13 | + expect(shouldStop({ ...base, score: 70 })).toEqual({ stop: false, reason: 'continue' }); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it('stops at max rounds even if not converged', () => { | |
| 17 | + expect(shouldStop({ ...base, score: 40, round: 3, maxRounds: 3 })).toEqual({ | |
| 18 | + stop: true, | |
| 19 | + reason: 'max_rounds', | |
| 20 | + }); | |
| 21 | + }); | |
| 22 | + | |
| 23 | + it('stops with insufficient_models when fewer than two remain - even if converged', () => { | |
| 24 | + expect(shouldStop({ ...base, score: 99, activeCount: 1 })).toEqual({ | |
| 25 | + stop: true, | |
| 26 | + reason: 'insufficient_models', | |
| 27 | + }); | |
| 28 | + expect(shouldStop({ ...base, score: 10, activeCount: 0 })).toEqual({ | |
| 29 | + stop: true, | |
| 30 | + reason: 'insufficient_models', | |
| 31 | + }); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it('prioritizes insufficient_models over convergence and max_rounds', () => { | |
| 35 | + // Converged AND at max rounds AND too few models -> insufficient wins. | |
| 36 | + expect(shouldStop({ score: 100, threshold: 85, round: 3, maxRounds: 3, activeCount: 1 }).reason).toBe( | |
| 37 | + 'insufficient_models', | |
| 38 | + ); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it('treats exactly two active models as enough to continue', () => { | |
| 42 | + expect(shouldStop({ ...base, score: 50, activeCount: 2 })).toEqual({ stop: false, reason: 'continue' }); | |
| 43 | + }); | |
| 44 | +}); |
added src/core/convergence.ts +41 −0
| @@ -0,0 +1,41 @@ | ||
| 1 | +/** | |
| 2 | + * The early-stop decision. | |
| 3 | + * | |
| 4 | + * Pure and side-effect free so it can be exhaustively unit-tested. The | |
| 5 | + * orchestrator calls this after each round with the convergence score returned | |
| 6 | + * by the assessor model and the current health of the council. | |
| 7 | + */ | |
| 8 | + | |
| 9 | +export type StopReason = 'converged' | 'max_rounds' | 'insufficient_models' | 'continue'; | |
| 10 | + | |
| 11 | +export interface StopDecision { | |
| 12 | + stop: boolean; | |
| 13 | + reason: StopReason; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export interface StopParams { | |
| 17 | + /** Convergence score for the round just completed (0-100). */ | |
| 18 | + score: number; | |
| 19 | + /** Threshold at/above which we consider the council converged. */ | |
| 20 | + threshold: number; | |
| 21 | + /** 1-based index of the round just completed. */ | |
| 22 | + round: number; | |
| 23 | + /** Configured maximum number of critique→revision rounds. */ | |
| 24 | + maxRounds: number; | |
| 25 | + /** Number of models still healthy enough to continue. */ | |
| 26 | + activeCount: number; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function shouldStop({ | |
| 30 | + score, | |
| 31 | + threshold, | |
| 32 | + round, | |
| 33 | + maxRounds, | |
| 34 | + activeCount, | |
| 35 | +}: StopParams): StopDecision { | |
| 36 | + // A debate needs at least two voices; below that there is nothing to debate. | |
| 37 | + if (activeCount < 2) return { stop: true, reason: 'insufficient_models' }; | |
| 38 | + if (score >= threshold) return { stop: true, reason: 'converged' }; | |
| 39 | + if (round >= maxRounds) return { stop: true, reason: 'max_rounds' }; | |
| 40 | + return { stop: false, reason: 'continue' }; | |
| 41 | +} |
added src/core/events.ts +92 −0
| @@ -0,0 +1,92 @@ | ||
| 1 | +/** | |
| 2 | + * The single typed event stream produced by the orchestrator. | |
| 3 | + * | |
| 4 | + * Both consumers subscribe to this one union: | |
| 5 | + * - the SSE endpoint serializes each event to the browser | |
| 6 | + * - the persistence layer reacts to `*_completed` events to write StageResults | |
| 7 | + * | |
| 8 | + * Keeping one source of truth means "what the UI sees" and "what we store" can | |
| 9 | + * never drift apart. | |
| 10 | + */ | |
| 11 | +import type { | |
| 12 | + AnswerRecord, | |
| 13 | + ConvergenceRecord, | |
| 14 | + CritiqueRecord, | |
| 15 | + DebateStatus, | |
| 16 | + Participant, | |
| 17 | + PublicDebateConfig, | |
| 18 | + RevisionRecord, | |
| 19 | + StageType, | |
| 20 | + SynthesisRecord, | |
| 21 | +} from './types'; | |
| 22 | + | |
| 23 | +export type DebateEvent = | |
| 24 | + | { | |
| 25 | + type: 'debate_started'; | |
| 26 | + debateId: string; | |
| 27 | + config: PublicDebateConfig; | |
| 28 | + participants: Participant[]; | |
| 29 | + chairmanModel: string; | |
| 30 | + /** True when chairman shares a provider family with a council member. */ | |
| 31 | + chairmanProviderConflict: boolean; | |
| 32 | + } | |
| 33 | + | { type: 'round_started'; round: number; kind: 'answers' | 'cycle' } | |
| 34 | + | { | |
| 35 | + type: 'stage_started'; | |
| 36 | + round: number; | |
| 37 | + stage: StageType; | |
| 38 | + participantId?: string; | |
| 39 | + model?: string; | |
| 40 | + } | |
| 41 | + | { | |
| 42 | + type: 'token_delta'; | |
| 43 | + round: number; | |
| 44 | + stage: StageType; | |
| 45 | + participantId: string; | |
| 46 | + delta: string; | |
| 47 | + } | |
| 48 | + | { type: 'answer_completed'; round: number; record: AnswerRecord } | |
| 49 | + | { type: 'critique_completed'; round: number; record: CritiqueRecord } | |
| 50 | + | { type: 'revision_completed'; round: number; record: RevisionRecord } | |
| 51 | + | { type: 'convergence_result'; round: number; record: ConvergenceRecord } | |
| 52 | + | { | |
| 53 | + type: 'model_failed'; | |
| 54 | + round: number; | |
| 55 | + stage: StageType; | |
| 56 | + participantId: string; | |
| 57 | + model: string; | |
| 58 | + error: string; | |
| 59 | + droppedFromDebate: boolean; | |
| 60 | + } | |
| 61 | + | { type: 'synthesis_completed'; record: SynthesisRecord } | |
| 62 | + | { | |
| 63 | + type: 'cost_update'; | |
| 64 | + totalCostUsd: number; | |
| 65 | + promptTokens: number; | |
| 66 | + completionTokens: number; | |
| 67 | + costByModel: Record<string, number>; | |
| 68 | + } | |
| 69 | + | { | |
| 70 | + type: 'debate_completed'; | |
| 71 | + debateId: string; | |
| 72 | + status: DebateStatus; | |
| 73 | + totalCostUsd: number; | |
| 74 | + rounds: number; | |
| 75 | + durationMs: number; | |
| 76 | + } | |
| 77 | + | { type: 'debate_failed'; debateId: string; error: string }; | |
| 78 | + | |
| 79 | +export type DebateEventType = DebateEvent['type']; | |
| 80 | + | |
| 81 | +/** Narrowing helper for consumers that only care about one event kind. */ | |
| 82 | +export function isEvent<T extends DebateEventType>( | |
| 83 | + event: DebateEvent, | |
| 84 | + type: T, | |
| 85 | +): event is Extract<DebateEvent, { type: T }> { | |
| 86 | + return event.type === type; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export type EmitFn = (event: DebateEvent) => void | Promise<void>; | |
| 90 | + | |
| 91 | +/** A no-op emitter, handy in tests that don't assert on the stream. */ | |
| 92 | +export const noopEmit: EmitFn = () => undefined; |
added src/core/index.ts +23 −0
| @@ -0,0 +1,23 @@ | ||
| 1 | +/** | |
| 2 | + * Public surface of the framework-agnostic debate engine. | |
| 3 | + * | |
| 4 | + * Everything a host (the Next.js route adapter, a CLI, a test) needs is | |
| 5 | + * re-exported here. Nothing in this module tree imports Next.js, Prisma, React, | |
| 6 | + * or a network client. | |
| 7 | + */ | |
| 8 | +export * from './types'; | |
| 9 | +export * from './events'; | |
| 10 | +export * from './llm-client'; | |
| 11 | +export * from './schemas'; | |
| 12 | +export * from './config'; | |
| 13 | +export * from './models'; | |
| 14 | +export * from './convergence'; | |
| 15 | +export * from './scoring'; | |
| 16 | +export * from './anonymize'; | |
| 17 | +export * from './json-repair'; | |
| 18 | +export * from './usage'; | |
| 19 | +export { requestStructured, StructuredParseError } from './structured'; | |
| 20 | +export { MockLlmClient, type MockScenario } from './mock-client'; | |
| 21 | +export { runDebate, type OrchestratorDeps, type RunOptions, type Logger } from './orchestrator'; | |
| 22 | +export { PROMPT_VERSION } from './prompts'; | |
| 23 | +export { withTimeout, TimeoutError } from './timeout'; |
added src/core/json-repair.test.ts +82 −0
| @@ -0,0 +1,82 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import { extractJsonValue, lightRepair, parseStructured, stripCodeFences } from './json-repair'; | |
| 4 | + | |
| 5 | +describe('stripCodeFences', () => { | |
| 6 | + it('removes ```json fences', () => { | |
| 7 | + expect(stripCodeFences('```json\n{"a":1}\n```')).toBe('{"a":1}'); | |
| 8 | + }); | |
| 9 | + it('removes bare ``` fences', () => { | |
| 10 | + expect(stripCodeFences('```\n[1,2,3]\n```')).toBe('[1,2,3]'); | |
| 11 | + }); | |
| 12 | + it('leaves unfenced content untouched', () => { | |
| 13 | + expect(stripCodeFences(' {"a":1} ')).toBe('{"a":1}'); | |
| 14 | + }); | |
| 15 | +}); | |
| 16 | + | |
| 17 | +describe('extractJsonValue', () => { | |
| 18 | + it('extracts an object embedded in prose', () => { | |
| 19 | + expect(extractJsonValue('Sure! Here it is: {"score": 7} - hope that helps.')).toBe('{"score": 7}'); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it('extracts an array', () => { | |
| 23 | + expect(extractJsonValue('Result:\n[{"x":1}]')).toBe('[{"x":1}]'); | |
| 24 | + }); | |
| 25 | + | |
| 26 | + it('ignores braces that appear inside string literals', () => { | |
| 27 | + const raw = '{"text": "a } b { c", "n": 1}'; | |
| 28 | + expect(extractJsonValue(raw)).toBe(raw); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it('handles escaped quotes inside strings', () => { | |
| 32 | + const raw = '{"q": "she said \\"hi\\" to me"}'; | |
| 33 | + expect(extractJsonValue(raw)).toBe(raw); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it('returns null when there is no JSON', () => { | |
| 37 | + expect(extractJsonValue('no json here at all')).toBeNull(); | |
| 38 | + }); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +describe('lightRepair', () => { | |
| 42 | + it('removes trailing commas', () => { | |
| 43 | + expect(lightRepair('{"a":1,}')).toBe('{"a":1}'); | |
| 44 | + expect(lightRepair('[1,2,3,]')).toBe('[1,2,3]'); | |
| 45 | + }); | |
| 46 | + it('normalizes smart quotes', () => { | |
| 47 | + expect(lightRepair('{“a”:1}')).toBe('{"a":1}'); | |
| 48 | + }); | |
| 49 | +}); | |
| 50 | + | |
| 51 | +describe('parseStructured', () => { | |
| 52 | + const schema = z.object({ score: z.number().min(0).max(10), notes: z.string().default('') }); | |
| 53 | + | |
| 54 | + it('parses valid JSON', () => { | |
| 55 | + const r = parseStructured('{"score": 8, "notes": "ok"}', schema); | |
| 56 | + expect(r.ok).toBe(true); | |
| 57 | + if (r.ok) expect(r.value.score).toBe(8); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it('parses JSON wrapped in a code fence and prose', () => { | |
| 61 | + const r = parseStructured('Here you go:\n```json\n{"score": 5}\n```', schema); | |
| 62 | + expect(r.ok).toBe(true); | |
| 63 | + if (r.ok) expect(r.value.notes).toBe(''); // default applied | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('repairs a trailing comma', () => { | |
| 67 | + const r = parseStructured('{"score": 3,}', schema); | |
| 68 | + expect(r.ok).toBe(true); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it('fails with a schema message on out-of-range values', () => { | |
| 72 | + const r = parseStructured('{"score": 99}', schema); | |
| 73 | + expect(r.ok).toBe(false); | |
| 74 | + if (!r.ok) expect(r.error).toMatch(/score/); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + it('fails gracefully when there is no JSON', () => { | |
| 78 | + const r = parseStructured('I refuse to answer.', schema); | |
| 79 | + expect(r.ok).toBe(false); | |
| 80 | + if (!r.ok) expect(r.extracted).toBeNull(); | |
| 81 | + }); | |
| 82 | +}); |
added src/core/json-repair.ts +97 −0
| @@ -0,0 +1,97 @@ | ||
| 1 | +/** | |
| 2 | + * Defensive parsing of model-produced JSON. | |
| 3 | + * | |
| 4 | + * Models wrap JSON in ```json fences, prepend "Here is the JSON:", emit trailing | |
| 5 | + * commas, or use smart quotes. We strip the obvious noise, extract the outermost | |
| 6 | + * JSON value, and light-touch repair common syntactic slips before validating. | |
| 7 | + * If parsing still fails, the caller retries once with a dedicated repair prompt | |
| 8 | + * (see `structured.ts`). | |
| 9 | + */ | |
| 10 | +import type { z } from 'zod'; | |
| 11 | + | |
| 12 | +/** Strip Markdown code fences and surrounding prose from a JSON-ish string. */ | |
| 13 | +export function stripCodeFences(raw: string): string { | |
| 14 | + let s = raw.trim(); | |
| 15 | + // ```json … ``` or ``` … ``` | |
| 16 | + const fence = s.match(/^```(?:json|jsonc|json5)?\s*\n?([\s\S]*?)\n?```$/i); | |
| 17 | + if (fence?.[1] !== undefined) s = fence[1].trim(); | |
| 18 | + return s; | |
| 19 | +} | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Extract the first balanced JSON object or array from arbitrary text. | |
| 23 | + * Returns null if no plausible JSON value is found. Correctly ignores braces | |
| 24 | + * that appear inside string literals. | |
| 25 | + */ | |
| 26 | +export function extractJsonValue(raw: string): string | null { | |
| 27 | + const s = stripCodeFences(raw); | |
| 28 | + const start = s.search(/[{[]/); | |
| 29 | + if (start === -1) return null; | |
| 30 | + | |
| 31 | + const open = s[start]!; | |
| 32 | + const close = open === '{' ? '}' : ']'; | |
| 33 | + let depth = 0; | |
| 34 | + let inString = false; | |
| 35 | + let escaped = false; | |
| 36 | + | |
| 37 | + for (let i = start; i < s.length; i++) { | |
| 38 | + const ch = s[i]!; | |
| 39 | + if (inString) { | |
| 40 | + if (escaped) escaped = false; | |
| 41 | + else if (ch === '\\') escaped = true; | |
| 42 | + else if (ch === '"') inString = false; | |
| 43 | + continue; | |
| 44 | + } | |
| 45 | + if (ch === '"') inString = true; | |
| 46 | + else if (ch === open) depth++; | |
| 47 | + else if (ch === close) { | |
| 48 | + depth--; | |
| 49 | + if (depth === 0) return s.slice(start, i + 1); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + return null; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Light syntactic repairs that are safe and common. */ | |
| 56 | +export function lightRepair(json: string): string { | |
| 57 | + return json | |
| 58 | + // smart quotes -> straight quotes | |
| 59 | + .replace(/[“”]/g, '"') | |
| 60 | + .replace(/[‘’]/g, "'") | |
| 61 | + // trailing commas before } or ] | |
| 62 | + .replace(/,\s*([}\]])/g, '$1'); | |
| 63 | +} | |
| 64 | + | |
| 65 | +export type ParseResult<T> = | |
| 66 | + | { ok: true; value: T } | |
| 67 | + | { ok: false; error: string; extracted: string | null }; | |
| 68 | + | |
| 69 | +/** | |
| 70 | + * Parse + validate a model response against a Zod schema, applying the full | |
| 71 | + * defensive pipeline. Never throws. | |
| 72 | + */ | |
| 73 | +export function parseStructured<T>(raw: string, schema: z.ZodType<T>): ParseResult<T> { | |
| 74 | + const extracted = extractJsonValue(raw); | |
| 75 | + if (extracted === null) { | |
| 76 | + return { ok: false, error: 'No JSON object or array found in response', extracted: null }; | |
| 77 | + } | |
| 78 | + | |
| 79 | + for (const candidate of [extracted, lightRepair(extracted)]) { | |
| 80 | + try { | |
| 81 | + const parsed = JSON.parse(candidate); | |
| 82 | + const result = schema.safeParse(parsed); | |
| 83 | + if (result.success) return { ok: true, value: result.data }; | |
| 84 | + // Parsed as JSON but failed schema - report the schema error. | |
| 85 | + return { | |
| 86 | + ok: false, | |
| 87 | + error: result.error.issues | |
| 88 | + .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) | |
| 89 | + .join('; '), | |
| 90 | + extracted, | |
| 91 | + }; | |
| 92 | + } catch { | |
| 93 | + // Try the next candidate (light-repaired form). | |
| 94 | + } | |
| 95 | + } | |
| 96 | + return { ok: false, error: 'Response was not valid JSON after repair', extracted }; | |
| 97 | +} |
added src/core/llm-client.ts +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +/** | |
| 2 | + * The narrow LLM contract the orchestrator depends on. | |
| 3 | + * | |
| 4 | + * The orchestrator never imports the Vercel AI SDK, OpenRouter, or `fetch` | |
| 5 | + * directly - it only knows this interface. That keeps the engine pure and | |
| 6 | + * unit-testable: tests inject a deterministic mock (see `mock-client.ts`), | |
| 7 | + * production injects the OpenRouter-backed client (see `lib/openrouter.ts`). | |
| 8 | + */ | |
| 9 | +import type { StageType, Usage } from './types'; | |
| 10 | + | |
| 11 | +export interface LlmMessage { | |
| 12 | + role: 'system' | 'user' | 'assistant'; | |
| 13 | + content: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** Opaque, provider-ignored hint used for observability and by the mock. */ | |
| 17 | +export interface LlmRequestMeta { | |
| 18 | + stage: StageType; | |
| 19 | + round: number; | |
| 20 | + participantId?: string; | |
| 21 | + /** Deterministic seed the mock uses to synthesize reproducible output. */ | |
| 22 | + seed?: string; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface LlmRequest { | |
| 26 | + model: string; | |
| 27 | + messages: LlmMessage[]; | |
| 28 | + temperature?: number; | |
| 29 | + maxTokens?: number; | |
| 30 | + /** Ask the provider for JSON output mode when supported. */ | |
| 31 | + json?: boolean; | |
| 32 | + signal?: AbortSignal; | |
| 33 | + meta?: LlmRequestMeta; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export interface LlmResult { | |
| 37 | + text: string; | |
| 38 | + usage: Usage; | |
| 39 | + model: string; | |
| 40 | + latencyMs: number; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export interface LlmStreamHandle { | |
| 44 | + /** Text deltas as they arrive. Consume fully before awaiting `result`. */ | |
| 45 | + stream: AsyncIterable<string>; | |
| 46 | + /** Resolves once the stream is exhausted, with final text + usage. */ | |
| 47 | + result: Promise<LlmResult>; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export interface LlmClient { | |
| 51 | + /** One-shot completion (used for critique / revision / convergence / synthesis). */ | |
| 52 | + complete(req: LlmRequest): Promise<LlmResult>; | |
| 53 | + /** Streaming completion (used for the answer + revision stages the UI watches). */ | |
| 54 | + streamComplete(req: LlmRequest): Promise<LlmStreamHandle>; | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** | |
| 58 | + * Thrown by a client when a call fails in a way the orchestrator should treat | |
| 59 | + * as a per-model failure (timeout, HTTP error, refusal). Carries whether the | |
| 60 | + * error is retryable so the client's own backoff logic can decide. | |
| 61 | + */ | |
| 62 | +export class LlmError extends Error { | |
| 63 | + readonly status?: number; | |
| 64 | + readonly retryable: boolean; | |
| 65 | + constructor(message: string, opts?: { status?: number; retryable?: boolean; cause?: unknown }) { | |
| 66 | + super(message, { cause: opts?.cause }); | |
| 67 | + this.name = 'LlmError'; | |
| 68 | + this.status = opts?.status; | |
| 69 | + this.retryable = opts?.retryable ?? false; | |
| 70 | + } | |
| 71 | +} |
added src/core/mock-client.ts +270 −0
| @@ -0,0 +1,270 @@ | ||
| 1 | +/** | |
| 2 | + * Deterministic, offline LLM client. | |
| 3 | + * | |
| 4 | + * Powers `MOCK_LLM=1` (local dev / CI / the public demo) and the orchestrator | |
| 5 | + * tests. Given the same inputs it always returns the same output - no network, | |
| 6 | + * no spend, no clock or RNG dependence - which is exactly what makes the state | |
| 7 | + * machine testable and the seeded demo reproducible. | |
| 8 | + * | |
| 9 | + * Output is synthesized from `req.meta.stage`, so the mock knows what shape to | |
| 10 | + * return without brittle prompt sniffing. A small scenario object lets tests | |
| 11 | + * force timeouts, failures, and JSON-repair paths. | |
| 12 | + */ | |
| 13 | +import { hashSeed } from './anonymize'; | |
| 14 | +import type { LlmClient, LlmRequest, LlmResult, LlmStreamHandle } from './llm-client'; | |
| 15 | +import { LlmError } from './llm-client'; | |
| 16 | +import type { StageType, Usage } from './types'; | |
| 17 | + | |
| 18 | +export interface MockRule { | |
| 19 | + model?: string; | |
| 20 | + stage?: StageType; | |
| 21 | + round?: number; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export interface MockScenario { | |
| 25 | + /** Calls matching any of these throw an LlmError (to test model drop-out). */ | |
| 26 | + fail?: MockRule[]; | |
| 27 | + /** First attempt returns schema-invalid JSON, forcing one repair round-trip. */ | |
| 28 | + malformFirst?: MockRule[]; | |
| 29 | + /** Convergence score = base + step * round (clamped 0-100). */ | |
| 30 | + convergenceBase?: number; | |
| 31 | + convergenceStep?: number; | |
| 32 | + /** Fixed latency reported per call (deterministic). */ | |
| 33 | + latencyMs?: number; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function ruleMatches(rule: MockRule, model: string, stage: StageType, round: number): boolean { | |
| 37 | + if (rule.model !== undefined && rule.model !== model) return false; | |
| 38 | + if (rule.stage !== undefined && rule.stage !== stage) return false; | |
| 39 | + if (rule.round !== undefined && rule.round !== round) return false; | |
| 40 | + return true; | |
| 41 | +} | |
| 42 | + | |
| 43 | +const REPAIR_MARKER = 'strict JSON fixer'; | |
| 44 | +const isRepairCall = (req: LlmRequest) => | |
| 45 | + req.messages.some((m) => m.role === 'system' && m.content.includes(REPAIR_MARKER)); | |
| 46 | + | |
| 47 | +/** Extract "--- Response X ---" labels from a critique/convergence/synthesis prompt. */ | |
| 48 | +function labelsFromPrompt(req: LlmRequest): string[] { | |
| 49 | + const text = req.messages.map((m) => m.content).join('\n'); | |
| 50 | + const labels = [...text.matchAll(/---\s*Response\s+([A-Z]+)\s*---/g)].map((m) => m[1]!); | |
| 51 | + return labels.length ? labels : ['A']; | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** On a repair call the labels live inside the echoed original response. */ | |
| 55 | +function labelsFromRepair(req: LlmRequest): string[] { | |
| 56 | + const text = req.messages.map((m) => m.content).join('\n'); | |
| 57 | + const labels = [...text.matchAll(/"label"\s*:\s*"([A-Z]+)"/g)].map((m) => m[1]!); | |
| 58 | + return labels.length ? [...new Set(labels)] : ['A']; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function ownAnswerFromPrompt(req: LlmRequest): string { | |
| 62 | + const user = req.messages.find((m) => m.role === 'user')?.content ?? ''; | |
| 63 | + const match = user.match(/Your current answer:\n([\s\S]*?)\n\nCritiques of your answer:/); | |
| 64 | + return match?.[1]?.trim() ?? 'Prior answer unavailable.'; | |
| 65 | +} | |
| 66 | + | |
| 67 | +function questionFromPrompt(req: LlmRequest): string { | |
| 68 | + const text = req.messages.map((m) => m.content).join('\n'); | |
| 69 | + const match = text.match(/Question:\n([\s\S]*?)(?:\n\n|$)/); | |
| 70 | + return match?.[1]?.trim() ?? 'the question'; | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** Deterministic pseudo-token count (~4 chars/token). */ | |
| 74 | +function tokensOf(text: string): number { | |
| 75 | + return Math.max(1, Math.round(text.length / 4)); | |
| 76 | +} | |
| 77 | + | |
| 78 | +function usageFor(req: LlmRequest, output: string): Usage { | |
| 79 | + const promptTokens = tokensOf(req.messages.map((m) => m.content).join('\n')); | |
| 80 | + const completionTokens = tokensOf(output); | |
| 81 | + // Synthetic pricing: $1 / 1M prompt tokens, $3 / 1M completion tokens. | |
| 82 | + const costUsd = (promptTokens * 1 + completionTokens * 3) / 1_000_000; | |
| 83 | + return { | |
| 84 | + promptTokens, | |
| 85 | + completionTokens, | |
| 86 | + totalTokens: promptTokens + completionTokens, | |
| 87 | + costUsd, | |
| 88 | + }; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export class MockLlmClient implements LlmClient { | |
| 92 | + constructor(private readonly scenario: MockScenario = {}) {} | |
| 93 | + | |
| 94 | + private guard(req: LlmRequest): { model: string; stage: StageType; round: number } { | |
| 95 | + const model = req.model; | |
| 96 | + const stage: StageType = req.meta?.stage ?? 'answer'; | |
| 97 | + const round = req.meta?.round ?? 0; | |
| 98 | + if (req.signal?.aborted) { | |
| 99 | + throw new LlmError('Request aborted', { retryable: false }); | |
| 100 | + } | |
| 101 | + if (this.scenario.fail?.some((r) => ruleMatches(r, model, stage, round))) { | |
| 102 | + throw new LlmError(`Mock forced failure for ${model} at ${stage} (round ${round})`, { | |
| 103 | + status: 503, | |
| 104 | + retryable: false, | |
| 105 | + }); | |
| 106 | + } | |
| 107 | + return { model, stage, round }; | |
| 108 | + } | |
| 109 | + | |
| 110 | + private render(req: LlmRequest): string { | |
| 111 | + const { model, stage, round } = this.guard(req); | |
| 112 | + const repair = isRepairCall(req); | |
| 113 | + const forceMalform = | |
| 114 | + !repair && (this.scenario.malformFirst?.some((r) => ruleMatches(r, model, stage, round)) ?? false); | |
| 115 | + const seed = req.meta?.seed ?? `${model}:${stage}:${round}`; | |
| 116 | + | |
| 117 | + switch (stage) { | |
| 118 | + case 'answer': | |
| 119 | + return this.renderAnswer(model, round, questionFromPrompt(req)); | |
| 120 | + case 'critique': | |
| 121 | + return this.renderCritique(req, repair, forceMalform, seed); | |
| 122 | + case 'revision': | |
| 123 | + return this.renderRevision(req, repair, forceMalform, round); | |
| 124 | + case 'convergence': | |
| 125 | + return this.renderConvergence(req, repair, forceMalform, round); | |
| 126 | + case 'synthesis': | |
| 127 | + return this.renderSynthesis(req, repair, forceMalform); | |
| 128 | + default: | |
| 129 | + return 'Mock response.'; | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + private renderAnswer(model: string, round: number, question: string): string { | |
| 134 | + const stance = round === 0 ? 'initial position' : `revised position (round ${round})`; | |
| 135 | + return ( | |
| 136 | + `**${model}** - ${stance}.\n\n` + | |
| 137 | + `On the question of "${truncate(question, 120)}", the key consideration is that ` + | |
| 138 | + `a well-formed answer must balance correctness against completeness. ${modelFlavor(model)} ` + | |
| 139 | + `My recommendation: proceed with the approach that has the strongest evidentiary support, ` + | |
| 140 | + `while explicitly noting the trade-offs involved.` | |
| 141 | + ); | |
| 142 | + } | |
| 143 | + | |
| 144 | + private renderCritique(req: LlmRequest, repair: boolean, malform: boolean, seed: string): string { | |
| 145 | + const labels = repair ? labelsFromRepair(req) : labelsFromPrompt(req); | |
| 146 | + const reviews = labels.map((label) => { | |
| 147 | + const s = hashSeed(seed, label) % 6; // 0..5 | |
| 148 | + const score = malform ? 42 : 4 + s; // 4..9 normally; out-of-range to trigger repair | |
| 149 | + return { | |
| 150 | + label, | |
| 151 | + weaknesses: [`Response ${label} underspecifies the edge cases.`], | |
| 152 | + strengths: [`Response ${label} states its assumptions clearly.`], | |
| 153 | + score, | |
| 154 | + justification: `Solid reasoning with a gap around edge cases (score ${score}).`, | |
| 155 | + }; | |
| 156 | + }); | |
| 157 | + return JSON.stringify({ reviews }); | |
| 158 | + } | |
| 159 | + | |
| 160 | + private renderRevision(req: LlmRequest, repair: boolean, malform: boolean, round: number): string { | |
| 161 | + const own = repair ? 'Refined answer after incorporating peer feedback.' : ownAnswerFromPrompt(req); | |
| 162 | + const revised = | |
| 163 | + `${own}\n\nAfter review (round ${round}): clarified the edge-case handling and ` + | |
| 164 | + `tightened the recommendation.`; | |
| 165 | + const payload = { | |
| 166 | + // omit `answer` when malforming, to fail the schema and force a repair | |
| 167 | + ...(malform ? {} : { answer: revised }), | |
| 168 | + changelog: { | |
| 169 | + changed: true, | |
| 170 | + summary: 'Incorporated the edge-case critique and sharpened the recommendation.', | |
| 171 | + bullets: ['Added edge-case handling', 'Tightened final recommendation'], | |
| 172 | + }, | |
| 173 | + }; | |
| 174 | + return JSON.stringify(payload); | |
| 175 | + } | |
| 176 | + | |
| 177 | + private renderConvergence(req: LlmRequest, repair: boolean, malform: boolean, round: number): string { | |
| 178 | + const base = this.scenario.convergenceBase ?? 60; | |
| 179 | + const step = this.scenario.convergenceStep ?? 18; | |
| 180 | + const score = malform ? 150 : Math.max(0, Math.min(100, base + step * round)); | |
| 181 | + const labels = (repair ? labelsFromRepair(req) : labelsFromPrompt(req)).slice(0, 2); | |
| 182 | + const disagreements = | |
| 183 | + score >= 100 | |
| 184 | + ? [] | |
| 185 | + : [ | |
| 186 | + { | |
| 187 | + topic: 'edge-case handling', | |
| 188 | + summary: 'Members differ on how strictly edge cases must be handled.', | |
| 189 | + positions: labels.map((label) => ({ | |
| 190 | + label, | |
| 191 | + stance: `Response ${label} favors its own emphasis on this point.`, | |
| 192 | + })), | |
| 193 | + }, | |
| 194 | + ]; | |
| 195 | + return JSON.stringify({ score, disagreements }); | |
| 196 | + } | |
| 197 | + | |
| 198 | + private renderSynthesis(req: LlmRequest, repair: boolean, malform: boolean): string { | |
| 199 | + const labels = (repair ? labelsFromRepair(req) : labelsFromPrompt(req)).slice(0, 2); | |
| 200 | + const payload = { | |
| 201 | + ...(malform ? {} : { | |
| 202 | + finalAnswer: | |
| 203 | + 'Synthesized answer: the council converged on prioritizing correctness, with ' + | |
| 204 | + 'explicit handling of edge cases and a clearly stated recommendation and its trade-offs.', | |
| 205 | + }), | |
| 206 | + dissent: | |
| 207 | + labels.length >= 2 | |
| 208 | + ? [ | |
| 209 | + { | |
| 210 | + topic: 'strictness of edge-case handling', | |
| 211 | + positions: labels.map((label) => ({ | |
| 212 | + label, | |
| 213 | + position: `Response ${label} maintains a distinct emphasis.`, | |
| 214 | + })), | |
| 215 | + }, | |
| 216 | + ] | |
| 217 | + : [], | |
| 218 | + }; | |
| 219 | + return JSON.stringify(payload); | |
| 220 | + } | |
| 221 | + | |
| 222 | + async complete(req: LlmRequest): Promise<LlmResult> { | |
| 223 | + const text = this.render(req); | |
| 224 | + return { | |
| 225 | + text, | |
| 226 | + usage: usageFor(req, text), | |
| 227 | + model: req.model, | |
| 228 | + latencyMs: this.scenario.latencyMs ?? 120 + (hashSeed(req.model, req.meta?.stage ?? '') % 400), | |
| 229 | + }; | |
| 230 | + } | |
| 231 | + | |
| 232 | + async streamComplete(req: LlmRequest): Promise<LlmStreamHandle> { | |
| 233 | + const text = this.render(req); | |
| 234 | + const usage = usageFor(req, text); | |
| 235 | + const latencyMs = this.scenario.latencyMs ?? 120 + (hashSeed(req.model, 'stream') % 400); | |
| 236 | + const chunks = chunkText(text, 5); | |
| 237 | + | |
| 238 | + async function* gen(): AsyncGenerator<string> { | |
| 239 | + for (const chunk of chunks) { | |
| 240 | + if (req.signal?.aborted) throw new LlmError('Request aborted', { retryable: false }); | |
| 241 | + yield chunk; | |
| 242 | + } | |
| 243 | + } | |
| 244 | + | |
| 245 | + return { | |
| 246 | + stream: gen(), | |
| 247 | + result: Promise.resolve({ text, usage, model: req.model, latencyMs }), | |
| 248 | + }; | |
| 249 | + } | |
| 250 | +} | |
| 251 | + | |
| 252 | +function chunkText(text: string, parts: number): string[] { | |
| 253 | + const size = Math.ceil(text.length / parts) || 1; | |
| 254 | + const out: string[] = []; | |
| 255 | + for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)); | |
| 256 | + return out.length ? out : ['']; | |
| 257 | +} | |
| 258 | + | |
| 259 | +function truncate(s: string, n: number): string { | |
| 260 | + return s.length <= n ? s : `${s.slice(0, n)}...`; | |
| 261 | +} | |
| 262 | + | |
| 263 | +function modelFlavor(model: string): string { | |
| 264 | + const h = hashSeed(model) % 3; | |
| 265 | + return h === 0 | |
| 266 | + ? 'This favors a conservative, evidence-first framing.' | |
| 267 | + : h === 1 | |
| 268 | + ? 'This leans toward a pragmatic, action-oriented framing.' | |
| 269 | + : 'This weighs long-term robustness above short-term simplicity.'; | |
| 270 | +} |
added src/core/models.ts +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +/** | |
| 2 | + * Model-identity helpers used for self-preference mitigation. | |
| 3 | + * | |
| 4 | + * OpenRouter slugs are "vendor/model[:variant]"; the vendor prefix is the | |
| 5 | + * provider family. Two models from the same family (e.g. two OpenAI models) | |
| 6 | + * share training lineage and stylistic priors, so a chairman that shares a | |
| 7 | + * family with a council member is a self-preference risk worth flagging. | |
| 8 | + */ | |
| 9 | + | |
| 10 | +/** Sensible cheap/fast default for the between-rounds convergence check. */ | |
| 11 | +export const DEFAULT_CONVERGENCE_MODEL = 'google/gemini-2.0-flash-001'; | |
| 12 | + | |
| 13 | +export function providerFamily(slug: string): string { | |
| 14 | + const idx = slug.indexOf('/'); | |
| 15 | + return (idx === -1 ? slug : slug.slice(0, idx)).toLowerCase(); | |
| 16 | +} | |
| 17 | + | |
| 18 | +/** True if the chairman shares a provider family with any council member. */ | |
| 19 | +export function chairmanSharesProvider(chairman: string, council: readonly string[]): boolean { | |
| 20 | + const fam = providerFamily(chairman); | |
| 21 | + return council.some((m) => providerFamily(m) === fam); | |
| 22 | +} |
added src/core/orchestrator.test.ts +195 −0
| @@ -0,0 +1,195 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import type { DebateEvent } from './events'; | |
| 3 | +import { MockLlmClient, type MockScenario } from './mock-client'; | |
| 4 | +import { runDebate } from './orchestrator'; | |
| 5 | +import type { DebateConfig, DebateResult } from './types'; | |
| 6 | + | |
| 7 | +function makeConfig(overrides: Partial<DebateConfig> = {}): DebateConfig { | |
| 8 | + return { | |
| 9 | + question: 'What is the best sorting algorithm for nearly-sorted data?', | |
| 10 | + models: ['openai/gpt-4o', 'anthropic/claude-3.5-sonnet', 'google/gemini-pro'], | |
| 11 | + chairmanModel: 'x-ai/grok-2', | |
| 12 | + convergenceModel: 'google/gemini-2.0-flash-001', | |
| 13 | + maxRounds: 3, | |
| 14 | + convergenceThreshold: 85, | |
| 15 | + temperature: 0.7, | |
| 16 | + perModelTimeoutMs: 5_000, | |
| 17 | + ...overrides, | |
| 18 | + }; | |
| 19 | +} | |
| 20 | + | |
| 21 | +interface RunHarness { | |
| 22 | + result: DebateResult; | |
| 23 | + events: DebateEvent[]; | |
| 24 | + of<T extends DebateEvent['type']>(type: T): Extract<DebateEvent, { type: T }>[]; | |
| 25 | +} | |
| 26 | + | |
| 27 | +async function run( | |
| 28 | + config: DebateConfig, | |
| 29 | + scenario: MockScenario = {}, | |
| 30 | + opts: { signal?: AbortSignal; debateId?: string } = {}, | |
| 31 | +): Promise<RunHarness> { | |
| 32 | + const events: DebateEvent[] = []; | |
| 33 | + let clock = 1_000; | |
| 34 | + const result = await runDebate( | |
| 35 | + config, | |
| 36 | + { | |
| 37 | + llm: new MockLlmClient({ latencyMs: 50, ...scenario }), | |
| 38 | + emit: (e) => { | |
| 39 | + events.push(e); | |
| 40 | + }, | |
| 41 | + now: () => (clock += 10), | |
| 42 | + }, | |
| 43 | + { debateId: opts.debateId ?? 'debate-test', signal: opts.signal }, | |
| 44 | + ); | |
| 45 | + return { | |
| 46 | + result, | |
| 47 | + events, | |
| 48 | + of: (type) => events.filter((e) => e.type === type) as never, | |
| 49 | + }; | |
| 50 | +} | |
| 51 | + | |
| 52 | +describe('runDebate - happy path', () => { | |
| 53 | + it('runs answers → critique/revision rounds → synthesis and completes', async () => { | |
| 54 | + const { result, of } = await run(makeConfig()); | |
| 55 | + | |
| 56 | + expect(result.status).toBe('completed'); | |
| 57 | + expect(result.participants).toHaveLength(3); | |
| 58 | + expect(result.initialAnswers).toHaveLength(3); | |
| 59 | + expect(result.synthesis).not.toBeNull(); | |
| 60 | + expect(result.synthesis!.finalAnswer.length).toBeGreaterThan(0); | |
| 61 | + | |
| 62 | + // Default mock convergence: 60, 78, 96 → stops at round 2 by convergence. | |
| 63 | + expect(result.rounds).toHaveLength(2); | |
| 64 | + for (const round of result.rounds) { | |
| 65 | + expect(round.critiques).toHaveLength(3); | |
| 66 | + expect(round.revisions).toHaveLength(3); | |
| 67 | + expect(round.convergence).not.toBeNull(); | |
| 68 | + } | |
| 69 | + expect(result.rounds.at(-1)!.convergence!.converged).toBe(true); | |
| 70 | + | |
| 71 | + // Event stream sanity. | |
| 72 | + expect(of('debate_started')).toHaveLength(1); | |
| 73 | + expect(of('answer_completed')).toHaveLength(3); | |
| 74 | + expect(of('synthesis_completed')).toHaveLength(1); | |
| 75 | + expect(of('debate_completed')[0]!.status).toBe('completed'); | |
| 76 | + | |
| 77 | + // Cost accrued and is attributed per model. | |
| 78 | + expect(result.totals.costUsd).toBeGreaterThan(0); | |
| 79 | + expect(Object.keys(result.totals.costByModel).length).toBeGreaterThanOrEqual(3); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it('anonymizes correctly: no reviewer scores itself, targets resolve to real peers', async () => { | |
| 83 | + const { result } = await run(makeConfig()); | |
| 84 | + const ids = new Set(result.participants.map((p) => p.id)); | |
| 85 | + for (const round of result.rounds) { | |
| 86 | + for (const critique of round.critiques) { | |
| 87 | + for (const review of critique.reviews) { | |
| 88 | + expect(review.targetParticipantId).not.toBe(critique.reviewerParticipantId); | |
| 89 | + expect(ids.has(review.targetParticipantId)).toBe(true); | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it('streams answer token deltas to the UI', async () => { | |
| 96 | + const { of } = await run(makeConfig()); | |
| 97 | + const deltas = of('token_delta'); | |
| 98 | + expect(deltas.length).toBeGreaterThan(0); | |
| 99 | + expect(deltas.every((d) => d.stage === 'answer')).toBe(true); | |
| 100 | + }); | |
| 101 | +}); | |
| 102 | + | |
| 103 | +describe('runDebate - early stopping', () => { | |
| 104 | + it('stops after one round when convergence is immediate', async () => { | |
| 105 | + const { result } = await run(makeConfig(), { convergenceBase: 90, convergenceStep: 0 }); | |
| 106 | + expect(result.rounds).toHaveLength(1); | |
| 107 | + expect(result.status).toBe('completed'); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + it('runs to max rounds when never converging', async () => { | |
| 111 | + const { result } = await run(makeConfig({ maxRounds: 2 }), { | |
| 112 | + convergenceBase: 10, | |
| 113 | + convergenceStep: 0, | |
| 114 | + }); | |
| 115 | + expect(result.rounds).toHaveLength(2); | |
| 116 | + expect(result.rounds.at(-1)!.convergence!.converged).toBe(false); | |
| 117 | + expect(result.synthesis).not.toBeNull(); | |
| 118 | + }); | |
| 119 | +}); | |
| 120 | + | |
| 121 | +describe('runDebate - failure handling', () => { | |
| 122 | + it('drops a model that fails its initial answer but continues with the rest', async () => { | |
| 123 | + const { result, of } = await run(makeConfig(), { | |
| 124 | + fail: [{ model: 'google/gemini-pro', stage: 'answer', round: 0 }], | |
| 125 | + }); | |
| 126 | + | |
| 127 | + expect(result.status).toBe('completed'); | |
| 128 | + expect(result.initialAnswers).toHaveLength(2); | |
| 129 | + expect(result.finalAnswers.map((a) => a.model)).not.toContain('google/gemini-pro'); | |
| 130 | + | |
| 131 | + const failed = of('model_failed'); | |
| 132 | + expect(failed).toHaveLength(1); | |
| 133 | + expect(failed[0]!.droppedFromDebate).toBe(true); | |
| 134 | + // Later rounds only involve the two survivors. | |
| 135 | + expect(result.rounds[0]!.critiques).toHaveLength(2); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + it('fails the debate when fewer than two models produce an initial answer', async () => { | |
| 139 | + const { result } = await run(makeConfig(), { | |
| 140 | + fail: [ | |
| 141 | + { model: 'anthropic/claude-3.5-sonnet', stage: 'answer', round: 0 }, | |
| 142 | + { model: 'google/gemini-pro', stage: 'answer', round: 0 }, | |
| 143 | + ], | |
| 144 | + }); | |
| 145 | + expect(result.status).toBe('failed'); | |
| 146 | + expect(result.synthesis).toBeNull(); | |
| 147 | + expect(result.error).toMatch(/initial answer/i); | |
| 148 | + }); | |
| 149 | + | |
| 150 | + it('keeps a model in the debate when it only fails a single critique', async () => { | |
| 151 | + const { result, of } = await run(makeConfig(), { | |
| 152 | + fail: [{ model: 'google/gemini-pro', stage: 'critique', round: 1 }], | |
| 153 | + }); | |
| 154 | + expect(result.status).toBe('completed'); | |
| 155 | + // The model that failed a critique is still present in the final answers. | |
| 156 | + expect(result.finalAnswers.map((a) => a.model)).toContain('google/gemini-pro'); | |
| 157 | + const failure = of('model_failed').find((f) => f.stage === 'critique'); | |
| 158 | + expect(failure?.droppedFromDebate).toBe(false); | |
| 159 | + }); | |
| 160 | +}); | |
| 161 | + | |
| 162 | +describe('runDebate - JSON repair', () => { | |
| 163 | + it('recovers from malformed critique JSON via a repair attempt (no drop)', async () => { | |
| 164 | + const { result, of } = await run(makeConfig(), { | |
| 165 | + malformFirst: [{ model: 'openai/gpt-4o', stage: 'critique', round: 1 }], | |
| 166 | + }); | |
| 167 | + expect(result.status).toBe('completed'); | |
| 168 | + // Repaired, so no critique failure was recorded for that model. | |
| 169 | + expect(of('model_failed')).toHaveLength(0); | |
| 170 | + expect(result.failures).toHaveLength(0); | |
| 171 | + expect(result.rounds[0]!.critiques).toHaveLength(3); | |
| 172 | + }); | |
| 173 | +}); | |
| 174 | + | |
| 175 | +describe('runDebate - chairman self-preference', () => { | |
| 176 | + it('flags when the chairman shares a provider family with a council member', async () => { | |
| 177 | + const { of } = await run( | |
| 178 | + makeConfig({ chairmanModel: 'openai/gpt-4o-mini' }), // same "openai" family as a council member | |
| 179 | + ); | |
| 180 | + expect(of('debate_started')[0]!.chairmanProviderConflict).toBe(true); | |
| 181 | + }); | |
| 182 | + | |
| 183 | + it('does not flag a chairman from an independent provider', async () => { | |
| 184 | + const { of } = await run(makeConfig()); // chairman x-ai/grok-2 | |
| 185 | + expect(of('debate_started')[0]!.chairmanProviderConflict).toBe(false); | |
| 186 | + }); | |
| 187 | +}); | |
| 188 | + | |
| 189 | +describe('runDebate - cancellation', () => { | |
| 190 | + it('reports aborted status when the debate signal is already aborted', async () => { | |
| 191 | + const { result } = await run(makeConfig(), {}, { signal: AbortSignal.abort() }); | |
| 192 | + expect(result.status).toBe('aborted'); | |
| 193 | + expect(result.synthesis).toBeNull(); | |
| 194 | + }); | |
| 195 | +}); |
added src/core/orchestrator.ts +662 −0
| @@ -0,0 +1,662 @@ | ||
| 1 | +/** | |
| 2 | + * The debate orchestrator - an explicit, framework-agnostic state machine. | |
| 3 | + * | |
| 4 | + * round 0 answers (independent, streamed) | |
| 5 | + * round 1..N critique -> revision -> convergence (repeat) | |
| 6 | + * final synthesis (chairman: answer + dissent) | |
| 7 | + * | |
| 8 | + * It depends only on an injected `LlmClient` and an `emit` callback, so it runs | |
| 9 | + * identically under the mock client in a unit test and under OpenRouter behind | |
| 10 | + * an SSE route. Every meaningful step is emitted as a `DebateEvent`; the return | |
| 11 | + * value is the fully-materialized, replayable `DebateResult`. | |
| 12 | + * | |
| 13 | + * Robustness contract: | |
| 14 | + * - Each model call is bounded by `perModelTimeoutMs` and cancelled on timeout. | |
| 15 | + * - A model that fails a stage is dropped from that stage, not the debate, | |
| 16 | + * whenever the debate can still proceed with ≥2 healthy members. | |
| 17 | + * - Structured outputs get one JSON-repair retry before the model is dropped. | |
| 18 | + * - Tokens spent on failed attempts are still billed. | |
| 19 | + */ | |
| 20 | +import type { z } from 'zod'; | |
| 21 | +import { anonymizePeers, hashSeed, type AnonymizationResult } from './anonymize'; | |
| 22 | +import { participantsFor } from './config'; | |
| 23 | +import { shouldStop, type StopReason } from './convergence'; | |
| 24 | +import type { DebateEvent, EmitFn } from './events'; | |
| 25 | +import type { LlmClient, LlmRequest } from './llm-client'; | |
| 26 | +import { chairmanSharesProvider } from './models'; | |
| 27 | +import { | |
| 28 | + buildAnswerPrompt, | |
| 29 | + buildConvergencePrompt, | |
| 30 | + buildCritiquePrompt, | |
| 31 | + buildRevisionPrompt, | |
| 32 | + buildSynthesisPrompt, | |
| 33 | +} from './prompts'; | |
| 34 | +import type { IncomingCritique } from './prompts/revision'; | |
| 35 | +import { | |
| 36 | + convergenceOutputSchema, | |
| 37 | + critiqueOutputSchema, | |
| 38 | + revisionOutputSchema, | |
| 39 | + synthesisOutputSchema, | |
| 40 | +} from './schemas'; | |
| 41 | +import { requestStructured, StructuredParseError } from './structured'; | |
| 42 | +import { withTimeout } from './timeout'; | |
| 43 | +import { | |
| 44 | + displayNameForModel, | |
| 45 | + emptyUsage, | |
| 46 | + type AnswerRecord, | |
| 47 | + type ConvergenceRecord, | |
| 48 | + type CritiqueRecord, | |
| 49 | + type DebateConfig, | |
| 50 | + type DebateResult, | |
| 51 | + type DebateStatus, | |
| 52 | + type Disagreement, | |
| 53 | + type FailureRecord, | |
| 54 | + type Participant, | |
| 55 | + type PeerReview, | |
| 56 | + type RevisionRecord, | |
| 57 | + type RoundRecord, | |
| 58 | + type StageType, | |
| 59 | + type SynthesisRecord, | |
| 60 | + type Usage, | |
| 61 | +} from './types'; | |
| 62 | +import { addUsage } from './usage'; | |
| 63 | + | |
| 64 | +export interface Logger { | |
| 65 | + info(msg: string, meta?: Record<string, unknown>): void; | |
| 66 | + warn(msg: string, meta?: Record<string, unknown>): void; | |
| 67 | + error(msg: string, meta?: Record<string, unknown>): void; | |
| 68 | +} | |
| 69 | + | |
| 70 | +const noopLogger: Logger = { info: () => {}, warn: () => {}, error: () => {} }; | |
| 71 | + | |
| 72 | +export interface OrchestratorDeps { | |
| 73 | + llm: LlmClient; | |
| 74 | + emit: EmitFn; | |
| 75 | + now?: () => number; | |
| 76 | + logger?: Logger; | |
| 77 | +} | |
| 78 | + | |
| 79 | +export interface RunOptions { | |
| 80 | + debateId: string; | |
| 81 | + /** Debate-level cancellation (server shutdown / explicit user cancel). */ | |
| 82 | + signal?: AbortSignal; | |
| 83 | +} | |
| 84 | + | |
| 85 | +/** Mutable run state, threaded through the phase helpers. */ | |
| 86 | +interface RunState { | |
| 87 | + current: Map<string, AnswerRecord>; | |
| 88 | + active: Set<string>; | |
| 89 | + initialAnswers: AnswerRecord[]; | |
| 90 | + rounds: RoundRecord[]; | |
| 91 | + failures: FailureRecord[]; | |
| 92 | + totals: Usage; | |
| 93 | + costByModel: Map<string, number>; | |
| 94 | + lastDisagreements: Disagreement[]; | |
| 95 | + /** Revisions produced by the in-flight round, folded into a RoundRecord. */ | |
| 96 | + pendingRevisions?: RevisionRecord[]; | |
| 97 | + synthesisResult: SynthesisRecord | null; | |
| 98 | +} | |
| 99 | + | |
| 100 | +export async function runDebate( | |
| 101 | + config: DebateConfig, | |
| 102 | + deps: OrchestratorDeps, | |
| 103 | + opts: RunOptions, | |
| 104 | +): Promise<DebateResult> { | |
| 105 | + const now = deps.now ?? Date.now; | |
| 106 | + const logger = deps.logger ?? noopLogger; | |
| 107 | + const startedAt = now(); | |
| 108 | + | |
| 109 | + const participants: Participant[] = participantsFor(config.models); | |
| 110 | + const byId = new Map(participants.map((p) => [p.id, p])); | |
| 111 | + | |
| 112 | + const state: RunState = { | |
| 113 | + current: new Map(), | |
| 114 | + active: new Set(), | |
| 115 | + initialAnswers: [], | |
| 116 | + rounds: [], | |
| 117 | + failures: [], | |
| 118 | + totals: emptyUsage(), | |
| 119 | + costByModel: new Map(), | |
| 120 | + lastDisagreements: [], | |
| 121 | + synthesisResult: null, | |
| 122 | + }; | |
| 123 | + | |
| 124 | + // --- helpers ------------------------------------------------------------- | |
| 125 | + const emit = (e: DebateEvent) => deps.emit(e); | |
| 126 | + | |
| 127 | + const bill = (model: string, usage: Usage) => { | |
| 128 | + state.totals = addUsage(state.totals, usage); | |
| 129 | + state.costByModel.set(model, (state.costByModel.get(model) ?? 0) + usage.costUsd); | |
| 130 | + }; | |
| 131 | + | |
| 132 | + const emitCost = async () => { | |
| 133 | + await emit({ | |
| 134 | + type: 'cost_update', | |
| 135 | + totalCostUsd: state.totals.costUsd, | |
| 136 | + promptTokens: state.totals.promptTokens, | |
| 137 | + completionTokens: state.totals.completionTokens, | |
| 138 | + costByModel: Object.fromEntries(state.costByModel), | |
| 139 | + }); | |
| 140 | + }; | |
| 141 | + | |
| 142 | + const recordFailure = async ( | |
| 143 | + round: number, | |
| 144 | + stage: StageType, | |
| 145 | + p: Participant, | |
| 146 | + error: unknown, | |
| 147 | + droppedFromDebate: boolean, | |
| 148 | + ) => { | |
| 149 | + const message = error instanceof Error ? error.message : String(error); | |
| 150 | + // A structured-parse failure still cost tokens - bill them. | |
| 151 | + if (error instanceof StructuredParseError) bill(p.model, error.usage); | |
| 152 | + state.failures.push({ | |
| 153 | + round, | |
| 154 | + stage, | |
| 155 | + participantId: p.id, | |
| 156 | + model: p.model, | |
| 157 | + error: message, | |
| 158 | + droppedFromDebate, | |
| 159 | + }); | |
| 160 | + if (droppedFromDebate) state.active.delete(p.id); | |
| 161 | + logger.warn('model_failed', { round, stage, model: p.model, error: message, droppedFromDebate }); | |
| 162 | + await emit({ | |
| 163 | + type: 'model_failed', | |
| 164 | + round, | |
| 165 | + stage, | |
| 166 | + participantId: p.id, | |
| 167 | + model: p.model, | |
| 168 | + error: message, | |
| 169 | + droppedFromDebate, | |
| 170 | + }); | |
| 171 | + }; | |
| 172 | + | |
| 173 | + const structured = <S extends z.ZodTypeAny>(req: LlmRequest, schema: S, signal: AbortSignal) => | |
| 174 | + requestStructured(deps.llm, { ...req, signal }, schema); | |
| 175 | + | |
| 176 | + const finish = async (status: DebateStatus, error?: string): Promise<DebateResult> => { | |
| 177 | + const finalAnswers = [...state.active] | |
| 178 | + .map((id) => state.current.get(id)) | |
| 179 | + .filter((a): a is AnswerRecord => Boolean(a)); | |
| 180 | + const durationMs = now() - startedAt; | |
| 181 | + | |
| 182 | + await emit({ | |
| 183 | + type: 'debate_completed', | |
| 184 | + debateId: opts.debateId, | |
| 185 | + status, | |
| 186 | + totalCostUsd: state.totals.costUsd, | |
| 187 | + rounds: state.rounds.length, | |
| 188 | + durationMs, | |
| 189 | + }); | |
| 190 | + | |
| 191 | + return { | |
| 192 | + debateId: opts.debateId, | |
| 193 | + config, | |
| 194 | + participants, | |
| 195 | + status, | |
| 196 | + initialAnswers: state.initialAnswers, | |
| 197 | + rounds: state.rounds, | |
| 198 | + synthesis: state.synthesisResult ?? null, | |
| 199 | + failures: state.failures, | |
| 200 | + finalAnswers, | |
| 201 | + totals: { | |
| 202 | + costUsd: state.totals.costUsd, | |
| 203 | + promptTokens: state.totals.promptTokens, | |
| 204 | + completionTokens: state.totals.completionTokens, | |
| 205 | + rounds: state.rounds.length, | |
| 206 | + durationMs, | |
| 207 | + costByModel: Object.fromEntries(state.costByModel), | |
| 208 | + }, | |
| 209 | + ...(error ? { error } : {}), | |
| 210 | + }; | |
| 211 | + }; | |
| 212 | + | |
| 213 | + // --- start --------------------------------------------------------------- | |
| 214 | + await emit({ | |
| 215 | + type: 'debate_started', | |
| 216 | + debateId: opts.debateId, | |
| 217 | + config, | |
| 218 | + participants, | |
| 219 | + chairmanModel: config.chairmanModel, | |
| 220 | + chairmanProviderConflict: chairmanSharesProvider(config.chairmanModel, config.models), | |
| 221 | + }); | |
| 222 | + | |
| 223 | + try { | |
| 224 | + // === Round 0: independent, streamed answers =========================== | |
| 225 | + await emit({ type: 'round_started', round: 0, kind: 'answers' }); | |
| 226 | + await runForEach(participants, async (p) => { | |
| 227 | + await emit({ type: 'stage_started', round: 0, stage: 'answer', participantId: p.id, model: p.model }); | |
| 228 | + try { | |
| 229 | + const record = await streamAnswer(p, 0, config.question); | |
| 230 | + state.current.set(p.id, record); | |
| 231 | + state.active.add(p.id); | |
| 232 | + state.initialAnswers.push(record); | |
| 233 | + bill(p.model, record.usage); | |
| 234 | + await emit({ type: 'answer_completed', round: 0, record }); | |
| 235 | + } catch (err) { | |
| 236 | + // No prior answer exists → this member cannot participate at all. | |
| 237 | + await recordFailure(0, 'answer', p, err, true); | |
| 238 | + } | |
| 239 | + }); | |
| 240 | + await emitCost(); | |
| 241 | + throwIfAborted(opts.signal); | |
| 242 | + | |
| 243 | + if (state.active.size < 2) { | |
| 244 | + return finish('failed', 'Not enough models produced an initial answer (need ≥2).'); | |
| 245 | + } | |
| 246 | + | |
| 247 | + // === Rounds 1..maxRounds: critique -> revision -> convergence ========= | |
| 248 | + let stopReason: StopReason = 'max_rounds'; | |
| 249 | + for (let round = 1; round <= config.maxRounds; round++) { | |
| 250 | + await emit({ type: 'round_started', round, kind: 'cycle' }); | |
| 251 | + | |
| 252 | + const critiques = await critiquePhase(round); | |
| 253 | + throwIfAborted(opts.signal); | |
| 254 | + await revisionPhase(round, critiques); | |
| 255 | + throwIfAborted(opts.signal); | |
| 256 | + const convergence = await convergencePhase(round); | |
| 257 | + await emitCost(); | |
| 258 | + | |
| 259 | + const decision = shouldStop({ | |
| 260 | + score: convergence?.score ?? 0, | |
| 261 | + threshold: config.convergenceThreshold, | |
| 262 | + round, | |
| 263 | + maxRounds: config.maxRounds, | |
| 264 | + activeCount: state.active.size, | |
| 265 | + }); | |
| 266 | + state.rounds.push({ round, critiques, revisions: state.pendingRevisions ?? [], convergence }); | |
| 267 | + state.pendingRevisions = undefined; | |
| 268 | + | |
| 269 | + if (decision.stop) { | |
| 270 | + stopReason = decision.reason; | |
| 271 | + if (decision.reason === 'insufficient_models') { | |
| 272 | + return finish('failed', 'Too many models dropped out to continue the debate.'); | |
| 273 | + } | |
| 274 | + break; | |
| 275 | + } | |
| 276 | + } | |
| 277 | + logger.info('deliberation_complete', { stopReason, rounds: state.rounds.length }); | |
| 278 | + throwIfAborted(opts.signal); | |
| 279 | + | |
| 280 | + // === Synthesis ======================================================== | |
| 281 | + await synthesisPhase(); | |
| 282 | + await emitCost(); | |
| 283 | + | |
| 284 | + return finish('completed'); | |
| 285 | + } catch (err) { | |
| 286 | + if (isAbortError(err) || opts.signal?.aborted) { | |
| 287 | + logger.warn('debate_aborted', { debateId: opts.debateId }); | |
| 288 | + return finish('aborted', 'Debate was cancelled.'); | |
| 289 | + } | |
| 290 | + const message = err instanceof Error ? err.message : String(err); | |
| 291 | + logger.error('debate_failed', { debateId: opts.debateId, error: message }); | |
| 292 | + await emit({ type: 'debate_failed', debateId: opts.debateId, error: message }); | |
| 293 | + return finish('failed', message); | |
| 294 | + } | |
| 295 | + | |
| 296 | + // --- phase implementations (closures over state) ------------------------- | |
| 297 | + | |
| 298 | + async function streamAnswer(p: Participant, round: number, question: string): Promise<AnswerRecord> { | |
| 299 | + const t0 = now(); | |
| 300 | + return withTimeout(async (signal) => { | |
| 301 | + const handle = await deps.llm.streamComplete({ | |
| 302 | + model: p.model, | |
| 303 | + messages: buildAnswerPrompt(question), | |
| 304 | + temperature: config.temperature, | |
| 305 | + signal, | |
| 306 | + meta: { stage: 'answer', round, participantId: p.id, seed: p.id }, | |
| 307 | + }); | |
| 308 | + for await (const delta of handle.stream) { | |
| 309 | + await emit({ type: 'token_delta', round, stage: 'answer', participantId: p.id, delta }); | |
| 310 | + } | |
| 311 | + const result = await handle.result; | |
| 312 | + return { | |
| 313 | + participantId: p.id, | |
| 314 | + model: p.model, | |
| 315 | + round, | |
| 316 | + content: result.text, | |
| 317 | + usage: result.usage, | |
| 318 | + latencyMs: result.latencyMs || now() - t0, | |
| 319 | + }; | |
| 320 | + }, config.perModelTimeoutMs, opts.signal); | |
| 321 | + } | |
| 322 | + | |
| 323 | + async function critiquePhase(round: number): Promise<CritiqueRecord[]> { | |
| 324 | + const reviewers = participants.filter((p) => state.active.has(p.id)); | |
| 325 | + const results: CritiqueRecord[] = []; | |
| 326 | + | |
| 327 | + await runForEach(reviewers, async (reviewer) => { | |
| 328 | + const peers = reviewers | |
| 329 | + .filter((p) => p.id !== reviewer.id) | |
| 330 | + .map((p) => ({ participantId: p.id, content: state.current.get(p.id)!.content })); | |
| 331 | + if (peers.length === 0) return; | |
| 332 | + | |
| 333 | + const anon = anonymizePeers(peers, hashSeed(opts.debateId, round, 'critique', reviewer.id)); | |
| 334 | + await emit({ type: 'stage_started', round, stage: 'critique', participantId: reviewer.id, model: reviewer.model }); | |
| 335 | + try { | |
| 336 | + const out = await withTimeout( | |
| 337 | + (signal) => | |
| 338 | + structured( | |
| 339 | + { | |
| 340 | + model: reviewer.model, | |
| 341 | + messages: buildCritiquePrompt(config.question, anon.peers), | |
| 342 | + temperature: config.temperature, | |
| 343 | + meta: { stage: 'critique', round, participantId: reviewer.id }, | |
| 344 | + }, | |
| 345 | + critiqueOutputSchema, | |
| 346 | + signal, | |
| 347 | + ), | |
| 348 | + config.perModelTimeoutMs, | |
| 349 | + opts.signal, | |
| 350 | + ); | |
| 351 | + bill(reviewer.model, out.usage); | |
| 352 | + const record = buildCritiqueRecord(round, reviewer, out.value.reviews, anon, out.usage, out.latencyMs); | |
| 353 | + results.push(record); | |
| 354 | + await emit({ type: 'critique_completed', round, record }); | |
| 355 | + } catch (err) { | |
| 356 | + // Losing a reviewer's critique doesn't remove it from the debate. | |
| 357 | + await recordFailure(round, 'critique', reviewer, err, false); | |
| 358 | + } | |
| 359 | + }); | |
| 360 | + | |
| 361 | + return results; | |
| 362 | + } | |
| 363 | + | |
| 364 | + async function revisionPhase(round: number, critiques: CritiqueRecord[]): Promise<void> { | |
| 365 | + const revisers = participants.filter((p) => state.active.has(p.id)); | |
| 366 | + const revisions: RevisionRecord[] = []; | |
| 367 | + | |
| 368 | + await runForEach(revisers, async (p) => { | |
| 369 | + const incoming: IncomingCritique[] = collectIncoming(p.id, critiques); | |
| 370 | + const ownAnswer = state.current.get(p.id)!.content; | |
| 371 | + await emit({ type: 'stage_started', round, stage: 'revision', participantId: p.id, model: p.model }); | |
| 372 | + try { | |
| 373 | + const out = await withTimeout( | |
| 374 | + (signal) => | |
| 375 | + structured( | |
| 376 | + { | |
| 377 | + model: p.model, | |
| 378 | + messages: buildRevisionPrompt(config.question, ownAnswer, incoming), | |
| 379 | + temperature: config.temperature, | |
| 380 | + meta: { stage: 'revision', round, participantId: p.id }, | |
| 381 | + }, | |
| 382 | + revisionOutputSchema, | |
| 383 | + signal, | |
| 384 | + ), | |
| 385 | + config.perModelTimeoutMs, | |
| 386 | + opts.signal, | |
| 387 | + ); | |
| 388 | + bill(p.model, out.usage); | |
| 389 | + const record: RevisionRecord = { | |
| 390 | + round, | |
| 391 | + participantId: p.id, | |
| 392 | + model: p.model, | |
| 393 | + content: out.value.answer, | |
| 394 | + changelog: { | |
| 395 | + changed: out.value.changelog.changed, | |
| 396 | + summary: out.value.changelog.summary, | |
| 397 | + bullets: out.value.changelog.bullets, | |
| 398 | + }, | |
| 399 | + usage: out.usage, | |
| 400 | + latencyMs: out.latencyMs, | |
| 401 | + }; | |
| 402 | + // Adopt the revised answer as this participant's current answer. | |
| 403 | + state.current.set(p.id, { | |
| 404 | + participantId: p.id, | |
| 405 | + model: p.model, | |
| 406 | + round, | |
| 407 | + content: out.value.answer, | |
| 408 | + usage: out.usage, | |
| 409 | + latencyMs: out.latencyMs, | |
| 410 | + }); | |
| 411 | + revisions.push(record); | |
| 412 | + await emit({ type: 'revision_completed', round, record }); | |
| 413 | + } catch (err) { | |
| 414 | + // Keep the participant's previous answer; drop only this revision. | |
| 415 | + await recordFailure(round, 'revision', p, err, false); | |
| 416 | + } | |
| 417 | + }); | |
| 418 | + | |
| 419 | + state.pendingRevisions = revisions; | |
| 420 | + } | |
| 421 | + | |
| 422 | + async function convergencePhase(round: number): Promise<ConvergenceRecord | null> { | |
| 423 | + const answersList = participants | |
| 424 | + .filter((p) => state.active.has(p.id)) | |
| 425 | + .map((p) => ({ participantId: p.id, content: state.current.get(p.id)!.content })); | |
| 426 | + | |
| 427 | + const anon = anonymizePeers(answersList, hashSeed(opts.debateId, round, 'convergence')); | |
| 428 | + await emit({ type: 'stage_started', round, stage: 'convergence', model: config.convergenceModel }); | |
| 429 | + try { | |
| 430 | + const out = await withTimeout( | |
| 431 | + (signal) => | |
| 432 | + structured( | |
| 433 | + { | |
| 434 | + model: config.convergenceModel, | |
| 435 | + messages: buildConvergencePrompt(config.question, anon.peers), | |
| 436 | + temperature: 0, | |
| 437 | + meta: { stage: 'convergence', round }, | |
| 438 | + }, | |
| 439 | + convergenceOutputSchema, | |
| 440 | + signal, | |
| 441 | + ), | |
| 442 | + config.perModelTimeoutMs, | |
| 443 | + opts.signal, | |
| 444 | + ); | |
| 445 | + bill(config.convergenceModel, out.usage); | |
| 446 | + const disagreements = mapDisagreements(out.value.disagreements, anon); | |
| 447 | + state.lastDisagreements = disagreements; | |
| 448 | + const record: ConvergenceRecord = { | |
| 449 | + round, | |
| 450 | + model: config.convergenceModel, | |
| 451 | + score: out.value.score, | |
| 452 | + disagreements, | |
| 453 | + converged: out.value.score >= config.convergenceThreshold, | |
| 454 | + usage: out.usage, | |
| 455 | + latencyMs: out.latencyMs, | |
| 456 | + }; | |
| 457 | + await emit({ type: 'convergence_result', round, record }); | |
| 458 | + return record; | |
| 459 | + } catch (err) { | |
| 460 | + // Convergence is advisory - never fail the debate on it. Treat as "not | |
| 461 | + // converged" and continue (or stop at max rounds). | |
| 462 | + const message = err instanceof Error ? err.message : String(err); | |
| 463 | + if (err instanceof StructuredParseError) bill(config.convergenceModel, err.usage); | |
| 464 | + logger.warn('convergence_failed', { round, error: message }); | |
| 465 | + const record: ConvergenceRecord = { | |
| 466 | + round, | |
| 467 | + model: config.convergenceModel, | |
| 468 | + score: 0, | |
| 469 | + disagreements: state.lastDisagreements, | |
| 470 | + converged: false, | |
| 471 | + usage: emptyUsage(), | |
| 472 | + latencyMs: 0, | |
| 473 | + }; | |
| 474 | + await emit({ type: 'convergence_result', round, record }); | |
| 475 | + return record; | |
| 476 | + } | |
| 477 | + } | |
| 478 | + | |
| 479 | + async function synthesisPhase(): Promise<void> { | |
| 480 | + const chairman = { id: 'chairman', model: config.chairmanModel, displayName: displayNameForModel(config.chairmanModel) }; | |
| 481 | + const finalists = participants | |
| 482 | + .filter((p) => state.active.has(p.id)) | |
| 483 | + .map((p) => ({ participantId: p.id, content: state.current.get(p.id)!.content })); | |
| 484 | + const anon = anonymizePeers(finalists, hashSeed(opts.debateId, 'synthesis')); | |
| 485 | + const remaining = state.lastDisagreements.map((d) => ({ topic: d.topic, summary: d.summary })); | |
| 486 | + | |
| 487 | + await emit({ type: 'stage_started', round: state.rounds.length, stage: 'synthesis', model: chairman.model }); | |
| 488 | + try { | |
| 489 | + const out = await withTimeout( | |
| 490 | + (signal) => | |
| 491 | + structured( | |
| 492 | + { | |
| 493 | + model: chairman.model, | |
| 494 | + messages: buildSynthesisPrompt(config.question, anon.peers, remaining), | |
| 495 | + temperature: config.temperature, | |
| 496 | + meta: { stage: 'synthesis', round: state.rounds.length }, | |
| 497 | + }, | |
| 498 | + synthesisOutputSchema, | |
| 499 | + signal, | |
| 500 | + ), | |
| 501 | + config.perModelTimeoutMs, | |
| 502 | + opts.signal, | |
| 503 | + ); | |
| 504 | + bill(chairman.model, out.usage); | |
| 505 | + const record: SynthesisRecord = { | |
| 506 | + model: chairman.model, | |
| 507 | + finalAnswer: out.value.finalAnswer, | |
| 508 | + dissent: out.value.dissent.map((d) => ({ | |
| 509 | + topic: d.topic, | |
| 510 | + positions: d.positions | |
| 511 | + .map((pos) => { | |
| 512 | + const pid = anon.labelMap[pos.label]; | |
| 513 | + const part = pid ? byId.get(pid) : undefined; | |
| 514 | + return part ? { participantId: part.id, model: part.model, position: pos.position } : null; | |
| 515 | + }) | |
| 516 | + .filter((x): x is NonNullable<typeof x> => x !== null), | |
| 517 | + })), | |
| 518 | + usage: out.usage, | |
| 519 | + latencyMs: out.latencyMs, | |
| 520 | + }; | |
| 521 | + state.synthesisResult = record; | |
| 522 | + await emit({ type: 'synthesis_completed', record }); | |
| 523 | + } catch (err) { | |
| 524 | + // Fallback synthesis so the debate still yields a usable answer. | |
| 525 | + if (err instanceof StructuredParseError) bill(chairman.model, err.usage); | |
| 526 | + const message = err instanceof Error ? err.message : String(err); | |
| 527 | + logger.error('synthesis_failed', { error: message }); | |
| 528 | + state.failures.push({ | |
| 529 | + round: state.rounds.length, | |
| 530 | + stage: 'synthesis', | |
| 531 | + participantId: 'chairman', | |
| 532 | + model: chairman.model, | |
| 533 | + error: message, | |
| 534 | + droppedFromDebate: false, | |
| 535 | + }); | |
| 536 | + const fallback = fallbackSynthesis(); | |
| 537 | + state.synthesisResult = fallback; | |
| 538 | + await emit({ type: 'synthesis_completed', record: fallback }); | |
| 539 | + } | |
| 540 | + } | |
| 541 | + | |
| 542 | + function fallbackSynthesis(): SynthesisRecord { | |
| 543 | + const answers = [...state.active].map((id) => state.current.get(id)!).filter(Boolean); | |
| 544 | + const best = answers[0]; | |
| 545 | + return { | |
| 546 | + model: config.chairmanModel, | |
| 547 | + finalAnswer: | |
| 548 | + (best?.content ?? 'No answer could be produced.') + | |
| 549 | + '\n\n_(Chairman synthesis was unavailable; showing the leading council answer.)_', | |
| 550 | + dissent: state.lastDisagreements.map((d) => ({ | |
| 551 | + topic: d.topic, | |
| 552 | + positions: d.positions | |
| 553 | + .filter((pos) => pos.participantId) | |
| 554 | + .map((pos) => ({ | |
| 555 | + participantId: pos.participantId!, | |
| 556 | + model: byId.get(pos.participantId!)?.model ?? 'unknown', | |
| 557 | + position: pos.stance, | |
| 558 | + })), | |
| 559 | + })), | |
| 560 | + usage: emptyUsage(), | |
| 561 | + latencyMs: 0, | |
| 562 | + }; | |
| 563 | + } | |
| 564 | + | |
| 565 | + function buildCritiqueRecord( | |
| 566 | + round: number, | |
| 567 | + reviewer: Participant, | |
| 568 | + reviews: Array<{ label: string; weaknesses: string[]; strengths: string[]; score: number; justification: string }>, | |
| 569 | + anon: AnonymizationResult, | |
| 570 | + usage: Usage, | |
| 571 | + latencyMs: number, | |
| 572 | + ): CritiqueRecord { | |
| 573 | + const mapped: PeerReview[] = reviews | |
| 574 | + .map((r) => { | |
| 575 | + const targetId = anon.labelMap[r.label]; | |
| 576 | + if (!targetId) return null; | |
| 577 | + return { | |
| 578 | + label: r.label, | |
| 579 | + targetParticipantId: targetId, | |
| 580 | + weaknesses: r.weaknesses, | |
| 581 | + strengths: r.strengths, | |
| 582 | + score: r.score, | |
| 583 | + justification: r.justification, | |
| 584 | + } satisfies PeerReview; | |
| 585 | + }) | |
| 586 | + .filter((x): x is PeerReview => x !== null); | |
| 587 | + return { | |
| 588 | + round, | |
| 589 | + reviewerParticipantId: reviewer.id, | |
| 590 | + reviewerModel: reviewer.model, | |
| 591 | + reviews: mapped, | |
| 592 | + usage, | |
| 593 | + latencyMs, | |
| 594 | + }; | |
| 595 | + } | |
| 596 | + | |
| 597 | + function collectIncoming(participantId: string, critiques: CritiqueRecord[]): IncomingCritique[] { | |
| 598 | + const out: IncomingCritique[] = []; | |
| 599 | + for (const c of critiques) { | |
| 600 | + for (const r of c.reviews) { | |
| 601 | + if (r.targetParticipantId === participantId) { | |
| 602 | + out.push({ | |
| 603 | + weaknesses: r.weaknesses, | |
| 604 | + strengths: r.strengths, | |
| 605 | + score: r.score, | |
| 606 | + justification: r.justification, | |
| 607 | + }); | |
| 608 | + } | |
| 609 | + } | |
| 610 | + } | |
| 611 | + return out; | |
| 612 | + } | |
| 613 | + | |
| 614 | + function mapDisagreements( | |
| 615 | + raw: Array<{ topic: string; summary: string; positions: Array<{ label: string; stance: string }> }>, | |
| 616 | + anon: AnonymizationResult, | |
| 617 | + ): Disagreement[] { | |
| 618 | + return raw.map((d) => ({ | |
| 619 | + topic: d.topic, | |
| 620 | + summary: d.summary, | |
| 621 | + positions: d.positions.map((pos) => ({ | |
| 622 | + label: pos.label, | |
| 623 | + participantId: anon.labelMap[pos.label], | |
| 624 | + stance: pos.stance, | |
| 625 | + })), | |
| 626 | + })); | |
| 627 | + } | |
| 628 | +} | |
| 629 | + | |
| 630 | +// --- module-scope helpers -------------------------------------------------- | |
| 631 | + | |
| 632 | +/** | |
| 633 | + * Run an async task per item concurrently, isolating failures. Individual tasks | |
| 634 | + * are expected to handle their own errors (they emit `model_failed`); this just | |
| 635 | + * guarantees one thrown task can't reject the whole phase. | |
| 636 | + */ | |
| 637 | +async function runForEach<T>(items: T[], task: (item: T) => Promise<void>): Promise<void> { | |
| 638 | + await Promise.all( | |
| 639 | + items.map(async (item) => { | |
| 640 | + try { | |
| 641 | + await task(item); | |
| 642 | + } catch { | |
| 643 | + // Task-level errors are already recorded as failures by the task itself. | |
| 644 | + } | |
| 645 | + }), | |
| 646 | + ); | |
| 647 | +} | |
| 648 | + | |
| 649 | +function isAbortError(err: unknown): boolean { | |
| 650 | + return ( | |
| 651 | + (err instanceof Error && err.name === 'AbortError') || | |
| 652 | + (typeof err === 'object' && err !== null && 'name' in err && (err as { name?: string }).name === 'AbortError') | |
| 653 | + ); | |
| 654 | +} | |
| 655 | + | |
| 656 | +function throwIfAborted(signal?: AbortSignal): void { | |
| 657 | + if (signal?.aborted) { | |
| 658 | + const e = new Error('Aborted'); | |
| 659 | + e.name = 'AbortError'; | |
| 660 | + throw e; | |
| 661 | + } | |
| 662 | +} |
added src/core/prompts/answer.ts +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +import type { LlmMessage } from '../llm-client'; | |
| 2 | +import { ROUNDTABLE_PREAMBLE } from './index'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Round 0 - independent answer. | |
| 6 | + * | |
| 7 | + * Each council member answers the question cold, with no knowledge of the other | |
| 8 | + * members' responses. Deliberately open-ended: we want genuine diversity of | |
| 9 | + * approach here, which the later rounds then reconcile. | |
| 10 | + */ | |
| 11 | +export function buildAnswerPrompt(question: string): LlmMessage[] { | |
| 12 | + return [ | |
| 13 | + { | |
| 14 | + role: 'system', | |
| 15 | + content: | |
| 16 | + `${ROUNDTABLE_PREAMBLE}\n\n` + | |
| 17 | + 'This is the opening round. Answer the question directly and completely on ' + | |
| 18 | + 'your own. State your reasoning and note any important assumptions, caveats, ' + | |
| 19 | + 'or uncertainty. Do not hedge to the point of vagueness - commit to a clear ' + | |
| 20 | + 'position where the evidence supports one.', | |
| 21 | + }, | |
| 22 | + { | |
| 23 | + role: 'user', | |
| 24 | + content: `Question:\n${question}`, | |
| 25 | + }, | |
| 26 | + ]; | |
| 27 | +} |
added src/core/prompts/convergence.ts +48 −0
| @@ -0,0 +1,48 @@ | ||
| 1 | +import type { AnonymizedPeer } from '../anonymize'; | |
| 2 | +import type { LlmMessage } from '../llm-client'; | |
| 3 | +import { jsonInstruction, ROUNDTABLE_PREAMBLE } from './index'; | |
| 4 | + | |
| 5 | +const CONVERGENCE_SHAPE = `{ | |
| 6 | + "score": 72, // 0-100: how substantively converged the answers are | |
| 7 | + "disagreements": [ | |
| 8 | + { | |
| 9 | + "topic": "short label for the contested point", | |
| 10 | + "summary": "what the disagreement is about", | |
| 11 | + "positions": [ | |
| 12 | + { "label": "A", "stance": "what response A holds on this point" }, | |
| 13 | + { "label": "C", "stance": "what response C holds on this point" } | |
| 14 | + ] | |
| 15 | + } | |
| 16 | + ] | |
| 17 | +}`; | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * Convergence check (runs after each round, on a cheap/fast model). | |
| 21 | + * | |
| 22 | + * Judges *substantive* agreement, not surface wording: two answers that reach the | |
| 23 | + * same conclusion by different phrasing are converged; two that share boilerplate | |
| 24 | + * but differ on the key claim are not. Returns a 0-100 score plus the concrete | |
| 25 | + * points still in dispute, which feed both the early-stop decision and the | |
| 26 | + * chairman's dissent report. | |
| 27 | + */ | |
| 28 | +export function buildConvergencePrompt(question: string, answers: AnonymizedPeer[]): LlmMessage[] { | |
| 29 | + const block = answers.map((a) => `--- Response ${a.label} ---\n${a.content}`).join('\n\n'); | |
| 30 | + | |
| 31 | + return [ | |
| 32 | + { | |
| 33 | + role: 'system', | |
| 34 | + content: | |
| 35 | + `${ROUNDTABLE_PREAMBLE}\n\n` + | |
| 36 | + 'You are the neutral convergence assessor. Read the current answers and judge ' + | |
| 37 | + 'how much they substantively agree on the points that matter for the question. ' + | |
| 38 | + 'Ignore stylistic differences. A score of 100 means they would give the reader ' + | |
| 39 | + 'the same practical conclusion; a low score means they still disagree on ' + | |
| 40 | + 'substance. List every material remaining disagreement.\n\n' + | |
| 41 | + jsonInstruction(CONVERGENCE_SHAPE), | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + role: 'user', | |
| 45 | + content: `Question:\n${question}\n\nCurrent answers:\n\n${block}\n\nReturn your JSON assessment now.`, | |
| 46 | + }, | |
| 47 | + ]; | |
| 48 | +} |
added src/core/prompts/critique.ts +54 −0
| @@ -0,0 +1,54 @@ | ||
| 1 | +import type { AnonymizedPeer } from '../anonymize'; | |
| 2 | +import type { LlmMessage } from '../llm-client'; | |
| 3 | +import { jsonInstruction, ROUNDTABLE_PREAMBLE } from './index'; | |
| 4 | + | |
| 5 | +const CRITIQUE_SHAPE = `{ | |
| 6 | + "reviews": [ | |
| 7 | + { | |
| 8 | + "label": "A", // the label of the response being reviewed | |
| 9 | + "weaknesses": ["specific error or gap", "..."], | |
| 10 | + "strengths": ["specific strong point", "..."], | |
| 11 | + "score": 7, // integer 1-10, overall quality | |
| 12 | + "justification": "one or two sentences explaining the score" | |
| 13 | + } | |
| 14 | + // ...one entry per response shown to you | |
| 15 | + ] | |
| 16 | +}`; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Critique phase. | |
| 20 | + * | |
| 21 | + * The reviewer sees every *other* member's answer, fully anonymized and in a | |
| 22 | + * per-reviewer randomized order (labels "Response A/B/C..."). It must return, for | |
| 23 | + * each response, concrete weaknesses, concrete strengths, and a calibrated 1-10 | |
| 24 | + * score with justification. Anonymity + randomized order is the core bias | |
| 25 | + * mitigation: a model cannot preferentially reward "its own style" if it cannot | |
| 26 | + * tell which answer is whose. | |
| 27 | + */ | |
| 28 | +export function buildCritiquePrompt(question: string, peers: AnonymizedPeer[]): LlmMessage[] { | |
| 29 | + const peerBlock = peers | |
| 30 | + .map((p) => `--- Response ${p.label} ---\n${p.content}`) | |
| 31 | + .join('\n\n'); | |
| 32 | + | |
| 33 | + return [ | |
| 34 | + { | |
| 35 | + role: 'system', | |
| 36 | + content: | |
| 37 | + `${ROUNDTABLE_PREAMBLE}\n\n` + | |
| 38 | + "You are now reviewing the other members' answers. They are anonymized and " + | |
| 39 | + 'shown in a random order; you do not know who wrote which, and none of them is ' + | |
| 40 | + 'your own. Judge only on merit: correctness first, then completeness, clarity, ' + | |
| 41 | + 'and calibration. Be specific - cite the exact claim you think is wrong or ' + | |
| 42 | + 'missing. Reward genuine strengths honestly. Scores should span the range; do ' + | |
| 43 | + 'not cluster everything at 7-8.\n\n' + | |
| 44 | + jsonInstruction(CRITIQUE_SHAPE), | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + role: 'user', | |
| 48 | + content: | |
| 49 | + `Question:\n${question}\n\n` + | |
| 50 | + `Answers to review (${peers.length}):\n\n${peerBlock}\n\n` + | |
| 51 | + 'Return your JSON review now.', | |
| 52 | + }, | |
| 53 | + ]; | |
| 54 | +} |
added src/core/prompts/index.ts +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +/** | |
| 2 | + * Versioned prompt templates for every debate stage. | |
| 3 | + * | |
| 4 | + * These are the *product* of Roundtable as much as the code is - the debate is | |
| 5 | + * only as good as the instructions the models receive. They live here, apart | |
| 6 | + * from orchestration logic, so they can be reviewed, versioned, and A/B'd | |
| 7 | + * without touching the state machine. | |
| 8 | + * | |
| 9 | + * `PROMPT_VERSION` is bumped whenever a template changes in a way that would | |
| 10 | + * alter model behavior; it is stamped onto stored results so historical debates | |
| 11 | + * remain interpretable. | |
| 12 | + */ | |
| 13 | +export const PROMPT_VERSION = '1.0.0'; | |
| 14 | + | |
| 15 | +export { buildAnswerPrompt } from './answer'; | |
| 16 | +export { buildCritiquePrompt } from './critique'; | |
| 17 | +export { buildRevisionPrompt } from './revision'; | |
| 18 | +export { buildConvergencePrompt } from './convergence'; | |
| 19 | +export { buildSynthesisPrompt } from './synthesis'; | |
| 20 | +export { buildRepairPrompt } from './repair'; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * Shared system preamble establishing the ground rules of the roundtable. Every | |
| 24 | + * stage builds on this so models share a consistent frame. | |
| 25 | + */ | |
| 26 | +export const ROUNDTABLE_PREAMBLE = | |
| 27 | + 'You are one member of an expert roundtable convened to answer a question as ' + | |
| 28 | + 'accurately and completely as possible. Other independent experts are answering ' + | |
| 29 | + 'the same question in parallel. The goal is a correct, well-reasoned answer - not ' + | |
| 30 | + 'winning. Be rigorous, cite concrete reasoning, and concede points that are right.'; | |
| 31 | + | |
| 32 | +/** Standard instruction appended to any stage that must return strict JSON. */ | |
| 33 | +export function jsonInstruction(shape: string): string { | |
| 34 | + return ( | |
| 35 | + 'Respond with a SINGLE JSON object and nothing else - no prose, no Markdown ' + | |
| 36 | + 'code fences, no commentary before or after. The object MUST match this shape ' + | |
| 37 | + `exactly:\n\n${shape}\n\n` + | |
| 38 | + 'Use plain double-quoted strings. Do not include trailing commas.' | |
| 39 | + ); | |
| 40 | +} |
added src/core/prompts/repair.ts +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +import type { LlmMessage } from '../llm-client'; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * JSON repair prompt. | |
| 5 | + * | |
| 6 | + * Sent as a single retry when a model's structured output failed to parse or | |
| 7 | + * validate. We echo back the raw response and the precise validation error and | |
| 8 | + * ask only for corrected JSON - cheaper and more reliable than re-running the | |
| 9 | + * whole stage. | |
| 10 | + */ | |
| 11 | +export function buildRepairPrompt(rawResponse: string, validationError: string): LlmMessage[] { | |
| 12 | + return [ | |
| 13 | + { | |
| 14 | + role: 'system', | |
| 15 | + content: | |
| 16 | + 'You are a strict JSON fixer. You will be given a malformed or invalid response ' + | |
| 17 | + 'and the reason it failed validation. Return ONLY the corrected JSON object that ' + | |
| 18 | + 'satisfies the requirement - no prose, no code fences. Preserve all of the ' + | |
| 19 | + 'original content and meaning; change only what is needed to make it valid.', | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + role: 'user', | |
| 23 | + content: | |
| 24 | + `The following response was invalid.\n\nValidation error: ${validationError}\n\n` + | |
| 25 | + `Original response:\n${rawResponse}\n\n` + | |
| 26 | + 'Return the corrected JSON now.', | |
| 27 | + }, | |
| 28 | + ]; | |
| 29 | +} |
added src/core/prompts/revision.ts +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +import type { LlmMessage } from '../llm-client'; | |
| 2 | +import { jsonInstruction, ROUNDTABLE_PREAMBLE } from './index'; | |
| 3 | + | |
| 4 | +/** One reviewer's feedback on this model's answer, with the source hidden. */ | |
| 5 | +export interface IncomingCritique { | |
| 6 | + weaknesses: string[]; | |
| 7 | + strengths: string[]; | |
| 8 | + score: number; | |
| 9 | + justification: string; | |
| 10 | +} | |
| 11 | + | |
| 12 | +const REVISION_SHAPE = `{ | |
| 13 | + "answer": "your full revised answer (or your unchanged answer if you are defending it)", | |
| 14 | + "changelog": { | |
| 15 | + "changed": true, // false if you are keeping your answer as-is | |
| 16 | + "summary": "1-2 sentences: what you changed and why, OR why you are defending your original position", | |
| 17 | + "bullets": ["concrete change 1", "concrete change 2"] // may be empty if unchanged | |
| 18 | + } | |
| 19 | +}`; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Revision phase. | |
| 23 | + * | |
| 24 | + * The model receives the anonymized critiques that targeted *its own* answer and | |
| 25 | + * decides, point by point, whether each is right. It either revises or explicitly | |
| 26 | + * defends. Crucially it must NOT cave to social pressure - a well-reasoned defense | |
| 27 | + * with justification is a first-class outcome, which is why the changelog forces | |
| 28 | + * an explicit `changed` boolean and reasoning either way. | |
| 29 | + */ | |
| 30 | +export function buildRevisionPrompt( | |
| 31 | + question: string, | |
| 32 | + ownAnswer: string, | |
| 33 | + critiques: IncomingCritique[], | |
| 34 | +): LlmMessage[] { | |
| 35 | + const feedbackBlock = | |
| 36 | + critiques.length === 0 | |
| 37 | + ? 'No critiques were received this round.' | |
| 38 | + : critiques | |
| 39 | + .map((c, i) => { | |
| 40 | + const w = c.weaknesses.length ? c.weaknesses.map((x) => ` - ${x}`).join('\n') : ' - (none)'; | |
| 41 | + const s = c.strengths.length ? c.strengths.map((x) => ` - ${x}`).join('\n') : ' - (none)'; | |
| 42 | + return `Reviewer ${i + 1} (score ${c.score}/10):\nWeaknesses:\n${w}\nStrengths:\n${s}\nComment: ${c.justification}`; | |
| 43 | + }) | |
| 44 | + .join('\n\n'); | |
| 45 | + | |
| 46 | + return [ | |
| 47 | + { | |
| 48 | + role: 'system', | |
| 49 | + content: | |
| 50 | + `${ROUNDTABLE_PREAMBLE}\n\n` + | |
| 51 | + 'You have received anonymous critiques of your answer. Weigh each on its ' + | |
| 52 | + 'merits. Where a critique is correct, revise to fix it. Where it is wrong or ' + | |
| 53 | + 'misguided, keep your position and say why - do not change your answer just to ' + | |
| 54 | + 'appease reviewers. Aim for the most correct, complete answer, not consensus ' + | |
| 55 | + 'for its own sake.\n\n' + | |
| 56 | + jsonInstruction(REVISION_SHAPE), | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + role: 'user', | |
| 60 | + content: | |
| 61 | + `Question:\n${question}\n\n` + | |
| 62 | + `Your current answer:\n${ownAnswer}\n\n` + | |
| 63 | + `Critiques of your answer:\n${feedbackBlock}\n\n` + | |
| 64 | + 'Return your JSON revision now.', | |
| 65 | + }, | |
| 66 | + ]; | |
| 67 | +} |
added src/core/prompts/synthesis.ts +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +import type { AnonymizedPeer } from '../anonymize'; | |
| 2 | +import type { LlmMessage } from '../llm-client'; | |
| 3 | +import { jsonInstruction, ROUNDTABLE_PREAMBLE } from './index'; | |
| 4 | + | |
| 5 | +const SYNTHESIS_SHAPE = `{ | |
| 6 | + "finalAnswer": "the single best answer to the question, synthesized from the council", | |
| 7 | + "dissent": [ | |
| 8 | + { | |
| 9 | + "topic": "short label for an unresolved disagreement", | |
| 10 | + "positions": [ | |
| 11 | + { "label": "A", "position": "what response A maintains" }, | |
| 12 | + { "label": "B", "position": "what response B maintains" } | |
| 13 | + ] | |
| 14 | + } | |
| 15 | + ] | |
| 16 | +}`; | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * Chairman synthesis (final stage). | |
| 20 | + * | |
| 21 | + * The chairman receives the anonymized final answers, the outstanding | |
| 22 | + * disagreements from the convergence check, and produces the authoritative | |
| 23 | + * answer plus an explicit dissent report. Answers are anonymized to the chairman | |
| 24 | + * as well - this is deliberate self-preference mitigation: even a chairman drawn | |
| 25 | + * from the same provider family as a council member cannot favor "its own" | |
| 26 | + * answer if it cannot identify it. Labels are mapped back to real models only | |
| 27 | + * after the chairman has committed its judgment. | |
| 28 | + */ | |
| 29 | +export function buildSynthesisPrompt( | |
| 30 | + question: string, | |
| 31 | + finalAnswers: AnonymizedPeer[], | |
| 32 | + remainingDisagreements: Array<{ topic: string; summary: string }>, | |
| 33 | +): LlmMessage[] { | |
| 34 | + const answersBlock = finalAnswers | |
| 35 | + .map((a) => `--- Response ${a.label} ---\n${a.content}`) | |
| 36 | + .join('\n\n'); | |
| 37 | + | |
| 38 | + const disagreementBlock = | |
| 39 | + remainingDisagreements.length === 0 | |
| 40 | + ? 'The council largely converged; no major disagreements were flagged.' | |
| 41 | + : remainingDisagreements.map((d) => `- ${d.topic}: ${d.summary}`).join('\n'); | |
| 42 | + | |
| 43 | + return [ | |
| 44 | + { | |
| 45 | + role: 'system', | |
| 46 | + content: | |
| 47 | + 'You are the chairman of an expert roundtable. Several anonymized experts have ' + | |
| 48 | + 'answered a question and revised their answers after mutual critique. Your job ' + | |
| 49 | + 'is to deliver the single best final answer and an honest dissent report.\n\n' + | |
| 50 | + `${ROUNDTABLE_PREAMBLE}\n\n` + | |
| 51 | + 'Synthesize - do not merely pick one answer or average them. Take the strongest, ' + | |
| 52 | + 'best-supported reasoning from across the council. Where the council genuinely ' + | |
| 53 | + 'disagrees and the evidence does not settle it, do NOT paper over it: record it ' + | |
| 54 | + "in the dissent report with each side's position. The responses are anonymized; " + | |
| 55 | + 'judge only on merit.\n\n' + | |
| 56 | + jsonInstruction(SYNTHESIS_SHAPE), | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + role: 'user', | |
| 60 | + content: | |
| 61 | + `Question:\n${question}\n\n` + | |
| 62 | + `Final answers from the council:\n\n${answersBlock}\n\n` + | |
| 63 | + `Points still in dispute after deliberation:\n${disagreementBlock}\n\n` + | |
| 64 | + 'Return your JSON synthesis now.', | |
| 65 | + }, | |
| 66 | + ]; | |
| 67 | +} |
added src/core/schemas.ts +98 −0
| @@ -0,0 +1,98 @@ | ||
| 1 | +/** | |
| 2 | + * Runtime schemas for everything that crosses a trust boundary: | |
| 3 | + * - the structured JSON we force models to return (critique/revision/etc.) | |
| 4 | + * - the debate configuration accepted from API clients | |
| 5 | + * | |
| 6 | + * Model output is untrusted input. Every model response that is supposed to be | |
| 7 | + * JSON is parsed defensively and validated against these schemas, with one | |
| 8 | + * repair attempt on failure (see `structured.ts`). | |
| 9 | + */ | |
| 10 | +import { z } from 'zod'; | |
| 11 | + | |
| 12 | +// --------------------------------------------------------------------------- | |
| 13 | +// Structured model outputs | |
| 14 | +// --------------------------------------------------------------------------- | |
| 15 | + | |
| 16 | +/** One reviewer's assessment of one anonymized peer answer. */ | |
| 17 | +export const peerReviewSchema = z.object({ | |
| 18 | + label: z.string().min(1).max(4), | |
| 19 | + weaknesses: z.array(z.string()).default([]), | |
| 20 | + strengths: z.array(z.string()).default([]), | |
| 21 | + score: z.coerce.number().min(1).max(10), | |
| 22 | + justification: z.string().default(''), | |
| 23 | +}); | |
| 24 | + | |
| 25 | +export const critiqueOutputSchema = z.object({ | |
| 26 | + reviews: z.array(peerReviewSchema).min(1), | |
| 27 | +}); | |
| 28 | +export type CritiqueOutput = z.infer<typeof critiqueOutputSchema>; | |
| 29 | + | |
| 30 | +export const revisionOutputSchema = z.object({ | |
| 31 | + answer: z.string().min(1), | |
| 32 | + changelog: z.object({ | |
| 33 | + changed: z.boolean(), | |
| 34 | + summary: z.string().default(''), | |
| 35 | + bullets: z.array(z.string()).default([]), | |
| 36 | + }), | |
| 37 | +}); | |
| 38 | +export type RevisionOutput = z.infer<typeof revisionOutputSchema>; | |
| 39 | + | |
| 40 | +export const convergenceOutputSchema = z.object({ | |
| 41 | + score: z.coerce.number().min(0).max(100), | |
| 42 | + disagreements: z | |
| 43 | + .array( | |
| 44 | + z.object({ | |
| 45 | + topic: z.string(), | |
| 46 | + summary: z.string().default(''), | |
| 47 | + positions: z | |
| 48 | + .array(z.object({ label: z.string(), stance: z.string() })) | |
| 49 | + .default([]), | |
| 50 | + }), | |
| 51 | + ) | |
| 52 | + .default([]), | |
| 53 | +}); | |
| 54 | +export type ConvergenceOutput = z.infer<typeof convergenceOutputSchema>; | |
| 55 | + | |
| 56 | +export const synthesisOutputSchema = z.object({ | |
| 57 | + finalAnswer: z.string().min(1), | |
| 58 | + dissent: z | |
| 59 | + .array( | |
| 60 | + z.object({ | |
| 61 | + topic: z.string(), | |
| 62 | + positions: z | |
| 63 | + .array(z.object({ label: z.string(), position: z.string() })) | |
| 64 | + .default([]), | |
| 65 | + }), | |
| 66 | + ) | |
| 67 | + .default([]), | |
| 68 | +}); | |
| 69 | +export type SynthesisOutput = z.infer<typeof synthesisOutputSchema>; | |
| 70 | + | |
| 71 | +// --------------------------------------------------------------------------- | |
| 72 | +// Debate configuration (API input) | |
| 73 | +// --------------------------------------------------------------------------- | |
| 74 | + | |
| 75 | +/** OpenRouter model slugs look like "vendor/model[:variant]". */ | |
| 76 | +export const modelSlugSchema = z | |
| 77 | + .string() | |
| 78 | + .min(1) | |
| 79 | + .max(128) | |
| 80 | + .regex(/^[\w.-]+\/[\w.:-]+$/, 'Expected an OpenRouter model slug like "vendor/model"'); | |
| 81 | + | |
| 82 | +export const debateConfigInputSchema = z | |
| 83 | + .object({ | |
| 84 | + question: z.string().min(3).max(8000), | |
| 85 | + models: z.array(modelSlugSchema).min(3).max(6), | |
| 86 | + chairmanModel: modelSlugSchema, | |
| 87 | + convergenceModel: modelSlugSchema.optional(), | |
| 88 | + maxRounds: z.number().int().min(1).max(5).default(3), | |
| 89 | + convergenceThreshold: z.number().int().min(0).max(100).default(85), | |
| 90 | + temperature: z.number().min(0).max(2).default(0.7), | |
| 91 | + perModelTimeoutMs: z.number().int().min(5_000).max(600_000).default(90_000), | |
| 92 | + }) | |
| 93 | + .refine((c) => new Set(c.models).size === c.models.length, { | |
| 94 | + message: 'Council models must be unique', | |
| 95 | + path: ['models'], | |
| 96 | + }); | |
| 97 | + | |
| 98 | +export type DebateConfigInput = z.infer<typeof debateConfigInputSchema>; |
added src/core/scoring.test.ts +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { buildScoreMatrix } from './scoring'; | |
| 3 | +import { emptyUsage, type CritiqueRecord, type Participant } from './types'; | |
| 4 | + | |
| 5 | +const participants: Participant[] = [ | |
| 6 | + { id: 'p0', model: 'openai/gpt-4o', displayName: 'GPT 4o' }, | |
| 7 | + { id: 'p1', model: 'anthropic/claude-3.5', displayName: 'Claude 3.5' }, | |
| 8 | + { id: 'p2', model: 'google/gemini-pro', displayName: 'Gemini Pro' }, | |
| 9 | +]; | |
| 10 | + | |
| 11 | +function critique(reviewer: string, scores: Record<string, number>): CritiqueRecord { | |
| 12 | + return { | |
| 13 | + round: 1, | |
| 14 | + reviewerParticipantId: reviewer, | |
| 15 | + reviewerModel: 'x/y', | |
| 16 | + reviews: Object.entries(scores).map(([targetParticipantId, score]) => ({ | |
| 17 | + label: 'A', | |
| 18 | + targetParticipantId, | |
| 19 | + weaknesses: [], | |
| 20 | + strengths: [], | |
| 21 | + score, | |
| 22 | + justification: '', | |
| 23 | + })), | |
| 24 | + usage: emptyUsage(), | |
| 25 | + latencyMs: 0, | |
| 26 | + }; | |
| 27 | +} | |
| 28 | + | |
| 29 | +describe('buildScoreMatrix', () => { | |
| 30 | + it('places scores in reviewer×target cells with self-cells null', () => { | |
| 31 | + const critiques = [ | |
| 32 | + critique('p0', { p1: 8, p2: 6 }), | |
| 33 | + critique('p1', { p0: 7, p2: 5 }), | |
| 34 | + critique('p2', { p0: 9, p1: 4 }), | |
| 35 | + ]; | |
| 36 | + const m = buildScoreMatrix(participants, critiques); | |
| 37 | + | |
| 38 | + expect(m.order).toEqual(['p0', 'p1', 'p2']); | |
| 39 | + expect(m.cells.p0!.p1).toBe(8); | |
| 40 | + expect(m.cells.p0!.p2).toBe(6); | |
| 41 | + expect(m.cells.p0!.p0).toBeNull(); // no self-review | |
| 42 | + expect(m.cells.p2!.p1).toBe(4); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it('computes averages received and given', () => { | |
| 46 | + const critiques = [ | |
| 47 | + critique('p0', { p1: 8, p2: 6 }), | |
| 48 | + critique('p1', { p0: 7, p2: 5 }), | |
| 49 | + critique('p2', { p0: 9, p1: 4 }), | |
| 50 | + ]; | |
| 51 | + const m = buildScoreMatrix(participants, critiques); | |
| 52 | + | |
| 53 | + // p0 received 7 (from p1) and 9 (from p2) -> 8 | |
| 54 | + expect(m.averagesReceived.p0).toBe(8); | |
| 55 | + // p1 received 8 (p0) and 4 (p2) -> 6 | |
| 56 | + expect(m.averagesReceived.p1).toBe(6); | |
| 57 | + // p0 gave 8 and 6 -> 7 | |
| 58 | + expect(m.averagesGiven.p0).toBe(7); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it('returns null averages for a participant nobody scored', () => { | |
| 62 | + const m = buildScoreMatrix(participants, [critique('p0', { p1: 8 })]); | |
| 63 | + expect(m.averagesReceived.p2).toBeNull(); | |
| 64 | + expect(m.averagesGiven.p1).toBeNull(); | |
| 65 | + }); | |
| 66 | + | |
| 67 | + it('ignores critiques from unknown reviewers defensively', () => { | |
| 68 | + const stray = critique('ghost', { p0: 10 }); | |
| 69 | + const m = buildScoreMatrix(participants, [stray]); | |
| 70 | + expect(m.averagesReceived.p0).toBeNull(); | |
| 71 | + }); | |
| 72 | +}); |
added src/core/scoring.ts +63 −0
| @@ -0,0 +1,63 @@ | ||
| 1 | +/** | |
| 2 | + * Aggregate critique scores into the N×N matrix the UI renders. | |
| 3 | + * | |
| 4 | + * Rows are reviewers, columns are the answers being scored. A cell is null when | |
| 5 | + * that reviewer did not score that target (e.g. the reviewer dropped out, or the | |
| 6 | + * self-cell). Pure and deterministic - unit-tested against hand-built records. | |
| 7 | + */ | |
| 8 | +import type { CritiqueRecord, Participant } from './types'; | |
| 9 | + | |
| 10 | +export interface ScoreMatrix { | |
| 11 | + /** participantIds in a stable order (matches `participants`). */ | |
| 12 | + order: string[]; | |
| 13 | + /** cells[reviewerId][targetId] = score or null. */ | |
| 14 | + cells: Record<string, Record<string, number | null>>; | |
| 15 | + /** Mean score each participant received (over non-null cells). */ | |
| 16 | + averagesReceived: Record<string, number | null>; | |
| 17 | + /** Mean score each participant handed out. */ | |
| 18 | + averagesGiven: Record<string, number | null>; | |
| 19 | +} | |
| 20 | + | |
| 21 | +function mean(nums: number[]): number | null { | |
| 22 | + if (nums.length === 0) return null; | |
| 23 | + return nums.reduce((a, b) => a + b, 0) / nums.length; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function buildScoreMatrix( | |
| 27 | + participants: Participant[], | |
| 28 | + critiques: CritiqueRecord[], | |
| 29 | +): ScoreMatrix { | |
| 30 | + const order = participants.map((p) => p.id); | |
| 31 | + const cells: Record<string, Record<string, number | null>> = {}; | |
| 32 | + for (const reviewer of order) { | |
| 33 | + cells[reviewer] = {}; | |
| 34 | + for (const target of order) cells[reviewer]![target] = null; | |
| 35 | + } | |
| 36 | + | |
| 37 | + for (const critique of critiques) { | |
| 38 | + const row = cells[critique.reviewerParticipantId]; | |
| 39 | + if (!row) continue; // reviewer not in participant set - ignore defensively | |
| 40 | + for (const review of critique.reviews) { | |
| 41 | + if (review.targetParticipantId in row) { | |
| 42 | + row[review.targetParticipantId] = review.score; | |
| 43 | + } | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + const averagesReceived: Record<string, number | null> = {}; | |
| 48 | + const averagesGiven: Record<string, number | null> = {}; | |
| 49 | + for (const target of order) { | |
| 50 | + const received: number[] = []; | |
| 51 | + const given: number[] = []; | |
| 52 | + for (const reviewer of order) { | |
| 53 | + const toTarget = cells[reviewer]![target]; | |
| 54 | + if (typeof toTarget === 'number') received.push(toTarget); | |
| 55 | + const fromTarget = cells[target]![reviewer]; | |
| 56 | + if (typeof fromTarget === 'number') given.push(fromTarget); | |
| 57 | + } | |
| 58 | + averagesReceived[target] = mean(received); | |
| 59 | + averagesGiven[target] = mean(given); | |
| 60 | + } | |
| 61 | + | |
| 62 | + return { order, cells, averagesReceived, averagesGiven }; | |
| 63 | +} |
added src/core/structured.test.ts +75 −0
| @@ -0,0 +1,75 @@ | ||
| 1 | +import { describe, expect, it } from 'vitest'; | |
| 2 | +import { z } from 'zod'; | |
| 3 | +import type { LlmClient, LlmRequest, LlmResult, LlmStreamHandle } from './llm-client'; | |
| 4 | +import { requestStructured, StructuredParseError } from './structured'; | |
| 5 | + | |
| 6 | +const schema = z.object({ answer: z.string(), score: z.number().min(0).max(10) }); | |
| 7 | + | |
| 8 | +/** A client that returns a scripted sequence of raw responses. */ | |
| 9 | +class ScriptedClient implements LlmClient { | |
| 10 | + calls: LlmRequest[] = []; | |
| 11 | + constructor(private readonly responses: string[]) {} | |
| 12 | + async complete(req: LlmRequest): Promise<LlmResult> { | |
| 13 | + this.calls.push(req); | |
| 14 | + const text = this.responses[this.calls.length - 1] ?? this.responses.at(-1) ?? ''; | |
| 15 | + return { | |
| 16 | + text, | |
| 17 | + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, costUsd: 0.001 }, | |
| 18 | + model: req.model, | |
| 19 | + latencyMs: 1, | |
| 20 | + }; | |
| 21 | + } | |
| 22 | + streamComplete(): Promise<LlmStreamHandle> { | |
| 23 | + throw new Error('not used'); | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +const req: LlmRequest = { model: 'x/y', messages: [{ role: 'user', content: 'hi' }] }; | |
| 28 | + | |
| 29 | +describe('requestStructured', () => { | |
| 30 | + it('returns the parsed value on a clean first attempt', async () => { | |
| 31 | + const client = new ScriptedClient(['{"answer":"ok","score":7}']); | |
| 32 | + const r = await requestStructured(client, req, schema); | |
| 33 | + expect(r.repaired).toBe(false); | |
| 34 | + expect(r.value).toEqual({ answer: 'ok', score: 7 }); | |
| 35 | + expect(client.calls).toHaveLength(1); | |
| 36 | + expect(r.usage.costUsd).toBeCloseTo(0.001); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it('repairs once when the first response is schema-invalid', async () => { | |
| 40 | + const client = new ScriptedClient([ | |
| 41 | + '{"answer":"ok","score":99}', // invalid: score > 10 | |
| 42 | + '{"answer":"ok","score":8}', // repaired | |
| 43 | + ]); | |
| 44 | + const r = await requestStructured(client, req, schema); | |
| 45 | + expect(r.repaired).toBe(true); | |
| 46 | + expect(r.value.score).toBe(8); | |
| 47 | + expect(client.calls).toHaveLength(2); | |
| 48 | + // usage accumulates across both calls | |
| 49 | + expect(r.usage.costUsd).toBeCloseTo(0.002); | |
| 50 | + // the repair call carries the repair system prompt | |
| 51 | + expect(client.calls[1]!.messages[0]!.content).toMatch(/JSON fixer/i); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it('repairs malformed (non-JSON-wrapped) output', async () => { | |
| 55 | + const client = new ScriptedClient([ | |
| 56 | + 'I think the answer is good.', // no JSON at all | |
| 57 | + '```json\n{"answer":"fixed","score":6}\n```', | |
| 58 | + ]); | |
| 59 | + const r = await requestStructured(client, req, schema); | |
| 60 | + expect(r.repaired).toBe(true); | |
| 61 | + expect(r.value.answer).toBe('fixed'); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it('throws StructuredParseError (with billed usage) after a failed repair', async () => { | |
| 65 | + const client = new ScriptedClient(['garbage', 'still garbage']); | |
| 66 | + await expect(requestStructured(client, req, schema)).rejects.toBeInstanceOf(StructuredParseError); | |
| 67 | + try { | |
| 68 | + await requestStructured(client, req, schema); | |
| 69 | + } catch (e) { | |
| 70 | + expect(e).toBeInstanceOf(StructuredParseError); | |
| 71 | + // tokens from both attempts are still billed | |
| 72 | + expect((e as StructuredParseError).usage.costUsd).toBeCloseTo(0.002); | |
| 73 | + } | |
| 74 | + }); | |
| 75 | +}); |
added src/core/structured.ts +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +/** | |
| 2 | + * Force a model to return schema-valid JSON, with one repair attempt. | |
| 3 | + * | |
| 4 | + * Pipeline: call → strip/extract/validate → on failure, re-ask with the raw | |
| 5 | + * output and the exact validation error → validate again. Tokens spent on the | |
| 6 | + * failed attempt are still billed (the user's key paid for them), so usage is | |
| 7 | + * accumulated across both calls and surfaced even when the repair ultimately | |
| 8 | + * fails. | |
| 9 | + */ | |
| 10 | +import type { z } from 'zod'; | |
| 11 | +import { parseStructured } from './json-repair'; | |
| 12 | +import type { LlmClient, LlmRequest } from './llm-client'; | |
| 13 | +import { buildRepairPrompt } from './prompts'; | |
| 14 | +import type { Usage } from './types'; | |
| 15 | +import { addUsage } from './usage'; | |
| 16 | + | |
| 17 | +export interface StructuredResult<T> { | |
| 18 | + value: T; | |
| 19 | + usage: Usage; | |
| 20 | + latencyMs: number; | |
| 21 | + raw: string; | |
| 22 | + repaired: boolean; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Raised when JSON is still invalid after the single repair retry. */ | |
| 26 | +export class StructuredParseError extends Error { | |
| 27 | + readonly usage: Usage; | |
| 28 | + readonly latencyMs: number; | |
| 29 | + constructor(message: string, opts: { usage: Usage; latencyMs: number }) { | |
| 30 | + super(message); | |
| 31 | + this.name = 'StructuredParseError'; | |
| 32 | + this.usage = opts.usage; | |
| 33 | + this.latencyMs = opts.latencyMs; | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +export async function requestStructured<S extends z.ZodTypeAny>( | |
| 38 | + llm: LlmClient, | |
| 39 | + req: LlmRequest, | |
| 40 | + schema: S, | |
| 41 | +): Promise<StructuredResult<z.infer<S>>> { | |
| 42 | + const first = await llm.complete({ ...req, json: true }); | |
| 43 | + const parsed = parseStructured(first.text, schema); | |
| 44 | + if (parsed.ok) { | |
| 45 | + return { | |
| 46 | + value: parsed.value, | |
| 47 | + usage: first.usage, | |
| 48 | + latencyMs: first.latencyMs, | |
| 49 | + raw: first.text, | |
| 50 | + repaired: false, | |
| 51 | + }; | |
| 52 | + } | |
| 53 | + | |
| 54 | + // Single repair attempt: hand the model its own output + the exact error. | |
| 55 | + const second = await llm.complete({ | |
| 56 | + ...req, | |
| 57 | + json: true, | |
| 58 | + messages: buildRepairPrompt(first.text, parsed.error), | |
| 59 | + }); | |
| 60 | + const usage = addUsage(first.usage, second.usage); | |
| 61 | + const latencyMs = first.latencyMs + second.latencyMs; | |
| 62 | + const parsed2 = parseStructured(second.text, schema); | |
| 63 | + if (parsed2.ok) { | |
| 64 | + return { value: parsed2.value, usage, latencyMs, raw: second.text, repaired: true }; | |
| 65 | + } | |
| 66 | + | |
| 67 | + throw new StructuredParseError( | |
| 68 | + `Model output failed schema validation after one repair attempt: ${parsed2.error}`, | |
| 69 | + { usage, latencyMs }, | |
| 70 | + ); | |
| 71 | +} |
added src/core/timeout.ts +37 −0
| @@ -0,0 +1,37 @@ | ||
| 1 | +/** | |
| 2 | + * Race a promise-returning function against a wall-clock deadline, wiring an | |
| 3 | + * AbortSignal through so the underlying LLM call is actually cancelled (not just | |
| 4 | + * abandoned). Also forwards a parent signal so a client disconnect / debate | |
| 5 | + * abort propagates down into in-flight model calls. | |
| 6 | + */ | |
| 7 | +export class TimeoutError extends Error { | |
| 8 | + readonly timeoutMs: number; | |
| 9 | + constructor(ms: number) { | |
| 10 | + super(`Operation timed out after ${ms}ms`); | |
| 11 | + this.name = 'TimeoutError'; | |
| 12 | + this.timeoutMs = ms; | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +export async function withTimeout<T>( | |
| 17 | + fn: (signal: AbortSignal) => Promise<T>, | |
| 18 | + ms: number, | |
| 19 | + parentSignal?: AbortSignal, | |
| 20 | +): Promise<T> { | |
| 21 | + const controller = new AbortController(); | |
| 22 | + const abortFromParent = () => controller.abort(parentSignal?.reason); | |
| 23 | + | |
| 24 | + if (parentSignal) { | |
| 25 | + if (parentSignal.aborted) controller.abort(parentSignal.reason); | |
| 26 | + else parentSignal.addEventListener('abort', abortFromParent, { once: true }); | |
| 27 | + } | |
| 28 | + | |
| 29 | + const timer = setTimeout(() => controller.abort(new TimeoutError(ms)), ms); | |
| 30 | + | |
| 31 | + try { | |
| 32 | + return await fn(controller.signal); | |
| 33 | + } finally { | |
| 34 | + clearTimeout(timer); | |
| 35 | + parentSignal?.removeEventListener('abort', abortFromParent); | |
| 36 | + } | |
| 37 | +} |
added src/core/types.ts +204 −0
| @@ -0,0 +1,204 @@ | ||
| 1 | +/** | |
| 2 | + * Core domain types for the Roundtable debate engine. | |
| 3 | + * | |
| 4 | + * This module is intentionally framework-agnostic: it imports nothing from | |
| 5 | + * Next.js, Prisma, or any transport. The orchestrator, the API route adapter, | |
| 6 | + * and a future CLI all speak these types. | |
| 7 | + */ | |
| 8 | + | |
| 9 | +/** The five kinds of work a debate performs. Mirrors the timeline in the UI. */ | |
| 10 | +export type StageType = 'answer' | 'critique' | 'revision' | 'convergence' | 'synthesis'; | |
| 11 | + | |
| 12 | +/** Anonymized reviewer-facing label, e.g. "A", "B", "C". */ | |
| 13 | +export type AnonLabel = string; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * A council member. `id` is a stable, debate-local identifier (`p0`, `p1`, ...) | |
| 17 | + * used everywhere internally so that the anonymized label mapping and the score | |
| 18 | + * matrix stay consistent even if two members happen to use the same model. | |
| 19 | + */ | |
| 20 | +export interface Participant { | |
| 21 | + id: string; | |
| 22 | + /** OpenRouter model slug, e.g. "openai/gpt-4o". */ | |
| 23 | + model: string; | |
| 24 | + /** Human-facing display name derived from the slug. */ | |
| 25 | + displayName: string; | |
| 26 | +} | |
| 27 | + | |
| 28 | +/** Fully-resolved configuration for a single debate run. */ | |
| 29 | +export interface DebateConfig { | |
| 30 | + question: string; | |
| 31 | + /** Council model slugs (3-6 enforced by the input schema). */ | |
| 32 | + models: string[]; | |
| 33 | + /** Model that writes the final synthesis + dissent report. */ | |
| 34 | + chairmanModel: string; | |
| 35 | + /** Cheap/fast model that scores convergence between rounds. */ | |
| 36 | + convergenceModel: string; | |
| 37 | + /** Number of critique→revision cycles (1-5). */ | |
| 38 | + maxRounds: number; | |
| 39 | + /** Stop early once convergence score ≥ this (0-100). */ | |
| 40 | + convergenceThreshold: number; | |
| 41 | + /** Sampling temperature applied to council answers and revisions. */ | |
| 42 | + temperature: number; | |
| 43 | + /** Per-model, per-stage wall-clock budget in ms before the call is dropped. */ | |
| 44 | + perModelTimeoutMs: number; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export type DebateStatus = | |
| 48 | + | 'pending' | |
| 49 | + | 'running' | |
| 50 | + | 'completed' | |
| 51 | + | 'failed' | |
| 52 | + | 'aborted'; | |
| 53 | + | |
| 54 | +// --------------------------------------------------------------------------- | |
| 55 | +// Stage records - the persisted, replayable artifacts of a debate. | |
| 56 | +// --------------------------------------------------------------------------- | |
| 57 | + | |
| 58 | +export interface Usage { | |
| 59 | + promptTokens: number; | |
| 60 | + completionTokens: number; | |
| 61 | + totalTokens: number; | |
| 62 | + costUsd: number; | |
| 63 | +} | |
| 64 | + | |
| 65 | +export const emptyUsage = (): Usage => ({ | |
| 66 | + promptTokens: 0, | |
| 67 | + completionTokens: 0, | |
| 68 | + totalTokens: 0, | |
| 69 | + costUsd: 0, | |
| 70 | +}); | |
| 71 | + | |
| 72 | +/** One model's answer at a point in time (round 0 seed, or a later revision). */ | |
| 73 | +export interface AnswerRecord { | |
| 74 | + participantId: string; | |
| 75 | + model: string; | |
| 76 | + round: number; | |
| 77 | + content: string; | |
| 78 | + usage: Usage; | |
| 79 | + latencyMs: number; | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** A single peer review inside one reviewer's critique output. */ | |
| 83 | +export interface PeerReview { | |
| 84 | + /** The anonymized label the reviewer saw (e.g. "B"). */ | |
| 85 | + label: AnonLabel; | |
| 86 | + /** Resolved back to the real author (server-side only until de-anonymized). */ | |
| 87 | + targetParticipantId: string; | |
| 88 | + weaknesses: string[]; | |
| 89 | + strengths: string[]; | |
| 90 | + /** 1-10. */ | |
| 91 | + score: number; | |
| 92 | + justification: string; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export interface CritiqueRecord { | |
| 96 | + round: number; | |
| 97 | + reviewerParticipantId: string; | |
| 98 | + reviewerModel: string; | |
| 99 | + reviews: PeerReview[]; | |
| 100 | + usage: Usage; | |
| 101 | + latencyMs: number; | |
| 102 | +} | |
| 103 | + | |
| 104 | +export interface RevisionChangelog { | |
| 105 | + changed: boolean; | |
| 106 | + /** What changed and why, or the defense of the original position. */ | |
| 107 | + summary: string; | |
| 108 | + bullets: string[]; | |
| 109 | +} | |
| 110 | + | |
| 111 | +export interface RevisionRecord { | |
| 112 | + round: number; | |
| 113 | + participantId: string; | |
| 114 | + model: string; | |
| 115 | + content: string; | |
| 116 | + changelog: RevisionChangelog; | |
| 117 | + usage: Usage; | |
| 118 | + latencyMs: number; | |
| 119 | +} | |
| 120 | + | |
| 121 | +export interface Disagreement { | |
| 122 | + topic: string; | |
| 123 | + summary: string; | |
| 124 | + /** Per-position stance, keyed by anonymized label as the evaluator saw it. */ | |
| 125 | + positions: Array<{ label: AnonLabel; participantId?: string; stance: string }>; | |
| 126 | +} | |
| 127 | + | |
| 128 | +export interface ConvergenceRecord { | |
| 129 | + round: number; | |
| 130 | + model: string; | |
| 131 | + score: number; | |
| 132 | + disagreements: Disagreement[]; | |
| 133 | + converged: boolean; | |
| 134 | + usage: Usage; | |
| 135 | + latencyMs: number; | |
| 136 | +} | |
| 137 | + | |
| 138 | +export interface DissentEntry { | |
| 139 | + topic: string; | |
| 140 | + positions: Array<{ participantId: string; model: string; position: string }>; | |
| 141 | +} | |
| 142 | + | |
| 143 | +export interface SynthesisRecord { | |
| 144 | + model: string; | |
| 145 | + finalAnswer: string; | |
| 146 | + dissent: DissentEntry[]; | |
| 147 | + usage: Usage; | |
| 148 | + latencyMs: number; | |
| 149 | +} | |
| 150 | + | |
| 151 | +/** A record of a model dropping out of a stage (timeout, error, bad JSON). */ | |
| 152 | +export interface FailureRecord { | |
| 153 | + round: number; | |
| 154 | + stage: StageType; | |
| 155 | + participantId: string; | |
| 156 | + model: string; | |
| 157 | + error: string; | |
| 158 | + /** True when the participant was removed from the remainder of the debate. */ | |
| 159 | + droppedFromDebate: boolean; | |
| 160 | +} | |
| 161 | + | |
| 162 | +/** One critique→revision→convergence cycle. */ | |
| 163 | +export interface RoundRecord { | |
| 164 | + round: number; | |
| 165 | + critiques: CritiqueRecord[]; | |
| 166 | + revisions: RevisionRecord[]; | |
| 167 | + convergence: ConvergenceRecord | null; | |
| 168 | +} | |
| 169 | + | |
| 170 | +/** The full, replayable result of a debate. */ | |
| 171 | +export interface DebateResult { | |
| 172 | + debateId: string; | |
| 173 | + config: DebateConfig; | |
| 174 | + participants: Participant[]; | |
| 175 | + status: DebateStatus; | |
| 176 | + initialAnswers: AnswerRecord[]; | |
| 177 | + rounds: RoundRecord[]; | |
| 178 | + synthesis: SynthesisRecord | null; | |
| 179 | + failures: FailureRecord[]; | |
| 180 | + /** Whichever answers were current when the debate ended. */ | |
| 181 | + finalAnswers: AnswerRecord[]; | |
| 182 | + totals: { | |
| 183 | + costUsd: number; | |
| 184 | + promptTokens: number; | |
| 185 | + completionTokens: number; | |
| 186 | + rounds: number; | |
| 187 | + durationMs: number; | |
| 188 | + costByModel: Record<string, number>; | |
| 189 | + }; | |
| 190 | + error?: string; | |
| 191 | +} | |
| 192 | + | |
| 193 | +/** Config fields that are safe to expose to the client (no secrets). */ | |
| 194 | +export type PublicDebateConfig = Omit<DebateConfig, never>; | |
| 195 | + | |
| 196 | +export function displayNameForModel(slug: string): string { | |
| 197 | + // "openai/gpt-4o-mini" -> "GPT 4o Mini" | |
| 198 | + const tail = slug.split('/').pop() ?? slug; | |
| 199 | + return tail | |
| 200 | + .replace(/[-_]/g, ' ') | |
| 201 | + .replace(/\b(gpt|llm)\b/gi, (m) => m.toUpperCase()) | |
| 202 | + .replace(/\b\w/g, (c) => c.toUpperCase()) | |
| 203 | + .trim(); | |
| 204 | +} |
added src/core/usage.ts +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +import { emptyUsage, type Usage } from './types'; | |
| 2 | + | |
| 3 | +/** Sum two usage records (tokens + cost). */ | |
| 4 | +export function addUsage(a: Usage, b: Usage): Usage { | |
| 5 | + return { | |
| 6 | + promptTokens: a.promptTokens + b.promptTokens, | |
| 7 | + completionTokens: a.completionTokens + b.completionTokens, | |
| 8 | + totalTokens: a.totalTokens + b.totalTokens, | |
| 9 | + costUsd: a.costUsd + b.costUsd, | |
| 10 | + }; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function sumUsage(usages: Iterable<Usage>): Usage { | |
| 14 | + let acc = emptyUsage(); | |
| 15 | + for (const u of usages) acc = addUsage(acc, u); | |
| 16 | + return acc; | |
| 17 | +} |