Files
site-builder/craft/src/panels/sitesmith/selfReview.ts
T
shadowdaoandClaude Opus 5.5 52b453eecc fix(sitesmith): honest self-review wording when changes were dropped or not applied
Live 2026.11.8: the server's review lockdown stripped every op, and the editor said
"made no changes. Sitesmith said: Image band narrowed..." (a fix that never happened). With
the backend's review_ops {proposed, kept}:
- all dropped -> new status "dropped": "Sitesmith proposed N changes, but none passed the
  safety checks, so nothing on your page was changed. What it proposed (not applied): ..."
- some dropped -> the "made fixes" note says how many were dropped and labels the quote as
  possibly describing them;
- not-applied / claimed-no-ops quotes are labelled as not applied instead of "Sitesmith said".
"Sitesmith said" is kept only where the quote describes what actually happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:33:02 -08:00

165 lines
11 KiB
TypeScript

import type { SendResult } from '../../types/sitesmith';
import type { CaptureResult } from '../../utils/self-review-capture';
/**
* Instruction for the one follow-up job. It is sent with intent 'review' and the render as
* review_image_ids, so the backend adds its SELF-REVIEW block (critique + patch) -- the render is
* never treated as a mockup. This text is what the user sees in the history.
*/
export const REVIEW_MESSAGE =
'Review the attached screenshot of my page as it is built now (it is a render of the current page, not a mockup to copy). ' +
'Fix only real problems with a small patch: hard-to-read text, cramped or uneven spacing, broken or misaligned layout, badly cropped images, missing icons. ' +
'If it already looks right, reply with a patch that has no ops and say so. Do not redesign the page or change the wording.';
export interface SelfReviewDeps {
/**
* Null when the canvas shows the result that was just applied; otherwise why it does not. Checked
* immediately before the page is composed, in the same tick, so the screenshot is of that canvas.
*/
canvasMismatch?: () => string | null;
/** Published-style HTML of the current page (untrusted; the capture sanitises it). */
composeHtml: () => string;
/** `assets/<file>` -> same-origin URL, for images in the capture. */
assetUrls: Record<string, string>;
allowUrls: string[];
capture: (html: string, o: { allowUrls: string[]; timeoutMs: number }) => Promise<CaptureResult>;
upload: (file: File) => Promise<{ ok: true; id: string } | { ok: false; message: string }>;
/** Start the follow-up job and wait for it. */
review: (message: string, imageId: string) => Promise<SendResult>;
/** Apply the review result (contrast repair included). `changed`/`ops`: patch ops that took effect / were sent. */
applyResult: (res: SendResult) => Promise<{ ok: boolean; message?: string; summary?: string; changed?: number; ops?: number }>;
removeImage: (id: string) => Promise<unknown>;
resolveAssetUrls: (html: string, urls: Record<string, string>) => string;
}
/**
* - `fixed`: the review ran and at least one change was applied.
* - `fine`: the review ran on the right page and Sitesmith changed nothing.
* - `mismatch`: Sitesmith said the screenshot was not the page it built, so it changed nothing.
* - `not-applied`: Sitesmith proposed changes but none of them could be applied.
* - `claimed-no-ops`: Sitesmith's words claim a fix but it sent no changes (nothing changed; say so).
* - `dropped`: Sitesmith proposed changes but the server's safety checks removed all of them (round 4).
* - `skipped`: the review did not run, or failed (the note says why).
* The model's message is quoted as "Sitesmith said" only when it describes what actually happened;
* whenever nothing it proposed was applied, the quote is labelled as not applied.
*/
export type SelfReviewStatus = 'fixed' | 'fine' | 'mismatch' | 'not-applied' | 'claimed-no-ops' | 'dropped' | 'skipped';
export interface SelfReviewOutcome {
status: SelfReviewStatus;
note: string;
/** The screenshot that was reviewed: side-by-side strips (1 = plain page), and whether only the page's top was shown. */
capture?: { strips: number; cropped: boolean; pageHeight: number };
}
const skip = (why: string): SelfReviewOutcome => ({ status: 'skipped', note: `Self-review skipped: ${why}. Your page was built as normal.` });
/**
* The model's own words that it was shown something other than what it built. A no-op patch is the
* model's only way to say so, and the evaluation showed it does, in prose; the backend has no
* structured flag for it (yet), so read the message. Deliberately narrow: "matches" alone is not enough.
*/
const NEG = String.raw`(?:does\s*n[o']?t|did\s*n[o']?t|do\s*n[o']?t|is\s*n[o']?t|isn't|doesn't|didn't|don't|not)`;
const MISMATCH_RES: RegExp[] = [
// "the render ... doesn't match the page I built"
new RegExp(String.raw`\b(?:render|screenshot|capture)\b[^.]{0,120}?\b${NEG}\s+(?:seem\s+to\s+|appear\s+to\s+)?match\b`, 'i'),
// "... ids (n1-n6) don't match my ai-* ids"
new RegExp(String.raw`\bids?\b[^.]{0,60}?\b${NEG}\s+match\b`, 'i'),
// "shows the old page", "an older or partial render"
/\b(?:old|older|previous|earlier|wrong|stale|partial|different)\s+(?:or\s+\w+\s+)?(?:page|render|version|screenshot|canvas)\b/i,
];
export function saysRenderMismatch(message: string | undefined | null): boolean {
return !!message && MISMATCH_RES.some((re) => re.test(message));
}
/**
* The model's words claim it changed something ("Fixed the hero", "I darkened the overlay"). Only
* consulted when it sent NO ops, so a claim can never be shown as a fix that did not happen
* (eval 2026-11-01). Judged per clause; a negation before the verb in the same clause ("no
* changes", "nothing needed to be changed", "have not changed") is not a claim.
*/
const CLAIM_VERB = /\b(?:fixed|changed|updated|adjusted|moved|increased|decreased|reduced|darkened|lightened|recolou?red|swapped|replaced|switched|tightened|enlarged|added|removed|corrected|improved|raised|lowered|gave|made)\b|\bnow\s+(?:sits|uses|has|reads|shows|is|are)\b/i;
const NEGATION = /\b(?:no|not|nothing|never|without|n't|none)\b|n't\b/i;
export function claimsAFix(message: string | undefined | null): boolean {
if (!message) return false;
return message.split(/[.!?;:\n]+/).some((clause) => {
const m = CLAIM_VERB.exec(clause);
return !!m && !NEGATION.test(clause.slice(0, m.index)) && !/^\s+(?:no|nothing|none)\b/i.test(clause.slice(m.index + m[0].length));
});
}
const quote = (m: string | undefined | null, label = 'Sitesmith said'): string => {
const t = (m ?? '').trim().replace(/\s+/g, ' ');
if (!t) return '';
return ` ${label}: "${t.length > 280 ? t.slice(0, 277) + '...' : t}"`;
};
const NOT_APPLIED = 'What it proposed (not applied)';
const changes = (n: number) => `${n} ${n === 1 ? 'change' : 'changes'}`;
/** One review round, never more. Never throws; any failure is a non-blocking note. */
export async function runSelfReview(d: SelfReviewDeps): Promise<SelfReviewOutcome> {
let shot: SelfReviewOutcome['capture'];
const out = await reviewOnce(d, (c) => { shot = c; });
if (!shot) return out;
const tail = shot.cropped && out.status !== 'skipped'
? ` The page is long, so the screenshot showed only the top ${Math.min(shot.pageHeight, 9000)} px of this ${shot.pageHeight} px page; check the rest yourself.` : '';
return { ...out, note: out.note + tail, capture: shot };
}
async function reviewOnce(d: SelfReviewDeps, onShot: (c: NonNullable<SelfReviewOutcome['capture']>) => void): Promise<SelfReviewOutcome> {
let imageId: string | null = null;
try {
let html: string;
const off = d.canvasMismatch?.() ?? null;
if (off) return skip(off);
try { html = d.resolveAssetUrls(d.composeHtml(), d.assetUrls); } catch { return skip('the page could not be rendered'); }
const cap = await d.capture(html, { allowUrls: d.allowUrls, timeoutMs: 20000 });
if (!cap.ok) return skip(cap.reason);
onShot({ strips: cap.strips ?? 1, cropped: cap.cropped, pageHeight: cap.pageHeight ?? cap.height });
const file = new File([cap.blob], 'self-review-render.jpg', { type: 'image/jpeg' });
const up = await d.upload(file);
if (!up.ok) return skip(up.message || 'the screenshot could not be uploaded');
imageId = up.id;
const res = await d.review(REVIEW_MESSAGE, imageId);
if (!res.ok) return skip(res.message || 'the review request failed');
const r = res.response;
if (r.type === 'ask') {
return { status: 'skipped', note: `Self-review: Sitesmith asked a question instead of reviewing the page, so nothing was changed.${quote(r.question)}` };
}
const msg = 'message' in r ? r.message : '';
// newer backends: how many ops the model proposed and how many the review lockdown kept
const ro = res.reviewOps;
const dropped = ro && ro.proposed > ro.kept ? ro.proposed - ro.kept : 0;
if (r.type === 'patch' && r.ops.length === 0 && ro && ro.proposed > 0) {
return { status: 'dropped', note: `Self-review: Sitesmith proposed ${changes(ro.proposed)}, but ${ro.proposed === 1 ? 'it did' : 'none'} ${ro.proposed === 1 ? 'not pass' : 'passed'} the safety checks, so nothing on your page was changed.${quote(msg, NOT_APPLIED)}` };
}
if (r.type === 'patch' && r.ops.length === 0) {
// Structured flag first (newer backends); the wording check keeps older replies working.
if (r.render_mismatch === true) {
return { status: 'mismatch', note: `Self-review: Sitesmith said the screenshot did not match the page it built (screenshot mismatch), so it changed nothing. The screenshot can be imperfect; your page is as built, so check it yourself.${quote(msg)}` };
}
if (saysRenderMismatch(msg)) {
return { status: 'mismatch', note: `Self-review did not work this time: Sitesmith said the screenshot did not match the page it built, so it changed nothing. Your page is as built; check it yourself.${quote(msg)}` };
}
if (claimsAFix(msg)) {
return { status: 'claimed-no-ops', note: `Self-review: Sitesmith's reply describes a fix, but it sent no changes, so nothing on your page was changed. Check the part it mentions yourself.${quote(msg, 'Its reply (nothing was applied)')}` };
}
return { status: 'fine', note: `Self-review: Sitesmith looked at a screenshot of the finished page and made no changes.${quote(msg)}` };
}
const applied = await d.applyResult(res);
if (!applied.ok) return skip(applied.message || 'the fixes could not be applied');
if (r.type === 'patch' && applied.changed === 0) {
return { status: 'not-applied', note: `Self-review: Sitesmith suggested ${r.ops.length === 1 ? 'a fix' : `${r.ops.length} fixes`}, but none could be applied (the parts it named are not on the page), so nothing changed.${quote(msg, NOT_APPLIED)}` };
}
const extra = applied.summary ? ` ${applied.summary}.` : '';
const partial = r.type === 'patch' && applied.changed !== undefined && applied.changed < r.ops.length
? ` ${applied.changed} of ${r.ops.length} suggested fixes were applied; the rest named parts that are not on the page.` : '';
const drop = dropped ? ` Sitesmith proposed ${changes(ro!.proposed)}; ${dropped} did not pass the safety checks and ${dropped === 1 ? 'was' : 'were'} dropped.` : '';
return { status: 'fixed', note: `Self-review: Sitesmith looked at a screenshot of the finished page and made fixes.${drop}${partial}${extra} Use Undo to go back.${quote(msg, dropped ? "Sitesmith's description (it may include the dropped changes)" : 'Sitesmith said')}` };
} catch (e) {
return skip(e instanceof Error && e.message ? e.message.slice(0, 100) : 'something went wrong');
} finally {
// The render is private scratch, not a keepsake: it would otherwise eat the per-site image quota.
if (imageId) { try { await d.removeImage(imageId); } catch { /* best effort */ } }
}
}