@cliwant/mcp-sam-gov 0.2.1 → 1.0.0
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/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- package/src/usitc.ts +420 -0
package/src/usaspending.ts
CHANGED
|
@@ -1,925 +1,2680 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* USAspending v2 wrappers (keyless).
|
|
3
|
-
*
|
|
4
|
-
* Coverage map (every endpoint here is verified KEYLESS):
|
|
5
|
-
* Awards / Recipients
|
|
6
|
-
* - search/spending_by_category/recipient → searchAwards
|
|
7
|
-
* - search/spending_by_award (subawards: false) → searchIndividualAwards
|
|
8
|
-
* - search/spending_by_award (subawards: true) → searchSubawards
|
|
9
|
-
* - search/spending_by_award (recipient filter) → searchAwardsByRecipient
|
|
10
|
-
* - awards/{generated_internal_id} → getAwardDetail
|
|
11
|
-
* - search/spending_by_award + awards/{id} pair → searchExpiringContracts
|
|
12
|
-
* Aggregate analysis
|
|
13
|
-
* - search/spending_over_time → spendingOverTime
|
|
14
|
-
* - search/spending_by_category/psc → searchPscSpending
|
|
15
|
-
* - search/spending_by_category/state_territory → searchStateSpending
|
|
16
|
-
* - search/spending_by_category/cfda → searchCfdaSpending
|
|
17
|
-
* - search/spending_by_category/federal_account → searchFederalAccountSpending
|
|
18
|
-
* - search/spending_by_category/awarding_agency → searchAgencySpending
|
|
19
|
-
* - search/spending_by_category/awarding_subagency → searchSubAgencySpending
|
|
20
|
-
* Agency profile
|
|
21
|
-
* - agency/{toptier_code} → getAgencyProfile
|
|
22
|
-
* - agency/{toptier_code}/awards → getAgencyAwardsSummary
|
|
23
|
-
* - agency/{toptier_code}/budget_function → getAgencyBudgetFunction
|
|
24
|
-
* Recipient profile
|
|
25
|
-
* - recipient/ POST → searchRecipients
|
|
26
|
-
* - recipient/{id} → getRecipientProfile
|
|
27
|
-
* Reference / autocomplete (anti-hallucination)
|
|
28
|
-
* - autocomplete/funding_agency → lookupAgency
|
|
29
|
-
* - autocomplete/naics → autocompleteNaics
|
|
30
|
-
* - autocomplete/recipient → autocompleteRecipient
|
|
31
|
-
* - references/naics → naicsHierarchy
|
|
32
|
-
* - references/glossary → glossary
|
|
33
|
-
* - references/toptier_agencies → listToptierAgencies
|
|
34
|
-
*
|
|
35
|
-
* Total: 22 endpoints across the USAspending surface, all keyless.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
const USAS = "https://api.usaspending.gov/api/v2";
|
|
39
|
-
|
|
40
|
-
export type UsasFilters = Record<string, unknown>;
|
|
41
|
-
|
|
42
|
-
function buildFilters(args: {
|
|
43
|
-
agency?: string;
|
|
44
|
-
naics?: string;
|
|
45
|
-
fiscalYear?: number;
|
|
46
|
-
setAside?: string;
|
|
47
|
-
pscCodes?: string[];
|
|
48
|
-
}): UsasFilters {
|
|
49
|
-
const filters: UsasFilters = { award_type_codes: ["A", "B", "C", "D"] };
|
|
50
|
-
if (args.agency) {
|
|
51
|
-
filters.agencies = [
|
|
52
|
-
{ type: "awarding", tier: "toptier", name: args.agency },
|
|
53
|
-
];
|
|
54
|
-
}
|
|
55
|
-
if (args.naics) filters.naics_codes = [args.naics];
|
|
56
|
-
if (args.fiscalYear) {
|
|
57
|
-
filters.time_period = [
|
|
58
|
-
{
|
|
59
|
-
start_date: `${args.fiscalYear - 1}-10-01`,
|
|
60
|
-
end_date: `${args.fiscalYear}-09-30`,
|
|
61
|
-
},
|
|
62
|
-
];
|
|
63
|
-
}
|
|
64
|
-
if (args.setAside) filters.set_aside_type_codes = [args.setAside];
|
|
65
|
-
if (args.pscCodes?.length) filters.psc_codes = args.pscCodes;
|
|
66
|
-
return filters;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
],
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
return
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
results
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
)
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* USAspending v2 wrappers (keyless).
|
|
3
|
+
*
|
|
4
|
+
* Coverage map (every endpoint here is verified KEYLESS):
|
|
5
|
+
* Awards / Recipients
|
|
6
|
+
* - search/spending_by_category/recipient → searchAwards
|
|
7
|
+
* - search/spending_by_award (subawards: false) → searchIndividualAwards
|
|
8
|
+
* - search/spending_by_award (subawards: true) → searchSubawards
|
|
9
|
+
* - search/spending_by_award (recipient filter) → searchAwardsByRecipient
|
|
10
|
+
* - awards/{generated_internal_id} → getAwardDetail
|
|
11
|
+
* - search/spending_by_award + awards/{id} pair → searchExpiringContracts
|
|
12
|
+
* Aggregate analysis
|
|
13
|
+
* - search/spending_over_time → spendingOverTime
|
|
14
|
+
* - search/spending_by_category/psc → searchPscSpending
|
|
15
|
+
* - search/spending_by_category/state_territory → searchStateSpending
|
|
16
|
+
* - search/spending_by_category/cfda → searchCfdaSpending
|
|
17
|
+
* - search/spending_by_category/federal_account → searchFederalAccountSpending
|
|
18
|
+
* - search/spending_by_category/awarding_agency → searchAgencySpending
|
|
19
|
+
* - search/spending_by_category/awarding_subagency → searchSubAgencySpending
|
|
20
|
+
* Agency profile
|
|
21
|
+
* - agency/{toptier_code} → getAgencyProfile
|
|
22
|
+
* - agency/{toptier_code}/awards → getAgencyAwardsSummary
|
|
23
|
+
* - agency/{toptier_code}/budget_function → getAgencyBudgetFunction
|
|
24
|
+
* Recipient profile
|
|
25
|
+
* - recipient/ POST → searchRecipients
|
|
26
|
+
* - recipient/{id} → getRecipientProfile
|
|
27
|
+
* Reference / autocomplete (anti-hallucination)
|
|
28
|
+
* - autocomplete/funding_agency → lookupAgency
|
|
29
|
+
* - autocomplete/naics → autocompleteNaics
|
|
30
|
+
* - autocomplete/recipient → autocompleteRecipient
|
|
31
|
+
* - references/naics → naicsHierarchy
|
|
32
|
+
* - references/glossary → glossary
|
|
33
|
+
* - references/toptier_agencies → listToptierAgencies
|
|
34
|
+
*
|
|
35
|
+
* Total: 22 endpoints across the USAspending surface, all keyless.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const USAS = "https://api.usaspending.gov/api/v2";
|
|
39
|
+
|
|
40
|
+
export type UsasFilters = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
function buildFilters(args: {
|
|
43
|
+
agency?: string;
|
|
44
|
+
naics?: string;
|
|
45
|
+
fiscalYear?: number;
|
|
46
|
+
setAside?: string;
|
|
47
|
+
pscCodes?: string[];
|
|
48
|
+
}): UsasFilters {
|
|
49
|
+
const filters: UsasFilters = { award_type_codes: ["A", "B", "C", "D"] };
|
|
50
|
+
if (args.agency) {
|
|
51
|
+
filters.agencies = [
|
|
52
|
+
{ type: "awarding", tier: "toptier", name: args.agency },
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
if (args.naics) filters.naics_codes = [args.naics];
|
|
56
|
+
if (args.fiscalYear) {
|
|
57
|
+
filters.time_period = [
|
|
58
|
+
{
|
|
59
|
+
start_date: `${args.fiscalYear - 1}-10-01`,
|
|
60
|
+
end_date: `${args.fiscalYear}-09-30`,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
if (args.setAside) filters.set_aside_type_codes = [args.setAside];
|
|
65
|
+
if (args.pscCodes?.length) filters.psc_codes = args.pscCodes;
|
|
66
|
+
return filters;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* VQ-6 (C79 dogfooding): derive the honest `_meta.filtersApplied` labels from the
|
|
71
|
+
* ACTUAL filter object sent upstream. Reading the built `filters` (not the args)
|
|
72
|
+
* means it can NEVER drift from what was really applied. `award_type_codes` is
|
|
73
|
+
* always present (contracts-only A/B/C/D). Every buildFilters-based tool previously
|
|
74
|
+
* reported `filtersApplied:[]` even when naics/agency/fiscalYear WAS applied — an
|
|
75
|
+
* agent verifying via `_meta` could not confirm its filter took effect.
|
|
76
|
+
*/
|
|
77
|
+
function filtersAppliedFromFilters(f: UsasFilters): string[] {
|
|
78
|
+
// UsasFilters is Record<string, unknown>, so narrow each value to a non-empty
|
|
79
|
+
// array before treating it as "applied".
|
|
80
|
+
const has = (k: string): boolean => {
|
|
81
|
+
const v = f[k];
|
|
82
|
+
return Array.isArray(v) && v.length > 0;
|
|
83
|
+
};
|
|
84
|
+
const applied: string[] = [];
|
|
85
|
+
// Label the ACTUAL award-type scope. A/B/C/D = contracts; 02/03/04/05 = grants
|
|
86
|
+
// (searchCfdaSpending). Adversarial-review SHIP-BLOCKER: a value-blind label
|
|
87
|
+
// made the grants tool falsely claim "contracts A/B/C/D".
|
|
88
|
+
const atc = f["award_type_codes"];
|
|
89
|
+
if (Array.isArray(atc) && atc.length > 0) {
|
|
90
|
+
const codes = atc.map(String);
|
|
91
|
+
applied.push(
|
|
92
|
+
codes.includes("A")
|
|
93
|
+
? "awardType(contracts A/B/C/D)"
|
|
94
|
+
: `awardType(${codes.join("/")})`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (has("agencies")) applied.push("agency");
|
|
98
|
+
if (has("naics_codes")) applied.push("naics");
|
|
99
|
+
if (has("time_period")) applied.push("fiscalYear");
|
|
100
|
+
if (has("set_aside_type_codes")) applied.push("setAside");
|
|
101
|
+
if (has("psc_codes")) applied.push("pscCodes");
|
|
102
|
+
if (has("recipient_search_text")) applied.push("recipientSearchText");
|
|
103
|
+
return applied;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
import { fetchWithRetry, ToolErrorCarrier, errorFromResponse } from "./errors.js";
|
|
107
|
+
import {
|
|
108
|
+
throughPathChain,
|
|
109
|
+
CircuitBreaker,
|
|
110
|
+
type ResiliencePath,
|
|
111
|
+
type Provenance,
|
|
112
|
+
} from "./datasource.js";
|
|
113
|
+
import { snapshotPath, provenanceMeta } from "./snapshot.js";
|
|
114
|
+
import { memoize } from "./cache.js";
|
|
115
|
+
import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
|
|
116
|
+
|
|
117
|
+
// ─── Resilience wiring (ADR-0045 pilot expansion — INERT by default) ───────
|
|
118
|
+
// The USAspending live host + a per-host circuit breaker keyed on the FIXED set
|
|
119
|
+
// {this host} (bounded — m3-regression). CONSULTED only by `throughPathChain`
|
|
120
|
+
// for a ≥2-path chain; when SAMGOV_SNAPSHOT_BASE_URL is unset the chain is
|
|
121
|
+
// single-path (live only), the breaker is a pure no-op, and every opted-in
|
|
122
|
+
// reference tool is BYTE-IDENTICAL to before this ADR. Only the three
|
|
123
|
+
// SLOW-CHANGING, canonical/queryless REFERENCE reads opt in (toptier agencies,
|
|
124
|
+
// the top-level NAICS tree, the glossary); the search/aggregate tools stay
|
|
125
|
+
// live-only (a snapshot can't cover free queries). See datasource.ts §"RESILIENCE
|
|
126
|
+
// PORT" and the policy boundary (no route-around / public-only / no-proxy).
|
|
127
|
+
const USAS_HOST = "api.usaspending.gov";
|
|
128
|
+
let usasBreaker = new CircuitBreaker([USAS_HOST]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Test-only: reset the resilience circuit breaker between OFFLINE fixtures (the
|
|
132
|
+
* breaker is module-level process state; a fresh instance isolates cases).
|
|
133
|
+
* Mirrors treasury.ts's `_resetTreasuryBreakerForTests`.
|
|
134
|
+
*/
|
|
135
|
+
export function _resetUsasBreakerForTests(): void {
|
|
136
|
+
usasBreaker = new CircuitBreaker([USAS_HOST]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const SPENDING_BY_AWARD_SOURCE =
|
|
140
|
+
"usaspending.gov/api/v2 search/spending_by_award";
|
|
141
|
+
const SPENDING_BY_CATEGORY_RECIPIENT_SOURCE =
|
|
142
|
+
"usaspending.gov/api/v2 search/spending_by_category/recipient";
|
|
143
|
+
const SPENDING_OVER_TIME_SOURCE =
|
|
144
|
+
"usaspending.gov/api/v2 search/spending_over_time";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `page_metadata` shape for the `spending_by_category/*` endpoints. These are
|
|
148
|
+
* cursor/page-paginated: the block carries `hasNext` but NO grand total
|
|
149
|
+
* (empirically verified 2026-07-03 for psc/state_territory/cfda/federal_account/
|
|
150
|
+
* awarding_agency/awarding_subagency — every one returns only
|
|
151
|
+
* `{page, next, previous, hasNext, hasPrevious}`). So a truthful aggregate
|
|
152
|
+
* `_meta` uses `hasNext` as the truncation signal and sets
|
|
153
|
+
* `totalAvailable: null` — never the page length (spec §3.3).
|
|
154
|
+
*/
|
|
155
|
+
type CategoryPageMeta = {
|
|
156
|
+
page?: number;
|
|
157
|
+
next?: number | null;
|
|
158
|
+
hasNext?: boolean;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build the `_meta` for a top-N `spending_by_category/*` aggregate. These
|
|
163
|
+
* category endpoints report no grand total, so `totalAvailable` is always
|
|
164
|
+
* `null` (honest "unknown" — never the returned count). Truncation is the
|
|
165
|
+
* endpoint's own `hasNext` when present, else `returned >= limit`.
|
|
166
|
+
*/
|
|
167
|
+
function categoryAggregateMeta(opts: {
|
|
168
|
+
source: string;
|
|
169
|
+
returned: number;
|
|
170
|
+
limit: number;
|
|
171
|
+
hasNext?: boolean;
|
|
172
|
+
fieldsUnavailable?: string[];
|
|
173
|
+
extraNotes?: string[];
|
|
174
|
+
filters?: UsasFilters;
|
|
175
|
+
}): Partial<ResponseMeta> {
|
|
176
|
+
const truncated = opts.hasNext ?? opts.returned >= opts.limit;
|
|
177
|
+
const notes: string[] = [];
|
|
178
|
+
if (truncated) {
|
|
179
|
+
notes.push(
|
|
180
|
+
`Capped at the top ${opts.limit} categories by amount; more categories may exist. This endpoint reports no grand total, so the true number of categories is unknown (totalAvailable is null, NOT the returned count). These extra categories are NOT page-reachable — all six callers post page:1 with NO offset/page input (nextOffset is null). Raise limit (up to 50) or narrow filters to see the rest.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (opts.extraNotes) notes.push(...opts.extraNotes);
|
|
184
|
+
return {
|
|
185
|
+
source: opts.source,
|
|
186
|
+
keylessMode: true,
|
|
187
|
+
returned: opts.returned,
|
|
188
|
+
// spec §3.3: the spending_by_category/* endpoints expose no total → null.
|
|
189
|
+
totalAvailable: null,
|
|
190
|
+
truncated,
|
|
191
|
+
pagination: {
|
|
192
|
+
offset: 0,
|
|
193
|
+
limit: opts.limit,
|
|
194
|
+
// W3-8 (honesty; mirrors awardPagination :488 / searchRecipients M1): all SIX
|
|
195
|
+
// callers (searchPscSpending / searchStateSpending / searchCfdaSpending /
|
|
196
|
+
// searchFederalAccountSpending / searchAgencySpending / searchSubAgencySpending)
|
|
197
|
+
// hardcode page:1 and expose ONLY `limit` (no offset/page arg) — so `nextOffset`
|
|
198
|
+
// is NOT consumable. Emitting `opts.returned` (the page length) made an agent
|
|
199
|
+
// re-fetch the SAME top-N forever while ranked-below-`limit` categories stayed
|
|
200
|
+
// unreachable. Emit null unconditionally; `truncated`/`hasMore` stay honest — the
|
|
201
|
+
// extras are reachable ONLY by raising `limit` (≤50) or narrowing filters.
|
|
202
|
+
nextOffset: null,
|
|
203
|
+
hasMore: truncated,
|
|
204
|
+
},
|
|
205
|
+
filtersApplied: opts.filters ? filtersAppliedFromFilters(opts.filters) : [],
|
|
206
|
+
filtersDropped: [],
|
|
207
|
+
fieldsUnavailable: opts.fieldsUnavailable ?? [],
|
|
208
|
+
notes,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Build the `_meta` for a reference / autocomplete tool. These are the
|
|
214
|
+
* anti-hallucination lookups (NAICS/recipient autocomplete, NAICS hierarchy,
|
|
215
|
+
* glossary, toptier agencies). Completeness rule (spec §2.3): a page that
|
|
216
|
+
* came back SHORT of the requested `limit` is the whole result set
|
|
217
|
+
* (`truncated:false`); a FULL page means more may exist (`truncated:true`).
|
|
218
|
+
* When the endpoint reports a real total (glossary), pass it so truncation is
|
|
219
|
+
* derived from `returned < total` instead. All are served from a 5-min TTL
|
|
220
|
+
* cache (see cache.ts) — noted so the AI knows the data may be up to 5 min old.
|
|
221
|
+
* [가설] we can't tell a cache HIT from a MISS here, so the note is
|
|
222
|
+
* unconditional rather than hit-specific.
|
|
223
|
+
*/
|
|
224
|
+
function referenceMeta(opts: {
|
|
225
|
+
source: string;
|
|
226
|
+
returned: number;
|
|
227
|
+
limit: number;
|
|
228
|
+
totalAvailable: number | null;
|
|
229
|
+
limitHonored?: boolean; // false ⇒ endpoint ignores `limit` (e.g. toptier)
|
|
230
|
+
extraNotes?: string[];
|
|
231
|
+
}): Partial<ResponseMeta> {
|
|
232
|
+
const { source, returned, limit, totalAvailable } = opts;
|
|
233
|
+
const limitHonored = opts.limitHonored ?? true;
|
|
234
|
+
let truncated: boolean;
|
|
235
|
+
let hasMore: boolean;
|
|
236
|
+
if (totalAvailable !== null) {
|
|
237
|
+
truncated = returned < totalAvailable;
|
|
238
|
+
hasMore = truncated;
|
|
239
|
+
} else if (!limitHonored) {
|
|
240
|
+
// Endpoint ignores `limit` and returns the full set → complete.
|
|
241
|
+
truncated = false;
|
|
242
|
+
hasMore = false;
|
|
243
|
+
} else {
|
|
244
|
+
truncated = returned >= limit;
|
|
245
|
+
hasMore = truncated;
|
|
246
|
+
}
|
|
247
|
+
const notes: string[] = [
|
|
248
|
+
"Reference lookup served from a 5-minute TTL cache; values may be up to 5 minutes stale.",
|
|
249
|
+
];
|
|
250
|
+
if (truncated) {
|
|
251
|
+
notes.push(
|
|
252
|
+
totalAvailable !== null
|
|
253
|
+
? `Showing ${returned} of ${totalAvailable} total; raise limit to see more.`
|
|
254
|
+
: `A full page of ${returned} was returned; more matches may exist — raise limit to widen the result.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (opts.extraNotes) notes.push(...opts.extraNotes);
|
|
258
|
+
return {
|
|
259
|
+
source,
|
|
260
|
+
keylessMode: true,
|
|
261
|
+
returned,
|
|
262
|
+
totalAvailable,
|
|
263
|
+
truncated,
|
|
264
|
+
pagination: {
|
|
265
|
+
// W3-8 (honesty; mirrors govinfo.ts:406-408 "no numeric offset"): the three
|
|
266
|
+
// callers (autocompleteNaics / autocompleteRecipient / glossary) forward only
|
|
267
|
+
// searchText/search + limit — NO offset/page input — so these reference lookups
|
|
268
|
+
// are not offset-pageable AT ALL. Emit BOTH offset:null and nextOffset:null;
|
|
269
|
+
// emitting `returned` for nextOffset made an agent re-fetch the SAME page forever.
|
|
270
|
+
// `hasMore`/`truncated` stay honest — the truncation notes already say "raise
|
|
271
|
+
// limit" (the only real way to widen), so no page cursor is implied.
|
|
272
|
+
offset: null,
|
|
273
|
+
limit,
|
|
274
|
+
nextOffset: null,
|
|
275
|
+
hasMore,
|
|
276
|
+
},
|
|
277
|
+
filtersApplied: [],
|
|
278
|
+
filtersDropped: [],
|
|
279
|
+
fieldsUnavailable: [],
|
|
280
|
+
notes,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* True total for a `spending_by_award` query, via the companion
|
|
286
|
+
* `spending_by_award_count` endpoint.
|
|
287
|
+
*
|
|
288
|
+
* WHY a companion query (not `page_metadata.total`): the `spending_by_award`
|
|
289
|
+
* response uses cursor-style pagination — its `page_metadata` carries only
|
|
290
|
+
* `page`/`hasNext`/`last_record_*`, NOT a `total` (empirically verified
|
|
291
|
+
* 2026-07-03; the spec's assumption that it mirrors `recipient/`'s
|
|
292
|
+
* `page_metadata.total` was wrong for this endpoint). The only honest source
|
|
293
|
+
* of a real count is `spending_by_award_count`, which returns per-award-type
|
|
294
|
+
* buckets; we sum them. Returns `null` on any failure — NEVER a page length
|
|
295
|
+
* (spec §3.3: never substitute page size for an unknown total).
|
|
296
|
+
*/
|
|
297
|
+
async function awardCount(
|
|
298
|
+
filters: UsasFilters,
|
|
299
|
+
mode: "awards" | "subawards",
|
|
300
|
+
): Promise<number | null> {
|
|
301
|
+
try {
|
|
302
|
+
type CountResp = { results?: Record<string, number> };
|
|
303
|
+
const body: Record<string, unknown> = { filters };
|
|
304
|
+
if (mode === "subawards") body.subawards = true;
|
|
305
|
+
const json = await postUsas<CountResp>(
|
|
306
|
+
"search/spending_by_award_count/",
|
|
307
|
+
body,
|
|
308
|
+
);
|
|
309
|
+
const results = json.results;
|
|
310
|
+
if (!results) return null;
|
|
311
|
+
// Awards → contracts+idvs+direct_payments+grants+loans+other.
|
|
312
|
+
// Subawards → subcontracts+subgrants. Sum every numeric bucket so we stay
|
|
313
|
+
// correct if the endpoint adds categories.
|
|
314
|
+
return Object.values(results).reduce(
|
|
315
|
+
(s, v) => s + (typeof v === "number" ? v : 0),
|
|
316
|
+
0,
|
|
317
|
+
);
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function postUsas<T>(
|
|
324
|
+
endpoint: string,
|
|
325
|
+
body: Record<string, unknown>,
|
|
326
|
+
): Promise<T> {
|
|
327
|
+
const r = await fetchWithRetry(
|
|
328
|
+
`${USAS}/${endpoint}`,
|
|
329
|
+
{
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: { "Content-Type": "application/json" },
|
|
332
|
+
body: JSON.stringify(body),
|
|
333
|
+
signal: AbortSignal.timeout(15_000),
|
|
334
|
+
},
|
|
335
|
+
`usaspending:${endpoint}`,
|
|
336
|
+
);
|
|
337
|
+
return (await r.json()) as T;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function getUsas<T>(endpoint: string): Promise<T> {
|
|
341
|
+
const r = await fetchWithRetry(
|
|
342
|
+
`${USAS}/${endpoint}`,
|
|
343
|
+
{ signal: AbortSignal.timeout(15_000) },
|
|
344
|
+
`usaspending:${endpoint}`,
|
|
345
|
+
);
|
|
346
|
+
return (await r.json()) as T;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* getUsas THROUGH the resilience path-chain (ADR-0045 pilot expansion). The LIVE
|
|
351
|
+
* path is byte-identical to a bare `getUsas(endpoint)` — same URL, same init
|
|
352
|
+
* ({signal} only), same label — so with no snapshot configured the chain is
|
|
353
|
+
* SINGLE-ENTRY and `throughPathChain` fast-paths (no breaker consult, no
|
|
354
|
+
* overhead) ⇒ output BYTE-IDENTICAL to before this ADR (the INERT guarantee).
|
|
355
|
+
*
|
|
356
|
+
* A snapshot fallback is added ONLY when (a) `snapshotKey` is provided (the
|
|
357
|
+
* caller declares this read is a canonical/queryless reference the builder can
|
|
358
|
+
* pre-fetch) AND (b) SAMGOV_SNAPSHOT_BASE_URL is configured (else `snapshotPath`
|
|
359
|
+
* returns null). When either is absent the chain stays live-only. The snapshot's
|
|
360
|
+
* `data` is the RAW upstream JSON (same shape as the live body), so the caller's
|
|
361
|
+
* existing mapping applies unchanged to both paths.
|
|
362
|
+
*/
|
|
363
|
+
async function getUsasResilient<T>(
|
|
364
|
+
endpoint: string,
|
|
365
|
+
snapshotKey?: string,
|
|
366
|
+
): Promise<{ body: T; provenance: Provenance }> {
|
|
367
|
+
const livePath: ResiliencePath<T> = {
|
|
368
|
+
host: USAS_HOST,
|
|
369
|
+
provenance: { dataPath: "live" },
|
|
370
|
+
run: () => getUsas<T>(endpoint),
|
|
371
|
+
};
|
|
372
|
+
const snap = snapshotKey ? snapshotPath<T>(snapshotKey) : null;
|
|
373
|
+
const paths = snap ? [livePath, snap] : [livePath];
|
|
374
|
+
return throughPathChain<T>(paths, usasBreaker);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── Aggregate share-of-wallet ───────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export async function searchAwards(args: {
|
|
380
|
+
agency?: string;
|
|
381
|
+
naics?: string;
|
|
382
|
+
fiscalYear?: number;
|
|
383
|
+
setAside?: string;
|
|
384
|
+
}) {
|
|
385
|
+
const filters = buildFilters(args);
|
|
386
|
+
type Resp = {
|
|
387
|
+
results?: { name?: string; amount?: number; count?: number }[];
|
|
388
|
+
page_metadata?: { total?: number; count?: number };
|
|
389
|
+
};
|
|
390
|
+
const limit = 10;
|
|
391
|
+
const json = await postUsas<Resp>(
|
|
392
|
+
"search/spending_by_category/recipient",
|
|
393
|
+
{ filters, limit, page: 1 },
|
|
394
|
+
);
|
|
395
|
+
const results = json.results ?? [];
|
|
396
|
+
// B1 (spec §1.3, §3.4): the spending_by_category/recipient endpoint returns
|
|
397
|
+
// `amount` but NOT a per-recipient award `count`. The old code defaulted the
|
|
398
|
+
// missing count to 0, so every recipient reported `awards:0` and
|
|
399
|
+
// `totalAwards:0` while `totalValue` was billions — a self-contradictory lie
|
|
400
|
+
// ("0 contracts worth $3.45B"). Emit `null` (explicit "not available"), NOT
|
|
401
|
+
// 0, and flag it in `_meta.fieldsUnavailable`. `amount`/`value` unchanged.
|
|
402
|
+
const data = {
|
|
403
|
+
totalAwards: null as number | null,
|
|
404
|
+
totalValue: results.reduce((s, r) => s + (r.amount ?? 0), 0),
|
|
405
|
+
topRecipients: results.map((r) => ({
|
|
406
|
+
name: r.name ?? "—",
|
|
407
|
+
value: r.amount ?? 0,
|
|
408
|
+
awards: null as number | null,
|
|
409
|
+
})),
|
|
410
|
+
};
|
|
411
|
+
return withMeta(data, {
|
|
412
|
+
source: SPENDING_BY_CATEGORY_RECIPIENT_SOURCE,
|
|
413
|
+
keylessMode: true,
|
|
414
|
+
returned: results.length,
|
|
415
|
+
// This is a landscape/top-N aggregate: the recipient tail is capped at
|
|
416
|
+
// `limit` and the category endpoint reports no grand total → unknown.
|
|
417
|
+
totalAvailable: null,
|
|
418
|
+
truncated: results.length >= limit,
|
|
419
|
+
filtersApplied: filtersAppliedFromFilters(filters),
|
|
420
|
+
filtersDropped: [],
|
|
421
|
+
fieldsUnavailable: ["awards", "totalAwards"],
|
|
422
|
+
notes: [
|
|
423
|
+
"Per-recipient award COUNTS are not available from the spending_by_category/recipient endpoint (it returns obligated amount only) — `awards` and `totalAwards` are null, not 0. For a real contract count use usas_search_awards_by_recipient (its _meta.totalAvailable) or usas_get_recipient_profile.",
|
|
424
|
+
"Only contract award types (A/B/C/D) are included; grants/IDVs are excluded from this share-of-wallet view.",
|
|
425
|
+
],
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─── Line-item awards ─────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
export async function searchIndividualAwards(args: {
|
|
432
|
+
agency?: string;
|
|
433
|
+
naics?: string;
|
|
434
|
+
fiscalYear?: number;
|
|
435
|
+
setAside?: string;
|
|
436
|
+
limit?: number;
|
|
437
|
+
}) {
|
|
438
|
+
const filters = buildFilters(args);
|
|
439
|
+
type Resp = {
|
|
440
|
+
results?: {
|
|
441
|
+
"Award ID"?: string;
|
|
442
|
+
"Recipient Name"?: string;
|
|
443
|
+
"Award Amount"?: number;
|
|
444
|
+
"Awarding Agency"?: string;
|
|
445
|
+
"Awarding Sub Agency"?: string;
|
|
446
|
+
NAICS?: { code?: string; description?: string };
|
|
447
|
+
"Place of Performance State Code"?: string;
|
|
448
|
+
"Place of Performance City Code"?: string;
|
|
449
|
+
"Place of Performance Country Code"?: string;
|
|
450
|
+
"Place of Performance Zip5"?: string;
|
|
451
|
+
Description?: string;
|
|
452
|
+
generated_internal_id?: string;
|
|
453
|
+
}[];
|
|
454
|
+
page_metadata?: { hasNext?: boolean };
|
|
455
|
+
};
|
|
456
|
+
const limit = args.limit ?? 10;
|
|
457
|
+
// D1/D2 field-parity (spec §3.2): "NAICS" and the "Place of Performance …"
|
|
458
|
+
// fields are valid `spending_by_award` field names (empirically verified
|
|
459
|
+
// 2026-07-03 + confirmed against the API contract) and cost NO extra request
|
|
460
|
+
// — USAspending returns whatever fields you ask for. Set-aside is NOT a
|
|
461
|
+
// requestable field on this endpoint (filter-only) → documented as
|
|
462
|
+
// detail-only in `_meta.fieldsUnavailable`.
|
|
463
|
+
const [json, total] = await Promise.all([
|
|
464
|
+
postUsas<Resp>("search/spending_by_award", {
|
|
465
|
+
filters,
|
|
466
|
+
fields: [
|
|
467
|
+
"Award ID",
|
|
468
|
+
"Recipient Name",
|
|
469
|
+
"Award Amount",
|
|
470
|
+
"Awarding Agency",
|
|
471
|
+
"Awarding Sub Agency",
|
|
472
|
+
"NAICS",
|
|
473
|
+
"Place of Performance State Code",
|
|
474
|
+
"Place of Performance City Code",
|
|
475
|
+
"Place of Performance Country Code",
|
|
476
|
+
"Place of Performance Zip5",
|
|
477
|
+
"Description",
|
|
478
|
+
],
|
|
479
|
+
limit,
|
|
480
|
+
page: 1,
|
|
481
|
+
subawards: false,
|
|
482
|
+
}),
|
|
483
|
+
awardCount(filters, "awards"),
|
|
484
|
+
]);
|
|
485
|
+
const results = json.results ?? [];
|
|
486
|
+
const data = {
|
|
487
|
+
awards: results.map((r) => ({
|
|
488
|
+
awardId: r["Award ID"] ?? "",
|
|
489
|
+
recipient: r["Recipient Name"] || null,
|
|
490
|
+
// F2 (P3 null-never-0): an ABSENT Award Amount → null, NEVER a fabricated
|
|
491
|
+
// $0 (a null-amount IDV/loan-type row must not read as "a $0 award"). A
|
|
492
|
+
// genuine 0 still survives — `??` fires only on null/undefined.
|
|
493
|
+
amount: r["Award Amount"] ?? null,
|
|
494
|
+
awardingAgency: r["Awarding Agency"] || null,
|
|
495
|
+
awardingSubAgency: r["Awarding Sub Agency"],
|
|
496
|
+
// D1: NAICS now returned (parity with usas_search_awards_by_recipient).
|
|
497
|
+
naicsCode: r.NAICS?.code,
|
|
498
|
+
naicsDescription: r.NAICS?.description,
|
|
499
|
+
placeOfPerformanceState: r["Place of Performance State Code"],
|
|
500
|
+
placeOfPerformanceCity: r["Place of Performance City Code"],
|
|
501
|
+
placeOfPerformanceCountry: r["Place of Performance Country Code"],
|
|
502
|
+
placeOfPerformanceZip: r["Place of Performance Zip5"],
|
|
503
|
+
description: r.Description,
|
|
504
|
+
generatedInternalId: r.generated_internal_id ?? "",
|
|
505
|
+
})),
|
|
506
|
+
};
|
|
507
|
+
const pagination = awardPagination(
|
|
508
|
+
0,
|
|
509
|
+
limit,
|
|
510
|
+
results.length,
|
|
511
|
+
total,
|
|
512
|
+
json.page_metadata?.hasNext ?? false,
|
|
513
|
+
);
|
|
514
|
+
const notes = [
|
|
515
|
+
"Set-aside type is NOT available from the spending_by_award search endpoint (it can only be FILTERED, not returned) — call usas_get_award_detail (setAsideType/setAsideDescription) per award via generatedInternalId.",
|
|
516
|
+
];
|
|
517
|
+
// W3-7: when more matches exist than this single ranked page holds, say so AND
|
|
518
|
+
// that they are not page-reachable (nextOffset is null — no offset input here).
|
|
519
|
+
if (pagination.hasMore) {
|
|
520
|
+
notes.push(
|
|
521
|
+
`Showing ${limit} of ${total ?? "more"} matching awards. These extra matches are NOT page-reachable — this tool returns one ranked page with no offset input (nextOffset is null). Raise limit (up to 50) or narrow the filters to see more.`,
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
return withMeta(data, {
|
|
525
|
+
source: SPENDING_BY_AWARD_SOURCE,
|
|
526
|
+
keylessMode: true,
|
|
527
|
+
returned: results.length,
|
|
528
|
+
totalAvailable: total,
|
|
529
|
+
pagination,
|
|
530
|
+
filtersApplied: filtersAppliedFromFilters(filters),
|
|
531
|
+
filtersDropped: [],
|
|
532
|
+
// Set-aside is not a `spending_by_award` output field; PoP city is often a
|
|
533
|
+
// numeric code (or null) rather than a name. Both live in detail.
|
|
534
|
+
fieldsUnavailable: ["setAside", "setAsideDescription"],
|
|
535
|
+
notes,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** hasMore for cursor-paginated award search: prefer the real total. */
|
|
540
|
+
function awardPagination(
|
|
541
|
+
offset: number,
|
|
542
|
+
limit: number,
|
|
543
|
+
returned: number,
|
|
544
|
+
total: number | null,
|
|
545
|
+
upstreamHasNext: boolean,
|
|
546
|
+
): NonNullable<MetaBundle["meta"]["pagination"]> {
|
|
547
|
+
const hasMore =
|
|
548
|
+
total !== null ? offset + returned < total : upstreamHasNext;
|
|
549
|
+
return {
|
|
550
|
+
offset,
|
|
551
|
+
limit,
|
|
552
|
+
// W3-7 (honesty; mirrors searchRecipients M1): ALL THREE callers
|
|
553
|
+
// (searchIndividualAwards / searchAwardsByRecipient / searchSubawards) request
|
|
554
|
+
// the upstream `page:1` with NO offset/page input in their tool schemas — so
|
|
555
|
+
// `nextOffset` is NOT consumable. Emitting `offset + returned` (the page length)
|
|
556
|
+
// made an agent re-fetch the SAME top-N forever while ranked-below-`limit` rows
|
|
557
|
+
// stayed unjoinable. Emit null unconditionally; `hasMore` stays honest (more
|
|
558
|
+
// matches DO exist) — the extras are reachable ONLY by raising `limit` (≤50) or
|
|
559
|
+
// narrowing the filters. (No offset-capable caller exists — verified: all 3 pass 0.)
|
|
560
|
+
nextOffset: null,
|
|
561
|
+
hasMore,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ─── Recipient win history ────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
export async function searchAwardsByRecipient(args: {
|
|
568
|
+
recipientName: string;
|
|
569
|
+
agency?: string;
|
|
570
|
+
naics?: string;
|
|
571
|
+
fiscalYear?: number;
|
|
572
|
+
limit?: number;
|
|
573
|
+
}) {
|
|
574
|
+
const filters = buildFilters(args);
|
|
575
|
+
filters.recipient_search_text = [args.recipientName];
|
|
576
|
+
type Resp = {
|
|
577
|
+
results?: {
|
|
578
|
+
"Award ID"?: string;
|
|
579
|
+
"Recipient Name"?: string;
|
|
580
|
+
"Award Amount"?: number;
|
|
581
|
+
"Awarding Agency"?: string;
|
|
582
|
+
"Awarding Sub Agency"?: string;
|
|
583
|
+
NAICS?: { code?: string; description?: string };
|
|
584
|
+
"Place of Performance State Code"?: string;
|
|
585
|
+
"Place of Performance City Code"?: string;
|
|
586
|
+
"Place of Performance Country Code"?: string;
|
|
587
|
+
"Place of Performance Zip5"?: string;
|
|
588
|
+
Description?: string;
|
|
589
|
+
generated_internal_id?: string;
|
|
590
|
+
}[];
|
|
591
|
+
page_metadata?: { hasNext?: boolean };
|
|
592
|
+
};
|
|
593
|
+
const limit = args.limit ?? 15;
|
|
594
|
+
const [json, total] = await Promise.all([
|
|
595
|
+
postUsas<Resp>("search/spending_by_award", {
|
|
596
|
+
filters,
|
|
597
|
+
fields: [
|
|
598
|
+
"Award ID",
|
|
599
|
+
"Recipient Name",
|
|
600
|
+
"Award Amount",
|
|
601
|
+
"Awarding Agency",
|
|
602
|
+
"Awarding Sub Agency",
|
|
603
|
+
"NAICS",
|
|
604
|
+
"Place of Performance State Code",
|
|
605
|
+
"Place of Performance City Code",
|
|
606
|
+
"Place of Performance Country Code",
|
|
607
|
+
"Place of Performance Zip5",
|
|
608
|
+
"Description",
|
|
609
|
+
],
|
|
610
|
+
limit,
|
|
611
|
+
page: 1,
|
|
612
|
+
subawards: false,
|
|
613
|
+
}),
|
|
614
|
+
awardCount(filters, "awards"),
|
|
615
|
+
]);
|
|
616
|
+
const results = json.results ?? [];
|
|
617
|
+
const data = {
|
|
618
|
+
awards: results.map((r) => ({
|
|
619
|
+
awardId: r["Award ID"] ?? "",
|
|
620
|
+
recipient: r["Recipient Name"] || null,
|
|
621
|
+
// F2 (P3 null-never-0): an ABSENT Award Amount → null, NEVER a fabricated
|
|
622
|
+
// $0 (a null-amount IDV/loan-type row must not read as "a $0 award"). A
|
|
623
|
+
// genuine 0 still survives — `??` fires only on null/undefined.
|
|
624
|
+
amount: r["Award Amount"] ?? null,
|
|
625
|
+
awardingAgency: r["Awarding Agency"] || null,
|
|
626
|
+
awardingSubAgency: r["Awarding Sub Agency"],
|
|
627
|
+
naicsCode: r.NAICS?.code,
|
|
628
|
+
naicsDescription: r.NAICS?.description,
|
|
629
|
+
placeOfPerformanceState: r["Place of Performance State Code"],
|
|
630
|
+
placeOfPerformanceCity: r["Place of Performance City Code"],
|
|
631
|
+
placeOfPerformanceCountry: r["Place of Performance Country Code"],
|
|
632
|
+
placeOfPerformanceZip: r["Place of Performance Zip5"],
|
|
633
|
+
description: r.Description,
|
|
634
|
+
generatedInternalId: r.generated_internal_id ?? "",
|
|
635
|
+
})),
|
|
636
|
+
// C5 (spec §1.4, §3.3): the OLD value was `results.length` — the PAGE SIZE,
|
|
637
|
+
// not the true count. A recipient with 400 awards but a 15-row page
|
|
638
|
+
// reported `totalRecords:15` (an order-of-magnitude lie). Now the REAL
|
|
639
|
+
// upstream total (via spending_by_award_count), or null if that companion
|
|
640
|
+
// query failed — never the page length.
|
|
641
|
+
totalRecords: total,
|
|
642
|
+
};
|
|
643
|
+
const pagination = awardPagination(
|
|
644
|
+
0,
|
|
645
|
+
limit,
|
|
646
|
+
results.length,
|
|
647
|
+
total,
|
|
648
|
+
json.page_metadata?.hasNext ?? false,
|
|
649
|
+
);
|
|
650
|
+
const notes = [
|
|
651
|
+
"Set-aside type is NOT available from the spending_by_award search endpoint (filter-only) — call usas_get_award_detail per award for setAsideType/setAsideDescription.",
|
|
652
|
+
];
|
|
653
|
+
// W3-7: extra matches beyond this ranked page are not page-reachable (nextOffset null).
|
|
654
|
+
if (pagination.hasMore) {
|
|
655
|
+
notes.push(
|
|
656
|
+
`Showing ${limit} of ${total ?? "more"} matching awards for this recipient. These extra matches are NOT page-reachable — this tool returns one ranked page with no offset input (nextOffset is null). Raise limit (up to 50) or narrow the filters to see more.`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
return withMeta(data, {
|
|
660
|
+
source: SPENDING_BY_AWARD_SOURCE,
|
|
661
|
+
keylessMode: true,
|
|
662
|
+
returned: results.length,
|
|
663
|
+
totalAvailable: total,
|
|
664
|
+
pagination,
|
|
665
|
+
filtersApplied: filtersAppliedFromFilters(filters),
|
|
666
|
+
filtersDropped: [],
|
|
667
|
+
fieldsUnavailable: ["setAside", "setAsideDescription"],
|
|
668
|
+
notes,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ─── Subaward enumeration ─────────────────────────────────────────
|
|
673
|
+
|
|
674
|
+
export async function searchSubawards(args: {
|
|
675
|
+
primeRecipientName?: string;
|
|
676
|
+
agency?: string;
|
|
677
|
+
naics?: string;
|
|
678
|
+
fiscalYear?: number;
|
|
679
|
+
limit?: number;
|
|
680
|
+
}) {
|
|
681
|
+
const filters = buildFilters(args);
|
|
682
|
+
if (args.primeRecipientName) {
|
|
683
|
+
filters.recipient_search_text = [args.primeRecipientName];
|
|
684
|
+
}
|
|
685
|
+
type Resp = {
|
|
686
|
+
results?: {
|
|
687
|
+
"Sub-Award ID"?: string;
|
|
688
|
+
"Sub-Award Recipient"?: string;
|
|
689
|
+
"Sub-Award Amount"?: number;
|
|
690
|
+
"Sub-Award Date"?: string;
|
|
691
|
+
NAICS?: { code?: string; description?: string };
|
|
692
|
+
prime_award_generated_internal_id?: string;
|
|
693
|
+
}[];
|
|
694
|
+
page_metadata?: { hasNext?: boolean };
|
|
695
|
+
};
|
|
696
|
+
const limit = args.limit ?? 15;
|
|
697
|
+
// A3 (spec §1.2, §3.2): the OLD code requested "Sub-Award NAICS", which is
|
|
698
|
+
// NOT a valid field name on this endpoint — `spending_by_award` echoes an
|
|
699
|
+
// unknown field back as `null` (verified 2026-07-03), so the arg looked
|
|
700
|
+
// honored but silently returned nothing. The valid field for subaward NAICS
|
|
701
|
+
// is "NAICS" (returns {code,description} — the PRIME award's NAICS, which is
|
|
702
|
+
// what USAspending exposes on subaward rows). Swap to it and map it.
|
|
703
|
+
const [json, total] = await Promise.all([
|
|
704
|
+
postUsas<Resp>("search/spending_by_award", {
|
|
705
|
+
filters,
|
|
706
|
+
fields: [
|
|
707
|
+
"Sub-Award ID",
|
|
708
|
+
"Sub-Award Recipient",
|
|
709
|
+
"Sub-Award Amount",
|
|
710
|
+
"Sub-Award Date",
|
|
711
|
+
"NAICS",
|
|
712
|
+
],
|
|
713
|
+
limit,
|
|
714
|
+
page: 1,
|
|
715
|
+
subawards: true,
|
|
716
|
+
}),
|
|
717
|
+
awardCount(filters, "subawards"),
|
|
718
|
+
]);
|
|
719
|
+
const results = json.results ?? [];
|
|
720
|
+
const data = {
|
|
721
|
+
subawards: results.map((r) => ({
|
|
722
|
+
subAwardId: r["Sub-Award ID"] ?? "",
|
|
723
|
+
// minor m2 (W3-1 honesty): an ABSENT Sub-Award Recipient → null, NOT a
|
|
724
|
+
// fabricated "(name redacted)". The old sentinel asserted a specific PRIVACY
|
|
725
|
+
// reason on ANY nullish value (schema gap / null echo / genuine redaction
|
|
726
|
+
// alike) — internally inconsistent with the null-never-fabricate discipline
|
|
727
|
+
// this same function applies to `amount` below. Honest null; the caller
|
|
728
|
+
// reads absence, not an invented redaction cause.
|
|
729
|
+
subRecipient: r["Sub-Award Recipient"] ?? null,
|
|
730
|
+
// F2 (P3 null-never-0): an ABSENT Sub-Award Amount → null, NEVER a
|
|
731
|
+
// fabricated $0. A genuine 0 still survives (`??` fires only on null).
|
|
732
|
+
amount: r["Sub-Award Amount"] ?? null,
|
|
733
|
+
actionDate: r["Sub-Award Date"] ?? "",
|
|
734
|
+
// A3: prime-award NAICS on the subaward row (the only NAICS the endpoint
|
|
735
|
+
// exposes for subawards). null when the row genuinely lacks it.
|
|
736
|
+
naicsCode: r.NAICS?.code,
|
|
737
|
+
naicsDescription: r.NAICS?.description,
|
|
738
|
+
primeAwardId: r.prime_award_generated_internal_id ?? "",
|
|
739
|
+
})),
|
|
740
|
+
};
|
|
741
|
+
const pagination = awardPagination(
|
|
742
|
+
0,
|
|
743
|
+
limit,
|
|
744
|
+
results.length,
|
|
745
|
+
total,
|
|
746
|
+
json.page_metadata?.hasNext ?? false,
|
|
747
|
+
);
|
|
748
|
+
const notes = [
|
|
749
|
+
"The `naicsCode`/`naicsDescription` on each subaward is the PRIME award's NAICS (USAspending does not expose a distinct sub-award NAICS on this endpoint). A subaward-specific NAICS is not available keyless.",
|
|
750
|
+
];
|
|
751
|
+
// W3-7: extra subawards beyond this ranked page are not page-reachable (nextOffset null).
|
|
752
|
+
if (pagination.hasMore) {
|
|
753
|
+
notes.push(
|
|
754
|
+
`Showing ${limit} of ${total ?? "more"} matching subawards. These extra matches are NOT page-reachable — this tool returns one ranked page with no offset input (nextOffset is null). Raise limit (up to 50) or narrow the filters to see more.`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
return withMeta(data, {
|
|
758
|
+
source: SPENDING_BY_AWARD_SOURCE,
|
|
759
|
+
keylessMode: true,
|
|
760
|
+
returned: results.length,
|
|
761
|
+
totalAvailable: total,
|
|
762
|
+
pagination,
|
|
763
|
+
filtersApplied: filtersAppliedFromFilters(filters),
|
|
764
|
+
filtersDropped: [],
|
|
765
|
+
fieldsUnavailable: [],
|
|
766
|
+
notes,
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ─── Per-award detail ─────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Parse USAspending's `number_of_offers_received` to a real number|null.
|
|
774
|
+
*
|
|
775
|
+
* LIVE-VERIFIED 2026-07-03: this field is a STRING on competed awards (e.g.
|
|
776
|
+
* "1", "2", "3") but is genuinely `null` on some delivery orders — so the
|
|
777
|
+
* previous typing/mapping (`number_of_offers_received?: string`, passed
|
|
778
|
+
* through raw) exposed a string where a numeric compare was expected. Coerce
|
|
779
|
+
* to a number; return null for null/empty/non-numeric so a missing value is
|
|
780
|
+
* an honest "unknown", never 0 or "".
|
|
781
|
+
*/
|
|
782
|
+
function parseOffers(raw: unknown): number | null {
|
|
783
|
+
if (raw === null || raw === undefined) return null;
|
|
784
|
+
const n = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
|
|
785
|
+
return Number.isFinite(n) ? n : null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** A parent-award / IDV linkage as returned on awards/{id} (all optional). */
|
|
789
|
+
export type AwardParentIdv = {
|
|
790
|
+
piid: string | null;
|
|
791
|
+
generatedUniqueAwardId: string | null;
|
|
792
|
+
idvTypeDescription: string | null;
|
|
793
|
+
multipleOrSingleAwardDescription: string | null;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
export async function getAwardDetail(generatedInternalId: string) {
|
|
797
|
+
try {
|
|
798
|
+
const r = await fetch(
|
|
799
|
+
`${USAS}/awards/${encodeURIComponent(generatedInternalId)}/`,
|
|
800
|
+
{ signal: AbortSignal.timeout(10_000) },
|
|
801
|
+
);
|
|
802
|
+
// 404 = the id genuinely doesn't resolve → null (a real "not found").
|
|
803
|
+
// 429/5xx = a RETRYABLE upstream fault, NOT a missing award → throw a
|
|
804
|
+
// classified error so callers never mislabel an outage as not_found.
|
|
805
|
+
if (r.status === 404) return null;
|
|
806
|
+
if (!r.ok) {
|
|
807
|
+
throw new ToolErrorCarrier({
|
|
808
|
+
kind: r.status === 429 ? "rate_limited" : "upstream_unavailable",
|
|
809
|
+
message: `usaspending awards/{id} returned ${r.status}`,
|
|
810
|
+
retryable: true,
|
|
811
|
+
upstreamStatus: r.status,
|
|
812
|
+
upstreamEndpoint: `awards/${generatedInternalId}`,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
type Resp = {
|
|
816
|
+
piid?: string;
|
|
817
|
+
description?: string;
|
|
818
|
+
total_obligation?: number;
|
|
819
|
+
base_and_all_options?: number;
|
|
820
|
+
base_exercised_options?: number;
|
|
821
|
+
subaward_count?: number;
|
|
822
|
+
type?: string;
|
|
823
|
+
type_description?: string;
|
|
824
|
+
category?: string;
|
|
825
|
+
period_of_performance?: {
|
|
826
|
+
start_date?: string;
|
|
827
|
+
end_date?: string;
|
|
828
|
+
potential_end_date?: string;
|
|
829
|
+
};
|
|
830
|
+
latest_transaction_contract_data?: {
|
|
831
|
+
type_set_aside?: string;
|
|
832
|
+
type_set_aside_description?: string;
|
|
833
|
+
extent_competed?: string;
|
|
834
|
+
extent_competed_description?: string;
|
|
835
|
+
number_of_offers_received?: string | number | null;
|
|
836
|
+
naics?: string;
|
|
837
|
+
naics_description?: string;
|
|
838
|
+
product_or_service_code?: string;
|
|
839
|
+
product_or_service_description?: string;
|
|
840
|
+
};
|
|
841
|
+
psc_hierarchy?: { base_code?: { code?: string; description?: string } };
|
|
842
|
+
parent_award?: {
|
|
843
|
+
piid?: string;
|
|
844
|
+
generated_unique_award_id?: string;
|
|
845
|
+
idv_type_description?: string;
|
|
846
|
+
multiple_or_single_aw_desc?: string;
|
|
847
|
+
} | null;
|
|
848
|
+
awarding_agency?: {
|
|
849
|
+
toptier_agency?: { name?: string };
|
|
850
|
+
subtier_agency?: { name?: string };
|
|
851
|
+
};
|
|
852
|
+
recipient?: { recipient_name?: string };
|
|
853
|
+
};
|
|
854
|
+
const json = (await r.json()) as Resp;
|
|
855
|
+
const ltc = json.latest_transaction_contract_data ?? {};
|
|
856
|
+
// PSC: prefer the ltc code, fall back to the psc_hierarchy base code (the
|
|
857
|
+
// ltc-level product_or_service_code is often absent while the hierarchy
|
|
858
|
+
// carries it — LIVE-VERIFIED 2026-07-03).
|
|
859
|
+
const pscCode =
|
|
860
|
+
ltc.product_or_service_code ?? json.psc_hierarchy?.base_code?.code ?? null;
|
|
861
|
+
const pscDescription =
|
|
862
|
+
ltc.product_or_service_description ??
|
|
863
|
+
json.psc_hierarchy?.base_code?.description ??
|
|
864
|
+
null;
|
|
865
|
+
const parent = json.parent_award ?? null;
|
|
866
|
+
const parentIdv: AwardParentIdv | null = parent
|
|
867
|
+
? {
|
|
868
|
+
piid: parent.piid ?? null,
|
|
869
|
+
generatedUniqueAwardId: parent.generated_unique_award_id ?? null,
|
|
870
|
+
idvTypeDescription: parent.idv_type_description ?? null,
|
|
871
|
+
multipleOrSingleAwardDescription:
|
|
872
|
+
parent.multiple_or_single_aw_desc ?? null,
|
|
873
|
+
}
|
|
874
|
+
: null;
|
|
875
|
+
return {
|
|
876
|
+
awardId: json.piid ?? "",
|
|
877
|
+
// Identity field is null (UNKNOWN) when absent OR blank — never "" (which
|
|
878
|
+
// reads as "no recipient"). `|| null` catches both a missing recipient_name
|
|
879
|
+
// and a present-but-empty one. Consistent with analyzeIncumbent's incumbent
|
|
880
|
+
// field (#43) and the money fields below.
|
|
881
|
+
recipient: json.recipient?.recipient_name || null,
|
|
882
|
+
// Money fields are null (UNKNOWN) when USAspending omits them — never 0.
|
|
883
|
+
// A null base_and_all_options is common and legitimate (IDVs/BPAs carry
|
|
884
|
+
// the ceiling at the vehicle level, grants/loans have no ceiling concept);
|
|
885
|
+
// rendering it as 0 would read as "a $0 ceiling", a data-absence-as-present
|
|
886
|
+
// masquerade. Consistent with baseExercisedOptions, which already nulls.
|
|
887
|
+
totalObligation: json.total_obligation ?? null,
|
|
888
|
+
baseAndAllOptions: json.base_and_all_options ?? null,
|
|
889
|
+
baseExercisedOptions: json.base_exercised_options ?? null,
|
|
890
|
+
subawardCount: json.subaward_count ?? null,
|
|
891
|
+
// Award type + human description (e.g. "C" / "DELIVERY ORDER").
|
|
892
|
+
contractAwardType: json.type_description ?? json.type ?? null,
|
|
893
|
+
periodOfPerformance: {
|
|
894
|
+
startDate: json.period_of_performance?.start_date ?? null,
|
|
895
|
+
endDate: json.period_of_performance?.end_date ?? null,
|
|
896
|
+
potentialEndDate: json.period_of_performance?.potential_end_date ?? null,
|
|
897
|
+
},
|
|
898
|
+
description: json.description ?? "",
|
|
899
|
+
setAsideType: ltc.type_set_aside,
|
|
900
|
+
setAsideDescription: ltc.type_set_aside_description,
|
|
901
|
+
competitionExtent: ltc.extent_competed,
|
|
902
|
+
competitionExtentDescription: ltc.extent_competed_description ?? null,
|
|
903
|
+
// E-type-hygiene fix: number_of_offers_received is now a parsed
|
|
904
|
+
// number|null, not the raw string it arrives as.
|
|
905
|
+
numberOfOffers: parseOffers(ltc.number_of_offers_received),
|
|
906
|
+
awardingAgency: json.awarding_agency?.toptier_agency?.name,
|
|
907
|
+
awardingSubAgency: json.awarding_agency?.subtier_agency?.name,
|
|
908
|
+
naicsCode: ltc.naics,
|
|
909
|
+
naicsDescription: ltc.naics_description,
|
|
910
|
+
pscCode,
|
|
911
|
+
pscDescription,
|
|
912
|
+
parentIdv,
|
|
913
|
+
};
|
|
914
|
+
} catch (e) {
|
|
915
|
+
// A classified upstream error (429/5xx) must propagate so the caller can
|
|
916
|
+
// retry and never mislabel it as not_found. A network/timeout/parse fault
|
|
917
|
+
// is likewise retryable — surface it, don't collapse it to a false null.
|
|
918
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
919
|
+
throw new ToolErrorCarrier({
|
|
920
|
+
kind: "upstream_unavailable",
|
|
921
|
+
message: `usaspending awards/{id} fetch failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
922
|
+
retryable: true,
|
|
923
|
+
upstreamEndpoint: `awards/${generatedInternalId}`,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// ─── Per-award transaction (modification) count ───────────────────
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Bounded modification-count for a single award via the keyless
|
|
932
|
+
* `POST transactions/` endpoint (`{award_id, limit:100, page:1}`).
|
|
933
|
+
*
|
|
934
|
+
* WHY bounded, not paged: this endpoint's `page_metadata` carries only
|
|
935
|
+
* `hasNext` — there is NO grand total (LIVE-VERIFIED 2026-07-03). So we read
|
|
936
|
+
* ONE 100-row page and return its length. If `hasNext` is true the true count
|
|
937
|
+
* exceeds 100, so we return `{ count: <len>, atLeast: true }` — a LOWER BOUND,
|
|
938
|
+
* never an unbounded fan-out. `modification_number` on the latest transaction
|
|
939
|
+
* is an unreliable proxy (it was `undefined` on the test award) and is not
|
|
940
|
+
* used. Returns `null` count on failure so the caller degrades honestly.
|
|
941
|
+
*/
|
|
942
|
+
async function transactionsCount(
|
|
943
|
+
generatedInternalId: string,
|
|
944
|
+
): Promise<{ count: number | null; atLeast: boolean }> {
|
|
945
|
+
try {
|
|
946
|
+
type Resp = {
|
|
947
|
+
results?: unknown[];
|
|
948
|
+
page_metadata?: { hasNext?: boolean };
|
|
949
|
+
};
|
|
950
|
+
const json = await postUsas<Resp>("transactions/", {
|
|
951
|
+
award_id: generatedInternalId,
|
|
952
|
+
limit: 100,
|
|
953
|
+
page: 1,
|
|
954
|
+
});
|
|
955
|
+
const rows = json.results ?? [];
|
|
956
|
+
return { count: rows.length, atLeast: json.page_metadata?.hasNext === true };
|
|
957
|
+
} catch {
|
|
958
|
+
return { count: null, atLeast: false };
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// ─── Per-award incumbent + public recompete-pressure analysis ─────
|
|
963
|
+
|
|
964
|
+
const ANALYZE_INCUMBENT_SOURCE =
|
|
965
|
+
"usaspending.gov awards/{id} + transactions + spending_by_award (keyless)";
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* The public fields that are decision-relevant for a recompete but are NOT
|
|
969
|
+
* in any keyless (or any public) source — declared in `_meta.fieldsUnavailable`
|
|
970
|
+
* so the AI hedges instead of inventing a vulnerability score.
|
|
971
|
+
*/
|
|
972
|
+
const ANALYZE_FIELDS_UNAVAILABLE = [
|
|
973
|
+
"past_performance_cpars",
|
|
974
|
+
"protest_history",
|
|
975
|
+
"option_exercise_intent",
|
|
976
|
+
];
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Per-award incumbent + PUBLIC recompete-pressure analysis (design doc 04
|
|
980
|
+
* §5.2). Given ONE award (`generatedInternalId`) it assembles, from keyless
|
|
981
|
+
* data only:
|
|
982
|
+
* - the incumbent identity + the award's agency/NAICS/PSC/vehicle,
|
|
983
|
+
* - PUBLIC recompete-pressure SIGNALS (obligated-vs-ceiling consumption, mod
|
|
984
|
+
* count, competition extent + number of offers, set-aside, days to the
|
|
985
|
+
* current PoP end, and option-extendable days), and
|
|
986
|
+
* - (optionally) the incumbent's other awards in the same agency.
|
|
987
|
+
*
|
|
988
|
+
* DESIGN — bounded & keyless, NO N+1 fan-out:
|
|
989
|
+
* 1 `awards/{id}` detail + 1 `transactions/` page (mod count, capped at
|
|
990
|
+
* 100 → lower bound) + (optional) 1 `searchAwardsByRecipient` call. That is
|
|
991
|
+
* at most 3 upstream calls regardless of award size.
|
|
992
|
+
*
|
|
993
|
+
* HONEST CEILING (mandatory): it emits INDIVIDUAL public signals + `pressureHints`
|
|
994
|
+
* (e.g. "single_offer", "ceiling_nearly_exhausted", "hard_stop_no_options") that
|
|
995
|
+
* are HINTS, never a score. It NEVER emits a composite "vulnerability score" —
|
|
996
|
+
* the most decision-relevant input (past performance / CPARS), protest history,
|
|
997
|
+
* and the incumbent's option-exercise intent are not public, and are declared
|
|
998
|
+
* in `_meta.fieldsUnavailable`. A not-found award raises a structured not_found
|
|
999
|
+
* error (never `{ok:true, data:null}`).
|
|
1000
|
+
*/
|
|
1001
|
+
export async function analyzeIncumbent(args: {
|
|
1002
|
+
generatedInternalId: string;
|
|
1003
|
+
includeOtherAwards?: boolean;
|
|
1004
|
+
otherAwardsLimit?: number;
|
|
1005
|
+
}) {
|
|
1006
|
+
const includeOtherAwards = args.includeOtherAwards ?? true;
|
|
1007
|
+
const otherAwardsLimit = Math.min(
|
|
1008
|
+
50,
|
|
1009
|
+
Math.max(1, Math.floor(args.otherAwardsLimit ?? 15)),
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
// --- 1. Award detail (throws not_found if the id doesn't resolve) ------
|
|
1013
|
+
const detail = await getAwardDetail(args.generatedInternalId);
|
|
1014
|
+
if (!detail) {
|
|
1015
|
+
throw new ToolErrorCarrier({
|
|
1016
|
+
kind: "not_found",
|
|
1017
|
+
message: `No award found for generatedInternalId '${args.generatedInternalId}' on usaspending.gov awards/{id}. Resolve a valid id via usas_search_individual_awards or usas_search_awards_by_recipient (each result carries a generatedInternalId).`,
|
|
1018
|
+
retryable: false,
|
|
1019
|
+
upstreamEndpoint: `awards/${args.generatedInternalId}`,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const nowMs = Date.now();
|
|
1024
|
+
let enrichmentCalls = 1; // the detail fetch
|
|
1025
|
+
|
|
1026
|
+
// --- 2. Bounded mod count (1 transactions page) -----------------------
|
|
1027
|
+
const mods = await transactionsCount(args.generatedInternalId);
|
|
1028
|
+
enrichmentCalls++;
|
|
1029
|
+
|
|
1030
|
+
// --- 3. Signals (all PUBLIC, individual — never combined into a score) -
|
|
1031
|
+
const obligated = detail.totalObligation;
|
|
1032
|
+
const ceiling = detail.baseAndAllOptions;
|
|
1033
|
+
// pctConsumed only when BOTH obligated is a number AND the ceiling is a usable
|
|
1034
|
+
// positive number; a null/absent obligated or a 0/absent/negative ceiling → null
|
|
1035
|
+
// (never a divide-by-zero, a null-coerced-to-0 ratio, or a nonsensical negative).
|
|
1036
|
+
const pctConsumed =
|
|
1037
|
+
typeof obligated === "number" && typeof ceiling === "number" && ceiling > 0
|
|
1038
|
+
? obligated / ceiling
|
|
1039
|
+
: null;
|
|
1040
|
+
|
|
1041
|
+
const currentEndDate = detail.periodOfPerformance.endDate;
|
|
1042
|
+
const potentialEndDate = detail.periodOfPerformance.potentialEndDate;
|
|
1043
|
+
const daysUntilCurrentEnd = daysUntil(currentEndDate, nowMs);
|
|
1044
|
+
const daysUntilPotentialEnd = daysUntil(potentialEndDate, nowMs);
|
|
1045
|
+
// extendableDays = runway the unexercised options would add. Null when
|
|
1046
|
+
// either end date is unusable.
|
|
1047
|
+
const extendableDays =
|
|
1048
|
+
daysUntilPotentialEnd !== null && daysUntilCurrentEnd !== null
|
|
1049
|
+
? daysUntilPotentialEnd - daysUntilCurrentEnd
|
|
1050
|
+
: null;
|
|
1051
|
+
|
|
1052
|
+
const numberOfOffers = detail.numberOfOffers; // already number|null
|
|
1053
|
+
|
|
1054
|
+
const signals = {
|
|
1055
|
+
obligatedVsCeiling: {
|
|
1056
|
+
obligated,
|
|
1057
|
+
baseAndAllOptions: ceiling,
|
|
1058
|
+
baseExercisedOptions: detail.baseExercisedOptions,
|
|
1059
|
+
pctConsumed,
|
|
1060
|
+
},
|
|
1061
|
+
modCount: mods.count,
|
|
1062
|
+
modCountAtLeast: mods.atLeast,
|
|
1063
|
+
setAside: detail.setAsideType ?? null,
|
|
1064
|
+
setAsideDescription: detail.setAsideDescription ?? null,
|
|
1065
|
+
extentCompeted: detail.competitionExtent ?? null,
|
|
1066
|
+
extentCompetedDescription: detail.competitionExtentDescription ?? null,
|
|
1067
|
+
numberOfOffers,
|
|
1068
|
+
currentEndDate,
|
|
1069
|
+
potentialEndDate,
|
|
1070
|
+
extendableDays,
|
|
1071
|
+
daysUntilCurrentEnd,
|
|
1072
|
+
vehicle: {
|
|
1073
|
+
contractAwardType: detail.contractAwardType,
|
|
1074
|
+
parentIdvPiid: detail.parentIdv?.piid ?? null,
|
|
1075
|
+
idvType: detail.parentIdv?.idvTypeDescription ?? null,
|
|
1076
|
+
singleOrMultiple:
|
|
1077
|
+
detail.parentIdv?.multipleOrSingleAwardDescription ?? null,
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
// --- pressureHints: individual PUBLIC flags — HINTS, never a score -----
|
|
1082
|
+
const pressureHints: string[] = [];
|
|
1083
|
+
if (numberOfOffers === 1) pressureHints.push("single_offer");
|
|
1084
|
+
if (pctConsumed !== null && pctConsumed >= 0.9)
|
|
1085
|
+
pressureHints.push("ceiling_nearly_exhausted");
|
|
1086
|
+
if (extendableDays !== null && extendableDays <= 0)
|
|
1087
|
+
pressureHints.push("hard_stop_no_options");
|
|
1088
|
+
|
|
1089
|
+
// --- 4. Incumbent's other awards in the same agency (1 bounded call) ---
|
|
1090
|
+
let incumbentOtherAwards:
|
|
1091
|
+
| Awaited<ReturnType<typeof searchAwardsByRecipient>>["data"]["awards"]
|
|
1092
|
+
| undefined;
|
|
1093
|
+
let otherAwardsFailed = false;
|
|
1094
|
+
if (includeOtherAwards && detail.recipient) {
|
|
1095
|
+
enrichmentCalls++; // count the attempt (whether or not it succeeds)
|
|
1096
|
+
try {
|
|
1097
|
+
const other = await searchAwardsByRecipient({
|
|
1098
|
+
recipientName: detail.recipient,
|
|
1099
|
+
agency: detail.awardingAgency,
|
|
1100
|
+
naics: detail.naicsCode,
|
|
1101
|
+
limit: otherAwardsLimit,
|
|
1102
|
+
});
|
|
1103
|
+
// Drop the award we're analyzing from its own "other awards" list.
|
|
1104
|
+
incumbentOtherAwards = other.data.awards.filter(
|
|
1105
|
+
(a) => a.generatedInternalId !== args.generatedInternalId,
|
|
1106
|
+
);
|
|
1107
|
+
} catch {
|
|
1108
|
+
// Non-fatal, but MUST be disclosed: an empty list here means "the search
|
|
1109
|
+
// failed", NOT "the incumbent has no other awards". (D1)
|
|
1110
|
+
incumbentOtherAwards = [];
|
|
1111
|
+
otherAwardsFailed = true;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// The award record carries no recipient_name → the incumbent identity is
|
|
1116
|
+
// UNKNOWN. Two masquerades to avoid: (1) `incumbent: ""` reads as "none"
|
|
1117
|
+
// rather than "unknown"; (2) with includeOtherAwards, the recipient search is
|
|
1118
|
+
// SKIPPED (the `detail.recipient` guard above is falsy) so `incumbentOtherAwards`
|
|
1119
|
+
// stays undefined → `?? []` emits an empty list that reads as "no other awards"
|
|
1120
|
+
// when the search never ran. Same class as the D1 otherAwardsFailed disclosure.
|
|
1121
|
+
const incumbentUnknown = !detail.recipient;
|
|
1122
|
+
|
|
1123
|
+
const data = {
|
|
1124
|
+
award: {
|
|
1125
|
+
awardId: detail.awardId,
|
|
1126
|
+
incumbent: detail.recipient || null,
|
|
1127
|
+
awardingAgency: detail.awardingAgency ?? null,
|
|
1128
|
+
awardingSubAgency: detail.awardingSubAgency ?? null,
|
|
1129
|
+
naicsCode: detail.naicsCode ?? null,
|
|
1130
|
+
pscCode: detail.pscCode ?? null,
|
|
1131
|
+
contractAwardType: detail.contractAwardType,
|
|
1132
|
+
startDate: detail.periodOfPerformance.startDate,
|
|
1133
|
+
currentEndDate,
|
|
1134
|
+
potentialEndDate,
|
|
1135
|
+
},
|
|
1136
|
+
signals,
|
|
1137
|
+
pressureHints,
|
|
1138
|
+
...(includeOtherAwards ? { incumbentOtherAwards: incumbentOtherAwards ?? [] } : {}),
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
// --- Truthful _meta ---------------------------------------------------
|
|
1142
|
+
// The offers value being null means single_offer could not be evaluated —
|
|
1143
|
+
// declare number_of_offers_received unavailable so the AI knows.
|
|
1144
|
+
const fieldsUnavailable = [...ANALYZE_FIELDS_UNAVAILABLE];
|
|
1145
|
+
if (numberOfOffers === null) {
|
|
1146
|
+
fieldsUnavailable.push("number_of_offers_received");
|
|
1147
|
+
}
|
|
1148
|
+
if (incumbentUnknown) {
|
|
1149
|
+
fieldsUnavailable.push("recipient_name");
|
|
1150
|
+
}
|
|
1151
|
+
// Null money fields are UNKNOWN, not $0 — disclose so an AI never cites a
|
|
1152
|
+
// fabricated zero (the values themselves are now null in obligatedVsCeiling).
|
|
1153
|
+
if (obligated === null) fieldsUnavailable.push("total_obligation");
|
|
1154
|
+
if (ceiling === null) fieldsUnavailable.push("base_and_all_options");
|
|
1155
|
+
|
|
1156
|
+
// List ONLY the calls actually attempted — never assert a recipient search
|
|
1157
|
+
// that failed or was skipped (D1). enrichmentCalls stays in lockstep with
|
|
1158
|
+
// this list: detail + transactions are always attempted; the recipient call
|
|
1159
|
+
// is attempted iff includeOtherAwards && a recipient name exists.
|
|
1160
|
+
const callList = ["awards/{id} detail", "1 transactions page"];
|
|
1161
|
+
if (includeOtherAwards && detail.recipient) callList.push("1 recipient search");
|
|
1162
|
+
const transactionsFailed = mods.count === null;
|
|
1163
|
+
|
|
1164
|
+
const notes: string[] = [
|
|
1165
|
+
"HONEST CEILING: PUBLIC signals only; no composite vulnerability score. Past-performance/CPARS ratings, protest history, and the incumbent's option-exercise intent are NOT public — judge the recompete with off-platform intelligence.",
|
|
1166
|
+
`Bounded keyless design: ${enrichmentCalls} upstream call(s) (${callList.join(" + ")}); no per-record fan-out.`,
|
|
1167
|
+
];
|
|
1168
|
+
if (mods.atLeast) {
|
|
1169
|
+
notes.push(
|
|
1170
|
+
`modCount is a LOWER BOUND: this award has more than 100 transactions (the transactions endpoint reports no total, so only one 100-row page is read). modCountAtLeast is true.`,
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
if (transactionsFailed) {
|
|
1174
|
+
notes.push(
|
|
1175
|
+
"modCount is null because the transactions call FAILED (not because the award has no modifications) — the modification count is unknown, not zero.",
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
if (otherAwardsFailed) {
|
|
1179
|
+
notes.push(
|
|
1180
|
+
"incumbentOtherAwards could not be retrieved (the recipient search FAILED) and is shown as an EMPTY list — this is NOT a confirmation that the incumbent has no other awards.",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
if (incumbentUnknown) {
|
|
1184
|
+
notes.push(
|
|
1185
|
+
"The award record carries no recipient_name — the incumbent identity is UNKNOWN (returned as null), NOT 'none'. Incumbent-specific analysis (identity, other awards) cannot be performed on this record.",
|
|
1186
|
+
);
|
|
1187
|
+
if (includeOtherAwards) {
|
|
1188
|
+
notes.push(
|
|
1189
|
+
"incumbentOtherAwards is an EMPTY list because there is no recipient name to search by — the recipient search was SKIPPED, not run and found empty. This is NOT a confirmation that the incumbent has no other awards.",
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (pctConsumed === null) {
|
|
1194
|
+
notes.push(
|
|
1195
|
+
"obligatedVsCeiling.pctConsumed is null because the obligated amount or the award's ceiling (base_and_all_options) is absent (null), zero, or a negative data-entry value — consumption cannot be computed.",
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
if (numberOfOffers === null) {
|
|
1199
|
+
notes.push(
|
|
1200
|
+
"number_of_offers_received is null on this award, so the 'single_offer' hint could not be evaluated (absence of the hint does NOT imply competition).",
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// A failed secondary enrichment means this is NOT the complete picture →
|
|
1205
|
+
// force complete:false so an AI never reads partial data as complete (D1/D2).
|
|
1206
|
+
// A blank recipient with includeOtherAwards is the same class: the emitted
|
|
1207
|
+
// empty incumbentOtherAwards would otherwise read as complete.
|
|
1208
|
+
const degraded =
|
|
1209
|
+
transactionsFailed ||
|
|
1210
|
+
otherAwardsFailed ||
|
|
1211
|
+
(includeOtherAwards && incumbentUnknown);
|
|
1212
|
+
|
|
1213
|
+
return withMeta(data, {
|
|
1214
|
+
source: ANALYZE_INCUMBENT_SOURCE,
|
|
1215
|
+
keylessMode: true,
|
|
1216
|
+
complete: degraded ? false : undefined,
|
|
1217
|
+
returned: 1,
|
|
1218
|
+
totalAvailable: 1,
|
|
1219
|
+
truncated: false,
|
|
1220
|
+
filtersApplied: [],
|
|
1221
|
+
filtersDropped: [],
|
|
1222
|
+
fieldsUnavailable,
|
|
1223
|
+
enrichedCount: enrichmentCalls,
|
|
1224
|
+
notes,
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// ─── Recompete radar ──────────────────────────────────────────────
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Set-aside → USAspending `set_aside_type_codes` filter code. The
|
|
1232
|
+
* `spending_by_award` endpoint DOES honor `set_aside_type_codes` server-side
|
|
1233
|
+
* (LIVE-VERIFIED 2026-07-03: VA×541512 base 696 → SDVOSBC 182, SBA 19, WOSB 1
|
|
1234
|
+
* — genuine reductions; the wrong keys `type_set_aside`/`set_aside` are
|
|
1235
|
+
* silently IGNORED, returning the unfiltered 696). Set-aside is a FILTER only,
|
|
1236
|
+
* never a requestable output field (verified: it comes back absent). So we
|
|
1237
|
+
* filter by it but cannot read a per-row set-aside VALUE from search — that
|
|
1238
|
+
* lives in usas_get_award_detail.
|
|
1239
|
+
*/
|
|
1240
|
+
const SET_ASIDE_CODES = new Set([
|
|
1241
|
+
"SBA",
|
|
1242
|
+
"8A",
|
|
1243
|
+
"HZS",
|
|
1244
|
+
"SDVOSBC",
|
|
1245
|
+
"WOSB",
|
|
1246
|
+
"EDWOSB",
|
|
1247
|
+
"VSA",
|
|
1248
|
+
"VSS",
|
|
1249
|
+
]);
|
|
1250
|
+
|
|
1251
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* A `spending_by_award` row as returned when we request the recompete field
|
|
1255
|
+
* set. Every value is optional/nullable — USAspending echoes unknown fields
|
|
1256
|
+
* back as `null`, and PoP end dates are legitimately null on some rows.
|
|
1257
|
+
*/
|
|
1258
|
+
type RecompeteRow = {
|
|
1259
|
+
"Award ID"?: string | null;
|
|
1260
|
+
"Recipient Name"?: string | null;
|
|
1261
|
+
"Award Amount"?: number | null;
|
|
1262
|
+
"Awarding Agency"?: string | null;
|
|
1263
|
+
"Awarding Sub Agency"?: string | null;
|
|
1264
|
+
"Start Date"?: string | null;
|
|
1265
|
+
"End Date"?: string | null;
|
|
1266
|
+
NAICS?: { code?: string; description?: string } | null;
|
|
1267
|
+
PSC?: { code?: string; description?: string } | null;
|
|
1268
|
+
"Contract Award Type"?: string | null;
|
|
1269
|
+
"Last Modified Date"?: string | null;
|
|
1270
|
+
"Period of Performance Potential End Date"?: string | null;
|
|
1271
|
+
generated_internal_id?: string | null;
|
|
1272
|
+
};
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Parse a PoP end date to "whole days from today" (UTC midnight). Returns
|
|
1276
|
+
* `null` for null/empty/unparseable/absurd values so the caller can COUNT the
|
|
1277
|
+
* row (never silently drop it) and treat it as out-of-window. Guards against
|
|
1278
|
+
* the far-future data-entry errors USAspending carries (e.g. year 2108).
|
|
1279
|
+
*/
|
|
1280
|
+
function daysUntil(dateStr: string | null | undefined, nowMs: number): number | null {
|
|
1281
|
+
if (!dateStr) return null;
|
|
1282
|
+
const t = new Date(dateStr).getTime();
|
|
1283
|
+
if (Number.isNaN(t)) return null;
|
|
1284
|
+
const year = new Date(t).getUTCFullYear();
|
|
1285
|
+
// Sanity clamp: PoP end dates outside [1990, 2200] are data errors.
|
|
1286
|
+
if (year < 1990 || year > 2200) return null;
|
|
1287
|
+
return Math.ceil((t - nowMs) / MS_PER_DAY);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** UTC "today minus N years" as YYYY-MM-DD, for the action_date lower bound. */
|
|
1291
|
+
function isoYearsAgo(nowMs: number, years: number): string {
|
|
1292
|
+
const d = new Date(nowMs);
|
|
1293
|
+
d.setUTCFullYear(d.getUTCFullYear() - years);
|
|
1294
|
+
return d.toISOString().slice(0, 10);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const RECOMPETE_SOURCE =
|
|
1298
|
+
"usaspending.gov spending_by_award (keyless)";
|
|
1299
|
+
|
|
1300
|
+
const RECOMPETE_FIELDS_UNAVAILABLE = [
|
|
1301
|
+
"past_performance_cpars",
|
|
1302
|
+
"protest_history",
|
|
1303
|
+
"option_exercise_intent",
|
|
1304
|
+
];
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Recompete radar — federal contracts whose current period of performance
|
|
1308
|
+
* ends inside a window around today, so you can see what's coming up for
|
|
1309
|
+
* recompete. Replaces the broken `searchExpiringContracts` internals.
|
|
1310
|
+
*
|
|
1311
|
+
* MECHANISM (LIVE-VERIFIED 2026-07-03 across VA×541512 and DoD×541330):
|
|
1312
|
+
* `spending_by_award` returns the current PoP end date directly under the
|
|
1313
|
+
* field ALIAS `"End Date"` (the canonical string
|
|
1314
|
+
* "Period of Performance Current End Date" is NOT a recognized field — it
|
|
1315
|
+
* comes back always null, and is not in the sort mappings → HTTP 400 if you
|
|
1316
|
+
* sort by it). Gold-standard confirmed: search `"End Date"` ===
|
|
1317
|
+
* `awards/{generated_internal_id}`.period_of_performance.end_date.
|
|
1318
|
+
*
|
|
1319
|
+
* We CANNOT filter by PoP end date server-side (`time_period.date_type` only
|
|
1320
|
+
* supports action_date/date_signed/last_modified_date/new_awards_only). So:
|
|
1321
|
+
* 1. server-side SORT by `"End Date"` DESC (the alias — the only PoP-end
|
|
1322
|
+
* value in the sort mappings),
|
|
1323
|
+
* 2. an action_date `time_period` lower bound (LOAD-BEARING: prunes inactive
|
|
1324
|
+
* records and much of the far-future data-entry garbage so DESC reaches
|
|
1325
|
+
* the window sooner),
|
|
1326
|
+
* 3. a CLIENT-SIDE window filter with pagination + a safe early-stop (DESC ⇒
|
|
1327
|
+
* once a row is earlier than the window start, every later row is earlier
|
|
1328
|
+
* too), bounded by `scanBudgetPages`.
|
|
1329
|
+
*
|
|
1330
|
+
* TRUTHFULNESS: rows with a null `"End Date"` are COUNTED (`missingEndDate`),
|
|
1331
|
+
* never silently dropped. If the scan budget is exhausted before the early-stop
|
|
1332
|
+
* fires, `scanTruncated` is set and `totalAvailable` becomes null (the returned
|
|
1333
|
+
* set is a lower bound, not the complete window). This tool emits PUBLIC
|
|
1334
|
+
* signals only — it never fabricates a composite "vulnerability" score;
|
|
1335
|
+
* past-performance/CPARS, protest history, and option-exercise intent are not
|
|
1336
|
+
* public and are declared in `_meta.fieldsUnavailable`.
|
|
1337
|
+
*/
|
|
1338
|
+
export async function searchRecompetes(args: {
|
|
1339
|
+
agency?: string;
|
|
1340
|
+
naics?: string;
|
|
1341
|
+
pscCodes?: string[];
|
|
1342
|
+
setAside?: string;
|
|
1343
|
+
windowStartDays?: number;
|
|
1344
|
+
windowEndDays?: number;
|
|
1345
|
+
minAwardValue?: number;
|
|
1346
|
+
includePotentialEnd?: boolean;
|
|
1347
|
+
actionDateLookbackYears?: number;
|
|
1348
|
+
page?: number;
|
|
1349
|
+
pageSize?: number;
|
|
1350
|
+
scanBudgetPages?: number;
|
|
1351
|
+
}) {
|
|
1352
|
+
const nowMs = Date.now();
|
|
1353
|
+
const windowStartDays = args.windowStartDays ?? -90;
|
|
1354
|
+
const windowEndDays = args.windowEndDays ?? 548; // ~18 months
|
|
1355
|
+
const minAwardValue = args.minAwardValue ?? 0;
|
|
1356
|
+
const includePotentialEnd = args.includePotentialEnd ?? false;
|
|
1357
|
+
const actionDateLookbackYears = args.actionDateLookbackYears ?? 3;
|
|
1358
|
+
const page = Math.max(1, Math.floor(args.page ?? 1));
|
|
1359
|
+
const pageSize = Math.min(100, Math.max(1, Math.floor(args.pageSize ?? 25)));
|
|
1360
|
+
const scanBudgetPages = Math.min(20, Math.max(1, Math.floor(args.scanBudgetPages ?? 8)));
|
|
1361
|
+
|
|
1362
|
+
// --- Build filters (only what we can send truthfully) -----------------
|
|
1363
|
+
const filters: UsasFilters = { award_type_codes: ["A", "B", "C", "D"] };
|
|
1364
|
+
const filtersApplied: string[] = ["awardType(contracts A/B/C/D)"];
|
|
1365
|
+
const filtersDropped: string[] = [];
|
|
1366
|
+
if (args.agency) {
|
|
1367
|
+
filters.agencies = [{ type: "awarding", tier: "toptier", name: args.agency }];
|
|
1368
|
+
filtersApplied.push("agency");
|
|
1369
|
+
}
|
|
1370
|
+
if (args.naics) {
|
|
1371
|
+
filters.naics_codes = [args.naics];
|
|
1372
|
+
filtersApplied.push("naics");
|
|
1373
|
+
}
|
|
1374
|
+
if (args.pscCodes?.length) {
|
|
1375
|
+
filters.psc_codes = args.pscCodes;
|
|
1376
|
+
filtersApplied.push("pscCodes");
|
|
1377
|
+
}
|
|
1378
|
+
// Set-aside: `set_aside_type_codes` is honored server-side (verified). Only
|
|
1379
|
+
// send a code we know the endpoint recognizes; otherwise record it dropped.
|
|
1380
|
+
if (args.setAside) {
|
|
1381
|
+
if (SET_ASIDE_CODES.has(args.setAside)) {
|
|
1382
|
+
filters.set_aside_type_codes = [args.setAside];
|
|
1383
|
+
filtersApplied.push("setAside");
|
|
1384
|
+
} else {
|
|
1385
|
+
filtersDropped.push("setAside");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
// action_date lower bound — the default date_type is action_date, so no
|
|
1389
|
+
// explicit date_type is needed (and passing one is optional).
|
|
1390
|
+
const lookbackStart = isoYearsAgo(nowMs, actionDateLookbackYears);
|
|
1391
|
+
const todayIso = new Date(nowMs).toISOString().slice(0, 10);
|
|
1392
|
+
filters.time_period = [{ start_date: lookbackStart, end_date: todayIso }];
|
|
1393
|
+
filtersApplied.push(`actionDateLookback(${actionDateLookbackYears}y)`);
|
|
1394
|
+
|
|
1395
|
+
const fields = [
|
|
1396
|
+
"Award ID",
|
|
1397
|
+
"Recipient Name",
|
|
1398
|
+
"Award Amount",
|
|
1399
|
+
"Awarding Agency",
|
|
1400
|
+
"Awarding Sub Agency",
|
|
1401
|
+
"Start Date",
|
|
1402
|
+
"End Date",
|
|
1403
|
+
"NAICS",
|
|
1404
|
+
"PSC",
|
|
1405
|
+
"Contract Award Type",
|
|
1406
|
+
"Last Modified Date",
|
|
1407
|
+
"generated_internal_id",
|
|
1408
|
+
];
|
|
1409
|
+
if (includePotentialEnd) {
|
|
1410
|
+
fields.push("Period of Performance Potential End Date");
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
type SearchResp = {
|
|
1414
|
+
results?: RecompeteRow[];
|
|
1415
|
+
page_metadata?: { hasNext?: boolean; page?: number };
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
// --- Scan pages (DESC by End Date) with early-stop + budget ----------
|
|
1419
|
+
type Shaped = {
|
|
1420
|
+
awardId: string;
|
|
1421
|
+
generatedInternalId: string;
|
|
1422
|
+
incumbent: string;
|
|
1423
|
+
amount: number;
|
|
1424
|
+
currentEndDate: string;
|
|
1425
|
+
daysUntilCurrentEnd: number;
|
|
1426
|
+
potentialEndDate?: string | null;
|
|
1427
|
+
extendableDays?: number | null;
|
|
1428
|
+
awardingAgency: string;
|
|
1429
|
+
awardingSubAgency: string | null;
|
|
1430
|
+
naicsCode: string | null;
|
|
1431
|
+
pscCode: string | null;
|
|
1432
|
+
contractAwardType: string | null;
|
|
1433
|
+
setAsideDescription: string | null;
|
|
1434
|
+
startDate: string | null;
|
|
1435
|
+
description: string | null;
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
const results: Shaped[] = [];
|
|
1439
|
+
let scanned = 0;
|
|
1440
|
+
let missingEndDate = 0;
|
|
1441
|
+
let pastWindow = false;
|
|
1442
|
+
let scanTruncated = false;
|
|
1443
|
+
|
|
1444
|
+
for (let p = 1; p <= scanBudgetPages; p++) {
|
|
1445
|
+
const resp = await postUsas<SearchResp>("search/spending_by_award", {
|
|
1446
|
+
filters,
|
|
1447
|
+
fields,
|
|
1448
|
+
sort: "End Date",
|
|
1449
|
+
order: "desc",
|
|
1450
|
+
limit: 100,
|
|
1451
|
+
page: p,
|
|
1452
|
+
subawards: false,
|
|
1453
|
+
});
|
|
1454
|
+
const rows = resp.results ?? [];
|
|
1455
|
+
for (const row of rows) {
|
|
1456
|
+
scanned++;
|
|
1457
|
+
const end = row["End Date"] ?? null;
|
|
1458
|
+
const d = daysUntil(end, nowMs);
|
|
1459
|
+
if (d === null) {
|
|
1460
|
+
// Null/unparseable/absurd end date — COUNT it, never silently drop.
|
|
1461
|
+
missingEndDate++;
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (d > windowEndDays) continue; // far future (incl. data errors) → skip
|
|
1465
|
+
if (d < windowStartDays) {
|
|
1466
|
+
// DESC ⇒ everything after this row is earlier ⇒ safe to stop.
|
|
1467
|
+
pastWindow = true;
|
|
1468
|
+
break;
|
|
1469
|
+
}
|
|
1470
|
+
const amount = row["Award Amount"] ?? 0;
|
|
1471
|
+
if (amount < minAwardValue) continue;
|
|
1472
|
+
const potentialEnd = includePotentialEnd
|
|
1473
|
+
? row["Period of Performance Potential End Date"] ?? null
|
|
1474
|
+
: undefined;
|
|
1475
|
+
let extendableDays: number | null | undefined;
|
|
1476
|
+
if (includePotentialEnd) {
|
|
1477
|
+
const pd = daysUntil(potentialEnd ?? null, nowMs);
|
|
1478
|
+
extendableDays = pd === null ? null : pd - d;
|
|
1479
|
+
}
|
|
1480
|
+
results.push({
|
|
1481
|
+
awardId: row["Award ID"] ?? "",
|
|
1482
|
+
generatedInternalId: row.generated_internal_id ?? "",
|
|
1483
|
+
incumbent: row["Recipient Name"] ?? "",
|
|
1484
|
+
amount,
|
|
1485
|
+
currentEndDate: end as string,
|
|
1486
|
+
daysUntilCurrentEnd: d,
|
|
1487
|
+
...(includePotentialEnd
|
|
1488
|
+
? { potentialEndDate: potentialEnd ?? null, extendableDays }
|
|
1489
|
+
: {}),
|
|
1490
|
+
awardingAgency: row["Awarding Agency"] ?? "",
|
|
1491
|
+
awardingSubAgency: row["Awarding Sub Agency"] ?? null,
|
|
1492
|
+
naicsCode: row.NAICS?.code ?? null,
|
|
1493
|
+
pscCode: row.PSC?.code ?? null,
|
|
1494
|
+
contractAwardType: row["Contract Award Type"] ?? null,
|
|
1495
|
+
// Set-aside VALUE is not a search output field (filter-only) → null
|
|
1496
|
+
// here; the caller reads it per-award via usas_get_award_detail.
|
|
1497
|
+
setAsideDescription: null,
|
|
1498
|
+
startDate: row["Start Date"] ?? null,
|
|
1499
|
+
description: null,
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
if (pastWindow) break;
|
|
1503
|
+
if (!resp.page_metadata?.hasNext) break;
|
|
1504
|
+
if (p === scanBudgetPages && !pastWindow) scanTruncated = true;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// Deterministic order: current end date ascending (soonest recompete first),
|
|
1508
|
+
// tiebreak by descending amount then awardId so paging is stable.
|
|
1509
|
+
results.sort((a, b) => {
|
|
1510
|
+
if (a.daysUntilCurrentEnd !== b.daysUntilCurrentEnd)
|
|
1511
|
+
return a.daysUntilCurrentEnd - b.daysUntilCurrentEnd;
|
|
1512
|
+
if (b.amount !== a.amount) return b.amount - a.amount;
|
|
1513
|
+
return a.awardId.localeCompare(b.awardId);
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
const totalInWindow = results.length; // EXACT iff not scanTruncated
|
|
1517
|
+
const startIdx = (page - 1) * pageSize;
|
|
1518
|
+
const pageSlice = results.slice(startIdx, startIdx + pageSize);
|
|
1519
|
+
|
|
1520
|
+
// --- Truthful _meta ---------------------------------------------------
|
|
1521
|
+
// totalAvailable is a REAL count only when we scanned the whole window
|
|
1522
|
+
// (early-stop fired). If the scan budget truncated, it is unknown → null,
|
|
1523
|
+
// and the returned set is a lower bound.
|
|
1524
|
+
const totalAvailable = scanTruncated ? null : totalInWindow;
|
|
1525
|
+
const nextOffset = startIdx + pageSize;
|
|
1526
|
+
const hasMore = scanTruncated
|
|
1527
|
+
? true // more may exist beyond the scanned pages
|
|
1528
|
+
: nextOffset < totalInWindow;
|
|
1529
|
+
const truncated = hasMore || scanTruncated;
|
|
1530
|
+
|
|
1531
|
+
const notes: string[] = [
|
|
1532
|
+
`Completeness boundary: only contracts with a recorded action in the last ${actionDateLookbackYears} year(s) are included (an action_date lower bound is required to make the End-Date sort reach the window; contracts with no action in that span are not returned).`,
|
|
1533
|
+
"Recompete window is applied client-side on the current period-of-performance END date; the API cannot filter by PoP end date server-side, so results are sorted by End Date (desc) and windowed here.",
|
|
1534
|
+
"HONEST CEILING: this tool emits PUBLIC signals only. Past-performance/CPARS ratings, protest history, and the incumbent's option-exercise intent are NOT public — it never emits a composite 'recompete vulnerability' score. Judge each row with off-platform intelligence.",
|
|
1535
|
+
];
|
|
1536
|
+
if (missingEndDate > 0) {
|
|
1537
|
+
notes.push(
|
|
1538
|
+
`${missingEndDate} scanned award(s) had no usable current PoP end date and were counted but excluded from the window (never silently dropped).`,
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
if (scanTruncated) {
|
|
1542
|
+
notes.push(
|
|
1543
|
+
`Scan budget of ${scanBudgetPages} page(s) (${scanned} awards) was exhausted before reaching the end of the window, so totalAvailable is unknown (null) and the returned recompetes are a LOWER BOUND. This agency×NAICS slice has a very large tail of long-duration/far-future contracts — narrow it (add pscCodes, a higher minAwardValue, a set-aside, or a tighter agency/sub-agency) or raise scanBudgetPages to get an exact window count.`,
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
if (filtersDropped.includes("setAside")) {
|
|
1547
|
+
notes.push(
|
|
1548
|
+
`The requested set-aside code is not a recognized USAspending set_aside_type_code and was NOT applied (results are unfiltered on set-aside). Valid codes: ${[...SET_ASIDE_CODES].join(", ")}.`,
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const data = {
|
|
1553
|
+
recompetes: pageSlice,
|
|
1554
|
+
page,
|
|
1555
|
+
pageSize,
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
return withMeta(data, {
|
|
1559
|
+
source: RECOMPETE_SOURCE,
|
|
1560
|
+
keylessMode: true,
|
|
1561
|
+
returned: pageSlice.length,
|
|
1562
|
+
totalAvailable,
|
|
1563
|
+
truncated,
|
|
1564
|
+
pagination: {
|
|
1565
|
+
offset: startIdx,
|
|
1566
|
+
limit: pageSize,
|
|
1567
|
+
nextOffset: hasMore ? nextOffset : null,
|
|
1568
|
+
hasMore,
|
|
1569
|
+
},
|
|
1570
|
+
filtersApplied,
|
|
1571
|
+
filtersDropped,
|
|
1572
|
+
fieldsUnavailable: [
|
|
1573
|
+
...RECOMPETE_FIELDS_UNAVAILABLE,
|
|
1574
|
+
"setAsideDescription(search-omits; use usas_get_award_detail)",
|
|
1575
|
+
],
|
|
1576
|
+
notes,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* DEPRECATED alias — kept working so existing callers of
|
|
1582
|
+
* `usas_search_expiring_contracts` don't break. Maps the old params onto
|
|
1583
|
+
* `searchRecompetes` and re-shapes the output to the legacy `{ contracts,
|
|
1584
|
+
* searchedCount }` keys the smoke/edge tests assert on. Prefer
|
|
1585
|
+
* `usas_search_recompetes`.
|
|
1586
|
+
*/
|
|
1587
|
+
export async function searchExpiringContracts(args: {
|
|
1588
|
+
agency?: string;
|
|
1589
|
+
naics?: string;
|
|
1590
|
+
// M2 (W3-1 honesty): `fiscalYear` is deliberately ABSENT. The recompete radar
|
|
1591
|
+
// windows on the current PoP end date around TODAY, not an obligation FY, so a
|
|
1592
|
+
// fiscalYear was inapplicable — it was validated by the input schema then
|
|
1593
|
+
// silently discarded (never forwarded to searchRecompetes), with empty
|
|
1594
|
+
// filtersDropped, i.e. zero signal to the caller. Removed from BOTH this arg
|
|
1595
|
+
// type and UsasExpiringInput (server.ts) so it can no longer be
|
|
1596
|
+
// validated-then-dropped. Use usas_search_recompetes' window controls instead.
|
|
1597
|
+
monthsUntilExpiry?: number;
|
|
1598
|
+
minAwardValue?: number;
|
|
1599
|
+
limit?: number;
|
|
1600
|
+
}) {
|
|
1601
|
+
const windowEndDays = Math.round((args.monthsUntilExpiry ?? 12) * 30.44);
|
|
1602
|
+
const pageSize = args.limit ?? 10;
|
|
1603
|
+
const bundle = await searchRecompetes({
|
|
1604
|
+
agency: args.agency,
|
|
1605
|
+
naics: args.naics,
|
|
1606
|
+
windowStartDays: -30, // legacy tool dropped rows expired > 30d ago
|
|
1607
|
+
windowEndDays,
|
|
1608
|
+
minAwardValue: args.minAwardValue ?? 100_000,
|
|
1609
|
+
pageSize,
|
|
1610
|
+
page: 1,
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
// Re-shape to the legacy contract row + keep the truthful _meta, appending a
|
|
1614
|
+
// deprecation note.
|
|
1615
|
+
type Recompete = (typeof bundle.data.recompetes)[number];
|
|
1616
|
+
const contracts = bundle.data.recompetes.map((r: Recompete) => ({
|
|
1617
|
+
awardId: r.awardId,
|
|
1618
|
+
recipient: r.incumbent,
|
|
1619
|
+
amount: r.amount,
|
|
1620
|
+
endDate: r.currentEndDate,
|
|
1621
|
+
potentialEndDate: r.potentialEndDate ?? null,
|
|
1622
|
+
awardingAgency: r.awardingAgency,
|
|
1623
|
+
awardingSubAgency: r.awardingSubAgency ?? undefined,
|
|
1624
|
+
naicsCode: r.naicsCode ?? undefined,
|
|
1625
|
+
setAsideDescription: r.setAsideDescription ?? undefined,
|
|
1626
|
+
description: r.description ?? undefined,
|
|
1627
|
+
daysUntilExpiry: r.daysUntilCurrentEnd,
|
|
1628
|
+
generatedInternalId: r.generatedInternalId,
|
|
1629
|
+
}));
|
|
1630
|
+
|
|
1631
|
+
const data = {
|
|
1632
|
+
contracts,
|
|
1633
|
+
// Legacy field: previously the count of value-filtered candidates. Now the
|
|
1634
|
+
// number of in-window recompetes returned on this page (honest, non-zero
|
|
1635
|
+
// where data exists).
|
|
1636
|
+
searchedCount: contracts.length,
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
const meta: Partial<ResponseMeta> = {
|
|
1640
|
+
...bundle.meta,
|
|
1641
|
+
notes: [
|
|
1642
|
+
"deprecated: use usas_search_recompetes — this alias re-shapes the corrected recompete-radar output onto the legacy { contracts, searchedCount } keys.",
|
|
1643
|
+
...(bundle.meta.notes ?? []),
|
|
1644
|
+
],
|
|
1645
|
+
};
|
|
1646
|
+
return withMeta(data, meta);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ─── Aggregate analysis: time series ──────────────────────────────
|
|
1650
|
+
|
|
1651
|
+
export async function spendingOverTime(args: {
|
|
1652
|
+
group?: "fiscal_year" | "quarter" | "month";
|
|
1653
|
+
agency?: string;
|
|
1654
|
+
naics?: string;
|
|
1655
|
+
setAside?: string;
|
|
1656
|
+
}) {
|
|
1657
|
+
const filters = buildFilters(args);
|
|
1658
|
+
type Resp = {
|
|
1659
|
+
group?: string;
|
|
1660
|
+
results?: {
|
|
1661
|
+
time_period?: { fiscal_year?: string; quarter?: string; month?: string };
|
|
1662
|
+
aggregated_amount?: number;
|
|
1663
|
+
Contract_Obligations?: number;
|
|
1664
|
+
Grant_Obligations?: number;
|
|
1665
|
+
Idv_Obligations?: number;
|
|
1666
|
+
}[];
|
|
1667
|
+
};
|
|
1668
|
+
const group = args.group ?? "fiscal_year";
|
|
1669
|
+
const json = await postUsas<Resp>("search/spending_over_time/", {
|
|
1670
|
+
group,
|
|
1671
|
+
filters,
|
|
1672
|
+
});
|
|
1673
|
+
const results = json.results ?? [];
|
|
1674
|
+
|
|
1675
|
+
const timeline = results.map((r) => ({
|
|
1676
|
+
timePeriod: r.time_period ?? {},
|
|
1677
|
+
// `total` and `contractObligations` are IDENTICAL: buildFilters restricts to
|
|
1678
|
+
// contract award types A/B/C/D, so aggregated_amount IS the contract
|
|
1679
|
+
// obligation (LIVE-VERIFIED: aggregated_amount === Contract_Obligations for
|
|
1680
|
+
// every bucket). The `?? r.aggregated_amount` fallback keeps that identity true
|
|
1681
|
+
// ONLY while buildFilters enforces contracts-only — if this tool ever sends a
|
|
1682
|
+
// wider award-type filter, revisit (aggregated_amount would be a MIXED total,
|
|
1683
|
+
// not the contract figure). `total`'s `?? 0` is a genuine zero (endpoint silent
|
|
1684
|
+
// on a bucket ⇒ no contract spend that period), NOT a masked-out category.
|
|
1685
|
+
total: r.aggregated_amount ?? 0,
|
|
1686
|
+
contractObligations: r.Contract_Obligations ?? r.aggregated_amount ?? 0,
|
|
1687
|
+
// Grant/IDV obligations are EXCLUDED by that contract-only filter — they are
|
|
1688
|
+
// NOT zero. The endpoint returns 0 for them here purely because A/B/C/D omits
|
|
1689
|
+
// grants (award types 02–05) and IDVs (IDV_*); an agency's real grant/IDV
|
|
1690
|
+
// spend can be billions per period (LIVE: DoD grants ~$4.8B in FY2008). Emit
|
|
1691
|
+
// null, never a fabricated 0 that reads as "no grant/IDV spending" (DA-1 class).
|
|
1692
|
+
grantObligations: null as number | null,
|
|
1693
|
+
idvObligations: null as number | null,
|
|
1694
|
+
}));
|
|
1695
|
+
|
|
1696
|
+
// spending_over_time returns the FULL timeline for the filter (no cursor/total),
|
|
1697
|
+
// so what we return IS the complete set. Read the span for disclosure. Format
|
|
1698
|
+
// each period label unambiguously per grouping (FY2024 / FY2024-Q1 / FY2024-M10)
|
|
1699
|
+
// so the span note can't be misread ("2024 1" as month-vs-quarter).
|
|
1700
|
+
const fmtPeriod = (
|
|
1701
|
+
p: { fiscal_year?: string; quarter?: string; month?: string } | undefined,
|
|
1702
|
+
): string | null => {
|
|
1703
|
+
if (!p) return null;
|
|
1704
|
+
const fy = p.fiscal_year ? `FY${p.fiscal_year}` : null;
|
|
1705
|
+
if (p.quarter) return fy ? `${fy}-Q${p.quarter}` : `Q${p.quarter}`;
|
|
1706
|
+
if (p.month) return fy ? `${fy}-M${p.month}` : `M${p.month}`;
|
|
1707
|
+
return fy;
|
|
1708
|
+
};
|
|
1709
|
+
const spanStart = fmtPeriod(timeline[0]?.timePeriod);
|
|
1710
|
+
const spanEnd = fmtPeriod(timeline[timeline.length - 1]?.timePeriod);
|
|
1711
|
+
|
|
1712
|
+
const filtersApplied = ["awardType(contracts A/B/C/D)"];
|
|
1713
|
+
if (args.agency) filtersApplied.push("agency");
|
|
1714
|
+
if (args.naics) filtersApplied.push("naics");
|
|
1715
|
+
if (args.setAside) filtersApplied.push("setAside");
|
|
1716
|
+
|
|
1717
|
+
const notes: string[] = [
|
|
1718
|
+
"This timeline counts CONTRACT obligations only (award types A/B/C/D), matching the other usas_search_*_spending tools — so `total` equals `contractObligations`.",
|
|
1719
|
+
"Grant, IDV, loan, direct, and other obligation types are EXCLUDED by that filter. grantObligations/idvObligations are null (NOT 0) so an agency's real grant/IDV spending — which can run billions per period — is never misread as zero here.",
|
|
1720
|
+
spanStart && spanEnd
|
|
1721
|
+
? `The timeline spans the full range the endpoint returned for this filter (${spanStart} … ${spanEnd}); a period showing 0 is a genuine zero for CONTRACT obligations in that period.`
|
|
1722
|
+
: "A period showing 0 is a genuine zero for CONTRACT obligations in that period.",
|
|
1723
|
+
];
|
|
1724
|
+
// Completeness caveat: no-cap is LIVE-VERIFIED only for fiscal_year (19 buckets,
|
|
1725
|
+
// FY2008–2026). spending_over_time carries NO pagination envelope, so a very long
|
|
1726
|
+
// month/quarter series could hit a silent server cap we could not detect — disclose
|
|
1727
|
+
// that rather than assert a completeness we can't prove for those granularities.
|
|
1728
|
+
if (group !== "fiscal_year") {
|
|
1729
|
+
notes.push(
|
|
1730
|
+
`Completeness for group='${group}': this endpoint returns no pagination envelope, and no-truncation is verified only for fiscal_year granularity — a very long ${group} series could in principle be capped server-side without a signal. Confirm the span (${spanStart ?? "?"} … ${spanEnd ?? "?"}) covers your expected range.`,
|
|
1731
|
+
);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
return withMeta(
|
|
1735
|
+
{ group: json.group ?? group, timeline },
|
|
1736
|
+
{
|
|
1737
|
+
source: SPENDING_OVER_TIME_SOURCE,
|
|
1738
|
+
keylessMode: true,
|
|
1739
|
+
returned: timeline.length,
|
|
1740
|
+
totalAvailable: timeline.length,
|
|
1741
|
+
truncated: false,
|
|
1742
|
+
filtersApplied,
|
|
1743
|
+
filtersDropped: [],
|
|
1744
|
+
fieldsUnavailable: [
|
|
1745
|
+
"grantObligations (excluded by the contract-only A/B/C/D filter — not zero)",
|
|
1746
|
+
"idvObligations (excluded by the contract-only A/B/C/D filter — not zero)",
|
|
1747
|
+
],
|
|
1748
|
+
notes,
|
|
1749
|
+
},
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// ─── Aggregate analysis: PSC spending ─────────────────────────────
|
|
1754
|
+
|
|
1755
|
+
export async function searchPscSpending(args: {
|
|
1756
|
+
agency?: string;
|
|
1757
|
+
naics?: string;
|
|
1758
|
+
fiscalYear?: number;
|
|
1759
|
+
limit?: number;
|
|
1760
|
+
}) {
|
|
1761
|
+
const filters = buildFilters(args);
|
|
1762
|
+
type Resp = {
|
|
1763
|
+
results?: { code?: string; name?: string; amount?: number }[];
|
|
1764
|
+
page_metadata?: CategoryPageMeta;
|
|
1765
|
+
};
|
|
1766
|
+
const limit = args.limit ?? 10;
|
|
1767
|
+
const json = await postUsas<Resp>(
|
|
1768
|
+
"search/spending_by_category/psc",
|
|
1769
|
+
{ filters, limit, page: 1 },
|
|
1770
|
+
);
|
|
1771
|
+
const results = json.results ?? [];
|
|
1772
|
+
const data = {
|
|
1773
|
+
psc: results.map((r) => ({
|
|
1774
|
+
pscCode: r.code ?? "",
|
|
1775
|
+
pscName: r.name ?? "",
|
|
1776
|
+
amount: r.amount ?? 0,
|
|
1777
|
+
})),
|
|
1778
|
+
};
|
|
1779
|
+
return withMeta(
|
|
1780
|
+
data,
|
|
1781
|
+
categoryAggregateMeta({
|
|
1782
|
+
source: "usaspending.gov/api/v2 search/spending_by_category/psc",
|
|
1783
|
+
returned: results.length,
|
|
1784
|
+
limit,
|
|
1785
|
+
hasNext: json.page_metadata?.hasNext,
|
|
1786
|
+
filters,
|
|
1787
|
+
}),
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// ─── Aggregate analysis: state / territory ─────────────────────────
|
|
1792
|
+
|
|
1793
|
+
export async function searchStateSpending(args: {
|
|
1794
|
+
agency?: string;
|
|
1795
|
+
naics?: string;
|
|
1796
|
+
fiscalYear?: number;
|
|
1797
|
+
limit?: number;
|
|
1798
|
+
}) {
|
|
1799
|
+
const filters = buildFilters(args);
|
|
1800
|
+
type Resp = {
|
|
1801
|
+
results?: { code?: string; name?: string; amount?: number }[];
|
|
1802
|
+
page_metadata?: CategoryPageMeta;
|
|
1803
|
+
};
|
|
1804
|
+
const limit = args.limit ?? 10;
|
|
1805
|
+
const json = await postUsas<Resp>(
|
|
1806
|
+
"search/spending_by_category/state_territory",
|
|
1807
|
+
{ filters, limit, page: 1 },
|
|
1808
|
+
);
|
|
1809
|
+
const results = json.results ?? [];
|
|
1810
|
+
const data = {
|
|
1811
|
+
states: results.map((r) => ({
|
|
1812
|
+
stateCode: r.code ?? "",
|
|
1813
|
+
stateName: r.name ?? "",
|
|
1814
|
+
amount: r.amount ?? 0,
|
|
1815
|
+
})),
|
|
1816
|
+
};
|
|
1817
|
+
return withMeta(
|
|
1818
|
+
data,
|
|
1819
|
+
categoryAggregateMeta({
|
|
1820
|
+
source:
|
|
1821
|
+
"usaspending.gov/api/v2 search/spending_by_category/state_territory",
|
|
1822
|
+
returned: results.length,
|
|
1823
|
+
limit,
|
|
1824
|
+
hasNext: json.page_metadata?.hasNext,
|
|
1825
|
+
filters,
|
|
1826
|
+
extraNotes: [
|
|
1827
|
+
"There are ~59 U.S. states/territories total; a capped result is a top-N by amount, not all places that received funding.",
|
|
1828
|
+
],
|
|
1829
|
+
}),
|
|
1830
|
+
);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// ─── Aggregate analysis: CFDA (grants) ─────────────────────────────
|
|
1834
|
+
|
|
1835
|
+
export async function searchCfdaSpending(args: {
|
|
1836
|
+
agency?: string;
|
|
1837
|
+
fiscalYear?: number;
|
|
1838
|
+
limit?: number;
|
|
1839
|
+
}) {
|
|
1840
|
+
// CFDA is grants — different award_type_codes
|
|
1841
|
+
const filters: UsasFilters = {
|
|
1842
|
+
award_type_codes: ["02", "03", "04", "05"], // grants
|
|
1843
|
+
};
|
|
1844
|
+
if (args.agency) {
|
|
1845
|
+
filters.agencies = [
|
|
1846
|
+
{ type: "awarding", tier: "toptier", name: args.agency },
|
|
1847
|
+
];
|
|
1848
|
+
}
|
|
1849
|
+
if (args.fiscalYear) {
|
|
1850
|
+
filters.time_period = [
|
|
1851
|
+
{
|
|
1852
|
+
start_date: `${args.fiscalYear - 1}-10-01`,
|
|
1853
|
+
end_date: `${args.fiscalYear}-09-30`,
|
|
1854
|
+
},
|
|
1855
|
+
];
|
|
1856
|
+
}
|
|
1857
|
+
type Resp = {
|
|
1858
|
+
results?: { code?: string; name?: string; amount?: number }[];
|
|
1859
|
+
page_metadata?: CategoryPageMeta;
|
|
1860
|
+
};
|
|
1861
|
+
const limit = args.limit ?? 10;
|
|
1862
|
+
const json = await postUsas<Resp>(
|
|
1863
|
+
"search/spending_by_category/cfda",
|
|
1864
|
+
{ filters, limit, page: 1 },
|
|
1865
|
+
);
|
|
1866
|
+
const results = json.results ?? [];
|
|
1867
|
+
const data = {
|
|
1868
|
+
programs: results.map((r) => ({
|
|
1869
|
+
cfdaCode: r.code ?? "",
|
|
1870
|
+
programName: r.name ?? "",
|
|
1871
|
+
amount: r.amount ?? 0,
|
|
1872
|
+
})),
|
|
1873
|
+
};
|
|
1874
|
+
return withMeta(
|
|
1875
|
+
data,
|
|
1876
|
+
categoryAggregateMeta({
|
|
1877
|
+
source: "usaspending.gov/api/v2 search/spending_by_category/cfda",
|
|
1878
|
+
returned: results.length,
|
|
1879
|
+
limit,
|
|
1880
|
+
hasNext: json.page_metadata?.hasNext,
|
|
1881
|
+
filters,
|
|
1882
|
+
extraNotes: [
|
|
1883
|
+
"This is a grants view (award types 02/03/04/05); contracts are excluded.",
|
|
1884
|
+
],
|
|
1885
|
+
}),
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// ─── Aggregate analysis: federal account (TAS) ─────────────────────
|
|
1890
|
+
|
|
1891
|
+
export async function searchFederalAccountSpending(args: {
|
|
1892
|
+
agency?: string;
|
|
1893
|
+
naics?: string;
|
|
1894
|
+
fiscalYear?: number;
|
|
1895
|
+
limit?: number;
|
|
1896
|
+
}) {
|
|
1897
|
+
const filters = buildFilters(args);
|
|
1898
|
+
type Resp = {
|
|
1899
|
+
results?: { code?: string; name?: string; amount?: number }[];
|
|
1900
|
+
page_metadata?: CategoryPageMeta;
|
|
1901
|
+
};
|
|
1902
|
+
const limit = args.limit ?? 10;
|
|
1903
|
+
const json = await postUsas<Resp>(
|
|
1904
|
+
"search/spending_by_category/federal_account",
|
|
1905
|
+
{ filters, limit, page: 1 },
|
|
1906
|
+
);
|
|
1907
|
+
const results = json.results ?? [];
|
|
1908
|
+
const data = {
|
|
1909
|
+
accounts: results.map((r) => ({
|
|
1910
|
+
tasCode: r.code ?? "",
|
|
1911
|
+
accountName: r.name ?? "",
|
|
1912
|
+
amount: r.amount ?? 0,
|
|
1913
|
+
})),
|
|
1914
|
+
};
|
|
1915
|
+
return withMeta(
|
|
1916
|
+
data,
|
|
1917
|
+
categoryAggregateMeta({
|
|
1918
|
+
source:
|
|
1919
|
+
"usaspending.gov/api/v2 search/spending_by_category/federal_account",
|
|
1920
|
+
returned: results.length,
|
|
1921
|
+
limit,
|
|
1922
|
+
hasNext: json.page_metadata?.hasNext,
|
|
1923
|
+
filters,
|
|
1924
|
+
}),
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// ─── Aggregate analysis: awarding agency ──────────────────────────
|
|
1929
|
+
|
|
1930
|
+
export async function searchAgencySpending(args: {
|
|
1931
|
+
naics?: string;
|
|
1932
|
+
fiscalYear?: number;
|
|
1933
|
+
setAside?: string;
|
|
1934
|
+
limit?: number;
|
|
1935
|
+
}) {
|
|
1936
|
+
const filters = buildFilters(args);
|
|
1937
|
+
type Resp = {
|
|
1938
|
+
results?: {
|
|
1939
|
+
name?: string;
|
|
1940
|
+
code?: string;
|
|
1941
|
+
amount?: number;
|
|
1942
|
+
agency_slug?: string;
|
|
1943
|
+
}[];
|
|
1944
|
+
page_metadata?: CategoryPageMeta;
|
|
1945
|
+
};
|
|
1946
|
+
const limit = args.limit ?? 10;
|
|
1947
|
+
const json = await postUsas<Resp>(
|
|
1948
|
+
"search/spending_by_category/awarding_agency",
|
|
1949
|
+
{ filters, limit, page: 1 },
|
|
1950
|
+
);
|
|
1951
|
+
const results = json.results ?? [];
|
|
1952
|
+
const data = {
|
|
1953
|
+
agencies: results.map((r) => ({
|
|
1954
|
+
name: r.name ?? "",
|
|
1955
|
+
code: r.code ?? "",
|
|
1956
|
+
slug: r.agency_slug ?? "",
|
|
1957
|
+
amount: r.amount ?? 0,
|
|
1958
|
+
})),
|
|
1959
|
+
};
|
|
1960
|
+
return withMeta(
|
|
1961
|
+
data,
|
|
1962
|
+
categoryAggregateMeta({
|
|
1963
|
+
source:
|
|
1964
|
+
"usaspending.gov/api/v2 search/spending_by_category/awarding_agency",
|
|
1965
|
+
returned: results.length,
|
|
1966
|
+
limit,
|
|
1967
|
+
hasNext: json.page_metadata?.hasNext,
|
|
1968
|
+
filters,
|
|
1969
|
+
}),
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
// ─── Sub-agency breakdown ─────────────────────────────────────────
|
|
1974
|
+
|
|
1975
|
+
export async function searchSubAgencySpending(args: {
|
|
1976
|
+
agency: string;
|
|
1977
|
+
fiscalYear?: number;
|
|
1978
|
+
}) {
|
|
1979
|
+
const filters = buildFilters(args);
|
|
1980
|
+
type Resp = {
|
|
1981
|
+
results?: { name?: string; amount?: number; count?: number }[];
|
|
1982
|
+
page_metadata?: CategoryPageMeta;
|
|
1983
|
+
};
|
|
1984
|
+
const limit = 10;
|
|
1985
|
+
const json = await postUsas<Resp>(
|
|
1986
|
+
"search/spending_by_category/awarding_subagency",
|
|
1987
|
+
{ filters, limit, page: 1 },
|
|
1988
|
+
);
|
|
1989
|
+
const results = json.results ?? [];
|
|
1990
|
+
// The awarding_subagency endpoint returns `amount` but NOT a per-subagency
|
|
1991
|
+
// award `count` (verified 2026-07-03). Emitting `awards: 0` would be a
|
|
1992
|
+
// FABRICATED count (0 reads as "zero contracts", not "unknown") — the exact B1
|
|
1993
|
+
// class. So `awards` is `null` (honest "unavailable"), consistent with
|
|
1994
|
+
// searchAwards' B1 fix, AND flagged in `_meta.fieldsUnavailable` + a note. An
|
|
1995
|
+
// AI that ignores `_meta` still sees null, never a fake 0.
|
|
1996
|
+
const data = {
|
|
1997
|
+
subAgencies: results.map((r) => ({
|
|
1998
|
+
name: r.name ?? "",
|
|
1999
|
+
amount: r.amount ?? 0,
|
|
2000
|
+
awards: null as number | null,
|
|
2001
|
+
})),
|
|
2002
|
+
};
|
|
2003
|
+
return withMeta(
|
|
2004
|
+
data,
|
|
2005
|
+
categoryAggregateMeta({
|
|
2006
|
+
source:
|
|
2007
|
+
"usaspending.gov/api/v2 search/spending_by_category/awarding_subagency",
|
|
2008
|
+
returned: results.length,
|
|
2009
|
+
limit,
|
|
2010
|
+
hasNext: json.page_metadata?.hasNext,
|
|
2011
|
+
filters,
|
|
2012
|
+
fieldsUnavailable: ["awards"],
|
|
2013
|
+
extraNotes: [
|
|
2014
|
+
"Per-subagency award COUNTS are not returned by this endpoint — the `awards` field is null for every row (unavailable, NOT a real count and NOT 0). Use amount for ranking; do not report `awards` as a contract count.",
|
|
2015
|
+
],
|
|
2016
|
+
}),
|
|
2017
|
+
);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// ─── Agency profile ───────────────────────────────────────────────
|
|
2021
|
+
|
|
2022
|
+
export async function getAgencyProfile(toptierCode: string) {
|
|
2023
|
+
type Resp = {
|
|
2024
|
+
fiscal_year?: number;
|
|
2025
|
+
toptier_code?: string;
|
|
2026
|
+
name?: string;
|
|
2027
|
+
abbreviation?: string;
|
|
2028
|
+
mission?: string;
|
|
2029
|
+
website?: string;
|
|
2030
|
+
subtier_agency_count?: number;
|
|
2031
|
+
congressional_justification_url?: string;
|
|
2032
|
+
};
|
|
2033
|
+
const json = await getUsas<Resp>(`agency/${toptierCode}/`);
|
|
2034
|
+
// minor m1 (W3-1 honesty) hollow-200 guard — mirror getRecipientProfile: a real
|
|
2035
|
+
// agency/{code} 200 always echoes a toptier_code and/or name. A 200 with NEITHER
|
|
2036
|
+
// is a degraded/hollow response (CDN/WAF interstitial, upstream hiccup) — do NOT
|
|
2037
|
+
// map it into a fabricated { name:undefined } "complete" profile; throw schema_drift.
|
|
2038
|
+
if (!json.toptier_code && !json.name) {
|
|
2039
|
+
throw new ToolErrorCarrier({
|
|
2040
|
+
kind: "schema_drift",
|
|
2041
|
+
message: `usaspending agency/${toptierCode} returned a 200 with no toptier_code or name — a hollow/degraded response, not a real agency profile. Retry, or resolve the code via usas_lookup_agency.`,
|
|
2042
|
+
retryable: true,
|
|
2043
|
+
upstreamEndpoint: `agency/${toptierCode}`,
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
return {
|
|
2047
|
+
fiscalYear: json.fiscal_year,
|
|
2048
|
+
toptierCode: json.toptier_code,
|
|
2049
|
+
name: json.name,
|
|
2050
|
+
abbreviation: json.abbreviation,
|
|
2051
|
+
mission: json.mission,
|
|
2052
|
+
website: json.website,
|
|
2053
|
+
subtierAgencyCount: json.subtier_agency_count,
|
|
2054
|
+
congressionalJustificationUrl: json.congressional_justification_url,
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
export async function getAgencyAwardsSummary(args: {
|
|
2059
|
+
toptierCode: string;
|
|
2060
|
+
fiscalYear?: number;
|
|
2061
|
+
}) {
|
|
2062
|
+
const fy = args.fiscalYear ?? new Date().getUTCFullYear();
|
|
2063
|
+
type Resp = {
|
|
2064
|
+
fiscal_year?: number;
|
|
2065
|
+
toptier_code?: string;
|
|
2066
|
+
transaction_count?: number;
|
|
2067
|
+
obligations?: number;
|
|
2068
|
+
latest_action_date?: string;
|
|
2069
|
+
};
|
|
2070
|
+
const json = await getUsas<Resp>(
|
|
2071
|
+
`agency/${args.toptierCode}/awards/?fiscal_year=${fy}`,
|
|
2072
|
+
);
|
|
2073
|
+
// M3 (W3-1 honesty) hollow-200 guard — mirror getRecipientProfile: a real
|
|
2074
|
+
// agency/{code}/awards 200 always carries transaction_count and/or obligations
|
|
2075
|
+
// as numbers. A valid-JSON but degraded/renamed 200 with NEITHER as a number is
|
|
2076
|
+
// a hollow response — do NOT map it into a confident false "$0 obligations, 0
|
|
2077
|
+
// transactions". Surface it as retryable schema_drift (a typed carrier).
|
|
2078
|
+
if (
|
|
2079
|
+
typeof json.transaction_count !== "number" &&
|
|
2080
|
+
typeof json.obligations !== "number"
|
|
2081
|
+
) {
|
|
2082
|
+
throw new ToolErrorCarrier({
|
|
2083
|
+
kind: "schema_drift",
|
|
2084
|
+
message: `usaspending agency/${args.toptierCode}/awards returned a 200 with neither transaction_count nor obligations for FY${fy} — a hollow/degraded response, not a real summary. Do NOT read it as $0; retry, or verify the toptier code via usas_lookup_agency.`,
|
|
2085
|
+
retryable: true,
|
|
2086
|
+
upstreamEndpoint: `agency/${args.toptierCode}/awards`,
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
const data = {
|
|
2090
|
+
fiscalYear: json.fiscal_year,
|
|
2091
|
+
toptierCode: json.toptier_code,
|
|
2092
|
+
// null-never-0: an individually-absent field is null (unknown), NOT a
|
|
2093
|
+
// fabricated 0. A genuine numeric 0 survives (`??` fires only on nullish).
|
|
2094
|
+
transactionCount: json.transaction_count ?? null,
|
|
2095
|
+
obligations: json.obligations ?? null,
|
|
2096
|
+
latestActionDate: json.latest_action_date,
|
|
2097
|
+
};
|
|
2098
|
+
// VQ-2 (C80 dogfooding): `obligations`/`transactionCount` from agency/{code}/awards
|
|
2099
|
+
// span ALL award types, NOT contracts only. Disclose scope so an agent doesn't
|
|
2100
|
+
// misread it as the procurement market (live: VA FY2024 = $238B all-awards vs
|
|
2101
|
+
// $66.9B contracts — a benefit-heavy agency's figure is dominated by direct
|
|
2102
|
+
// benefit payments, not procurement).
|
|
2103
|
+
return withMeta(data, {
|
|
2104
|
+
source: "usaspending.gov/api/v2 agency/{code}/awards",
|
|
2105
|
+
keylessMode: true,
|
|
2106
|
+
returned: 1,
|
|
2107
|
+
// FILT-1 consistency (adversarial review): this tool filters by URL params, not
|
|
2108
|
+
// buildFilters, so filtersAppliedFromFilters can't reach it — declare them here.
|
|
2109
|
+
filtersApplied: ["toptierCode", "fiscalYear"],
|
|
2110
|
+
notes: [
|
|
2111
|
+
"SCOPE: `obligations` and `transactionCount` cover ALL award types (contracts, grants, direct payments incl. benefits, loans) for this agency — NOT prime contracts only. For a benefit-heavy agency (VA/SSA/HHS) direct benefit payments DOMINATE this figure (VA FY2024: ~$238B all-awards vs ~$67B prime contract awards A/B/C/D); for a procurement-heavy agency (DoD/DHS) obligations closely tracks contract spending. For the CONTRACTS-only obligation use usas_spending_over_time (contractObligations) or usas_search_*_spending — those filter by the agency's canonical NAME, so first resolve it from this toptierCode via usas_get_agency_profile (→ name).",
|
|
2112
|
+
],
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
export async function getAgencyBudgetFunction(args: {
|
|
2117
|
+
toptierCode: string;
|
|
2118
|
+
fiscalYear?: number;
|
|
2119
|
+
limit?: number;
|
|
2120
|
+
}) {
|
|
2121
|
+
const fy = args.fiscalYear ?? new Date().getUTCFullYear();
|
|
2122
|
+
const limit = args.limit ?? 10;
|
|
2123
|
+
type Resp = {
|
|
2124
|
+
toptier_code?: string;
|
|
2125
|
+
fiscal_year?: number;
|
|
2126
|
+
results?: {
|
|
2127
|
+
name?: string;
|
|
2128
|
+
children?: {
|
|
2129
|
+
name?: string;
|
|
2130
|
+
obligated_amount?: number;
|
|
2131
|
+
gross_outlay_amount?: number;
|
|
2132
|
+
}[];
|
|
2133
|
+
}[];
|
|
2134
|
+
// Unlike the spending_by_category/* endpoints, agency/budget_function
|
|
2135
|
+
// DOES report a real grand total in page_metadata.total (verified
|
|
2136
|
+
// 2026-07-03: e.g. DoD → total:6 while a 3-row page has hasNext:true).
|
|
2137
|
+
page_metadata?: { page?: number; total?: number; hasNext?: boolean };
|
|
2138
|
+
};
|
|
2139
|
+
const json = await getUsas<Resp>(
|
|
2140
|
+
`agency/${args.toptierCode}/budget_function/?fiscal_year=${fy}&limit=${limit}`,
|
|
2141
|
+
);
|
|
2142
|
+
const results = json.results ?? [];
|
|
2143
|
+
// minor m1 (W3-1 honesty) hollow-200 guard — a real budget_function 200 echoes
|
|
2144
|
+
// toptier_code + fiscal_year and carries results. A 200 with NONE of the three is
|
|
2145
|
+
// a degraded/hollow response — do NOT map it into a fabricated empty budget
|
|
2146
|
+
// (which reads as "this agency has no budget functions"); throw schema_drift.
|
|
2147
|
+
if (!json.toptier_code && !json.fiscal_year && results.length === 0) {
|
|
2148
|
+
throw new ToolErrorCarrier({
|
|
2149
|
+
kind: "schema_drift",
|
|
2150
|
+
message: `usaspending agency/${args.toptierCode}/budget_function returned a 200 with no toptier_code, fiscal_year, or results for FY${fy} — a hollow/degraded response, not a real budget. Retry.`,
|
|
2151
|
+
retryable: true,
|
|
2152
|
+
upstreamEndpoint: `agency/${args.toptierCode}/budget_function`,
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
const total = json.page_metadata?.total ?? null;
|
|
2156
|
+
const data = {
|
|
2157
|
+
toptierCode: json.toptier_code,
|
|
2158
|
+
fiscalYear: json.fiscal_year,
|
|
2159
|
+
functions: results.map((r) => ({
|
|
2160
|
+
name: r.name ?? "",
|
|
2161
|
+
programs: (r.children ?? []).map((c) => ({
|
|
2162
|
+
name: c.name ?? "",
|
|
2163
|
+
obligated: c.obligated_amount ?? 0,
|
|
2164
|
+
outlays: c.gross_outlay_amount ?? 0,
|
|
2165
|
+
})),
|
|
2166
|
+
})),
|
|
2167
|
+
};
|
|
2168
|
+
const hasMore =
|
|
2169
|
+
total !== null ? results.length < total : (json.page_metadata?.hasNext ?? false);
|
|
2170
|
+
return withMeta(data, {
|
|
2171
|
+
source: "usaspending.gov/api/v2 agency/{code}/budget_function",
|
|
2172
|
+
keylessMode: true,
|
|
2173
|
+
returned: results.length,
|
|
2174
|
+
// Real total from the endpoint (budget-function count for the FY).
|
|
2175
|
+
totalAvailable: total,
|
|
2176
|
+
truncated: hasMore,
|
|
2177
|
+
pagination: {
|
|
2178
|
+
offset: 0,
|
|
2179
|
+
limit,
|
|
2180
|
+
// W3-7 (honesty; mirrors searchRecipients M1 / awardPagination): this tool has
|
|
2181
|
+
// NO offset/page input (page hardcoded to the `limit` slice) — so `nextOffset`
|
|
2182
|
+
// is NOT consumable. Emitting `results.length` made an agent re-fetch the SAME
|
|
2183
|
+
// top-N forever. Emit null; `hasMore` stays honest (more functions DO exist).
|
|
2184
|
+
nextOffset: null,
|
|
2185
|
+
hasMore,
|
|
2186
|
+
},
|
|
2187
|
+
filtersApplied: [],
|
|
2188
|
+
filtersDropped: [],
|
|
2189
|
+
fieldsUnavailable: [],
|
|
2190
|
+
notes: hasMore
|
|
2191
|
+
? [
|
|
2192
|
+
`Showing the top ${limit} budget functions; ${total ?? "more"} exist for FY${fy}. These extra functions are NOT page-reachable — this tool has no offset input (nextOffset is null). Raise limit to see the rest.`,
|
|
2193
|
+
]
|
|
2194
|
+
: [],
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// ─── Recipient list + profile ─────────────────────────────────────
|
|
2199
|
+
|
|
2200
|
+
export async function searchRecipients(args: {
|
|
2201
|
+
keyword: string;
|
|
2202
|
+
recipientLevel?: "P" | "C" | "R";
|
|
2203
|
+
limit?: number;
|
|
2204
|
+
}) {
|
|
2205
|
+
type Resp = {
|
|
2206
|
+
page_metadata?: { total?: number };
|
|
2207
|
+
results?: {
|
|
2208
|
+
id?: string;
|
|
2209
|
+
duns?: string;
|
|
2210
|
+
uei?: string;
|
|
2211
|
+
name?: string;
|
|
2212
|
+
recipient_level?: string;
|
|
2213
|
+
amount?: number;
|
|
2214
|
+
}[];
|
|
2215
|
+
};
|
|
2216
|
+
const limit = args.limit ?? 10;
|
|
2217
|
+
const body: Record<string, unknown> = {
|
|
2218
|
+
keyword: args.keyword,
|
|
2219
|
+
limit,
|
|
2220
|
+
page: 1,
|
|
2221
|
+
};
|
|
2222
|
+
if (args.recipientLevel) {
|
|
2223
|
+
body.recipient_level = args.recipientLevel;
|
|
2224
|
+
}
|
|
2225
|
+
const json = await postUsas<Resp>("recipient/", body);
|
|
2226
|
+
const results = json.results ?? [];
|
|
2227
|
+
// recipient/ DOES report a real grand total in page_metadata.total
|
|
2228
|
+
// (verified 2026-07-03: "booz" → total:512). Keep it in _meta.totalAvailable
|
|
2229
|
+
// and derive truncation from returned < total. null (not 0) when absent so
|
|
2230
|
+
// we never claim a total the endpoint didn't give.
|
|
2231
|
+
const total = json.page_metadata?.total ?? null;
|
|
2232
|
+
const data = {
|
|
2233
|
+
// F3 (P1): mirror the honest null `_meta.totalAvailable` — an OMITTED total
|
|
2234
|
+
// must NOT default to 0 in the data field (a 200-with-rows-but-no-total then
|
|
2235
|
+
// says "0 records" while rows exist and _meta says null: a self-contradiction).
|
|
2236
|
+
totalRecords: total,
|
|
2237
|
+
recipients: results.map((r) => ({
|
|
2238
|
+
id: r.id ?? "",
|
|
2239
|
+
duns: r.duns,
|
|
2240
|
+
uei: r.uei,
|
|
2241
|
+
name: r.name ?? "",
|
|
2242
|
+
level: r.recipient_level,
|
|
2243
|
+
totalAmount: r.amount ?? 0,
|
|
2244
|
+
})),
|
|
2245
|
+
};
|
|
2246
|
+
const hasMore = total !== null ? results.length < total : results.length >= limit;
|
|
2247
|
+
return withMeta(data, {
|
|
2248
|
+
source: "usaspending.gov/api/v2 recipient/",
|
|
2249
|
+
keylessMode: true,
|
|
2250
|
+
returned: results.length,
|
|
2251
|
+
totalAvailable: total,
|
|
2252
|
+
truncated: hasMore,
|
|
2253
|
+
pagination: {
|
|
2254
|
+
offset: 0,
|
|
2255
|
+
limit,
|
|
2256
|
+
// M1 (W3-1 honesty): this tool has NO offset/page input — page is hardcoded
|
|
2257
|
+
// to 1 (see body above) — so `nextOffset` is NOT consumable. Emitting
|
|
2258
|
+
// `results.length` made an agent re-fetch the SAME top-N forever while
|
|
2259
|
+
// ranked-below-`limit` recipients stayed unjoinable. Emit null
|
|
2260
|
+
// unconditionally (mirror edgar.ts:656 / the FTS beyond-window pattern:
|
|
2261
|
+
// hasMore stays true while nextOffset is null). The extra matches are
|
|
2262
|
+
// reachable ONLY by raising `limit` (≤50) or narrowing the keyword.
|
|
2263
|
+
nextOffset: null,
|
|
2264
|
+
hasMore,
|
|
2265
|
+
},
|
|
2266
|
+
filtersApplied: [],
|
|
2267
|
+
filtersDropped: [],
|
|
2268
|
+
fieldsUnavailable: [],
|
|
2269
|
+
notes: hasMore
|
|
2270
|
+
? [
|
|
2271
|
+
`Showing the top ${limit} recipients by amount; ${total ?? "more"} match the keyword. These extra matches are NOT page-reachable — this tool has no offset input (nextOffset is null). Raise limit (up to 50) or narrow the keyword to see more.`,
|
|
2272
|
+
]
|
|
2273
|
+
: [],
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
export async function getRecipientProfile(recipientId: string) {
|
|
2278
|
+
type Resp = {
|
|
2279
|
+
name?: string;
|
|
2280
|
+
alternate_names?: string[];
|
|
2281
|
+
duns?: string;
|
|
2282
|
+
uei?: string;
|
|
2283
|
+
recipient_id?: string;
|
|
2284
|
+
recipient_level?: string;
|
|
2285
|
+
parent_id?: string;
|
|
2286
|
+
parent_name?: string;
|
|
2287
|
+
business_types?: string[];
|
|
2288
|
+
location?: {
|
|
2289
|
+
address_line1?: string;
|
|
2290
|
+
city_name?: string;
|
|
2291
|
+
state_code?: string;
|
|
2292
|
+
country_name?: string;
|
|
2293
|
+
zip5?: string;
|
|
2294
|
+
};
|
|
2295
|
+
total_transaction_amount?: number;
|
|
2296
|
+
total_transactions?: number;
|
|
2297
|
+
};
|
|
2298
|
+
// NOT via getUsas: USAspending signals a NONEXISTENT recipient with HTTP 400 +
|
|
2299
|
+
// `detail: "Recipient ID not found: '...'"` (LIVE-VERIFIED 2026-07-06 — NOT a
|
|
2300
|
+
// 404, and NOT a malformed-input 400). Through fetchWithRetry/errorFromResponse
|
|
2301
|
+
// that 400 becomes `invalid_input` ("Bad request"), telling a caller its
|
|
2302
|
+
// recipient_id was MALFORMED when the recipient simply does not exist. Read the
|
|
2303
|
+
// body to distinguish that not-found from a genuine bad-input 400 and classify
|
|
2304
|
+
// honestly (mirrors getAwardDetail's explicit status handling).
|
|
2305
|
+
let r: Response;
|
|
2306
|
+
try {
|
|
2307
|
+
r = await fetch(`${USAS}/recipient/${encodeURIComponent(recipientId)}/`, {
|
|
2308
|
+
signal: AbortSignal.timeout(15_000),
|
|
2309
|
+
});
|
|
2310
|
+
} catch (e) {
|
|
2311
|
+
// A network-level fault (DNS/reset/timeout) is an OUTAGE, not a missing
|
|
2312
|
+
// recipient — classify as retryable rather than surfacing the generic `unknown`.
|
|
2313
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
2314
|
+
throw new ToolErrorCarrier({
|
|
2315
|
+
kind: "upstream_unavailable",
|
|
2316
|
+
message: `usaspending recipient/{id} fetch failed: ${e instanceof Error ? e.message : String(e)}. This is an outage, not a missing recipient. Retry.`,
|
|
2317
|
+
retryable: true,
|
|
2318
|
+
upstreamEndpoint: `recipient/${recipientId}`,
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
if (!r.ok) {
|
|
2322
|
+
let detail = "";
|
|
2323
|
+
try {
|
|
2324
|
+
detail = ((await r.json()) as { detail?: string }).detail ?? "";
|
|
2325
|
+
} catch {
|
|
2326
|
+
/* non-JSON error body → detail stays "" and we fall through to errorFromResponse */
|
|
2327
|
+
}
|
|
2328
|
+
if (
|
|
2329
|
+
r.status === 404 ||
|
|
2330
|
+
(r.status === 400 && /recipient\s*(id\s+)?not found/i.test(detail))
|
|
2331
|
+
) {
|
|
2332
|
+
throw new ToolErrorCarrier({
|
|
2333
|
+
kind: "not_found",
|
|
2334
|
+
message: `No recipient profile found for recipient_id '${recipientId}' on usaspending.gov${detail ? ` (upstream: ${detail})` : ""}. Resolve a valid recipient_id via usas_search_recipients — each result carries an id. This is a genuine not-found, NOT a malformed input.`,
|
|
2335
|
+
retryable: false,
|
|
2336
|
+
upstreamStatus: r.status,
|
|
2337
|
+
upstreamEndpoint: `recipient/${recipientId}`,
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
// Any other non-2xx (a genuine bad-input 400, 429, 5xx) → the shared matrix.
|
|
2341
|
+
throw new ToolErrorCarrier(errorFromResponse(r, `recipient/${recipientId}`));
|
|
2342
|
+
}
|
|
2343
|
+
const json = (await r.json()) as Resp;
|
|
2344
|
+
// Defensive hollow-200 guard (cf. sam_lookup_organization / grants / opportunity):
|
|
2345
|
+
// a real recipient 200 always carries a name and/or recipient_id. A 200 with
|
|
2346
|
+
// NEITHER is a degraded/hollow response (CDN/WAF interstitial, upstream hiccup) —
|
|
2347
|
+
// do NOT map it into a fabricated { name:"" } profile; surface it as schema_drift.
|
|
2348
|
+
if (!json.recipient_id && !json.name) {
|
|
2349
|
+
throw new ToolErrorCarrier({
|
|
2350
|
+
kind: "schema_drift",
|
|
2351
|
+
message: `usaspending recipient/{id} returned a 200 with no recipient_id or name for '${recipientId}' — a hollow/degraded response, not a real profile. Retry, or verify the id via usas_search_recipients.`,
|
|
2352
|
+
retryable: true,
|
|
2353
|
+
upstreamEndpoint: `recipient/${recipientId}`,
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
return {
|
|
2357
|
+
name: json.name ?? "",
|
|
2358
|
+
alternateNames: json.alternate_names ?? [],
|
|
2359
|
+
duns: json.duns,
|
|
2360
|
+
uei: json.uei,
|
|
2361
|
+
recipientId: json.recipient_id,
|
|
2362
|
+
level: json.recipient_level,
|
|
2363
|
+
parentId: json.parent_id,
|
|
2364
|
+
parentName: json.parent_name,
|
|
2365
|
+
businessTypes: json.business_types ?? [],
|
|
2366
|
+
location: json.location ?? {},
|
|
2367
|
+
totalAmount: json.total_transaction_amount ?? 0,
|
|
2368
|
+
totalTransactions: json.total_transactions ?? 0,
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// ─── Reference / autocomplete ─────────────────────────────────────
|
|
2373
|
+
|
|
2374
|
+
export async function lookupAgency(searchText: string) {
|
|
2375
|
+
// Cache: agency lookups are extremely repeat-prone (`VA`, `DHS`, etc.)
|
|
2376
|
+
// and effectively static across a session.
|
|
2377
|
+
return memoize(`usas:agency:${searchText.toLowerCase()}`, async () => {
|
|
2378
|
+
type Resp = {
|
|
2379
|
+
results?: {
|
|
2380
|
+
toptier_flag?: boolean;
|
|
2381
|
+
toptier_agency?: {
|
|
2382
|
+
name?: string;
|
|
2383
|
+
abbreviation?: string;
|
|
2384
|
+
toptier_code?: string;
|
|
2385
|
+
};
|
|
2386
|
+
}[];
|
|
2387
|
+
};
|
|
2388
|
+
// Via postUsas (fetchWithRetry) so a DOWN service THROWS upstream_unavailable
|
|
2389
|
+
// instead of returning `{ matches: [] }` — which an AI reads as "no such
|
|
2390
|
+
// agency" when the endpoint is merely down. This was the last silent-empty-
|
|
2391
|
+
// on-outage in the codebase; now consistent with autocompleteNaics/Recipient
|
|
2392
|
+
// (a GENUINE no-match still returns an honest empty `matches`).
|
|
2393
|
+
const json = await postUsas<Resp>("autocomplete/funding_agency/", {
|
|
2394
|
+
search_text: searchText,
|
|
2395
|
+
limit: 5,
|
|
2396
|
+
});
|
|
2397
|
+
return {
|
|
2398
|
+
matches: (json.results ?? []).map((r) => ({
|
|
2399
|
+
name: r.toptier_agency?.name ?? "",
|
|
2400
|
+
abbreviation: r.toptier_agency?.abbreviation,
|
|
2401
|
+
toptierCode: r.toptier_agency?.toptier_code,
|
|
2402
|
+
isToptier: !!r.toptier_flag,
|
|
2403
|
+
})),
|
|
2404
|
+
};
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
export async function autocompleteNaics(args: {
|
|
2409
|
+
searchText: string;
|
|
2410
|
+
limit?: number;
|
|
2411
|
+
}) {
|
|
2412
|
+
const limit = args.limit ?? 10;
|
|
2413
|
+
return memoize(
|
|
2414
|
+
`usas:naics:${args.searchText.toLowerCase()}:${limit}`,
|
|
2415
|
+
async () => {
|
|
2416
|
+
type Resp = {
|
|
2417
|
+
results?: {
|
|
2418
|
+
naics?: string;
|
|
2419
|
+
naics_description?: string;
|
|
2420
|
+
year_retired?: string | null;
|
|
2421
|
+
}[];
|
|
2422
|
+
};
|
|
2423
|
+
const json = await postUsas<Resp>("autocomplete/naics/", {
|
|
2424
|
+
search_text: args.searchText,
|
|
2425
|
+
limit,
|
|
2426
|
+
});
|
|
2427
|
+
const results = json.results ?? [];
|
|
2428
|
+
const data = {
|
|
2429
|
+
naics: results.map((r) => ({
|
|
2430
|
+
code: r.naics ?? "",
|
|
2431
|
+
description: r.naics_description ?? "",
|
|
2432
|
+
retired: !!r.year_retired,
|
|
2433
|
+
})),
|
|
2434
|
+
};
|
|
2435
|
+
return withMeta(data, referenceMeta({
|
|
2436
|
+
source: "usaspending.gov/api/v2 autocomplete/naics",
|
|
2437
|
+
returned: results.length,
|
|
2438
|
+
limit,
|
|
2439
|
+
// autocomplete/naics returns only {results} — no total (verified
|
|
2440
|
+
// 2026-07-03). A full page means more matches likely exist.
|
|
2441
|
+
totalAvailable: null,
|
|
2442
|
+
}));
|
|
2443
|
+
},
|
|
2444
|
+
);
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
export async function autocompleteRecipient(args: {
|
|
2448
|
+
searchText: string;
|
|
2449
|
+
limit?: number;
|
|
2450
|
+
}) {
|
|
2451
|
+
const limit = args.limit ?? 10;
|
|
2452
|
+
return memoize(
|
|
2453
|
+
`usas:recipient:${args.searchText.toLowerCase()}:${limit}`,
|
|
2454
|
+
async () => {
|
|
2455
|
+
type Resp = {
|
|
2456
|
+
// NOTE: this endpoint's top-level `count` equals the RETURNED row
|
|
2457
|
+
// count (verified 2026-07-03: 5 asked → count:5), NOT a grand total —
|
|
2458
|
+
// so it is NOT a usable totalAvailable (spec §3.3: never substitute
|
|
2459
|
+
// page size for an unknown total). Left as null.
|
|
2460
|
+
count?: number;
|
|
2461
|
+
results?: {
|
|
2462
|
+
recipient_name?: string;
|
|
2463
|
+
uei?: string;
|
|
2464
|
+
duns?: string;
|
|
2465
|
+
}[];
|
|
2466
|
+
};
|
|
2467
|
+
const json = await postUsas<Resp>("autocomplete/recipient/", {
|
|
2468
|
+
search_text: args.searchText,
|
|
2469
|
+
limit,
|
|
2470
|
+
});
|
|
2471
|
+
const results = json.results ?? [];
|
|
2472
|
+
const data = {
|
|
2473
|
+
recipients: results.map((r) => ({
|
|
2474
|
+
name: r.recipient_name ?? "",
|
|
2475
|
+
uei: r.uei,
|
|
2476
|
+
duns: r.duns,
|
|
2477
|
+
})),
|
|
2478
|
+
};
|
|
2479
|
+
return withMeta(data, referenceMeta({
|
|
2480
|
+
source: "usaspending.gov/api/v2 autocomplete/recipient",
|
|
2481
|
+
returned: results.length,
|
|
2482
|
+
limit,
|
|
2483
|
+
totalAvailable: null,
|
|
2484
|
+
}));
|
|
2485
|
+
},
|
|
2486
|
+
);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
export async function naicsHierarchy(args: { naicsFilter?: string }) {
|
|
2490
|
+
return memoize(`usas:naics-hierarchy:${args.naicsFilter ?? ""}`, async () => {
|
|
2491
|
+
type NaicsNode = {
|
|
2492
|
+
naics?: string;
|
|
2493
|
+
naics_description?: string;
|
|
2494
|
+
count?: number;
|
|
2495
|
+
children?: NaicsNode[];
|
|
2496
|
+
};
|
|
2497
|
+
type Resp = { results?: NaicsNode[] };
|
|
2498
|
+
// VQ-4 (C81 dogfooding): DRILL-DOWN is the PATH param references/naics/{code}/
|
|
2499
|
+
// (returns the node WITH its `children`), NOT `?filter=` — `filter` is a keyword
|
|
2500
|
+
// search (live: filter=54 fuzzy-matched sectors 32/45/48/54, and the top-level
|
|
2501
|
+
// response omits `children` so hasChildren was ALWAYS false). Unfiltered returns
|
|
2502
|
+
// the top-level 2-digit sectors.
|
|
2503
|
+
const path = args.naicsFilter
|
|
2504
|
+
? `references/naics/${encodeURIComponent(args.naicsFilter)}/`
|
|
2505
|
+
: "references/naics/";
|
|
2506
|
+
// Snapshot ONLY the canonical UNFILTERED top-level NAICS tree (a single,
|
|
2507
|
+
// well-defined queryless read the builder can pre-fetch). A drill-down
|
|
2508
|
+
// (`naicsFilter` set) passes no key ⇒ live only. Even unfiltered, the
|
|
2509
|
+
// snapshot is INERT unless SAMGOV_SNAPSHOT_BASE_URL is configured.
|
|
2510
|
+
const snapshotKey = args.naicsFilter ? undefined : "usas_naics_hierarchy";
|
|
2511
|
+
const { body: json, provenance } = await getUsasResilient<Resp>(
|
|
2512
|
+
path,
|
|
2513
|
+
snapshotKey,
|
|
2514
|
+
);
|
|
2515
|
+
const results = json.results ?? [];
|
|
2516
|
+
// NAICS levels are 2→4→6 digit (USAspending skips 3/5); only a 6-digit code is a
|
|
2517
|
+
// leaf. The top-level response carries no `children`, so hasChildren is derived
|
|
2518
|
+
// from code length — an honest structural signal, never a blanket false.
|
|
2519
|
+
const toRow = (r: NaicsNode) => ({
|
|
2520
|
+
code: r.naics ?? "",
|
|
2521
|
+
description: r.naics_description ?? "",
|
|
2522
|
+
count: r.count ?? 0,
|
|
2523
|
+
hasChildren: (r.naics ?? "").length < 6,
|
|
2524
|
+
});
|
|
2525
|
+
// Filtered: results[0] is the requested node; its `children` are the drill-down
|
|
2526
|
+
// level. Unfiltered: results ARE the top-level sectors.
|
|
2527
|
+
const node = args.naicsFilter ? results[0] : undefined;
|
|
2528
|
+
const level = args.naicsFilter ? (node?.children ?? []) : results;
|
|
2529
|
+
const notes: string[] = [];
|
|
2530
|
+
if (args.naicsFilter && !node) {
|
|
2531
|
+
notes.push(
|
|
2532
|
+
`NAICS '${args.naicsFilter}' was NOT found in the USAspending reference dataset — no hierarchy returned (nothing fabricated). Confirm it is a current 2/4/6-digit NAICS code.`,
|
|
2533
|
+
);
|
|
2534
|
+
} else if (args.naicsFilter && node && level.length === 0) {
|
|
2535
|
+
// Adversarial review F2: derive the digit count instead of hardcoding "6-digit"
|
|
2536
|
+
// (a 4-digit code with no children in USAspending's data would else mislabel).
|
|
2537
|
+
const digits = (node.naics ?? args.naicsFilter).length;
|
|
2538
|
+
notes.push(
|
|
2539
|
+
`NAICS ${node.naics ?? args.naicsFilter} is a leaf (${digits}-digit — no child codes in USAspending's reference). The node's own row is in \`parent\`.`,
|
|
2540
|
+
);
|
|
2541
|
+
} else {
|
|
2542
|
+
notes.push(
|
|
2543
|
+
"Drill into any row with hasChildren:true by calling again with its `code` as naicsFilter. Levels are 2→4→6 digit (USAspending skips 3/5-digit).",
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
const data = {
|
|
2547
|
+
filter: args.naicsFilter ?? null,
|
|
2548
|
+
// Adversarial review F3: structured found signal — a leaf (found, no children)
|
|
2549
|
+
// and a nonexistent code both yield hierarchy:[], so `returned` alone can't
|
|
2550
|
+
// distinguish them. found:true/false disambiguates; null when unfiltered.
|
|
2551
|
+
found: args.naicsFilter ? node !== undefined : null,
|
|
2552
|
+
parent: node
|
|
2553
|
+
? { code: node.naics ?? "", description: node.naics_description ?? "", count: node.count ?? 0 }
|
|
2554
|
+
: null,
|
|
2555
|
+
hierarchy: level.map(toRow),
|
|
2556
|
+
};
|
|
2557
|
+
// references/naics has NO limit param and NO total — the returned level IS the
|
|
2558
|
+
// complete set for that node.
|
|
2559
|
+
return withMeta(data, {
|
|
2560
|
+
...referenceMeta({
|
|
2561
|
+
source: "usaspending.gov/api/v2 references/naics",
|
|
2562
|
+
returned: data.hierarchy.length,
|
|
2563
|
+
limit: data.hierarchy.length,
|
|
2564
|
+
totalAvailable: data.hierarchy.length,
|
|
2565
|
+
limitHonored: false,
|
|
2566
|
+
extraNotes: notes,
|
|
2567
|
+
}),
|
|
2568
|
+
filtersApplied: args.naicsFilter ? ["naicsFilter(direct-children)"] : [],
|
|
2569
|
+
// P5 provenance: threaded ONLY when NON-live (snapshot) — live ⇒ {} ⇒
|
|
2570
|
+
// byte-identical _meta (provenanceMeta returns the empty object).
|
|
2571
|
+
...provenanceMeta(provenance),
|
|
2572
|
+
});
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
const GLOSSARY_DEFAULT_LIMIT = 25;
|
|
2577
|
+
|
|
2578
|
+
export async function glossary(args: { limit?: number; search?: string }) {
|
|
2579
|
+
const limit = args.limit ?? GLOSSARY_DEFAULT_LIMIT;
|
|
2580
|
+
return memoize(`usas:glossary:${args.search ?? ""}:${limit}`, async () => {
|
|
2581
|
+
type Resp = {
|
|
2582
|
+
// references/glossary DOES report a real grand total in
|
|
2583
|
+
// page_metadata.count (verified 2026-07-03: 151, stable across limits).
|
|
2584
|
+
page_metadata?: { count?: number };
|
|
2585
|
+
results?: { term?: string; slug?: string; plain?: string }[];
|
|
2586
|
+
};
|
|
2587
|
+
const params = new URLSearchParams();
|
|
2588
|
+
params.set("limit", String(limit));
|
|
2589
|
+
if (args.search) params.set("search", args.search);
|
|
2590
|
+
// Snapshot ONLY the canonical read — NO search term AND the default limit —
|
|
2591
|
+
// which is the exact query the builder pre-fetches (references/glossary/
|
|
2592
|
+
// ?limit=25). A search or a non-default limit passes no key ⇒ live only, so
|
|
2593
|
+
// the served snapshot can never desync `returned` from what was asked. INERT
|
|
2594
|
+
// unless SAMGOV_SNAPSHOT_BASE_URL is configured.
|
|
2595
|
+
const snapshotKey =
|
|
2596
|
+
!args.search && limit === GLOSSARY_DEFAULT_LIMIT
|
|
2597
|
+
? "usas_glossary"
|
|
2598
|
+
: undefined;
|
|
2599
|
+
const { body: json, provenance } = await getUsasResilient<Resp>(
|
|
2600
|
+
`references/glossary/?${params.toString()}`,
|
|
2601
|
+
snapshotKey,
|
|
2602
|
+
);
|
|
2603
|
+
const results = json.results ?? [];
|
|
2604
|
+
const total = json.page_metadata?.count ?? null;
|
|
2605
|
+
const data = {
|
|
2606
|
+
// F3 (P1): mirror the honest null `_meta.totalAvailable` — an OMITTED count
|
|
2607
|
+
// must NOT default to 0 in the data field (that would contradict the null meta).
|
|
2608
|
+
totalRecords: total,
|
|
2609
|
+
terms: results.map((r) => ({
|
|
2610
|
+
term: r.term ?? "",
|
|
2611
|
+
slug: r.slug ?? "",
|
|
2612
|
+
definition: r.plain ?? "",
|
|
2613
|
+
})),
|
|
2614
|
+
};
|
|
2615
|
+
return withMeta(data, {
|
|
2616
|
+
...referenceMeta({
|
|
2617
|
+
source: "usaspending.gov/api/v2 references/glossary",
|
|
2618
|
+
returned: results.length,
|
|
2619
|
+
limit,
|
|
2620
|
+
totalAvailable: total,
|
|
2621
|
+
}),
|
|
2622
|
+
// P5 provenance — threaded ONLY when NON-live ⇒ live stays byte-identical.
|
|
2623
|
+
...provenanceMeta(provenance),
|
|
2624
|
+
});
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
export async function listToptierAgencies(args: { limit?: number }) {
|
|
2629
|
+
const limit = args.limit ?? 50;
|
|
2630
|
+
return memoize(`usas:toptier:${limit}`, async () => {
|
|
2631
|
+
type Resp = {
|
|
2632
|
+
results?: {
|
|
2633
|
+
agency_name?: string;
|
|
2634
|
+
abbreviation?: string;
|
|
2635
|
+
toptier_code?: string;
|
|
2636
|
+
agency_slug?: string;
|
|
2637
|
+
active_fy?: string;
|
|
2638
|
+
obligated_amount?: number;
|
|
2639
|
+
}[];
|
|
2640
|
+
};
|
|
2641
|
+
// The toptier list is canonical + the endpoint IGNORES `limit` (returns the
|
|
2642
|
+
// COMPLETE ~111-agency set for any limit — verified 2026-07-03), so the
|
|
2643
|
+
// snapshot is limit-independent and always opted in (key stable). INERT
|
|
2644
|
+
// unless SAMGOV_SNAPSHOT_BASE_URL is configured.
|
|
2645
|
+
const { body: json, provenance } = await getUsasResilient<Resp>(
|
|
2646
|
+
`references/toptier_agencies/?limit=${limit}`,
|
|
2647
|
+
"usas_toptier_agencies",
|
|
2648
|
+
);
|
|
2649
|
+
const results = json.results ?? [];
|
|
2650
|
+
const data = {
|
|
2651
|
+
agencies: results.map((r) => ({
|
|
2652
|
+
name: r.agency_name ?? "",
|
|
2653
|
+
abbreviation: r.abbreviation,
|
|
2654
|
+
toptierCode: r.toptier_code,
|
|
2655
|
+
slug: r.agency_slug,
|
|
2656
|
+
activeFiscalYear: r.active_fy,
|
|
2657
|
+
obligatedAmount: r.obligated_amount ?? 0,
|
|
2658
|
+
})),
|
|
2659
|
+
};
|
|
2660
|
+
// IMPORTANT: this endpoint IGNORES the `limit` param (verified 2026-07-03:
|
|
2661
|
+
// limit=3 AND limit=1000 both return all 111 toptier agencies). So the
|
|
2662
|
+
// response is ALWAYS the complete set — truncated:false, and the returned
|
|
2663
|
+
// count IS the total. Deriving truncation from `returned >= limit` would be
|
|
2664
|
+
// a false positive, so limitHonored:false forces complete.
|
|
2665
|
+
return withMeta(data, {
|
|
2666
|
+
...referenceMeta({
|
|
2667
|
+
source: "usaspending.gov/api/v2 references/toptier_agencies",
|
|
2668
|
+
returned: results.length,
|
|
2669
|
+
limit,
|
|
2670
|
+
totalAvailable: results.length,
|
|
2671
|
+
limitHonored: false,
|
|
2672
|
+
extraNotes: [
|
|
2673
|
+
"The toptier_agencies endpoint returns the COMPLETE list of ~111 toptier agencies regardless of the `limit` value (limit is ignored upstream).",
|
|
2674
|
+
],
|
|
2675
|
+
}),
|
|
2676
|
+
// P5 provenance — threaded ONLY when NON-live ⇒ live stays byte-identical.
|
|
2677
|
+
...provenanceMeta(provenance),
|
|
2678
|
+
});
|
|
2679
|
+
});
|
|
2680
|
+
}
|