@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/integrity.ts
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integrity & teaming tier (keyless) — debarment screening + small-business
|
|
3
|
+
* discovery for teaming, both grounded on LIVE-VERIFIED public endpoints
|
|
4
|
+
* (2026-07-03).
|
|
5
|
+
*
|
|
6
|
+
* Two keyless tools:
|
|
7
|
+
* - sam_check_exclusions → SAM debarment/exclusion screening
|
|
8
|
+
* - usas_search_teaming_partners → award-derived small-business discovery,
|
|
9
|
+
* integrity-screened
|
|
10
|
+
*
|
|
11
|
+
* The defining truthfulness constraints of this tier:
|
|
12
|
+
*
|
|
13
|
+
* 1. SAM exclusions — an EMPTY result is a TRUE NEGATIVE with a narrow
|
|
14
|
+
* meaning ("no matching exclusion under these terms"), NOT a clean bill of
|
|
15
|
+
* health. We say so, loudly, in every `_meta.notes` so an AI never reads
|
|
16
|
+
* "0 records" as "responsible". The exclusions index is `ex` (NOT
|
|
17
|
+
* `ei`/`exclusion`), served keyless from sam.gov's frontend SGS with an
|
|
18
|
+
* `application/hal+json` Accept + a browser-y User-Agent. Deep paging is
|
|
19
|
+
* capped at 10,000 records server-side.
|
|
20
|
+
*
|
|
21
|
+
* 2. USAspending socioeconomic proxy — a BOGUS `recipient_type_names` value
|
|
22
|
+
* returns `0` results with HTTP 200 (VERIFIED — a silent accept). So the
|
|
23
|
+
* `cert` parameter MUST be a Zod enum of values confirmed live (see the
|
|
24
|
+
* server's TeamingPartnersInput); the runtime here ALSO re-validates the
|
|
25
|
+
* cert against `VERIFIED_CERTS` and throws a structured `invalid_input`
|
|
26
|
+
* rather than ever issuing a confident-empty list. And the cert is
|
|
27
|
+
* AWARD-DERIVED (recorded on the firm's federal awards), NOT the SBA
|
|
28
|
+
* certification of record (which needs a keyed SAM Entity call) — every
|
|
29
|
+
* response says so.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
|
|
33
|
+
import { withMeta } from "./meta.js";
|
|
34
|
+
|
|
35
|
+
// ─── Shared HTTP ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
// SAM's public frontend SGS endpoint gates on a browser-y User-Agent AND
|
|
38
|
+
// requires `Accept: application/hal+json`. Mirror the pricing tier's UA so
|
|
39
|
+
// behavior is consistent across the server.
|
|
40
|
+
const SAM_UA =
|
|
41
|
+
"Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
|
|
42
|
+
const SAM_HAL_HEADERS = {
|
|
43
|
+
Accept: "application/hal+json",
|
|
44
|
+
"User-Agent": SAM_UA,
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
const SGS_BASE = "https://sam.gov/api/prod/sgs/v1/search";
|
|
48
|
+
const USAS = "https://api.usaspending.gov/api/v2";
|
|
49
|
+
|
|
50
|
+
// Server-side deep-paging cap on the SGS search index (LIVE-VERIFIED:
|
|
51
|
+
// page.maxAllowedRecords = 10000).
|
|
52
|
+
const SGS_MAX_RECORDS = 10_000;
|
|
53
|
+
|
|
54
|
+
async function getSgsJson<T>(url: string, label: string): Promise<T> {
|
|
55
|
+
const r = await fetchWithRetry(
|
|
56
|
+
url,
|
|
57
|
+
{ headers: SAM_HAL_HEADERS, signal: AbortSignal.timeout(15_000) },
|
|
58
|
+
label,
|
|
59
|
+
);
|
|
60
|
+
return (await r.json()) as T;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function postUsas<T>(
|
|
64
|
+
endpoint: string,
|
|
65
|
+
body: Record<string, unknown>,
|
|
66
|
+
): Promise<T> {
|
|
67
|
+
const r = await fetchWithRetry(
|
|
68
|
+
`${USAS}/${endpoint}`,
|
|
69
|
+
{
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
signal: AbortSignal.timeout(15_000),
|
|
74
|
+
},
|
|
75
|
+
`usaspending:${endpoint}`,
|
|
76
|
+
);
|
|
77
|
+
return (await r.json()) as T;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── 1. sam_check_exclusions ─────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
const EXCLUSIONS_SOURCE = "sam.gov/api/prod (frontend HAL, keyless)";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The mandatory disclosure attached to EVERY exclusions response. An empty
|
|
86
|
+
* result is a narrow true-negative — never a general clearance. Kept as a
|
|
87
|
+
* constant so the smoke/edge tests can assert on it verbatim.
|
|
88
|
+
*/
|
|
89
|
+
const NOT_PROOF_NOTE =
|
|
90
|
+
"An empty result means no matching exclusion was found (not currently excluded under these terms) — it is NOT proof of general responsibility.";
|
|
91
|
+
|
|
92
|
+
type ExclusionClassification =
|
|
93
|
+
| "Firm"
|
|
94
|
+
| "Individual"
|
|
95
|
+
| "Special Entity Designation"
|
|
96
|
+
| "any";
|
|
97
|
+
|
|
98
|
+
/** A single result row from the SGS `index=ex` exclusions index. */
|
|
99
|
+
type SgsExclusionResult = {
|
|
100
|
+
title?: string;
|
|
101
|
+
classification?: { code?: string; value?: string | null };
|
|
102
|
+
ueiSam?: string | null;
|
|
103
|
+
cageCode?: string | null;
|
|
104
|
+
samNumber?: string | null;
|
|
105
|
+
exclusionType?: string | null;
|
|
106
|
+
type?: { code?: string; value?: string | null };
|
|
107
|
+
exclusionProgram?: string | null;
|
|
108
|
+
excludingAgency?: string | null;
|
|
109
|
+
excludingAgencyDesc?: string | null;
|
|
110
|
+
ctCode?: string | null;
|
|
111
|
+
ctCodeDesc?: string | null;
|
|
112
|
+
isActive?: boolean | null;
|
|
113
|
+
activationDate?: string | null;
|
|
114
|
+
terminationDate?: string | null;
|
|
115
|
+
address?: Record<string, unknown> | null;
|
|
116
|
+
_id?: string | null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type SgsExclusionResp = {
|
|
120
|
+
_embedded?: { results?: SgsExclusionResult[] };
|
|
121
|
+
page?: {
|
|
122
|
+
size?: number;
|
|
123
|
+
totalElements?: number;
|
|
124
|
+
totalPages?: number;
|
|
125
|
+
number?: number;
|
|
126
|
+
maxAllowedRecords?: number;
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Normalize a UEI/CAGE for a case-insensitive post-filter compare. */
|
|
131
|
+
function norm(s: string | null | undefined): string {
|
|
132
|
+
return (s ?? "").trim().toUpperCase();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalize a legal entity NAME for a precise match: uppercase, strip
|
|
137
|
+
* punctuation, drop trailing entity suffixes (LLC/INC/CORP/…), and collapse
|
|
138
|
+
* whitespace. Used to decide whether an exclusion record genuinely names a
|
|
139
|
+
* given firm — SAM's free-text `q` tokenizes, so a raw "≥1 result" is NOT a
|
|
140
|
+
* match ("VISIONARY CONSULTING PARTNERS, LLC" would otherwise hit every
|
|
141
|
+
* unrelated "…CONSULTING…" exclusion, a dangerous false positive).
|
|
142
|
+
*/
|
|
143
|
+
function normName(s: string | null | undefined): string {
|
|
144
|
+
return (s ?? "")
|
|
145
|
+
.toUpperCase()
|
|
146
|
+
.replace(/[.,/#!$%^&*;:{}=\-_`~()'"]/g, " ")
|
|
147
|
+
.replace(/\b(LLC|L L C|INC|INCORPORATED|CORP|CORPORATION|CO|COMPANY|LTD|LIMITED|LP|LLP|PLLC|PC)\b/g, " ")
|
|
148
|
+
.replace(/\s+/g, " ")
|
|
149
|
+
.trim();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Keyless SAM debarment / exclusion screening.
|
|
154
|
+
*
|
|
155
|
+
* Requires at least one of `query`/`uei`/`cage` (else structured
|
|
156
|
+
* invalid_input). `query` drives the server-side `q=`; `uei`/`cage` are
|
|
157
|
+
* POST-filtered on the returned rows (the frontend SGS has no dedicated
|
|
158
|
+
* uei/cage query param). `activeOnly` and `classification` are also applied as
|
|
159
|
+
* post-filters. The response distinguishes:
|
|
160
|
+
* - `excluded`: ≥1 ACTIVE record matched the (post-filtered) query,
|
|
161
|
+
* - `matchCount`: how many rows matched after post-filtering.
|
|
162
|
+
* An empty/false result is disclosed as a NARROW true-negative, never a
|
|
163
|
+
* general clearance (see NOT_PROOF_NOTE).
|
|
164
|
+
*/
|
|
165
|
+
export async function checkExclusions(args: {
|
|
166
|
+
query?: string;
|
|
167
|
+
uei?: string;
|
|
168
|
+
cage?: string;
|
|
169
|
+
activeOnly?: boolean;
|
|
170
|
+
classification?: ExclusionClassification;
|
|
171
|
+
page?: number;
|
|
172
|
+
size?: number;
|
|
173
|
+
}) {
|
|
174
|
+
const query = args.query?.trim() || undefined;
|
|
175
|
+
const uei = args.uei?.trim() || undefined;
|
|
176
|
+
const cage = args.cage?.trim() || undefined;
|
|
177
|
+
const activeOnly = args.activeOnly ?? true;
|
|
178
|
+
const classification = args.classification ?? "any";
|
|
179
|
+
const page = Math.max(0, Math.floor(args.page ?? 0));
|
|
180
|
+
const size = Math.min(100, Math.max(1, Math.floor(args.size ?? 25)));
|
|
181
|
+
|
|
182
|
+
// At least one selector is required — an unbounded exclusions dump is never
|
|
183
|
+
// a meaningful screen.
|
|
184
|
+
if (!query && !uei && !cage) {
|
|
185
|
+
throw new ToolErrorCarrier({
|
|
186
|
+
kind: "invalid_input",
|
|
187
|
+
message:
|
|
188
|
+
"sam_check_exclusions requires at least one of query, uei, or cage. Pass the firm/individual name (query) and/or a UEI/CAGE to screen.",
|
|
189
|
+
retryable: false,
|
|
190
|
+
upstreamEndpoint: "sgs/v1/search?index=ex",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build the SGS query. `q` is the only server-side text selector on this
|
|
195
|
+
// index; uei/cage are post-filtered. When only a uei/cage is given (no
|
|
196
|
+
// name), use it as the `q` so the server still narrows.
|
|
197
|
+
const qValue = query ?? uei ?? cage ?? "";
|
|
198
|
+
const params = new URLSearchParams({
|
|
199
|
+
index: "ex",
|
|
200
|
+
q: qValue,
|
|
201
|
+
page: String(page),
|
|
202
|
+
size: String(size),
|
|
203
|
+
mode: "search",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const url = `${SGS_BASE}?${params.toString()}`;
|
|
207
|
+
const json = await getSgsJson<SgsExclusionResp>(url, "sam:sgs:ex");
|
|
208
|
+
const rawResults = json._embedded?.results ?? [];
|
|
209
|
+
const totalElements = json.page?.totalElements ?? null;
|
|
210
|
+
|
|
211
|
+
const filtersApplied: string[] = [];
|
|
212
|
+
const filtersDropped: string[] = [];
|
|
213
|
+
if (query) filtersApplied.push("query(q, server-side)");
|
|
214
|
+
else if (uei) filtersApplied.push("uei(as q, server-side)");
|
|
215
|
+
else if (cage) filtersApplied.push("cage(as q, server-side)");
|
|
216
|
+
|
|
217
|
+
// Post-filter on uei/cage/classification/activeOnly over the fetched page.
|
|
218
|
+
const ueiU = norm(uei);
|
|
219
|
+
const cageU = norm(cage);
|
|
220
|
+
let filtered = rawResults;
|
|
221
|
+
// NAME-MATCH GATE (truthfulness-critical): SAM's free-text `q` tokenizes, so a
|
|
222
|
+
// raw hit list is NOT a set of name matches — "VISIONARY CONSULTING PARTNERS"
|
|
223
|
+
// otherwise hits every unrelated "…PARTNERS…"/"…CONSULTING…" exclusion. When a
|
|
224
|
+
// name `query` is given, keep only records whose NORMALIZED name equals it
|
|
225
|
+
// (mirroring the teaming screen), so `excluded`/`records`/`matchCount` never
|
|
226
|
+
// flag someone else's exclusion. uei/cage-only selectors are gated below.
|
|
227
|
+
let looseTextHits = 0;
|
|
228
|
+
if (query) {
|
|
229
|
+
const target = normName(query);
|
|
230
|
+
if (target.length > 0) {
|
|
231
|
+
const before = filtered.length;
|
|
232
|
+
filtered = filtered.filter((r) => normName(r.title) === target);
|
|
233
|
+
looseTextHits = before - filtered.length;
|
|
234
|
+
filtersApplied.push("name(normalized exact match)");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (uei) {
|
|
238
|
+
// Always narrow to the exact UEI when supplied (with or without a name
|
|
239
|
+
// query) — a UEI used as free-text `q` is otherwise a loose text hit.
|
|
240
|
+
filtered = filtered.filter((r) => norm(r.ueiSam) === ueiU);
|
|
241
|
+
filtersApplied.push("uei(exact post-filter)");
|
|
242
|
+
}
|
|
243
|
+
if (cage) {
|
|
244
|
+
filtered = filtered.filter((r) => norm(r.cageCode) === cageU);
|
|
245
|
+
filtersApplied.push("cage(exact post-filter)");
|
|
246
|
+
}
|
|
247
|
+
if (classification !== "any") {
|
|
248
|
+
filtered = filtered.filter(
|
|
249
|
+
(r) => (r.classification?.code ?? "") === classification,
|
|
250
|
+
);
|
|
251
|
+
filtersApplied.push(`classification(${classification})`);
|
|
252
|
+
}
|
|
253
|
+
if (activeOnly) {
|
|
254
|
+
filtered = filtered.filter((r) => r.isActive === true);
|
|
255
|
+
filtersApplied.push("activeOnly");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const records = filtered.map((r) => {
|
|
259
|
+
const ueiSam = r.ueiSam ?? null;
|
|
260
|
+
return {
|
|
261
|
+
name: r.title ?? "",
|
|
262
|
+
classification: r.classification?.code ?? null,
|
|
263
|
+
uei: ueiSam,
|
|
264
|
+
cage: r.cageCode ?? null,
|
|
265
|
+
samNumber: r.samNumber ?? null,
|
|
266
|
+
excludingAgency: r.excludingAgency ?? null,
|
|
267
|
+
excludingAgencyDesc: r.excludingAgencyDesc ?? null,
|
|
268
|
+
exclusionType: r.exclusionType ?? r.type?.value ?? null,
|
|
269
|
+
exclusionProgram: r.exclusionProgram ?? null,
|
|
270
|
+
ctCode: r.ctCode ?? null,
|
|
271
|
+
ctCodeDesc: r.ctCodeDesc ?? null,
|
|
272
|
+
isActive: r.isActive ?? null,
|
|
273
|
+
activationDate: r.activationDate ?? null,
|
|
274
|
+
terminationDate: r.terminationDate ?? null,
|
|
275
|
+
address: r.address ?? null,
|
|
276
|
+
// FAPIIS (the official exclusions/responsibility record) lookup URL.
|
|
277
|
+
samFapiisUrl: ueiSam
|
|
278
|
+
? `https://sam.gov/search/?index=ex&q=${encodeURIComponent(ueiSam)}`
|
|
279
|
+
: `https://sam.gov/search/?index=ex&q=${encodeURIComponent(r.title ?? qValue)}`,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const excluded = records.some((r) => r.isActive === true);
|
|
284
|
+
const matchCount = records.length;
|
|
285
|
+
|
|
286
|
+
// truncated when the server total exceeds what a single page returned, OR
|
|
287
|
+
// when we hit the 10k deep-paging ceiling, OR when a post-filter means the
|
|
288
|
+
// fetched page may not contain every match.
|
|
289
|
+
const postFiltered =
|
|
290
|
+
Boolean(query) ||
|
|
291
|
+
Boolean(uei) ||
|
|
292
|
+
Boolean(cage) ||
|
|
293
|
+
classification !== "any";
|
|
294
|
+
const serverTruncated =
|
|
295
|
+
totalElements !== null && rawResults.length < totalElements;
|
|
296
|
+
const hitCap = totalElements !== null && totalElements > SGS_MAX_RECORDS;
|
|
297
|
+
const truncated = serverTruncated || hitCap || postFiltered;
|
|
298
|
+
|
|
299
|
+
const notes: string[] = [NOT_PROOF_NOTE];
|
|
300
|
+
notes.push(
|
|
301
|
+
"Exclusion screening is only as precise as the name/UEI/CAGE you pass. A name match is NOT identity-proof — confirm the UEI/CAGE, exclusion type, and dates against the FAPIIS record (samFapiisUrl) before acting on a hit.",
|
|
302
|
+
);
|
|
303
|
+
if (query && looseTextHits > 0) {
|
|
304
|
+
notes.push(
|
|
305
|
+
`SAM's free-text search returned ${looseTextHits} more record(s) sharing a word with "${query}" but NOT matching the normalized firm name — those are OTHER entities' exclusions and were dropped. \`excluded\` reflects ONLY records whose normalized name matches your query. Because this checked one page of text hits, a match under a name VARIANT could sit on a later page — if in doubt, raise \`size\` or verify the firm's UEI via samFapiisUrl.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (postFiltered) {
|
|
309
|
+
notes.push(
|
|
310
|
+
"A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true match count for the combined filter may exceed this page. Narrow with a more specific `query` or raise `size`.",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
if (hitCap) {
|
|
314
|
+
notes.push(
|
|
315
|
+
`SAM caps deep paging at ${SGS_MAX_RECORDS.toLocaleString()} records; this query is too broad to enumerate fully — narrow the query.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (uei && !query) {
|
|
319
|
+
notes.push(
|
|
320
|
+
"You passed a UEI as the sole selector; it was used as the free-text `q` (the frontend exclusions index has no dedicated UEI field), so a match is a text hit, not a keyed UEI lookup — verify the returned uei equals the one you searched.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return withMeta(
|
|
325
|
+
{
|
|
326
|
+
excluded,
|
|
327
|
+
matchCount,
|
|
328
|
+
records,
|
|
329
|
+
page,
|
|
330
|
+
size,
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
source: EXCLUSIONS_SOURCE,
|
|
334
|
+
keylessMode: true,
|
|
335
|
+
returned: records.length,
|
|
336
|
+
totalAvailable: totalElements,
|
|
337
|
+
truncated,
|
|
338
|
+
pagination: {
|
|
339
|
+
offset: page * size,
|
|
340
|
+
limit: size,
|
|
341
|
+
nextOffset: serverTruncated ? (page + 1) * size : null,
|
|
342
|
+
hasMore: serverTruncated,
|
|
343
|
+
},
|
|
344
|
+
filtersApplied,
|
|
345
|
+
filtersDropped,
|
|
346
|
+
fieldsUnavailable: [],
|
|
347
|
+
notes,
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── 1b. sam_integrity_lookup (keyless composition) ──────────────
|
|
353
|
+
|
|
354
|
+
const INTEGRITY_SOURCE =
|
|
355
|
+
"sam.gov exclusions (keyless) + FAPIIS/Responsibility-Qualification deep-link (record-level key-gated)";
|
|
356
|
+
|
|
357
|
+
/** The canonical, currently-resolving FAPIIS landing page (fapiis.gov 301s here). */
|
|
358
|
+
const FAPIIS_CONTENT_URL = "https://sam.gov/content/fapiis";
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The mandatory disclosure attached to EVERY integrity-lookup response. Kept as
|
|
362
|
+
* a constant so smoke/edge/fault tests can assert it verbatim. It states, in
|
|
363
|
+
* order: (1) what keyless data this covers, (2) that FAPIIS
|
|
364
|
+
* Responsibility/Qualification records have NO keyless machine API, and (3) that
|
|
365
|
+
* `review_fapiis` is therefore NOT a clean bill of health.
|
|
366
|
+
*/
|
|
367
|
+
const INTEGRITY_FAPIIS_NOTE =
|
|
368
|
+
"FAPIIS / Responsibility-Qualification records are publicly VIEWABLE at the linked SAM page but have no keyless machine API — record-level retrieval requires an optional SAM Entity key. This lookup covers the keyless government-wide EXCLUSION list only; it is not a full integrity clearance.";
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Keyless one-call integrity screen — "any integrity red flags on this entity?"
|
|
372
|
+
*
|
|
373
|
+
* Composes the KEYLESS exclusion verdict (via {@link checkExclusions}, REUSED —
|
|
374
|
+
* exclusion fetching is not re-implemented here) with an HONEST pointer to the
|
|
375
|
+
* FAPIIS / Responsibility-Qualification record, which has NO keyless machine
|
|
376
|
+
* API. Requires at least one of `uei`/`cage`/`name` (uei preferred); `name`
|
|
377
|
+
* maps to the exclusion tool's `query`.
|
|
378
|
+
*
|
|
379
|
+
* TRUTHFULNESS (doc 07 §2.2):
|
|
380
|
+
* - `integrityFlag` is `"excluded"` when ≥1 ACTIVE matching exclusion is found,
|
|
381
|
+
* else `"review_fapiis"`. It NEVER emits `"clear"` keylessly — terminations
|
|
382
|
+
* for default/cause, non-responsibility determinations, and self-reported
|
|
383
|
+
* criminal/civil/administrative proceedings live in FAPIIS, which is not
|
|
384
|
+
* machine-readable without a key, so the absence of an exclusion does NOT
|
|
385
|
+
* prove integrity.
|
|
386
|
+
* - `fapiisRecords` is ALWAYS `null` (never faked), with
|
|
387
|
+
* `_meta.fieldsUnavailable: ["fapiisRecords"]` + INTEGRITY_FAPIIS_NOTE.
|
|
388
|
+
* - An upstream `checkExclusions` failure PROPAGATES as the classified error
|
|
389
|
+
* (the ToolErrorCarrier bubbles) — it is never masked as a "clear"/empty.
|
|
390
|
+
*/
|
|
391
|
+
export async function integrityLookup(args: {
|
|
392
|
+
uei?: string;
|
|
393
|
+
cage?: string;
|
|
394
|
+
name?: string;
|
|
395
|
+
}) {
|
|
396
|
+
const uei = args.uei?.trim() || undefined;
|
|
397
|
+
const cage = args.cage?.trim() || undefined;
|
|
398
|
+
const name = args.name?.trim() || undefined;
|
|
399
|
+
|
|
400
|
+
// At least one identifier is required — an identity-less integrity screen is
|
|
401
|
+
// meaningless. Mirror checkExclusions' structured invalid_input.
|
|
402
|
+
if (!uei && !cage && !name) {
|
|
403
|
+
throw new ToolErrorCarrier({
|
|
404
|
+
kind: "invalid_input",
|
|
405
|
+
message:
|
|
406
|
+
"sam_integrity_lookup requires at least one of uei, cage, or name (uei preferred). Pass the entity's UEI/CAGE and/or legal name to screen.",
|
|
407
|
+
retryable: false,
|
|
408
|
+
upstreamEndpoint: "sgs/v1/search?index=ex",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// REUSE checkExclusions for the keyless exclusion verdict (map name→query).
|
|
413
|
+
// A failure here throws a classified ToolErrorCarrier that bubbles to the
|
|
414
|
+
// dispatcher — we never swallow it into a fake "clear".
|
|
415
|
+
const exclusionsRes = await checkExclusions({
|
|
416
|
+
query: name,
|
|
417
|
+
uei,
|
|
418
|
+
cage,
|
|
419
|
+
activeOnly: true,
|
|
420
|
+
});
|
|
421
|
+
const ex = exclusionsRes.data;
|
|
422
|
+
const excluded = ex.excluded === true;
|
|
423
|
+
|
|
424
|
+
// integrityFlag: "excluded" iff an ACTIVE matching exclusion was found; else
|
|
425
|
+
// "review_fapiis". NEVER "clear" — absence of an exclusion is not proof of
|
|
426
|
+
// integrity (the FAPIIS responsibility record is key-gated below).
|
|
427
|
+
const integrityFlag: "excluded" | "review_fapiis" = excluded
|
|
428
|
+
? "excluded"
|
|
429
|
+
: "review_fapiis";
|
|
430
|
+
|
|
431
|
+
// Human deep-links: the always-valid FAPIIS content page, plus an
|
|
432
|
+
// entity-workspace Responsibility/Qualification deep-link when a UEI is known.
|
|
433
|
+
// These are NOT fetched by the tool — they are pointers for a human/agent.
|
|
434
|
+
const fapiisUrl = uei
|
|
435
|
+
? `https://sam.gov/workspace/profile/${encodeURIComponent(uei)}/responsibilityInformation`
|
|
436
|
+
: FAPIIS_CONTENT_URL;
|
|
437
|
+
|
|
438
|
+
const data = {
|
|
439
|
+
entity: {
|
|
440
|
+
name: name ?? null,
|
|
441
|
+
uei: uei ?? null,
|
|
442
|
+
cage: cage ?? null,
|
|
443
|
+
},
|
|
444
|
+
exclusions: {
|
|
445
|
+
excluded,
|
|
446
|
+
activeCount: ex.matchCount,
|
|
447
|
+
records: ex.records,
|
|
448
|
+
},
|
|
449
|
+
// KEY-GATED — never faked. Record-level FAPIIS retrieval needs a SAM Entity
|
|
450
|
+
// key; keyless we can only point at the viewable page.
|
|
451
|
+
fapiisRecords: null,
|
|
452
|
+
fapiisContentUrl: FAPIIS_CONTENT_URL,
|
|
453
|
+
fapiisUrl,
|
|
454
|
+
integrityFlag,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const notes: string[] = [];
|
|
458
|
+
// Carry through the exclusion tool's own honesty first.
|
|
459
|
+
notes.push(NOT_PROOF_NOTE);
|
|
460
|
+
// Then the FAPIIS key-gating disclosure.
|
|
461
|
+
notes.push(INTEGRITY_FAPIIS_NOTE);
|
|
462
|
+
if (integrityFlag === "review_fapiis") {
|
|
463
|
+
notes.push(
|
|
464
|
+
"integrityFlag is 'review_fapiis' (NEVER 'clear' in keyless mode): no ACTIVE government-wide exclusion matched these identifiers, but that does NOT establish responsibility — review the FAPIIS / Responsibility-Qualification record at fapiisUrl (terminations for default/cause, non-responsibility determinations, and self-reported proceedings are not machine-readable keylessly).",
|
|
465
|
+
);
|
|
466
|
+
} else {
|
|
467
|
+
notes.push(
|
|
468
|
+
"integrityFlag is 'excluded' — at least one ACTIVE government-wide exclusion matched these identifiers (see exclusions.records). Confirm the UEI/CAGE, exclusion type, and dates against the FAPIIS record before acting; a name match is not identity-proof.",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (!uei) {
|
|
472
|
+
notes.push(
|
|
473
|
+
"No UEI was supplied, so fapiisUrl points at the general FAPIIS page rather than the entity's Responsibility/Qualification profile — pass the UEI for an entity-specific deep-link and a keyed exclusion match.",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return withMeta(data, {
|
|
478
|
+
source: INTEGRITY_SOURCE,
|
|
479
|
+
keylessMode: true,
|
|
480
|
+
// A single composite verdict record — not a paged list.
|
|
481
|
+
returned: 1,
|
|
482
|
+
totalAvailable: 1,
|
|
483
|
+
// The exclusion verdict itself is complete for these terms; the FAPIIS
|
|
484
|
+
// record dimension is declared unavailable (not truncated).
|
|
485
|
+
truncated: false,
|
|
486
|
+
filtersApplied: [],
|
|
487
|
+
filtersDropped: [],
|
|
488
|
+
// FAPIIS record-level content is unavailable keylessly — declared, not faked.
|
|
489
|
+
fieldsUnavailable: ["fapiisRecords"],
|
|
490
|
+
notes,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ─── 2. usas_search_teaming_partners ─────────────────────────────
|
|
495
|
+
|
|
496
|
+
const TEAMING_SOURCE =
|
|
497
|
+
"usaspending (award-derived socioeconomic proxy, keyless)";
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The `recipient_type_names` vocabulary CONFIRMED live (2026-07-03) to narrow a
|
|
501
|
+
* known-populated NAICS (541512, 2023+) to a plausible non-zero, non-baseline
|
|
502
|
+
* count — the server SILENTLY accepts a bogus value and returns 0 with HTTP
|
|
503
|
+
* 200, so this allow-list is the guardrail. The server's Zod enum mirrors this
|
|
504
|
+
* set; this runtime re-check is defense-in-depth so a bad value can never yield
|
|
505
|
+
* a confident-empty list.
|
|
506
|
+
*
|
|
507
|
+
* Verified counts (NAICS 541512, action_date ≥ 2023-01-01):
|
|
508
|
+
* small_business ....................................... 11539
|
|
509
|
+
* 8a_program_participant ............................... 4902
|
|
510
|
+
* woman_owned_business ................................. 3251
|
|
511
|
+
* veteran_owned_business ............................... 2805
|
|
512
|
+
* service_disabled_veteran_owned_business ............. 2450
|
|
513
|
+
* women_owned_small_business .......................... 1931
|
|
514
|
+
* economically_disadvantaged_women_owned_small_business 1192
|
|
515
|
+
* historically_underutilized_business_firm (HUBZone) .. 1025
|
|
516
|
+
*/
|
|
517
|
+
export const VERIFIED_CERTS = [
|
|
518
|
+
"small_business",
|
|
519
|
+
"8a_program_participant",
|
|
520
|
+
"woman_owned_business",
|
|
521
|
+
"women_owned_small_business",
|
|
522
|
+
"economically_disadvantaged_women_owned_small_business",
|
|
523
|
+
"service_disabled_veteran_owned_business",
|
|
524
|
+
"veteran_owned_business",
|
|
525
|
+
"historically_underutilized_business_firm",
|
|
526
|
+
] as const;
|
|
527
|
+
|
|
528
|
+
export type VerifiedCert = (typeof VERIFIED_CERTS)[number];
|
|
529
|
+
|
|
530
|
+
const TEAMING_PROXY_NOTE =
|
|
531
|
+
"cert reflects socioeconomic categories recorded on the firm's federal awards, NOT the current SBA certification of record (which requires a SAM Entity key) — verify active certification in SAM/SBS before teaming.";
|
|
532
|
+
|
|
533
|
+
/** True total for a spending_by_award query via the companion count endpoint. */
|
|
534
|
+
async function teamingAwardCount(
|
|
535
|
+
filters: Record<string, unknown>,
|
|
536
|
+
): Promise<number | null> {
|
|
537
|
+
try {
|
|
538
|
+
type CountResp = { results?: Record<string, number> };
|
|
539
|
+
const json = await postUsas<CountResp>("search/spending_by_award_count/", {
|
|
540
|
+
filters,
|
|
541
|
+
subawards: false,
|
|
542
|
+
});
|
|
543
|
+
const results = json.results;
|
|
544
|
+
if (!results) return null;
|
|
545
|
+
return Object.values(results).reduce(
|
|
546
|
+
(s, v) => s + (typeof v === "number" ? v : 0),
|
|
547
|
+
0,
|
|
548
|
+
);
|
|
549
|
+
} catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
type TeamingAwardRow = {
|
|
555
|
+
"Award ID"?: string | null;
|
|
556
|
+
"Recipient Name"?: string | null;
|
|
557
|
+
"Award Amount"?: number | null;
|
|
558
|
+
"Awarding Agency"?: string | null;
|
|
559
|
+
"Awarding Sub Agency"?: string | null;
|
|
560
|
+
NAICS?: { code?: string; description?: string } | null;
|
|
561
|
+
recipient_id?: string | null;
|
|
562
|
+
"Start Date"?: string | null;
|
|
563
|
+
"End Date"?: string | null;
|
|
564
|
+
// The award's BASE obligation date = when it was first awarded/obligated (the real
|
|
565
|
+
// "award date"). Always in the past; unlike "End Date" (PoP end, often FUTURE for
|
|
566
|
+
// ongoing contracts) it is the correct source for a recency signal.
|
|
567
|
+
"Base Obligation Date"?: string | null;
|
|
568
|
+
generated_internal_id?: string | null;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
type TeamingSearchResp = {
|
|
572
|
+
results?: TeamingAwardRow[];
|
|
573
|
+
page_metadata?: { hasNext?: boolean; page?: number };
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Small-business teaming-partner discovery by socioeconomic certification +
|
|
578
|
+
* NAICS + agency award history (keyless USAspending `spending_by_award`
|
|
579
|
+
* proxy), integrity-screened.
|
|
580
|
+
*
|
|
581
|
+
* MECHANISM: query `spending_by_award` filtered by `recipient_type_names:[cert]`
|
|
582
|
+
* (+ optional naics/agency/subagency + an action_date lookback), page a bounded
|
|
583
|
+
* number of award rows, then AGGREGATE client-side by `recipient_id`
|
|
584
|
+
* (spending_by_award is NOT pre-grouped by recipient — one firm spans many
|
|
585
|
+
* rows). Each candidate carries agencyAwardCount + agencyObligated +
|
|
586
|
+
* mostRecentAwardDate + sampleAwards, ranked by agencyObligated desc, with
|
|
587
|
+
* `minAwards` applied AFTER aggregation.
|
|
588
|
+
*
|
|
589
|
+
* INTEGRITY: when `excludeDebarred`, the top candidates (bounded by
|
|
590
|
+
* `screenCap`) are screened via checkExclusions and flagged/dropped on an
|
|
591
|
+
* ACTIVE exclusion. The screen is bounded and DISCLOSED (how many screened /
|
|
592
|
+
* removed / whether the screen was capped).
|
|
593
|
+
*
|
|
594
|
+
* HONESTY: the cert is AWARD-DERIVED, not the SBA registry of record
|
|
595
|
+
* (TEAMING_PROXY_NOTE, always in _meta). A bogus cert never reaches the network
|
|
596
|
+
* — it is rejected as invalid_input (the endpoint would silently return 0).
|
|
597
|
+
*/
|
|
598
|
+
export async function searchTeamingPartners(args: {
|
|
599
|
+
cert: string;
|
|
600
|
+
naics?: string;
|
|
601
|
+
agency?: string;
|
|
602
|
+
subagency?: string;
|
|
603
|
+
lookbackYears?: number;
|
|
604
|
+
excludeDebarred?: boolean;
|
|
605
|
+
minAwards?: number;
|
|
606
|
+
limit?: number;
|
|
607
|
+
page?: number;
|
|
608
|
+
screenCap?: number;
|
|
609
|
+
scanPages?: number;
|
|
610
|
+
}) {
|
|
611
|
+
// --- Guardrail: the cert MUST be a verified value (defense-in-depth over
|
|
612
|
+
// the server's Zod enum). A bogus value would be SILENTLY accepted by the
|
|
613
|
+
// endpoint (HTTP 200, 0 results) — reject it loudly instead. ----------------
|
|
614
|
+
const cert = args.cert;
|
|
615
|
+
if (!(VERIFIED_CERTS as readonly string[]).includes(cert)) {
|
|
616
|
+
throw new ToolErrorCarrier({
|
|
617
|
+
kind: "invalid_input",
|
|
618
|
+
message: `Unknown socioeconomic cert '${cert}'. USAspending SILENTLY accepts an unrecognized recipient_type_names value and returns 0 results with HTTP 200, so an unverified value would yield a confident-but-empty list. Use one of: ${VERIFIED_CERTS.join(", ")}.`,
|
|
619
|
+
retryable: false,
|
|
620
|
+
upstreamEndpoint: "search/spending_by_award",
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const lookbackYears = Math.min(
|
|
625
|
+
20,
|
|
626
|
+
Math.max(1, Math.floor(args.lookbackYears ?? 3)),
|
|
627
|
+
);
|
|
628
|
+
const excludeDebarred = args.excludeDebarred ?? true;
|
|
629
|
+
const minAwards = Math.max(1, Math.floor(args.minAwards ?? 1));
|
|
630
|
+
const limit = Math.min(50, Math.max(1, Math.floor(args.limit ?? 25)));
|
|
631
|
+
const page = Math.max(1, Math.floor(args.page ?? 1));
|
|
632
|
+
const screenCap = Math.min(25, Math.max(1, Math.floor(args.screenCap ?? 10)));
|
|
633
|
+
const scanPages = Math.min(10, Math.max(1, Math.floor(args.scanPages ?? 4)));
|
|
634
|
+
|
|
635
|
+
// --- Build filters (only what we can send truthfully) ---------------------
|
|
636
|
+
const nowMs = Date.now();
|
|
637
|
+
const startDate = new Date(nowMs);
|
|
638
|
+
startDate.setUTCFullYear(startDate.getUTCFullYear() - lookbackYears);
|
|
639
|
+
const startIso = startDate.toISOString().slice(0, 10);
|
|
640
|
+
const todayIso = new Date(nowMs).toISOString().slice(0, 10);
|
|
641
|
+
|
|
642
|
+
const filters: Record<string, unknown> = {
|
|
643
|
+
award_type_codes: ["A", "B", "C", "D"],
|
|
644
|
+
recipient_type_names: [cert],
|
|
645
|
+
time_period: [{ start_date: startIso, end_date: todayIso }],
|
|
646
|
+
};
|
|
647
|
+
const filtersApplied: string[] = [
|
|
648
|
+
`cert(${cert})`,
|
|
649
|
+
`actionDateLookback(${lookbackYears}y)`,
|
|
650
|
+
];
|
|
651
|
+
const filtersDropped: string[] = [];
|
|
652
|
+
if (args.naics) {
|
|
653
|
+
filters.naics_codes = [args.naics];
|
|
654
|
+
filtersApplied.push("naics");
|
|
655
|
+
}
|
|
656
|
+
// agency + optional subagency. `agencies` accepts a toptier `name` and,
|
|
657
|
+
// alongside it, a subtier entry when a subagency is given.
|
|
658
|
+
if (args.agency) {
|
|
659
|
+
const agencies: Record<string, unknown>[] = [
|
|
660
|
+
{ type: "awarding", tier: "toptier", name: args.agency },
|
|
661
|
+
];
|
|
662
|
+
if (args.subagency) {
|
|
663
|
+
agencies.push({ type: "awarding", tier: "subtier", name: args.subagency });
|
|
664
|
+
}
|
|
665
|
+
filters.agencies = agencies;
|
|
666
|
+
filtersApplied.push("agency");
|
|
667
|
+
if (args.subagency) filtersApplied.push("subagency");
|
|
668
|
+
} else if (args.subagency) {
|
|
669
|
+
// A subagency without a parent agency is ambiguous on this endpoint — do
|
|
670
|
+
// not send it silently; disclose it was dropped.
|
|
671
|
+
filtersDropped.push("subagency(requires agency)");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const fields = [
|
|
675
|
+
"Award ID",
|
|
676
|
+
"Recipient Name",
|
|
677
|
+
"Award Amount",
|
|
678
|
+
"Awarding Agency",
|
|
679
|
+
"Awarding Sub Agency",
|
|
680
|
+
"NAICS",
|
|
681
|
+
"recipient_id",
|
|
682
|
+
"Start Date",
|
|
683
|
+
"End Date",
|
|
684
|
+
"Base Obligation Date",
|
|
685
|
+
];
|
|
686
|
+
|
|
687
|
+
// --- Scan a bounded number of award-value-sorted pages, then aggregate by
|
|
688
|
+
// recipient. spending_by_award is NOT grouped by recipient, so a firm spans
|
|
689
|
+
// multiple rows — we roll them up. --------------------------------------
|
|
690
|
+
type Candidate = {
|
|
691
|
+
recipientName: string;
|
|
692
|
+
recipient_id: string | null;
|
|
693
|
+
uei: string | null;
|
|
694
|
+
cert: string;
|
|
695
|
+
naicsMatched: Set<string>;
|
|
696
|
+
agencyAwardCount: number;
|
|
697
|
+
agencyObligated: number;
|
|
698
|
+
mostRecentAwardDate: string | null;
|
|
699
|
+
sampleAwards: {
|
|
700
|
+
awardId: string;
|
|
701
|
+
agency: string;
|
|
702
|
+
amount: number;
|
|
703
|
+
date: string | null;
|
|
704
|
+
}[];
|
|
705
|
+
excluded: boolean | null;
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
const byRecipient = new Map<string, Candidate>();
|
|
709
|
+
let rowsScanned = 0;
|
|
710
|
+
let scanTruncated = false;
|
|
711
|
+
|
|
712
|
+
for (let p = 1; p <= scanPages; p++) {
|
|
713
|
+
const resp = await postUsas<TeamingSearchResp>(
|
|
714
|
+
"search/spending_by_award",
|
|
715
|
+
{
|
|
716
|
+
filters,
|
|
717
|
+
fields,
|
|
718
|
+
sort: "Award Amount",
|
|
719
|
+
order: "desc",
|
|
720
|
+
limit: 100,
|
|
721
|
+
page: p,
|
|
722
|
+
subawards: false,
|
|
723
|
+
},
|
|
724
|
+
);
|
|
725
|
+
const rows = resp.results ?? [];
|
|
726
|
+
for (const row of rows) {
|
|
727
|
+
rowsScanned++;
|
|
728
|
+
const name = row["Recipient Name"] ?? "";
|
|
729
|
+
// Key by recipient_id when present, else fall back to the (uppercased)
|
|
730
|
+
// name so nameless-id rows still aggregate deterministically.
|
|
731
|
+
const key = row.recipient_id ?? `name:${norm(name)}`;
|
|
732
|
+
const amount =
|
|
733
|
+
typeof row["Award Amount"] === "number" ? row["Award Amount"] : 0;
|
|
734
|
+
// LEAD-8 / TEAM-1 fix (Codex dogfood C76): `mostRecentAwardDate` and each
|
|
735
|
+
// sample award's `date` are the AWARD date — sourced from Base Obligation
|
|
736
|
+
// Date (when the award was first obligated, always past — live-verified 40/40
|
|
737
|
+
// populated & 0 future for contract types A/B/C/D), NOT "End Date" (the PoP
|
|
738
|
+
// END, which is FUTURE for ongoing contracts — live-verified 13/40 future,
|
|
739
|
+
// producing "award dates" like 2027-05-16). "End Date" is DELIBERATELY NOT a
|
|
740
|
+
// fallback: it is the only future-capable field, so falling back to it would
|
|
741
|
+
// reintroduce the exact bug on a sparse row. Start Date (PoP start, ~always
|
|
742
|
+
// past) is the sole fallback; when both are absent the award date is unknown
|
|
743
|
+
// (null) rather than a fabricated/future value.
|
|
744
|
+
const date = row["Base Obligation Date"] ?? row["Start Date"] ?? null;
|
|
745
|
+
let c = byRecipient.get(key);
|
|
746
|
+
if (!c) {
|
|
747
|
+
c = {
|
|
748
|
+
recipientName: name,
|
|
749
|
+
recipient_id: row.recipient_id ?? null,
|
|
750
|
+
uei: null, // spending_by_award does not return UEI on the award row.
|
|
751
|
+
cert,
|
|
752
|
+
naicsMatched: new Set<string>(),
|
|
753
|
+
agencyAwardCount: 0,
|
|
754
|
+
agencyObligated: 0,
|
|
755
|
+
mostRecentAwardDate: null,
|
|
756
|
+
sampleAwards: [],
|
|
757
|
+
excluded: null,
|
|
758
|
+
};
|
|
759
|
+
byRecipient.set(key, c);
|
|
760
|
+
}
|
|
761
|
+
c.agencyAwardCount += 1;
|
|
762
|
+
c.agencyObligated += amount;
|
|
763
|
+
if (row.NAICS?.code) c.naicsMatched.add(row.NAICS.code);
|
|
764
|
+
if (date && (c.mostRecentAwardDate === null || date > c.mostRecentAwardDate)) {
|
|
765
|
+
c.mostRecentAwardDate = date;
|
|
766
|
+
}
|
|
767
|
+
if (c.sampleAwards.length < 3) {
|
|
768
|
+
c.sampleAwards.push({
|
|
769
|
+
awardId: row["Award ID"] ?? "",
|
|
770
|
+
agency: row["Awarding Agency"] ?? "",
|
|
771
|
+
amount,
|
|
772
|
+
date,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!resp.page_metadata?.hasNext) break;
|
|
777
|
+
if (p === scanPages && resp.page_metadata?.hasNext) scanTruncated = true;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// --- Rank by obligated desc, apply minAwards, page ------------------------
|
|
781
|
+
const ranked = [...byRecipient.values()]
|
|
782
|
+
.filter((c) => c.agencyAwardCount >= minAwards)
|
|
783
|
+
.sort((a, b) => {
|
|
784
|
+
if (b.agencyObligated !== a.agencyObligated)
|
|
785
|
+
return b.agencyObligated - a.agencyObligated;
|
|
786
|
+
if (b.agencyAwardCount !== a.agencyAwardCount)
|
|
787
|
+
return b.agencyAwardCount - a.agencyAwardCount;
|
|
788
|
+
return a.recipientName.localeCompare(b.recipientName);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
const totalCandidates = ranked.length; // EXACT only when !scanTruncated
|
|
792
|
+
const startIdx = (page - 1) * limit;
|
|
793
|
+
const pageSlice = ranked.slice(startIdx, startIdx + limit);
|
|
794
|
+
|
|
795
|
+
// --- Integrity screen (bounded, disclosed) --------------------------------
|
|
796
|
+
let screenedCount = 0;
|
|
797
|
+
let removedCount = 0;
|
|
798
|
+
let screenFailed = false;
|
|
799
|
+
let screenCapped = false;
|
|
800
|
+
if (excludeDebarred && pageSlice.length > 0) {
|
|
801
|
+
const toScreen = pageSlice.slice(0, screenCap);
|
|
802
|
+
screenCapped = pageSlice.length > screenCap;
|
|
803
|
+
for (const c of toScreen) {
|
|
804
|
+
if (!c.recipientName) continue;
|
|
805
|
+
try {
|
|
806
|
+
const res = await checkExclusions({
|
|
807
|
+
query: c.recipientName,
|
|
808
|
+
activeOnly: true,
|
|
809
|
+
size: 25,
|
|
810
|
+
});
|
|
811
|
+
screenedCount++;
|
|
812
|
+
// PRECISION: `checkExclusions` returns every free-text hit (SAM's `q`
|
|
813
|
+
// tokenizes), so `res.data.excluded` alone is a false-positive trap —
|
|
814
|
+
// it is true if ANY active record shares a word with the firm name.
|
|
815
|
+
// Only flag this candidate excluded when a returned ACTIVE record's
|
|
816
|
+
// NAME actually matches the firm's (normalized). A non-matching hit is
|
|
817
|
+
// someone else's exclusion and must NOT drop a clean partner.
|
|
818
|
+
const target = normName(c.recipientName);
|
|
819
|
+
const nameMatch =
|
|
820
|
+
target.length > 0 &&
|
|
821
|
+
res.data.records.some(
|
|
822
|
+
(rec) => rec.isActive === true && normName(rec.name) === target,
|
|
823
|
+
);
|
|
824
|
+
c.excluded = nameMatch;
|
|
825
|
+
if (nameMatch) removedCount++;
|
|
826
|
+
} catch {
|
|
827
|
+
// A screen failure must NOT be read as "clean" — leave excluded:null
|
|
828
|
+
// and disclose that screening degraded.
|
|
829
|
+
screenFailed = true;
|
|
830
|
+
c.excluded = null;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Materialize the candidate rows (after screening) — drop active exclusions
|
|
836
|
+
// when excludeDebarred, keep everyone otherwise.
|
|
837
|
+
const candidates = pageSlice
|
|
838
|
+
.filter((c) => !(excludeDebarred && c.excluded === true))
|
|
839
|
+
.map((c) => ({
|
|
840
|
+
recipientName: c.recipientName,
|
|
841
|
+
recipient_id: c.recipient_id,
|
|
842
|
+
uei: c.uei,
|
|
843
|
+
cert: c.cert,
|
|
844
|
+
naicsMatched: [...c.naicsMatched],
|
|
845
|
+
agencyAwardCount: c.agencyAwardCount,
|
|
846
|
+
agencyObligated: c.agencyObligated,
|
|
847
|
+
mostRecentAwardDate: c.mostRecentAwardDate,
|
|
848
|
+
sampleAwards: c.sampleAwards,
|
|
849
|
+
excluded: c.excluded,
|
|
850
|
+
}));
|
|
851
|
+
|
|
852
|
+
// --- True total (award count) via the companion endpoint. This is the
|
|
853
|
+
// number of AWARDS, not distinct recipients — the endpoint reports no
|
|
854
|
+
// distinct-recipient total, so recipient totalAvailable stays null. -------
|
|
855
|
+
const awardTotal = await teamingAwardCount(filters);
|
|
856
|
+
|
|
857
|
+
// --- Truthful _meta -------------------------------------------------------
|
|
858
|
+
const notes: string[] = [TEAMING_PROXY_NOTE];
|
|
859
|
+
notes.push(
|
|
860
|
+
`Candidates are aggregated client-side by recipient over a bounded ${scanPages}-page scan (${rowsScanned} award row(s), sorted by award amount desc). agencyObligated/agencyAwardCount reflect the SCANNED rows for this cert×filters slice, not necessarily the firm's entire history.`,
|
|
861
|
+
);
|
|
862
|
+
if (scanTruncated) {
|
|
863
|
+
notes.push(
|
|
864
|
+
`The ${scanPages}-page scan budget was exhausted with more award rows available, so the candidate ranking is a LOWER BOUND (a firm ranked lower here could have more awards on unscanned pages). totalAvailable (distinct recipients) is unknown — narrow with naics/agency/subagency or raise scanPages for a complete ranking.`,
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
if (awardTotal !== null) {
|
|
868
|
+
notes.push(
|
|
869
|
+
`The cert×filters slice covers ${awardTotal} award(s) total (via spending_by_award_count); the ${rowsScanned} scanned row(s) are the highest-value subset. This is an AWARD count, not a distinct-recipient count.`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
if (excludeDebarred) {
|
|
873
|
+
notes.push(
|
|
874
|
+
`Integrity screen: ${screenedCount} of the top ${pageSlice.length} ranked candidate(s) were screened for ACTIVE SAM exclusions${screenCapped ? ` (capped at ${screenCap}; lower-ranked candidates on this page were NOT screened)` : ""}; ${removedCount} with an active exclusion ${removedCount === 1 ? "was" : "were"} dropped. Matching is by NORMALIZED NAME (a firm is flagged only when an active exclusion record's name matches — a shared-word text hit does NOT drop a firm), and because UEI is unavailable on award rows this is a NAME match, not a keyed UEI match — confirm any borderline case in SAM. An unscreened candidate's excluded flag is null (unknown), NOT a clearance.`,
|
|
875
|
+
);
|
|
876
|
+
if (screenFailed) {
|
|
877
|
+
notes.push(
|
|
878
|
+
"At least one exclusion screen FAILED (upstream error) — those candidates show excluded:null and were NOT dropped; re-run to complete screening. A failed screen is not a clean result.",
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
} else {
|
|
882
|
+
notes.push(
|
|
883
|
+
"excludeDebarred is false — candidates were NOT screened for SAM exclusions (`excluded` is null for all). Screen with sam_check_exclusions before teaming.",
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
if (filtersDropped.includes("subagency(requires agency)")) {
|
|
887
|
+
notes.push(
|
|
888
|
+
"A subagency was requested without a parent agency and was NOT applied — pass `agency` (the toptier name) alongside `subagency`.",
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const hasMore = scanTruncated ? true : startIdx + limit < totalCandidates;
|
|
893
|
+
const truncated = hasMore || scanTruncated;
|
|
894
|
+
|
|
895
|
+
return withMeta(
|
|
896
|
+
{
|
|
897
|
+
candidates,
|
|
898
|
+
cert,
|
|
899
|
+
page,
|
|
900
|
+
limit,
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
source: TEAMING_SOURCE,
|
|
904
|
+
keylessMode: true,
|
|
905
|
+
returned: candidates.length,
|
|
906
|
+
// The distinct-recipient total is unknown when the scan truncated; even
|
|
907
|
+
// when complete, the endpoint reports only an AWARD count (awardTotal),
|
|
908
|
+
// not a distinct-recipient total, so recipient totalAvailable is null.
|
|
909
|
+
totalAvailable: null,
|
|
910
|
+
truncated,
|
|
911
|
+
pagination: {
|
|
912
|
+
offset: startIdx,
|
|
913
|
+
limit,
|
|
914
|
+
nextOffset: hasMore ? startIdx + limit : null,
|
|
915
|
+
hasMore,
|
|
916
|
+
},
|
|
917
|
+
filtersApplied,
|
|
918
|
+
filtersDropped,
|
|
919
|
+
// UEI is not returned on the spending_by_award row (needs a recipient
|
|
920
|
+
// profile lookup); the SBA cert of record needs a keyed SAM Entity call.
|
|
921
|
+
fieldsUnavailable: [
|
|
922
|
+
"uei(needs usas_get_recipient_profile)",
|
|
923
|
+
"sbaCertificationOfRecord(needs keyed SAM Entity)",
|
|
924
|
+
],
|
|
925
|
+
notes,
|
|
926
|
+
},
|
|
927
|
+
);
|
|
928
|
+
}
|