Skip to content
← Projects

Ask the Pentagon's paperwork

A plain-English search over the Defense Department's daily contract announcements, built by two of us in four days in February 2025.

Gov Watch is a search box over the contracts the Defense Department announces every working day. on february 24, 2025, that announcement was 16 paragraphs and 16,717 characters, and its first sentence named 27 companies before it got to a verb. so how big should one searchable piece of that page be?

The first paragraph of the Feb. 24, 2025 announcement set as a block of small grey text, with the 27 company names slightly darker. The words 'were awarded a $1,500,000,000' are boxed in green at character 2,428, two thirds of the way down. Labels below: 27 companies, 2,428 characters, then the verb; 3,547 characters, one paragraph.
from the archived defense.gov page for feb. 24, 2025

Public, and impossible to read

every contract on that page is public. reading it is the hard part. there's no table and no search, just prose: company, city, contract number, amount, what it's for, which office signed it. then the next company.

in february 2025 government spending was all over the news, and we wanted to ask this page questions the way you'd ask a person. "which companies got navy ship repair contracts?" "what has the pentagon been buying lately?"

there were two of us. my friend wrote the scraper, the embeddings, the prompt and the first frontend. i reworked the home page around the box, and wrote the API client, the first results component, a streaming search on the backend and the CORS rules. the repo started on february 23 and search worked on the 25th. most of my half landed that evening: "changing frontend of app" at 5:38pm, "adding cors and streaming" at 6:42, and "security" at 9:37, which narrowed CORS from every origin to a short named list.

A box instead of a dashboard

the first frontend, from the 24th, was a dashboard: four stat cards for total contracts, total value, active contractors and average contract, with placeholder numbers in them, and a nav with four links.

a dashboard shows what someone decided to count. the questions we had were specific: who, for what, how much. so on the 25th we stripped it back. my friend cut the nav to one link and made the box dark, with a placeholder that types. i took the stat cards out and rebuilt the search button, so the question is the brightest thing on the page.

Two captures of the GovWatch home page. Feb 24: a navy page with four nav links, a white search box with a bright green Search button, and stat cards reading 142,384 total contracts and $245.6B total value. Live today: a near-black page with one About link, the green serif headline, and a dark search box with a dark, glowing Search button.

the placeholder writes an example question, holds it, erases it and moves to the next. there are five, from "Show me contracts over $10M in California last year" to "Show me aircraft procurement contracts since 2020". it types at 50ms per character, holds for 1,500ms and erases at 30ms per character, so the 51-character first question types for 2.55 seconds, sits for 1.5 and erases in 1.53. all five take about 27 seconds, then it starts over.

the second question: 52 characters, about 5.7 seconds

green is the only accent: brand green for the headline and the company names, and a separate emerald for money. the search button is seven stacked layers: a 1px gradient border, a navy fill, three gradients for depth, an inner green glow and a hover sheen.

seven layers for one button. i stand by every one of them

Everything hangs on the size of a piece

cut the announcements into pieces, turn each piece into a vector, and when someone asks a question, find the pieces closest to it and let a model answer from those.

Two columns of boxes. Each run, one week: listing page, yesterday back six days; daily pages; sections, highlighted green, cut at each centered bold heading; embed with text-embedding-004 into 768 numbers; Pinecone, storing the vector, url, date, heading and first 1,000 characters. Every question: the question; embed with the same model; top 5 closest sections; a prompt that says Based ONLY on the information provided above; the answer from gemini-2.0-flash plus five sources.

each run asks defense.gov's listing for one week, yesterday back six days. the listing isn't plain links: each entry is a custom element, listing-titles-only, with the article URL and the publish date in its attributes, so the scraper reads the attributes and ignores the text. every daily page is titled "Contracts For" plus a date, and the format moves around. "Feb. 24, 2025" has a period and "March 14, 2025" is spelled out, so the scraper tries five formats in order and writes 2025-02-24.

Cut where the page already cuts

there were three candidates for one piece, and february 24 shows why two of them lose.

