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?


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.


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.
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 themEverything 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.


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.


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.

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.


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 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 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 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.
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


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.


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.