Skip to content

WorkWingman — Demographic Resources (Technical)

A local, opt-in feature that surfaces free career-prep and community resources matched to the demographics a user already self-identified during onboarding — Karat's Brilliant Black Minds (free mock interviews for Black software engineers) was the seed example. This document covers the privacy model, the matching algorithm, the curated dataset, and how the list is maintained.

Privacy model — read this first

This feature exists in a sensitive space (demographic data), so the privacy rules are non-negotiable and enforced structurally, not just by convention:

  • Matching is 100% local. DemographicResourceService.GetResourcesFor(Demographics) (src/WorkWingman.Core/Interfaces/IDemographicResourceService.cs, implemented in src/WorkWingman.Infrastructure/Services/DemographicResourceService.cs) is a pure, synchronous, in-memory function. It makes no HTTP calls, opens no sockets, writes nothing to disk, and calls no logger. It compares the Demographics already on the local IntakeProfile against a static, committed list — nothing more.
  • The demographic values never leave the machine. They are typed once during onboarding (IntakeProfile.Demographics — see IntakeProfile.cs), saved to the local JSON store (%USERPROFILE%\Wingman\data\intake.json), and read back server-side by ProfileController.GetDemographicResources. They are never sent to a remote API, never included in analytics, and never written to a log statement anywhere in this code path.
  • The endpoint cannot be used to probe demographics from outside. GET /api/profile/demographic-resources takes no request parameters. It reads the profile from local storage server-side and returns only the matched resources (organization names, URLs, descriptions) — never the demographic values themselves. There is no way to submit or query a demographic value through this endpoint.
  • No inference, ever. The matcher only acts on values the user explicitly typed into the onboarding form. Blank fields are blank — the service never guesses a demographic from a name, a resume, a photo, or any other signal. See "Matching is conservative" below.

Data model

DemographicResource (src/WorkWingman.Core/Models/DemographicResource.cs):

Field Type Meaning
Name string Organization/program name.
Url string Official site.
WhatItOffers string One-line description of the concrete offering (mock interviews, mentorship, a job board, scholarships, a community).
DemographicTags List<string> Canonical display tags this resource serves (e.g. ["Black", "African American"]).
IsFree bool true unless the entry is explicitly a paid/mixed-tier org (see dataset notes below).
Kind DemographicResourceKind InterviewPrep, Community, JobBoard, Mentorship, or Scholarship.

The curated dataset

Stored as a static, committed C# seed — DemographicResourceSeed.All — rather than a database or remote config, so it ships with the app, is code-reviewed like any other change, and requires zero network access to use. It is explicitly not exhaustive: it is a curated starting point across five demographic dimensions, verified real and free (or free-tier) at the time each entry was added.

Verification method: every entry was checked via live web search/fetch immediately before being added — confirming the organization currently exists, the URL resolves, and the specific offering described is actually free (or has a genuinely free tier) — not carried over from a stale list. One organization commonly cited in older "women in tech" lists, Women Who Code, was deliberately excluded because it closed operations in 2024; this is a concrete example of why periodic re-verification matters (see "Maintenance" below).

Coverage as of the last review (2026-07-04):

Dimension Included Count
Race/ethnicity — Black/African American Karat Brilliant Black Minds, /dev/color, NSBE, AfroTech, Blacks In Technology Foundation 5
Race/ethnicity — Latino/Hispanic Techqueria, SHPE, Latinas in Tech 3
Race/ethnicity — Indigenous AISES 1
Gender Girls Who Code, AnitaB.org/Grace Hopper, Elpha 3
LGBTQ+ Lesbians Who Tech + Allies, Out in Tech 2
Veterans VetsinTech, Operation Code, Hiring Our Heroes 3
Disability Disability:IN, Lime Connect 2

19 resources total. Every URL is https://; most are free with a couple of professional-society entries (NSBE, SHPE) marked IsFree = false because their general membership carries dues (they do offer free tiers for new graduates, which is called out in WhatItOffers).

Matching algorithm

DemographicResourceService.GetResourcesFor:

  1. Extract terms. Pulls Gender, RaceEthnicity, VeteranStatus, and DisabilityStatus off the Demographics object — every non-blank field, lowercased and trimmed. Languages is deliberately excluded: it is a list of spoken languages, not an identity dimension any curated resource is matched on, and including it risked surfacing identity-targeted resources off an unrelated signal.
  2. Return early on nothing set. If all four fields are blank/whitespace, the method returns an empty list immediately — no comparison is even attempted.
  3. Expand synonyms. A small table of synonym groups (e.g. black / african american; hispanic / latino / latina / latinx / latine; woman / female; veteran / military spouse / service member; lgbtq / gay / lesbian / bisexual / transgender / queer; disability / disabled) expands each raw answer into every phrase it's a substring match for, so "African-American" and "Black" both resolve to the same match set, and a free-text answer like "I am a disabled veteran" matches both the veteran and disability groups.
  4. Match tags. A resource is included if any of its DemographicTags (lowercased) appears in the expanded term set. Results across every dimension are unioned (a Black veteran sees both Black-focused and veteran-focused resources, deduplicated by construction since the union is a HashSet-backed lookup against a fixed list).

