Skip to content

Commit 76d5bc3

Browse files
committed
feat(api): generate release content before embed on /batch
HTTP /batch (local ingest, discovery persist) was embedding immediately and never summarizing. Run generateContentForReleases first so Vectorize sees title_generated/summary when the org is opted in — matching the poll workflow. The scrape persister skips generate; DeterministicUpdate still runs it as a later durable step.
1 parent 18856f0 commit 76d5bc3

7 files changed

Lines changed: 376 additions & 115 deletions

File tree

docs/architecture/content-pipelines.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ A map of every routine or scheduled AI-content job — org overview regeneration
2525

2626
**Batch feed-enrich.** Async sibling of the synchronous ingest-time feed enrichment, for render-heavy/JS-shell sources the synchronous route can't finish before a client disconnect. No cron path exists yet — `BATCH_ENRICH_ENABLED` is a reserved var, not a live gate. See [ingest.md → Feed enrichment](ingest.md).
2727

28-
**Ingest-time passes (summary, marketing classifier, feed enrichment).** Three cheap AI passes run between parse and insert — generated titles/summaries (gated per org by `auto_generate_content`, the same opt-in that gates overview regen; see `packages/core-internal/src/eligibility.ts`), a marketing-vs-product classifier (opt-in per source), and thin-feed article extraction. See [ingest.md](ingest.md) for the full pipeline and each pass's model/cap details.
28+
**Ingest-time passes (summary, marketing classifier, feed enrichment).** Three cheap AI passes run between parse and insert — generated titles/summaries (gated per org by `auto_generate_content`, the same opt-in that gates overview regen; see `packages/core-internal/src/eligibility.ts`), a marketing-vs-product classifier (opt-in per source), and thin-feed article extraction. The summary pass also runs on HTTP `/batch` (local ingest, discovery persist) via `runBatchIngestEffects`, _before_ embed, so Vectorize sees `summary` when the org is opted in. The D1 scrape persister skips it — DeterministicUpdate still generates as a later workflow step. See [ingest.md](ingest.md) for the full pipeline and each pass's model/cap details.
2929

3030
AI-scored release importance (1–5) rides the release-content summary call above — no new pipeline, no new model env var, the same reuse-the-existing-lane pattern as collection daily summaries below. See [ingest.md → Content summarization](ingest.md).
3131

