Module 14: The Opportunity Review Assistant
Capstone Phase 1 and 2: Scope, Data Dictionary, and Loading the Batch
Deciding who the assistant is for and what it will refuse to do, surveying the fixture data to write an honest data dictionary, and loading the batch through the client boundary.
Lesson 43 of 46 in the recommended order · About 30 min (estimate)
On this page
Outcome
By the end of this lesson you can state who the Opportunity Review Assistant is for and what it will not do, and you can write a data dictionary from a survey of the actual batch rather than from an assumption about it.
Why it matters
The capstone is one project built across eleven phases, and this lesson covers the first two. Everything downstream depends on the two documents produced here, and neither is code.
The scope document matters most for what it excludes. A tool that shortlists government contracting opportunities is one careless sentence away from sounding like it tells you whether to bid, whether you qualify, or whether you would win. It does none of those things, and saying so plainly, in the project's own documentation, is part of building it responsibly.
The data dictionary matters because every later phase makes a decision that belongs in it. What does an absent set-aside mean? Is a zero amount real? Which fields are required? Answer those once, in writing, and the normalisation, the matching rules, and the tests all agree with each other. Answer them ad hoc in three files and they will not.
Everything runs against the fixtures in this course's capstone/fixtures/ directory. No network, no key, no account.
Concept
Opportunity Review Assistant: the project you will build
Start with phases 1 and 2: Define the scope, load and survey the fixtures, then write the data dictionary.
Write acceptance criteria before implementing the later phases. This is a build map, not evidence that the project is complete.
Load · phase 2
Input- Synthetic stored fixture pages only.
Boundary- Client → injected fixture transport; bounded paging.
Next- Loaded records → normalise and validate.
No live provider, account, credential or network connection is needed.
Validate · phase 3
Input- Loaded records and the field policies you wrote.
Output- Clean records plus errors naming the record and field.
Next- Usable records → matching; errors remain accounted for.
Do not silently treat malformed records as successful input.
Match · phase 4
Input- Normalised records + configured matching rules.
Output- Verdicts with reasons; missing rule inputs remain incomplete.
Next- Pipeline results → local storage and reporting.
A matching verdict supports reading. It is not a bid or eligibility decision.
Store · phase 5
Input- Validated records in a constrained local SQLite schema.
Rerun- Refresh existing records without duplicating them.
Read paths- Stored results support the report and the read-only interface.
Bound query parameters; keep writes in the load, not the interface.
Report · phase 6
Outputs- CSV shortlist + Markdown summary.
Keep visible- Reasons, exclusions and the denominator behind each figure.
Reader- A person reviews the evidence and makes their own decisions.
No procurement, legal, eligibility or award advice; no prediction or submission.
Local interface · phase 9
Read paths- Shortlist, summary and health routes.
Boundary- Validate parameters before querying; no route writes.
Access- Bind to loopback. That is not authentication.
Another way to read pipeline results, not a step that submits the report.
The user story, in one paragraph, names the reader and the decision. For example: a small-business owner reviewing published federal contracting opportunities each week, who wants a shortlist of notices matching their categories and size, with the reason for each verdict, so they can spend their reading time on the ones worth reading.
The non-goals are the more valuable half. At minimum:
- It does not give procurement, legal, eligibility, or award advice.
- It does not decide whether you should bid, or predict any outcome.
- It does not submit anything anywhere.
- It does not ingest restricted, proprietary, or client data. Every record it reads is a synthetic fixture committed alongside the project.
- It does not run unattended against a live service as part of this course.
The data dictionary has one entry per field, with four parts: the source name, one sentence of meaning, the type you store it as, and what your program does when the field is absent or unusable. That fourth part is the one people skip and the one every later phase needs.
Write it after surveying the batch. A field you assumed was always present, and is not, becomes a KeyError in production and a decision in a document.
The acceptance criteria for the whole project are written now, while it is easy to be honest about them. Module 13's rule applies: two people should agree whether each is met without discussing it.
Phase 2, loading the batch, reuses Module 7's design unchanged. The client takes a transport; the fixture transport returns the stored page for the offset it is asked for; the paging loop stops on a short page and is bounded by a hard page cap. Building it this way now is what lets phase 8 test the client's failure paths by handing it a different stored response rather than by rewriting anything.
Read the code
import json
PAGE_1 = json.loads(
'{"totalRecords": 3, "limit": 2, "offset": 0, "results": ['
'{"noticeId": "SYN-2026-0001", "setAside": "Total Small Business",'
' "placeOfPerformance": {"state": "OR"}},'
'{"noticeId": "SYN-2026-0005", "setAside": null,'
' "placeOfPerformance": {"state": "OR"}}]}'
)
PAGE_2 = json.loads(
'{"totalRecords": 3, "limit": 2, "offset": 2, "results": ['
'{"noticeId": "SYN-2026-0007", "setAside": "Total Small Business"}]}'
)
PAGES = {0: PAGE_1, 2: PAGE_2}
def fixture_transport(params):
"""Returns a stored page. Deterministic, offline, no credential."""
page = PAGES.get(params["offset"])
if page is None:
return {"status": 404, "body": None}
return {"status": 200, "body": page}
def load_all(transport, page_size=2, max_pages=5):
"""Return (records, requests, error). Bounded by max_pages."""
records, offset = [], 0
for attempt in range(max_pages):
response = transport({"offset": offset, "limit": page_size})
if response["status"] != 200:
return records, attempt + 1, f"status {response['status']}"
page = response["body"]["results"]
records.extend(page)
if len(page) < page_size:
return records, attempt + 1, None
offset += page_size
return records, max_pages, "hit the page limit"
records, requests, error = load_all(fixture_transport)
print(len(records), requests, error)
print(sorted({key for record in records for key in record}))
print(sum(1 for record in records if "placeOfPerformance" not in record))
The transport returns a 404 for an offset it does not know about, so a paging bug shows up as a failure rather than as a quietly short result.
The last two lines are the survey. The first collects the union of every key seen anywhere, which is the list your data dictionary must cover. The second counts records missing a specific field, which is how "is this optional" gets answered with a number rather than an impression.
Predict the output
Predict the three printed lines.
Check your prediction
3 2 None
['noticeId', 'placeOfPerformance', 'setAside']
1
Three records in two requests, ending on a short page. The union has three keys, and one record has no place of performance at all.
That third line is the data-dictionary decision arriving as evidence. placeOfPerformance is optional in this data, so the dictionary must say what the program does without it, and the matching rules must handle a record whose state is unknown. Nothing in the batch announced this; the survey found it.
Modify the code
Change load_all's page_size to 3 and predict all three lines.
What changes, and why
2 1 None
['noticeId', 'placeOfPerformance', 'setAside']
0
Two records, one request, and now zero records missing the field.
The transport only has pages stored at offsets 0 and 2, so asking for three at offset 0 returns the two-record page, which is short, and the loop stops satisfied. SYN-2026-0007, the record with no place of performance, is never fetched.
Every number changed, nothing failed, and the survey now reports that the field is always present. This is the survivor bias from Module 8 arriving at the very first phase: a data dictionary written from an incompletely loaded batch documents data that does not exist. Confirm your record count against totalRecords before you survey anything.
Debug the bug
A first draft of the project scope reads:
The Opportunity Review Assistant scans SAM.gov for contract opportunities and tells you which ones you are eligible for and most likely to win, so you can focus your bidding effort. It automatically ingests your company profile and past award history to improve its recommendations over time.
What is wrong with this scope
Five problems, and only the last is about software.
"Tells you which ones you are eligible for" is an eligibility determination. Eligibility depends on registrations, certifications, size standards, and requirements the tool does not read and cannot verify. Stating it is both wrong and the kind of wrong that costs somebody real money.
"Most likely to win" is an award prediction. Nothing in this project supports it, and the fixtures contain no outcome data at all.
"Scans SAM.gov" implies a live connection to a real service. This project reads synthetic fixture files; connecting to a real provider is outside the course entirely, so the user story must not promise it.
"Automatically ingests your company profile and past award history" brings real business data into a learning project, which the privacy rules from Module 13 rule out. It also expands the scope well past what eleven phases can deliver.
"Improve its recommendations over time" describes a system that changes its behaviour without anyone deciding to, which is the opposite of the explainable matching this project is built around.
A scope that is defensible:
The Opportunity Review Assistant reads a batch of published contracting opportunity records from a local fixture file, normalises and validates them, applies matching rules the operator configures, and produces a shortlist in which every included and excluded record carries the reason for its verdict. It is a reading aid. It does not give procurement, legal, eligibility, or award advice, does not predict outcomes, does not submit anything, and does not ingest private or restricted data.
Shorter, checkable, and every claim is one the project can actually support.
Try it yourself
Survey a batch the way phase 1 requires: find which fields are present in every record and which are only sometimes there.
Loading this exercise…
Practical challenge (optional)
Optional but strongly recommended, because phase 3 depends on it. Run the survey over the real capstone fixtures, capstone/fixtures/opportunities-page-1.json and opportunities-page-2.json, and write the full data dictionary: every field, its meaning, its stored type, and its behaviour when absent or unusable. Then write the project's user story and at least three non-goals, including the one excluding procurement, legal, eligibility, and award advice. These two documents are the deliverable for phase 1.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- Which four things does a data-dictionary entry record?
- Why must the data dictionary be written after surveying the batch rather than before?
- Name three claims the project's scope must explicitly exclude.
- Why does the fixture transport return
404for an unknown offset rather than an empty page?
Answers
- The source field name, one sentence of meaning, the type you store it as, and what the program does when the field is absent or unusable.
- A dictionary written from expectation documents data you do not have. Fields that turn out to be optional are exactly the ones that later fail, and only a survey finds them.
- Eligibility determinations, award or outcome predictions, and procurement or legal advice. Also acceptable: submitting anything, and ingesting private or restricted data.
- An empty success looks identical to a legitimately empty page, so a paging bug would end the loop quietly with a partial result. A failure status makes the same bug visible.
Sign in to track your progress on this exercise.
Summary and next step
Phase 1 produces a user story, explicit non-goals, a surveyed data dictionary, and acceptance criteria; phase 2 loads the full batch through a transport-injected client bounded by a page cap, and confirms the count against totalRecords before anything is surveyed. Next: turning those raw records into validated ones, and applying matching rules that explain themselves.