@librechat/agents 3.7.9 → 3.7.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/tools/search/metrics.cjs +234 -0
- package/dist/cjs/tools/search/metrics.cjs.map +1 -0
- package/dist/cjs/tools/search/rerankers.cjs +80 -68
- package/dist/cjs/tools/search/rerankers.cjs.map +1 -1
- package/dist/cjs/tools/search/search.cjs +95 -45
- package/dist/cjs/tools/search/search.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +77 -49
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/cjs/tools/search/utils.cjs.map +1 -1
- package/dist/esm/tools/search/metrics.mjs +234 -0
- package/dist/esm/tools/search/metrics.mjs.map +1 -0
- package/dist/esm/tools/search/rerankers.mjs +80 -68
- package/dist/esm/tools/search/rerankers.mjs.map +1 -1
- package/dist/esm/tools/search/search.mjs +96 -46
- package/dist/esm/tools/search/search.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +77 -49
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/esm/tools/search/utils.mjs.map +1 -1
- package/dist/types/tools/search/metrics.d.ts +15 -0
- package/dist/types/tools/search/rerankers.d.ts +30 -5
- package/dist/types/tools/search/tool.d.ts +7 -1
- package/dist/types/tools/search/types.d.ts +88 -4
- package/dist/types/tools/search/utils.d.ts +2 -10
- package/package.json +1 -1
- package/src/tools/search/metrics.ts +400 -0
- package/src/tools/search/rerankers.ts +160 -97
- package/src/tools/search/search.ts +139 -56
- package/src/tools/search/tool.ts +126 -62
- package/src/tools/search/types.ts +104 -4
- package/src/tools/search/utils.ts +2 -10
|
@@ -291,7 +291,9 @@ export interface JinaRerankerResult {
|
|
|
291
291
|
}
|
|
292
292
|
export interface JinaRerankerResponse {
|
|
293
293
|
model: string;
|
|
294
|
-
|
|
294
|
+
/** Telemetry only, and absent from some Jina-compatible endpoints — never
|
|
295
|
+
* dereference it on a path that would discard a usable ranking. */
|
|
296
|
+
usage?: {
|
|
295
297
|
total_tokens: number;
|
|
296
298
|
};
|
|
297
299
|
results: JinaRerankerResult[];
|
|
@@ -303,12 +305,13 @@ export interface CohereRerankerResult {
|
|
|
303
305
|
export interface CohereRerankerResponse {
|
|
304
306
|
results: CohereRerankerResult[];
|
|
305
307
|
id: string;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
+
/** Telemetry only; see {@link JinaRerankerResponse.usage}. */
|
|
309
|
+
meta?: {
|
|
310
|
+
api_version?: {
|
|
308
311
|
version: string;
|
|
309
312
|
is_experimental: boolean;
|
|
310
313
|
};
|
|
311
|
-
billed_units
|
|
314
|
+
billed_units?: {
|
|
312
315
|
search_units: number;
|
|
313
316
|
};
|
|
314
317
|
};
|
|
@@ -343,6 +346,81 @@ export interface RagApiRerankResponse {
|
|
|
343
346
|
}
|
|
344
347
|
export type SafeSearchLevel = 0 | 1 | 2;
|
|
345
348
|
export type Logger = WinstonLogger;
|
|
349
|
+
/** Compact, redacted view of a thrown error, safe to hand to a logger. */
|
|
350
|
+
export interface SafeErrorLog {
|
|
351
|
+
message: string;
|
|
352
|
+
name?: string;
|
|
353
|
+
code?: string;
|
|
354
|
+
status?: number;
|
|
355
|
+
method?: string;
|
|
356
|
+
url?: string;
|
|
357
|
+
responseDataSummary?: string;
|
|
358
|
+
value?: string;
|
|
359
|
+
}
|
|
360
|
+
/** Why a rerank returned the candidates' original order instead of a ranking. */
|
|
361
|
+
export type RerankFallback = 'no_api_key' | 'no_base_url' | 'no_token_supplier' | 'bad_response' | 'invalid_results' | 'chunk_error' | 'placeholder' | 'error';
|
|
362
|
+
/** One provider query. `results` is the row count that query contributed. */
|
|
363
|
+
export interface SearchObservation {
|
|
364
|
+
provider: string;
|
|
365
|
+
type: string;
|
|
366
|
+
results: number;
|
|
367
|
+
durationMs: number;
|
|
368
|
+
error?: string;
|
|
369
|
+
/** The query rejected rather than reporting failure in its response. Those
|
|
370
|
+
* were logged at error level before they were aggregated, so the summary
|
|
371
|
+
* has to carry the distinction to keep that severity. */
|
|
372
|
+
thrown?: boolean;
|
|
373
|
+
}
|
|
374
|
+
/** One scraped link. A failure carries `error`; a success carries the sizes
|
|
375
|
+
* the scrape produced, so the summary can report both without a second pass. */
|
|
376
|
+
export interface ScrapeObservation {
|
|
377
|
+
url: string;
|
|
378
|
+
chars?: number;
|
|
379
|
+
highlights?: number;
|
|
380
|
+
error?: string;
|
|
381
|
+
}
|
|
382
|
+
/** One reranker round trip. A search reranks once per scraped source, so
|
|
383
|
+
* these fold into a single summary rather than logging per source. */
|
|
384
|
+
export interface RerankObservation {
|
|
385
|
+
provider: string;
|
|
386
|
+
chunks: number;
|
|
387
|
+
results: number;
|
|
388
|
+
durationMs: number;
|
|
389
|
+
model?: string;
|
|
390
|
+
/** Provider-reported usage: Jina tokens, Cohere billed search units. */
|
|
391
|
+
units?: number;
|
|
392
|
+
/** Chunks a provider candidate cap dropped before submission. */
|
|
393
|
+
dropped?: number;
|
|
394
|
+
/** Set only when a provider cap reduced the requested result count, so a
|
|
395
|
+
* search returning fewer highlights than configured says why. */
|
|
396
|
+
topK?: number;
|
|
397
|
+
topKLimit?: number;
|
|
398
|
+
reason?: RerankFallback;
|
|
399
|
+
error?: SafeErrorLog;
|
|
400
|
+
}
|
|
401
|
+
/** Per-rerank state threaded from the start of a call to whichever exit it
|
|
402
|
+
* takes, so every path records exactly one observation. */
|
|
403
|
+
export interface RerankRun {
|
|
404
|
+
metrics: SearchMetrics;
|
|
405
|
+
documents: string[];
|
|
406
|
+
topK: number;
|
|
407
|
+
startedAt: number;
|
|
408
|
+
model?: string;
|
|
409
|
+
units?: number;
|
|
410
|
+
dropped?: number;
|
|
411
|
+
topKLimit?: number;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Fold-as-you-go counters for one `web_search` call. Recording is O(1) and
|
|
415
|
+
* allocation-free past a bounded reason map; {@link SearchMetrics.flush}
|
|
416
|
+
* emits at most one line per phase that actually ran.
|
|
417
|
+
*/
|
|
418
|
+
export interface SearchMetrics {
|
|
419
|
+
recordSearch(observation: SearchObservation): void;
|
|
420
|
+
recordScrape(observation: ScrapeObservation): void;
|
|
421
|
+
recordRerank(observation: RerankObservation): void;
|
|
422
|
+
flush(): void;
|
|
423
|
+
}
|
|
346
424
|
export interface SearchToolConfig extends SearchConfig, ProcessSourcesConfig, FirecrawlConfig {
|
|
347
425
|
tavilyScraperOptions?: TavilyScraperConfig;
|
|
348
426
|
crwScraperOptions?: CrwScraperConfig;
|
|
@@ -572,6 +650,9 @@ export interface FirecrawlScraperConfig extends BaseSearchProviderConfig {
|
|
|
572
650
|
onlyMainContent?: boolean;
|
|
573
651
|
changeTrackingOptions?: object;
|
|
574
652
|
}
|
|
653
|
+
/** Result kind a parallel sub-search covers; `web` is the untyped main
|
|
654
|
+
* search, which every provider serves without a `type` parameter. */
|
|
655
|
+
export type SubSearchType = 'web' | 'images' | 'videos' | 'news';
|
|
575
656
|
export type GetSourcesParams = {
|
|
576
657
|
query: string;
|
|
577
658
|
date?: DATE_RANGE;
|
|
@@ -870,6 +951,9 @@ export type ProcessSourcesFields = {
|
|
|
870
951
|
news: boolean;
|
|
871
952
|
proMode: boolean;
|
|
872
953
|
onGetHighlights: SearchToolConfig['onGetHighlights'];
|
|
954
|
+
/** Collector owned by the caller; when omitted, one is created and flushed
|
|
955
|
+
* for this call so a direct `processSources` still summarizes itself. */
|
|
956
|
+
metrics?: SearchMetrics;
|
|
873
957
|
};
|
|
874
958
|
export interface SearchToolSchema {
|
|
875
959
|
type: 'object';
|
|
@@ -1,14 +1,6 @@
|
|
|
1
|
+
import type { SafeErrorLog } from './types';
|
|
1
2
|
import type * as t from './types';
|
|
2
|
-
export
|
|
3
|
-
message: string;
|
|
4
|
-
name?: string;
|
|
5
|
-
code?: string;
|
|
6
|
-
status?: number;
|
|
7
|
-
method?: string;
|
|
8
|
-
url?: string;
|
|
9
|
-
responseDataSummary?: string;
|
|
10
|
-
value?: string;
|
|
11
|
-
}
|
|
3
|
+
export type { SafeErrorLog } from './types';
|
|
12
4
|
/**
|
|
13
5
|
* Creates a default logger that maps to console methods
|
|
14
6
|
* Uses a singleton pattern to avoid creating multiple instances
|
package/package.json
CHANGED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type * as t from './types';
|
|
2
|
+
|
|
3
|
+
/** Distinct failure reasons kept per phase before the tail folds into
|
|
4
|
+
* `other`: enough to characterize a mixed failure without letting a
|
|
5
|
+
* pathological run grow one map entry per source. */
|
|
6
|
+
const MAX_REASON_KEYS = 6;
|
|
7
|
+
/** Failing hosts named in a summary before the rest are counted only. */
|
|
8
|
+
const MAX_FAILURE_SAMPLES = 3;
|
|
9
|
+
/** Cap on the raw provider message carried alongside a summary. */
|
|
10
|
+
const MAX_DETAIL_LENGTH = 200;
|
|
11
|
+
/** Cap on a provider-supplied label placed inside a summary line, so a
|
|
12
|
+
* malformed response cannot stretch a fixed-width line without bound. */
|
|
13
|
+
const MAX_LABEL_LENGTH = 48;
|
|
14
|
+
const OTHER_REASON = 'other';
|
|
15
|
+
|
|
16
|
+
const HTTP_STATUS_REGEX = /\b(?:status(?:\scode)?|http)\D{0,3}(\d{3})\b/i;
|
|
17
|
+
|
|
18
|
+
/** Free-form provider messages have unbounded cardinality; the aggregate only
|
|
19
|
+
* keeps a normalized class so the reason map stays small and comparable. */
|
|
20
|
+
const REASON_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
21
|
+
[/ECONNABORTED|ETIMEDOUT|timeout|timed out/i, 'timeout'],
|
|
22
|
+
[/ENOTFOUND|EAI_AGAIN|getaddrinfo/i, 'dns'],
|
|
23
|
+
[/ECONNREFUSED|ECONNRESET|EPIPE|socket hang up/i, 'connection'],
|
|
24
|
+
[/CERT_|SSL|TLS|self[- ]signed/i, 'tls'],
|
|
25
|
+
[/abort|cancel/i, 'aborted'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const classifyFailure = (message?: string): string => {
|
|
29
|
+
if (message == null || message === '') {
|
|
30
|
+
return 'unknown';
|
|
31
|
+
}
|
|
32
|
+
const status = HTTP_STATUS_REGEX.exec(message);
|
|
33
|
+
if (status != null) {
|
|
34
|
+
return `http_${status[1]}`;
|
|
35
|
+
}
|
|
36
|
+
for (const [pattern, reason] of REASON_PATTERNS) {
|
|
37
|
+
if (pattern.test(message)) {
|
|
38
|
+
return reason;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return OTHER_REASON;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const bump = (counts: Map<string, number>, rawKey: string): void => {
|
|
45
|
+
const key = label(rawKey);
|
|
46
|
+
const existing = counts.get(key);
|
|
47
|
+
if (existing != null) {
|
|
48
|
+
counts.set(key, existing + 1);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (counts.size >= MAX_REASON_KEYS) {
|
|
52
|
+
counts.set(OTHER_REASON, (counts.get(OTHER_REASON) ?? 0) + 1);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
counts.set(key, 1);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const hostOf = (url: string): string => {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
61
|
+
} catch {
|
|
62
|
+
return url.slice(0, 60);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const formatCount = (value: number): string =>
|
|
67
|
+
value < 10000 ? String(value) : `${(value / 1000).toFixed(1)}k`;
|
|
68
|
+
|
|
69
|
+
const formatMs = (ms: number): string =>
|
|
70
|
+
ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
71
|
+
|
|
72
|
+
const formatMap = (counts: Map<string, number>): string => {
|
|
73
|
+
let out = '';
|
|
74
|
+
for (const [key, count] of counts) {
|
|
75
|
+
out += `${out === '' ? '' : ','}${key}:${count}`;
|
|
76
|
+
}
|
|
77
|
+
return `{${out}}`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const truncate = (value: string, max: number): string =>
|
|
81
|
+
value.length <= max ? value : `${value.slice(0, max)}…`;
|
|
82
|
+
|
|
83
|
+
/** Newlines, bidi overrides, and other non-printing characters let a remote
|
|
84
|
+
* payload split one summary into several physical lines or forge a second
|
|
85
|
+
* apparent entry, so they never survive into a log message. */
|
|
86
|
+
const UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}"]+/gu;
|
|
87
|
+
|
|
88
|
+
const sanitize = (value: string): string => value.replace(UNPRINTABLE, ' ');
|
|
89
|
+
|
|
90
|
+
/** Every provider-supplied string that reaches a summary line or a map key
|
|
91
|
+
* goes through here: the phases promise one bounded line, and neither a
|
|
92
|
+
* remote payload nor an external caller's label is safe on its own. */
|
|
93
|
+
const label = (value: string): string =>
|
|
94
|
+
truncate(sanitize(value), MAX_LABEL_LENGTH);
|
|
95
|
+
|
|
96
|
+
const detailOf = (value: string): string =>
|
|
97
|
+
truncate(sanitize(value), MAX_DETAIL_LENGTH);
|
|
98
|
+
|
|
99
|
+
/** A phase carries one provider label. Nothing in this package mixes them,
|
|
100
|
+
* but a wrong label is worse than an honest one if that ever changes. */
|
|
101
|
+
const mergeProvider = (current: string, incoming: string): string =>
|
|
102
|
+
current === incoming ? current : 'mixed';
|
|
103
|
+
|
|
104
|
+
interface SearchPhase {
|
|
105
|
+
provider: string;
|
|
106
|
+
queries: number;
|
|
107
|
+
failed: number;
|
|
108
|
+
errors: number;
|
|
109
|
+
durationMs: number;
|
|
110
|
+
types: Map<string, number>;
|
|
111
|
+
reasons: Map<string, number>;
|
|
112
|
+
detail?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface ScrapePhase {
|
|
116
|
+
links: number;
|
|
117
|
+
ok: number;
|
|
118
|
+
empty: number;
|
|
119
|
+
chars: number;
|
|
120
|
+
highlights: number;
|
|
121
|
+
failed: number;
|
|
122
|
+
reasons: Map<string, number>;
|
|
123
|
+
samples: string[];
|
|
124
|
+
detail?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface RerankPhase {
|
|
128
|
+
provider: string;
|
|
129
|
+
model?: string;
|
|
130
|
+
calls: number;
|
|
131
|
+
fallbacks: number;
|
|
132
|
+
errors: number;
|
|
133
|
+
chunks: number;
|
|
134
|
+
maxChunks: number;
|
|
135
|
+
dropped: number;
|
|
136
|
+
topK?: number;
|
|
137
|
+
topKLimit?: number;
|
|
138
|
+
results: number;
|
|
139
|
+
units: number;
|
|
140
|
+
durationMs: number;
|
|
141
|
+
maxDurationMs: number;
|
|
142
|
+
reasons: Map<string, number>;
|
|
143
|
+
error?: t.SafeErrorLog;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Creates the counter set for one `web_search` call.
|
|
148
|
+
*
|
|
149
|
+
* The pipeline reranks once per scraped source and scrapes once per link, so
|
|
150
|
+
* logging at those points scales the log with the result count. Every phase
|
|
151
|
+
* records into fixed-width counters instead and {@link t.SearchMetrics.flush}
|
|
152
|
+
* emits one line per phase, at the highest severity that phase reached.
|
|
153
|
+
*
|
|
154
|
+
* @param autoFlush emit on every record, for a caller with no enclosing search
|
|
155
|
+
* to fold into (a reranker used directly). Recording is synchronous, so the
|
|
156
|
+
* record/flush/reset cycle can never interleave with a concurrent call.
|
|
157
|
+
*/
|
|
158
|
+
export const createSearchMetrics = (
|
|
159
|
+
logger: t.Logger,
|
|
160
|
+
autoFlush = false
|
|
161
|
+
): t.SearchMetrics => {
|
|
162
|
+
let search: SearchPhase | undefined;
|
|
163
|
+
let scrape: ScrapePhase | undefined;
|
|
164
|
+
let rerank: RerankPhase | undefined;
|
|
165
|
+
|
|
166
|
+
const flushSearch = (phase: SearchPhase): void => {
|
|
167
|
+
let line =
|
|
168
|
+
`[web_search] search=${label(phase.provider)}` +
|
|
169
|
+
` queries=${phase.queries}`;
|
|
170
|
+
if (phase.types.size > 0) {
|
|
171
|
+
line += ` results=${formatMap(phase.types)}`;
|
|
172
|
+
}
|
|
173
|
+
line += ` dur=${formatMs(phase.durationMs)}`;
|
|
174
|
+
if (phase.failed === 0) {
|
|
175
|
+
logger.debug(line);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
line += ` failed=${phase.failed} reasons=${formatMap(phase.reasons)}`;
|
|
179
|
+
/** A rejected query was an error-level event before it was aggregated;
|
|
180
|
+
* a provider that merely reported failure was not. */
|
|
181
|
+
if (phase.errors === 0) {
|
|
182
|
+
logger.warn(line);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (phase.detail == null) {
|
|
186
|
+
logger.error(line);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
logger.error(line, phase.detail);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const flushScrape = (phase: ScrapePhase): void => {
|
|
193
|
+
let line =
|
|
194
|
+
`[web_search] scrape links=${phase.links} ok=${phase.ok}` +
|
|
195
|
+
` chars=${formatCount(phase.chars)} highlights=${phase.highlights}`;
|
|
196
|
+
if (phase.empty > 0) {
|
|
197
|
+
line += ` empty=${phase.empty}`;
|
|
198
|
+
}
|
|
199
|
+
if (phase.failed === 0) {
|
|
200
|
+
logger.debug(line);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
line += ` failed=${phase.failed} reasons=${formatMap(phase.reasons)}`;
|
|
204
|
+
if (phase.samples.length > 0) {
|
|
205
|
+
line += ` sample=${phase.samples.join(',')}`;
|
|
206
|
+
}
|
|
207
|
+
if (phase.detail == null) {
|
|
208
|
+
logger.error(line);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
logger.error(line, phase.detail);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const flushRerank = (phase: RerankPhase): void => {
|
|
215
|
+
let line =
|
|
216
|
+
`[web_search] rerank=${label(phase.provider)} calls=${phase.calls}` +
|
|
217
|
+
` chunks=${phase.chunks} maxChunks=${phase.maxChunks}` +
|
|
218
|
+
` results=${phase.results}` +
|
|
219
|
+
` dur=${formatMs(phase.durationMs)}` +
|
|
220
|
+
` maxDur=${formatMs(phase.maxDurationMs)}`;
|
|
221
|
+
if (phase.model != null) {
|
|
222
|
+
/** The only free-form remote value in the line, so it is the one field
|
|
223
|
+
* that gets delimited — a truncated payload cannot then read as
|
|
224
|
+
* further fields. */
|
|
225
|
+
line += ` model="${label(phase.model)}"`;
|
|
226
|
+
}
|
|
227
|
+
if (phase.units > 0) {
|
|
228
|
+
line += ` units=${phase.units}`;
|
|
229
|
+
}
|
|
230
|
+
if (phase.dropped > 0) {
|
|
231
|
+
line += ` dropped=${phase.dropped}`;
|
|
232
|
+
}
|
|
233
|
+
if (phase.topKLimit != null) {
|
|
234
|
+
line += ` topK=${phase.topK} topKLimit=${phase.topKLimit}`;
|
|
235
|
+
}
|
|
236
|
+
if (phase.fallbacks === 0) {
|
|
237
|
+
logger.debug(line);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
line += ` fallbacks=${phase.fallbacks} reasons=${formatMap(phase.reasons)}`;
|
|
241
|
+
/** A placeholder reranker returns input order by design, so a phase whose
|
|
242
|
+
* only fallbacks are placeholders is the configuration working — it stays
|
|
243
|
+
* at the debug level it had before these counters existed. */
|
|
244
|
+
const degraded = phase.fallbacks - (phase.reasons.get('placeholder') ?? 0);
|
|
245
|
+
if (degraded === 0 && phase.errors === 0) {
|
|
246
|
+
logger.debug(line);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (phase.errors === 0) {
|
|
250
|
+
logger.warn(line);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (phase.error == null) {
|
|
254
|
+
logger.error(line);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
logger.error(line, phase.error);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const flush = (): void => {
|
|
261
|
+
if (search != null) {
|
|
262
|
+
flushSearch(search);
|
|
263
|
+
search = undefined;
|
|
264
|
+
}
|
|
265
|
+
if (scrape != null) {
|
|
266
|
+
flushScrape(scrape);
|
|
267
|
+
scrape = undefined;
|
|
268
|
+
}
|
|
269
|
+
if (rerank != null) {
|
|
270
|
+
flushRerank(rerank);
|
|
271
|
+
rerank = undefined;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const recordSearch = (observation: t.SearchObservation): void => {
|
|
276
|
+
search ??= {
|
|
277
|
+
provider: observation.provider,
|
|
278
|
+
queries: 0,
|
|
279
|
+
failed: 0,
|
|
280
|
+
errors: 0,
|
|
281
|
+
durationMs: 0,
|
|
282
|
+
types: new Map(),
|
|
283
|
+
reasons: new Map(),
|
|
284
|
+
};
|
|
285
|
+
search.provider = mergeProvider(search.provider, observation.provider);
|
|
286
|
+
search.queries += 1;
|
|
287
|
+
/** Sub-searches run concurrently, so the phase lasts as long as its
|
|
288
|
+
* slowest query rather than the sum of all of them. */
|
|
289
|
+
search.durationMs = Math.max(search.durationMs, observation.durationMs);
|
|
290
|
+
if (observation.error != null) {
|
|
291
|
+
search.failed += 1;
|
|
292
|
+
/** Provider messages are prose: classify before counting, or equivalent
|
|
293
|
+
* failures never collapse and the raw text lands in the summary. */
|
|
294
|
+
bump(
|
|
295
|
+
search.reasons,
|
|
296
|
+
`${observation.type}:${classifyFailure(observation.error)}`
|
|
297
|
+
);
|
|
298
|
+
/** `detail` is only ever logged on the error path, so it is captured
|
|
299
|
+
* only from a thrown observation — otherwise an earlier soft failure's
|
|
300
|
+
* message would be attached to an exception it has nothing to do
|
|
301
|
+
* with, and the real one dropped. */
|
|
302
|
+
if (observation.thrown === true) {
|
|
303
|
+
search.errors += 1;
|
|
304
|
+
search.detail ??= detailOf(observation.error);
|
|
305
|
+
}
|
|
306
|
+
} else if (observation.results > 0) {
|
|
307
|
+
const type = label(observation.type);
|
|
308
|
+
const seen = search.types.get(type) ?? 0;
|
|
309
|
+
search.types.set(type, seen + observation.results);
|
|
310
|
+
}
|
|
311
|
+
if (autoFlush) {
|
|
312
|
+
flush();
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const recordScrape = (observation: t.ScrapeObservation): void => {
|
|
317
|
+
scrape ??= {
|
|
318
|
+
links: 0,
|
|
319
|
+
ok: 0,
|
|
320
|
+
empty: 0,
|
|
321
|
+
chars: 0,
|
|
322
|
+
highlights: 0,
|
|
323
|
+
failed: 0,
|
|
324
|
+
reasons: new Map(),
|
|
325
|
+
samples: [],
|
|
326
|
+
detail: undefined,
|
|
327
|
+
};
|
|
328
|
+
scrape.links += 1;
|
|
329
|
+
if (observation.error != null) {
|
|
330
|
+
scrape.failed += 1;
|
|
331
|
+
const reason = classifyFailure(observation.error);
|
|
332
|
+
bump(scrape.reasons, reason);
|
|
333
|
+
if (scrape.samples.length < MAX_FAILURE_SAMPLES) {
|
|
334
|
+
scrape.samples.push(label(`${hostOf(observation.url)}:${reason}`));
|
|
335
|
+
}
|
|
336
|
+
scrape.detail ??= detailOf(observation.error);
|
|
337
|
+
} else {
|
|
338
|
+
scrape.ok += 1;
|
|
339
|
+
const chars = observation.chars ?? 0;
|
|
340
|
+
scrape.chars += chars;
|
|
341
|
+
scrape.highlights += observation.highlights ?? 0;
|
|
342
|
+
if (chars === 0) {
|
|
343
|
+
scrape.empty += 1;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (autoFlush) {
|
|
347
|
+
flush();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const recordRerank = (observation: t.RerankObservation): void => {
|
|
352
|
+
rerank ??= {
|
|
353
|
+
provider: observation.provider,
|
|
354
|
+
calls: 0,
|
|
355
|
+
fallbacks: 0,
|
|
356
|
+
errors: 0,
|
|
357
|
+
chunks: 0,
|
|
358
|
+
maxChunks: 0,
|
|
359
|
+
dropped: 0,
|
|
360
|
+
results: 0,
|
|
361
|
+
units: 0,
|
|
362
|
+
durationMs: 0,
|
|
363
|
+
maxDurationMs: 0,
|
|
364
|
+
reasons: new Map(),
|
|
365
|
+
};
|
|
366
|
+
rerank.provider = mergeProvider(rerank.provider, observation.provider);
|
|
367
|
+
rerank.calls += 1;
|
|
368
|
+
rerank.chunks += observation.chunks;
|
|
369
|
+
rerank.maxChunks = Math.max(rerank.maxChunks, observation.chunks);
|
|
370
|
+
rerank.results += observation.results;
|
|
371
|
+
rerank.dropped += observation.dropped ?? 0;
|
|
372
|
+
rerank.topK ??= observation.topK;
|
|
373
|
+
rerank.topKLimit ??= observation.topKLimit;
|
|
374
|
+
rerank.units += observation.units ?? 0;
|
|
375
|
+
rerank.durationMs += observation.durationMs;
|
|
376
|
+
rerank.maxDurationMs = Math.max(
|
|
377
|
+
rerank.maxDurationMs,
|
|
378
|
+
observation.durationMs
|
|
379
|
+
);
|
|
380
|
+
if (observation.model != null) {
|
|
381
|
+
rerank.model =
|
|
382
|
+
rerank.model == null
|
|
383
|
+
? observation.model
|
|
384
|
+
: mergeProvider(rerank.model, observation.model);
|
|
385
|
+
}
|
|
386
|
+
if (observation.reason != null) {
|
|
387
|
+
rerank.fallbacks += 1;
|
|
388
|
+
bump(rerank.reasons, observation.reason);
|
|
389
|
+
}
|
|
390
|
+
if (observation.error != null) {
|
|
391
|
+
rerank.errors += 1;
|
|
392
|
+
rerank.error ??= observation.error;
|
|
393
|
+
}
|
|
394
|
+
if (autoFlush) {
|
|
395
|
+
flush();
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
return { recordSearch, recordScrape, recordRerank, flush };
|
|
400
|
+
};
|