@cliwant/mcp-sam-gov 0.3.0 → 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 +226 -64
- 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 +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- 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 +74 -1
- 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 +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- 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 +419 -3
- 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 +111 -6
- 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 +3889 -296
- 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 +1629 -201
- 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 +5 -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 +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- 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 +139 -7
- 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 +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/src/usaspending.ts
CHANGED
|
@@ -66,8 +66,259 @@ function buildFilters(args: {
|
|
|
66
66
|
return filters;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
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";
|
|
70
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
|
+
}
|
|
71
322
|
|
|
72
323
|
async function postUsas<T>(
|
|
73
324
|
endpoint: string,
|
|
@@ -95,6 +346,34 @@ async function getUsas<T>(endpoint: string): Promise<T> {
|
|
|
95
346
|
return (await r.json()) as T;
|
|
96
347
|
}
|
|
97
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
|
+
|
|
98
377
|
// ─── Aggregate share-of-wallet ───────────────────────────────────
|
|
99
378
|
|
|
100
379
|
export async function searchAwards(args: {
|
|
@@ -106,21 +385,45 @@ export async function searchAwards(args: {
|
|
|
106
385
|
const filters = buildFilters(args);
|
|
107
386
|
type Resp = {
|
|
108
387
|
results?: { name?: string; amount?: number; count?: number }[];
|
|
388
|
+
page_metadata?: { total?: number; count?: number };
|
|
109
389
|
};
|
|
390
|
+
const limit = 10;
|
|
110
391
|
const json = await postUsas<Resp>(
|
|
111
392
|
"search/spending_by_category/recipient",
|
|
112
|
-
{ filters, limit
|
|
393
|
+
{ filters, limit, page: 1 },
|
|
113
394
|
);
|
|
114
395
|
const results = json.results ?? [];
|
|
115
|
-
|
|
116
|
-
|
|
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,
|
|
117
404
|
totalValue: results.reduce((s, r) => s + (r.amount ?? 0), 0),
|
|
118
405
|
topRecipients: results.map((r) => ({
|
|
119
406
|
name: r.name ?? "—",
|
|
120
407
|
value: r.amount ?? 0,
|
|
121
|
-
awards:
|
|
408
|
+
awards: null as number | null,
|
|
122
409
|
})),
|
|
123
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
|
+
});
|
|
124
427
|
}
|
|
125
428
|
|
|
126
429
|
// ─── Line-item awards ─────────────────────────────────────────────
|
|
@@ -140,38 +443,123 @@ export async function searchIndividualAwards(args: {
|
|
|
140
443
|
"Award Amount"?: number;
|
|
141
444
|
"Awarding Agency"?: string;
|
|
142
445
|
"Awarding Sub Agency"?: string;
|
|
446
|
+
NAICS?: { code?: string; description?: string };
|
|
143
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;
|
|
144
451
|
Description?: string;
|
|
145
452
|
generated_internal_id?: string;
|
|
146
453
|
}[];
|
|
454
|
+
page_metadata?: { hasNext?: boolean };
|
|
147
455
|
};
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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) => ({
|
|
165
488
|
awardId: r["Award ID"] ?? "",
|
|
166
|
-
recipient: r["Recipient Name"]
|
|
167
|
-
|
|
168
|
-
|
|
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,
|
|
169
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,
|
|
170
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"],
|
|
171
503
|
description: r.Description,
|
|
172
504
|
generatedInternalId: r.generated_internal_id ?? "",
|
|
173
505
|
})),
|
|
174
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
|
+
};
|
|
175
563
|
}
|
|
176
564
|
|
|
177
565
|
// ─── Recipient win history ────────────────────────────────────────
|
|
@@ -193,40 +581,92 @@ export async function searchAwardsByRecipient(args: {
|
|
|
193
581
|
"Awarding Agency"?: string;
|
|
194
582
|
"Awarding Sub Agency"?: string;
|
|
195
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;
|
|
196
588
|
Description?: string;
|
|
197
589
|
generated_internal_id?: string;
|
|
198
590
|
}[];
|
|
591
|
+
page_metadata?: { hasNext?: boolean };
|
|
199
592
|
};
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
]);
|
|
215
616
|
const results = json.results ?? [];
|
|
216
|
-
|
|
617
|
+
const data = {
|
|
217
618
|
awards: results.map((r) => ({
|
|
218
619
|
awardId: r["Award ID"] ?? "",
|
|
219
|
-
recipient: r["Recipient Name"]
|
|
220
|
-
|
|
221
|
-
|
|
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,
|
|
222
626
|
awardingSubAgency: r["Awarding Sub Agency"],
|
|
223
627
|
naicsCode: r.NAICS?.code,
|
|
224
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"],
|
|
225
633
|
description: r.Description,
|
|
226
634
|
generatedInternalId: r.generated_internal_id ?? "",
|
|
227
635
|
})),
|
|
228
|
-
|
|
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,
|
|
229
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
|
+
});
|
|
230
670
|
}
|
|
231
671
|
|
|
232
672
|
// ─── Subaward enumeration ─────────────────────────────────────────
|
|
@@ -248,47 +688,140 @@ export async function searchSubawards(args: {
|
|
|
248
688
|
"Sub-Award Recipient"?: string;
|
|
249
689
|
"Sub-Award Amount"?: number;
|
|
250
690
|
"Sub-Award Date"?: string;
|
|
691
|
+
NAICS?: { code?: string; description?: string };
|
|
251
692
|
prime_award_generated_internal_id?: string;
|
|
252
693
|
}[];
|
|
694
|
+
page_metadata?: { hasNext?: boolean };
|
|
253
695
|
};
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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) => ({
|
|
269
722
|
subAwardId: r["Sub-Award ID"] ?? "",
|
|
270
|
-
|
|
271
|
-
|
|
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,
|
|
272
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,
|
|
273
738
|
primeAwardId: r.prime_award_generated_internal_id ?? "",
|
|
274
739
|
})),
|
|
275
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
|
+
});
|
|
276
768
|
}
|
|
277
769
|
|
|
278
770
|
// ─── Per-award detail ─────────────────────────────────────────────
|
|
279
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
|
+
|
|
280
796
|
export async function getAwardDetail(generatedInternalId: string) {
|
|
281
797
|
try {
|
|
282
798
|
const r = await fetch(
|
|
283
799
|
`${USAS}/awards/${encodeURIComponent(generatedInternalId)}/`,
|
|
284
800
|
{ signal: AbortSignal.timeout(10_000) },
|
|
285
801
|
);
|
|
286
|
-
|
|
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
|
+
}
|
|
287
815
|
type Resp = {
|
|
288
816
|
piid?: string;
|
|
289
817
|
description?: string;
|
|
290
818
|
total_obligation?: number;
|
|
291
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;
|
|
292
825
|
period_of_performance?: {
|
|
293
826
|
start_date?: string;
|
|
294
827
|
end_date?: string;
|
|
@@ -298,10 +831,20 @@ export async function getAwardDetail(generatedInternalId: string) {
|
|
|
298
831
|
type_set_aside?: string;
|
|
299
832
|
type_set_aside_description?: string;
|
|
300
833
|
extent_competed?: string;
|
|
301
|
-
|
|
834
|
+
extent_competed_description?: string;
|
|
835
|
+
number_of_offers_received?: string | number | null;
|
|
302
836
|
naics?: string;
|
|
303
837
|
naics_description?: string;
|
|
838
|
+
product_or_service_code?: string;
|
|
839
|
+
product_or_service_description?: string;
|
|
304
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;
|
|
305
848
|
awarding_agency?: {
|
|
306
849
|
toptier_agency?: { name?: string };
|
|
307
850
|
subtier_agency?: { name?: string };
|
|
@@ -310,11 +853,43 @@ export async function getAwardDetail(generatedInternalId: string) {
|
|
|
310
853
|
};
|
|
311
854
|
const json = (await r.json()) as Resp;
|
|
312
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;
|
|
313
875
|
return {
|
|
314
876
|
awardId: json.piid ?? "",
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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,
|
|
318
893
|
periodOfPerformance: {
|
|
319
894
|
startDate: json.period_of_performance?.start_date ?? null,
|
|
320
895
|
endDate: json.period_of_performance?.end_date ?? null,
|
|
@@ -324,87 +899,751 @@ export async function getAwardDetail(generatedInternalId: string) {
|
|
|
324
899
|
setAsideType: ltc.type_set_aside,
|
|
325
900
|
setAsideDescription: ltc.type_set_aside_description,
|
|
326
901
|
competitionExtent: ltc.extent_competed,
|
|
327
|
-
|
|
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),
|
|
328
906
|
awardingAgency: json.awarding_agency?.toptier_agency?.name,
|
|
329
907
|
awardingSubAgency: json.awarding_agency?.subtier_agency?.name,
|
|
330
908
|
naicsCode: ltc.naics,
|
|
331
909
|
naicsDescription: ltc.naics_description,
|
|
910
|
+
pscCode,
|
|
911
|
+
pscDescription,
|
|
912
|
+
parentIdv,
|
|
332
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 };
|
|
333
957
|
} catch {
|
|
334
|
-
return null;
|
|
958
|
+
return { count: null, atLeast: false };
|
|
335
959
|
}
|
|
336
960
|
}
|
|
337
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
|
+
|
|
338
1228
|
// ─── Recompete radar ──────────────────────────────────────────────
|
|
339
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
|
+
*/
|
|
340
1587
|
export async function searchExpiringContracts(args: {
|
|
341
1588
|
agency?: string;
|
|
342
1589
|
naics?: string;
|
|
343
|
-
fiscalYear
|
|
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.
|
|
344
1597
|
monthsUntilExpiry?: number;
|
|
345
1598
|
minAwardValue?: number;
|
|
346
1599
|
limit?: number;
|
|
347
1600
|
}) {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const search = await postUsas<SearchResp>("search/spending_by_award", {
|
|
358
|
-
filters,
|
|
359
|
-
fields: ["Award ID", "Recipient Name", "Award Amount"],
|
|
360
|
-
limit: 50,
|
|
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,
|
|
361
1610
|
page: 1,
|
|
362
|
-
subawards: false,
|
|
363
|
-
sort: "Award Amount",
|
|
364
|
-
order: "desc",
|
|
365
1611
|
});
|
|
366
1612
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
}));
|
|
378
1630
|
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const days = Math.ceil((end - now) / (24 * 60 * 60 * 1000));
|
|
387
|
-
if (days < -30 || days > cutoffDays) return null;
|
|
388
|
-
const orig = enrich[idx] ?? {};
|
|
389
|
-
return {
|
|
390
|
-
awardId: d.awardId || orig["Award ID"] || "",
|
|
391
|
-
recipient: d.recipient || orig["Recipient Name"] || "",
|
|
392
|
-
amount: d.totalObligation || orig["Award Amount"] || 0,
|
|
393
|
-
endDate: d.periodOfPerformance.endDate,
|
|
394
|
-
potentialEndDate: d.periodOfPerformance.potentialEndDate,
|
|
395
|
-
awardingAgency: d.awardingAgency ?? "",
|
|
396
|
-
awardingSubAgency: d.awardingSubAgency,
|
|
397
|
-
naicsCode: d.naicsCode,
|
|
398
|
-
setAsideDescription: d.setAsideDescription,
|
|
399
|
-
description: d.description,
|
|
400
|
-
daysUntilExpiry: days,
|
|
401
|
-
};
|
|
402
|
-
})
|
|
403
|
-
.filter((x): x is NonNullable<typeof x> => x !== null)
|
|
404
|
-
.slice(0, args.limit ?? 10)
|
|
405
|
-
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
|
|
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
|
+
};
|
|
406
1638
|
|
|
407
|
-
|
|
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);
|
|
408
1647
|
}
|
|
409
1648
|
|
|
410
1649
|
// ─── Aggregate analysis: time series ──────────────────────────────
|
|
@@ -426,20 +1665,89 @@ export async function spendingOverTime(args: {
|
|
|
426
1665
|
Idv_Obligations?: number;
|
|
427
1666
|
}[];
|
|
428
1667
|
};
|
|
1668
|
+
const group = args.group ?? "fiscal_year";
|
|
429
1669
|
const json = await postUsas<Resp>("search/spending_over_time/", {
|
|
430
|
-
group
|
|
1670
|
+
group,
|
|
431
1671
|
filters,
|
|
432
1672
|
});
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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;
|
|
442
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
|
+
);
|
|
443
1751
|
}
|
|
444
1752
|
|
|
445
1753
|
// ─── Aggregate analysis: PSC spending ─────────────────────────────
|
|
@@ -453,18 +1761,31 @@ export async function searchPscSpending(args: {
|
|
|
453
1761
|
const filters = buildFilters(args);
|
|
454
1762
|
type Resp = {
|
|
455
1763
|
results?: { code?: string; name?: string; amount?: number }[];
|
|
1764
|
+
page_metadata?: CategoryPageMeta;
|
|
456
1765
|
};
|
|
1766
|
+
const limit = args.limit ?? 10;
|
|
457
1767
|
const json = await postUsas<Resp>(
|
|
458
1768
|
"search/spending_by_category/psc",
|
|
459
|
-
{ filters, limit
|
|
1769
|
+
{ filters, limit, page: 1 },
|
|
460
1770
|
);
|
|
461
|
-
|
|
462
|
-
|
|
1771
|
+
const results = json.results ?? [];
|
|
1772
|
+
const data = {
|
|
1773
|
+
psc: results.map((r) => ({
|
|
463
1774
|
pscCode: r.code ?? "",
|
|
464
1775
|
pscName: r.name ?? "",
|
|
465
1776
|
amount: r.amount ?? 0,
|
|
466
1777
|
})),
|
|
467
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
|
+
);
|
|
468
1789
|
}
|
|
469
1790
|
|
|
470
1791
|
// ─── Aggregate analysis: state / territory ─────────────────────────
|
|
@@ -478,18 +1799,35 @@ export async function searchStateSpending(args: {
|
|
|
478
1799
|
const filters = buildFilters(args);
|
|
479
1800
|
type Resp = {
|
|
480
1801
|
results?: { code?: string; name?: string; amount?: number }[];
|
|
1802
|
+
page_metadata?: CategoryPageMeta;
|
|
481
1803
|
};
|
|
1804
|
+
const limit = args.limit ?? 10;
|
|
482
1805
|
const json = await postUsas<Resp>(
|
|
483
1806
|
"search/spending_by_category/state_territory",
|
|
484
|
-
{ filters, limit
|
|
1807
|
+
{ filters, limit, page: 1 },
|
|
485
1808
|
);
|
|
486
|
-
|
|
487
|
-
|
|
1809
|
+
const results = json.results ?? [];
|
|
1810
|
+
const data = {
|
|
1811
|
+
states: results.map((r) => ({
|
|
488
1812
|
stateCode: r.code ?? "",
|
|
489
1813
|
stateName: r.name ?? "",
|
|
490
1814
|
amount: r.amount ?? 0,
|
|
491
1815
|
})),
|
|
492
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
|
+
);
|
|
493
1831
|
}
|
|
494
1832
|
|
|
495
1833
|
// ─── Aggregate analysis: CFDA (grants) ─────────────────────────────
|
|
@@ -518,18 +1856,34 @@ export async function searchCfdaSpending(args: {
|
|
|
518
1856
|
}
|
|
519
1857
|
type Resp = {
|
|
520
1858
|
results?: { code?: string; name?: string; amount?: number }[];
|
|
1859
|
+
page_metadata?: CategoryPageMeta;
|
|
521
1860
|
};
|
|
1861
|
+
const limit = args.limit ?? 10;
|
|
522
1862
|
const json = await postUsas<Resp>(
|
|
523
1863
|
"search/spending_by_category/cfda",
|
|
524
|
-
{ filters, limit
|
|
1864
|
+
{ filters, limit, page: 1 },
|
|
525
1865
|
);
|
|
526
|
-
|
|
527
|
-
|
|
1866
|
+
const results = json.results ?? [];
|
|
1867
|
+
const data = {
|
|
1868
|
+
programs: results.map((r) => ({
|
|
528
1869
|
cfdaCode: r.code ?? "",
|
|
529
1870
|
programName: r.name ?? "",
|
|
530
1871
|
amount: r.amount ?? 0,
|
|
531
1872
|
})),
|
|
532
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
|
+
);
|
|
533
1887
|
}
|
|
534
1888
|
|
|
535
1889
|
// ─── Aggregate analysis: federal account (TAS) ─────────────────────
|
|
@@ -543,18 +1897,32 @@ export async function searchFederalAccountSpending(args: {
|
|
|
543
1897
|
const filters = buildFilters(args);
|
|
544
1898
|
type Resp = {
|
|
545
1899
|
results?: { code?: string; name?: string; amount?: number }[];
|
|
1900
|
+
page_metadata?: CategoryPageMeta;
|
|
546
1901
|
};
|
|
1902
|
+
const limit = args.limit ?? 10;
|
|
547
1903
|
const json = await postUsas<Resp>(
|
|
548
1904
|
"search/spending_by_category/federal_account",
|
|
549
|
-
{ filters, limit
|
|
1905
|
+
{ filters, limit, page: 1 },
|
|
550
1906
|
);
|
|
551
|
-
|
|
552
|
-
|
|
1907
|
+
const results = json.results ?? [];
|
|
1908
|
+
const data = {
|
|
1909
|
+
accounts: results.map((r) => ({
|
|
553
1910
|
tasCode: r.code ?? "",
|
|
554
1911
|
accountName: r.name ?? "",
|
|
555
1912
|
amount: r.amount ?? 0,
|
|
556
1913
|
})),
|
|
557
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
|
+
);
|
|
558
1926
|
}
|
|
559
1927
|
|
|
560
1928
|
// ─── Aggregate analysis: awarding agency ──────────────────────────
|
|
@@ -573,19 +1941,33 @@ export async function searchAgencySpending(args: {
|
|
|
573
1941
|
amount?: number;
|
|
574
1942
|
agency_slug?: string;
|
|
575
1943
|
}[];
|
|
1944
|
+
page_metadata?: CategoryPageMeta;
|
|
576
1945
|
};
|
|
1946
|
+
const limit = args.limit ?? 10;
|
|
577
1947
|
const json = await postUsas<Resp>(
|
|
578
1948
|
"search/spending_by_category/awarding_agency",
|
|
579
|
-
{ filters, limit
|
|
1949
|
+
{ filters, limit, page: 1 },
|
|
580
1950
|
);
|
|
581
|
-
|
|
582
|
-
|
|
1951
|
+
const results = json.results ?? [];
|
|
1952
|
+
const data = {
|
|
1953
|
+
agencies: results.map((r) => ({
|
|
583
1954
|
name: r.name ?? "",
|
|
584
1955
|
code: r.code ?? "",
|
|
585
1956
|
slug: r.agency_slug ?? "",
|
|
586
1957
|
amount: r.amount ?? 0,
|
|
587
1958
|
})),
|
|
588
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
|
+
);
|
|
589
1971
|
}
|
|
590
1972
|
|
|
591
1973
|
// ─── Sub-agency breakdown ─────────────────────────────────────────
|
|
@@ -597,18 +1979,42 @@ export async function searchSubAgencySpending(args: {
|
|
|
597
1979
|
const filters = buildFilters(args);
|
|
598
1980
|
type Resp = {
|
|
599
1981
|
results?: { name?: string; amount?: number; count?: number }[];
|
|
1982
|
+
page_metadata?: CategoryPageMeta;
|
|
600
1983
|
};
|
|
1984
|
+
const limit = 10;
|
|
601
1985
|
const json = await postUsas<Resp>(
|
|
602
1986
|
"search/spending_by_category/awarding_subagency",
|
|
603
|
-
{ filters, limit
|
|
1987
|
+
{ filters, limit, page: 1 },
|
|
604
1988
|
);
|
|
605
|
-
|
|
606
|
-
|
|
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) => ({
|
|
607
1998
|
name: r.name ?? "",
|
|
608
1999
|
amount: r.amount ?? 0,
|
|
609
|
-
awards:
|
|
2000
|
+
awards: null as number | null,
|
|
610
2001
|
})),
|
|
611
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
|
+
);
|
|
612
2018
|
}
|
|
613
2019
|
|
|
614
2020
|
// ─── Agency profile ───────────────────────────────────────────────
|
|
@@ -625,6 +2031,18 @@ export async function getAgencyProfile(toptierCode: string) {
|
|
|
625
2031
|
congressional_justification_url?: string;
|
|
626
2032
|
};
|
|
627
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
|
+
}
|
|
628
2046
|
return {
|
|
629
2047
|
fiscalYear: json.fiscal_year,
|
|
630
2048
|
toptierCode: json.toptier_code,
|
|
@@ -652,13 +2070,47 @@ export async function getAgencyAwardsSummary(args: {
|
|
|
652
2070
|
const json = await getUsas<Resp>(
|
|
653
2071
|
`agency/${args.toptierCode}/awards/?fiscal_year=${fy}`,
|
|
654
2072
|
);
|
|
655
|
-
|
|
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 = {
|
|
656
2090
|
fiscalYear: json.fiscal_year,
|
|
657
2091
|
toptierCode: json.toptier_code,
|
|
658
|
-
|
|
659
|
-
|
|
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,
|
|
660
2096
|
latestActionDate: json.latest_action_date,
|
|
661
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
|
+
});
|
|
662
2114
|
}
|
|
663
2115
|
|
|
664
2116
|
export async function getAgencyBudgetFunction(args: {
|
|
@@ -667,6 +2119,7 @@ export async function getAgencyBudgetFunction(args: {
|
|
|
667
2119
|
limit?: number;
|
|
668
2120
|
}) {
|
|
669
2121
|
const fy = args.fiscalYear ?? new Date().getUTCFullYear();
|
|
2122
|
+
const limit = args.limit ?? 10;
|
|
670
2123
|
type Resp = {
|
|
671
2124
|
toptier_code?: string;
|
|
672
2125
|
fiscal_year?: number;
|
|
@@ -678,14 +2131,32 @@ export async function getAgencyBudgetFunction(args: {
|
|
|
678
2131
|
gross_outlay_amount?: number;
|
|
679
2132
|
}[];
|
|
680
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 };
|
|
681
2138
|
};
|
|
682
2139
|
const json = await getUsas<Resp>(
|
|
683
|
-
`agency/${args.toptierCode}/budget_function/?fiscal_year=${fy}&limit=${
|
|
2140
|
+
`agency/${args.toptierCode}/budget_function/?fiscal_year=${fy}&limit=${limit}`,
|
|
684
2141
|
);
|
|
685
|
-
|
|
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 = {
|
|
686
2157
|
toptierCode: json.toptier_code,
|
|
687
2158
|
fiscalYear: json.fiscal_year,
|
|
688
|
-
functions:
|
|
2159
|
+
functions: results.map((r) => ({
|
|
689
2160
|
name: r.name ?? "",
|
|
690
2161
|
programs: (r.children ?? []).map((c) => ({
|
|
691
2162
|
name: c.name ?? "",
|
|
@@ -694,6 +2165,34 @@ export async function getAgencyBudgetFunction(args: {
|
|
|
694
2165
|
})),
|
|
695
2166
|
})),
|
|
696
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
|
+
});
|
|
697
2196
|
}
|
|
698
2197
|
|
|
699
2198
|
// ─── Recipient list + profile ─────────────────────────────────────
|
|
@@ -714,18 +2213,28 @@ export async function searchRecipients(args: {
|
|
|
714
2213
|
amount?: number;
|
|
715
2214
|
}[];
|
|
716
2215
|
};
|
|
2216
|
+
const limit = args.limit ?? 10;
|
|
717
2217
|
const body: Record<string, unknown> = {
|
|
718
2218
|
keyword: args.keyword,
|
|
719
|
-
limit
|
|
2219
|
+
limit,
|
|
720
2220
|
page: 1,
|
|
721
2221
|
};
|
|
722
2222
|
if (args.recipientLevel) {
|
|
723
2223
|
body.recipient_level = args.recipientLevel;
|
|
724
2224
|
}
|
|
725
2225
|
const json = await postUsas<Resp>("recipient/", body);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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) => ({
|
|
729
2238
|
id: r.id ?? "",
|
|
730
2239
|
duns: r.duns,
|
|
731
2240
|
uei: r.uei,
|
|
@@ -734,6 +2243,35 @@ export async function searchRecipients(args: {
|
|
|
734
2243
|
totalAmount: r.amount ?? 0,
|
|
735
2244
|
})),
|
|
736
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
|
+
});
|
|
737
2275
|
}
|
|
738
2276
|
|
|
739
2277
|
export async function getRecipientProfile(recipientId: string) {
|
|
@@ -757,9 +2295,64 @@ export async function getRecipientProfile(recipientId: string) {
|
|
|
757
2295
|
total_transaction_amount?: number;
|
|
758
2296
|
total_transactions?: number;
|
|
759
2297
|
};
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
)
|
|
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
|
+
}
|
|
763
2356
|
return {
|
|
764
2357
|
name: json.name ?? "",
|
|
765
2358
|
alternateNames: json.alternate_names ?? [],
|
|
@@ -782,14 +2375,6 @@ export async function lookupAgency(searchText: string) {
|
|
|
782
2375
|
// Cache: agency lookups are extremely repeat-prone (`VA`, `DHS`, etc.)
|
|
783
2376
|
// and effectively static across a session.
|
|
784
2377
|
return memoize(`usas:agency:${searchText.toLowerCase()}`, async () => {
|
|
785
|
-
try {
|
|
786
|
-
const r = await fetch(`${USAS}/autocomplete/funding_agency/`, {
|
|
787
|
-
method: "POST",
|
|
788
|
-
headers: { "Content-Type": "application/json" },
|
|
789
|
-
body: JSON.stringify({ search_text: searchText, limit: 5 }),
|
|
790
|
-
signal: AbortSignal.timeout(10_000),
|
|
791
|
-
});
|
|
792
|
-
if (!r.ok) return { matches: [] };
|
|
793
2378
|
type Resp = {
|
|
794
2379
|
results?: {
|
|
795
2380
|
toptier_flag?: boolean;
|
|
@@ -800,7 +2385,15 @@ export async function lookupAgency(searchText: string) {
|
|
|
800
2385
|
};
|
|
801
2386
|
}[];
|
|
802
2387
|
};
|
|
803
|
-
|
|
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
|
+
});
|
|
804
2397
|
return {
|
|
805
2398
|
matches: (json.results ?? []).map((r) => ({
|
|
806
2399
|
name: r.toptier_agency?.name ?? "",
|
|
@@ -809,9 +2402,6 @@ export async function lookupAgency(searchText: string) {
|
|
|
809
2402
|
isToptier: !!r.toptier_flag,
|
|
810
2403
|
})),
|
|
811
2404
|
};
|
|
812
|
-
} catch {
|
|
813
|
-
return { matches: [] };
|
|
814
|
-
}
|
|
815
2405
|
});
|
|
816
2406
|
}
|
|
817
2407
|
|
|
@@ -819,8 +2409,9 @@ export async function autocompleteNaics(args: {
|
|
|
819
2409
|
searchText: string;
|
|
820
2410
|
limit?: number;
|
|
821
2411
|
}) {
|
|
2412
|
+
const limit = args.limit ?? 10;
|
|
822
2413
|
return memoize(
|
|
823
|
-
`usas:naics:${args.searchText.toLowerCase()}:${
|
|
2414
|
+
`usas:naics:${args.searchText.toLowerCase()}:${limit}`,
|
|
824
2415
|
async () => {
|
|
825
2416
|
type Resp = {
|
|
826
2417
|
results?: {
|
|
@@ -831,15 +2422,24 @@ export async function autocompleteNaics(args: {
|
|
|
831
2422
|
};
|
|
832
2423
|
const json = await postUsas<Resp>("autocomplete/naics/", {
|
|
833
2424
|
search_text: args.searchText,
|
|
834
|
-
limit
|
|
2425
|
+
limit,
|
|
835
2426
|
});
|
|
836
|
-
|
|
837
|
-
|
|
2427
|
+
const results = json.results ?? [];
|
|
2428
|
+
const data = {
|
|
2429
|
+
naics: results.map((r) => ({
|
|
838
2430
|
code: r.naics ?? "",
|
|
839
2431
|
description: r.naics_description ?? "",
|
|
840
2432
|
retired: !!r.year_retired,
|
|
841
2433
|
})),
|
|
842
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
|
+
}));
|
|
843
2443
|
},
|
|
844
2444
|
);
|
|
845
2445
|
}
|
|
@@ -848,10 +2448,16 @@ export async function autocompleteRecipient(args: {
|
|
|
848
2448
|
searchText: string;
|
|
849
2449
|
limit?: number;
|
|
850
2450
|
}) {
|
|
2451
|
+
const limit = args.limit ?? 10;
|
|
851
2452
|
return memoize(
|
|
852
|
-
`usas:recipient:${args.searchText.toLowerCase()}:${
|
|
2453
|
+
`usas:recipient:${args.searchText.toLowerCase()}:${limit}`,
|
|
853
2454
|
async () => {
|
|
854
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;
|
|
855
2461
|
results?: {
|
|
856
2462
|
recipient_name?: string;
|
|
857
2463
|
uei?: string;
|
|
@@ -860,67 +2466,168 @@ export async function autocompleteRecipient(args: {
|
|
|
860
2466
|
};
|
|
861
2467
|
const json = await postUsas<Resp>("autocomplete/recipient/", {
|
|
862
2468
|
search_text: args.searchText,
|
|
863
|
-
limit
|
|
2469
|
+
limit,
|
|
864
2470
|
});
|
|
865
|
-
|
|
866
|
-
|
|
2471
|
+
const results = json.results ?? [];
|
|
2472
|
+
const data = {
|
|
2473
|
+
recipients: results.map((r) => ({
|
|
867
2474
|
name: r.recipient_name ?? "",
|
|
868
2475
|
uei: r.uei,
|
|
869
2476
|
duns: r.duns,
|
|
870
2477
|
})),
|
|
871
2478
|
};
|
|
2479
|
+
return withMeta(data, referenceMeta({
|
|
2480
|
+
source: "usaspending.gov/api/v2 autocomplete/recipient",
|
|
2481
|
+
returned: results.length,
|
|
2482
|
+
limit,
|
|
2483
|
+
totalAvailable: null,
|
|
2484
|
+
}));
|
|
872
2485
|
},
|
|
873
2486
|
);
|
|
874
2487
|
}
|
|
875
2488
|
|
|
876
2489
|
export async function naicsHierarchy(args: { naicsFilter?: string }) {
|
|
877
2490
|
return memoize(`usas:naics-hierarchy:${args.naicsFilter ?? ""}`, async () => {
|
|
878
|
-
type
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
children?: unknown[];
|
|
884
|
-
}[];
|
|
2491
|
+
type NaicsNode = {
|
|
2492
|
+
naics?: string;
|
|
2493
|
+
naics_description?: string;
|
|
2494
|
+
count?: number;
|
|
2495
|
+
children?: NaicsNode[];
|
|
885
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.
|
|
886
2503
|
const path = args.naicsFilter
|
|
887
|
-
? `references/naics
|
|
2504
|
+
? `references/naics/${encodeURIComponent(args.naicsFilter)}/`
|
|
888
2505
|
: "references/naics/";
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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),
|
|
897
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
|
+
});
|
|
898
2573
|
});
|
|
899
2574
|
}
|
|
900
2575
|
|
|
2576
|
+
const GLOSSARY_DEFAULT_LIMIT = 25;
|
|
2577
|
+
|
|
901
2578
|
export async function glossary(args: { limit?: number; search?: string }) {
|
|
902
|
-
|
|
2579
|
+
const limit = args.limit ?? GLOSSARY_DEFAULT_LIMIT;
|
|
2580
|
+
return memoize(`usas:glossary:${args.search ?? ""}:${limit}`, async () => {
|
|
903
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).
|
|
904
2584
|
page_metadata?: { count?: number };
|
|
905
2585
|
results?: { term?: string; slug?: string; plain?: string }[];
|
|
906
2586
|
};
|
|
907
2587
|
const params = new URLSearchParams();
|
|
908
|
-
params.set("limit", String(
|
|
2588
|
+
params.set("limit", String(limit));
|
|
909
2589
|
if (args.search) params.set("search", args.search);
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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) => ({
|
|
914
2610
|
term: r.term ?? "",
|
|
915
2611
|
slug: r.slug ?? "",
|
|
916
2612
|
definition: r.plain ?? "",
|
|
917
2613
|
})),
|
|
918
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
|
+
});
|
|
919
2625
|
});
|
|
920
2626
|
}
|
|
921
2627
|
|
|
922
2628
|
export async function listToptierAgencies(args: { limit?: number }) {
|
|
923
|
-
|
|
2629
|
+
const limit = args.limit ?? 50;
|
|
2630
|
+
return memoize(`usas:toptier:${limit}`, async () => {
|
|
924
2631
|
type Resp = {
|
|
925
2632
|
results?: {
|
|
926
2633
|
agency_name?: string;
|
|
@@ -931,11 +2638,17 @@ export async function listToptierAgencies(args: { limit?: number }) {
|
|
|
931
2638
|
obligated_amount?: number;
|
|
932
2639
|
}[];
|
|
933
2640
|
};
|
|
934
|
-
|
|
935
|
-
|
|
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",
|
|
936
2648
|
);
|
|
937
|
-
|
|
938
|
-
|
|
2649
|
+
const results = json.results ?? [];
|
|
2650
|
+
const data = {
|
|
2651
|
+
agencies: results.map((r) => ({
|
|
939
2652
|
name: r.agency_name ?? "",
|
|
940
2653
|
abbreviation: r.abbreviation,
|
|
941
2654
|
toptierCode: r.toptier_code,
|
|
@@ -944,5 +2657,24 @@ export async function listToptierAgencies(args: { limit?: number }) {
|
|
|
944
2657
|
obligatedAmount: r.obligated_amount ?? 0,
|
|
945
2658
|
})),
|
|
946
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
|
+
});
|
|
947
2679
|
});
|
|
948
2680
|
}
|