Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 88 additions & 12 deletions lib/lx/articleGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,65 @@ export async function uploadImage(
return data.publicUrl;
}

// An inline image that fails to generate used to have its marker deleted,
// which threw away the only record of where the image belonged and what it
// was meant to show — the post published a paragraph short of its plan and
// nothing could put it back. Leave a self-describing marker instead: it is
// an HTML comment, so it renders as nothing for the reader, and the repair
// sweep can regenerate exactly that image later. See repairImages.ts.
const PENDING_INLINE_RE = /<!--\s*INLINE_IMAGE_PENDING\s+([^>]*?)-->/g;

// `--` cannot appear inside an HTML comment, and a quote would end the
// attribute early. Neither survives a round trip, so drop both.
function markerAttr(value: string): string {
return value.replace(/--+/g, " ").replace(/["<>]/g, "").trim();
}

export function pendingInlineMarker(spec: {
// 1-based position of this image in the article, which is also what names
// its object in storage. Carried on the marker so a repair that fills in
// only image 2 cannot overwrite image 1.
index: number;
kind?: string | null;
alt?: string | null;
prompt: string;
}): string {
const kind = markerAttr(spec.kind ?? "concept");
const alt = markerAttr(spec.alt ?? "");
const prompt = markerAttr(spec.prompt);
return `<!-- INLINE_IMAGE_PENDING n="${spec.index}" kind="${kind}" alt="${alt}" prompt="${prompt}" -->`;
}

export type PendingInlineImage = {
raw: string;
index: number;
kind: string;
alt: string;
prompt: string;
};

export function parsePendingInlineMarkers(markdown: string): PendingInlineImage[] {
const out: PendingInlineImage[] = [];
for (const m of markdown.matchAll(PENDING_INLINE_RE)) {
const attrs = m[1] ?? "";
const read = (name: string) =>
new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1] ?? "";
const prompt = read("prompt");
// A marker with no prompt can't be regenerated — skip it rather than
// sending the image model an empty brief.
if (!prompt) continue;
const index = Number.parseInt(read("n"), 10);
out.push({
raw: m[0],
index: Number.isFinite(index) && index > 0 ? index : out.length + 1,
kind: read("kind") || "concept",
alt: read("alt"),
prompt,
});
}
return out;
}

export function validateInternalLinks(
markdown: string,
expected: string[],
Expand Down Expand Up @@ -1342,6 +1401,11 @@ export async function generateArticle(
// Featured image + inline section images, generated in parallel so the
// 30–60s image latency stacks once instead of 4×.
let imageUrl: string | null = null;
// Every image call is best-effort so a provider blip can't cost us the
// article. That silence is the trap: for a week in August every post
// published image-less and nothing recorded why. Collect the reasons and
// store them on the row.
const imageFailures: string[] = [];
const inlineImageUrls: Array<string | null> = new Array(
article.inline_image_prompts.length,
).fill(null);
Expand All @@ -1360,11 +1424,11 @@ export async function generateArticle(
});
if (bytes)
imageUrl = await uploadImage(supabase, typedSite.id, finalSlug, bytes);
if (!imageUrl) imageFailures.push("hero: no image returned");
} catch (err) {
console.warn(
"[lx] hero image generation failed, continuing without",
err instanceof Error ? err.message : err,
);
const msg = err instanceof Error ? err.message : String(err);
imageFailures.push(`hero: ${msg}`);
console.warn("[lx] hero image generation failed, continuing without", msg);
}
})(),
...article.inline_image_prompts.map(async (p, i) => {
Expand All @@ -1383,25 +1447,33 @@ export async function generateArticle(
bytes,
);
}
if (!inlineImageUrls[i]) {
imageFailures.push(`inline ${i + 1}: no image returned`);
}
} catch (err) {
console.warn(
`[lx] inline image ${i + 1} failed, continuing without`,
err instanceof Error ? err.message : err,
);
const msg = err instanceof Error ? err.message : String(err);
imageFailures.push(`inline ${i + 1}: ${msg}`);
console.warn(`[lx] inline image ${i + 1} failed, continuing without`, msg);
}
}),
]);

