Skip to content

Commit f627d9f

Browse files
authored
feat(search): add staged highlights rollout (firecrawl#4041)
1 parent 9293920 commit f627d9f

12 files changed

Lines changed: 237 additions & 734 deletions

File tree

apps/api/src/config.ts

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

162-
// Search highlights (beta): highlighter service base URL. TOKEN is optional
162+
// Search highlights: highlighter service base URL. TOKEN is optional
163163
// bearer auth for legacy/external services; the in-cluster service omits it.
164164
HIGHLIGHT_MODEL_URL: z.string().optional(),
165165
HIGHLIGHT_MODEL_TOKEN: z.string().optional(),
166-
HIGHLIGHT_SHADOW_RATE: z.coerce.number().min(0).max(1).default(0),
166+
// Stable percentage of non-MCP/CLI cohorts whose generated highlights are
167+
// returned. The remaining eligible traffic still runs in shadow mode.
168+
HIGHLIGHT_ROLLOUT_PERCENT: z.coerce.number().min(0).max(100).default(0),
167169

168170
// Exchange (routed data sources service)
169171
FIRE_EXCHANGE_URL: z.url().optional(),

apps/api/src/controllers/v1/search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export async function searchController(
236236
{
237237
teamId: req.auth.team_id,
238238
origin: req.body.origin,
239+
integration: req.body.integration,
239240
apiKeyId: req.acuc?.api_key_id ?? null,
240241
flags: req.acuc?.flags ?? null,
241242
requestId: jobId,

apps/api/src/controllers/v1/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1319,7 +1319,6 @@ export type TeamFlags = {
13191319
// POST /v2/search/:jobId/feedback returns 403 TEAM_OPTED_OUT when true.
13201320
searchFeedbackOptOut?: boolean;
13211321
researchBeta?: boolean;
1322-
highlightsBeta?: boolean;
13231322
enrichBeta?: boolean;
13241323
professionalProfileCompanyDataBeta?: boolean;
13251324
organizationDataSourceAccess?: Record<
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { searchRequestSchema } from "./types";
3+
4+
describe("searchRequestSchema highlights", () => {
5+
it("preserves an omitted value for integration and rollout selection", () => {
6+
const request = searchRequestSchema.parse({ query: "firecrawl" });
7+
8+
expect(request.highlights).toBeUndefined();
9+
});
10+
11+
it("allows highlights to be enabled explicitly", () => {
12+
const request = searchRequestSchema.parse({
13+
query: "firecrawl",
14+
highlights: true,
15+
});
16+
17+
expect(request.highlights).toBe(true);
18+
});
19+
20+
it("allows highlights to be disabled explicitly", () => {
21+
const request = searchRequestSchema.parse({
22+
query: "firecrawl",
23+
highlights: false,
24+
});
25+
26+
expect(request.highlights).toBe(false);
27+
});
28+
});

apps/api/src/controllers/v2/search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export async function searchController(
219219
{
220220
teamId: req.auth.team_id,
221221
origin: req.body.origin,
222+
integration: req.body.integration,
222223
apiKeyId: req.acuc?.api_key_id ?? null,
223224
flags: req.acuc?.flags ?? null,
224225
requestId: agentRequestId ?? jobId,

apps/api/src/controllers/v2/types.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,7 +1560,6 @@ export type TeamFlags = {
15601560
debugBranding?: boolean;
15611561
maxBrowserSessions?: number;
15621562
researchBeta?: boolean;
1563-
highlightsBeta?: boolean;
15641563
menuBeta?: boolean;
15651564
enrichBeta?: boolean;
15661565
professionalProfileCompanyDataBeta?: boolean;
@@ -1960,10 +1959,10 @@ export const searchRequestSchema = z
19601959
timeout: z.int().positive().finite().prefault(60000),
19611960
ignoreInvalidURLs: z.boolean().optional().prefault(false),
19621961
asyncScraping: z.boolean().optional().prefault(false),
1963-
// Experimental: replace each result's snippet with query-relevant
1964-
// highlights pulled from our index (last 30 days), out-of-line from
1965-
// scrapeURL. Falls back to the provider snippet when the URL isn't indexed.
1966-
highlights: z.boolean().optional().prefault(false),
1962+
// Replace each result's snippet with query-relevant highlights pulled from
1963+
// our index. When omitted, the caller integration and rollout cohort decide
1964+
// whether generated highlights are returned or only run in shadow mode.
1965+
highlights: z.boolean().optional(),
19671966
__searchPreviewToken: z.string().optional(),
19681967
threatProtection: threatProtectionOverrideSchema.optional(),
19691968
scrapeOptions: baseScrapeOptions

apps/api/src/scraper/scrapeURL/transformers/menu.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export async function fetchMenu(
1212
}
1313

1414
// Beta gate: during beta the menu format is limited to teams with the menuBeta
15-
// flag. Fail closed and silent (mirrors highlightsBeta) -- a team without the
16-
// flag gets no menu field, no error, and no engine call.
15+
// flag. Fail closed and silent: a team without the flag gets no menu field,
16+
// no error, and no engine call.
1717
if (meta.internalOptions?.teamFlags?.menuBeta !== true) {
1818
meta.logger.info("menu format requested without menuBeta flag; skipping");
1919
return document;

apps/api/src/search/execute.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ import {
1313
mergeScrapedContent,
1414
calculateScrapeCredits,
1515
} from "./scrape";
16-
import { applyIndexedSearchHighlights, highlightsEnvReady } from "./highlights";
17-
import { runSearchHighlightsShadow } from "./highlights-shadow";
16+
import {
17+
highlightsEnvReady,
18+
runIndexedSearchHighlights,
19+
searchHighlightsMode,
20+
} from "./highlights";
1821
import { trackSearchResults, trackSearchRequest } from "../lib/tracking";
1922
import type { BillingMetadata } from "../services/billing/types";
2023
import type { ThreatProtectionPolicy } from "../lib/threat-protection/types";
2124
import { checkUrlsAgainstThreatPolicy } from "../lib/threat-protection/request";
2225
import { calculateThreatScanCredits } from "../lib/scrape-billing";
26+
import { config } from "../config";
27+
import { logger as rootLogger } from "../lib/logger";
2328

2429
interface SearchOptions {
2530
query: string;
@@ -42,6 +47,7 @@ interface SearchOptions {
4247
interface SearchContext {
4348
teamId: string;
4449
origin: string;
50+
integration?: string | null;
4551
apiKeyId: number | null;
4652
flags: TeamFlags;
4753
requestId: string;
@@ -241,32 +247,47 @@ export async function executeSearch(
241247
}
242248
}
243249

244-
// Experimental highlights beta: replace provider snippets with index-backed
245-
// highlights. Gated on (1) the request opting in, (2) the team's highlightsBeta
246-
// flag, and (3) all required envs being present (index DB, GCS, model). Any
247-
// gate failing => silently keep the provider snippets.
250+
// Every eligible request runs the same index-backed highlight path. MCP/CLI,
251+
// explicit opt-ins, and rollout-selected cohorts await and apply the result;
252+
// everyone else runs it in shadow without delaying or mutating the response.
248253
// Runs after scraping (mergeScrapedContent rebuilds the result objects, so
249254
// highlight mutations must come last to survive). Uses the user's original
250255
// query, not the domain-filtered upstream query.
251-
const shouldApplyHighlights =
252-
options.highlights &&
253-
flags?.highlightsBeta === true &&
254-
highlightsEnvReady();
255-
if (shouldApplyHighlights) {
256-
await applyIndexedSearchHighlights(
256+
if (zeroDataRetention !== true && !isZDR && highlightsEnvReady()) {
257+
const mode = searchHighlightsMode({
258+
requested: options.highlights,
259+
origin: context.origin,
260+
integration: context.integration,
261+
cohortKey:
262+
context.apiKeyId !== null
263+
? `api-key:${context.apiKeyId}`
264+
: `team:${context.teamId}`,
265+
rolloutPercent: config.HIGHLIGHT_ROLLOUT_PERCENT,
266+
});
267+
const highlightRun = runIndexedSearchHighlights(
257268
searchResponse,
258269
query,
259270
logger,
260-
context.requestId,
271+
{
272+
mode,
273+
requestId: context.requestId,
274+
teamId: context.teamId,
275+
},
261276
);
262-
} else {
263-
runSearchHighlightsShadow({
264-
response: searchResponse,
265-
query,
266-
requestId: context.requestId,
267-
teamId,
268-
zeroDataRetention: zeroDataRetention === true || isZDR === true,
269-
});
277+
278+
if (mode === "apply") {
279+
await highlightRun;
280+
} else {
281+
void highlightRun.catch(error => {
282+
rootLogger.warn("Search highlights shadow failed", {
283+
canonicalLog: "search/highlights",
284+
mode,
285+
requestId: context.requestId,
286+
teamId: context.teamId,
287+
errorType: error instanceof Error ? error.name : "unknown",
288+
});
289+
});
290+
}
270291
}
271292

272293
const scrapeFormats = scrapeOptions?.formats

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

Lines changed: 0 additions & 154 deletions
This file was deleted.

0 commit comments

Comments
 (0)