Add --valuable-results to search feedback by dtaivpp · Pull Request #164 · firecrawl/cli · GitHub
Skip to content
29 changes: 15 additions & 14 deletions README.md
18 changes: 11 additions & 7 deletions skills/firecrawl-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ Keep large search responses in `--json -o` output and select the relevant result
- **`--highlights` on by default:** results are query-relevant excerpts from the page. Use `--no-highlights` for the original snippets.
- **`--scrape` fetches full content** — reuse that content instead of re-scraping result URLs. This saves credits and avoids redundant fetches.
- For large results, use `-o` and bounded local reads when a filesystem is available. Do not dump the full response into context.
- Use `jq` to extract URLs or titles: `jq -r '.data.web[].url' .firecrawl/search.json`
- Use `jq` to extract URLs **with their source and 1-indexed position** (you'll need both for feedback): `jq -r '.data | to_entries[] | select(.key as $k | ["web","images","news"] | index($k)) | .key as $s | .value | to_entries[] | "\($s):\(.key + 1)\t\(.value.url // "-")"' .firecrawl/search.json`
Only those three groups are addressable by `--valuable-results`; `data.tools` and any other key are skipped, and an image or news result with no `url` prints `-` — address it by its position anyway.
- Naming convention: `.firecrawl/search-{query}.json` or `.firecrawl/search-{query}-scraped.json`

## Send feedback after every search (refunds 1 credit)
Expand All @@ -102,9 +103,10 @@ Search costs 2 credits. After you've actually used the results (or decided they

- **Time window:** must be sent within ~2 minutes of the search. Late feedback is rejected.
- **`--missing-content` is the most important field.** It's a list of _specific pieces_ of content you expected but did not find. One topic per entry, each in its own string. These aggregate across teams and tell us what to index next.
- **`--valuable-results` marks which results were useful.** Results come back grouped (`data.web`, `data.images`, `data.news`) and **each group is numbered from 1 independently**, so every entry is `source:position` — `web:1` and `news:1` are two different results. **Be exhaustive** — list every result that was actually useful; unlisted results are treated as not useful, so a partial list corrupts the signal. Reserve `--valuable-sources` for useful URLs that were NOT among the returned results (e.g. a page you found by following a result's link) — never report the same result in both.
- **Substantive content required** (zero-effort feedback is rejected with HTTP 400):
- `good` → must include at least one `--valuable-sources` entry.
- `partial` → must include `--valuable-sources` or `--missing-content`.
- `good` → must include `--valuable-results` or at least one `--valuable-sources` entry.
- `partial` → must include `--valuable-results`, `--valuable-sources`, or `--missing-content`.
- `bad` → must include `--missing-content` or `--query-suggestions`.
- **Daily refund cap (per team, per UTC day, default 100 credits).** Once your team has been refunded 100 credits today, further submissions still record feedback but no longer refund credits. The response includes `creditsRefundedToday` / `dailyRefundCap` / `dailyCapReached`. **When `dailyCapReached: true`, stop calling `search-feedback` for the rest of the UTC day** — it won't refund anything and you're wasting bandwidth.
- **Idempotent:** re-submitting for the same search id returns success but no extra refund.
Expand All @@ -113,13 +115,15 @@ Search costs 2 credits. After you've actually used the results (or decided they
Verify the search returned results before reading its `id`. Zero-result searches write no output file, so the file may be missing — or left over from an earlier search. The guard below skips feedback when the file is missing or has zero results; call `search-feedback` only inside it:

```bash
# Send once per search. Rate honestly and replace the placeholder with the
# rating that matches what actually happened. The two fields shown
# satisfy the substantive-content rule for every rating.
# Send once per search. Replace BOTH placeholders: the rating that matches
# what actually happened, and the exhaustive source:position list of the
# results that were genuinely useful (from the jq above). Never send the
# list below as-is -- marking results you did not use corrupts the signal.
# The two fields shown satisfy the substantive-content rule for every rating.
if SEARCH_ID=$(jq -er 'select(any(.data[]; length > 0)) | .id' .firecrawl/search-react-hooks.json); then
firecrawl search-feedback "$SEARCH_ID" \
--rating "<good|partial|bad>" \
--valuable-sources '[{"url":"https://react.dev/reference/react/hooks","reason":"Most authoritative"}]' \
--valuable-results "<source:position,... - every result you actually used>" \
--missing-content '[{"topic":"useDeferredValue","description":"No example of useDeferredValue with Suspense"}]' \
--silent &
fi
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/commands/feedback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
parseFeedbackListArg,
parsePageNumbersArg,
} from '../../commands/feedback';
import {
parseValuableResultsArg,
type ValuableResultInput,
} from '../../commands/search-feedback';
import { parseAlexandriaFeedbackArray } from '../../commands/alexandria-feedback';
import { getClient } from '../../utils/client';
import { initializeConfig } from '../../utils/config';
Expand Down Expand Up @@ -155,6 +159,45 @@ describe('executeEndpointFeedback', () => {
});
});

it('rejects --valuable-results on non-search endpoints', async () => {
const result = await executeEndpointFeedback({
endpoint: 'scrape',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'good',
valuableResults: [{ source: 'web', position: 1 }],
});

expect(result.success).toBe(false);
expect(result.error).toBe(
'--valuable-results is only supported for search feedback.'
);
expect(mockFetch).not.toHaveBeenCalled();
});

it('forwards valuableResults for search feedback', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ success: true, creditsRefunded: 1 }),
});

const valuableResults: ValuableResultInput[] = [
{ source: 'web', position: 1 },
{ source: 'news', position: 2 },
];
const result = await executeEndpointFeedback({
endpoint: 'search',
jobId: '0193f6c5-1234-7890-abcd-1234567890ab',
rating: 'good',
valuableResults,
});

expect(result.success).toBe(true);
const [, request] = mockFetch.mock.calls[0];
expect(JSON.parse(request.body).valuableResults).toEqual(valuableResults);
});

it('treats team opt-out as a disabled success', async () => {
mockFetch.mockResolvedValue({
ok: false,
Expand Down Expand Up @@ -341,6 +384,55 @@ describe('feedback parsing', () => {
expect(parsePageNumbersArg('1, 2, bad, -1, 3')).toEqual([1, 2, 3]);
expect(parsePageNumbersArg('[4,5]')).toEqual([4, 5]);
});

it('parses valuable results as source:position pairs', () => {
expect(parseValuableResultsArg('web:1, news:2')).toEqual([
{ source: 'web', position: 1 },
{ source: 'news', position: 2 },
]);
expect(parseValuableResultsArg('images:3')).toEqual([
{ source: 'images', position: 3 },
]);
});

it('parses valuable results from JSON, keeping reasons', () => {
expect(
parseValuableResultsArg(
'[{"source":"web","position":1,"reason":"Answered it"},{"source":"news","position":2}]'
)
).toEqual([
{ source: 'web', position: 1, reason: 'Answered it' },
{ source: 'news', position: 2 },
]);
});

// Each group is numbered from 1 independently, so a bare position does not
// identify a result.
it('rejects valuable results without a source', () => {
expect(() => parseValuableResultsArg('1,3')).toThrow(
'must be "source:position"'
);
expect(() => parseValuableResultsArg('[{"position":1}]')).toThrow(
'source must be one of'
);
});

it('rejects unknown sources and non-positive positions', () => {
expect(() => parseValuableResultsArg('video:1')).toThrow(
'source must be one of'
);
expect(() => parseValuableResultsArg('web:0')).toThrow(
'positions must be integers of 1 or greater'
);
expect(() => parseValuableResultsArg('web:abc')).toThrow(
'positions must be integers of 1 or greater'
);
});

it('returns undefined for empty input', () => {
expect(parseValuableResultsArg(undefined)).toBeUndefined();
expect(parseValuableResultsArg(' ')).toBeUndefined();
});
});

describe('parseAlexandriaFeedbackArray capability issues', () => {
Expand Down
15 changes: 15 additions & 0 deletions src/commands/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { getConfig, isCustomApiUrl, validateConfig } from '../utils/config';
import { getClient } from '../utils/client';
import {
parseMissingContentArg,
parseValuableResultsArg,
parseValuableSourcesArg,
type MissingContentInput,
type SearchFeedbackRating,
type ValuableResultInput,
type ValuableSourceInput,
} from './search-feedback';

Expand All @@ -25,6 +27,7 @@ export interface EndpointFeedbackOptions {
tags?: string[];
note?: string;
valuableSources?: ValuableSourceInput[];
valuableResults?: ValuableResultInput[];
missingContent?: MissingContentInput[];
querySuggestions?: string;
url?: string;
Expand Down Expand Up @@ -213,6 +216,7 @@ export function parseEndpointFeedbackCliOptions(options: {
metadata?: string;
metadataFile?: string;
valuableSources?: string;
valuableResults?: string;
missingContent?: string | string[];
rating?: string;
}) {
Expand All @@ -223,6 +227,7 @@ export function parseEndpointFeedbackCliOptions(options: {
pageNumbers: parsePageNumbersArg(options.pageNumbers),
metadata: parseMetadataArg(options.metadata, options.metadataFile),
valuableSources: parseValuableSourcesArg(options.valuableSources),
valuableResults: parseValuableResultsArg(options.valuableResults),
missingContent: parseMissingContentArg(options.missingContent),
};
}
Expand Down Expand Up @@ -265,6 +270,15 @@ export async function executeEndpointFeedback(
if (options.endpoint !== 'alexandria' && !options.jobId) {
throw new Error('Job feedback requires a job ID.');
}
// Positions in `valuableResults` are 1-indexed within a search result
// group (web/images/news), so they are meaningless anywhere else. Reject
// rather than drop: silently ignoring the flag would let a caller believe
// the results were recorded.
if (options.endpoint !== 'search' && options.valuableResults?.length) {
throw new Error(
'--valuable-results is only supported for search feedback.'
);
}
const entries: Array<[string, unknown]> =
options.endpoint === 'alexandria'
? [
Expand All @@ -279,6 +293,7 @@ export async function executeEndpointFeedback(
['tags', normalizeList(options.tags)],
['note', options.note],
['valuableSources', options.valuableSources],
['valuableResults', options.valuableResults],
['missingContent', options.missingContent],
['querySuggestions', options.querySuggestions],
['url', options.url],
Expand Down
110 changes: 110 additions & 0 deletions src/commands/search-feedback.ts
Loading
Loading