// Substitute the inline-image markers in markdown. A missing/failed
// image strips the marker rather than leaving a visible comment.
// Substitute the inline-image markers in markdown. A missing/failed image
// leaves a PENDING marker — invisible to the reader, but enough for the
// repair sweep to generate that one image later instead of losing it.
let bodyWithImages = article.markdown_body;
for (let i = 0; i < article.inline_image_prompts.length; i++) {
const marker = new RegExp(`<!--\\s*INLINE_IMAGE_${i + 1}\\s*-->`, "g");
const url = inlineImageUrls[i];
const alt = article.inline_image_prompts[i]?.alt ?? "";
const spec = article.inline_image_prompts[i];
const alt = spec?.alt ?? "";
bodyWithImages = bodyWithImages.replace(
marker,
url ? `![${alt.replace(/[\[\]]/g, "")}](${url})` : "",
url
? `![${alt.replace(/[\[\]]/g, "")}](${url})`
: spec
? pendingInlineMarker({ index: i + 1, kind: spec.kind, alt, prompt: spec.prompt })
: "",
);
}

Expand Down Expand Up @@ -1464,6 +1536,10 @@ export async function generateArticle(
// a blog whose scores are creeping up is drifting before it fails.
slop_score: gate?.score ?? null,
slop_issues: gate ? gate.issues : [],
generation_error:
imageFailures.length > 0
? `images: ${imageFailures.join("; ")}`.slice(0, 2000)
: null,
status: "ready",
})
.select("id")
Expand Down
39 changes: 27 additions & 12 deletions lib/lx/guestPostGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
generateImage,
generateInlineImage,
normalizeArticleOutput,
pendingInlineMarker,
refundCredit,
ensureTableOfContentsLinks,
slugify,
Expand Down Expand Up @@ -210,8 +211,10 @@ export async function generateGuestPost(
const baseSlug = article.slug || slugify(article.title);
const finalSlug = await uniqueSlug(supabase, target.id, baseSlug);

// Hero + inline images.
// Hero + inline images. Best-effort, so record why each one is missing
// rather than publishing image-less in silence — see articleGen.
let imageUrl: string | null = null;
const imageFailures: string[] = [];
const inlineImageUrls: Array<string | null> = new Array(
article.inline_image_prompts.length,
).fill(null);
Expand All @@ -228,11 +231,11 @@ export async function generateGuestPost(
brand: target.domain ?? null,
});
if (bytes) imageUrl = await uploadImage(supabase, target.id, finalSlug, bytes);
if (!imageUrl) imageFailures.push("hero: no image returned");
} catch (err) {
console.warn(
"[lx] guest hero image failed, continuing without",
err instanceof Error ? err.message : err,
);
const msg = err instanceof Error ? err.message : String(err);
imageFailures.push(`hero: ${msg}`);
console.warn("[lx] guest hero image failed, continuing without", msg);
}
})(),
...article.inline_image_prompts.map(async (p, i) => {
Expand All @@ -251,24 +254,32 @@ export async function generateGuestPost(
bytes,
);
}
if (!inlineImageUrls[i]) {
imageFailures.push(`inline ${i + 1}: no image returned`);
}
} catch (err) {
console.warn(
`[lx] guest inline image ${i + 1} failed, continuing without`,
err instanceof Error ? err.message : err,
);
const msg = err instanceof Error ? err.message : String(err);
imageFailures.push(`inline ${i + 1}: ${msg}`);
console.warn(`[lx] guest inline image ${i + 1} failed, continuing without`, msg);
}
}),
]);

// Substitute the inline image markers + strip any pandoc heading IDs.
// Substitute the inline image markers + strip any pandoc heading IDs. A
// failed image leaves a PENDING marker for the repair sweep to fill in.
let bodyWithImages = article.markdown_body;
for (let i = 0; i < article.inline_image_prompts.length; i++) {
const marker = new RegExp(`<!--\\s*INLINE_IMAGE_${i + 1}\\s*-->`, "g");
const url = inlineImageUrls[i];
const alt = article.inline_image_prompts[i]?.alt ?? "";
const spec = article.inline_image_prompts[i];
const alt = spec?.alt ?? "";
bodyWithImages = bodyWithImages.replace(
marker,
url ? `![${alt.replace(/[\[\]]/g, "")}](${url})` : "",
url
? `![${alt.replace(/[\[\]]/g, "")}](${url})`
: spec
? pendingInlineMarker({ index: i + 1, kind: spec.kind, alt, prompt: spec.prompt })
: "",
);
}
bodyWithImages = bodyWithImages.replace(
Expand Down Expand Up @@ -316,6 +327,10 @@ export async function generateGuestPost(
tags: article.tags,
internal_links: internalLinksPayload,
outbound_links: outboundLinksPayload,
generation_error:
imageFailures.length > 0
? `images: ${imageFailures.join("; ")}`.slice(0, 2000)
: null,
status: "ready",
})
.select("id")
Expand Down
Loading
Loading