a whole day is too big. that page put environmental engineering, laser sights for the marines and pharmacy automation for military hospitals in one place. one vector for 16,717 characters of that points nowhere in particular.

a paragraph comes close to one award, but it isn't a clean unit. the 16 paragraphs that day run from a 15-character footnote, "*Small business", to 3,976 characters. the 27-company award is a 3,547-character paragraph on its own. one paragraph is a correction to an announcement from ten days earlier. split by paragraph, the index would hold pieces more than 250 times apart in size, plus a footnote and a correction posing as awards.

Three rows of bars on one 16,717-character scale. One day: a single dashed bar. One paragraph: 16 dashed bars of very different widths, from 15 to 3,976 characters. One section: five green bars for air force 5,198, navy 6,946, army 2,736, uniformed services university 866 and defense health agency 971.

the page already makes a cut that works. every branch or agency gets a centered, bold heading, and everything under it is that branch's awards for the day.

A crop of the archived defense.gov page: the end of the Air Force section, a correction about a $99,000,000 Anduril contract, the centered bold heading NAVY, and the first Navy award, Envision Technology's $249,000,000 contract for Squad Aiming Laser systems.

so the scraper walks every <p> in the article body. if it's centered and has a <strong>, it starts a new section named after the heading. otherwise its text joins the current one. on february 24 that gives five sections: air force, navy, army, the uniformed services university and the defense health agency.

each section's id is the article number plus the heading, like 4076032_NAVY. that makes the write idempotent. if a week gets scraped twice, the second run overwrites the same ids instead of adding copies, so nothing has to remember what's already in the index. every week lands in the same Pinecone namespace, contracts, so one search covers every week ever scraped.

The vector reads everything, the model reads the head

each section goes to Gemini's text-embedding-004 whole and comes back as 768 numbers. the embedding call only cuts text past 25,000 characters, and the longest section that day, navy, is 6,946, so every section went in whole. the vector goes into Pinecone with four pieces of metadata: the URL, the date, the heading and the first 1,000 characters of the text.

the vector is built from the whole section, so a question about anything in it can find it. the stored text is what the model will actually read, and it's capped, so five of them always fit in a small prompt.

Five Pinecone records for Feb. 24, each a grey bar for the full section with its first 1,000 characters in green: air force 1,000 of 5,198, noted as all company names with the verb at 2,428; navy 1,000 of 6,946; army 1,000 of 2,736; uniformed services university 866 of 866; defense health agency 971 of 971. Total if all five come back: 4,845 characters.

for february 24 the numbers are easy to check. air force, navy and army are cut to 1,000. the two health sections fit whole, at 866 and 971. if all five came back for one question, the context in the prompt would be 4,845 characters: 4,837 of text and four blank lines between them.

navy's first 1,000 characters hold one complete award and the start of a list.

The first 1,000 characters of the Navy section as stored. The Envision Technology award is complete, with $249,000,000, $74,970 and $52,281,154 highlighted, ending at character 900 with the contracting activity. A green cut mark falls after 'AM Pierce', and the rest of the list continues in faint grey.

the first award runs exactly 900 characters, so the model gets all of it: Envision Technology in Roanoke, $249,000,000 for Squad Aiming Laser systems for the Marine Corps, work in Manchester, New Hampshire, done by february 2030. the list after it is cut at "AM Pierce". army's head does the same: its first award, nine firms competing for orders on a $190,000,000 architect-engineer contract run out of Honolulu, is 947 characters and fits whole.

air force shows the other side of the trade. its first 1,000 characters are company names, because the verb doesn't arrive until character 2,428. a question about environmental engineering still finds that section, since its vector was built from all 5,198 characters. but the model reading the stored text sees about a dozen company names and no amount. that's why every answer carries its source: the date, the heading and a link to the page with the number on it.

Answer only from the five

a question goes through the same embedding model, so it lands in the same space as the sections. Pinecone returns the five closest, and their stored text is joined and dropped into a prompt with the question. if nothing with text comes back, the model never gets called, and the backend answers with one fixed line: "I couldn't find any relevant information about your query in the contracts database."

