Overview
The weekly ritual for an enterprise seller is predictable: pull the pipeline CSV from the CRM, write the forecast narrative to the manager, assemble the QBR deck for the quarterly review, build the account plan for priority deals. Each artifact draws on the same underlying math — bucket rollups, week-over-week deltas, obligation-to-capability crosswalks — but is typically assembled by hand from the same export. Seller Admin Tools automates that path. Local, offline, deterministic, and vendor-neutral: no column names are hard-coded anywhere; a mapping screen translates any CRM export to a canonical schema, with saved profiles that make the following week's re-import zero-click. Adapted from an enterprise security-sales methodology, then de-branded and generalized before public release. The original four-session Streamlit build was ported to FastHTML 0.14.11 — specifically for offline-first operation with no CDN dependencies — then hardened through a security review. 72 commits · 10 PRs · 3 active days.
Project Design
Four feature sessions built the four tools against a set of PRDs: ingest and column mapping, forecast narrative, QBR assembler, then account plan. After the build sessions, ten pull requests closed out correctness, security, accessibility, and packaging work. The core layer is pure Python functions over pandas 3.0 DataFrames and SQLite — fully exercised headlessly by pytest with no UI dependencies. The web layer is a thin FastHTML ASGI app; routes render strictly from view models in core/views/ so on-screen numbers and export artifacts share a single rendering path and cannot disagree. All behavior-driving logic — stage buckets, risk thresholds, coaching asks, narrative templates, alias normalization, regulatory obligation maps, and product crosswalk — lives in six YAML files in config/ read at call time. Changing a CRM format is a column remap; changing a threshold is a YAML edit; adding a regulatory framework is a new entry in obligation_map.yaml. No Python changes required for any of those. The Streamlit-to-FastHTML migration was driven by one constraint: the offline invariant needed to be mechanically enforced rather than advisory. FastHTML's CDN-header suppression config plus a startup assert at web/server.py made that possible — the server refuses to start if any response header references an external URL.
Key modules
Ingest / Mapping
CSV parsing with date-format resolution (auto/US/International/ISO with live preview), a money parser that handles dollar signs, thousands separators, and parenthesized negatives, and five validation states (blocking vs. warning — rows still import on warnings). Auto-suggests column mapping against PIPELINE_SCHEMA and ACCOUNT_SCHEMA; the user confirms every field, suggestions are never applied silently. Named profiles make re-import zero-click. Re-import detection keyed by file SHA-256 catches the same file submitted twice.
Forecast Narrative
Commit / upside / pipeline bucket rollup using forecast_category when present, stage-derived otherwise. Week-over-week delta runs in two passes: ID-join first on opportunity_id, name-join fallback for ID-less rows. Duplicate non-empty IDs are demoted to name-join; duplicate name keys are demoted to unmatched — every ambiguous case is surfaced, never silently miscounted. Four risk flags (stalled: 45 days; slipped: 90 days; no_sponsor: deals ≥$500K; big_and_late: deals ≥$1M closing within 30 days), each with a plain-English evidence string and a configurable coaching ask from risk_rules.yaml. Exports to .md.
QBR Assembler
One-click from snapshot to a five-slide .pptx deck (title, scorecard, native bar chart, top deals, risks and asks) plus a .md appendix. Per-seller rollup with alias normalization across snapshots. A consistency-guard test parses the commit figure back out of the built deck and asserts it equals the narrative's — the two tools share the same core/forecast functions and cannot drift. DRAFT footer on every slide; tables row- and column-capped; long names truncated.
Account Plan
Joins an account-facts CSV with open pipeline. Obligation → capability → gap crosswalk: each obligation is marked landed, partial (a competitor holds it), or gap. Whitespace estimate sums open pipeline against gap capabilities; products that cannot be resolved to a capability are excluded and reported rather than guessed. Rule-based next actions. Exports to .pptx and .md.
Config layer
Six YAML files in config/ drive all behavior at call time: stage_map.yaml (raw CRM stage string → canonical bucket), aliases.yaml (account and owner name normalization), risk_rules.yaml (four flag thresholds and coaching asks), narrative_templates.yaml (sentence templates for forecast output), obligation_map.yaml (regulatory obligation → required capability), product_map.yaml (product name → capability category). No code change needed for new CRM formats, thresholds, stage labels, or regulatory frameworks.
Security layer
Pure-ASGI LocalOnlyMiddleware (web/security.py) rejects requests with non-loopback Host headers and blocks CSRF by requiring a same-origin Origin or Referer header on all mutating requests. Startup assert scans HTTP headers at boot and raises RuntimeError if any external URL appears. Dependency floors in constraints.txt. Security-audit CI on every push and weekly cron.
Key features
Offline-first as a hard invariant — from Streamlit to FastHTML
The original build ran on Streamlit. Streamlit works, but its frontend dependencies pull from a CDN — which made 'fully offline' a policy rather than a property the code enforced. The port to FastHTML 0.14.11 was chosen specifically because that version's fast_app(default_hdrs=False, pico=False) configuration suppresses all CDN headers. That pin is load-bearing: a different version might not. The startup assert at web/server.py scans every response header for src= or href= values containing an external URL and raises a RuntimeError before the server finishes booting if any appear. htmx 2.0.7 is vendored in web/static/. Tailwind CSS v3.4.17 is a standalone CLI binary — not a pip dependency — and the compiled stylesheet is committed to the repo. There is no network call anywhere in the application, and the process won't start if that changes. The socket-guard test goes further: it monkeypatches socket.socket to raise, then runs a full narrative generation pass, kept green as a hard invariant.
LocalOnlyMiddleware — a purpose-built ASGI security layer for local servers
A local web server accepting requests on localhost faces two classes of attack a remote server does not: DNS rebinding (a malicious page rewrites DNS so its requests appear to originate from 127.0.0.1) and CSRF-to-localhost (a page in the browser makes a cross-origin POST to the local app). LocalOnlyMiddleware (web/security.py) is a pure-ASGI layer added after a security review. It rejects any request whose Host header is not a loopback address — 127.0.0.1, localhost, or ::1 — before the request reaches any route handler. It also rejects all mutating requests (POST, PUT, PATCH, DELETE) that lack a same-origin Origin or Referer header. Docker and Azure App Service demo images can opt in to remote access via the SELLER_ADMIN_TOOLS_ALLOW_REMOTE=1 environment variable; in that mode the loopback check is lifted but the same-origin check still applies. The security test module covers ten scenarios: loopback variants, IPv6 loopback, the remote-allow mode, Referer fallback, and the blocking behavior for each attack class.
Security & ops decisions
- LocalOnlyMiddleware rejects any request whose Host header is not a loopback address (127.0.0.1 / localhost / ::1) — closing the DNS-rebinding attack surface before any route handler is reached.
- All mutating requests (POST, PUT, PATCH, DELETE) require a same-origin Origin or Referer header. Missing header → 403. The check survives the SELLER_ADMIN_TOOLS_ALLOW_REMOTE=1 remote-access mode.
- A startup assert scans all HTTP response headers at boot and raises RuntimeError immediately if any src= or href= value contains an external URL — making the offline invariant a process-level gate, not a policy.
- A socket-guard test monkeypatches socket.socket to raise, then runs a full narrative generation pass. Kept green as a CI hard invariant: a network call anywhere in the core path would fail this test.
- Dependency floors in constraints.txt: pillow>=12.3.0 (image-decoding advisories below that threshold, pulled by python-pptx) and python-multipart>=0.0.31 (DoS-class advisories below that threshold on the file-upload path, pulled by FastHTML/Starlette).
- Security-audit CI (.github/workflows/security-audit.yml) runs pip-audit plus the full pytest gate on every push, pull request, and weekly cron.
- Real CRM exports and the runtime database (data/agents.db) are git-ignored. Sample data is entirely fictional — 40 pipeline rows and 5 account-facts rows designed to exercise every failure class in the ingest path.
Builder notes
- The Streamlit-to-FastHTML migration happened mid-project after the offline requirement crystallized — Streamlit can't vendor its frontend dependencies, FastHTML can. The startup assert and LocalOnlyMiddleware came out of a security review on the same day, from the same session.
- The snapshot store's as_of_date model — using the week the data represents rather than when it was imported — sounds like a minor detail until you batch-import three weekly CSVs and discover that imported_at collapses all three to the same timestamp, making the week-over-week trend flatline.
Lessons learned
- A hard invariant is worth more than a policy: the offline requirement became real only after the startup assert was added. 'We don't pull from CDNs' is a statement; 'the server refuses to boot if a CDN reference appears in any header' is a control.
- Config-not-code is an underrated portability strategy. When stage labels, risk thresholds, coaching asks, and regulatory frameworks live in YAML files read at call time, adapting the tool to a different CRM or methodology is an edit session, not a port. The de-branding work that preceded public release was possible because the methodology-specific wording lived in templates, not in function bodies.
- A consistency-guard test — one that parses its own export and asserts the parsed value equals what the source computed — is worth writing. The QBR deck's commit figure is verified this way: the test builds the deck, extracts the number from the .pptx, and asserts it matches the forecast function's output. The two tools share the same core functions and the test proves it mechanically.
- The week-over-week delta algorithm needed explicit demotion paths for ambiguous cases: duplicate opportunity IDs fall back to name-join, duplicate name keys fall to unmatched. The alternative — picking one and moving on — produces a miscounted forecast that looks correct.
What carried forward
Mechanical invariant enforcement over advisory policies: if a requirement matters enough to write down, it matters enough to fail the process when violated. And the config-not-code pattern: behavior that might need tuning, substitution, or localization belongs in data files read at call time, not in function bodies that require a code change.
Posts from this project
Case study in progress.