@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/gsa-csv.ts
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSA daily-CSV keyless backbone (opt-in) — page-completing enrichment for the
|
|
3
|
+
* fields the keyless SAM HAL list endpoint nulls.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* ----------------
|
|
7
|
+
* `sam_search_opportunities` (keyless HAL) returns a page of notices but nulls
|
|
8
|
+
* each row's naics / setAside / place-of-performance / responseDeadline / type
|
|
9
|
+
* (the list payload omits those VALUES even though the server-side filter
|
|
10
|
+
* honored them). Today the only fill path is `sam_get_opportunity` PER notice —
|
|
11
|
+
* N detail calls to complete an N-row page.
|
|
12
|
+
*
|
|
13
|
+
* The GSA "Contract Opportunities" daily bulk CSV carries all of those fields
|
|
14
|
+
* keyed by NoticeId (the 32-hex id that equals the HAL `_id`). So ONE cached
|
|
15
|
+
* index lookup completes a whole page, and it's a durability hedge if the
|
|
16
|
+
* undocumented HAL list drifts.
|
|
17
|
+
*
|
|
18
|
+
* Source — LIVE-VERIFIED 2026-07-03
|
|
19
|
+
* ---------------------------------
|
|
20
|
+
* https://falextracts.s3.amazonaws.com/Contract Opportunities/datagov/ContractOpportunitiesFullCSV.csv
|
|
21
|
+
* HEAD 200, 225,722,960 bytes (225.7 MB), `last-modified` daily,
|
|
22
|
+
* `accept-ranges: bytes`, 47 columns. Key + HAL-nulled fields (0-indexed):
|
|
23
|
+
* NoticeId@0 (32-hex == HAL _id), Title@1, Type@10, SetASideCode@14,
|
|
24
|
+
* SetASide@15, ResponseDeadLine@16, NaicsCode@17, PopCity@20, PopState@21,
|
|
25
|
+
* PopZip@22, PopCountry@23, Active@24, Description@46.
|
|
26
|
+
*
|
|
27
|
+
* Gold-standard verification (2026-07-03): for a live notice
|
|
28
|
+
* (a4be592da0304872a252980925b9458f) the CSV index's naics/PoP EQUAL what
|
|
29
|
+
* `sam_get_opportunity` (detail) returns — naics 238990, PoP WA / Hoodsport /
|
|
30
|
+
* 98548 all match; setAsideCode 'SBA' equals detail's mapped setAside 'SBA'.
|
|
31
|
+
*
|
|
32
|
+
* The non-negotiable design constraints
|
|
33
|
+
* -------------------------------------
|
|
34
|
+
* 1. OFF BY DEFAULT. No forced 225 MB download. Enabled only via env
|
|
35
|
+
* (`SAM_GOV_CSV_CACHE` = a cache dir, or `SAM_GOV_ENABLE_CSV=1` to use a
|
|
36
|
+
* default dir under the OS temp). Every EXISTING keyless HAL tool is
|
|
37
|
+
* UNCHANGED.
|
|
38
|
+
* 2. BOUNDED RAM. Never hold the 225 MB in memory. Stream the download to a
|
|
39
|
+
* cache FILE on disk. Build a COMPACT index of only the ~11 needed columns
|
|
40
|
+
* (NOT Description@46), keyed by NoticeId (~24 MB for this snapshot's
|
|
41
|
+
* ~78k rows, verified). Persist the compact index to the cache dir and
|
|
42
|
+
* load THAT into a Map; refresh when > 24 h old or the CSV `last-modified`
|
|
43
|
+
* changed.
|
|
44
|
+
* 3. STREAMING RFC-4180 parser, NO new npm dependency. Fields are quoted and
|
|
45
|
+
* some (Title, addresses) embed commas; a state machine handles the
|
|
46
|
+
* in-quote toggle, `""`→`"` escape, and newlines inside quotes. Since we
|
|
47
|
+
* skip Description@46 (the worst offender) we only parse through column 24.
|
|
48
|
+
* 4. TRUTHFUL `_meta` (the product): honest source, freshness (CSV
|
|
49
|
+
* last-modified + index build time), degraded on download/parse failure
|
|
50
|
+
* (never a silent empty), and per-noticeId a notice ABSENT from the
|
|
51
|
+
* current snapshot → `found:false` + nulls + an explicit "not in current
|
|
52
|
+
* CSV snapshot" disclosure, NEVER faked. DISABLED → a structured note
|
|
53
|
+
* explaining how to enable it (not an error, not fake data). A cold-cache
|
|
54
|
+
* first call that triggers the download is slow → disclosed as "warming".
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
58
|
+
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
59
|
+
import { createInterface } from "node:readline";
|
|
60
|
+
import { pipeline } from "node:stream/promises";
|
|
61
|
+
import * as os from "node:os";
|
|
62
|
+
import * as path from "node:path";
|
|
63
|
+
import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
|
|
64
|
+
import { withMeta } from "./meta.js";
|
|
65
|
+
|
|
66
|
+
// ─── Source + config ─────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** The GSA daily bulk CSV (keyless). Space in the path is URL-encoded. */
|
|
69
|
+
export const GSA_CSV_URL =
|
|
70
|
+
"https://falextracts.s3.amazonaws.com/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv";
|
|
71
|
+
|
|
72
|
+
export const GSA_CSV_SOURCE = "gsa.gov daily bulk CSV (keyless)";
|
|
73
|
+
|
|
74
|
+
/** Max noticeIds accepted per batch (completes a `sam_search_opportunities` page). */
|
|
75
|
+
export const MAX_NOTICE_IDS = 100;
|
|
76
|
+
|
|
77
|
+
/** Refresh the cached index when it is older than this (or last-modified drifts). */
|
|
78
|
+
const INDEX_TTL_MS = 24 * 60 * 60 * 1000; // 24 h
|
|
79
|
+
|
|
80
|
+
/** File names inside the cache dir. */
|
|
81
|
+
const RAW_CSV_FILE = "gsa-opportunities.csv";
|
|
82
|
+
const INDEX_FILE = "gsa-notice-index.json";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 0-indexed CSV columns we keep. Description@46 is deliberately SKIPPED (it is
|
|
86
|
+
* the largest field by far and the worst quoting offender — skipping it both
|
|
87
|
+
* caps the index size AND means the streaming parser only has to parse cleanly
|
|
88
|
+
* through column 24 (Active)).
|
|
89
|
+
*/
|
|
90
|
+
const COL = {
|
|
91
|
+
NoticeId: 0,
|
|
92
|
+
Title: 1,
|
|
93
|
+
Type: 10,
|
|
94
|
+
SetASideCode: 14,
|
|
95
|
+
SetASide: 15,
|
|
96
|
+
ResponseDeadLine: 16,
|
|
97
|
+
NaicsCode: 17,
|
|
98
|
+
PopCity: 20,
|
|
99
|
+
PopState: 21,
|
|
100
|
+
PopZip: 22,
|
|
101
|
+
PopCountry: 23,
|
|
102
|
+
Active: 24,
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
/** Highest column index we need — we can stop parsing a record after this. */
|
|
106
|
+
const MAX_COL = COL.Active; // 24
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the opt-in config from the environment.
|
|
110
|
+
*
|
|
111
|
+
* Enablement (in priority order):
|
|
112
|
+
* - `SAM_GOV_CSV_FIXTURE` = path to a LOCAL CSV file → enabled, no network
|
|
113
|
+
* (used by tests and for pinning a pre-downloaded file). The cache dir
|
|
114
|
+
* defaults next to the fixture unless `SAM_GOV_CSV_CACHE` is also set.
|
|
115
|
+
* - `SAM_GOV_CSV_CACHE` = a cache DIRECTORY path → enabled; downloads live.
|
|
116
|
+
* - `SAM_GOV_ENABLE_CSV` truthy (`1`/`true`/`yes`) → enabled with a default
|
|
117
|
+
* cache dir under the OS temp (`<tmp>/mcp-sam-gov-csv`).
|
|
118
|
+
* - otherwise DISABLED (default) — the backbone never touches the network.
|
|
119
|
+
*/
|
|
120
|
+
export type CsvConfig = {
|
|
121
|
+
enabled: boolean;
|
|
122
|
+
cacheDir: string;
|
|
123
|
+
/** A local CSV to index instead of downloading (tests / pinned file). */
|
|
124
|
+
fixturePath: string | null;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export function resolveCsvConfig(env: NodeJS.ProcessEnv = process.env): CsvConfig {
|
|
128
|
+
const fixture = env.SAM_GOV_CSV_FIXTURE?.trim() || null;
|
|
129
|
+
const cacheEnv = env.SAM_GOV_CSV_CACHE?.trim() || null;
|
|
130
|
+
const enableFlag = /^(1|true|yes|on)$/i.test(env.SAM_GOV_ENABLE_CSV?.trim() ?? "");
|
|
131
|
+
|
|
132
|
+
if (fixture) {
|
|
133
|
+
return {
|
|
134
|
+
enabled: true,
|
|
135
|
+
cacheDir: cacheEnv ?? path.join(path.dirname(fixture), ".mcp-sam-gov-csv-cache"),
|
|
136
|
+
fixturePath: fixture,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (cacheEnv) {
|
|
140
|
+
return { enabled: true, cacheDir: cacheEnv, fixturePath: null };
|
|
141
|
+
}
|
|
142
|
+
if (enableFlag) {
|
|
143
|
+
return {
|
|
144
|
+
enabled: true,
|
|
145
|
+
cacheDir: path.join(os.tmpdir(), "mcp-sam-gov-csv"),
|
|
146
|
+
fixturePath: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return { enabled: false, cacheDir: "", fixturePath: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Streaming RFC-4180 parser ───────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse one LOGICAL CSV record's text into fields 0..maxCol (inclusive).
|
|
156
|
+
*
|
|
157
|
+
* A proper state machine (NOT `split(",")`): tracks an in-quote toggle, turns
|
|
158
|
+
* `""` into a single `"`, and treats commas/CR/quotes literally while inside a
|
|
159
|
+
* quoted field. It also reports whether parsing ended INSIDE an open quote —
|
|
160
|
+
* the record-assembler uses that to know a physical newline fell inside a
|
|
161
|
+
* quoted field and the logical record continues on the next line.
|
|
162
|
+
*
|
|
163
|
+
* CRITICAL — we must scan the WHOLE text to get quote parity right, even though
|
|
164
|
+
* we only KEEP fields up to `maxCol`. Once we're past `maxCol` we stop STORING
|
|
165
|
+
* fields (so the giant Description@46 is never materialized — bounded memory),
|
|
166
|
+
* but we KEEP walking the characters to maintain the in-quote toggle. Early-
|
|
167
|
+
* returning at `maxCol` would misjudge a newline that falls inside a LATER
|
|
168
|
+
* quoted field (e.g. an embedded newline in Description@46): the record would
|
|
169
|
+
* be wrongly treated as complete and the field's tail would corrupt the next
|
|
170
|
+
* record. (This was a real bug — the fix is: cap storage, never cap scanning.)
|
|
171
|
+
*/
|
|
172
|
+
export function parseRecordFields(
|
|
173
|
+
text: string,
|
|
174
|
+
maxCol: number = MAX_COL,
|
|
175
|
+
): { fields: string[]; inQuotes: boolean } {
|
|
176
|
+
const fields: string[] = [];
|
|
177
|
+
let field = "";
|
|
178
|
+
let inQuotes = false;
|
|
179
|
+
let col = 0;
|
|
180
|
+
const keeping = () => col <= maxCol;
|
|
181
|
+
for (let i = 0; i < text.length; i++) {
|
|
182
|
+
const c = text[i];
|
|
183
|
+
if (inQuotes) {
|
|
184
|
+
if (c === '"') {
|
|
185
|
+
if (text[i + 1] === '"') {
|
|
186
|
+
if (keeping()) field += '"';
|
|
187
|
+
i++; // consume the escaped quote
|
|
188
|
+
} else {
|
|
189
|
+
inQuotes = false;
|
|
190
|
+
}
|
|
191
|
+
} else if (keeping()) {
|
|
192
|
+
field += c;
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
if (c === '"') {
|
|
196
|
+
inQuotes = true;
|
|
197
|
+
} else if (c === ",") {
|
|
198
|
+
if (keeping()) fields.push(field);
|
|
199
|
+
field = "";
|
|
200
|
+
col++;
|
|
201
|
+
} else if (c === "\r") {
|
|
202
|
+
// Strip stray CR (records are assembled by \n; CRLF → LF handled here).
|
|
203
|
+
} else if (keeping()) {
|
|
204
|
+
field += c;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (keeping()) fields.push(field);
|
|
209
|
+
return { fields, inQuotes };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Assemble physical lines (from readline) into LOGICAL records, correctly
|
|
214
|
+
* re-joining a record whose quoted field contained a newline. Calls `onRecord`
|
|
215
|
+
* with the parsed fields (0..MAX_COL) of each complete logical record.
|
|
216
|
+
*/
|
|
217
|
+
function makeRecordAssembler(onRecord: (fields: string[]) => void) {
|
|
218
|
+
let pending: string | null = null;
|
|
219
|
+
return {
|
|
220
|
+
push(line: string) {
|
|
221
|
+
const candidate = pending === null ? line : pending + "\n" + line;
|
|
222
|
+
const res = parseRecordFields(candidate, MAX_COL);
|
|
223
|
+
if (res.inQuotes) {
|
|
224
|
+
// Newline fell inside a quoted field → keep accumulating.
|
|
225
|
+
pending = candidate;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
pending = null;
|
|
229
|
+
onRecord(res.fields);
|
|
230
|
+
},
|
|
231
|
+
flush() {
|
|
232
|
+
if (pending !== null) {
|
|
233
|
+
const res = parseRecordFields(pending, MAX_COL);
|
|
234
|
+
onRecord(res.fields);
|
|
235
|
+
pending = null;
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Compact index ───────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/** The compact per-notice record we keep in the index (NOT Description). */
|
|
244
|
+
export type NoticeFields = {
|
|
245
|
+
title: string;
|
|
246
|
+
type: string;
|
|
247
|
+
setAsideCode: string;
|
|
248
|
+
setAside: string;
|
|
249
|
+
responseDeadline: string;
|
|
250
|
+
naicsCode: string;
|
|
251
|
+
popCity: string;
|
|
252
|
+
popState: string;
|
|
253
|
+
popZip: string;
|
|
254
|
+
popCountry: string;
|
|
255
|
+
active: string;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** The persisted index file shape. */
|
|
259
|
+
type PersistedIndex = {
|
|
260
|
+
builtAt: string; // ISO — when we built the compact index
|
|
261
|
+
csvLastModified: string | null; // the CSV `last-modified` at download time
|
|
262
|
+
csvBytes: number | null;
|
|
263
|
+
rowCount: number;
|
|
264
|
+
notices: Record<string, NoticeFields>;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/** In-memory loaded index (a Map for O(1) lookup) + its freshness metadata. */
|
|
268
|
+
type LoadedIndex = {
|
|
269
|
+
map: Map<string, NoticeFields>;
|
|
270
|
+
builtAt: string;
|
|
271
|
+
csvLastModified: string | null;
|
|
272
|
+
csvBytes: number | null;
|
|
273
|
+
rowCount: number;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const EMPTY_FIELDS: NoticeFields = {
|
|
277
|
+
title: "",
|
|
278
|
+
type: "",
|
|
279
|
+
setAsideCode: "",
|
|
280
|
+
setAside: "",
|
|
281
|
+
responseDeadline: "",
|
|
282
|
+
naicsCode: "",
|
|
283
|
+
popCity: "",
|
|
284
|
+
popState: "",
|
|
285
|
+
popZip: "",
|
|
286
|
+
popCountry: "",
|
|
287
|
+
active: "",
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** Build a compact NoticeFields from a parsed record's field array. */
|
|
291
|
+
function fieldsFromRecord(rec: string[]): NoticeFields {
|
|
292
|
+
return {
|
|
293
|
+
title: rec[COL.Title] ?? "",
|
|
294
|
+
type: rec[COL.Type] ?? "",
|
|
295
|
+
setAsideCode: rec[COL.SetASideCode] ?? "",
|
|
296
|
+
setAside: rec[COL.SetASide] ?? "",
|
|
297
|
+
responseDeadline: rec[COL.ResponseDeadLine] ?? "",
|
|
298
|
+
naicsCode: rec[COL.NaicsCode] ?? "",
|
|
299
|
+
popCity: rec[COL.PopCity] ?? "",
|
|
300
|
+
popState: rec[COL.PopState] ?? "",
|
|
301
|
+
popZip: rec[COL.PopZip] ?? "",
|
|
302
|
+
popCountry: rec[COL.PopCountry] ?? "",
|
|
303
|
+
active: rec[COL.Active] ?? "",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Stream a CSV file on disk line-by-line and build the compact index. NEVER
|
|
309
|
+
* loads the whole file into memory — readline yields one physical line at a
|
|
310
|
+
* time and the assembler holds at most one in-progress (quote-spanning) record.
|
|
311
|
+
*/
|
|
312
|
+
async function buildIndexFromFile(
|
|
313
|
+
csvPath: string,
|
|
314
|
+
): Promise<{ notices: Record<string, NoticeFields>; rowCount: number }> {
|
|
315
|
+
const notices: Record<string, NoticeFields> = Object.create(null);
|
|
316
|
+
let headerSeen = false;
|
|
317
|
+
let rowCount = 0;
|
|
318
|
+
|
|
319
|
+
const rl = createInterface({
|
|
320
|
+
input: createReadStream(csvPath, { encoding: "utf8" }),
|
|
321
|
+
crlfDelay: Infinity,
|
|
322
|
+
});
|
|
323
|
+
const asm = makeRecordAssembler((rec) => {
|
|
324
|
+
if (!headerSeen) {
|
|
325
|
+
headerSeen = true; // first logical record is the 47-column header
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
rowCount++;
|
|
329
|
+
const noticeId = (rec[COL.NoticeId] ?? "").trim();
|
|
330
|
+
// Only index a well-formed 32-hex NoticeId (equals the HAL `_id`). A
|
|
331
|
+
// malformed key would never match a real lookup — skip it.
|
|
332
|
+
if (!/^[0-9a-f]{32}$/i.test(noticeId)) return;
|
|
333
|
+
notices[noticeId.toLowerCase()] = fieldsFromRecord(rec);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
for await (const line of rl) asm.push(line);
|
|
337
|
+
asm.flush();
|
|
338
|
+
|
|
339
|
+
return { notices, rowCount };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── Download + refresh ──────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
/** HEAD the CSV to read `last-modified` + size without downloading the body. */
|
|
345
|
+
async function headCsv(): Promise<{ lastModified: string | null; bytes: number | null }> {
|
|
346
|
+
try {
|
|
347
|
+
const r = await fetchWithRetry(
|
|
348
|
+
GSA_CSV_URL,
|
|
349
|
+
{ method: "HEAD", signal: AbortSignal.timeout(15_000) },
|
|
350
|
+
"gsa:csv:head",
|
|
351
|
+
);
|
|
352
|
+
const len = r.headers.get("content-length");
|
|
353
|
+
return {
|
|
354
|
+
lastModified: r.headers.get("last-modified"),
|
|
355
|
+
bytes: len ? Number(len) : null,
|
|
356
|
+
};
|
|
357
|
+
} catch {
|
|
358
|
+
// A HEAD failure is non-fatal for staleness checks — treat as "unknown".
|
|
359
|
+
return { lastModified: null, bytes: null };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Stream the 225 MB CSV to a file on disk (bounded RAM — the body is piped
|
|
365
|
+
* straight to a write stream, never buffered). Downloads to a `.tmp` then
|
|
366
|
+
* atomically renames so a crashed download never leaves a truncated cache file.
|
|
367
|
+
*/
|
|
368
|
+
async function downloadCsvToDisk(cacheDir: string): Promise<{ path: string; bytes: number }> {
|
|
369
|
+
await mkdir(cacheDir, { recursive: true });
|
|
370
|
+
const finalPath = path.join(cacheDir, RAW_CSV_FILE);
|
|
371
|
+
const tmpPath = finalPath + ".tmp";
|
|
372
|
+
const r = await fetchWithRetry(
|
|
373
|
+
GSA_CSV_URL,
|
|
374
|
+
{ signal: AbortSignal.timeout(180_000) },
|
|
375
|
+
"gsa:csv:download",
|
|
376
|
+
);
|
|
377
|
+
if (!r.body) {
|
|
378
|
+
throw new ToolErrorCarrier({
|
|
379
|
+
kind: "upstream_unavailable",
|
|
380
|
+
message: "GSA CSV download returned no response body.",
|
|
381
|
+
retryable: true,
|
|
382
|
+
upstreamEndpoint: "gsa:csv:download",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
// `r.body` is a web ReadableStream — pipeline accepts it directly on Node 20+.
|
|
386
|
+
await pipeline(
|
|
387
|
+
r.body as unknown as NodeJS.ReadableStream,
|
|
388
|
+
createWriteStream(tmpPath),
|
|
389
|
+
);
|
|
390
|
+
await rename(tmpPath, finalPath);
|
|
391
|
+
const st = await stat(finalPath);
|
|
392
|
+
return { path: finalPath, bytes: st.size };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Read a persisted index file, or null if absent/unreadable/corrupt. */
|
|
396
|
+
async function readPersistedIndex(cacheDir: string): Promise<PersistedIndex | null> {
|
|
397
|
+
try {
|
|
398
|
+
const raw = await readFile(path.join(cacheDir, INDEX_FILE), "utf8");
|
|
399
|
+
const parsed = JSON.parse(raw) as PersistedIndex;
|
|
400
|
+
if (!parsed || typeof parsed !== "object" || !parsed.notices) return null;
|
|
401
|
+
return parsed;
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Persist the compact index atomically (`.tmp` + rename). */
|
|
408
|
+
async function writePersistedIndex(cacheDir: string, idx: PersistedIndex): Promise<void> {
|
|
409
|
+
await mkdir(cacheDir, { recursive: true });
|
|
410
|
+
const finalPath = path.join(cacheDir, INDEX_FILE);
|
|
411
|
+
const tmpPath = finalPath + ".tmp";
|
|
412
|
+
await writeFile(tmpPath, JSON.stringify(idx), "utf8");
|
|
413
|
+
await rename(tmpPath, finalPath);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Process-lifetime memo of the loaded index + an in-flight refresh guard so
|
|
417
|
+
// concurrent tool calls don't each trigger a 225 MB download.
|
|
418
|
+
let loaded: LoadedIndex | null = null;
|
|
419
|
+
let refreshInFlight: Promise<{ index: LoadedIndex; warmed: boolean }> | null = null;
|
|
420
|
+
|
|
421
|
+
/** Is a persisted index stale (older than TTL, or CSV last-modified drifted)? */
|
|
422
|
+
function isStale(idx: PersistedIndex, head: { lastModified: string | null }): boolean {
|
|
423
|
+
const ageMs = Date.now() - new Date(idx.builtAt).getTime();
|
|
424
|
+
if (!Number.isFinite(ageMs) || ageMs > INDEX_TTL_MS) return true;
|
|
425
|
+
if (
|
|
426
|
+
head.lastModified &&
|
|
427
|
+
idx.csvLastModified &&
|
|
428
|
+
head.lastModified !== idx.csvLastModified
|
|
429
|
+
) {
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function toLoaded(idx: PersistedIndex): LoadedIndex {
|
|
436
|
+
return {
|
|
437
|
+
map: new Map(Object.entries(idx.notices)),
|
|
438
|
+
builtAt: idx.builtAt,
|
|
439
|
+
csvLastModified: idx.csvLastModified,
|
|
440
|
+
csvBytes: idx.csvBytes,
|
|
441
|
+
rowCount: idx.rowCount,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Ensure a fresh index is loaded, (re)building from a fixture or a fresh
|
|
447
|
+
* download as needed. Returns the loaded index and whether THIS call warmed
|
|
448
|
+
* the cache (i.e. paid the slow download/build) so the tool can disclose it.
|
|
449
|
+
*
|
|
450
|
+
* Concurrency: a single in-flight refresh is shared across callers.
|
|
451
|
+
*/
|
|
452
|
+
async function ensureIndex(cfg: CsvConfig): Promise<{ index: LoadedIndex; warmed: boolean }> {
|
|
453
|
+
// ── Fixture mode: index the local file (no network, no staleness/HEAD). ──
|
|
454
|
+
if (cfg.fixturePath) {
|
|
455
|
+
if (loaded) return { index: loaded, warmed: false };
|
|
456
|
+
if (refreshInFlight) return refreshInFlight;
|
|
457
|
+
refreshInFlight = (async () => {
|
|
458
|
+
const st = await stat(cfg.fixturePath as string).catch(() => {
|
|
459
|
+
throw new ToolErrorCarrier({
|
|
460
|
+
kind: "not_found",
|
|
461
|
+
message: `SAM_GOV_CSV_FIXTURE points at a file that does not exist: ${cfg.fixturePath}`,
|
|
462
|
+
retryable: false,
|
|
463
|
+
upstreamEndpoint: "gsa:csv:fixture",
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
const { notices, rowCount } = await buildIndexFromFile(cfg.fixturePath as string);
|
|
467
|
+
const persisted: PersistedIndex = {
|
|
468
|
+
builtAt: new Date().toISOString(),
|
|
469
|
+
csvLastModified: st.mtime.toUTCString(),
|
|
470
|
+
csvBytes: st.size,
|
|
471
|
+
rowCount,
|
|
472
|
+
notices,
|
|
473
|
+
};
|
|
474
|
+
loaded = toLoaded(persisted);
|
|
475
|
+
return { index: loaded, warmed: true };
|
|
476
|
+
})();
|
|
477
|
+
try {
|
|
478
|
+
return await refreshInFlight;
|
|
479
|
+
} finally {
|
|
480
|
+
refreshInFlight = null;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Download mode: reuse a fresh persisted/loaded index; else refresh. ──
|
|
485
|
+
// Fast path: an in-memory index that isn't past its TTL (skip the HEAD).
|
|
486
|
+
if (loaded && Date.now() - new Date(loaded.builtAt).getTime() <= INDEX_TTL_MS) {
|
|
487
|
+
return { index: loaded, warmed: false };
|
|
488
|
+
}
|
|
489
|
+
if (refreshInFlight) return refreshInFlight;
|
|
490
|
+
|
|
491
|
+
refreshInFlight = (async () => {
|
|
492
|
+
const head = await headCsv();
|
|
493
|
+
|
|
494
|
+
// Try the on-disk persisted index first.
|
|
495
|
+
const persisted = await readPersistedIndex(cfg.cacheDir);
|
|
496
|
+
if (persisted && !isStale(persisted, head)) {
|
|
497
|
+
loaded = toLoaded(persisted);
|
|
498
|
+
return { index: loaded, warmed: false };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Stale or missing → download + rebuild (the slow, disclosed path).
|
|
502
|
+
const { path: csvPath, bytes } = await downloadCsvToDisk(cfg.cacheDir);
|
|
503
|
+
const { notices, rowCount } = await buildIndexFromFile(csvPath);
|
|
504
|
+
const fresh: PersistedIndex = {
|
|
505
|
+
builtAt: new Date().toISOString(),
|
|
506
|
+
csvLastModified: head.lastModified,
|
|
507
|
+
csvBytes: head.bytes ?? bytes,
|
|
508
|
+
rowCount,
|
|
509
|
+
notices,
|
|
510
|
+
};
|
|
511
|
+
await writePersistedIndex(cfg.cacheDir, fresh).catch(() => {
|
|
512
|
+
// A failed persist is non-fatal — we still serve the in-memory index this
|
|
513
|
+
// process; it just won't survive a restart. Don't throw.
|
|
514
|
+
});
|
|
515
|
+
loaded = toLoaded(fresh);
|
|
516
|
+
return { index: loaded, warmed: true };
|
|
517
|
+
})();
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
return await refreshInFlight;
|
|
521
|
+
} finally {
|
|
522
|
+
refreshInFlight = null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** TEST-ONLY: drop the process-lifetime memo so a test can re-point the env. */
|
|
527
|
+
export function _resetIndexForTests(): void {
|
|
528
|
+
loaded = null;
|
|
529
|
+
refreshInFlight = null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ─── Non-blocking accessor (for inline search enrichment) ────────
|
|
533
|
+
//
|
|
534
|
+
// `sam_search_opportunities` must stay fast: it must NEVER pay a synchronous
|
|
535
|
+
// 225 MB download to enrich a page. This accessor returns the ALREADY-loaded/
|
|
536
|
+
// fresh index if one is in memory, else returns null IMMEDIATELY — optionally
|
|
537
|
+
// kicking off a background refresh (fire-and-forget) so a LATER search can
|
|
538
|
+
// enrich once the cache warms. It never awaits I/O on the hot path.
|
|
539
|
+
|
|
540
|
+
/** A read-only view of a loaded index for enrichment consumers. */
|
|
541
|
+
export type ReadyIndex = {
|
|
542
|
+
get(noticeId: string): NoticeFields | undefined;
|
|
543
|
+
csvLastModified: string | null;
|
|
544
|
+
indexBuiltAt: string;
|
|
545
|
+
rowCount: number;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/** Wrap a LoadedIndex as the read-only ReadyIndex the enrichment path uses. */
|
|
549
|
+
function toReady(idx: LoadedIndex): ReadyIndex {
|
|
550
|
+
return {
|
|
551
|
+
get: (noticeId: string) => idx.map.get((noticeId ?? "").trim().toLowerCase()),
|
|
552
|
+
csvLastModified: idx.csvLastModified,
|
|
553
|
+
indexBuiltAt: idx.builtAt,
|
|
554
|
+
rowCount: idx.rowCount,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* NON-BLOCKING: return a ready CSV index if one is loaded + fresh, else null.
|
|
560
|
+
*
|
|
561
|
+
* Guarantees for the search hot-path:
|
|
562
|
+
* - Disabled config → null (no work, no network) — caller skips enrichment.
|
|
563
|
+
* - An in-memory index within its TTL → returned synchronously (no HEAD, no
|
|
564
|
+
* download).
|
|
565
|
+
* - No in-memory index (cold) → returns null IMMEDIATELY and, unless a
|
|
566
|
+
* refresh is already in flight, kicks off a background `ensureIndex` (its
|
|
567
|
+
* result is memoized into `loaded` for a subsequent call). The promise is
|
|
568
|
+
* deliberately NOT awaited here and its rejection is swallowed so a failed
|
|
569
|
+
* warm never surfaces on the search path.
|
|
570
|
+
* - An in-memory index PAST its TTL → still returned (stale-but-usable) while
|
|
571
|
+
* a background refresh is kicked off; the caller discloses the age via
|
|
572
|
+
* freshness so a slightly-stale snapshot is honest, never a stall.
|
|
573
|
+
*
|
|
574
|
+
* This never throws — any misconfiguration or I/O error degrades to null.
|
|
575
|
+
*/
|
|
576
|
+
export function tryGetReadyIndex(
|
|
577
|
+
cfg: CsvConfig = resolveCsvConfig(),
|
|
578
|
+
): ReadyIndex | null {
|
|
579
|
+
try {
|
|
580
|
+
if (!cfg.enabled) return null;
|
|
581
|
+
|
|
582
|
+
if (loaded) {
|
|
583
|
+
const ageMs = Date.now() - new Date(loaded.builtAt).getTime();
|
|
584
|
+
// Past TTL (download mode only — a fixture never expires): serve the
|
|
585
|
+
// stale index now, refresh in the background for next time.
|
|
586
|
+
if (!cfg.fixturePath && (!Number.isFinite(ageMs) || ageMs > INDEX_TTL_MS)) {
|
|
587
|
+
kickBackgroundRefresh(cfg);
|
|
588
|
+
}
|
|
589
|
+
return toReady(loaded);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Cold: nothing loaded yet — never block the search. Warm in the
|
|
593
|
+
// background and return null so this call proceeds un-enriched.
|
|
594
|
+
kickBackgroundRefresh(cfg);
|
|
595
|
+
return null;
|
|
596
|
+
} catch {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Fire-and-forget a single shared refresh; swallow errors (never block/throw). */
|
|
602
|
+
function kickBackgroundRefresh(cfg: CsvConfig): void {
|
|
603
|
+
if (refreshInFlight) return;
|
|
604
|
+
// ensureIndex sets/reuses `refreshInFlight` itself; we just make sure its
|
|
605
|
+
// rejection is handled so an unhandledRejection never escapes the warm.
|
|
606
|
+
void ensureIndex(cfg).catch(() => {
|
|
607
|
+
/* a failed background warm is non-fatal — the next call retries */
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ─── Inline enrichment merge (used by sam_search_opportunities) ──
|
|
612
|
+
//
|
|
613
|
+
// Pure, testable merge: given the keyless HAL search page (whose
|
|
614
|
+
// naics/setAside/PoP/deadline/type are null) and a ready CSV index, fill the
|
|
615
|
+
// null fields from the CSV snapshot and report EXACTLY what changed so the
|
|
616
|
+
// caller can rebuild `_meta` truthfully. Notices absent from the snapshot are
|
|
617
|
+
// left untouched and counted. Never fabricates: only null→value, only for a
|
|
618
|
+
// noticeId present in the index.
|
|
619
|
+
|
|
620
|
+
/** One opportunity row as emitted by the keyless sam_search handler. */
|
|
621
|
+
export type SearchOppRow = {
|
|
622
|
+
noticeId: string;
|
|
623
|
+
title?: string | null;
|
|
624
|
+
agency?: string | null;
|
|
625
|
+
solicitationNumber?: string | null;
|
|
626
|
+
responseDeadline?: string | null;
|
|
627
|
+
naics?: string | null;
|
|
628
|
+
setAside?: string | null;
|
|
629
|
+
uiLink?: string | null;
|
|
630
|
+
// Enrichment MAY add these (absent on the un-enriched/disabled shape):
|
|
631
|
+
type?: string | null;
|
|
632
|
+
placeOfPerformance?: SamCsvPlaceOfPerformance | null;
|
|
633
|
+
[k: string]: unknown;
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
/** Place-of-performance shape composed from the CSV Pop* columns. */
|
|
637
|
+
export type SamCsvPlaceOfPerformance = {
|
|
638
|
+
city: string | null;
|
|
639
|
+
state: string | null;
|
|
640
|
+
zip: string | null;
|
|
641
|
+
country: string | null;
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
export type EnrichmentOutcome = {
|
|
645
|
+
/** The page with null fields filled from the CSV where the notice was found. */
|
|
646
|
+
opportunities: SearchOppRow[];
|
|
647
|
+
/** Notices in the page that were present in the CSV snapshot. */
|
|
648
|
+
foundCount: number;
|
|
649
|
+
/** Notices in the page absent from the CSV snapshot (left un-enriched). */
|
|
650
|
+
missingCount: number;
|
|
651
|
+
/** Union of field names filled on ≥1 row (subset of naics/setAside/… ). */
|
|
652
|
+
fieldsFilled: Set<string>;
|
|
653
|
+
/** Snapshot freshness, mirrored into data + _meta by the caller. */
|
|
654
|
+
freshness: {
|
|
655
|
+
csvLastModified: string | null;
|
|
656
|
+
indexBuiltAt: string;
|
|
657
|
+
indexAgeHours: number | null;
|
|
658
|
+
rowCount: number;
|
|
659
|
+
};
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
/** Compose a CSV place-of-performance, or null when every Pop* cell is empty. */
|
|
663
|
+
function popFromFields(f: NoticeFields): SamCsvPlaceOfPerformance | null {
|
|
664
|
+
const city = nn(f.popCity);
|
|
665
|
+
const state = nn(f.popState);
|
|
666
|
+
const zip = nn(f.popZip);
|
|
667
|
+
const country = nn(f.popCountry);
|
|
668
|
+
if (city === null && state === null && zip === null && country === null) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
return { city, state, zip, country };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Fill the keyless search page's null naics/setAside/placeOfPerformance (and
|
|
676
|
+
* responseDeadline/type when currently null) from the ready CSV index.
|
|
677
|
+
*
|
|
678
|
+
* Rules (honesty):
|
|
679
|
+
* - Only a null field is filled — a value already present (e.g. keyed mode,
|
|
680
|
+
* or a HAL row that carried the value) is NEVER overwritten.
|
|
681
|
+
* - A field is filled only when the CSV cell is a real non-empty value (an
|
|
682
|
+
* empty CSV cell stays null — absence ≠ empty).
|
|
683
|
+
* - `type`/`placeOfPerformance` keys are ADDED only when a value is actually
|
|
684
|
+
* filled from the CSV, so a not-in-snapshot row keeps the exact original
|
|
685
|
+
* shape (no spurious null keys).
|
|
686
|
+
* - A noticeId absent from the snapshot is left byte-identical + counted.
|
|
687
|
+
*/
|
|
688
|
+
export function enrichSearchOpportunities(
|
|
689
|
+
opportunities: SearchOppRow[],
|
|
690
|
+
index: ReadyIndex,
|
|
691
|
+
): EnrichmentOutcome {
|
|
692
|
+
const fieldsFilled = new Set<string>();
|
|
693
|
+
let foundCount = 0;
|
|
694
|
+
let missingCount = 0;
|
|
695
|
+
|
|
696
|
+
const enriched = opportunities.map((row) => {
|
|
697
|
+
const rec = index.get(row.noticeId);
|
|
698
|
+
if (!rec) {
|
|
699
|
+
missingCount++;
|
|
700
|
+
return row; // absent from snapshot — untouched, disclosed via counts
|
|
701
|
+
}
|
|
702
|
+
foundCount++;
|
|
703
|
+
const out: SearchOppRow = { ...row };
|
|
704
|
+
|
|
705
|
+
// naics — fill only if currently null/absent.
|
|
706
|
+
if (out.naics == null) {
|
|
707
|
+
const v = nn(rec.naicsCode);
|
|
708
|
+
if (v !== null) {
|
|
709
|
+
out.naics = v;
|
|
710
|
+
fieldsFilled.add("naics");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// setAside — the keyless HAL nulls typeOfSetAside; the CSV's short code
|
|
714
|
+
// (e.g. 'SBA') is the value that matches sam_get_opportunity's setAside.
|
|
715
|
+
if (out.setAside == null) {
|
|
716
|
+
const v = nn(rec.setAsideCode);
|
|
717
|
+
if (v !== null) {
|
|
718
|
+
out.setAside = v;
|
|
719
|
+
fieldsFilled.add("setAside");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// responseDeadline — fill only if currently null.
|
|
723
|
+
if (out.responseDeadline == null) {
|
|
724
|
+
const v = nn(rec.responseDeadline);
|
|
725
|
+
if (v !== null) {
|
|
726
|
+
out.responseDeadline = v;
|
|
727
|
+
fieldsFilled.add("responseDeadline");
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// type — add the key only when the CSV has a real value.
|
|
731
|
+
if (out.type == null) {
|
|
732
|
+
const v = nn(rec.type);
|
|
733
|
+
if (v !== null) {
|
|
734
|
+
out.type = v;
|
|
735
|
+
fieldsFilled.add("type");
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// placeOfPerformance — add the key only when ≥1 Pop* cell is populated.
|
|
739
|
+
if (out.placeOfPerformance == null) {
|
|
740
|
+
const pop = popFromFields(rec);
|
|
741
|
+
if (pop !== null) {
|
|
742
|
+
out.placeOfPerformance = pop;
|
|
743
|
+
fieldsFilled.add("placeOfPerformance");
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const builtAtMs = new Date(index.indexBuiltAt).getTime();
|
|
750
|
+
const indexAgeHours = Number.isFinite(builtAtMs)
|
|
751
|
+
? Math.round(((Date.now() - builtAtMs) / 3_600_000) * 10) / 10
|
|
752
|
+
: null;
|
|
753
|
+
|
|
754
|
+
return {
|
|
755
|
+
opportunities: enriched,
|
|
756
|
+
foundCount,
|
|
757
|
+
missingCount,
|
|
758
|
+
fieldsFilled,
|
|
759
|
+
freshness: {
|
|
760
|
+
csvLastModified: index.csvLastModified,
|
|
761
|
+
indexBuiltAt: index.indexBuiltAt,
|
|
762
|
+
indexAgeHours,
|
|
763
|
+
rowCount: index.rowCount,
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ─── The tool: sam_lookup_notice_fields ──────────────────────────
|
|
769
|
+
|
|
770
|
+
export type LookupResult = {
|
|
771
|
+
noticeId: string;
|
|
772
|
+
found: boolean;
|
|
773
|
+
naicsCode: string | null;
|
|
774
|
+
setAside: string | null;
|
|
775
|
+
setAsideCode: string | null;
|
|
776
|
+
popState: string | null;
|
|
777
|
+
popCity: string | null;
|
|
778
|
+
popZip: string | null;
|
|
779
|
+
popCountry: string | null;
|
|
780
|
+
responseDeadline: string | null;
|
|
781
|
+
type: string | null;
|
|
782
|
+
active: boolean | null;
|
|
783
|
+
title: string | null;
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
/** Map an empty-string CSV cell to null (an absent value ≠ a real empty). */
|
|
787
|
+
function nn(s: string): string | null {
|
|
788
|
+
const t = s.trim();
|
|
789
|
+
return t === "" ? null : t;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** CSV Active is "Yes"/"No" — normalize to a boolean, null if neither. */
|
|
793
|
+
function activeBool(s: string): boolean | null {
|
|
794
|
+
const t = s.trim().toLowerCase();
|
|
795
|
+
if (t === "yes") return true;
|
|
796
|
+
if (t === "no") return false;
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* BATCH keyless enrichment: for each 32-hex noticeId return the HAL-nulled
|
|
802
|
+
* fields (naics / setAside / place-of-performance / responseDeadline / type /
|
|
803
|
+
* active / title) from the cached GSA CSV index — completing a whole
|
|
804
|
+
* `sam_search_opportunities` page in ONE call.
|
|
805
|
+
*
|
|
806
|
+
* Honesty contract (the product):
|
|
807
|
+
* - DISABLED (default) → `data.enabled:false`, every row `found:false`+nulls,
|
|
808
|
+
* and `_meta.notes` explains how to enable the backbone. NOT an error, NOT
|
|
809
|
+
* fake data.
|
|
810
|
+
* - a noticeId ABSENT from the current snapshot → `found:false` + nulls + a
|
|
811
|
+
* disclosure that it is "not in the current CSV snapshot", NEVER faked.
|
|
812
|
+
* - download/parse failure → `degraded` (a structured error is thrown and
|
|
813
|
+
* surfaced by the server as a retryable envelope) — never a silent empty.
|
|
814
|
+
* - `_meta.freshness` carries the CSV `last-modified` + the index build time
|
|
815
|
+
* so the AI knows how stale the snapshot is; a cold-cache first call that
|
|
816
|
+
* triggered the download is disclosed as "warming".
|
|
817
|
+
*/
|
|
818
|
+
export async function lookupNoticeFields(
|
|
819
|
+
args: { noticeIds: string[] },
|
|
820
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
821
|
+
) {
|
|
822
|
+
const requested = Array.isArray(args.noticeIds) ? args.noticeIds : [];
|
|
823
|
+
// Normalize + validate the batch. Cap at MAX_NOTICE_IDS.
|
|
824
|
+
if (requested.length === 0) {
|
|
825
|
+
throw new ToolErrorCarrier({
|
|
826
|
+
kind: "invalid_input",
|
|
827
|
+
message: "sam_lookup_notice_fields requires a non-empty noticeIds array (1..100).",
|
|
828
|
+
retryable: false,
|
|
829
|
+
upstreamEndpoint: "gsa:csv:lookup",
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
if (requested.length > MAX_NOTICE_IDS) {
|
|
833
|
+
throw new ToolErrorCarrier({
|
|
834
|
+
kind: "invalid_input",
|
|
835
|
+
message: `sam_lookup_notice_fields accepts at most ${MAX_NOTICE_IDS} noticeIds per call (got ${requested.length}). Split the page into batches.`,
|
|
836
|
+
retryable: false,
|
|
837
|
+
upstreamEndpoint: "gsa:csv:lookup",
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const cfg = resolveCsvConfig(env);
|
|
842
|
+
|
|
843
|
+
// ── DISABLED (default): a structured "how to enable" note, never fake data. ──
|
|
844
|
+
if (!cfg.enabled) {
|
|
845
|
+
const results: LookupResult[] = requested.map((id) => ({
|
|
846
|
+
noticeId: id,
|
|
847
|
+
found: false,
|
|
848
|
+
naicsCode: null,
|
|
849
|
+
setAside: null,
|
|
850
|
+
setAsideCode: null,
|
|
851
|
+
popState: null,
|
|
852
|
+
popCity: null,
|
|
853
|
+
popZip: null,
|
|
854
|
+
popCountry: null,
|
|
855
|
+
responseDeadline: null,
|
|
856
|
+
type: null,
|
|
857
|
+
active: null,
|
|
858
|
+
title: null,
|
|
859
|
+
}));
|
|
860
|
+
return withMeta(
|
|
861
|
+
{ results, enabled: false, freshness: null },
|
|
862
|
+
{
|
|
863
|
+
source: GSA_CSV_SOURCE,
|
|
864
|
+
keylessMode: true,
|
|
865
|
+
complete: false,
|
|
866
|
+
truncated: false,
|
|
867
|
+
returned: results.length,
|
|
868
|
+
totalAvailable: null,
|
|
869
|
+
filtersApplied: [],
|
|
870
|
+
filtersDropped: [],
|
|
871
|
+
fieldsUnavailable: [
|
|
872
|
+
"naicsCode",
|
|
873
|
+
"setAside",
|
|
874
|
+
"setAsideCode",
|
|
875
|
+
"popState",
|
|
876
|
+
"popCity",
|
|
877
|
+
"popZip",
|
|
878
|
+
"popCountry",
|
|
879
|
+
"responseDeadline",
|
|
880
|
+
"type",
|
|
881
|
+
"active",
|
|
882
|
+
"title",
|
|
883
|
+
],
|
|
884
|
+
notes: [
|
|
885
|
+
"The GSA daily-CSV keyless backbone is DISABLED (default). No data was looked up and NO network download occurred — every result is found:false with null fields (this is NOT 'not found', it means the backbone is off).",
|
|
886
|
+
"To enable it, set the env var SAM_GOV_CSV_CACHE to a writable cache-directory path (e.g. SAM_GOV_CSV_CACHE=/var/cache/mcp-sam-gov), OR set SAM_GOV_ENABLE_CSV=1 to use a default cache dir under the OS temp. On first use (and each daily refresh) the server streams the ~226 MB GSA CSV to disk once and builds a compact on-disk index (~24 MB) — subsequent lookups are instant.",
|
|
887
|
+
"Until enabled, complete a sam_search_opportunities page by calling sam_get_opportunity per noticeId (one detail call each) to read naics/setAside/place-of-performance/deadline/type.",
|
|
888
|
+
],
|
|
889
|
+
},
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── ENABLED: ensure the index, then batch-look-up. ──
|
|
894
|
+
// A download/parse failure throws a classified ToolErrorCarrier (retryable) —
|
|
895
|
+
// the server surfaces it as { ok:false, error } rather than a silent empty.
|
|
896
|
+
let index: LoadedIndex;
|
|
897
|
+
let warmed: boolean;
|
|
898
|
+
try {
|
|
899
|
+
const r = await ensureIndex(cfg);
|
|
900
|
+
index = r.index;
|
|
901
|
+
warmed = r.warmed;
|
|
902
|
+
} catch (e) {
|
|
903
|
+
if (e instanceof ToolErrorCarrier) throw e;
|
|
904
|
+
throw new ToolErrorCarrier({
|
|
905
|
+
kind: "upstream_unavailable",
|
|
906
|
+
message: `GSA CSV backbone failed to build its index: ${(e as Error).message}. The 225 MB CSV download or parse did not complete; retry (the cache may warm on a subsequent call).`,
|
|
907
|
+
retryable: true,
|
|
908
|
+
upstreamEndpoint: "gsa:csv:index",
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
let foundCount = 0;
|
|
913
|
+
let missingCount = 0;
|
|
914
|
+
const results: LookupResult[] = requested.map((rawId) => {
|
|
915
|
+
const key = (rawId ?? "").trim().toLowerCase();
|
|
916
|
+
const rec = /^[0-9a-f]{32}$/i.test(key) ? index.map.get(key) : undefined;
|
|
917
|
+
const f = rec ?? EMPTY_FIELDS;
|
|
918
|
+
const found = rec !== undefined;
|
|
919
|
+
if (found) foundCount++;
|
|
920
|
+
else missingCount++;
|
|
921
|
+
return {
|
|
922
|
+
noticeId: rawId,
|
|
923
|
+
found,
|
|
924
|
+
naicsCode: found ? nn(f.naicsCode) : null,
|
|
925
|
+
setAside: found ? nn(f.setAside) : null,
|
|
926
|
+
setAsideCode: found ? nn(f.setAsideCode) : null,
|
|
927
|
+
popState: found ? nn(f.popState) : null,
|
|
928
|
+
popCity: found ? nn(f.popCity) : null,
|
|
929
|
+
popZip: found ? nn(f.popZip) : null,
|
|
930
|
+
popCountry: found ? nn(f.popCountry) : null,
|
|
931
|
+
responseDeadline: found ? nn(f.responseDeadline) : null,
|
|
932
|
+
type: found ? nn(f.type) : null,
|
|
933
|
+
active: found ? activeBool(f.active) : null,
|
|
934
|
+
title: found ? nn(f.title) : null,
|
|
935
|
+
};
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
const notes: string[] = [];
|
|
939
|
+
notes.push(
|
|
940
|
+
`Enrichment fields are read from the GSA daily bulk CSV snapshot (keyless), keyed by NoticeId. Snapshot last-modified: ${index.csvLastModified ?? "unknown"}; index built: ${index.builtAt} from ${index.rowCount.toLocaleString()} CSV rows.`,
|
|
941
|
+
);
|
|
942
|
+
if (warmed) {
|
|
943
|
+
notes.push(
|
|
944
|
+
"CSV index WARMING (first call this process / daily refresh): the ~226 MB CSV was just streamed to disk and indexed, so THIS call was slow (tens of seconds). Subsequent calls hit the cached on-disk index and return instantly until the daily refresh.",
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
notes.push(
|
|
948
|
+
"setAside is the CSV's human-readable label (e.g. 'Total Small Business Set-Aside (FAR 19.5)'); setAsideCode is the short code (e.g. 'SBA', '8A') that matches sam_get_opportunity's setAside. type is the CSV's procedure-type label (e.g. 'Solicitation', 'Combined Synopsis/Solicitation').",
|
|
949
|
+
);
|
|
950
|
+
if (missingCount > 0) {
|
|
951
|
+
notes.push(
|
|
952
|
+
`${missingCount} of ${requested.length} requested noticeId(s) were NOT in the current CSV snapshot (found:false + null fields) — a snapshot is a point-in-time daily file, so a very new notice (posted after the snapshot) or one dropped from the extract will be absent. This is an explicit "not in current CSV snapshot" disclosure, NOT fabricated data; fall back to sam_get_opportunity for those noticeIds.`,
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
notes.push(
|
|
956
|
+
"The CSV snapshot can lag the live HAL by up to ~24 h; for a field that must be real-time (e.g. a deadline just amended), confirm with sam_get_opportunity.",
|
|
957
|
+
);
|
|
958
|
+
|
|
959
|
+
// Machine-readable freshness object mirrored into `data` (the ResponseMeta
|
|
960
|
+
// type has no typed `freshness` field, and meta.ts is out of scope to change,
|
|
961
|
+
// so we surface freshness structurally here AND in _meta.notes for the AI).
|
|
962
|
+
const builtAtMs = new Date(index.builtAt).getTime();
|
|
963
|
+
const ageHours = Number.isFinite(builtAtMs)
|
|
964
|
+
? Math.round(((Date.now() - builtAtMs) / 3_600_000) * 10) / 10
|
|
965
|
+
: null;
|
|
966
|
+
const freshness = {
|
|
967
|
+
csvLastModified: index.csvLastModified,
|
|
968
|
+
indexBuiltAt: index.builtAt,
|
|
969
|
+
indexAgeHours: ageHours,
|
|
970
|
+
rowCount: index.rowCount,
|
|
971
|
+
warming: warmed,
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
return withMeta(
|
|
975
|
+
{ results, enabled: true, freshness, foundCount, missingCount },
|
|
976
|
+
{
|
|
977
|
+
source: GSA_CSV_SOURCE,
|
|
978
|
+
keylessMode: true,
|
|
979
|
+
// A batch lookup against a known snapshot is "complete" for the ids asked
|
|
980
|
+
// — there is no pagination and nothing was truncated/dropped. Absent ids
|
|
981
|
+
// are disclosed per-row (found:false) + in notes, not via truncation.
|
|
982
|
+
complete: true,
|
|
983
|
+
truncated: false,
|
|
984
|
+
returned: results.length,
|
|
985
|
+
totalAvailable: results.length,
|
|
986
|
+
filtersApplied: ["noticeIds(exact, CSV index)"],
|
|
987
|
+
filtersDropped: [],
|
|
988
|
+
fieldsUnavailable: [],
|
|
989
|
+
notes,
|
|
990
|
+
},
|
|
991
|
+
);
|
|
992
|
+
}
|