Skip to content

Commit b113bec

Browse files
authored
feat(search): batch query highlights (firecrawl#3993)
1 parent b1bd74a commit b113bec

5 files changed

Lines changed: 444 additions & 76 deletions

File tree

apps/api/src/config.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,8 @@ const configSchema = z.object({
159159
CLICKHOUSE_ANALYTICS_URL: z.string().optional(),
160160
CLICKHOUSE_ANALYTICS_DATABASE: z.string().optional(),
161161

162-
// Search highlights (beta): query-highlights model service. URL is the base
163-
// (e.g. https://firecrawl--query-highlights.modal.run); TOKEN is the bearer
164-
// token sent on every request.
162+
// Search highlights (beta): highlighter service base URL. TOKEN is optional
163+
// bearer auth for legacy/external services; the in-cluster service omits it.
165164
HIGHLIGHT_MODEL_URL: z.string().optional(),
166165
HIGHLIGHT_MODEL_TOKEN: z.string().optional(),
167166

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

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

8-
import { generateHighlights } from "./highlight-model";
8+
import { generateHighlightsBatch } from "./highlight-model";
9+
import { config } from "../config";
910

1011
const logger = {
1112
info: vi.fn(),
@@ -30,34 +31,81 @@ afterEach(() => {
3031
vi.clearAllMocks();
3132
});
3233

33-
describe("generateHighlights", () => {
34-
it("posts the query and full markdown to /highlight with the bearer token", async () => {
35-
const fetchMock = mockFetchOnce({ highlights: [], markdown: "" });
34+
describe("generateHighlightsBatch", () => {
35+
it("posts every page to one /batch_highlight call with the bearer token", async () => {
36+
const fetchMock = mockFetchOnce({ pages: [] });
3637

37-
await generateHighlights("q1", "# Page\n\nbody text", { logger });
38+
await generateHighlightsBatch(
39+
"q1",
40+
[
41+
{ id: "0", markdown: "# First\n\nbody text" },
42+
{ id: "1", markdown: "# Second\n\nmore text" },
43+
],
44+
{ logger },
45+
);
3846

3947
expect(fetchMock).toHaveBeenCalledTimes(1);
4048
const [url, init] = fetchMock.mock.calls[0];
41-
expect(url).toBe("https://highlight.test/highlight");
49+
expect(url).toBe("https://highlight.test/batch_highlight");
4250
expect(init.method).toBe("POST");
4351
expect(init.headers.Authorization).toBe("Bearer secret-token");
4452

4553
const sent = JSON.parse(init.body);
46-
expect(sent).toEqual({ query: "q1", markdown: "# Page\n\nbody text" });
54+
expect(sent).toEqual({
55+
query: "q1",
56+
pages: [
57+
{ id: "0", markdown: "# First\n\nbody text" },
58+
{ id: "1", markdown: "# Second\n\nmore text" },
59+
],
60+
});
4761
});
4862

49-
it("returns the highlights and reassembled markdown from the service", async () => {
63+
it("omits bearer auth for the in-cluster service when no token is configured", async () => {
64+
const token = config.HIGHLIGHT_MODEL_TOKEN;
65+
config.HIGHLIGHT_MODEL_TOKEN = undefined;
66+
const fetchMock = mockFetchOnce({ pages: [] });
67+
68+
try {
69+
await generateHighlightsBatch("q1", [{ id: "0", markdown: "markdown" }], {
70+
logger,
71+
});
72+
} finally {
73+
config.HIGHLIGHT_MODEL_TOKEN = token;
74+
}
75+
76+
const [, init] = fetchMock.mock.calls[0];
77+
expect(init.headers).toEqual({ "Content-Type": "application/json" });
78+
});
79+
80+
it("returns successful pages keyed by ID and leaves missing pages absent", async () => {
5081
mockFetchOnce({
51-
highlights: [
52-
{ block_index: 0, score: 0.9 },
53-
{ block_index: 2, score: 0.5 },
82+
pages: [
83+
{
84+
id: "1",
85+
cache: "hit",
86+
output: {
87+
highlights: [
88+
{ block_index: 0, score: 0.9 },
89+
{ block_index: 2, score: 0.5 },
90+
],
91+
markdown: "reassembled answer",
92+
},
93+
},
94+
{ id: "invalid-without-output" },
5495
],
55-
markdown: "reassembled answer",
5696
});
5797

58-
const out = await generateHighlights("q", "some markdown", { logger });
98+
const out = await generateHighlightsBatch(
99+
"q",
100+
[
101+
{ id: "0", markdown: "missing" },
102+
{ id: "1", markdown: "some markdown" },
103+
],
104+
{ logger },
105+
);
59106

60-
expect(out).toEqual({
107+
expect(out?.get("0")).toBeUndefined();
108+
expect(out?.get("1")).toEqual({
61109
highlights: [
62110
{ block_index: 0, score: 0.9 },
63111
{ block_index: 2, score: 0.5 },
@@ -66,37 +114,117 @@ describe("generateHighlights", () => {
66114
});
67115
});
68116

69-
it("debug-logs the highlights array", async () => {
117+
it("debug-logs batch coverage and highlights", async () => {
70118
mockFetchOnce({
71-
highlights: [{ block_index: 1, score: 0.8 }],
72-
markdown: "x",
119+
pages: [
120+
{
121+
id: "page-1",
122+
output: {
123+
highlights: [{ block_index: 1, score: 0.8 }],
124+
markdown: "x",
125+
},
126+
},
127+
],
73128
});
74129

75-
await generateHighlights("q", "md", { logger });
130+
await generateHighlightsBatch("q", [{ id: "page-1", markdown: "md" }], {
131+
logger,
132+
});
76133

77134
expect(logger.debug).toHaveBeenCalledWith(
78-
"query highlights",
135+
"query highlights batch",
79136
expect.objectContaining({
80137
canonicalLog: "search/highlights",
81-
highlights: [{ block_index: 1, score: 0.8 }],
138+
requestedPages: 1,
139+
returnedPages: 1,
140+
pages: [
141+
{
142+
id: "page-1",
143+
highlights: [{ block_index: 1, score: 0.8 }],
144+
},
145+
],
82146
}),
83147
);
84148
});
85149

86-
it("defaults missing fields to empty highlights and markdown", async () => {
150+
it("defaults missing pages to an empty result map", async () => {
87151
mockFetchOnce({});
88152

89-
const out = await generateHighlights("q", "md", { logger });
153+
const out = await generateHighlightsBatch(
154+
"q",
155+
[{ id: "0", markdown: "md" }],
156+
{ logger },
157+
);
90158

91-
expect(out).toEqual({ highlights: [], markdown: "" });
159+
expect(out).toEqual(new Map());
160+
});
161+
162+
it("does not call the service for an empty batch", async () => {
163+
const fetchMock = vi.fn();
164+
vi.stubGlobal("fetch", fetchMock);
165+
166+
const out = await generateHighlightsBatch("q", [], { logger });
167+
168+
expect(fetchMock).not.toHaveBeenCalled();
169+
expect(out).toEqual(new Map());
92170
});
93171

94172
it("returns null when the service errors", async () => {
95173
mockFetchOnce({ error: "boom" }, false, 500);
96174

97-
const out = await generateHighlights("q", "md", { logger });
175+
const out = await generateHighlightsBatch(
176+
"q",
177+
[{ id: "0", markdown: "md" }],
178+
{ logger },
179+
);
98180

99181
expect(out).toBeNull();
100182
expect(logger.warn).toHaveBeenCalled();
101183
});
184+
185+
it("falls back to legacy per-page calls while the old service URL is configured", async () => {
186+
const fetchMock = vi
187+
.fn()
188+
.mockResolvedValueOnce({
189+
ok: false,
190+
status: 400,
191+
text: async () => "legacy batch contract",
192+
})
193+
.mockResolvedValueOnce({
194+
ok: true,
195+
status: 200,
196+
json: async () => ({ markdown: "first legacy highlight" }),
197+
text: async () => "",
198+
})
199+
.mockResolvedValueOnce({
200+
ok: false,
201+
status: 500,
202+
json: async () => ({}),
203+
text: async () => "page failed",
204+
});
205+
vi.stubGlobal("fetch", fetchMock);
206+
207+
const out = await generateHighlightsBatch(
208+
"q",
209+
[
210+
{ id: "0", markdown: "first" },
211+
{ id: "1", markdown: "second" },
212+
],
213+
{ logger },
214+
);
215+
216+
expect(fetchMock).toHaveBeenCalledTimes(3);
217+
expect(fetchMock.mock.calls.map(call => call[0])).toEqual([
218+
"https://highlight.test/batch_highlight",
219+
"https://highlight.test/highlight",
220+
"https://highlight.test/highlight",
221+
]);
222+
expect(out).toEqual(
223+
new Map([["0", { highlights: [], markdown: "first legacy highlight" }]]),
224+
);
225+
expect(logger.warn).toHaveBeenCalledWith(
226+
"legacy query highlight failed",
227+
expect.objectContaining({ pageId: "1" }),
228+
);
229+
});
102230
});

0 commit comments

Comments
 (0)