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>
165 lines
11 KiB
TypeScript
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 */ } }
|
|
}
|
|
}
|