Skip to content

Commit 1d7b367

Browse files
ralyodioclaude
andauthored
autoblog: hero image only, at medium quality (~$10/day -> ~$0.60/day) (#347)
The daily autoblog run (15 articles across 10 sites at 09:00 UTC) drew a hero plus 3 inline gpt-image-2 images per article at HIGH quality: 60 images at ~$0.165, about $9.90 a day on the shared OpenAI key. Anthony picked option B: one hero image per article, at medium (~$0.04). - IMAGE_QUALITY high -> medium (hero, any inline, and the repair sweep). - INLINE_IMAGE_COUNT 3 -> 0. The system prompt now asks for no placeholders; with the count above 0 the old instructions come back unchanged. - The schema no longer fails an article whose model returns inline slots anyway: normalizeArticleOutput trims them to INLINE_IMAGE_COUNT, and dropUnfilledInlineMarkers strips any stray <!--INLINE_IMAGE_N--> from the body (articles and guest posts). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 62cffd5 commit 1d7b367

3 files changed

Lines changed: 84 additions & 23 deletions

File tree

‎lib/lx/articleGen.ts‎

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,10 @@ const EMBED_MODEL = "text-embedding-3-small";
4343
const CLAUDE_MODEL = env.backendAiAnthropicModel;
4444
const IMAGE_MODEL = "gpt-image-2";
4545
const IMAGE_SIZE = "1536x1024";
46-
// gpt-image-2 quality tier: low / medium / high / auto. We pay the premium
47-
// "high" tier on the hero + 3 inline images for visibly sharper textures,
48-
// better composition, and stronger instruction-following — readers spot
49-
// AI-art at low/medium quality immediately.
50-
const IMAGE_QUALITY: "low" | "medium" | "high" | "auto" = "high";
46+
// gpt-image-2 quality tier: low / medium / high / auto. "medium" since
47+
// 2026-10-09: "high" on a hero + 3 inline images was ~$0.165 an image,
48+
// 60 images and ~$10 a day across the fleet. Medium is ~$0.04.
49+
const IMAGE_QUALITY: "low" | "medium" | "high" | "auto" = "medium";
5150
const BUCKET = "lx-article-images";
5251

5352
// Cap on input to the LLM so a malicious or oversized sitemap can't
@@ -87,7 +86,9 @@ type LinkCandidate = {
8786
distance: number;
8887
};
8988

90-
export const INLINE_IMAGE_COUNT = 3;
89+
// Hero only since 2026-10-09 (cost, see IMAGE_QUALITY). Raise this to bring
90+
// inline images back: the prompt, schema and render loop all follow it.
91+
export const INLINE_IMAGE_COUNT = 0;
9192
const MAX_PRIOR_ARTICLE_LINKS = 3;
9293

9394
export const ArticleSchema = z.object({
@@ -137,8 +138,11 @@ export const ArticleSchema = z.object({
137138
labels: z.array(z.string().max(80)).max(12).default([]),
138139
}),
139140
)
141+
// A model that adds extra slots anyway should not cost us the article:
142+
// normalizeArticleOutput trims to INLINE_IMAGE_COUNT.
140143
.min(INLINE_IMAGE_COUNT)
141-
.max(INLINE_IMAGE_COUNT),
144+
.max(12)
145+
.default([]),
142146
});
143147

