profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
repositories.ts 20,560 bytes
1 /**
2 * Data access for debates, keys, presets, and spend.
3 *
4 * The write path is event-driven: `persistEvent` is called for each completed
5 * stage as the orchestrator emits it, so state survives a client disconnect and
6 * can be replayed. `loadDebateResult` reconstructs the exact `DebateResult`
7 * shape the UI renders - the same shape the live event reducer builds - so live
8 * and replay share every component.
9 */
10 import type { Prisma, DebateStatus as DbStatus } from '@prisma/client';
11 import type { DebateEvent } from '@/core/events';
12 import type {
13 AnswerRecord,
14 ConvergenceRecord,
15 CritiqueRecord,
16 DebateConfig,
17 DebateResult,
18 DebateStatus,
19 Disagreement,
20 FailureRecord,
21 Participant,
22 PeerReview,
23 ProvenanceClaim,
24 ProvenanceRecord,
25 RevisionChangelog,
26 RevisionRecord,
27 RoundRecord,
28 SynthesisRecord,
29 } from '@/core/types';
30 import { prisma } from './client';
31
32 // ---------------------------------------------------------------------------
33 // Creation + lifecycle
34 // ---------------------------------------------------------------------------
35
36 export interface CreateDebateArgs {
37 userId: string | null;
38 config: DebateConfig;
39 participants: Participant[];
40 promptVersion: string;
41 isDemo?: boolean;
42 }
43
44 export async function createDebate(args: CreateDebateArgs): Promise<string> {
45 const debate = await prisma.debate.create({
46 data: {
47 userId: args.userId,
48 question: args.config.question,
49 status: 'running',
50 config: args.config as unknown as Prisma.InputJsonValue,
51 chairmanModel: args.config.chairmanModel,
52 convergenceModel: args.config.convergenceModel,
53 maxRounds: args.config.maxRounds,
54 convergenceThreshold: args.config.convergenceThreshold,
55 temperature: args.config.temperature,
56 promptVersion: args.promptVersion,
57 isDemo: args.isDemo ?? false,
58 participants: {
59 create: args.participants.map((p, i) => ({
60 localId: p.id,
61 model: p.model,
62 displayName: p.displayName,
63 orderIndex: i,
64 })),
65 },
66 },
67 select: { id: true },
68 });
69 return debate.id;
70 }
71
72 /** Persist the durable events. Transient events (tokens, cost) are ignored. */
73 export async function persistEvent(debateId: string, event: DebateEvent): Promise<void> {
74 switch (event.type) {
75 case 'answer_completed': {
76 const r = event.record;
77 await prisma.stageResult.create({
78 data: {
79 debateId,
80 round: r.round,
81 stage: 'answer',
82 participantLocalId: r.participantId,
83 model: r.model,
84 content: r.content,
85 promptTokens: r.usage.promptTokens,
86 completionTokens: r.usage.completionTokens,
87 costUsd: r.usage.costUsd,
88 latencyMs: r.latencyMs,
89 },
90 });
91 return;
92 }
93 case 'critique_completed': {
94 const r = event.record;
95 await prisma.stageResult.create({
96 data: {
97 debateId,
98 round: r.round,
99 stage: 'critique',
100 participantLocalId: r.reviewerParticipantId,
101 model: r.reviewerModel,
102 content: '',
103 data: { reviews: r.reviews } as unknown as Prisma.InputJsonValue,
104 promptTokens: r.usage.promptTokens,
105 completionTokens: r.usage.completionTokens,
106 costUsd: r.usage.costUsd,
107 latencyMs: r.latencyMs,
108 },
109 });
110 return;
111 }
112 case 'revision_completed': {
113 const r = event.record;
114 await prisma.stageResult.create({
115 data: {
116 debateId,
117 round: r.round,
118 stage: 'revision',
119 participantLocalId: r.participantId,
120 model: r.model,
121 content: r.content,
122 data: { changelog: r.changelog } as unknown as Prisma.InputJsonValue,
123 promptTokens: r.usage.promptTokens,
124 completionTokens: r.usage.completionTokens,
125 costUsd: r.usage.costUsd,
126 latencyMs: r.latencyMs,
127 },
128 });
129 return;
130 }
131 case 'convergence_result': {
132 const r = event.record;
133 await prisma.stageResult.create({
134 data: {
135 debateId,
136 round: r.round,
137 stage: 'convergence',
138 model: r.model,
139 content: '',
140 data: {
141 score: r.score,
142 converged: r.converged,
143 disagreements: r.disagreements,
144 } as unknown as Prisma.InputJsonValue,
145 promptTokens: r.usage.promptTokens,
146 completionTokens: r.usage.completionTokens,
147 costUsd: r.usage.costUsd,
148 latencyMs: r.latencyMs,
149 },
150 });
151 return;
152 }
153 case 'model_failed': {
154 await prisma.stageResult.create({
155 data: {
156 debateId,
157 round: event.round,
158 stage: event.stage,
159 participantLocalId: event.participantId,
160 model: event.model,
161 content: '',
162 error: event.error,
163 },
164 });
165 return;
166 }
167 case 'provenance_completed': {
168 const r = event.record;
169 await prisma.stageResult.create({
170 data: {
171 debateId,
172 round: r.round,
173 stage: 'provenance',
174 model: r.model,
175 content: '',
176 data: { claims: r.claims } as unknown as Prisma.InputJsonValue,
177 promptTokens: r.usage.promptTokens,
178 completionTokens: r.usage.completionTokens,
179 costUsd: r.usage.costUsd,
180 latencyMs: r.latencyMs,
181 },
182 });
183 return;
184 }
185 case 'synthesis_completed': {
186 const r = event.record;
187 await prisma.synthesisResult.upsert({
188 where: { debateId },
189 create: {
190 debateId,
191 model: r.model,
192 finalAnswer: r.finalAnswer,
193 dissent: r.dissent as unknown as Prisma.InputJsonValue,
194 promptTokens: r.usage.promptTokens,
195 completionTokens: r.usage.completionTokens,
196 costUsd: r.usage.costUsd,
197 latencyMs: r.latencyMs,
198 },
199 update: {
200 finalAnswer: r.finalAnswer,
201 dissent: r.dissent as unknown as Prisma.InputJsonValue,
202 },
203 });
204 return;
205 }
206 default:
207 return; // transient - nothing to persist
208 }
209 }
210
211 export async function finalizeDebate(debateId: string, result: DebateResult): Promise<void> {
212 await prisma.debate.update({
213 where: { id: debateId },
214 data: {
215 status: result.status as DbStatus,
216 totalCostUsd: result.totals.costUsd,
217 promptTokens: result.totals.promptTokens,
218 completionTokens: result.totals.completionTokens,
219 roundsCompleted: result.totals.rounds,
220 durationMs: result.totals.durationMs,
221 error: result.error ?? null,
222 },
223 });
224 }
225
226 // ---------------------------------------------------------------------------
227 // Reconstruction (replay)
228 // ---------------------------------------------------------------------------
229
230 const debateInclude = {
231 participants: { orderBy: { orderIndex: 'asc' } },
232 stageResults: { orderBy: { createdAt: 'asc' } },
233 synthesis: true,
234 } satisfies Prisma.DebateInclude;
235
236 type DebateWithRelations = Prisma.DebateGetPayload<{ include: typeof debateInclude }>;
237
238 export async function loadDebateResult(debateId: string): Promise<DebateResult | null> {
239 const debate = await prisma.debate.findUnique({ where: { id: debateId }, include: debateInclude });
240 return debate ? toResult(debate) : null;
241 }
242
243 export async function loadDebateByShareToken(token: string): Promise<DebateResult | null> {
244 const debate = await prisma.debate.findUnique({ where: { shareToken: token }, include: debateInclude });
245 return debate ? toResult(debate) : null;
246 }
247
248 function toResult(debate: DebateWithRelations): DebateResult {
249 const participants: Participant[] = debate.participants.map((p) => ({
250 id: p.localId,
251 model: p.model,
252 displayName: p.displayName,
253 }));
254
255 const answersRows = debate.stageResults.filter((s) => s.stage === 'answer' && !s.error);
256 const initialAnswers: AnswerRecord[] = answersRows
257 .filter((s) => s.round === 0)
258 .map((s) => stageToAnswer(s));
259
260 const roundNumbers = [...new Set(debate.stageResults.filter((s) => s.round >= 1).map((s) => s.round))].sort(
261 (a, b) => a - b,
262 );
263
264 const rounds: RoundRecord[] = roundNumbers.map((round) => {
265 const inRound = debate.stageResults.filter((s) => s.round === round && !s.error);
266 const critiques: CritiqueRecord[] = inRound
267 .filter((s) => s.stage === 'critique')
268 .map((s) => ({
269 round,
270 reviewerParticipantId: s.participantLocalId ?? '',
271 reviewerModel: s.model,
272 reviews: readJson<{ reviews: PeerReview[] }>(s.data)?.reviews ?? [],
273 usage: usageFromRow(s),
274 latencyMs: s.latencyMs,
275 }));
276 const revisions: RevisionRecord[] = inRound
277 .filter((s) => s.stage === 'revision')
278 .map((s) => ({
279 round,
280 participantId: s.participantLocalId ?? '',
281 model: s.model,
282 content: s.content,
283 changelog: readJson<{ changelog: RevisionChangelog }>(s.data)?.changelog ?? {
284 changed: false,
285 summary: '',
286 bullets: [],
287 },
288 usage: usageFromRow(s),
289 latencyMs: s.latencyMs,
290 }));
291 const convRow = inRound.find((s) => s.stage === 'convergence');
292 const convData = convRow
293 ? readJson<{ score: number; converged: boolean; disagreements: Disagreement[] }>(convRow.data)
294 : null;
295 const convergence: ConvergenceRecord | null = convRow
296 ? {
297 round,
298 model: convRow.model,
299 score: convData?.score ?? 0,
300 converged: convData?.converged ?? false,
301 disagreements: convData?.disagreements ?? [],
302 usage: usageFromRow(convRow),
303 latencyMs: convRow.latencyMs,
304 }
305 : null;
306 return { round, critiques, revisions, convergence };
307 });
308
309 // Current answer per participant = highest-round answer/revision row.
310 const currentByParticipant = new Map<string, AnswerRecord>();
311 for (const s of answersRows) upsertLatest(currentByParticipant, stageToAnswer(s));
312 for (const round of rounds) {
313 for (const rev of round.revisions) {
314 upsertLatest(currentByParticipant, {
315 participantId: rev.participantId,
316 model: rev.model,
317 round: rev.round,
318 content: rev.content,
319 usage: rev.usage,
320 latencyMs: rev.latencyMs,
321 });
322 }
323 }
324
325 const failures: FailureRecord[] = debate.stageResults
326 .filter((s) => s.error)
327 .map((s) => ({
328 round: s.round,
329 stage: s.stage,
330 participantId: s.participantLocalId ?? '',
331 model: s.model,
332 error: s.error ?? '',
333 droppedFromDebate: s.stage === 'answer' && s.round === 0,
334 }));
335 const droppedIds = new Set(failures.filter((f) => f.droppedFromDebate).map((f) => f.participantId));
336
337 const finalAnswers = [...currentByParticipant.values()].filter((a) => !droppedIds.has(a.participantId));
338
339 const synthesis: SynthesisRecord | null = debate.synthesis
340 ? {
341 model: debate.synthesis.model,
342 finalAnswer: debate.synthesis.finalAnswer,
343 dissent:
344 readJson<SynthesisRecord['dissent']>(debate.synthesis.dissent) ?? [],
345 usage: {
346 promptTokens: debate.synthesis.promptTokens,
347 completionTokens: debate.synthesis.completionTokens,
348 totalTokens: debate.synthesis.promptTokens + debate.synthesis.completionTokens,
349 costUsd: debate.synthesis.costUsd,
350 },
351 latencyMs: debate.synthesis.latencyMs,
352 }
353 : null;
354
355 const provRow = debate.stageResults.find((s) => s.stage === 'provenance' && !s.error);
356 const provenance: ProvenanceRecord | null = provRow
357 ? {
358 model: provRow.model,
359 round: provRow.round,
360 claims: readJson<{ claims: ProvenanceClaim[] }>(provRow.data)?.claims ?? [],
361 usage: usageFromRow(provRow),
362 latencyMs: provRow.latencyMs,
363 }
364 : null;
365
366 const costByModel: Record<string, number> = {};
367 for (const s of debate.stageResults) {
368 costByModel[s.model] = (costByModel[s.model] ?? 0) + s.costUsd;
369 }
370
371 return {
372 debateId: debate.id,
373 config: debate.config as unknown as DebateConfig,
374 participants,
375 status: debate.status as DebateStatus,
376 initialAnswers,
377 rounds,
378 synthesis,
379 provenance,
380 failures,
381 finalAnswers,
382 totals: {
383 costUsd: debate.totalCostUsd,
384 promptTokens: debate.promptTokens,
385 completionTokens: debate.completionTokens,
386 rounds: debate.roundsCompleted,
387 durationMs: debate.durationMs,
388 costByModel,
389 },
390 ...(debate.error ? { error: debate.error } : {}),
391 };
392 }
393
394 // ---------------------------------------------------------------------------
395 // History / queries
396 // ---------------------------------------------------------------------------
397
398 export interface DebateSummary {
399 id: string;
400 question: string;
401 status: DebateStatus;
402 models: string[];
403 chairmanModel: string;
404 totalCostUsd: number;
405 roundsCompleted: number;
406 createdAt: string;
407 isDemo: boolean;
408 shareToken: string | null;
409 }
410
411 export async function listDebates(
412 userId: string,
413 opts: { search?: string; take?: number; skip?: number } = {},
414 ): Promise<DebateSummary[]> {
415 const debates = await prisma.debate.findMany({
416 where: {
417 userId,
418 ...(opts.search ? { question: { contains: opts.search, mode: 'insensitive' } } : {}),
419 },
420 orderBy: { createdAt: 'desc' },
421 take: opts.take ?? 30,
422 skip: opts.skip ?? 0,
423 include: { participants: { orderBy: { orderIndex: 'asc' }, select: { model: true } } },
424 });
425 return debates.map((d) => summarize(d, d.participants.map((p) => p.model)));
426 }
427
428 export async function listDemoDebates(): Promise<DebateSummary[]> {
429 const debates = await prisma.debate.findMany({
430 where: { isDemo: true },
431 orderBy: { createdAt: 'asc' },
432 include: { participants: { orderBy: { orderIndex: 'asc' }, select: { model: true } } },
433 });
434 return debates.map((d) => summarize(d, d.participants.map((p) => p.model)));
435 }
436
437 function summarize(
438 d: { id: string; question: string; status: DbStatus; chairmanModel: string; totalCostUsd: number; roundsCompleted: number; createdAt: Date; isDemo: boolean; shareToken: string | null },
439 models: string[],
440 ): DebateSummary {
441 return {
442 id: d.id,
443 question: d.question,
444 status: d.status as DebateStatus,
445 models,
446 chairmanModel: d.chairmanModel,
447 totalCostUsd: d.totalCostUsd,
448 roundsCompleted: d.roundsCompleted,
449 createdAt: d.createdAt.toISOString(),
450 isDemo: d.isDemo,
451 shareToken: d.shareToken,
452 };
453 }
454
455 export interface DebateOwnership {
456 userId: string | null;
457 isDemo: boolean;
458 }
459
460 export async function getDebateOwnership(debateId: string): Promise<DebateOwnership | null> {
461 const d = await prisma.debate.findUnique({ where: { id: debateId }, select: { userId: true, isDemo: true } });
462 return d ? { userId: d.userId, isDemo: d.isDemo } : null;
463 }
464
465 export async function deleteDebate(debateId: string): Promise<void> {
466 await prisma.debate.delete({ where: { id: debateId } });
467 }
468
469 export async function monthlySpendUsd(userId: string): Promise<number> {
470 const start = new Date();
471 start.setUTCDate(1);
472 start.setUTCHours(0, 0, 0, 0);
473 const agg = await prisma.debate.aggregate({
474 where: { userId, createdAt: { gte: start } },
475 _sum: { totalCostUsd: true },
476 });
477 return agg._sum.totalCostUsd ?? 0;
478 }
479
480 export async function countRecentDebates(userId: string, sinceMs: number): Promise<number> {
481 return prisma.debate.count({
482 where: { userId, createdAt: { gte: new Date(Date.now() - sinceMs) } },
483 });
484 }
485
486 // ---------------------------------------------------------------------------
487 // Sharing
488 // ---------------------------------------------------------------------------
489
490 export async function ensureShareToken(debateId: string): Promise<string> {
491 const token = randomToken();
492 const claimed = await prisma.debate.updateMany({
493 where: { id: debateId, shareToken: null },
494 data: { shareToken: token },
495 });
496 if (claimed.count === 1) return token;
497
498 const existing = await prisma.debate.findUnique({
499 where: { id: debateId },
500 select: { shareToken: true },
501 });
502 if (!existing?.shareToken) throw new Error('Debate not found');
503 return existing.shareToken;
504 }
505
506 // ---------------------------------------------------------------------------
507 // helpers
508 // ---------------------------------------------------------------------------
509
510 function stageToAnswer(s: { participantLocalId: string | null; model: string; round: number; content: string; promptTokens: number; completionTokens: number; costUsd: number; latencyMs: number }): AnswerRecord {
511 return {
512 participantId: s.participantLocalId ?? '',
513 model: s.model,
514 round: s.round,
515 content: s.content,
516 usage: usageFromRow(s),
517 latencyMs: s.latencyMs,
518 };
519 }
520
521 function usageFromRow(s: { promptTokens: number; completionTokens: number; costUsd: number }) {
522 return {
523 promptTokens: s.promptTokens,
524 completionTokens: s.completionTokens,
525 totalTokens: s.promptTokens + s.completionTokens,
526 costUsd: s.costUsd,
527 };
528 }
529
530 function upsertLatest(map: Map<string, AnswerRecord>, rec: AnswerRecord): void {
531 const prev = map.get(rec.participantId);
532 if (!prev || rec.round >= prev.round) map.set(rec.participantId, rec);
533 }
534
535 function readJson<T>(value: Prisma.JsonValue | null): T | null {
536 if (value === null || value === undefined) return null;
537 return value as unknown as T;
538 }
539
540 function randomToken(): string {
541 // URL-safe unlisted token.
542 const bytes = new Uint8Array(18);
543 globalThis.crypto.getRandomValues(bytes);
544 return Buffer.from(bytes).toString('base64url');
545 }
546
547 // ---------------------------------------------------------------------------
548 // BYOK saved keys
549 // ---------------------------------------------------------------------------
550
551 export interface SavedKeyInfo {
552 keyMask: string;
553 label: string | null;
554 updatedAt: string;
555 }
556
557 /** Returns display metadata only - never the encrypted payload. */
558 export async function getSavedKeyInfo(userId: string): Promise<SavedKeyInfo | null> {
559 const row = await prisma.apiKey.findUnique({ where: { userId }, select: { keyMask: true, label: true, updatedAt: true } });
560 return row ? { keyMask: row.keyMask, label: row.label, updatedAt: row.updatedAt.toISOString() } : null;
561 }
562
563 /** Returns the encrypted payload for server-side decryption at debate time. */
564 export async function getEncryptedKey(userId: string): Promise<string | null> {
565 const row = await prisma.apiKey.findUnique({ where: { userId }, select: { encrypted: true } });
566 return row?.encrypted ?? null;
567 }
568
569 export async function saveEncryptedKey(
570 userId: string,
571 encrypted: string,
572 keyMask: string,
573 label: string | null,
574 ): Promise<void> {
575 await prisma.apiKey.upsert({
576 where: { userId },
577 create: { userId, encrypted, keyMask, label },
578 update: { encrypted, keyMask, label },
579 });
580 }
581
582 export async function deleteSavedKey(userId: string): Promise<void> {
583 await prisma.apiKey.deleteMany({ where: { userId } });
584 }
585
586 // ---------------------------------------------------------------------------
587 // Presets ("my default council")
588 // ---------------------------------------------------------------------------
589
590 export interface PresetInput {
591 name: string;
592 models: string[];
593 chairmanModel: string;
594 convergenceModel?: string | null;
595 maxRounds?: number;
596 convergenceThreshold?: number;
597 temperature?: number;
598 }
599
600 export async function listPresets(userId: string) {
601 return prisma.preset.findMany({ where: { userId }, orderBy: { updatedAt: 'desc' } });
602 }
603
604 export async function upsertPreset(userId: string, input: PresetInput) {
605 return prisma.preset.upsert({
606 where: { userId_name: { userId, name: input.name } },
607 create: {
608 userId,
609 name: input.name,
610 models: input.models,
611 chairmanModel: input.chairmanModel,
612 convergenceModel: input.convergenceModel ?? null,
613 maxRounds: input.maxRounds ?? 3,
614 convergenceThreshold: input.convergenceThreshold ?? 85,
615 temperature: input.temperature ?? 0.7,
616 },
617 update: {
618 models: input.models,
619 chairmanModel: input.chairmanModel,
620 convergenceModel: input.convergenceModel ?? null,
621 maxRounds: input.maxRounds ?? 3,
622 convergenceThreshold: input.convergenceThreshold ?? 85,
623 temperature: input.temperature ?? 0.7,
624 },
625 });
626 }
627
628 export async function deletePreset(userId: string, id: string): Promise<void> {
629 await prisma.preset.deleteMany({ where: { id, userId } });
630 }
631