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 | |