Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4661108
feat(ui): rename BD page H1 to a no-score, thesis-consistent title
CJud25 Jul 23, 2026
282a9c6
fix(export): render cited URLs as autolinks so brackets don't backsla…
CJud25 Jul 23, 2026
41766d6
fix(ui): remove dead Planning controls from the Governance page
CJud25 Jul 23, 2026
3b0b123
style(a11y): darken .small-note captions to the AA-passing slate
CJud25 Jul 23, 2026
8653ba0
style(ui): widen the guided-demo Open button so its label no longer c…
CJud25 Jul 23, 2026
826b671
style(ui): collapse raw scan JSON into a "Scan detail (raw)" expander
CJud25 Jul 23, 2026
5130408
style(ui): lead the Cases table with title/location and drop the raw …
CJud25 Jul 23, 2026
eaad7ae
fix(governance): rewrite Decision-boundary bullets + inventory framin…
CJud25 Jul 23, 2026
719257d
docs: annotate removed metrics.py / two-page reduction and expand the…
CJud25 Jul 23, 2026
adffc04
feat(ui): land on the Opportunity Packet tab first and re-cut the tou…
CJud25 Jul 23, 2026
ab34318
fix(ui): reconcile the offline/synthetic contract-facts pull failure …
CJud25 Jul 23, 2026
bae8882
feat(packet): resolve the bundled synthetic PIID into honestly-labele…
CJud25 Jul 23, 2026
23c2118
feat(tracker): bundle a synthetic NIB/NPA sample + offline scan affor…
CJud25 Jul 23, 2026
4dd73cf
fix(packet): detach ACS geography when the vintage year changes (year…
CJud25 Jul 23, 2026
e52cf25
fix(observability): log packet-path internal errors and stop mislabel…
CJud25 Jul 23, 2026
a197784
chore(deploy): add a runtime VOLUME and surface the host-or-not + Win…
CJud25 Jul 23, 2026
dc656fb
ci: add ruff lint (and format check) to the pipeline
CJud25 Jul 23, 2026
78c1877
ci: add pip-audit, a container smoke boot, and a Python 3.12 matrix leg
CJud25 Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,21 @@ permissions:
jobs:
test-and-build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 3.11 is the CI-canonical/numpy 2.4.6 branch; 3.12 exercises the
# numpy>=2.5.1 branch real deployments install (pyproject.toml /
# requirements.txt python_version markers).
python-version: ["3.11", "3.12"]
steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python 3.11
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: |
requirements.txt
Expand All @@ -27,11 +34,55 @@ jobs:
- name: Install development dependencies
run: python -m pip install --disable-pip-version-check -r requirements-dev.txt

- name: Lint (ruff)
run: ruff check .

- name: Run tests
run: python -m pytest -q

- name: Validate deterministic synthetic data
run: python scripts/validate_demo_data.py

# Dependency scan, container build, and the container smoke boot run
# once (the 3.11 leg) -- they exercise the shipped artifact, not the
# interpreter matrix, so duplicating them per Python version is pure
# cost with no extra signal.
- name: Dependency vulnerability scan (pip-audit)
if: matrix.python-version == '3.11'
# TODO(owner): pip-audit currently flags advisories on the pinned
# streamlit==1.46.1 (PYSEC-2026-212/2285) and its transitive pillow
# dependency (PYSEC-2026-165 and others); fixing either requires a
# streamlit major-version bump, which is a runtime-behavior change
# this CI-hardening task does not make unilaterally (563+ tests are
# AppTest-pinned to this exact streamlit version -- see ADR history).
# Non-blocking until the owner triages and bumps the pinned version.
run: pip-audit -r requirements.txt || true

- name: Build demo container
if: matrix.python-version == '3.11'
run: docker build --tag reconradar:ci .

- name: Container smoke boot
if: matrix.python-version == '3.11'
# /_stcore/health is a Streamlit server-liveness endpoint; it does
# NOT execute app.py's script or open the case ledger, so this only
# proves the container boots and serves -- it is not a substitute
# for the Dockerfile VOLUME's writable-ownership review (see the
# honesty note on that change).
run: |
docker run -d --name reconradar-smoke -p 8501:8501 reconradar:ci
ok=0
for i in $(seq 1 15); do
if curl -fsS http://localhost:8501/_stcore/health; then
ok=1
break
fi
sleep 2
done
docker logs reconradar-smoke || true
docker stop reconradar-smoke || true
docker rm reconradar-smoke || true
if [ "$ok" != "1" ]; then
echo "Container did not report healthy within the smoke-boot budget."
exit 1
fi
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
# Changelog

## [Unreleased]

### Changed

- The Opportunity Packet tab is now the landing surface on the BD page (first paint, no manual tab click), and the guided tour's packet beats were re-cut to describe the already-open packet instead of instructing the operator to open a tab it couldn't reach.
- The Privacy & Governance page's "Decision boundaries" and "Data inventory" framing were corrected to describe only shipped capabilities (ADR-024); the page no longer references deleted concepts (site views, source scores, organization scenarios, small-sample shrinkage, the start-stage gate).
- Cited source URLs (packet body and export manifest) render as clean autolinks instead of backslash-escaped Markdown, so a pasted citation resolves correctly.
- Packet-path connector failures log via `logging.getLogger` and no longer mislabel an internal bug as a public-source outage.

### Added

- The bundled synthetic PIID resolves into honestly-labeled, offline SYNTHETIC-example Contract Facts (never `API_RETRIEVED`, never a live award URL), so the offline guided demo reaches a populated flagship moment with no red error (ADR-025).
- A bundled synthetic NIB/NPA workbook sample and an in-app affordance to scan it offline.

## [1.0.0] - 2026-07-22

### Added

- Initial public release of ReconRadar: the cited, score-free Opportunity Packet and public-evidence case tracker for AbilityOne NPA business development.
- **Retrieve/parse API boundary** (ADR-011): every public connector (Census ACS, USAspending, Federal Register) separates a network `retrieve` step from a pure `parse` step, with `Assurance.API_RETRIEVED` provenance and bounded, fail-loud `ConnectorError` mapping.
- **Eligibility Gate** (ADR-013): presents set-aside evidence for the N6 slice as a cited state, never a pursuit verdict.
- **Contract Facts** (ADR-014): live USAspending award-detail facts resolved to an exact single award, with obligated and ceiling values kept as distinct labeled lines.
- **Capture Window** (ADR-015): an honest band anchored to the solicitation, never a manufactured contract-end-date estimate.
- **Incumbent & teaming leads** (ADR-017): a cited packet of leads, never a computed share.
- **Packet export assembler** (ADR-018): a composed, cited export document — Section ledger plus Source manifest — never a re-derivation of the packet's facts.
- **Radar handoff intake** (ADR-019): an uploaded claim, gated onto the packet tab, never auto-trusted.
- **Owner-attested domain constants** (ADR-020): the ODLH/PLDLH-EDLH ratio definitions and related constants, attested by the pilot NPA's compliance coordinator, replacing provisional guesses.
- **Staffing what-if** (ADR-021): an agency-wide ODLH planning indicator computed from analyst-entered hypothetical input, never a determination.
- **R2a determination-support map** (ADR-022): routes citations to the four suitability criteria; it never assesses or scores them.
- **Federal Register PL notices** (ADR-023): a cited national notice-list pull from the Federal Register API, never a claim about the specific contract in the packet.
- **Legacy synthetic feasibility removed** (ADR-016): the "Opportunity Recon" capture board and its `ready_to_pursue_score` formula were deleted before this release, keeping the shipped product to its one, score-free identity.
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --create-home --uid 10001 reconradar
COPY --chown=reconradar:reconradar . .

# The SQLite case ledger lives at data/runtime/tens_hq.sqlite3 (bd_page.py),
# but data/runtime is .dockerignore'd, so it does not exist in the image.
# Create it and hand ownership to the runtime user WHILE STILL ROOT, before
# declaring it a mount point -- a bare VOLUME on a path that does not yet
# exist would make Docker auto-create it root-owned, which uid 10001 could
# not write, silently breaking the ledger.
RUN mkdir -p /app/data/runtime && chown -R reconradar:reconradar /app/data
VOLUME ["/app/data/runtime"]

USER reconradar
EXPOSE 8501

Expand Down
26 changes: 21 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,27 @@ on it. A missing value reads "Not reported" — never $0.00.
.\run_demo.ps1 # provisions a venv and launches the app
```

or manually: `pip install -r requirements.txt` then `streamlit run app.py`
(Python 3.11+). The packet works fully offline on the bundled SYNTHETIC examples;
the four live pulls (USAspending ×2, Census ACS, Federal Register) need internet,
and the ACS pull needs a free Census API key in `TENS_HQ_CENSUS_API_KEY` (the key
rides only the wire request — cited URLs stay keyless by construction).
A default Windows 11 execution policy blocks that script (`.ps1 files are
disabled on this system`). Either run it as:

```powershell
powershell -ExecutionPolicy Bypass -File .\run_demo.ps1
```

or install manually — this also works on any OS: `pip install -r requirements.txt`
then `streamlit run app.py` (Python 3.11+). The packet works fully offline on the
bundled SYNTHETIC examples; the four live pulls (USAspending ×2, Census ACS,
Federal Register) need internet, and the ACS pull needs a free Census API key in
`TENS_HQ_CENSUS_API_KEY` (the key rides only the wire request — cited URLs stay
keyless by construction).

**Hosting note.** The case ledger (`data/runtime/tens_hq.sqlite3`) is local,
single-user, and unauthenticated — it is not an identity, access-control, or
multi-tenant boundary. A shared hosted URL running this app would commingle every
visitor's cases and let one visitor read or mutate another's. Run the full app
locally (or per pilot analyst), or host only the packet-only surface, until
per-identity isolation and auth land (see `docs/ARCHITECTURE.md` §"Trust and
storage boundaries").

- `docs/DEMO_SCRIPT.md` — a guided walkthrough (the in-app "▶ Guided demo"
follows it).
Expand Down
39 changes: 20 additions & 19 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,27 @@

from __future__ import annotations

import sys
from collections.abc import Sequence
from pathlib import Path
import sys

PROJECT_ROOT = Path(__file__).resolve().parent
SRC_ROOT = PROJECT_ROOT / "src"
if str(SRC_ROOT) not in sys.path:
sys.path.insert(0, str(SRC_ROOT))

import streamlit as st
import streamlit as st # noqa: E402 -- sys.path must be set (above) first

from tens_hq.constants import APP_VERSION, DEFAULT_SEED, DEFAULT_TARGET, SYNTHETIC_BANNER
from tens_hq.demo_tour import DEMO_TOUR
from tens_hq.pages import PAGE_RENDERERS, apply_theme
from tens_hq.roles import allowed_pages, pilot_mode_enabled
from tens_hq.synthetic import generate_demo_data
from tens_hq.constants import ( # noqa: E402
APP_VERSION,
DEFAULT_SEED,
DEFAULT_TARGET,
SYNTHETIC_BANNER,
)
from tens_hq.demo_tour import DEMO_TOUR # noqa: E402
from tens_hq.pages import PAGE_RENDERERS, apply_theme # noqa: E402
from tens_hq.roles import allowed_pages, pilot_mode_enabled # noqa: E402
from tens_hq.synthetic import generate_demo_data # noqa: E402

st.set_page_config(
page_title="ReconRadar",
Expand Down Expand Up @@ -79,7 +84,7 @@ def _render_guided_demo() -> None:
st.markdown(f"**{beat.title}**")
st.caption(beat.sentence)
st.caption(f"Point at: {beat.highlight}")
controls = st.columns(3)
controls = st.columns([1, 2, 1])
controls[0].button(
"◀",
key="tour_previous",
Expand Down Expand Up @@ -141,17 +146,13 @@ def main() -> None:
# The guided tour is a demo aid; a pilot session is real work.
_render_guided_demo()
st.markdown("---")
if pilot_mode or page == "BD Feasibility":
# The public scanner is deliberately independent from the
# synthetic planning model; do not show controls that could make
# a directory row look like a forecast or feasibility score.
scenario = "Base"
target_pct = DEFAULT_TARGET * 100.0
else:
st.markdown("**Planning controls**")
scenario = st.selectbox("Scenario", ["Base", "Conservative", "Optimistic"])
target_pct = st.slider("Planning target", 70.0, 82.0, DEFAULT_TARGET * 100.0, 0.5)
st.caption("Site indicators are planning proxies. They are not official ODLH determinations.")
# Both page renderers immediately `del` these args (pages.py,
# bd_page.py) -- the "Planning controls" selectbox/slider changed
# nothing on the Governance page and invited a reviewer to distrust
# a trust page (§5.10). scenario/target_pct stay defined as harmless
# constants for the PAGE_RENDERERS(...) call below.
scenario = "Base"
target_pct = DEFAULT_TARGET * 100.0
st.markdown("---")
st.caption(f"Demo v{APP_VERSION} · Seed {DEFAULT_SEED}")

Expand Down
Binary file added data/samples/sample_nib_npa.xlsx
Binary file not shown.
16 changes: 16 additions & 0 deletions data/samples/sample_usaspending_award.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"piid": "SYNTH-A2-0001",
"generated_unique_award_id": "CONT_AWD_SYNTH_A2_0001_SYNTHETIC_EXAMPLE",
"total_obligation": 180000.0,
"base_and_all_options": 420000.0,
"date_signed": "2024-06-01",
"type": "D",
"type_description": "DEFINITIVE CONTRACT",
"category": "contract",
"description": "SYNTHETIC EXAMPLE AWARD DESCRIPTION -- bundled offline sample, not a real USAspending record",
"period_of_performance": {"start_date": "2024-06-01", "end_date": "2025-05-31", "last_modified_date": "2024-06-05", "potential_end_date": "2028-05-31 00:00:00"},
"place_of_performance": {"city_name": "DENVER", "county_name": "DENVER", "state_code": "CO", "location_country_code": "USA"},
"awarding_agency": {"toptier_agency": {"name": "SYNTHETIC EXAMPLE General Services Administration", "code": "047", "abbreviation": "SYN-GSA"}, "subtier_agency": {"name": "SYNTHETIC Public Buildings Service", "code": "4732", "abbreviation": "SYN-PBS"}, "office_agency_name": "SYNTHETIC EXAMPLE OFFICE"},
"recipient": {"recipient_name": "SYNTHETIC EXAMPLE SERVICES LLC", "recipient_uei": "SYNTHUEI0001A", "business_categories": ["Category Business", "Small Business"]},
"latest_transaction_contract_data": {"type_set_aside": null, "type_set_aside_description": null, "extent_competed": "D", "extent_competed_description": "FULL AND OPEN COMPETITION AFTER EXCLUSION OF SOURCES", "number_of_offers_received": 4, "naics": "561720", "naics_description": "JANITORIAL SERVICES", "product_or_service_code": "S201", "product_or_service_description": "HOUSEKEEPING- CUSTODIAL JANITORIAL", "solicitation_procedures_description": "NEGOTIATED PROPOSAL/QUOTE", "multiple_or_single_award_description": "SINGLE AWARD", "referenced_idv_agency_iden": "4732"}
}
46 changes: 31 additions & 15 deletions docs/DEMO_SCRIPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ Timing: about 12 minutes for Acts 1–3; Acts 4–5 add about 5 more. The packet
fully offline with the bundled SYNTHETIC examples; the live pulls need internet.

> The sidebar's "▶ Guided demo" walks this script's spine — six packet beats,
> then the governance close — and can pace the walkthrough. It navigates pages
> only: opening the **Opportunity Packet** tab on each beat is still your click.
> then the governance close — and can pace the walkthrough. The BD page now
> lands on the **Opportunity Packet** tab, so the packet beats need no manual
> tab click; the tour still switches pages for the governance close.

## Act 1 — Setup (1 minute)

Expand All @@ -19,9 +20,10 @@ fully offline with the bundled SYNTHETIC examples; the live pulls need internet.
DIRECTORY DATA ONLY* — and, at the top of the page body, the warning line:
public directory evidence is discovery evidence only; the scanner never infers
capacity, candidate supply, a relationship, or an acquisition outcome.
3. Open the **Opportunity Packet** tab (rightmost). Read the framing caption aloud:
the packet "never renders a score, ranking, or bid/no-bid recommendation."
That sentence is the product thesis; the next ten minutes are its proof.
3. The page lands on the **Opportunity Packet** tab — no tab click needed. Read
the framing caption aloud: the packet "never renders a score, ranking, or
bid/no-bid recommendation." That sentence is the product thesis; the next
ten minutes are its proof.

## Act 2 — Assemble the packet (7 minutes)

Expand All @@ -45,7 +47,12 @@ each beat — the demo's credibility rests on never overclaiming what a section
pick the exact award — it never auto-picks. Obligated dollars and the ceiling
stay distinct; a missing amount reads "not reported," never $0.00. The pull
also cites the award's reported description and place of performance and
prefills empty place inputs.
prefills empty place inputs. **Offline note (ADR-025):** the bundled
SYNTHETIC PIID (`SYNTH-A2-0001`, prefilled by the handoff above) resolves
from a committed, invented sample instead of a network call — the section
still populates fully, labeled "SYNTHETIC example — offline, not a live
retrieval" throughout, never `Assurance API_RETRIEVED`. A real PIID still
needs a network connection.
3. **Eligibility gate.** Scroll the rendered packet: the gate sits above all other
evidence and now reads from the live set-aside code, superseding the analyst
field.
Expand Down Expand Up @@ -108,7 +115,11 @@ The same page's other tabs are the repeatable evidence workflow around the packe
NIB or SourceAmerica workbook, then separately the AbilityOne Services workbook —
one upload normally cannot satisfy both evidence gates), **Verification**, and
**Assessment** — which reports evidence *readiness*, never a score. Case lifecycle
states are distinct from evidence-readiness labels by design.
states are distinct from evidence-readiness labels by design. **No network? Tick
"Use the bundled SYNTHETIC NIB/NPA example instead of an upload" on the Scan tab
(ADR-026) to run the NIB/NPA lane offline** — the same bundled Denver/Aurora, CO
rows the packet's PL cross-reference sample already uses, so a case can be walked
all the way to Validated with no upload and no network.

## Act 5 (optional) — Governance close (2 minutes)

Expand Down Expand Up @@ -138,8 +149,13 @@ to explicit human review.
`TENS_HQ_CENSUS_API_KEY` environment variable before launching, or the pull
fails loud with sign-up instructions. The key rides only the wire request —
cited source URLs stay keyless by construction (test-locked). Everything else is offline; for a no-network
demo, skip them and run on the bundled SYNTHETIC examples. The packet stays
honest about what's missing — the gate reads Unknown, Geography shows its
demo, skip them and run on the bundled SYNTHETIC examples. **The Contract
Facts pull is the one exception (ADR-025): the bundled SYNTHETIC PIID
`SYNTH-A2-0001` now resolves from a committed offline sample instead of a
network call, so that section (and the Eligibility Gate and Capture Window
it feeds) populates fully offline, clearly labeled SYNTHETIC throughout —
never `Assurance API_RETRIEVED`. Every other PIID still needs a network.**
The packet stays honest about what's missing — the gate reads Unknown, Geography shows its
placeholder, and the R2a map states per criterion what is absent; sections whose
evidence was never attached are omitted from the body and listed as not included,
with the reason, in the export's Section ledger.
Expand All @@ -152,17 +168,17 @@ to explicit human review.
result rather than showing it against the wrong contract — expected behavior, not
a bug.
- **In-app guided tour:** the sidebar "▶ Guided demo" follows this script's
sequence (packet-first, governance close last). It changes the page, never the
tab — open the Opportunity Packet tab yourself — and its beat captions quote
the real widget labels, locked by a test against the page source.
sequence (packet-first, governance close last). The BD page now lands on the
Opportunity Packet tab, so the packet beats need no manual tab click; the
tour still switches pages for the governance close, and its beat captions
quote the real widget labels, locked by a test against the page source.
- **Counsel-gated content:** the C3/C4 claims (named with their citations in
ADR-020 and the counsel packet) are with counsel and are excluded from the
packet's copy. Guard tests lock the whole rendered packet, the full export,
the guided-tour copy, and every packet module's source text against that
vocabulary. Do not ad-lib it into the demo.
- **If asked why a no-score product lives on a page named "BD Feasibility Scanner
& Tracker":** the name is legacy; the page records evidence readiness and case
state, never a feasibility score. Answer it head-on — the question is the thesis.
- **If asked why the page is titled the way it is:** the page is titled to match
the thesis — it records cited evidence and case state, never a feasibility score.

## What we can do next

Expand Down
Loading
Loading