Skip to content

Commit e029022

Browse files
feat(monitoring): add user defined goal with llm validation (firecrawl#3567)
* feat(changeTracking): add optional AI judge ("goal") to classify diffs as meaningful or noise Adds a single new optional field on the changeTracking format — `goal` — that opts a caller into an AI judge. When set AND changeStatus === "changed", the judge classifies the diff as meaningful (matches the goal) or noise (page churn) and attaches the result to `document.changeTracking.judgment`. Backwards compatible: callers that don't set `goal` see no change in response shape or behavior. `changeStatus` is never overridden. - types.ts: new `goal: string (≤2000)` on changeTrackingFormatWithOptions, new optional `judgment` on Document.changeTracking (v1 + v2) - transformers/judgeChange.ts: gemini-2.5-flash-lite via @ai-sdk/google; goal is primary signal, markdown diff is source of truth, json diff augments when present; security guard against page-content injection - transformers/diff.ts: fires judge at end of "changed" branch when goal is set; fails open if judge errors - tests: unit tests (live Gemini, gated on GOOGLE_GENERATIVE_AI_API_KEY) for whitespace/timestamp noise, real price/MacBook signal, markdown-mode diffs; snips covering API contract + first-run no-judgment behavior * feat(monitoring): wire goal + judgment through monitor backend Threads the new changeTracking.goal field from the monitor record into each scrape call, extracts the resulting judgment from the response, and persists it to monitor_check_pages.judgment. Adds a derived monitor.page.meaningful webhook event so subscribers can opt into filtered notifications. Existing monitor.page webhook continues to fire on every page transition regardless of judgment — backwards compatible. - types.ts: add `goal` to createMonitorSchema + MonitorRow, add `judgment` to MonitorCheckPageInsert, register `monitor.page.meaningful` event - runner.ts: inject monitor.goal into the changeTracking format options inside withMonitorScrapeDefaults - results.ts: extractJudgmentFromDoc + derivePageWebhookEvents helpers, persist judgment on insertMonitorCheckPages, fire monitor.page.meaningful when judge classifies the change as meaningful Depends on (separate PRs): - firecrawl-db migration adding monitors.goal + monitor_check_pages.judgment - monitor CREATE/UPDATE endpoints accepting + persisting goal - store.ts SELECT statements adding `goal` to the monitor row column list * refactor(monitoring): move AI judge from scrape pipeline into monitor service The judge is fundamentally a monitoring concern — it filters alerts based on a per-monitor goal. Putting it on the public scrape API surface meant every SDK and consumer of /v2/scrape had to know about goal/judgment for a feature only the monitor service uses. Moving it keeps the scrape API unchanged and lets the judge evolve internally. Reverted (now back to upstream behavior): - controllers/{v1,v2}/types.ts: drop `goal` from changeTracking format options, drop `judgment` from Document.changeTracking - scraper/scrapeURL/transformers/diff.ts: drop the judge call - __tests__/snips/v2/change-tracking-judge.test.ts: deleted Moved + adapted: - services/monitoring/judgeChange.ts (was transformers/judgeChange.ts): takes a plain Logger instead of Meta — no longer coupled to scrapeURL - services/monitoring/judgeChange.test.ts: same 7 live-Gemini tests, imports updated Wired into monitor pipeline: - services/monitoring/diff-orchestrator.ts: accepts goal + extractionPrompt, calls judgeChange when goal is set + status==changed, returns judgment on the result - services/monitoring/runner.ts + results.ts: thread monitor.goal and the changeTracking format's prompt through to the orchestrator, persist resulting judgment to monitor_check_pages New tests: - services/monitoring/page-events.{ts,test.ts} (6 tests): pure-logic derivation of webhook events from status + judgment; backwards-compat guarantee that every event set always contains monitor.page - services/monitoring/diff-orchestrator.test.ts (5 tests): judge gating with mocked judge + GCS — skips when goal absent, skips on new/same, fires on changed, swallows judge errors All 41 tests across the monitoring service pass. * chore(diff): strip leftover blank line in transformers/diff.ts * fix(monitoring): persist goal on create + update (codex P1) Codex review caught: the create/update schemas accept `goal` but store.ts never wrote it. The runner reads monitor.goal to decide whether to call the judge, so without persistence the entire judge path was unreachable for monitors configured via the API. - createMonitor: include `goal: input.goal ?? null` in the INSERT - updateMonitor: patch `goal` when present in input (?? null so explicit clears work) * feat(monitoring): gate emails on judgment + surface meaningful/noise in body When a monitor has `goal` set and ALL changed pages were judged noise (and no new/removed/error activity), the summary email is suppressed. Otherwise the email fires with judgment info shown inline: - Summary line: "Changed: 12 (2 meaningful, 10 noise)" when judge ran - Per-row "[meaningful]" / "[noise]" badge next to status - Per-row reason as muted text below - Meaningful pages float to the top of the list Backwards compat: monitors without `goal` see no behavior change — no gating, no badges, identical email to today. - notification/monitoring_email.ts: gating + body enrichment - notification/monitoring_email.test.ts: 5 new tests covering gating paths - monitoring/runner.ts: thread judgment from PageResult into the email page payload * tighten judge prompt + harden webhook + judge call prompt: sync the v5 system prompt that came out of the desktop eval suite — five-rule structure with explicit hard-noise / goal-override / named-field / default-meaningful / default-noise tiers, diff context awareness, and a single-quoted reason citation format that doesn't trip JSON.parse. eval went from 60% to ~95% on a 184-fixture suite across news, research, finance, ecom, docs, jobs, social, sports, regulatory, weather verticals. parser: add a regex-fallback path when JSON.parse fails so a stray double quote in the reason no longer collapses to "default meaningful" via the outer catch. timeout: wrap the Gemini call in an AbortController with a 15s cap so a hung judge call can't stall the monitor pipeline for a URL. webhooks: add MONITOR_PAGE_MEANINGFUL to the WebhookEvent enum + data map, drop the as-any cast in results.ts, and fan out the two events with Promise.all instead of awaiting them serially. tests: two new live-gemini cases pin the named-field rule. * address codex + auggie review findings - email gating: pass the full filtered page list to sendMonitoringEmailSummary so the noise-vs-meaningful decision sees every changed page. previously the runner sliced to 25 before gating, which could suppress alerts when a meaningful page sat past position 25. the email renderer still caps for display. - goal normalization: trim incoming goals and treat empty/whitespace-only as null. users who leave the field blank no longer get quietly opted into AI judging. - migration safety: only include the goal column in the create-monitor insert when the caller provided one. keeps create working in environments where the firecrawl-db migration hasn't landed yet. - judge retries: wrap the gemini call in a 3-attempt loop with 8s per-attempt timeout and jittered backoff. only transient errors (429, 5xx, timeouts, network blips) retry. previously a single transient failure collapsed to "default meaningful". * split judge_enabled from goal text users want to write a goal as a persistent draft and toggle the AI judge on and off without losing that text. introduce a separate judge_enabled boolean on monitors; the judge only runs when judge_enabled is true and the goal is non-empty. - types: add judgeEnabled to create/update schemas, add judge_enabled to MonitorRow - store: persist judge_enabled on insert (only when caller provided one, matching the goal-column defense) and on patch - runner + results: gate the judge call on judge_enabled && goal so toggling off skips the LLM entirely - email: gate the noise-suppression on judge_enabled (with goal) so monitors with a saved-but-disabled goal still get email-on-any-change - test: extend the email gating fixture to set judge_enabled when goal is set * fix(monitoring): only suppress email when changed-page list is complete The caller of sendMonitoringEmailSummary passes a paginated subset (limit 100). On a check with 101+ changed pages where the first 100 were judged noise, the gate would suppress the email even if an unseen page was meaningful. Compare changedPages.length against check.changed_count and fail open when the list is truncated. Also drop unused export on BlockContext to unblock knip pre-commit. * fix(monitoring): drop fragile error/parse fallbacks in judge - isTransientJudgeError now keys off structured fields only (AbortError/TimeoutError name, Node syscall code whitelist, HTTP 408/425/429 + 5xx). Removes message-substring matching that over-fired on "network" and missed locale variants. - Drop the regex-based JSON parse fallback. If JSON.parse fails we now return the same fail-open default used elsewhere instead of fabricating a judgment from a malformed response. * chore(monitoring): consolidate tests + drop noisy comments - Merge judgeChange.test, diff-orchestrator.test, page-events.test into one meaningful-monitoring.test.ts. Cuts redundant cases (e.g. the derivePageWebhookEvents matrix collapses to one assertion). - Remove restate-the-what comments throughout. Keep only WHY comments (truncation safety, forward-compat insert) and trim those. * fix(monitoring): strict meaningful parse + split judge tests - Replace Boolean(parsed.meaningful) with strict ===true/===false check; default to true (fail open) when the field is missing or malformed. Avoids flipping a string 'false' or 0 into a spurious noise verdict. - Split judgeChange tests back into their own file. The previous consolidation broke the live-Gemini tests because the meaningful-monitoring file mocks ./judgeChange at module load, which would intercept the calls. * Remove transient judge error checks; add monitor fields Remove the isTransientJudgeError helper and associated special-case logic from judgeChange.ts: the judge call now simply retries up to JUDGE_MAX_ATTEMPTS and uses the same backoff/jitter; logging and the fallback reason message were simplified accordingly. Add new optional monitor fields to SDK types so monitors can carry a goal and a flag to enable/disable the judge: in the JS SDK add goal and judgeEnabled to CreateMonitorRequest and UpdateMonitorRequest; in the Python SDK add goal and judge_enabled (alias judgeEnabled) to MonitorCreateRequest and MonitorUpdateRequest. These fields are optional and intended to allow configuring monitor goals and toggling the judge behavior. * Include diff text/json in monitoring payloads Expose computed diffs through the monitoring pipeline: add diffText and diffJson to the MonitorPageDiffResult type, populate them in computeAndPersistPageDiff (when available), and thread them through to sendMonitorPageWebhook. The webhook payload now includes a diff object (text and/or json) so downstream consumers receive the actual change content alongside existing metadata. * feat(monitoring): bill +1 credit per judge invocation - billTeam fires after each successful judge call in recordMonitorScrapeSuccess. Only changed pages on goal+ judge_enabled monitors incur the extra credit; unchanged pages or monitors without a goal cost the same as before. - estimateMonitorCreditsPerRun accepts judgeEnabled; doubles the per-run estimate as the upper bound when on, so the monthly figure stored on monitor rows reflects the true ceiling cost. Wired through create + update paths. - Embed diff text/json into the monitor.page webhook payload so consumers don't need a follow-up API call for the diff. * fix(monitoring): merged credit re-estimate + valid billing metadata - updateMonitor now reads the existing row and merges patch inputs with current state before re-estimating credits. Three regressions this addresses: 1. Goal/judge-only updates were skipped entirely because the recalc was gated on `patch.targets` being present. 2. A targets-only update would drop the existing judge multiplier because judgeEnabled wasn't merged in. 3. Schedule-only updates didn't re-estimate either. Also handles intervalMs falling back to the merged cron. - billTeam now receives a proper BillingMetadata ({endpoint: "monitor", jobId: checkId}) instead of an ad-hoc object cast through `as any`, so endpoint attribution in Autumn stays correct. * fix(monitoring): plug API surface holes for goal + judgment - Zod accepts goal: null on create/update so clearing the goal via PATCH no longer 400s. - serializeMonitor returns goal + judgeEnabled so clients can read back what they wrote. - Check-page list response includes judgment so consumers fetching diff history get the same data as the webhook. - JS + Python SDK Monitor / MonitorCheckPage types gain goal, judgeEnabled, judgment. * feat(monitoring): render the unified diff inline in summary email - buildHtml emits a styled <pre> block per page when diffText is present: green-tinted + lines, red-tinted - lines, purple hunk headers, capped at 24 lines / 200 chars per line with a "… N more lines" footer. - runner now loads up to 5 meaningful-changed diff artifacts from GCS in parallel before calling sendMonitoringEmailSummary and threads the text through. Per-page errors swallowed so a single GCS blip doesn't drop the entire email. * fix(monitoring): include judge multiplier in per-check credit reservation createMonitorCheck was calling estimateMonitorCreditsPerRun without the judgeEnabled flag, so the per-run estimated_credits was the scrape-only cost. Final billing was correct (results.ts bills +1 per judge call) but the Autumn reservation lock under-reserved on judge-heavy monitors near their credit ceiling. Now we pass the same merged judge-on flag the monthly estimate uses on monitor row create/update, keeping the lock and the final bill in sync. * chore(webhook): declare judgment + diff on MonitorPageData The send site (results.ts::sendMonitorPageWebhook) has been including these fields in the payload for both monitor.page and monitor.page.meaningful events, but the public TS interface omitted them and the send call cast through any. Now the interface accurately describes the wire payload: judgment?: { meaningful, confidence, reason, fields } | null diff?: { text?, json? } | null Same shapes as the JS/Python SDKs' MonitorCheckPage types so a consumer typing their webhook handler from this interface gets the full shape. * chore(judge): make 'you see only the diff' framing explicit Adds a short framing paragraph at the top of the system prompt spelling out two things the judge had to infer before: - it's part of a long-running monitor comparing consecutive scrapes - the full page is not available, only the diff + surrounding context ~50 tokens. Doesn't fix any specific failing case in the eval — it anchors the model's mental model upfront so reasons no longer drift into 'the page now shows X' phrasing as if the model had access to the rendered page. * collapse monitor.page.meaningful into monitor.page with isMeaningful flag * auto-enable judge when goal is set * Nick: * NIck: --------- Co-authored-by: Nicolas <20311743+nickscamara@users.noreply.github.com>
1 parent 2656051 commit e029022

37 files changed

Lines changed: 1266 additions & 80 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ function serializeMonitor(monitor: any) {
7979
retentionDays: monitor.retention_days,
8080
estimatedCreditsPerMonth: monitor.estimated_credits_per_month,
8181
lastCheckSummary: monitor.last_check_summary,
82+
goal: monitor.goal ?? null,
83+
judgeEnabled: Boolean(monitor.judge_enabled),
8284
createdAt: monitor.created_at,
8385
updatedAt: monitor.updated_at,
8486
};
@@ -401,6 +403,7 @@ export async function getMonitorCheckController(
401403
statusCode: page.status_code,
402404
error: page.error,
403405
metadata: page.metadata,
406+
judgment: page.judgment ?? null,
404407
createdAt: page.created_at,
405408
};
406409
if (!artifact) {

apps/api/src/services/monitoring/diff-orchestrator.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { v7 as uuidv7 } from "uuid";
2+
import { logger as rootLogger } from "../../lib/logger";
23
import { getJobFromGCS } from "../../lib/gcs-jobs";
34
import {
45
MonitorDiffArtifact,
@@ -11,14 +12,25 @@ import {
1112
formatsRequestGitDiff,
1213
formatsRequestJsonExtraction,
1314
} from "./diff";
15+
import { judgeChange } from "./judgeChange";
1416

1517
type MonitorPageDiffStatus = "same" | "new" | "changed";
1618

19+
type Judgment = {
20+
meaningful: boolean;
21+
confidence: "high" | "medium" | "low";
22+
reason: string;
23+
fields: string[];
24+
};
25+
1726
type MonitorPageDiffResult = {
1827
status: MonitorPageDiffStatus;
1928
diffGcsKey: string | null;
2029
diffTextBytes: number | null;
2130
diffJsonBytes: number | null;
31+
judgment?: Judgment;
32+
diffText?: string;
33+
diffJson?: Record<string, { previous: unknown; current: unknown }>;
2234
};
2335

2436
type PreviousPageRef = {
@@ -48,9 +60,21 @@ export async function computeAndPersistPageDiff(params: {
4860
doc: any;
4961
previous: PreviousPageRef | null;
5062
formats: unknown;
63+
goal?: string | null;
64+
extractionPrompt?: string | null;
5165
}): Promise<MonitorPageDiffResult> {
52-
const { teamId, monitorId, checkId, url, scrapeId, doc, previous, formats } =
53-
params;
66+
const {
67+
teamId,
68+
monitorId,
69+
checkId,
70+
url,
71+
scrapeId,
72+
doc,
73+
previous,
74+
formats,
75+
goal,
76+
extractionPrompt,
77+
} = params;
5478

5579
const wantsJson = formatsRequestJsonExtraction(formats);
5680
const wantsGitDiff = formatsRequestGitDiff(formats);
@@ -130,11 +154,28 @@ export async function computeAndPersistPageDiff(params: {
130154
...(markdownSidecar ? { markdown: markdownSidecar } : {}),
131155
};
132156
const sizes = await saveMonitorDiffArtifact(diffGcsKey, artifact);
157+
const judgment = goal
158+
? await runJudge({
159+
goal,
160+
extractionPrompt,
161+
jsonDiff: result.status === "changed" ? result.json : undefined,
162+
markdownDiff: markdownSidecar
163+
? {
164+
previous: previousDoc?.markdown ?? "",
165+
current: doc?.markdown ?? "",
166+
diffText: markdownSidecar.text,
167+
}
168+
: undefined,
169+
})
170+
: undefined;
133171
return {
134172
status: "changed",
135173
diffGcsKey,
136174
diffTextBytes: sizes.textBytes,
137175
diffJsonBytes: sizes.jsonBytes,
176+
...(judgment ? { judgment } : {}),
177+
...(result.status === "changed" ? { diffJson: result.json } : {}),
178+
...(markdownSidecar ? { diffText: markdownSidecar.text } : {}),
138179
};
139180
}
140181

@@ -181,10 +222,47 @@ export async function computeAndPersistPageDiff(params: {
181222
json: diff.json,
182223
};
183224
const sizes = await saveMonitorDiffArtifact(diffGcsKey, artifact);
225+
const judgment = goal
226+
? await runJudge({
227+
goal,
228+
extractionPrompt,
229+
markdownDiff: {
230+
previous: previousMarkdown,
231+
current: currentMarkdown,
232+
diffText: diff.text,
233+
},
234+
})
235+
: undefined;
184236
return {
185237
status: "changed",
186238
diffGcsKey,
187239
diffTextBytes: sizes.textBytes,
188240
diffJsonBytes: sizes.jsonBytes,
241+
...(judgment ? { judgment } : {}),
242+
diffText: diff.text,
243+
};
244+
}
245+
246+
async function runJudge(args: {
247+
goal: string;
248+
extractionPrompt?: string | null;
249+
jsonDiff?: Record<string, { previous: unknown; current: unknown }>;
250+
markdownDiff?: {
251+
previous: string;
252+
current: string;
253+
diffText?: string;
189254
};
255+
}): Promise<Judgment | undefined> {
256+
try {
257+
return await judgeChange({
258+
logger: rootLogger.child({ module: "monitoring-judge" }),
259+
goal: args.goal,
260+
extractionPrompt: args.extractionPrompt ?? undefined,
261+
jsonDiff: args.jsonDiff,
262+
markdownDiff: args.markdownDiff,
263+
});
264+
} catch (error) {
265+
rootLogger.error("Judge call failed", { error });
266+
return undefined;
267+
}
190268
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { judgeChange } from "./judgeChange";
2+
import { logger as winstonLogger } from "../../lib/logger";
3+
4+
const HAS_GEMINI = !!process.env.GOOGLE_GENERATIVE_AI_API_KEY;
5+
const describeIfGemini = HAS_GEMINI ? describe : describe.skip;
6+
const TEST_TIMEOUT = 30000;
7+
const buildLogger = () => winstonLogger.child({ test: "judgeChange" });
8+
9+
describe("judgeChange — input validation (no LLM call)", () => {
10+
it("returns low-confidence meaningful when no diff payload is provided", async () => {
11+
const result = await judgeChange({
12+
logger: buildLogger(),
13+
goal: "anything",
14+
});
15+
expect(result.meaningful).toBe(true);
16+
expect(result.confidence).toBe("low");
17+
expect(result.fields).toEqual([]);
18+
});
19+
});
20+
21+
describeIfGemini("judgeChange — live Gemini", () => {
22+
it(
23+
"classifies whitespace-only field change as noise",
24+
async () => {
25+
const result = await judgeChange({
26+
logger: buildLogger(),
27+
goal: "Track the page heading verbatim",
28+
jsonDiff: {
29+
headline: {
30+
previous: "Power AI agents with clean web data",
31+
current: "Power AI agents with clean web data",
32+
},
33+
},
34+
});
35+
expect(result.meaningful).toBe(false);
36+
},
37+
TEST_TIMEOUT,
38+
);
39+
40+
it(
41+
"named-field rule: sub-1% price change is meaningful when goal names 'price'",
42+
async () => {
43+
const result = await judgeChange({
44+
logger: buildLogger(),
45+
goal: "Track the Pro tier price. Tell me about ANY price change.",
46+
jsonDiff: {
47+
pro_price: { previous: "$19.00", current: "$19.01" },
48+
},
49+
});
50+
expect(result.meaningful).toBe(true);
51+
},
52+
TEST_TIMEOUT,
53+
);
54+
55+
it(
56+
"named-field rule does NOT apply to unmentioned fields",
57+
async () => {
58+
const result = await judgeChange({
59+
logger: buildLogger(),
60+
goal: "Track the Pro tier price.",
61+
jsonDiff: {
62+
view_count: { previous: "12402", current: "12418" },
63+
},
64+
});
65+
expect(result.meaningful).toBe(false);
66+
},
67+
TEST_TIMEOUT,
68+
);
69+
70+
it(
71+
"markdown: new list item matching the goal is meaningful",
72+
async () => {
73+
const result = await judgeChange({
74+
logger: buildLogger(),
75+
goal: "tell me when a new MacBook is announced",
76+
markdownDiff: {
77+
previous:
78+
"# MacBook lineup\n- MacBook Air M2\n- MacBook Pro M3\n\nUpdated 2026-05-19T18:42:00Z",
79+
current:
80+
"# MacBook lineup\n- MacBook Air M4 — NEW\n- MacBook Air M2\n- MacBook Pro M3\n\nUpdated 2026-05-19T18:43:01Z",
81+
diffText:
82+
"@@ -1,4 +1,5 @@\n # MacBook lineup\n+- MacBook Air M4 — NEW\n - MacBook Air M2\n - MacBook Pro M3\n \n-Updated 2026-05-19T18:42:00Z\n+Updated 2026-05-19T18:43:01Z",
83+
},
84+
});
85+
expect(result.meaningful).toBe(true);
86+
expect(result.reason.toLowerCase()).toMatch(/macbook|m4|new/);
87+
},
88+
TEST_TIMEOUT,
89+
);
90+
});

0 commit comments

Comments
 (0)