144148
type ArticleOutput = z.infer<typeof ArticleSchema>;
@@ -156,7 +160,7 @@ export function normalizeArticleOutput(article: ArticleOutput): ArticleOutput {
156160
meta_description: truncateText(article.meta_description, 160),
157161
excerpt: truncateText(article.excerpt, 240),
158162
tags: article.tags.slice(0, 8).map((t) => truncateText(t, 40)),
159-
inline_image_prompts: article.inline_image_prompts.map((p) => ({
163+
inline_image_prompts: article.inline_image_prompts.slice(0, INLINE_IMAGE_COUNT).map((p) => ({
160164
...p,
161165
alt: truncateText(p.alt, 200),
162166
prompt: truncateText(p.prompt, 500),
@@ -165,6 +169,14 @@ export function normalizeArticleOutput(article: ArticleOutput): ArticleOutput {
165169
};
166170
}
167171

172+
// Remove any `<!--INLINE_IMAGE_N-->` the model placed beyond the slots we
173+
// render, so an unfilled placeholder never reaches the page.
174+
export function dropUnfilledInlineMarkers(markdown: string): string {
175+
return markdown
176+
.replace(/^[ \t]*<!--\s*INLINE_IMAGE_\d+\s*-->[ \t]*\n?/gm, "")
177+
.replace(/<!--\s*INLINE_IMAGE_\d+\s*-->/g, "");
178+
}
179+
168180
export function stripInPageAnchorLinks(markdown: string): string {
169181
// The receiver quality gate counts every rendered <a>. A 30-link table
170182
// of contents can push link density over 1% even when the body only has
@@ -297,21 +309,28 @@ export function buildSystemPrompt(): string {
297309
"",
298310
"Internal links: insert each provided URL inline exactly once as a standard markdown `[anchor](url)` link where the surrounding sentence is genuinely about that URL's topic. Never create a \"Further reading\" list. Never invent URLs — use only those provided. Link candidates may include both site pages AND prior blog posts on this same site — treat both the same way (inline contextual anchor; the prior posts are clearly labeled in the candidate list).",
299311
"",
300-
`Inline images: place exactly ${INLINE_IMAGE_COUNT} placeholder lines of the form '<!--INLINE_IMAGE_1-->', '<!--INLINE_IMAGE_2-->', '<!--INLINE_IMAGE_3-->' (each on its own line, in numeric order) inside markdown_body. Place each marker on a blank line immediately after a major H2 boundary, distributed across the body — never inside the intro, the TOC, a blockquote, a table, or inside the final '### Try {brand}' CTA block.`,
301-
"",
302-
`For each placeholder, return one object in inline_image_prompts (same 1→${INLINE_IMAGE_COUNT} order). Each object MUST set a kind that fits what the surrounding section is doing — this is the difference between a decorative blob and an informative graphic:`,
303-
"",
304-
"- kind=\"chart\" — when the section discusses metrics, percentages, comparisons of magnitudes, or trends. Provide 2–5 short labels (≤24 chars each) that should appear on bars/segments. Plausible numbers are fine; the chart is illustrative.",
305-
"- kind=\"flow\" — when the section walks through a process or workflow with 3–5 steps. Provide the step labels in order.",
306-
"- kind=\"comparison\" — when the section explicitly contrasts two approaches (good/bad, before/after, wrong/right). Provide exactly 2 labels (e.g., [\"Before\", \"After\"]).",
307-
"- kind=\"checklist\" — when the section is a 'do these things' list of 3–6 items. Provide the checklist labels.",
308-
"- kind=\"concept\" — fallback for atmospheric / opening sections where no data, flow, comparison, or list is being presented. No labels needed.",
309-
"",
310-
"Aim for a MIX across the three inline images — at least one should be chart/flow/comparison/checklist when the article has any quantitative or procedural content. 'concept' is fine for at most one of the three.",
311-
"",
312-
"alt: a short accessibility description of what the image shows.",
313-
"prompt: a one-sentence brief describing the image's content (this becomes the image-model prompt). Don't include style direction — the renderer adds it.",
314-
"",
312+
...(INLINE_IMAGE_COUNT > 0
313+
? [
314+
`Inline images: place exactly ${INLINE_IMAGE_COUNT} placeholder lines of the form '<!--INLINE_IMAGE_1-->', '<!--INLINE_IMAGE_2-->', '<!--INLINE_IMAGE_3-->' (each on its own line, in numeric order) inside markdown_body. Place each marker on a blank line immediately after a major H2 boundary, distributed across the body — never inside the intro, the TOC, a blockquote, a table, or inside the final '### Try {brand}' CTA block.`,
315+
"",
316+
`For each placeholder, return one object in inline_image_prompts (same 1→${INLINE_IMAGE_COUNT} order). Each object MUST set a kind that fits what the surrounding section is doing — this is the difference between a decorative blob and an informative graphic:`,
317+
"",
318+
"- kind=\"chart\" — when the section discusses metrics, percentages, comparisons of magnitudes, or trends. Provide 2–5 short labels (≤24 chars each) that should appear on bars/segments. Plausible numbers are fine; the chart is illustrative.",
319+
"- kind=\"flow\" — when the section walks through a process or workflow with 3–5 steps. Provide the step labels in order.",
320+
"- kind=\"comparison\" — when the section explicitly contrasts two approaches (good/bad, before/after, wrong/right). Provide exactly 2 labels (e.g., [\"Before\", \"After\"]).",
321+
"- kind=\"checklist\" — when the section is a 'do these things' list of 3–6 items. Provide the checklist labels.",
322+
"- kind=\"concept\" — fallback for atmospheric / opening sections where no data, flow, comparison, or list is being presented. No labels needed.",
323+
"",
324+
"Aim for a MIX across the three inline images — at least one should be chart/flow/comparison/checklist when the article has any quantitative or procedural content. 'concept' is fine for at most one of the three.",
325+
"",
326+
"alt: a short accessibility description of what the image shows.",
327+
"prompt: a one-sentence brief describing the image's content (this becomes the image-model prompt). Don't include style direction — the renderer adds it.",
328+
"",
329+
]
330+
: [
331+
"Inline images: none. Put no image placeholders, HTML comments or image markdown in markdown_body, and return inline_image_prompts as an empty array.",
332+
"",
333+
]),
315334
"Output: strict JSON matching the schema. The markdown_body is the article body only.",
316335
].join("\n");
317336
}
@@ -1480,6 +1499,8 @@ export async function generateArticle(
14801499
);
14811500
}
14821501

1502+
bodyWithImages = dropUnfilledInlineMarkers(bodyWithImages);
1503+
14831504
// Strip pandoc-style `## Heading {#anchor-id}` attributes from headings.
14841505
// Both our renderers (pandoc with gfm_auto_identifiers, marked with the
14851506
// custom heading renderer) auto-slug headings, so explicit IDs are noise.

‎lib/lx/guestPostGen.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
ArticleSchema,
3030
INLINE_IMAGE_COUNT,
3131
buildSystemPrompt,
32+
dropUnfilledInlineMarkers,
3233
extractSectionForMarker,
3334
generateImage,
3435
generateInlineImage,
@@ -284,6 +285,7 @@ export async function generateGuestPost(
284285
: "",
285286
);
286287
}
288+
bodyWithImages = dropUnfilledInlineMarkers(bodyWithImages);
287289
bodyWithImages = bodyWithImages.replace(
288290
/^(#{1,6}[^\n]*?)\s*\{#[a-z0-9][a-z0-9-]*\}\s*$/gim,
289291
"$1",

‎tests/contract/lx-article.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { describe, it, expect } from "vitest";
22
import {
3+
ArticleSchema,
4+
INLINE_IMAGE_COUNT,
5+
buildSystemPrompt,
6+
dropUnfilledInlineMarkers,
37
ensureTableOfContentsLinks,
8+
normalizeArticleOutput,
49
extractSectionForMarker,
510
slugify,
611
validateInternalLinks,
@@ -194,3 +199,36 @@ That's it.
194199
expect(out.startsWith('Section heading: "Topic title"')).toBe(true);
195200
});
196201
});
202+
203+
describe("hero-only images (INLINE_IMAGE_COUNT = 0)", () => {
204+
const base = {
205+
title: "A title long enough",
206+
slug: "a-title",
207+
meta_description: "m".repeat(60),
208+
excerpt: "e".repeat(60),
209+
tags: ["one", "two", "three"],
210+
markdown_body: "b".repeat(7500),
211+
used_internal_link_urls: [],
212+
};
213+
const extra = { alt: "an extra image", prompt: "p".repeat(30) };
214+
215+
it("asks the model for no inline images", () => {
216+
expect(INLINE_IMAGE_COUNT).toBe(0);
217+
expect(buildSystemPrompt()).toContain("Inline images: none.");
218+
expect(buildSystemPrompt()).not.toContain("<!--INLINE_IMAGE_1-->");
219+
});
220+
221+
it("accepts an article with no inline_image_prompts", () => {
222+
expect(ArticleSchema.parse(base).inline_image_prompts).toEqual([]);
223+
});
224+
225+
it("drops inline images a model returns anyway instead of failing", () => {
226+
const parsed = ArticleSchema.parse({ ...base, inline_image_prompts: [extra, extra] });
227+
expect(normalizeArticleOutput(parsed).inline_image_prompts).toEqual([]);
228+
});
229+
230+
it("removes stray placeholders from the body", () => {
231+
const md = "## One\n\n<!--INLINE_IMAGE_1-->\n\nText <!-- INLINE_IMAGE_2 --> here.\n";
232+
expect(dropUnfilledInlineMarkers(md)).toBe("## One\n\n\nText here.\n");
233+
});
234+
});

0 commit comments

Comments
 (0)