debate-view.ts
10,068 bytes
| 1 | /** |
|---|---|
| 2 | * The client-side debate view-model and its reducer. |
| 3 | * |
| 4 | * `applyEvent` folds the `DebateEvent` stream into a `DebateView` - the single |
| 5 | * shape every debate component renders. Because live streaming and recorded |
| 6 | * playback both feed the SAME reducer, and `fromResult` lifts a persisted |
| 7 | * `DebateResult` into the same shape, one set of components serves live debates, |
| 8 | * history replay, the demo, and the public share page. |
| 9 | */ |
| 10 | import type { DebateEvent } from '@/core/events'; |
| 11 | import { chairmanSharesProvider } from '@/core/models'; |
| 12 | import type { |
| 13 | AnswerRecord, |
| 14 | ConvergenceRecord, |
| 15 | CritiqueRecord, |
| 16 | DebateConfig, |
| 17 | DebateResult, |
| 18 | DebateStatus, |
| 19 | FailureRecord, |
| 20 | Participant, |
| 21 | ProvenanceRecord, |
| 22 | RevisionRecord, |
| 23 | RoundRecord, |
| 24 | StageType, |
| 25 | SynthesisRecord, |
| 26 | } from '@/core/types'; |
| 27 | |
| 28 | export interface DebateView { |
| 29 | debateId: string; |
| 30 | status: DebateStatus; |
| 31 | question: string; |
| 32 | config: DebateConfig | null; |
| 33 | participants: Participant[]; |
| 34 | chairmanModel: string; |
| 35 | convergenceModel: string; |
| 36 | chairmanProviderConflict: boolean; |
| 37 | initialAnswers: Record<string, AnswerRecord>; |
| 38 | rounds: RoundRecord[]; |
| 39 | synthesis: SynthesisRecord | null; |
| 40 | provenance: ProvenanceRecord | null; |
| 41 | failures: FailureRecord[]; |
| 42 | totals: { |
| 43 | costUsd: number; |
| 44 | promptTokens: number; |
| 45 | completionTokens: number; |
| 46 | costByModel: Record<string, number>; |
| 47 | }; |
| 48 | /** Live-only: participantId -> answer text streaming in right now. */ |
| 49 | streaming: Record<string, string>; |
| 50 | activeStage: { round: number; stage: StageType } | null; |
| 51 | /** participantId -> the stage it is currently working on. */ |
| 52 | working: Record<string, StageType>; |
| 53 | droppedParticipants: string[]; |
| 54 | /** Set when the spend cap cut the debate short of its configured rounds. */ |
| 55 | budgetReached: { round: number; totalCostUsd: number; maxCostUsd: number } | null; |
| 56 | /** Set when the user concluded the debate early (live streams only). */ |
| 57 | gavelStruck: { round: number } | null; |
| 58 | error?: string; |
| 59 | } |
| 60 | |
| 61 | export function initialDebateView(question = ''): DebateView { |
| 62 | return { |
| 63 | debateId: '', |
| 64 | status: 'pending', |
| 65 | question, |
| 66 | config: null, |
| 67 | participants: [], |
| 68 | chairmanModel: '', |
| 69 | convergenceModel: '', |
| 70 | chairmanProviderConflict: false, |
| 71 | initialAnswers: {}, |
| 72 | rounds: [], |
| 73 | synthesis: null, |
| 74 | provenance: null, |
| 75 | failures: [], |
| 76 | totals: { costUsd: 0, promptTokens: 0, completionTokens: 0, costByModel: {} }, |
| 77 | streaming: {}, |
| 78 | activeStage: null, |
| 79 | working: {}, |
| 80 | droppedParticipants: [], |
| 81 | budgetReached: null, |
| 82 | gavelStruck: null, |
| 83 | }; |
| 84 | } |
| 85 | |
| 86 | function ensureRound(rounds: RoundRecord[], round: number): RoundRecord[] { |
| 87 | if (rounds.some((r) => r.round === round)) return rounds; |
| 88 | return [...rounds, { round, critiques: [], revisions: [], convergence: null }].sort((a, b) => a.round - b.round); |
| 89 | } |
| 90 | |
| 91 | function updateRound(rounds: RoundRecord[], round: number, fn: (r: RoundRecord) => RoundRecord): RoundRecord[] { |
| 92 | return ensureRound(rounds, round).map((r) => (r.round === round ? fn(r) : r)); |
| 93 | } |
| 94 | |
| 95 | export function applyEvent(prev: DebateView, event: DebateEvent): DebateView { |
| 96 | switch (event.type) { |
| 97 | case 'debate_started': |
| 98 | return { |
| 99 | ...prev, |
| 100 | debateId: event.debateId, |
| 101 | status: 'running', |
| 102 | config: event.config, |
| 103 | question: event.config.question, |
| 104 | participants: event.participants, |
| 105 | chairmanModel: event.chairmanModel, |
| 106 | convergenceModel: event.config.convergenceModel, |
| 107 | chairmanProviderConflict: event.chairmanProviderConflict, |
| 108 | }; |
| 109 | |
| 110 | case 'stage_started': { |
| 111 | const working = { ...prev.working }; |
| 112 | const streaming = { ...prev.streaming }; |
| 113 | if (event.participantId) { |
| 114 | working[event.participantId] = event.stage; |
| 115 | if (event.stage === 'answer' && streaming[event.participantId] === undefined) { |
| 116 | streaming[event.participantId] = ''; |
| 117 | } |
| 118 | } |
| 119 | return { ...prev, activeStage: { round: event.round, stage: event.stage }, working, streaming }; |
| 120 | } |
| 121 | |
| 122 | case 'token_delta': { |
| 123 | const current = prev.streaming[event.participantId] ?? ''; |
| 124 | return { ...prev, streaming: { ...prev.streaming, [event.participantId]: current + event.delta } }; |
| 125 | } |
| 126 | |
| 127 | case 'answer_completed': { |
| 128 | const working = { ...prev.working }; |
| 129 | delete working[event.record.participantId]; |
| 130 | const streaming = { ...prev.streaming }; |
| 131 | delete streaming[event.record.participantId]; |
| 132 | return { |
| 133 | ...prev, |
| 134 | initialAnswers: { ...prev.initialAnswers, [event.record.participantId]: event.record }, |
| 135 | working, |
| 136 | streaming, |
| 137 | }; |
| 138 | } |
| 139 | |
| 140 | case 'critique_completed': { |
| 141 | const working = { ...prev.working }; |
| 142 | delete working[event.record.reviewerParticipantId]; |
| 143 | return { |
| 144 | ...prev, |
| 145 | rounds: updateRound(prev.rounds, event.round, (r) => ({ |
| 146 | ...r, |
| 147 | critiques: [...r.critiques.filter((c) => c.reviewerParticipantId !== event.record.reviewerParticipantId), event.record], |
| 148 | })), |
| 149 | working, |
| 150 | }; |
| 151 | } |
| 152 | |
| 153 | case 'revision_completed': { |
| 154 | const working = { ...prev.working }; |
| 155 | delete working[event.record.participantId]; |
| 156 | return { |
| 157 | ...prev, |
| 158 | rounds: updateRound(prev.rounds, event.round, (r) => ({ |
| 159 | ...r, |
| 160 | revisions: [...r.revisions.filter((x) => x.participantId !== event.record.participantId), event.record], |
| 161 | })), |
| 162 | working, |
| 163 | }; |
| 164 | } |
| 165 | |
| 166 | case 'convergence_result': |
| 167 | return { |
| 168 | ...prev, |
| 169 | rounds: updateRound(prev.rounds, event.round, (r) => ({ ...r, convergence: event.record })), |
| 170 | }; |
| 171 | |
| 172 | case 'model_failed': { |
| 173 | const working = { ...prev.working }; |
| 174 | delete working[event.participantId]; |
| 175 | const streaming = { ...prev.streaming }; |
| 176 | delete streaming[event.participantId]; |
| 177 | return { |
| 178 | ...prev, |
| 179 | working, |
| 180 | streaming, |
| 181 | failures: [ |
| 182 | ...prev.failures, |
| 183 | { |
| 184 | round: event.round, |
| 185 | stage: event.stage, |
| 186 | participantId: event.participantId, |
| 187 | model: event.model, |
| 188 | error: event.error, |
| 189 | droppedFromDebate: event.droppedFromDebate, |
| 190 | }, |
| 191 | ], |
| 192 | droppedParticipants: event.droppedFromDebate |
| 193 | ? [...new Set([...prev.droppedParticipants, event.participantId])] |
| 194 | : prev.droppedParticipants, |
| 195 | }; |
| 196 | } |
| 197 | |
| 198 | case 'cost_update': |
| 199 | return { |
| 200 | ...prev, |
| 201 | totals: { |
| 202 | costUsd: event.totalCostUsd, |
| 203 | promptTokens: event.promptTokens, |
| 204 | completionTokens: event.completionTokens, |
| 205 | costByModel: event.costByModel, |
| 206 | }, |
| 207 | }; |
| 208 | |
| 209 | case 'synthesis_completed': |
| 210 | return { ...prev, synthesis: event.record, activeStage: null }; |
| 211 | |
| 212 | case 'provenance_completed': |
| 213 | return { ...prev, provenance: event.record, activeStage: null }; |
| 214 | |
| 215 | case 'budget_reached': |
| 216 | return { |
| 217 | ...prev, |
| 218 | budgetReached: { round: event.round, totalCostUsd: event.totalCostUsd, maxCostUsd: event.maxCostUsd }, |
| 219 | }; |
| 220 | |
| 221 | case 'gavel_struck': |
| 222 | return { ...prev, gavelStruck: { round: event.round } }; |
| 223 | |
| 224 | case 'debate_completed': |
| 225 | return { |
| 226 | ...prev, |
| 227 | status: event.status, |
| 228 | activeStage: null, |
| 229 | working: {}, |
| 230 | totals: { ...prev.totals, costUsd: event.totalCostUsd }, |
| 231 | }; |
| 232 | |
| 233 | case 'debate_failed': |
| 234 | return { ...prev, status: 'failed', error: event.error, activeStage: null, working: {} }; |
| 235 | |
| 236 | default: |
| 237 | return prev; |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | /** Lift a persisted result into a view for replay (no live streaming state). */ |
| 242 | export function fromResult(result: DebateResult): DebateView { |
| 243 | const initialAnswers: Record<string, AnswerRecord> = {}; |
| 244 | for (const a of result.initialAnswers) initialAnswers[a.participantId] = a; |
| 245 | return { |
| 246 | debateId: result.debateId, |
| 247 | status: result.status, |
| 248 | question: result.config.question, |
| 249 | config: result.config, |
| 250 | participants: result.participants, |
| 251 | chairmanModel: result.config.chairmanModel, |
| 252 | convergenceModel: result.config.convergenceModel, |
| 253 | chairmanProviderConflict: chairmanSharesProvider(result.config.chairmanModel, result.config.models), |
| 254 | initialAnswers, |
| 255 | rounds: result.rounds, |
| 256 | synthesis: result.synthesis, |
| 257 | provenance: result.provenance, |
| 258 | failures: result.failures, |
| 259 | totals: { |
| 260 | costUsd: result.totals.costUsd, |
| 261 | promptTokens: result.totals.promptTokens, |
| 262 | completionTokens: result.totals.completionTokens, |
| 263 | costByModel: result.totals.costByModel, |
| 264 | }, |
| 265 | streaming: {}, |
| 266 | activeStage: null, |
| 267 | working: {}, |
| 268 | droppedParticipants: result.failures.filter((f) => f.droppedFromDebate).map((f) => f.participantId), |
| 269 | // Not persisted as an event; derivable: the cap was crossed and the debate |
| 270 | // stopped short of its configured rounds. |
| 271 | budgetReached: |
| 272 | result.config.maxCostUsd !== undefined && |
| 273 | result.totals.costUsd >= result.config.maxCostUsd && |
| 274 | result.totals.rounds < result.config.maxRounds |
| 275 | ? { |
| 276 | round: result.totals.rounds, |
| 277 | totalCostUsd: result.totals.costUsd, |
| 278 | maxCostUsd: result.config.maxCostUsd, |
| 279 | } |
| 280 | : null, |
| 281 | gavelStruck: null, |
| 282 | ...(result.error ? { error: result.error } : {}), |
| 283 | }; |
| 284 | } |
| 285 | |
| 286 | /** Current answer text per participant (latest revision, else initial). */ |
| 287 | export function currentAnswers(view: DebateView): Record<string, AnswerRecord> { |
| 288 | const out: Record<string, AnswerRecord> = { ...view.initialAnswers }; |
| 289 | for (const round of view.rounds) { |
| 290 | for (const rev of round.revisions) { |
| 291 | out[rev.participantId] = { |
| 292 | participantId: rev.participantId, |
| 293 | model: rev.model, |
| 294 | round: rev.round, |
| 295 | content: rev.content, |
| 296 | usage: rev.usage, |
| 297 | latencyMs: rev.latencyMs, |
| 298 | }; |
| 299 | } |
| 300 | } |
| 301 | return out; |
| 302 | } |
| 303 | |
| 304 | /** All critique records across rounds, useful for the aggregate matrix. */ |
| 305 | export function allCritiques(view: DebateView): CritiqueRecord[] { |
| 306 | return view.rounds.flatMap((r) => r.critiques); |
| 307 | } |
| 308 | |
| 309 | export type { RevisionRecord, ConvergenceRecord, SynthesisRecord, FailureRecord }; |
| 310 | |