The prompt from embeddings.py in monospace: the role line, USER QUERY, RELEVANT CONTRACT INFORMATION, then two highlighted lines, 'Based ONLY on the information provided above, please answer the user's query' and 'If the information doesn't contain an answer to the query, say so clearly.' Between them, four format rules, a two-contract example and a list of make-sure rules.

the rule that matters puts its key word in capitals: "Based ONLY on the information provided above". the last line backs it up: "If the information doesn't contain an answer to the query, say so clearly."

the model only reads. if a contract isn't in those five sections, it isn't in the answer. the answer comes from gemini-2.0-flash, and a small, fast model is enough, because its job is to find the relevant awards in under 5,000 characters and list them cleanly.

Every dollar lit by one regex

the model liked to escape dollar signs as \$, which is right for some Markdown renderers and wrong for ours, and it would open bold text without closing it. so in april the prompt grew a format section: complete pairs of double asterisks, plain dollar signs, a blank line between items, three spaces before the amount line, and an example with two contracts.

the dollar-sign rule is in there twice, worded two ways

the strictness is for the frontend, which has no Markdown library. the results component has three rules of its own: a blank line starts a new item, anything in double asterisks turns green and semibold, and anything matching \$[\d,]+(\.\d+)?[MBK]? gets its own emerald. the optional [MBK] at the end is for shorthand like the placeholder's "$10M", so an amount lights up whichever way it's written.

here's the navy award from february 24, written the way the prompt's example asks and played through the live frontend.

live frontend, mock table hidden; answer hand-written in the prompt's format

two amounts light up, $249,000,000 and $52,281,154. the company is green because the answer bolds it, the way the prompt's example does. the source card underneath carries the date and the heading, NAVY, and links back to the real page.

One marker, so any stream renders the same

Three stacked stages. Backend: FastAPI returns JSON with the answer and a sources list holding the date 2025-02-24, the article URL and the section NAVY. lib/api.ts: the same answer flattened to text, then a highlighted '--- Sources ---' line, then one source line with URL, date in parentheses and heading. search-results.tsx: three regexes pull back the url, date and heading, and three rules turn bold into green, dollar amounts into emerald and blank lines into new items.

between the backend and the page, the answer is plain text with one marker in it. the backend returns JSON: the answer, plus a list of sources with a score, URL, date and heading. lib/api.ts flattens that into one block of text, the answer, then a line that says --- Sources ---, then one line per source, and hands the page a stream.

the results component reads the stream chunk by chunk with a TextDecoder and re-parses after every chunk. once it sees the marker, it splits there and pulls the URL, date and heading back out of each source line with three small regexes. because the marker is all it looks for, one chunk or a hundred render the same way. that's what let the same component work with the streaming search i wrote on the backend, search_with_gemini_stream, which yields Gemini's output as it arrives.

each state has its own card. while the request is out, "Searching..." with a spinner, and the button's label says the same. while the stream is read, "Processing Results...". then "Search Results", with the sources under it.

Asking defense.gov nicely

a month in, defense.gov started turning away the scraper's plain requests. the fix was to send what a browser sends: an Accept line, a language, keep-alive and a user agent. the first version claimed to be Chrome on Windows, and only on the listing request. 38 minutes later both the listing and every daily page went out as the one that stuck, and the same commit taught the date parser its other four formats.

Two user-agent strings with their differences highlighted. Mar 21, 6:47pm: Mozilla/5.0, Windows NT 10.0; Win64; x64, Chrome/91. 7:25pm, the one that stuck: Mozilla/5.0, Linux; Android 6.0; Nexus 5 Build/MRA58N, Chrome/134 Mobile Safari. Below: both also sent Accept, Accept-Language en-US, Connection keep-alive and Upgrade-Insecure-Requests 1.
as far as defense.gov could tell, its most dedicated reader of contract pages was a Nexus 5 from 2013

The cut was already there

the model and the vector store came off the shelf, and the frontend took a day. the hard call was the size of a piece, and someone at the Pentagon had already made it: a centered, bold heading over each agency's awards, every working day.

we just cut where they did.