pgroast 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +173 -2
- package/bin/pgroast.mjs +19 -0
- package/data/precedents.sql.gz +0 -0
- package/docs/corpus.md +352 -0
- package/docs/design.md +540 -0
- package/docs/eval.md +245 -0
- package/docs/haas-paranoia-notes.md +223 -0
- package/invariants/README.md +15 -0
- package/invariants/nbtree.md +14 -0
- package/package.json +51 -4
- package/prompts/brief.md +32 -0
- package/prompts/committer-paranoia.md +149 -0
- package/prompts/critic.md +56 -0
- package/prompts/enrich.md +35 -0
- package/prompts/pushback-map.md +58 -0
- package/prompts/pushback-reduce.md +72 -0
- package/prompts/reflect.md +20 -0
- package/prompts/relevance.md +20 -0
- package/prompts/roaster-already-exists.md +23 -0
- package/prompts/roaster-common.md +88 -0
- package/prompts/roaster-demand.md +28 -0
- package/prompts/roaster-maintenance-fit.md +22 -0
- package/prompts/roaster-prior-attempts.md +29 -0
- package/prompts/roaster-replication-ondisk.md +24 -0
- package/prompts/roaster-security.md +21 -0
- package/prompts/roaster-standards-compat.md +21 -0
- package/prompts/supervisor.md +31 -0
- package/prompts/synth-self.md +19 -0
- package/prompts/synth.md +140 -0
- package/prompts/tone.md +21 -0
- package/prompts/writing.md +98 -0
- package/sql/schema.sql +45 -0
- package/src/cli.ts +391 -0
- package/src/corpus/cf.ts +220 -0
- package/src/corpus/cli.ts +189 -0
- package/src/corpus/db.ts +119 -0
- package/src/corpus/fetch.ts +104 -0
- package/src/corpus/reverts.ts +133 -0
- package/src/corpus/search.ts +201 -0
- package/src/corpus/util.ts +133 -0
- package/src/corpus/wiki.ts +80 -0
- package/src/eval/cases.ts +118 -0
- package/src/eval/cli.ts +105 -0
- package/src/eval/judge.ts +136 -0
- package/src/eval/report.ts +268 -0
- package/src/eval/run.ts +153 -0
- package/src/eval/score.ts +275 -0
- package/src/roast/doctor.ts +109 -0
- package/src/roast/enrich.ts +335 -0
- package/src/roast/invariants.ts +65 -0
- package/src/roast/llm.ts +601 -0
- package/src/roast/material.ts +133 -0
- package/src/roast/page.ts +266 -0
- package/src/roast/pipeline.ts +981 -0
- package/src/roast/precedents.ts +308 -0
- package/src/roast/pushback.ts +664 -0
- package/src/roast/render.ts +386 -0
- package/src/roast/sanitize.ts +140 -0
- package/src/roast/schema.ts +714 -0
- package/src/roast/tone.ts +216 -0
- package/src/thread/fetch.ts +172 -0
- package/src/thread/load.ts +227 -0
- package/src/thread/local.ts +73 -0
- package/src/thread/parse.ts +243 -0
- package/src/thread/patchprose.ts +143 -0
- package/src/thread/types.ts +120 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// roast.md is rendered from roast.json by code: same content, never a second LLM call.
|
|
2
|
+
|
|
3
|
+
import { LEVEL_NAMES, TOUCH_POINTS, type FinalObjection, type Roast, type SelfAdvice } from "./schema.ts";
|
|
4
|
+
|
|
5
|
+
/** First line of roast-summary.txt, verbatim from the issue. */
|
|
6
|
+
export const SUMMARY_HEADER = "DRAFT — rewrite in your own words before using; AI-generated";
|
|
7
|
+
|
|
8
|
+
const day = (iso: string | null) => (iso ? iso.slice(0, 10) : "date unknown");
|
|
9
|
+
const link = (text: string, url: string) => `[${text.replace(/([[\]])/g, "\\$1")}](${url.replace(/\)/g, "%29")})`;
|
|
10
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
11
|
+
const touchLabel = (id: string) => TOUCH_POINTS.find((t) => t.id === id)?.label ?? id.replace(/_/g, " ");
|
|
12
|
+
|
|
13
|
+
const ref = (text: string, url: string) => (url ? link(text, url) : text);
|
|
14
|
+
const roundNote = (o: FinalObjection) =>
|
|
15
|
+
`${o.round > 0 ? `, added in round ${o.round}` : ""}${o.revised_in_round != null ? `, restated in round ${o.revised_in_round}` : ""}`;
|
|
16
|
+
|
|
17
|
+
function objectionBlock(o: FinalObjection, n: number, advice?: SelfAdvice): string {
|
|
18
|
+
const lines: string[] = [];
|
|
19
|
+
lines.push(`### ${n}. ${oneLine(o.title)}`);
|
|
20
|
+
if (advice) {
|
|
21
|
+
lines.push("");
|
|
22
|
+
lines.push(`**Before you send: ${advice.action === "fix" ? "fix it" : "rebut it in the cover letter"}.** ${advice.how.trim()}`);
|
|
23
|
+
const cite = o.precedents.flatMap((p) => (p.thread_urls.length ? p.thread_urls : [p.url]).map((u) => link(oneLine(p.title), u)));
|
|
24
|
+
if (cite.length) lines.push("", `Threads to cite or to distinguish yourself from: ${cite.join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
lines.push("");
|
|
27
|
+
const sev = o.critic.original_severity ? `${o.severity} (first rated ${o.critic.original_severity}; changed in the counter-check)` : o.severity;
|
|
28
|
+
const touch = [...new Set([o.category, ...o.touch_points])].map(touchLabel).join(", ");
|
|
29
|
+
lines.push(`${sev} · level ${o.level}${o.rubric_item ? `, rubric item ${o.rubric_item}` : ""} · ${touch} · \`${o.id}\`${roundNote(o)}`);
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push(o.failure_scenario.trim());
|
|
32
|
+
lines.push("");
|
|
33
|
+
lines.push(o.argument.trim());
|
|
34
|
+
lines.push("");
|
|
35
|
+
if (o.precedents.length) {
|
|
36
|
+
lines.push("**Precedents.**");
|
|
37
|
+
for (const p of o.precedents) {
|
|
38
|
+
const threads = p.thread_urls.length ? `; thread: ${p.thread_urls.map((u, i) => link(p.thread_urls.length > 1 ? `#${i + 1}` : "link", u)).join(", ")}` : "";
|
|
39
|
+
lines.push(`- ${link(oneLine(p.title), p.url)} (${p.status || "outcome unknown"}, ${day(p.date)})${threads}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push("");
|
|
42
|
+
}
|
|
43
|
+
if (o.thread_refs.length) {
|
|
44
|
+
lines.push(`Thread: ${o.thread_refs.map((r) => ref(`#${r.index} (${r.from}, ${day(r.date)})`, r.url)).join(", ")}`);
|
|
45
|
+
lines.push("");
|
|
46
|
+
}
|
|
47
|
+
lines.push(`**Question:** ${o.author_must_show.trim()}`);
|
|
48
|
+
lines.push("");
|
|
49
|
+
lines.push(`Counter-check: held. ${o.critic.reasoning.trim()}`);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verdictSection(r: Roast, heading: string): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
out.push(heading);
|
|
56
|
+
out.push("");
|
|
57
|
+
out.push(`**${r.verdict}** (confidence: ${r.confidence})`);
|
|
58
|
+
out.push("");
|
|
59
|
+
out.push(r.verdict_reasoning.trim());
|
|
60
|
+
out.push("");
|
|
61
|
+
out.push(
|
|
62
|
+
r.failing_level
|
|
63
|
+
? `First failing level, from the base up: **${LEVEL_NAMES[r.failing_level]}**. The review stops there; higher levels are not evaluated.`
|
|
64
|
+
: "No level of the review pyramid fails.",
|
|
65
|
+
);
|
|
66
|
+
out.push("");
|
|
67
|
+
for (const l of r.levels) out.push(`- ${l.name}: ${l.status === "not_evaluated" ? "not evaluated" : l.status}`);
|
|
68
|
+
out.push("- Levels 3-5 (implementation): out of scope");
|
|
69
|
+
if (r.provisional_note.trim()) out.push("", `Provisional: ${r.provisional_note.trim()}`);
|
|
70
|
+
out.push("");
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function prioritySection(r: Roast): string[] {
|
|
75
|
+
const p = r.priority;
|
|
76
|
+
return [
|
|
77
|
+
"## Priority",
|
|
78
|
+
"",
|
|
79
|
+
"Whether it is worth the reviewers' time now. This is apart from the verdict and never changes it.",
|
|
80
|
+
"",
|
|
81
|
+
`- **Recommended action:** ${p.recommended_action}`,
|
|
82
|
+
`- **Audience:** ${p.audience}`,
|
|
83
|
+
`- **Maintenance burden:** ${p.maintenance_burden}`,
|
|
84
|
+
`- **Review cost:** ${p.review_cost}`,
|
|
85
|
+
`- **Why:** ${oneLine(p.reason)}`,
|
|
86
|
+
"",
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renderMarkdown(r: Roast): string {
|
|
91
|
+
const out: string[] = [];
|
|
92
|
+
const self = r.mode === "self";
|
|
93
|
+
const title = r.input.cf?.title ?? r.brief.title ?? r.input.subject;
|
|
94
|
+
out.push(`# Design pre-review${self ? " (self check)" : ""}: ${oneLine(title)}`);
|
|
95
|
+
out.push("");
|
|
96
|
+
out.push(`> ${r.disclosure}`);
|
|
97
|
+
out.push("");
|
|
98
|
+
if (self) {
|
|
99
|
+
out.push("This is a private check of your own proposal before you send it. It lists the objections you are most likely to meet, and for each one whether to fix the proposal or to answer the objection in the cover letter. The check is one-sided on purpose: it looks only for objections.", "");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Input
|
|
103
|
+
const src: string[] = [];
|
|
104
|
+
if (r.input.cf) {
|
|
105
|
+
src.push(`- CommitFest: ${link(`patch ${r.input.cf.id}`, r.input.cf.url)}${r.input.cf.status ? ` (${r.input.cf.status})` : r.input.cf.redacted ? " (status withheld)" : ""}; authors: ${r.input.cf.authors || "not given"}`);
|
|
106
|
+
}
|
|
107
|
+
if (r.input.thread_url) src.push(`- Thread: ${link(oneLine(r.input.subject), r.input.thread_url)}, started ${day(r.input.thread_start)}`);
|
|
108
|
+
else src.push(`- Draft, not sent: ${r.input.arg} ("${oneLine(r.input.subject)}")`);
|
|
109
|
+
const mode = [r.input.blind != null ? `blind mode, first ${r.input.blind}` : "", r.input.before ? `before ${r.input.before}` : ""].filter(Boolean).join("; ");
|
|
110
|
+
if (r.input.thread_url) src.push(`- Messages read: ${r.input.messages_read} of ${r.input.messages_total}${mode ? ` (${mode})` : ""}`);
|
|
111
|
+
src.push(`- Patch prose read: ${r.input.attachments_read.length ? r.input.attachments_read.join(", ") : "none"} (commit message, file list and doc hunks only; no code review)`);
|
|
112
|
+
if (r.input.hint) src.push(`- Directed precedent lookup: "${oneLine(r.input.hint)}"`);
|
|
113
|
+
if (r.invariants_loaded.length) src.push(`- Subsystem invariants checked: ${r.invariants_loaded.join(", ")}`);
|
|
114
|
+
out.push(...src, "");
|
|
115
|
+
|
|
116
|
+
// 1. Verdict (in self mode it moves down: the author needs the to-do list first)
|
|
117
|
+
if (!self) out.push(...verdictSection(r, "## 1. Verdict"));
|
|
118
|
+
|
|
119
|
+
// The proposal
|
|
120
|
+
out.push("## The proposal, restated");
|
|
121
|
+
out.push("");
|
|
122
|
+
out.push(`**Problem.** ${r.brief.problem.trim()}`);
|
|
123
|
+
out.push("");
|
|
124
|
+
out.push(`**Proposed design.** ${r.brief.proposed_design.trim()}`);
|
|
125
|
+
out.push("");
|
|
126
|
+
if (r.brief.user_visible_surface.length) {
|
|
127
|
+
out.push("**User-visible surface.**");
|
|
128
|
+
out.push(...r.brief.user_visible_surface.map((s) => `- ${oneLine(s)}`), "");
|
|
129
|
+
}
|
|
130
|
+
if (r.brief.author_claims.length) {
|
|
131
|
+
out.push("**What the author claims.**");
|
|
132
|
+
out.push(...r.brief.author_claims.map((s) => `- ${oneLine(s)}`), "");
|
|
133
|
+
}
|
|
134
|
+
if (r.brief.thread_state.trim()) out.push(`**Thread state.** ${r.brief.thread_state.trim()}`, "");
|
|
135
|
+
|
|
136
|
+
// 2. Prior attempts
|
|
137
|
+
out.push("## 2. Prior attempts");
|
|
138
|
+
out.push("");
|
|
139
|
+
if (r.precedent_backend === "none") {
|
|
140
|
+
out.push("The precedent corpus was not available in this run, so earlier attempts were NOT searched. This is not a finding of \"no precedent\".");
|
|
141
|
+
} else if (r.prior_attempts.length === 0) {
|
|
142
|
+
out.push(`No precedent found (corpus searched for items before ${r.precedent_cutoff}; ${r.precedents_retrieved.length} candidates retrieved, none is the same or a similar idea).`);
|
|
143
|
+
} else {
|
|
144
|
+
for (const p of r.prior_attempts) {
|
|
145
|
+
const rel = p.relation === "same_idea" ? "same idea" : p.relation === "similar_idea" ? "similar idea" : "related";
|
|
146
|
+
out.push(`### ${oneLine(p.title)} (${rel})`);
|
|
147
|
+
out.push("");
|
|
148
|
+
out.push(`- **Link:** ${link(p.url, p.url)}`);
|
|
149
|
+
if (p.thread_urls.length) out.push(`- **Thread:** ${p.thread_urls.map((u) => link(u, u)).join(", ")}`);
|
|
150
|
+
out.push(`- **Date:** ${p.date ? `decided ${day(p.date)}` : "decision date unknown"}${p.first_date ? `; started ${day(p.first_date)}` : ""}`);
|
|
151
|
+
out.push(`- **Outcome:** ${p.status || "unknown"} (${p.kind || "?"})`);
|
|
152
|
+
out.push(`- **Who objected:** ${p.who_objected.trim() || "not recorded in the corpus"}${p.who_objected.trim() && p.enriched ? " (senders of the linked thread, picked by the AI summary)" : ""}`);
|
|
153
|
+
if (p.reason.trim()) out.push(`- **Why it was not accepted${p.enriched ? " (AI summary of the linked thread; check it there)" : " (as recorded in the corpus)"}:** ${oneLine(p.reason)}`);
|
|
154
|
+
out.push(`- **Reason (quote):** ${p.quote ? `"${oneLine(p.quote)}"${p.quote_url ? ` (${link("message", p.quote_url)})` : ""}` : "quote unavailable"}`);
|
|
155
|
+
out.push(`- **What is different this time:** ${oneLine(p.what_is_different)}`);
|
|
156
|
+
out.push("");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
out.push("");
|
|
160
|
+
|
|
161
|
+
// 3. Steelman
|
|
162
|
+
const adviceFor = (o: FinalObjection) => r.self_advice.find((a) => a.objection_id === o.id);
|
|
163
|
+
out.push(self ? "## 3. Objections to address before you send" : "## 3. Objections a reviewer is likely to raise");
|
|
164
|
+
out.push("");
|
|
165
|
+
if (r.objections.length === 0) {
|
|
166
|
+
out.push("No objection held up strongly enough in the counter-check to list here.");
|
|
167
|
+
out.push("");
|
|
168
|
+
} else {
|
|
169
|
+
r.objections.forEach((o, i) => out.push(objectionBlock(o, i + 1, self ? adviceFor(o) : undefined), ""));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 4. Touch points
|
|
173
|
+
out.push("## 4. Touch points");
|
|
174
|
+
out.push("");
|
|
175
|
+
out.push("| Touch point | Hit | Why |");
|
|
176
|
+
out.push("|---|---|---|");
|
|
177
|
+
for (const t of r.touch_points) out.push(`| ${t.label} | ${t.hit ? "**yes**" : "no"} | ${t.hit ? oneLine(t.reason).replace(/\|/g, "\\|") : ""} |`);
|
|
178
|
+
out.push("");
|
|
179
|
+
|
|
180
|
+
// 5. Questions
|
|
181
|
+
out.push(self ? "## 5. Questions to answer in your cover letter" : "## 5. Questions the author must answer before anyone reviews code");
|
|
182
|
+
out.push("");
|
|
183
|
+
if (r.questions.length === 0) out.push("None.");
|
|
184
|
+
else r.questions.forEach((q, i) => out.push(`${i + 1}. ${oneLine(q)}`));
|
|
185
|
+
out.push("");
|
|
186
|
+
|
|
187
|
+
// Committability
|
|
188
|
+
out.push("## Committability");
|
|
189
|
+
out.push("");
|
|
190
|
+
out.push('"Will not get committed" is not the same as "should be rejected". The verdict above is about the merits (levels 1-2) only; this section is about who would sign for it, and it never changes the verdict.');
|
|
191
|
+
out.push("");
|
|
192
|
+
out.push(`- **Owner:** ${r.committability.owner}${r.committability.owner_note.trim() ? `. ${oneLine(r.committability.owner_note)}` : ""}`);
|
|
193
|
+
out.push(`- **Liability:** ${oneLine(r.committability.liability)}`);
|
|
194
|
+
out.push(`- **Author follow-up:** ${oneLine(r.committability.author_follow_up)}`);
|
|
195
|
+
out.push("");
|
|
196
|
+
out.push(
|
|
197
|
+
r.not_now.applies
|
|
198
|
+
? `**Not now / show demand: yes.** This is an outcome, not a rejection. Evidence that would change it: ${oneLine(r.not_now.evidence_that_would_change_it) || "(not stated)"}`
|
|
199
|
+
: "**Not now / show demand:** does not apply.",
|
|
200
|
+
);
|
|
201
|
+
out.push("");
|
|
202
|
+
|
|
203
|
+
out.push(...prioritySection(r));
|
|
204
|
+
if (self) out.push(...verdictSection(r, "## How a reviewer would likely classify it"));
|
|
205
|
+
|
|
206
|
+
// Appendix
|
|
207
|
+
out.push("## Appendix");
|
|
208
|
+
out.push("");
|
|
209
|
+
if (r.other_surviving.length) {
|
|
210
|
+
out.push("### Held up in the counter-check, not listed above");
|
|
211
|
+
out.push("");
|
|
212
|
+
for (const o of r.other_surviving) {
|
|
213
|
+
out.push(`- \`${o.id}\` (${o.severity}, level ${o.level}${roundNote(o)}) ${oneLine(o.title)}. Not listed: ${o.not_ranked_reason ?? "not ranked"}.`);
|
|
214
|
+
const a = self ? adviceFor(o) : undefined;
|
|
215
|
+
if (a) out.push(` - Before you send: ${a.action === "fix" ? "fix it" : "rebut it in the cover letter"}. ${oneLine(a.how)}`);
|
|
216
|
+
}
|
|
217
|
+
out.push("");
|
|
218
|
+
}
|
|
219
|
+
out.push("### Refuted in the counter-check");
|
|
220
|
+
out.push("");
|
|
221
|
+
if (r.refuted.length === 0) out.push("None.");
|
|
222
|
+
for (const o of r.refuted) {
|
|
223
|
+
out.push(`- \`${o.id}\` ${oneLine(o.title)}. Refuted (${o.critic.refutation_kind.replace(/_/g, " ")}): ${oneLine(o.critic.reasoning)}`);
|
|
224
|
+
}
|
|
225
|
+
out.push("");
|
|
226
|
+
if (r.withdrawn.length) {
|
|
227
|
+
out.push("### Withdrawn in a later round");
|
|
228
|
+
out.push("");
|
|
229
|
+
for (const o of r.withdrawn) out.push(`- \`${o.id}\` ${oneLine(o.title)}. Withdrawn in round ${o.withdrawal?.round ?? "?"}: ${oneLine(o.withdrawal?.reason ?? "")}`);
|
|
230
|
+
out.push("");
|
|
231
|
+
}
|
|
232
|
+
if (r.rounds_requested > 0) {
|
|
233
|
+
out.push("### Rounds");
|
|
234
|
+
out.push("");
|
|
235
|
+
out.push("| Round | Kind | LLM calls | New objections | Of them survived | Revised | Withdrawn |");
|
|
236
|
+
out.push("|---|---|---|---|---|---|---|");
|
|
237
|
+
const kind = { initial: "first pass", what_did_you_forget: `"${"What did you forget?"}"`, are_you_happy: `"${"Are you happy with the results?"}"`, supervisor: "supervisor follow-ups" } as const;
|
|
238
|
+
for (const x of r.rounds) out.push(`| ${x.round} | ${kind[x.kind]} | ${x.llm_calls} | ${x.new_objections} | ${x.new_survived} | ${x.revised} | ${x.withdrawn} |`);
|
|
239
|
+
out.push("");
|
|
240
|
+
for (const x of r.rounds) for (const f of x.follow_ups) out.push(`- Round ${x.round} follow-up for ${f.angle}: ${oneLine(f.uncovered_area)}`);
|
|
241
|
+
if (r.rounds.length - 1 < r.rounds_requested) out.push(`- ${r.rounds_requested} extra round(s) requested, ${r.rounds.length - 1} run.`);
|
|
242
|
+
out.push("");
|
|
243
|
+
}
|
|
244
|
+
out.push("### Review angles");
|
|
245
|
+
out.push("");
|
|
246
|
+
for (const n of r.roaster_notes) out.push(`- ${n.angle}: ${n.objections} objection(s)${n.note.trim() ? `. ${oneLine(n.note)}` : ""}`);
|
|
247
|
+
out.push("");
|
|
248
|
+
out.push("### Precedents retrieved");
|
|
249
|
+
out.push("");
|
|
250
|
+
if (r.precedents_retrieved.length === 0) out.push(r.precedent_backend === "none" ? "Search not done." : "None.");
|
|
251
|
+
for (const p of r.precedents_retrieved) {
|
|
252
|
+
const flags = [p.relevant === true ? "used in the review" : p.relevant === false ? "judged not relevant" : "", p.enriched ? "reason summarised from its thread" : ""].filter(Boolean).join("; ");
|
|
253
|
+
out.push(`- ${p.id}: ${link(oneLine(p.title), p.url)} (${p.kind}, ${p.status || "?"}, ${day(p.date ?? p.first_date)})${flags ? ` - ${flags}` : ""}`);
|
|
254
|
+
}
|
|
255
|
+
out.push("");
|
|
256
|
+
if (r.warnings.length) {
|
|
257
|
+
out.push("### Warnings");
|
|
258
|
+
out.push("");
|
|
259
|
+
out.push(...r.warnings.map((w) => `- ${oneLine(w)}`), "");
|
|
260
|
+
}
|
|
261
|
+
const s = r.sanitizer;
|
|
262
|
+
out.push("### Run");
|
|
263
|
+
out.push("");
|
|
264
|
+
out.push(`- Backend: ${r.backend} CLI on the subscription login (no API key); models: review angles ${r.models.roaster}, counter-check ${r.models.critic}, synthesis ${r.models.synth}; no tools given to any of them`);
|
|
265
|
+
out.push(`- LLM calls: ${r.stats.llm_calls}; wall time: ${Math.round(r.stats.wall_ms / 1000)} s${r.stats.cost_usd != null && !r.stats.dry_run ? `; cost reported by the CLI: $${r.stats.cost_usd.toFixed(2)}` : ""}${r.stats.dry_run ? "; DRY RUN (no model was called; content is placeholder)" : ""}`);
|
|
266
|
+
if (r.enrichment) {
|
|
267
|
+
const e = r.enrichment;
|
|
268
|
+
out.push(`- Precedent enrichment: ${e.candidates} thread(s) lacked a recorded reason: ${e.from_cache} from cache, ${e.new_calls} summarised now, ${e.failed} failed; quotes: ${e.quotes_verified} verified, ${e.quotes_dropped} dropped; relevance pass: ${e.relevance_pass}, ${e.relevant} of ${r.precedents_retrieved.length} kept`);
|
|
269
|
+
}
|
|
270
|
+
const t = r.tone_lint;
|
|
271
|
+
out.push(`- Tone check: ${t.fields_checked} text field(s) checked, ${t.hits_found} finding(s), ${t.sentences_rewritten} sentence(s) rewritten (repair call: ${t.repair_call}), ${t.remaining.length} left`);
|
|
272
|
+
for (const h of t.remaining) out.push(` - \`${h.path}\`: ${h.rule}${h.match ? ` ("${h.match}")` : ""}`);
|
|
273
|
+
out.push(`- Sanitizer: ${s.urls_removed.length} link(s) removed, ${s.precedent_ids_dropped.length} unknown precedent id(s) dropped, ${s.thread_refs_dropped.length} bad message reference(s) dropped, ${s.quotes_dropped.length} unverifiable quote(s) dropped`);
|
|
274
|
+
out.push(`- Generated: ${r.generated_at} by ${r.tool}`);
|
|
275
|
+
out.push("");
|
|
276
|
+
out.push("---");
|
|
277
|
+
out.push("");
|
|
278
|
+
out.push(r.disclosure);
|
|
279
|
+
out.push("");
|
|
280
|
+
return out.join("\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* roast.md: the short report. Verdict, prior attempts, the listed objections, the questions and
|
|
285
|
+
* the touch points that are hit. Everything else is in roast-full.md (renderMarkdown).
|
|
286
|
+
*/
|
|
287
|
+
export function renderCompactMarkdown(r: Roast, fullName = "roast-full.md"): string {
|
|
288
|
+
const out: string[] = [];
|
|
289
|
+
const self = r.mode === "self";
|
|
290
|
+
const title = r.input.cf?.title ?? r.brief.title ?? r.input.subject;
|
|
291
|
+
out.push(`# Design pre-review${self ? " (self check)" : ""}: ${oneLine(title)}`, "", `> ${r.disclosure}`, "");
|
|
292
|
+
|
|
293
|
+
const mode = [r.input.blind != null ? `first ${r.input.blind} only` : "", r.input.before ? `before ${r.input.before}` : ""].filter(Boolean).join(", ");
|
|
294
|
+
const src: string[] = [];
|
|
295
|
+
if (r.input.cf) src.push(link(`CommitFest ${r.input.cf.id}`, r.input.cf.url));
|
|
296
|
+
if (r.input.thread_url) src.push(`${link("thread", r.input.thread_url)}, ${r.input.messages_read} of ${r.input.messages_total} messages read${mode ? ` (${mode})` : ""}`);
|
|
297
|
+
else src.push(`draft ${r.input.arg}`);
|
|
298
|
+
src.push("no code read");
|
|
299
|
+
out.push(`${src.join("; ")}.`, "");
|
|
300
|
+
|
|
301
|
+
const verdict = [
|
|
302
|
+
`**${r.verdict}** (confidence: ${r.confidence}). ${r.verdict_reasoning.trim()}`,
|
|
303
|
+
...(r.provisional_note.trim() ? ["", `Not checked: ${r.provisional_note.trim()}`] : []),
|
|
304
|
+
"",
|
|
305
|
+
];
|
|
306
|
+
if (!self) out.push("## Verdict", "", ...verdict);
|
|
307
|
+
|
|
308
|
+
out.push("## Prior attempts", "");
|
|
309
|
+
if (r.precedent_backend === "none") out.push("Not searched: the precedent index was not available in this run.");
|
|
310
|
+
else if (r.prior_attempts.length === 0) out.push(`None found before ${r.precedent_cutoff}.`);
|
|
311
|
+
for (const p of r.prior_attempts) {
|
|
312
|
+
const rel = p.relation === "same_idea" ? "same idea" : p.relation === "similar_idea" ? "similar idea" : "related";
|
|
313
|
+
const why = p.quote ? `"${oneLine(p.quote)}"${p.quote_url ? ` (${link("message", p.quote_url)})` : ""}` : p.reason.trim() ? `${oneLine(p.reason)}${p.enriched ? " (AI summary of the thread)" : ""}` : "reason not recorded";
|
|
314
|
+
out.push(`- ${link(oneLine(p.title), p.thread_urls[0] ?? p.url)} (${rel}; ${p.status || "outcome unknown"}, ${day(p.date ?? p.first_date)}). ${why} Different now: ${oneLine(p.what_is_different)}`);
|
|
315
|
+
}
|
|
316
|
+
out.push("");
|
|
317
|
+
|
|
318
|
+
const adviceFor = (o: FinalObjection) => r.self_advice.find((a) => a.objection_id === o.id);
|
|
319
|
+
out.push(self ? "## Objections to address before you send" : "## Objections a reviewer is likely to raise", "");
|
|
320
|
+
if (r.objections.length === 0) out.push("No objection held up in the counter-check.", "");
|
|
321
|
+
r.objections.forEach((o, i) => {
|
|
322
|
+
const a = self ? adviceFor(o) : undefined;
|
|
323
|
+
out.push(`### ${i + 1}. ${oneLine(o.title)}`, "");
|
|
324
|
+
if (a) out.push(`**Before you send: ${a.action === "fix" ? "fix it" : "rebut it in the cover letter"}.** ${a.how.trim()}`, "");
|
|
325
|
+
out.push(o.failure_scenario.trim(), "", o.argument.trim(), "");
|
|
326
|
+
for (const p of o.precedents) out.push(`Precedent: ${link(oneLine(p.title), p.thread_urls[0] ?? p.url)} (${p.status || "outcome unknown"}, ${day(p.date)})`, "");
|
|
327
|
+
out.push(`**Question:** ${o.author_must_show.trim()}`, "");
|
|
328
|
+
const refs = o.thread_refs.map((t) => ref(`#${t.index}`, t.url)).join(", ");
|
|
329
|
+
out.push(`<sub>${o.severity}, level ${o.level}, ${touchLabel(o.category)}${refs ? `; messages ${refs}` : ""}; \`${o.id}\`</sub>`, "");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
out.push(self ? "## Questions to answer in your cover letter" : "## Questions for the author", "");
|
|
333
|
+
if (r.questions.length === 0) out.push("None.");
|
|
334
|
+
else r.questions.forEach((q, i) => out.push(`${i + 1}. ${oneLine(q)}`));
|
|
335
|
+
out.push("");
|
|
336
|
+
|
|
337
|
+
const hit = r.touch_points.filter((t) => t.hit);
|
|
338
|
+
out.push("## Touch points", "");
|
|
339
|
+
if (hit.length === 0) out.push("None of the 15 is hit.");
|
|
340
|
+
for (const t of hit) out.push(`- **${t.label}:** ${oneLine(t.reason)}`);
|
|
341
|
+
out.push("");
|
|
342
|
+
|
|
343
|
+
out.push("## Who would commit it, and when", "");
|
|
344
|
+
out.push(`- Owner: ${r.committability.owner}${r.committability.owner_note.trim() ? `. ${oneLine(r.committability.owner_note)}` : ""}`);
|
|
345
|
+
if (r.not_now.applies) out.push(`- Not now, show demand first: ${oneLine(r.not_now.evidence_that_would_change_it) || "(evidence not stated)"}`);
|
|
346
|
+
out.push(`- Priority: ${r.priority.recommended_action}; audience ${r.priority.audience}; review cost ${r.priority.review_cost}. ${oneLine(r.priority.reason)}`, "");
|
|
347
|
+
if (self) out.push("## How a reviewer would likely classify it", "", ...verdict);
|
|
348
|
+
|
|
349
|
+
const more = [`${r.other_surviving.length} more objection(s) that held`, `${r.refuted.length} refuted`, "the proposal restated", "all 15 touch points", "run details"];
|
|
350
|
+
out.push("---", "", `${link(fullName, fullName)} has ${more.join(", ")}.${r.warnings.length ? ` Warnings in this run: ${r.warnings.length}.` : ""}${r.stats.dry_run ? " DRY RUN: no model was called; content is placeholder." : ""}`, "", r.disclosure, "");
|
|
351
|
+
return out.join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const clip = (s: string, n: number) => {
|
|
355
|
+
const t = oneLine(s);
|
|
356
|
+
return t.length <= n ? t : t.slice(0, n - 3).replace(/\s+\S*$/, "") + "...";
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* roast-summary.txt: at most 10 plain-text lines, built by code from the roast (no model call).
|
|
361
|
+
* A starting point for a person's own words, never something to paste: hence the first line.
|
|
362
|
+
*/
|
|
363
|
+
export function renderSummary(r: Roast): string {
|
|
364
|
+
const lines: string[] = [SUMMARY_HEADER];
|
|
365
|
+
const self = r.mode === "self";
|
|
366
|
+
const stop = (t: string) => (/[.?!]$/.test(t) ? t : `${t}.`);
|
|
367
|
+
lines.push(`${clip(r.input.cf?.title ?? r.brief.title ?? r.input.subject, 120)}${self ? " (self check)" : ""}: ${r.verdict}, confidence ${r.confidence}. Idea and design only; no code was read.`);
|
|
368
|
+
const top = r.objections.slice(0, 3);
|
|
369
|
+
if (top.length === 0) lines.push("No objection held up in the counter-check.");
|
|
370
|
+
top.forEach((o, i) => {
|
|
371
|
+
const a = self ? r.self_advice.find((x) => x.objection_id === o.id) : undefined;
|
|
372
|
+
lines.push(`${i + 1}. ${stop(clip(o.title, 140))}${o.severity === "fatal" ? " This one would stop the proposal as it is." : ""}${a ? ` (${a.action === "fix" ? "fix before sending" : "rebut in the cover letter"})` : ""}`);
|
|
373
|
+
});
|
|
374
|
+
const pa = r.prior_attempts[0];
|
|
375
|
+
lines.push(
|
|
376
|
+
r.precedent_backend === "none"
|
|
377
|
+
? "Earlier attempts: not searched."
|
|
378
|
+
: pa
|
|
379
|
+
? `Earlier attempt: ${clip(pa.title, 90)} (${pa.status || "outcome unknown"}, ${day(pa.date ?? pa.first_date)}): ${pa.url}`
|
|
380
|
+
: "Earlier attempts: none found.",
|
|
381
|
+
);
|
|
382
|
+
if (r.questions[0]) lines.push(`First question: ${clip(r.questions[0], 220)}`);
|
|
383
|
+
if (r.not_now.applies) lines.push(`Not now, show demand first: ${clip(r.not_now.evidence_that_would_change_it, 170)}`);
|
|
384
|
+
lines.push(`Suggested next step: ${r.priority.recommended_action}.`);
|
|
385
|
+
return lines.slice(0, 10).join("\n") + "\n";
|
|
386
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Anti-hallucination guards, applied by code to everything the model returns.
|
|
2
|
+
//
|
|
3
|
+
// - A URL may appear in the output only if it is in the allowed set: the thread itself, links
|
|
4
|
+
// written inside the visible thread, the CommitFest entry, and links of retrieved precedents.
|
|
5
|
+
// Any other URL is cut out and reported.
|
|
6
|
+
// - Precedents are cited by run-local id (P1, P2, ...). Unknown ids are dropped. The links shown
|
|
7
|
+
// for a precedent are copied from the corpus row by code, never taken from model text.
|
|
8
|
+
// - Thread messages are cited by index. Unknown indexes are dropped.
|
|
9
|
+
// - A quote is kept only if it is found verbatim (modulo whitespace) in the corpus row or the thread.
|
|
10
|
+
|
|
11
|
+
import type { Precedent, SanitizerReport } from "./schema.ts";
|
|
12
|
+
|
|
13
|
+
export const REMOVED_LINK = "[link removed: not in the retrieved set]";
|
|
14
|
+
|
|
15
|
+
const URL_RE = /\bhttps?:\/\/[^\s<>"'`\])}]+/gi;
|
|
16
|
+
/** Scheme-less references to the places a fabricated precedent would point at. */
|
|
17
|
+
const BARE_RE =
|
|
18
|
+
/(?<![\w/.@-])(?:www\.)?(?:postgr\.es\/m|postgresql\.org\/message-id|commitfest\.postgresql\.org|git\.postgresql\.org|wiki\.postgresql\.org|github\.com|youtu\.be|youtube\.com)\/[^\s<>"'`\])}]+/gi;
|
|
19
|
+
|
|
20
|
+
export function normaliseUrl(u: string): string {
|
|
21
|
+
let s = u.trim().replace(/[.,;:!?]+$/, "");
|
|
22
|
+
s = s.replace(/^(?!https?:\/\/)/i, "https://");
|
|
23
|
+
s = s.replace(/^http:\/\//i, "https://").replace(/^https:\/\/www\./i, "https://");
|
|
24
|
+
try {
|
|
25
|
+
s = decodeURIComponent(s);
|
|
26
|
+
} catch {
|
|
27
|
+
// keep as is
|
|
28
|
+
}
|
|
29
|
+
s = s.replace(/#.*$/, (h) => (/#[A-Za-z]/.test(h) ? h : ""));
|
|
30
|
+
// postgr.es/m/X and postgresql.org/message-id/[flat/]X name the same thing.
|
|
31
|
+
s = s.replace(/^https:\/\/postgr\.es\/m\//i, "https://postgresql.org/message-id/");
|
|
32
|
+
s = s.replace(/^(https:\/\/postgresql\.org\/message-id\/)flat\//i, "$1");
|
|
33
|
+
const m = s.match(/^(https:\/\/)([^/]+)(.*)$/);
|
|
34
|
+
if (m) s = m[1]! + m[2]!.toLowerCase() + m[3]!;
|
|
35
|
+
return s.replace(/\/+$/, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function extractUrls(text: string): string[] {
|
|
39
|
+
return [...(text.match(URL_RE) ?? []), ...(text.match(BARE_RE) ?? [])];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class AllowedUrls {
|
|
43
|
+
private readonly set = new Set<string>();
|
|
44
|
+
add(url: string | null | undefined): void {
|
|
45
|
+
if (url) this.set.add(normaliseUrl(url));
|
|
46
|
+
}
|
|
47
|
+
addFromText(text: string): void {
|
|
48
|
+
for (const u of extractUrls(text)) this.add(u);
|
|
49
|
+
}
|
|
50
|
+
has(url: string): boolean {
|
|
51
|
+
return this.set.has(normaliseUrl(url));
|
|
52
|
+
}
|
|
53
|
+
get size(): number {
|
|
54
|
+
return this.set.size;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function emptyReport(): SanitizerReport {
|
|
59
|
+
return { urls_removed: [], precedent_ids_dropped: [], thread_refs_dropped: [], quotes_dropped: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Replace every URL that is not allowed, in every string of a JSON value. Returns a copy. */
|
|
63
|
+
export function scrubUrls<T>(value: T, allowed: AllowedUrls, stage: string, report: SanitizerReport): T {
|
|
64
|
+
const scrub = (s: string): string => {
|
|
65
|
+
const cut = (u: string): string => {
|
|
66
|
+
const trail = u.match(/[.,;:!?]+$/)?.[0] ?? "";
|
|
67
|
+
const core = trail ? u.slice(0, -trail.length) : u;
|
|
68
|
+
if (allowed.has(core)) return u;
|
|
69
|
+
report.urls_removed.push({ stage, url: core });
|
|
70
|
+
return REMOVED_LINK + trail;
|
|
71
|
+
};
|
|
72
|
+
return s.replace(URL_RE, cut).replace(BARE_RE, cut);
|
|
73
|
+
};
|
|
74
|
+
const walk = (v: unknown): unknown => {
|
|
75
|
+
if (typeof v === "string") return scrub(v);
|
|
76
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
77
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
|
|
78
|
+
return v;
|
|
79
|
+
};
|
|
80
|
+
return walk(value) as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Keep only ids that are in the retrieved list. Accepts "P3", "[P3]", "p3". */
|
|
84
|
+
export function filterPrecedentIds(ids: string[], known: Map<string, Precedent>, stage: string, report: SanitizerReport): string[] {
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
for (const raw of ids) {
|
|
87
|
+
const id = String(raw).trim().replace(/^\[|\]$/g, "").toUpperCase();
|
|
88
|
+
if (known.has(id)) {
|
|
89
|
+
if (!out.includes(id)) out.push(id);
|
|
90
|
+
} else {
|
|
91
|
+
report.precedent_ids_dropped.push({ stage, id: String(raw) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function filterThreadRefs(refs: number[], visible: Set<number>, stage: string, report: SanitizerReport): number[] {
|
|
98
|
+
const out: number[] = [];
|
|
99
|
+
for (const r of refs) {
|
|
100
|
+
if (visible.has(r)) {
|
|
101
|
+
if (!out.includes(r)) out.push(r);
|
|
102
|
+
} else {
|
|
103
|
+
report.thread_refs_dropped.push({ stage, index: r });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fold whitespace, quote styles and case so a quote can be matched against its source. */
|
|
110
|
+
export function foldForQuote(s: string): string {
|
|
111
|
+
return s
|
|
112
|
+
.replace(/[‘’‛]/g, "'")
|
|
113
|
+
.replace(/[“”]/g, '"')
|
|
114
|
+
.replace(/[–—]/g, "-")
|
|
115
|
+
.replace(/^\s*>+\s?/gm, "")
|
|
116
|
+
.replace(/\s+/g, " ")
|
|
117
|
+
.trim()
|
|
118
|
+
.toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const MIN_QUOTE_CHARS = 20;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return the quote if it is found verbatim in one of the sources, else null.
|
|
125
|
+
* An ellipsis ("..." or "[...]") splits the quote; every part must be found.
|
|
126
|
+
*/
|
|
127
|
+
export function verifyQuote(quote: string | null | undefined, sources: string[]): string | null {
|
|
128
|
+
if (!quote) return null;
|
|
129
|
+
const q = quote.trim().replace(/^["'“]+|["'”]+$/g, "");
|
|
130
|
+
if (foldForQuote(q).length < MIN_QUOTE_CHARS) return null;
|
|
131
|
+
const parts = q
|
|
132
|
+
.split(/\[\.\.\.\]|\.\.\.|…/)
|
|
133
|
+
.map(foldForQuote)
|
|
134
|
+
.map((p) => p.replace(/^[.,;:]+|[.,;:]+$/g, "").trim())
|
|
135
|
+
.filter((p) => p.length > 0);
|
|
136
|
+
if (parts.length === 0) return null;
|
|
137
|
+
const folded = sources.map(foldForQuote);
|
|
138
|
+
const ok = folded.some((src) => parts.every((p) => p.length >= 8 && src.includes(p)));
|
|
139
|
+
return ok ? q : null;
|
|
140
|
+
}
|