Matching is conservative. Free-text answers that don't match any known synonym (e.g. "Prefer not to say") correctly return no resources for that field — the matcher never falls back to a fuzzy/best-guess match.

Negation-aware, and scoped to the right phrase. Real EEO forms commonly render the opposite of a demographic as a free-text option — "Not Hispanic or Latino", "I am not a protected veteran", "No disability" — and a naive substring match on "hispanic"/"veteran"/"disability" would wrongly treat that as a positive self-identification. DemographicResourceService splits each answer into clauses (on ;, ,, ., newline) and, within a clause, only lets a negation marker ("not", "no", "non-", "don't", …) suppress phrases in the same synonym group it is textually attached to — a phrase from a different group later in the same clause starts fresh. This matters for the standard combined US Census/EEO wording that has no punctuation at all between the two answers, e.g. "Non-Hispanic Black": "Non-" correctly negates "Hispanic" only; the separate, positive "Black" match is preserved. See DemographicResourceServiceTests.GetResourcesFor_NonHispanicBlack_* for the exact regression case that drove this design.

API surface

ProfileController.GetDemographicResources (src/WorkWingman.Api/Controllers/ProfileController.cs):

GET /api/profile/demographic-resources
→ 200 OK, DemographicResource[]
→ 401 Unauthorized  (untrusted caller — see below)

Reads IntakeProfile via the existing IProfileService.GetIntakeAsync, then calls IDemographicResourceService.GetResourcesFor(profile.Demographics). No request body, no query parameters — the profile is always read from local storage, never supplied by the caller.

Gated on the per-launch token, unlike most GETs in this API. The API's CORS policy allows any origin (documented in Program.cs — necessary because the packaged Electron renderer and the Angular dev server present different origins), and most GET endpoints are intentionally left open as read-only detection. This endpoint is the exception: its response can reveal the user's self-identified demographics by the resource names alone, so a hostile page open in a browser while WorkWingman is running must not be able to read it. It uses the same ApiToken.IsTrusted check (src/WorkWingman.Api/Security/ApiToken.cs) as the dependency-doctor install endpoint: the per-launch token (injected into the packaged Electron renderer, forwarded via the X-WorkWingman-Token header) or the trusted localhost:4200 dev origin. The frontend interceptor (frontend/src/app/core/api-token.interceptor.ts) attaches the token to this path (alongside the install endpoint) automatically.

Frontend — "Communities & prep for you"

The Study & prep screen (frontend/src/app/features/study/study.ts / study.html) fetches the matched resources once on load via ApiService.getDemographicResources() and renders a "Communities & prep for you" card above the per-job study grid:

  • Supportive framing, always visible as a caption: "Optional suggestions based on what you shared in onboarding — communities and prep, never a requirement. Most are free; a few are marked 'membership dues' where the org itself charges (their free tiers are called out in the card). Matched entirely on this machine; nothing about what you shared is sent anywhere." The copy deliberately does not claim every resource is free, because the curated dataset includes a couple of professional societies (NSBE, SHPE) whose general membership carries dues — each such card shows a "membership dues" badge (!r.isFree) rather than letting a blanket "free" claim imply otherwise.
  • Renders nothing when there's nothing to show. showDemographicResources is a computed signal that is false whenever the resource list is empty (no demographics set, or the API call failed — including a 401 from the trust gate above, which the component treats identically to "no matches" rather than surfacing an error) — the section simply doesn't exist in the DOM, rather than showing an empty state or a prompt to fill in demographics. This keeps the feature opt-in in spirit, not just in name: there is no nudge to disclose anything.
  • Dismissible, and the dismissal sticks. A "Hide" button calls dismissDemographicResources(), which sets a localStorage flag (wingman-hide-demographic-resources) read back on every future load — once hidden, it stays hidden across restarts, mirroring the existing ThemeService pattern for durable local UI preferences (frontend/src/app/core/theme.service.ts).
  • Each resource card shows its kind (Interview prep / Community / Job board / Mentorship / Scholarship), a "membership dues" badge when isFree is false, name, one-line offering description, and a "visit →" external link — the same visual language as the existing per-job resource cards on this screen.

Maintenance

This is a curated, versioned, human-reviewed list, not a live feed — there is no scraper or external API call backing it (that would violate the "matching is fully local" rule and would risk staleness in the other direction — silently pulling in unverified entries). Expect to:

  • Re-verify every entry periodically (organizations rebrand, shut down, or change pricing — see the Women Who Code note above).
  • Add new dimensions or organizations as they're identified, following the same verification method (confirm the org currently exists, the URL resolves, and the offering is genuinely free/free-tier before adding).
  • Update the "Last reviewed" date in the seed file's doc comment (DemographicResourceSeed.cs) whenever a review pass happens, even if no entries changed.

Friendlier version: ../plain/demographic-resources.md