Skip to content

Commit bb9175f

Browse files
authored
fix(api): move highlight shadow preprocessing off API (firecrawl#4035)
* fix(api): move highlight shadow preprocessing off api * test(api): separate indexed shadow suite
1 parent e5ea306 commit bb9175f

6 files changed

Lines changed: 281 additions & 68 deletions

File tree

apps/api/src/search/highlight-model.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ vi.mock("../config", () => ({
55
},
66
}));
77

8-
import { generateHighlightsBatch } from "./highlight-model";
8+
import {
9+
generateHighlightsBatch,
10+
generateHighlightsIndexedBatch,
11+
} from "./highlight-model";
912
import { config } from "../config";
1013

1114
const logger = {
@@ -33,6 +36,35 @@ afterEach(() => {
3336
});
3437

3538
describe("generateHighlightsBatch", () => {
39+
it("posts lightweight index references to the indexed Stage 1 endpoint", async () => {
40+
const fetchMock = mockFetchOnce({ pages: [] });
41+
42+
await generateHighlightsIndexedBatch(
43+
"q1",
44+
[
45+
{
46+
id: "0",
47+
url: "https://first.test/path",
48+
indexObject: "index-object.json",
49+
},
50+
],
51+
{ logger, logPayload: false, requestId: "request-1" },
52+
);
53+
54+
const [url, init] = fetchMock.mock.calls[0];
55+
expect(url).toBe("https://highlight.test/batch_highlight_indexed");
56+
expect(JSON.parse(init.body)).toEqual({
57+
query: "q1",
58+
pages: [
59+
{
60+
id: "0",
61+
url: "https://first.test/path",
62+
indexObject: "index-object.json",
63+
},
64+
],
65+
});
66+
});
67+
3668
it("posts every page to one /batch_highlight call with the bearer token", async () => {
3769
const fetchMock = mockFetchOnce({ pages: [] });
3870

apps/api/src/search/highlight-model.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ interface HighlightPage {
6161
markdown: string;
6262
}
6363

64+
export interface HighlightIndexedPage {
65+
id: string;
66+
url: string;
67+
indexObject: string;
68+
}
69+
6470
// Result for one page in the batch: the service's highlight entries plus the
6571
// reassembled markdown document.
6672
interface HighlightResult {
@@ -149,16 +155,20 @@ function requestHeaders(requestId?: string): Record<string, string> {
149155
* its provider snippet without discarding successful pages. Returns null when
150156
* the whole call fails.
151157
*/
152-
export async function generateHighlightsBatch(
158+
interface HighlightBatchOptions {
159+
logger: Logger;
160+
logPayload?: boolean;
161+
allowLegacyFallback?: boolean;
162+
requestId?: string;
163+
onFailure?: (reason: HighlightFailureReason) => void;
164+
}
165+
166+
async function generateHighlightsBatchRequest(
167+
endpoint: "/batch_highlight" | "/batch_highlight_indexed",
153168
query: string,
154-
pages: HighlightPage[],
155-
opts: {
156-
logger: Logger;
157-
logPayload?: boolean;
158-
allowLegacyFallback?: boolean;
159-
requestId?: string;
160-
onFailure?: (reason: HighlightFailureReason) => void;
161-
},
169+
pages: Array<HighlightPage | HighlightIndexedPage>,
170+
opts: HighlightBatchOptions,
171+
legacyPages?: HighlightPage[],
162172
): Promise<Map<string, HighlightResult> | null> {
163173
if (pages.length === 0) {
164174
return new Map();
@@ -170,7 +180,7 @@ export async function generateHighlightsBatch(
170180
try {
171181
const baseUrl = config.HIGHLIGHT_MODEL_URL!.replace(/\/$/, "");
172182
const res = await fetchBatchWithRetry(
173-
`${baseUrl}/batch_highlight`,
183+
`${baseUrl}${endpoint}`,
174184
{
175185
method: "POST",
176186
headers: requestHeaders(opts.requestId),
@@ -186,6 +196,7 @@ export async function generateHighlightsBatch(
186196
// endpoint, whose /batch_highlight contract uses {requests: [...]}. Keep
187197
// highlights available until infra switches the URL to GCP Stage 1.
188198
if (
199+
legacyPages &&
189200
opts.allowLegacyFallback !== false &&
190201
(res.status === 400 || res.status === 404)
191202
) {
@@ -196,7 +207,7 @@ export async function generateHighlightsBatch(
196207
return await generateLegacyHighlightsBatch(
197208
baseUrl,
198209
query,
199-
pages,
210+
legacyPages,
200211
controller.signal,
201212
opts,
202213
);
@@ -253,6 +264,33 @@ export async function generateHighlightsBatch(
253264
}
254265
}
255266

267+
export async function generateHighlightsBatch(
268+
query: string,
269+
pages: HighlightPage[],
270+
opts: HighlightBatchOptions,
271+
): Promise<Map<string, HighlightResult> | null> {
272+
return generateHighlightsBatchRequest(
273+
"/batch_highlight",
274+
query,
275+
pages,
276+
opts,
277+
pages,
278+
);
279+
}
280+
281+
export async function generateHighlightsIndexedBatch(
282+
query: string,
283+
pages: HighlightIndexedPage[],
284+
opts: Omit<HighlightBatchOptions, "allowLegacyFallback">,
285+
): Promise<Map<string, HighlightResult> | null> {
286+
return generateHighlightsBatchRequest(
287+
"/batch_highlight_indexed",
288+
query,
289+
pages,
290+
{ ...opts, allowLegacyFallback: false },
291+
);
292+
}
293+
256294
async function generateLegacyHighlightsBatch(
257295
baseUrl: string,
258296
query: string,

apps/api/src/search/highlights-shadow.test.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ vi.mock("../config", () => ({
77

88
vi.mock("./highlights", () => ({
99
highlightsEnvReady: vi.fn(() => true),
10-
applySearchHighlights: vi.fn(),
10+
searchHighlightURLs: vi.fn(() => []),
11+
runIndexedSearchHighlightsShadow: vi.fn(),
1112
}));
1213

1314
import { config } from "../config";
14-
import { applySearchHighlights, highlightsEnvReady } from "./highlights";
15+
import {
16+
highlightsEnvReady,
17+
runIndexedSearchHighlightsShadow,
18+
searchHighlightURLs,
19+
} from "./highlights";
1520
import { createSearchHighlightsShadowRunner } from "./highlights-shadow";
1621

1722
const logger = {
@@ -31,7 +36,7 @@ afterEach(() => {
3136

3237
describe("runSearchHighlightsShadow", () => {
3338
it("runs without applying results and emits a content-free canonical log", async () => {
34-
vi.mocked(applySearchHighlights).mockResolvedValue({
39+
vi.mocked(runIndexedSearchHighlightsShadow).mockResolvedValue({
3540
attempted: 10,
3641
indexHits: 7,
3742
replaced: 6,
@@ -49,17 +54,12 @@ describe("runSearchHighlightsShadow", () => {
4954
).toBe("started");
5055
await new Promise(resolve => setImmediate(resolve));
5156

52-
expect(applySearchHighlights).toHaveBeenCalledWith(
53-
{},
57+
expect(searchHighlightURLs).toHaveBeenCalledWith({});
58+
expect(runIndexedSearchHighlightsShadow).toHaveBeenCalledWith(
59+
[],
5460
"private query",
5561
expect.objectContaining({ silent: true }),
56-
{
57-
applyResults: false,
58-
suppressSummaryLog: true,
59-
suppressPayloadLog: true,
60-
allowLegacyFallback: false,
61-
requestId: "request-1",
62-
},
62+
"request-1",
6363
);
6464
expect(logger.info).toHaveBeenCalledWith(
6565
"Search highlights shadow completed",
@@ -84,7 +84,7 @@ describe("runSearchHighlightsShadow", () => {
8484
replaced: number;
8585
succeeded: boolean;
8686
}) => void;
87-
vi.mocked(applySearchHighlights).mockReturnValue(
87+
vi.mocked(runIndexedSearchHighlightsShadow).mockReturnValue(
8888
new Promise(resolve => {
8989
finish = resolve;
9090
}),
@@ -116,7 +116,7 @@ describe("runSearchHighlightsShadow", () => {
116116
});
117117

118118
it("emits the content-free failure category", async () => {
119-
vi.mocked(applySearchHighlights).mockResolvedValue({
119+
vi.mocked(runIndexedSearchHighlightsShadow).mockResolvedValue({
120120
attempted: 3,
121121
indexHits: 1,
122122
replaced: 0,
@@ -165,6 +165,6 @@ describe("runSearchHighlightsShadow", () => {
165165
runSearchHighlightsShadow({ ...input, zeroDataRetention: false }),
166166
).toBe("skipped");
167167

168-
expect(applySearchHighlights).not.toHaveBeenCalled();
168+
expect(runIndexedSearchHighlightsShadow).not.toHaveBeenCalled();
169169
});
170170
});

apps/api/src/search/highlights-shadow.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { createLogger, type Logger } from "winston";
22
import type { SearchV2Response } from "../lib/entities";
33
import { config } from "../config";
44
import { logger as rootLogger } from "../lib/logger";
5-
import { applySearchHighlights, highlightsEnvReady } from "./highlights";
5+
import {
6+
highlightsEnvReady,
7+
runIndexedSearchHighlightsShadow,
8+
searchHighlightURLs,
9+
} from "./highlights";
610

711
const CANONICAL_LOG = "search/highlights-shadow";
812
const shadowLogger = createLogger({ silent: true });
@@ -53,21 +57,17 @@ export function createSearchHighlightsShadowRunner(
5357
return "dropped";
5458
}
5559

60+
const urls = searchHighlightURLs(options.response);
61+
const { query, requestId, teamId } = options;
5662
inFlight++;
5763
const startedAt = Date.now();
58-
void applySearchHighlights(options.response, options.query, shadowLogger, {
59-
applyResults: false,
60-
suppressSummaryLog: true,
61-
suppressPayloadLog: true,
62-
allowLegacyFallback: false,
63-
requestId: options.requestId,
64-
})
64+
void runIndexedSearchHighlightsShadow(urls, query, shadowLogger, requestId)
6565
.then(result => {
6666
canonicalLogger.info("Search highlights shadow completed", {
6767
canonicalLog: CANONICAL_LOG,
6868
outcome: result.succeeded ? "completed" : "failed",
69-
requestId: options.requestId,
70-
teamId: options.teamId,
69+
requestId,
70+
teamId,
7171
attempted: result.attempted,
7272
indexHits: result.indexHits,
7373
wouldReplace: result.replaced,
@@ -82,8 +82,8 @@ export function createSearchHighlightsShadowRunner(
8282
canonicalLogger.warn("Search highlights shadow failed", {
8383
canonicalLog: CANONICAL_LOG,
8484
outcome: "failed",
85-
requestId: options.requestId,
86-
teamId: options.teamId,
85+
requestId,
86+
teamId,
8787
errorType: error instanceof Error ? error.name : "unknown",
8888
timeTakenMs: Date.now() - startedAt,
8989
inFlight,

apps/api/src/search/highlights.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,21 @@ vi.mock("../scraper/scrapeURL/lib/removeUnwantedElements", () => ({
3535

3636
vi.mock("./highlight-model", () => ({
3737
generateHighlightsBatch: vi.fn(),
38+
generateHighlightsIndexedBatch: vi.fn(),
3839
}));
3940

40-
import { generateHighlightsBatch } from "./highlight-model";
41+
import {
42+
generateHighlightsBatch,
43+
generateHighlightsIndexedBatch,
44+
} from "./highlight-model";
4145
import { config } from "../config";
4246
import { indexGetRecent5 } from "../db/rpc";
43-
import { applySearchHighlights, highlightsEnvReady } from "./highlights";
47+
import {
48+
applySearchHighlights,
49+
highlightsEnvReady,
50+
runIndexedSearchHighlightsShadow,
51+
searchHighlightURLs,
52+
} from "./highlights";
4453

4554
const logger = {
4655
info: vi.fn(),
@@ -56,6 +65,54 @@ afterEach(() => {
5665
vi.clearAllMocks();
5766
});
5867

68+
describe("runIndexedSearchHighlightsShadow", () => {
69+
it("resolves lightweight index references without loading page content", async () => {
70+
vi.mocked(generateHighlightsIndexedBatch).mockResolvedValue(
71+
new Map([["0", { highlights: [], markdown: "shadow highlight" }]]),
72+
);
73+
const response = {
74+
web: [{ url: "https://first.test", description: "fallback" }],
75+
news: [{ url: "https://second.test", snippet: "fallback" }],
76+
} as any;
77+
const urls = searchHighlightURLs(response);
78+
79+
const result = await runIndexedSearchHighlightsShadow(
80+
urls,
81+
"query",
82+
logger,
83+
"request-1",
84+
);
85+
86+
expect(generateHighlightsIndexedBatch).toHaveBeenCalledWith(
87+
"query",
88+
[
89+
{
90+
id: "0",
91+
url: "https://first.test",
92+
indexObject: "index:https://first.test.json",
93+
},
94+
{
95+
id: "1",
96+
url: "https://second.test",
97+
indexObject: "index:https://second.test.json",
98+
},
99+
],
100+
{
101+
logger,
102+
logPayload: false,
103+
requestId: "request-1",
104+
onFailure: expect.any(Function),
105+
},
106+
);
107+
expect(result).toEqual({
108+
attempted: 2,
109+
indexHits: 2,
110+
replaced: 1,
111+
succeeded: true,
112+
});
113+
});
114+
});
115+
59116
describe("applySearchHighlights", () => {
60117
it("enables the in-cluster service without requiring a bearer token", () => {
61118
const token = config.HIGHLIGHT_MODEL_TOKEN;

0 commit comments

Comments
 (0)