profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
export-markdown.ts 5,352 bytes
1 /**
2 * Render a `DebateResult` as a clean, self-contained Markdown deliberation
3 * report - the downloadable counterpart to the shareable web page.
4 */
5 import { buildMasqueradeReport } from '@/core/masquerade';
6 import { buildScoreMatrix } from '@/core/scoring';
7 import { buildSpineProfiles } from '@/core/spine';
8 import type { DebateResult, Participant } from '@/core/types';
9
10 export function debateToMarkdown(result: DebateResult): string {
11 const nameOf = nameLookup(result.participants);
12 const lines: string[] = [];
13 const push = (s = '') => lines.push(s);
14
15 push(`# Roundtable Deliberation Report`);
16 push();
17 push(`## Question`);
18 push(result.config.question);
19 push();
20
21 push(`## Council`);
22 for (const p of result.participants) push(`- **${p.displayName}** \`${p.model}\``);
23 push(`- **Chairman:** \`${result.config.chairmanModel}\``);
24 push(`- **Convergence assessor:** \`${result.config.convergenceModel}\``);
25 push();
26 push(
27 `_Rounds: ${result.totals.rounds} · Cost: $${result.totals.costUsd.toFixed(4)} · ` +
28 `Tokens: ${result.totals.promptTokens + result.totals.completionTokens} · Status: ${result.status}_`,
29 );
30 push();
31
32 if (result.synthesis) {
33 push(`## Final Answer`);
34 push(`_Synthesized by \`${result.synthesis.model}\`._`);
35 push();
36 push(result.synthesis.finalAnswer);
37 push();
38 if (result.synthesis.dissent.length) {
39 push(`### Dissent Report`);
40 for (const d of result.synthesis.dissent) {
41 push(`- **${d.topic}**`);
42 for (const pos of d.positions) push(` - ${nameOf(pos.participantId)}: ${pos.position}`);
43 }
44 push();
45 }
46 if (result.provenance && result.provenance.claims.length) {
47 push(`### Claim Check`);
48 push(`_Each substantive claim traced back to the council by \`${result.provenance.model}\`._`);
49 push();
50 for (const claim of result.provenance.claims) {
51 const source = claim.unsourced
52 ? "**chairman's own addition - no council member made this claim**"
53 : `supported by ${claim.supportedBy.map((m) => nameOf(m.participantId)).join(', ')}` +
54 (claim.contestedBy.length
55 ? `; contested by ${claim.contestedBy.map((m) => nameOf(m.participantId)).join(', ')}`
56 : '');
57 push(`- ${claim.text} (${source})`);
58 }
59 push();
60 }
61 }
62
63 push(`## Round 0 - Independent Answers`);
64 for (const a of result.initialAnswers) {
65 push(`### ${nameOf(a.participantId)}`);
66 push(a.content);
67 push();
68 }
69
70 for (const round of result.rounds) {
71 push(`## Round ${round.round}`);
72 if (round.convergence) {
73 push(`_Convergence score: ${round.convergence.score}/100 ` + `(${round.convergence.converged ? 'converged' : 'not converged'})._`);
74 push();
75 }
76
77 if (round.critiques.length) {
78 push(`### Critique scores`);
79 const matrix = buildScoreMatrix(result.participants, round.critiques);
80 const header = ['reviewer ↓ / target →', ...matrix.order.map(nameOf)];
81 push(`| ${header.join(' | ')} |`);
82 push(`| ${header.map(() => '---').join(' | ')} |`);
83 for (const reviewer of matrix.order) {
84 const cells = matrix.order.map((target) => {
85 const v = matrix.cells[reviewer]![target];
86 return v === null ? '-' : String(v);
87 });
88 push(`| ${nameOf(reviewer)} | ${cells.join(' | ')} |`);
89 }
90 push();
91 }
92
93 if (round.revisions.length) {
94 push(`### Revisions`);
95 for (const rev of round.revisions) {
96 push(`#### ${nameOf(rev.participantId)}`);
97 push(`> ${rev.changelog.changed ? 'Revised' : 'Defended original'}: ${rev.changelog.summary}`);
98 push();
99 push(rev.content);
100 push();
101 }
102 }
103 }
104
105 const spine = buildSpineProfiles(result.participants, result.rounds).filter((p) => p.entries.length > 0);
106 if (spine.length) {
107 push(`## Pressure Response`);
108 push(`_How each model handled peer critique: caved = changed a well-scored answer, stonewalled = kept a poorly-scored one._`);
109 push();
110 push(`| Model | Round | Peers scored it | Response |`);
111 push(`| --- | --- | --- | --- |`);
112 for (const profile of spine) {
113 for (const entry of profile.entries) {
114 const score = entry.meanIncomingScore === null ? '-' : `${entry.meanIncomingScore.toFixed(1)}/10`;
115 push(`| ${nameOf(profile.participantId)} | ${entry.round} | ${score} | ${entry.verdict} |`);
116 }
117 }
118 push();
119 }
120
121 const masquerade = buildMasqueradeReport(
122 result.participants,
123 result.rounds.flatMap((r) => r.critiques),
124 );
125 if (masquerade.verdict !== 'insufficient' && masquerade.hitRate !== null && masquerade.chanceRate !== null) {
126 push(`## Anonymity Check`);
127 push(
128 `Reviewers identified authors in ${Math.round(masquerade.hitRate * 100)}% of ${masquerade.guesses} guesses ` +
129 `vs ${Math.round(masquerade.chanceRate * 100)}% chance - ` +
130 (masquerade.verdict === 'holding' ? 'the masks held.' : 'writing style leaked authorship in this debate.'),
131 );
132 push();
133 }
134
135 push(`---`);
136 push(`_Generated by Roundtable._`);
137 return lines.join('\n');
138 }
139
140 function nameLookup(participants: Participant[]): (id: string) => string {
141 const map = new Map(participants.map((p) => [p.id, p.displayName]));
142 return (id: string) => map.get(id) ?? id;
143 }
144