workers/api/src/lib/d1-scrape-persister.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export function d1ScrapePersister(opts: {
104104
await runBatchIngestEffects(db, env, source, result, {
105105
skipEmbed: true,
106106
skipInvalidate: true,
107+
// DeterministicUpdate runs generate + embed as durable steps later.
108+
// Summarizing here would double-call the model on every scrape persist.
109+
skipSummarize: true,
107110
});
108111
return { inserted: result.inserted, insertedIds: result.insertedIds };
109112
},

workers/api/src/lib/ingest-steps.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import type { BreakingLevel } from "@buildinternet/releases-core/breaking";
4141
import { embedReleasesForSource, type FetchOneEnv } from "../cron/poll-fetch.js";
4242
import { buildFetchOneEnv } from "../workflows/_fetch-env.js";
4343
import { invalidateLatestCache, type InvalidationEnv } from "./latest-cache.js";
44-
import { resolveSummarizeModel } from "./text-model.js";
44+
import { resolveSummarizeModel, type TextModelEnv } from "./text-model.js";
4545
import { IN_ARRAY_CHUNK_SIZE, chunkArray } from "./d1-limits.js";
4646
import { logUsage } from "./usage-log.js";
4747
// Type-only — erased at compile, so no runtime import cycle with the workflow
@@ -120,7 +120,7 @@ const MAX_AUTOGEN_BODY_CHARS = 50_000;
120120
export async function generateContentForReleases(
121121
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- drizzle override pattern; same as the rest of this workflow
122122
db: any,
123-
env: PollAndFetchWorkflowEnv,
123+
env: TextModelEnv,
124124
source: Source,
125125
insertedIds: string[],
126126
// `ignoreAutoGenerateGate` drops the org-level `auto_generate_content` opt-in

workers/api/src/lib/release-batch-ingest.ts

Lines changed: 171 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
* cascade coverage clustering, total count. Everything here is either a
1010
* write the caller needs reflected in the response, or a read needed to
1111
* decide one.
12-
* - `runBatchIngestEffects` — the post-insert fire-and-forget extras: publish
13-
* to the ReleaseHub DO, the web revalidate ping, latest-cache invalidation, and
14-
* embed + `embeddedAt` marking. These already tolerate failure independently
15-
* (each has its own catch/logEvent), so they run concurrently via
16-
* `Promise.allSettled` rather than sequentially.
12+
* - `runBatchIngestEffects` — the post-insert extras: publish to the ReleaseHub
13+
* DO, the web revalidate ping, latest-cache invalidation, generate-content
14+
* (same eligibility as poll), then embed + `embeddedAt` marking. Publish /
15+
* revalidate / invalidate tolerate failure independently and run concurrently
16+
* via `Promise.allSettled`. Generate is awaited *before* embed so the
17+
* Vectorize payload sees `summary` / `title_generated` when the org is opted
18+
* in — matching `runContentAndEmbedSteps`. The scrape persister skips both
19+
* generate and embed; the DeterministicUpdate workflow runs them as durable
20+
* steps later.
1721
*
1822
* Request-shaped concerns (body parsing, `releases`-array validation, `mode`
1923
* validation → `enrichMode`) stay in the route handler — they're HTTP
@@ -50,6 +54,8 @@ import { embedAndUpsertReleases } from "@releases/search/embed-releases.js";
5054
import { logEvent } from "@releases/lib/log-event";
5155
import { FLAGS, flag, type FlagshipBinding } from "@releases/lib/flags";
5256
import type { MediaTransformBinding } from "./media-ingest.js";
57+
import { generateContentForReleases } from "./ingest-steps.js";
58+
import type { TextModelEnv } from "./text-model.js";
5359

5460
/**
5561
* Shape-coerce media via {@link normalizeMediaBind}, then rewrite each item's
@@ -115,15 +121,57 @@ export interface BatchIngestEnv {
115121

116122
/**
117123
* Env slice used by `runBatchIngestEffects` — a union of the effect helpers'
118-
* own env slices, plus the embed config env.
124+
* own env slices, plus the embed config env and the summarize-lane bindings
125+
* `generateContentForReleases` reads via `resolveSummarizeModel`.
119126
*/
120-
export interface BatchEffectsEnv extends PublishEnv, WebRevalidateEnv, InvalidationEnv, EmbedEnv {
127+
export interface BatchEffectsEnv
128+
extends PublishEnv, WebRevalidateEnv, InvalidationEnv, EmbedEnv, TextModelEnv {
121129
// Typed loosely (matches the route's `Env.Bindings` Cloudflare `VectorizeIndex`)
122130
// and narrowed via cast at the call site below — see the note in
123131
// embedSourceSideEffect about why the cast is needed.
124132
RELEASES_INDEX: unknown;
125133
}
126134

135+
/** Rows loaded for the embed pass — the subset `embedAndUpsertReleases` reads. */
136+
export type BatchEmbedRow = {
137+
id: string;
138+
title: string;
139+
content: string;
140+
summary: string | null;
141+
version: string | null;
142+
publishedAt: string | null;
143+
sourceId: string;
144+
type: ReleaseType;
145+
};
146+
147+
export type BatchEffectsOpts = {
148+
skipEmbed?: boolean;
149+
skipInvalidate?: boolean;
150+
/**
151+
* Skip the generate-content fill. The D1 scrape persister passes this
152+
* because DeterministicUpdate runs `generateContentForReleases` as a
153+
* durable workflow step later — without it, a scrape update would
154+
* summarize twice.
155+
*/
156+
skipSummarize?: boolean;
157+
/**
158+
* Test seam. Production omits this and uses `generateContentForReleases`.
159+
* Same injection style as `runGenerateContent({ generate })`.
160+
*/
161+
generateContent?: (
162+
db: D1Db,
163+
env: BatchEffectsEnv,
164+
src: Source,
165+
insertedIds: string[],
166+
) => Promise<number>;
167+
/**
168+
* Test seam. Production omits this and uses `embedAndUpsertReleases`.
169+
* Called with the rows loaded *after* generate so a test can assert the
170+
* embed payload already carries `summary`.
171+
*/
172+
embedReleases?: (rows: BatchEmbedRow[]) => Promise<void>;
173+
};
174+
127175
/**
128176
* Durable core: deny filter, title dedup, media R2 mirror, chunked upsert,
129177
* cascade coverage clustering, total count.
@@ -368,20 +416,24 @@ export async function ingestReleaseBatch(
368416
}
369417

370418
/**
371-
* The post-insert waitUntil extras: ReleaseHub publish, web revalidate, embed +
372-
* embeddedAt marking, latest-cache invalidation. Awaitable — the caller
373-
* decides whether to await inline or hand it to `waitUntil`.
419+
* The post-insert waitUntil extras: ReleaseHub publish, web revalidate,
420+
* latest-cache invalidation, generate-content, then embed + `embeddedAt`
421+
* marking. Awaitable — the caller decides whether to await inline or hand
422+
* it to `waitUntil`.
374423
*
375424
* Publish/revalidate/invalidate already tolerate failure independently (each
376425
* logs its own error internally), so they run concurrently via
377-
* `Promise.allSettled` rather than one blocking the others.
426+
* `Promise.allSettled` rather than one blocking the others. Generate is
427+
* sequential with embed (generate first) so the embed SELECT sees
428+
* `summary` / `title_generated` when the org is opted in. A generate
429+
* failure is fail-open: we still embed the raw body.
378430
*/
379431
export async function runBatchIngestEffects(
380432
db: D1Db,
381433
env: BatchEffectsEnv,
382434
src: Source,
383435
result: BatchIngestResult,
384-
opts?: { skipEmbed?: boolean; skipInvalidate?: boolean },
436+
opts?: BatchEffectsOpts,
385437
): Promise<void> {
386438
const { visiblePublishRows, insertedIds } = result;
387439
const tasks: Array<Promise<unknown>> = [];
@@ -390,7 +442,8 @@ export async function runBatchIngestEffects(
390442
// `tail -f`, the upcoming web live view, webhook delivery) see new
391443
// releases in real time. Coverage-side rows are excluded — they're
392444
// not shown in default feeds and shouldn't broadcast on the live tail
393-
// either.
445+
// either. Publish stays concurrent with generate: the live payload
446+
// omits `summary` by contract (`events.md`).
394447
if (visiblePublishRows.length > 0) {
395448
tasks.push(
396449
publishReleaseEvents(env, {
@@ -434,94 +487,112 @@ export async function runBatchIngestEffects(
434487
);
435488
}
436489

437-
// Fire-and-forget: embed the rows we just wrote. Never fails the write —
438-
// embedAndUpsertReleases catches every error internally and logs to
439-
// console.
440-
if (!opts?.skipEmbed && insertedIds.length > 0) {
441-
tasks.push(
442-
(async () => {
443-
try {
444-
const embedConfig = await buildEmbedConfig(env);
445-
if (!embedConfig) return;
446-
// Load the rows back so we have full content, category, etc.
447-
// We need the org/product category for metadata filtering.
448-
const [orgRow] = src.orgId
449-
? await db
450-
.select({ category: organizations.category })
451-
.from(organizations)
452-
.where(eq(organizations.id, src.orgId))
453-
: [{ category: null as string | null }];
454-
// D1 bind-param cap is 100; chunk the IN clause so we stay
455-
// well clear of the limit even if the caller posts a large
456-
// batch. See `./d1-limits.ts`.
457-
const rowsToEmbed: Array<{
458-
id: string;
459-
title: string;
460-
content: string;
461-
summary: string | null;
462-
version: string | null;
463-
publishedAt: string | null;
464-
sourceId: string;
465-
type: ReleaseType;
466-
}> = [];
467-
for (let i = 0; i < insertedIds.length; i += RELEASES_ID_IN_CHUNK_SIZE) {
468-
const slice = insertedIds.slice(i, i + RELEASES_ID_IN_CHUNK_SIZE);
469-
// oxlint-disable-next-line no-await-in-loop -- D1 chunked select (100 bind param limit for inArray)
470-
const rows = await db
471-
.select({
472-
id: releases.id,
473-
title: releases.title,
474-
content: releases.content,
475-
summary: releases.summary,
476-
version: releases.version,
477-
publishedAt: releases.publishedAt,
478-
sourceId: releases.sourceId,
479-
type: releases.type,
480-
})
481-
.from(releases)
482-
.where(inArray(releases.id, slice));
483-
rowsToEmbed.push(...rows);
484-
}
485-
486-
const category = orgRow?.category ?? null;
487-
await embedAndUpsertReleases({
488-
// oxlint-disable-next-line no-map-spread -- copy-on-write required; r is a DB row
489-
releases: rowsToEmbed.map((r) => ({
490-
...r,
491-
orgId: src.orgId,
492-
productId: src.productId,
493-
category,
494-
})),
495-
// See note in embedSourceSideEffect about the cast.
496-
vectorIndex:
497-
env.RELEASES_INDEX as unknown as import("@releases/search/vector-search.js").VectorizeIndex,
498-
embedConfig,
499-
onPersisted: async (ids) => {
500-
if (ids.length === 0) return;
501-
// Mark the rows as embedded. D1's 100 bind-param cap means
502-
// the embeddedAt SET + N IN-clause ids must total ≤100, so
503-
// we chunk IDs — see `./d1-limits.ts`.
504-
const now = new Date().toISOString();
505-
for (let i = 0; i < ids.length; i += RELEASES_ID_IN_CHUNK_SIZE) {
506-
const slice = ids.slice(i, i + RELEASES_ID_IN_CHUNK_SIZE);
507-
// oxlint-disable-next-line no-await-in-loop -- D1 chunked update (100 bind param limit)
508-
await db
509-
.update(releases)
510-
.set({ embeddedAt: now })
511-
.where(inArray(releases.id, slice));
512-
}
513-
},
514-
});
515-
} catch (err) {
516-
logEvent("warn", {
517-
component: "sources-batch",
518-
event: "embed-side-effect-failed",
519-
err: err instanceof Error ? err : String(err),
520-
});
521-
}
522-
})(),
523-
);
490+
const shouldGenerate = !opts?.skipSummarize && insertedIds.length > 0;
491+
const shouldEmbed = !opts?.skipEmbed && insertedIds.length > 0;
492+
if (shouldGenerate || shouldEmbed) {
493+
tasks.push(generateThenEmbed(db, env, src, insertedIds, opts));
524494
}
525495

526496
await Promise.allSettled(tasks);
527497
}
498+
499+
/**
500+
* Generate (opt-in, fail-open) then embed. Split out of `runBatchIngestEffects`
501+
* so publish/revalidate stay concurrent with this chain instead of waiting
502+
* on the LLM.
503+
*/
504+
async function generateThenEmbed(
505+
db: D1Db,
506+
env: BatchEffectsEnv,
507+
src: Source,
508+
insertedIds: string[],
509+
opts?: BatchEffectsOpts,
510+
): Promise<void> {
511+
if (!opts?.skipSummarize && insertedIds.length > 0) {
512+
try {
513+
const generate = opts?.generateContent ?? generateContentForReleases;
514+
await generate(db, env, src, insertedIds);
515+
} catch (err) {
516+
logEvent("warn", {
517+
component: "sources-batch",
518+
event: "generate-side-effect-failed",
519+
sourceId: src.id,
520+
err: err instanceof Error ? err : String(err),
521+
});
522+
}
523+
}
524+
525+
if (opts?.skipEmbed || insertedIds.length === 0) return;
526+
527+
try {
528+
// Load the rows back so we have full content, category, and (when
529+
// generate just ran) the new summary. Chunk the IN clause for D1's
530+
// 100 bind-param cap — see `./d1-limits.ts`.
531+
const [orgRow] = src.orgId
532+
? await db
533+
.select({ category: organizations.category })
534+
.from(organizations)
535+
.where(eq(organizations.id, src.orgId))
536+
: [{ category: null as string | null }];
537+
const rowsToEmbed: BatchEmbedRow[] = [];
538+
for (let i = 0; i < insertedIds.length; i += RELEASES_ID_IN_CHUNK_SIZE) {
539+
const slice = insertedIds.slice(i, i + RELEASES_ID_IN_CHUNK_SIZE);
540+
// oxlint-disable-next-line no-await-in-loop -- D1 chunked select (100 bind param limit for inArray)
541+
const rows = await db
542+
.select({
543+
id: releases.id,
544+
title: releases.title,
545+
content: releases.content,
546+
summary: releases.summary,
547+
version: releases.version,
548+
publishedAt: releases.publishedAt,
549+
sourceId: releases.sourceId,
550+
type: releases.type,
551+
})
552+
.from(releases)
553+
.where(inArray(releases.id, slice));
554+
rowsToEmbed.push(...rows);
555+
}
556+
557+
if (opts?.embedReleases) {
558+
await opts.embedReleases(rowsToEmbed);
559+
return;
560+
}
561+
562+
const embedConfig = await buildEmbedConfig(env);
563+
if (!embedConfig) return;
564+
565+
const category = orgRow?.category ?? null;
566+
await embedAndUpsertReleases({
567+
// oxlint-disable-next-line no-map-spread -- copy-on-write required; r is a DB row
568+
releases: rowsToEmbed.map((r) => ({
569+
...r,
570+
orgId: src.orgId,
571+
productId: src.productId,
572+
category,
573+
})),
574+
// See note in embedSourceSideEffect about the cast.
575+
vectorIndex:
576+
env.RELEASES_INDEX as unknown as import("@releases/search/vector-search.js").VectorizeIndex,
577+
embedConfig,
578+
onPersisted: async (ids) => {
579+
if (ids.length === 0) return;
580+
// Mark the rows as embedded. D1's 100 bind-param cap means
581+
// the embeddedAt SET + N IN-clause ids must total ≤100, so
582+
// we chunk IDs — see `./d1-limits.ts`.
583+
const now = new Date().toISOString();
584+
for (let i = 0; i < ids.length; i += RELEASES_ID_IN_CHUNK_SIZE) {
585+
const slice = ids.slice(i, i + RELEASES_ID_IN_CHUNK_SIZE);
586+
// oxlint-disable-next-line no-await-in-loop -- D1 chunked update (100 bind param limit)
587+
await db.update(releases).set({ embeddedAt: now }).where(inArray(releases.id, slice));
588+
}
589+
},
590+
});
591+
} catch (err) {
592+
logEvent("warn", {
593+
component: "sources-batch",
594+
event: "embed-side-effect-failed",
595+
err: err instanceof Error ? err : String(err),
596+
});
597+
}
598+
}

0 commit comments

Comments
 (0)