@cyanheads/epa-mcp-server 0.4.0 → 0.4.1
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/AGENTS.md +5 -5
- package/CLAUDE.md +5 -5
- package/Dockerfile +3 -3
- package/README.md +24 -19
- package/changelog/0.4.x/0.4.1.md +34 -0
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/resources/definitions/facility.resource.d.ts +8 -1
- package/dist/mcp-server/resources/definitions/facility.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/facility.resource.js +13 -7
- package/dist/mcp-server/resources/definitions/facility.resource.js.map +1 -1
- package/dist/mcp-server/resources/definitions/index.d.ts +7 -1
- package/dist/mcp-server/resources/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts +30 -2
- package/dist/mcp-server/tools/definitions/get-facility.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-facility.tool.js +208 -43
- package/dist/mcp-server/tools/definitions/get-facility.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts +4 -2
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js +12 -5
- package/dist/mcp-server/tools/definitions/get-tri-releases.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/index.d.ts +116 -16
- package/dist/mcp-server/tools/definitions/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-facilities.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-facilities.tool.js +34 -16
- package/dist/mcp-server/tools/definitions/search-facilities.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts +3 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.js +11 -1
- package/dist/mcp-server/tools/definitions/search-superfund.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts +7 -0
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js +21 -9
- package/dist/mcp-server/tools/definitions/search-tri-releases.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts +20 -3
- package/dist/mcp-server/tools/definitions/search-violations.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-violations.tool.js +84 -15
- package/dist/mcp-server/tools/definitions/search-violations.tool.js.map +1 -1
- package/dist/services/airnow/airnow-service.d.ts.map +1 -1
- package/dist/services/airnow/airnow-service.js +9 -3
- package/dist/services/airnow/airnow-service.js.map +1 -1
- package/dist/services/call-budget.d.ts +22 -0
- package/dist/services/call-budget.d.ts.map +1 -0
- package/dist/services/call-budget.js +28 -0
- package/dist/services/call-budget.js.map +1 -0
- package/dist/services/dmap/dmap-service.d.ts +43 -13
- package/dist/services/dmap/dmap-service.d.ts.map +1 -1
- package/dist/services/dmap/dmap-service.js +270 -68
- package/dist/services/dmap/dmap-service.js.map +1 -1
- package/dist/services/echo/echo-service.d.ts +38 -11
- package/dist/services/echo/echo-service.d.ts.map +1 -1
- package/dist/services/echo/echo-service.js +417 -223
- package/dist/services/echo/echo-service.js.map +1 -1
- package/dist/services/echo/types.d.ts +167 -80
- package/dist/services/echo/types.d.ts.map +1 -1
- package/dist/services/ejscreen/ejscreen-service.d.ts.map +1 -1
- package/dist/services/ejscreen/ejscreen-service.js +9 -3
- package/dist/services/ejscreen/ejscreen-service.js.map +1 -1
- package/package.json +6 -6
- package/server.json +3 -3
|
@@ -4,52 +4,190 @@
|
|
|
4
4
|
* and enforcement case search.
|
|
5
5
|
* @module services/echo/echo-service
|
|
6
6
|
*/
|
|
7
|
-
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
|
|
7
|
+
import { JsonRpcErrorCode, McpError, notFound, serviceUnavailable, } from '@cyanheads/mcp-ts-core/errors';
|
|
8
8
|
import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
|
|
9
9
|
import { getServerConfig } from '../../config/server-config.js';
|
|
10
|
-
|
|
10
|
+
import { callBudget } from '../call-budget.js';
|
|
11
|
+
/**
|
|
12
|
+
* Budget for one getFacility call — every get_dfr attempt plus the status lookup — kept under
|
|
13
|
+
* the 60 s default request timeout of MCP SDK clients.
|
|
14
|
+
*/
|
|
15
|
+
const FACILITY_DEADLINE_MS = 50_000;
|
|
16
|
+
/** Ceiling for the best-effort p_frs status lookup within that budget. */
|
|
17
|
+
const STATUS_LOOKUP_DEADLINE_MS = 10_000;
|
|
18
|
+
/**
|
|
19
|
+
* Budget for one searchFacilities or searchViolations call — the discovery request, its get_qid
|
|
20
|
+
* page, and every retry and backoff of either — kept under the 60 s default request timeout of
|
|
21
|
+
* MCP SDK clients. A legitimate `p_ct=Los Angeles` facility search takes 54.6–56.7 s, so the
|
|
22
|
+
* budget keeps it and leaves 2 s for transport.
|
|
23
|
+
*/
|
|
24
|
+
const SEARCH_DEADLINE_MS = 58_000;
|
|
25
|
+
/**
|
|
26
|
+
* The facility-search columns normalizeFacility maps, as echo_rest_services.metadata column IDs:
|
|
27
|
+
* FacName, FacStreet, FacCity, FacState, FacZip, RegistryID, FacCounty, FacLat, FacLong, TRIIDs,
|
|
28
|
+
* FacComplianceStatus, FacInspectionCount, FacTotalPenalties, TRIReleasesTransfers, FacFIPSCode,
|
|
29
|
+
* FacDerivedStctyFIPS, AIRFlag, NPDESFlag, SDWISFlag, RCRAFlag. ECHO's default row omits the
|
|
30
|
+
* coordinates, the per-program flags, and the inspection and penalty counts.
|
|
31
|
+
*/
|
|
32
|
+
const FACILITY_SEARCH_COLUMNS = '1,2,3,4,5,6,7,19,20,28,38,43,62,70,83,93,100,101,102,103';
|
|
33
|
+
/** ECHO `p_med` media code per program; `p_med` takes a single code. */
|
|
34
|
+
const PROGRAM_MEDIA_CODES = {
|
|
35
|
+
CAA: 'A',
|
|
36
|
+
CWA: 'W',
|
|
37
|
+
RCRA: 'R',
|
|
38
|
+
TRI: 'T',
|
|
39
|
+
SDWA: 'S',
|
|
40
|
+
};
|
|
41
|
+
/** `p_cs` value selecting facilities with one or more programs in significant noncompliance. */
|
|
42
|
+
const SIGNIFICANT_NONCOMPLIANCE = '3';
|
|
43
|
+
/** ECHO `p_civil_criminal_indicator` per case type; `all` sends none. */
|
|
44
|
+
const CASE_TYPE_CODES = { civil: 'CI', criminal: 'CR', all: undefined };
|
|
45
|
+
/**
|
|
46
|
+
* Case milestones whose date is the filing date ECHO reports as DateFiled. ECHO records them for
|
|
47
|
+
* civil cases only, so a date-bounded case search returns no criminal case.
|
|
48
|
+
*/
|
|
49
|
+
const FILING_MILESTONES = 'CMF,CPOA';
|
|
50
|
+
/** Open start for a filing-date range given only an end; ECHO requires both ends. */
|
|
51
|
+
const EARLIEST_FILING_DATE = '1900-01-01';
|
|
52
|
+
/** ECHO's MM/DD/YYYY form of a YYYY-MM-DD date. */
|
|
53
|
+
const toEchoDate = (iso) => `${iso.slice(5, 7)}/${iso.slice(8, 10)}/${iso.slice(0, 4)}`;
|
|
54
|
+
/**
|
|
55
|
+
* ECHO runs a search without any parameter it does not recognize and leaves that parameter out
|
|
56
|
+
* of `Results.QueryParameters`. Fails when a sent `p_*` filter is missing there by name. Values
|
|
57
|
+
* are not compared: ECHO also lists defaults it was not sent and echoes values it ignores.
|
|
58
|
+
*/
|
|
59
|
+
function assertFiltersApplied(endpoint, url, applied) {
|
|
60
|
+
const names = new Set((applied ?? []).map((parameter) => parameter.QP));
|
|
61
|
+
const dropped = [...new URL(url).searchParams.keys()].filter((key) => key.startsWith('p_') && !names.has(key));
|
|
62
|
+
if (dropped.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
const filters = dropped.length === 1 ? 'that filter' : 'those filters';
|
|
65
|
+
throw new McpError(JsonRpcErrorCode.InternalError, `ECHO ${endpoint} did not apply ${dropped.join(', ')}, so its results would ignore ${filters}. Retrying will not help; search without ${filters}.`, { droppedParameters: dropped, retryable: false });
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One SEARCH_DEADLINE_MS budget for an ECHO search. Each request the search makes takes
|
|
69
|
+
* `remainingMs()` as its deadline, so the discovery request and its get_qid page share the budget.
|
|
70
|
+
*/
|
|
71
|
+
const searchBudget = (search, narrow) => callBudget('ECHO', search, SEARCH_DEADLINE_MS, narrow);
|
|
72
|
+
/** Disclosure for a profile built without the p_frs row. */
|
|
73
|
+
const STATUS_LOOKUP_NOTICE = "ECHO's facility search request failed, so this profile has no complianceStatus or compliance.quartersInViolation. Every other field comes from ECHO's detailed facility report; calling again may fill them.";
|
|
74
|
+
/**
|
|
75
|
+
* Newest compliance monitoring records kept in a profile. A Texas refinery logs several hundred
|
|
76
|
+
* state investigations in five years (581 at registry 110041990913, about 130 KB unbounded).
|
|
77
|
+
*/
|
|
78
|
+
const INSPECTION_CAP = 100;
|
|
79
|
+
/** Drops keys whose value is undefined, so optional output fields are absent rather than undefined. */
|
|
80
|
+
function compact(record) {
|
|
81
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
|
|
82
|
+
}
|
|
83
|
+
/** ECHO reports missing values as null or ""; both read as absent. */
|
|
84
|
+
const echoText = (value) => value || undefined;
|
|
85
|
+
/** Parses an ECHO count or dollar string ("12", "$1,750", "75,223"). */
|
|
86
|
+
function parseEchoNumber(value) {
|
|
87
|
+
if (!value)
|
|
88
|
+
return;
|
|
89
|
+
const parsed = Number(value.replace(/[$,]/g, ''));
|
|
90
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
91
|
+
}
|
|
92
|
+
/** Sort key (YYYYMMDD) for an ECHO MM/DD/YYYY date. */
|
|
93
|
+
const dateKey = (date) => date ? `${date.slice(6)}${date.slice(0, 2)}${date.slice(3, 5)}` : '';
|
|
94
|
+
/**
|
|
95
|
+
* Classifies ECHO's in-band failure: HTTP 200 with `Results.Error.ErrorMessage`. Only an
|
|
96
|
+
* unrecognized message stays a retryable ServiceUnavailable. The queryset-limit message
|
|
97
|
+
* ("Rows Returned would be <n>…") comes from get_facilities, which this service never calls;
|
|
98
|
+
* get_facility_info and get_case_info have no row ceiling.
|
|
99
|
+
*/
|
|
100
|
+
function echoBodyError(message) {
|
|
101
|
+
const detail = `ECHO API error: ${message}`;
|
|
102
|
+
if (/^ID .+ is invalid\.$/s.test(message))
|
|
103
|
+
return notFound(detail);
|
|
104
|
+
if (/^QueryID .+ not found in ECHO\.$/s.test(message)) {
|
|
105
|
+
return serviceUnavailable(detail, { retryable: false });
|
|
106
|
+
}
|
|
107
|
+
return serviceUnavailable(detail);
|
|
108
|
+
}
|
|
109
|
+
/** The NotFound `epa_get_facility` and `epa://facility/{registry_id}` declare as `facility_not_found`. */
|
|
110
|
+
function facilityNotFound(registryId, cause) {
|
|
111
|
+
return notFound(`No facility found for Registry ID "${registryId}".`, { reason: 'facility_not_found', registryId }, cause === undefined ? undefined : { cause });
|
|
112
|
+
}
|
|
113
|
+
/** Settlement dates recorded on an enforcement case. */
|
|
114
|
+
const settlementDates = (entry) => (entry.Settlements?.Settlement ?? []).flatMap((s) => s.SettlementDate ? [s.SettlementDate] : []);
|
|
115
|
+
/** A case's issue date; ECHO writes an unknown one as "01/01/0001". */
|
|
116
|
+
const issueDate = (entry) => entry.IssueDate && entry.IssueDate !== '01/01/0001' ? entry.IssueDate : undefined;
|
|
117
|
+
/** Federal plus state and local penalty across a case's settlements, in dollars. */
|
|
118
|
+
const settledPenalty = (entry) => (entry.Settlements?.Settlement ?? []).reduce((sum, s) => sum + (parseEchoNumber(s.FederalPenalty) ?? 0) + (parseEchoNumber(s.StateLocalPenalty) ?? 0), 0);
|
|
119
|
+
/**
|
|
120
|
+
* Removes and returns the `CaseFormalActions` entry that records `action`: same statute, same
|
|
121
|
+
* source (the case prefixes it, e.g. "AIR/"), and issued — or, lacking an issue date, settled —
|
|
122
|
+
* on the action date. Among several such cases, the one whose settled penalty equals the
|
|
123
|
+
* action's penalty wins.
|
|
124
|
+
*/
|
|
125
|
+
function takeCase(cases, action) {
|
|
126
|
+
const matches = cases.flatMap((entry, index) => {
|
|
127
|
+
const issued = issueDate(entry);
|
|
128
|
+
const sameAction = entry.StatuteCode === action.Statute &&
|
|
129
|
+
entry.SourceID?.split('/').pop() === action.SourceID &&
|
|
130
|
+
(issued
|
|
131
|
+
? issued === action.ActionDate
|
|
132
|
+
: settlementDates(entry).includes(action.ActionDate ?? ''));
|
|
133
|
+
return sameAction ? [{ entry, index }] : [];
|
|
134
|
+
});
|
|
135
|
+
const penalty = parseEchoNumber(action.PenaltyAmount) ?? 0;
|
|
136
|
+
const match = matches.find(({ entry }) => settledPenalty(entry) === penalty) ?? matches[0];
|
|
137
|
+
return match && cases.splice(match.index, 1)[0];
|
|
138
|
+
}
|
|
139
|
+
/** The latest TRIHistory year and its releases + transfers, summed across rows for that year. */
|
|
140
|
+
function latestTriTotal(rows) {
|
|
141
|
+
const year = Math.max(...rows.map((row) => Number(row.Year)).filter(Number.isFinite));
|
|
142
|
+
const totals = rows
|
|
143
|
+
.filter((row) => Number(row.Year) === year)
|
|
144
|
+
.map((row) => parseEchoNumber(row.TotalReleasesTransfers))
|
|
145
|
+
.filter((lbs) => lbs !== undefined);
|
|
146
|
+
return totals.length > 0 ? { year, lbs: totals.reduce((sum, lbs) => sum + lbs, 0) } : undefined;
|
|
147
|
+
}
|
|
148
|
+
/** The reporting year in a TRI permit's FacilityStatus ("Last Reported for 2024"); 0 when absent. */
|
|
149
|
+
const lastReportedYear = (status) => Number(/\b(\d{4})\b/.exec(status ?? '')?.[1] ?? 0);
|
|
150
|
+
/**
|
|
151
|
+
* Normalizes an ECHO facility row, read by the column names FACILITY_SEARCH_COLUMNS requests, to
|
|
152
|
+
* the domain type. Program flags match the `p_med` filters: a TRI facility is one with a TRI ID
|
|
153
|
+
* (ECHO's TRIFlag marks current reporters only). getFacility passes the same columns, some unset.
|
|
154
|
+
*/
|
|
11
155
|
function normalizeFacility(raw) {
|
|
12
|
-
const penaltyAir = parseFloat(raw.AIRPenalties ?? '0') || 0;
|
|
13
|
-
const penaltyCwa = parseFloat(raw.CWAPenalties ?? '0') || 0;
|
|
14
|
-
const penaltyRcr = parseFloat(raw.RCRPenalties ?? '0') || 0;
|
|
15
|
-
const totalPenalties = penaltyAir + penaltyCwa + penaltyRcr;
|
|
16
156
|
return {
|
|
17
157
|
registryId: raw.RegistryID ?? '',
|
|
18
158
|
name: raw.FacName ?? '',
|
|
19
|
-
...(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
159
|
+
...compact({
|
|
160
|
+
street: echoText(raw.FacStreet),
|
|
161
|
+
city: echoText(raw.FacCity),
|
|
162
|
+
state: echoText(raw.FacState),
|
|
163
|
+
zip: echoText(raw.FacZip),
|
|
164
|
+
county: echoText(raw.FacCounty),
|
|
165
|
+
fipsCode: echoText(raw.FacFIPSCode) ?? echoText(raw.FacDerivedStctyFIPS),
|
|
166
|
+
latitude: parseEchoNumber(raw.FacLat),
|
|
167
|
+
longitude: parseEchoNumber(raw.FacLong),
|
|
168
|
+
complianceStatus: echoText(raw.FacComplianceStatus),
|
|
169
|
+
}),
|
|
30
170
|
programs: {
|
|
31
171
|
air: raw.AIRFlag === 'Y',
|
|
32
|
-
water: raw.
|
|
33
|
-
rcra: raw.
|
|
34
|
-
tri: raw.
|
|
35
|
-
sdwa: raw.
|
|
172
|
+
water: raw.NPDESFlag === 'Y',
|
|
173
|
+
rcra: raw.RCRAFlag === 'Y',
|
|
174
|
+
tri: echoText(raw.TRIIDs) !== undefined,
|
|
175
|
+
sdwa: raw.SDWISFlag === 'Y',
|
|
36
176
|
},
|
|
37
|
-
...(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
...(raw.InspectionCount &&
|
|
42
|
-
!Number.isNaN(parseInt(raw.InspectionCount, 10)) && {
|
|
43
|
-
inspectionCount: parseInt(raw.InspectionCount, 10),
|
|
177
|
+
...compact({
|
|
178
|
+
triReleasesTransfersInLbs: parseEchoNumber(raw.TRIReleasesTransfers),
|
|
179
|
+
inspectionCount: parseEchoNumber(raw.FacInspectionCount),
|
|
180
|
+
totalPenaltiesInDollars: parseEchoNumber(raw.FacTotalPenalties),
|
|
44
181
|
}),
|
|
45
|
-
...(totalPenalties > 0 && { totalPenaltiesInDollars: totalPenalties }),
|
|
46
182
|
};
|
|
47
183
|
}
|
|
48
184
|
export class EchoService {
|
|
49
185
|
baseUrl;
|
|
50
|
-
|
|
51
|
-
|
|
186
|
+
userAgent;
|
|
187
|
+
// storage retained for future caching/state use
|
|
188
|
+
constructor(config, _storage) {
|
|
52
189
|
this.baseUrl = getServerConfig().echoBaseUrl;
|
|
190
|
+
this.userAgent = `${config.pkg.name}/${config.pkg.version}`;
|
|
53
191
|
}
|
|
54
192
|
/** Build URL with query params. */
|
|
55
193
|
buildUrl(path, params) {
|
|
@@ -61,13 +199,16 @@ export class EchoService {
|
|
|
61
199
|
}
|
|
62
200
|
return url.toString();
|
|
63
201
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Fetch JSON from ECHO with retry. `retry.deadlineMs` bounds every attempt and backoff and is
|
|
204
|
+
* required, so no ECHO request runs without a share of its tool call's budget; `maxRetries`
|
|
205
|
+
* narrows the attempt count.
|
|
206
|
+
*/
|
|
207
|
+
fetchJson(url, ctx, retry) {
|
|
208
|
+
return withRetry(async ({ signal }) => {
|
|
68
209
|
const response = await fetch(url, {
|
|
69
|
-
signal
|
|
70
|
-
headers: { 'User-Agent':
|
|
210
|
+
signal,
|
|
211
|
+
headers: { 'User-Agent': this.userAgent },
|
|
71
212
|
});
|
|
72
213
|
if (!response.ok) {
|
|
73
214
|
throw await httpErrorFromResponse(response, { service: 'ECHO' });
|
|
@@ -77,62 +218,46 @@ export class EchoService {
|
|
|
77
218
|
throw serviceUnavailable('ECHO API returned HTML instead of JSON — likely rate-limited or unavailable.');
|
|
78
219
|
}
|
|
79
220
|
const parsed = JSON.parse(text);
|
|
80
|
-
// ECHO returns bot-blocking errors in Results.Error.ErrorMessage rather than HTTP error codes
|
|
81
221
|
const errorMsg = parsed.Results
|
|
82
222
|
?.Error?.ErrorMessage;
|
|
83
|
-
if (errorMsg)
|
|
84
|
-
throw
|
|
85
|
-
}
|
|
223
|
+
if (errorMsg)
|
|
224
|
+
throw echoBodyError(errorMsg);
|
|
86
225
|
return parsed;
|
|
87
226
|
}, {
|
|
88
227
|
operation: 'EchoService.fetchJson',
|
|
89
228
|
baseDelayMs: 1000,
|
|
90
229
|
signal: ctx.signal,
|
|
230
|
+
...retry,
|
|
91
231
|
});
|
|
92
232
|
}
|
|
93
233
|
/**
|
|
94
|
-
* Search EPA-regulated facilities using ECHO get_facility_info
|
|
95
|
-
* At least one geographic parameter must be
|
|
234
|
+
* Search EPA-regulated facilities using ECHO get_facility_info, then get_qid for the first
|
|
235
|
+
* page when ECHO does not return the rows inline. At least one geographic parameter must be
|
|
236
|
+
* supplied.
|
|
96
237
|
*/
|
|
97
238
|
async searchFacilities(params, ctx) {
|
|
239
|
+
const budget = searchBudget('facility search', 'Narrow it with zip_code instead of city or state, a smaller radius_miles, or a programs filter, or call again later.');
|
|
98
240
|
const limit = params.limit ?? 50;
|
|
99
|
-
|
|
241
|
+
// buildUrl drops undefined values; latitude/longitude of 0 are valid and sent.
|
|
242
|
+
const url = this.buildUrl('echo_rest_services.get_facility_info', {
|
|
100
243
|
output: 'JSON',
|
|
101
244
|
responseset: limit,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
qparams.p_long = params.longitude;
|
|
114
|
-
if (params.radiusMiles !== undefined)
|
|
115
|
-
qparams.p_radius = params.radiusMiles;
|
|
116
|
-
if (params.activeOnly)
|
|
117
|
-
qparams.p_act = 'Y';
|
|
118
|
-
if (params.hasViolation)
|
|
119
|
-
qparams.p_sv_flag = 'Y';
|
|
120
|
-
// Program filters — comma-separated list restricts to facilities in those programs
|
|
121
|
-
if (params.programs?.length) {
|
|
122
|
-
const programMap = {
|
|
123
|
-
CAA: 'AIR',
|
|
124
|
-
CWA: 'CWA',
|
|
125
|
-
RCRA: 'RCRA',
|
|
126
|
-
TRI: 'TRI',
|
|
127
|
-
SDWA: 'SDWA',
|
|
128
|
-
};
|
|
129
|
-
const mapped = params.programs.map((p) => programMap[p]).filter(Boolean);
|
|
130
|
-
if (mapped.length)
|
|
131
|
-
qparams.p_naa = mapped.join(',');
|
|
132
|
-
}
|
|
133
|
-
const url = this.buildUrl('echo_rest_services.get_facility_info', qparams);
|
|
245
|
+
qcolumns: FACILITY_SEARCH_COLUMNS,
|
|
246
|
+
p_zip: params.zipCode,
|
|
247
|
+
p_st: params.state,
|
|
248
|
+
p_ct: params.city,
|
|
249
|
+
p_lat: params.latitude,
|
|
250
|
+
p_long: params.longitude,
|
|
251
|
+
p_radius: params.radiusMiles,
|
|
252
|
+
p_act: params.activeOnly ? 'Y' : undefined,
|
|
253
|
+
p_cs: params.hasViolation ? SIGNIFICANT_NONCOMPLIANCE : undefined,
|
|
254
|
+
p_med: params.program && PROGRAM_MEDIA_CODES[params.program],
|
|
255
|
+
});
|
|
134
256
|
ctx.log.debug('ECHO facility search', { endpoint: 'echo_rest_services.get_facility_info' });
|
|
135
|
-
const data = await this.fetchJson(url, ctx
|
|
257
|
+
const data = await this.fetchJson(url, ctx, {
|
|
258
|
+
deadlineMs: budget.remainingMs(),
|
|
259
|
+
}).catch(budget.rethrow);
|
|
260
|
+
assertFiltersApplied('echo_rest_services.get_facility_info', url, data.Results?.QueryParameters);
|
|
136
261
|
const directFacilities = data.Results?.Facilities ?? [];
|
|
137
262
|
const parsedTotalCount = Number.parseInt(data.Results?.QueryRows ?? data.Results?.TotalCount ?? '', 10);
|
|
138
263
|
const totalCount = Number.isNaN(parsedTotalCount) ? directFacilities.length : parsedTotalCount;
|
|
@@ -148,150 +273,205 @@ export class EchoService {
|
|
|
148
273
|
output: 'JSON',
|
|
149
274
|
qid: queryId,
|
|
150
275
|
pageno: 1,
|
|
276
|
+
qcolumns: FACILITY_SEARCH_COLUMNS,
|
|
151
277
|
});
|
|
152
278
|
ctx.log.debug('ECHO facility search page', {
|
|
153
279
|
endpoint: 'echo_rest_services.get_qid',
|
|
154
280
|
queryId,
|
|
155
281
|
});
|
|
156
|
-
const page = await this.fetchJson(qidUrl, ctx
|
|
282
|
+
const page = await this.fetchJson(qidUrl, ctx, {
|
|
283
|
+
deadlineMs: budget.remainingMs(),
|
|
284
|
+
}).catch(budget.rethrow);
|
|
157
285
|
raw = page.Results?.Facilities ?? [];
|
|
158
286
|
}
|
|
159
|
-
return {
|
|
160
|
-
facilities: raw
|
|
161
|
-
.slice(0, limit)
|
|
162
|
-
.map((f) => normalizeFacility(f)),
|
|
163
|
-
totalCount,
|
|
164
|
-
};
|
|
287
|
+
return { facilities: raw.slice(0, limit).map(normalizeFacility), totalCount };
|
|
165
288
|
}
|
|
166
289
|
/**
|
|
167
|
-
* Retrieve a full compliance profile for a single facility
|
|
290
|
+
* Retrieve a full compliance profile for a single facility from one
|
|
291
|
+
* dfr_rest_services.get_dfr response, plus a best-effort facility search row for the
|
|
292
|
+
* facility-level compliance status that no DFR section carries.
|
|
168
293
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* always returned 0 results. Steps 2-5 run in parallel after the initial DFR fetch.
|
|
294
|
+
* get_dfr takes a Registry ID or a program ID (a TRI or RCRA ID resolves to its Registry ID);
|
|
295
|
+
* echo_rest_services.get_facility_info takes the resolved Registry ID only as `p_frs`.
|
|
296
|
+
* A failed status lookup is disclosed as an enrichment notice.
|
|
173
297
|
*/
|
|
174
298
|
async getFacility(registryId, ctx) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
299
|
+
const { profile, notice } = await this.getFacilityReport(registryId, ctx);
|
|
300
|
+
if (notice)
|
|
301
|
+
ctx.enrich.notice(notice);
|
|
302
|
+
return profile;
|
|
303
|
+
}
|
|
304
|
+
/** The getFacility profile, with the notice to show when the status lookup failed. */
|
|
305
|
+
async getFacilityReport(registryId, ctx) {
|
|
306
|
+
const startedAt = Date.now();
|
|
307
|
+
const dfrUrl = this.buildUrl('dfr_rest_services.get_dfr', { output: 'JSON', p_id: registryId });
|
|
181
308
|
ctx.log.debug('ECHO get_dfr', { registryId });
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
throw
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
FacCounty: frsPerm?.FacilityCountyName ?? undefined,
|
|
200
|
-
FacFIPSCode: frsPerm?.FacilityFipsCode ?? undefined,
|
|
201
|
-
FacLat: frsPerm?.Latitude ?? dfrData.Results?.SpatialMetadata?.Latitude83 ?? undefined,
|
|
202
|
-
FacLon: frsPerm?.Longitude ?? dfrData.Results?.SpatialMetadata?.Longitude83 ?? undefined,
|
|
203
|
-
// Program flags derived from Permits[].Statute (CAA→AIR, CWA, RCRA, TRI, SDWA)
|
|
204
|
-
AIRFlag: statutes.has('CAA') ? 'Y' : 'N',
|
|
205
|
-
CWAFlag: statutes.has('CWA') || statutes.has('NPDES') ? 'Y' : 'N',
|
|
206
|
-
RCRFlag: statutes.has('RCRA') ? 'Y' : 'N',
|
|
207
|
-
TRIFlag: statutes.has('TRI') ? 'Y' : 'N',
|
|
208
|
-
SDWAFlag: statutes.has('SDWA') ? 'Y' : 'N',
|
|
309
|
+
const dfr = await this.fetchJson(dfrUrl, ctx, {
|
|
310
|
+
deadlineMs: FACILITY_DEADLINE_MS,
|
|
311
|
+
}).catch((error) => {
|
|
312
|
+
throw error instanceof McpError && error.code === JsonRpcErrorCode.NotFound
|
|
313
|
+
? facilityNotFound(registryId, error)
|
|
314
|
+
: error;
|
|
315
|
+
});
|
|
316
|
+
const results = dfr.Results;
|
|
317
|
+
const confirmedId = results?.RegistryID;
|
|
318
|
+
if (!results || !confirmedId)
|
|
319
|
+
throw facilityNotFound(registryId);
|
|
320
|
+
const permits = results.Permits ?? [];
|
|
321
|
+
const frs = permits.find((p) => p.EPASystem === 'FRS');
|
|
322
|
+
const systemPermits = (system) => permits.filter((p) => p.EPASystem === system);
|
|
323
|
+
const soleSourceId = (system) => {
|
|
324
|
+
const ids = systemPermits(system).flatMap((p) => (p.SourceID ? [p.SourceID] : []));
|
|
325
|
+
return ids.length === 1 ? ids[0] : undefined;
|
|
209
326
|
};
|
|
210
|
-
const
|
|
211
|
-
//
|
|
212
|
-
const [
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
327
|
+
const flag = (system) => (systemPermits(system).length > 0 ? 'Y' : 'N');
|
|
328
|
+
// The FRS entry carries null county and FIPS; program entries carry "King County" / "53033".
|
|
329
|
+
const permitValue = (key) => [frs, ...permits].map((p) => echoText(p?.[key])).find((value) => value !== undefined);
|
|
330
|
+
const status = await this.fetchFacilityStatusRow(confirmedId, ctx, Math.max(0, FACILITY_DEADLINE_MS - (Date.now() - startedAt)));
|
|
331
|
+
const statusRow = status.row;
|
|
332
|
+
const triFacilityIds = [
|
|
333
|
+
...new Set(systemPermits('TRI')
|
|
334
|
+
.sort((a, b) => lastReportedYear(b.FacilityStatus) - lastReportedYear(a.FacilityStatus))
|
|
335
|
+
.flatMap((p) => (p.SourceID ? [p.SourceID] : []))),
|
|
336
|
+
];
|
|
337
|
+
// Program flags key on ECHO's media systems: an EIS, CEDRI, RMP, or GHGRP entry also carries
|
|
338
|
+
// Statute CAA without an ICIS-Air record, and a TRI entry carries Statute EP313. County and
|
|
339
|
+
// FIPS come from Permits[] first, so they do not depend on the best-effort status row.
|
|
340
|
+
const base = normalizeFacility({
|
|
341
|
+
RegistryID: confirmedId,
|
|
342
|
+
FacName: frs?.FacilityName,
|
|
343
|
+
FacStreet: frs?.FacilityStreet,
|
|
344
|
+
FacCity: frs?.FacilityCity,
|
|
345
|
+
FacState: frs?.FacilityState,
|
|
346
|
+
FacZip: frs?.FacilityZip,
|
|
347
|
+
FacCounty: permitValue('FacilityCountyName') ?? statusRow?.FacCounty,
|
|
348
|
+
FacFIPSCode: permitValue('FacilityFipsCode') ?? statusRow?.FacFIPSCode,
|
|
349
|
+
FacDerivedStctyFIPS: statusRow?.FacDerivedStctyFIPS,
|
|
350
|
+
FacLat: frs?.Latitude ?? results.SpatialMetadata?.Latitude83,
|
|
351
|
+
FacLong: frs?.Longitude ?? results.SpatialMetadata?.Longitude83,
|
|
352
|
+
FacComplianceStatus: statusRow?.FacComplianceStatus,
|
|
353
|
+
AIRFlag: flag('ICIS-Air'),
|
|
354
|
+
NPDESFlag: flag('ICIS-NPDES'),
|
|
355
|
+
RCRAFlag: flag('RCRAInfo'),
|
|
356
|
+
TRIIDs: triFacilityIds.join(','),
|
|
357
|
+
SDWISFlag: flag('SDWIS'),
|
|
358
|
+
});
|
|
359
|
+
const tri = latestTriTotal((results.TRIHistory?.Sources ?? []).flatMap((source) => source.ReleaseYear ?? []));
|
|
360
|
+
const programDates = new Map((results.EnforcementComplianceSummaries?.ProgramDates ?? []).map((d) => [d.Program, d]));
|
|
361
|
+
const programCompliance = (results.EnforcementComplianceSummaries?.Summaries ?? []).flatMap((summary) => {
|
|
362
|
+
const recorded = compact({
|
|
363
|
+
currentStatus: echoText(summary.CurrentStatus),
|
|
364
|
+
quartersInNoncompliance: parseEchoNumber(summary.QtrsInNC),
|
|
365
|
+
quartersInSignificantNoncompliance: parseEchoNumber(summary.QtrsInSNC),
|
|
366
|
+
inspections: parseEchoNumber(summary.Inspections),
|
|
367
|
+
lastInspectionDate: echoText(summary.LastInspection),
|
|
368
|
+
informalActions: parseEchoNumber(summary.InformalActions),
|
|
369
|
+
formalActions: parseEchoNumber(summary.FormalActions),
|
|
370
|
+
totalPenaltiesInDollars: parseEchoNumber(summary.TotalPenalties),
|
|
371
|
+
});
|
|
372
|
+
// ECHO lists a statute row with no values for a program the facility has no record in.
|
|
373
|
+
if (!summary.Statute || Object.keys(recorded).length === 0)
|
|
374
|
+
return [];
|
|
375
|
+
const dates = programDates.get(summary.Statute);
|
|
376
|
+
return [
|
|
377
|
+
compact({
|
|
378
|
+
statute: summary.Statute,
|
|
379
|
+
...recorded,
|
|
380
|
+
threeYearPeriodStart: echoText(dates?.StartDate3Yr),
|
|
381
|
+
fiveYearPeriodStart: echoText(dates?.StartDate5Yr),
|
|
382
|
+
periodEnd: echoText(dates?.EndDate),
|
|
383
|
+
}),
|
|
384
|
+
];
|
|
385
|
+
});
|
|
386
|
+
const programSummary = (statute) => programCompliance.find((p) => p.statute === statute);
|
|
387
|
+
const monitoring = results.ComplianceHistory?.Inspection ?? [];
|
|
388
|
+
const inspections = [...monitoring]
|
|
389
|
+
.sort((a, b) => dateKey(b.Date).localeCompare(dateKey(a.Date)))
|
|
390
|
+
.slice(0, INSPECTION_CAP)
|
|
391
|
+
.map((inspection) => compact({
|
|
392
|
+
category: echoText(inspection.ActivityType),
|
|
393
|
+
activityType: echoText(inspection.InspectionType),
|
|
394
|
+
activityDate: echoText(inspection.Date),
|
|
395
|
+
description: echoText(inspection.Finding),
|
|
396
|
+
statute: echoText(inspection.Statute),
|
|
397
|
+
sourceId: echoText(inspection.SourceID),
|
|
398
|
+
leadAgency: echoText(inspection.LeadAgency),
|
|
399
|
+
}));
|
|
400
|
+
const cases = [...(results.CaseFormalActions?.Action ?? [])];
|
|
401
|
+
const formalActions = [...(results.FormalActions?.Action ?? [])]
|
|
402
|
+
.sort((a, b) => dateKey(b.ActionDate).localeCompare(dateKey(a.ActionDate)))
|
|
403
|
+
.map((action) => {
|
|
404
|
+
const recordedCase = takeCase(cases, action);
|
|
405
|
+
const settlements = recordedCase ? settlementDates(recordedCase) : [];
|
|
406
|
+
return compact({
|
|
407
|
+
caseId: echoText(recordedCase?.CaseID),
|
|
408
|
+
settlementDate: settlements.sort((a, b) => dateKey(b).localeCompare(dateKey(a)))[0],
|
|
409
|
+
penaltyAssessedInDollars: parseEchoNumber(action.PenaltyAmount),
|
|
410
|
+
actionType: echoText(action.ActionType),
|
|
411
|
+
actionDate: echoText(action.ActionDate),
|
|
412
|
+
statute: echoText(action.Statute),
|
|
413
|
+
sourceId: echoText(action.SourceID),
|
|
414
|
+
leadAgency: echoText(action.LeadAgency),
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
const air = base.programs.air ? programSummary('CAA') : undefined;
|
|
418
|
+
const water = base.programs.water ? programSummary('CWA') : undefined;
|
|
419
|
+
const quartersInViolation = parseEchoNumber(statusRow?.FacQtrsWithNC);
|
|
420
|
+
const profile = {
|
|
288
421
|
...base,
|
|
289
|
-
|
|
422
|
+
triFacilityIds,
|
|
423
|
+
...(tri && { triReleasesTransfersInLbs: tri.lbs, triReleasesTransfersYear: tri.year }),
|
|
424
|
+
...(quartersInViolation !== undefined && { compliance: { quartersInViolation } }),
|
|
425
|
+
programCompliance,
|
|
290
426
|
inspections,
|
|
427
|
+
inspectionsTotal: monitoring.length,
|
|
291
428
|
formalActions,
|
|
292
|
-
...(
|
|
293
|
-
|
|
429
|
+
...(air?.currentStatus && {
|
|
430
|
+
airCompliance: compact({
|
|
431
|
+
programId: soleSourceId('ICIS-Air'),
|
|
432
|
+
status: air.currentStatus,
|
|
433
|
+
statusDate: air.periodEnd,
|
|
434
|
+
}),
|
|
435
|
+
}),
|
|
436
|
+
...(water?.currentStatus && {
|
|
437
|
+
waterCompliance: compact({
|
|
438
|
+
status: water.currentStatus,
|
|
439
|
+
permitId: soleSourceId('ICIS-NPDES'),
|
|
440
|
+
}),
|
|
441
|
+
}),
|
|
294
442
|
};
|
|
443
|
+
return status.failed ? { profile, notice: STATUS_LOOKUP_NOTICE } : { profile };
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Best-effort echo_rest_services.get_facility_info?p_frs row, the only source of ECHO's
|
|
447
|
+
* facility-level compliance status. Two attempts within what is left of the call's budget;
|
|
448
|
+
* any failure but caller cancellation returns `failed` instead of throwing.
|
|
449
|
+
*/
|
|
450
|
+
async fetchFacilityStatusRow(registryId, ctx, budgetMs) {
|
|
451
|
+
const url = this.buildUrl('echo_rest_services.get_facility_info', {
|
|
452
|
+
output: 'JSON',
|
|
453
|
+
p_frs: registryId,
|
|
454
|
+
});
|
|
455
|
+
ctx.log.debug('ECHO facility status lookup', {
|
|
456
|
+
endpoint: 'echo_rest_services.get_facility_info',
|
|
457
|
+
registryId,
|
|
458
|
+
});
|
|
459
|
+
try {
|
|
460
|
+
const data = await this.fetchJson(url, ctx, {
|
|
461
|
+
maxRetries: 1,
|
|
462
|
+
deadlineMs: Math.min(STATUS_LOOKUP_DEADLINE_MS, budgetMs),
|
|
463
|
+
});
|
|
464
|
+
return { row: data.Results?.Facilities?.find((f) => f.RegistryID === registryId) };
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (ctx.signal?.aborted)
|
|
468
|
+
throw error;
|
|
469
|
+
ctx.log.warning('ECHO facility status lookup failed; returning the profile without it', {
|
|
470
|
+
registryId,
|
|
471
|
+
error: error instanceof Error ? error.message : String(error),
|
|
472
|
+
});
|
|
473
|
+
return { failed: true };
|
|
474
|
+
}
|
|
295
475
|
}
|
|
296
476
|
/**
|
|
297
477
|
* Search EPA enforcement cases using a two-step ECHO case_rest_services flow.
|
|
@@ -301,31 +481,43 @@ export class EchoService {
|
|
|
301
481
|
*
|
|
302
482
|
* The single-step approach (reading Cases from get_case_info) always returns empty because
|
|
303
483
|
* get_case_info is a discovery/cluster endpoint that does not include paginated case records.
|
|
484
|
+
*
|
|
485
|
+
* Filing dates are validated YYYY-MM-DD strings. A date bound sends both MM/DD/YYYY ends ECHO
|
|
486
|
+
* requires — an open end reads 01/01/1900 or today (UTC), and each end is clamped to that span —
|
|
487
|
+
* with the civil filing milestones. A range that clamps empty returns no cases without a
|
|
488
|
+
* request: ECHO answers an inverted range with an in-band error, not an empty result.
|
|
304
489
|
*/
|
|
305
490
|
async searchViolations(params, ctx) {
|
|
491
|
+
const budget = searchBudget('case search', 'Narrow it with zip_code, program, or a filing-date range, or call again later.');
|
|
306
492
|
const limit = params.limit ?? 50;
|
|
307
|
-
const
|
|
493
|
+
const { dateFiledStart, dateFiledEnd } = params;
|
|
494
|
+
const dated = dateFiledStart !== undefined || dateFiledEnd !== undefined;
|
|
495
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
496
|
+
const fromDate = dateFiledStart !== undefined && dateFiledStart > EARLIEST_FILING_DATE
|
|
497
|
+
? dateFiledStart
|
|
498
|
+
: EARLIEST_FILING_DATE;
|
|
499
|
+
const toDate = dateFiledEnd !== undefined && dateFiledEnd < today ? dateFiledEnd : today;
|
|
500
|
+
if (dated && fromDate > toDate)
|
|
501
|
+
return { cases: [], totalCount: 0 };
|
|
502
|
+
// Step 1: Get QueryID from get_case_info (returns cluster data only, no Cases[])
|
|
503
|
+
const infoUrl = this.buildUrl('case_rest_services.get_case_info', {
|
|
308
504
|
output: 'JSON',
|
|
309
505
|
responseset: limit,
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
qparams.p_date_filed_to = params.dateFiledEnd;
|
|
321
|
-
// Case type filter
|
|
322
|
-
if (params.caseType && params.caseType !== 'all') {
|
|
323
|
-
qparams.p_case_category = params.caseType === 'criminal' ? 'C' : 'V';
|
|
324
|
-
}
|
|
325
|
-
// Step 1: Get QueryID from get_case_info (returns cluster data only, no Cases[])
|
|
326
|
-
const infoUrl = this.buildUrl('case_rest_services.get_case_info', qparams);
|
|
506
|
+
p_state: params.state,
|
|
507
|
+
p_fac_zip: params.zipCode,
|
|
508
|
+
p_law: params.program,
|
|
509
|
+
p_civil_criminal_indicator: params.caseType && CASE_TYPE_CODES[params.caseType],
|
|
510
|
+
...(dated && {
|
|
511
|
+
p_from_date: toEchoDate(fromDate),
|
|
512
|
+
p_to_date: toEchoDate(toDate),
|
|
513
|
+
p_milestone: FILING_MILESTONES,
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
327
516
|
ctx.log.debug('ECHO case search step 1', { endpoint: 'case_rest_services.get_case_info' });
|
|
328
|
-
const infoData = await this.fetchJson(infoUrl, ctx
|
|
517
|
+
const infoData = await this.fetchJson(infoUrl, ctx, {
|
|
518
|
+
deadlineMs: budget.remainingMs(),
|
|
519
|
+
}).catch(budget.rethrow);
|
|
520
|
+
assertFiltersApplied('case_rest_services.get_case_info', infoUrl, infoData.Results?.QueryParameters);
|
|
329
521
|
const queryId = infoData.Results?.QueryID;
|
|
330
522
|
const totalCount = parseInt(infoData.Results?.QueryRows ?? '0', 10) || 0;
|
|
331
523
|
if (!queryId || totalCount === 0) {
|
|
@@ -338,7 +530,9 @@ export class EchoService {
|
|
|
338
530
|
pageno: 1,
|
|
339
531
|
});
|
|
340
532
|
ctx.log.debug('ECHO case search step 2', { endpoint: 'case_rest_services.get_qid', queryId });
|
|
341
|
-
const data = await this.fetchJson(qidUrl, ctx
|
|
533
|
+
const data = await this.fetchJson(qidUrl, ctx, {
|
|
534
|
+
deadlineMs: budget.remainingMs(),
|
|
535
|
+
}).catch(budget.rethrow);
|
|
342
536
|
const raw = (data.Results?.Cases ?? []).slice(0, limit);
|
|
343
537
|
const cases = raw.map((c) => {
|
|
344
538
|
const fedPenalty = c.FedPenalty ? parseFloat(String(c.FedPenalty).replace(/[$,]/g, '')) : NaN;
|