Interviewer research & common ground (technical)¶
WorkWingman helps the user prep genuine rapport with an interviewer — shared schools, past employers, degrees, or location — without any LinkedIn scraping, people-search, auto-connect, or messaging. This is the strictest-guardrailed feature in the app; read this whole document before touching any of the code it describes.
Plain-language version: ../plain/interviewer-research.md
The hard guardrails, stated precisely¶
| Allowed | Never |
|---|---|
Parse the HTML of one public LinkedIn profile page the user themselves opened and handed to WorkWingman (InterviewerProfileExtractor), the same read-only, rides-the-user's-own-view pattern as LinkedInPostingSignalExtractor. |
Bulk-scrape LinkedIn profiles, crawl a list of people, or call LinkedIn's people-search. |
| Let the user type interviewer details in manually when a profile page isn't available — always a full fallback, never a degraded second-class path. | Auto-discover interviewers from any source WorkWingman itself queries. |
Compute CommonGround locally, entirely in-process, against the user's own IntakeProfile. |
Send an interviewer's data, the user's IntakeProfile, or any computed overlap anywhere off the machine. |
Store InterviewerProfiles in the same local LocalJsonStore every other collection uses. |
Sync interviewer data to Drive, a CRM, or any other service. |
Surface real overlaps only (CommonGround.HasAnyOverlap) as talking points for the user's own use in the interview. |
Auto-connect, auto-message, or send anything to the interviewer. There is no Send/Connect/Message method anywhere in this feature — see the structural test below. |
If a future contributor is tempted to make the extractor "more reliable" by adding a scheduled re-fetch, a login step, or a search across LinkedIn's people directory — that is the point to stop. It moves this feature from "read one page the user already opened" to the ToS-violating bulk scraping this codebase deliberately avoids everywhere else (see job-signals.md and outreach-drafting.md for the same line drawn elsewhere).
How an interviewer gets identified¶
The user adds an InterviewerProfile for a job in one of two ways — never by WorkWingman going
looking on its own:
- Manual entry. The user types in name, role, current company, schools, past companies, degrees, and location by hand. This is the default, always-available path.
- Paste a public profile page. The user opens the interviewer's public LinkedIn profile
in their own browser, then pastes the page's HTML into WorkWingman.
InterviewerProfileExtractor.ExtractFromPageHtmlparses that one HTML snapshot — nothing is fetched by WorkWingman itself, no login, no additional page loads, no repeat visits. Fields it can't find (LinkedIn's markup varies by viewer/locale/rollout) are left blank, exactly likeLinkedInPostingSignalExtractor's graceful degradation — the user can then fill in the gaps manually.
Every InterviewerProfile carries an InterviewerProfileSource (ManualEntry or
ParsedFromPublicProfilePage) — there is deliberately no Scraped/AutoDiscovered member, and
none should ever be added.
Parsing: InterviewerProfileExtractor¶
InterviewerProfileExtractor.cs
is a public static class — a pure parse function with zero network/browser dependency,
mirroring LinkedInPostingSignalExtractor's shape exactly. ExtractFromPageHtml(string html)
uses the same minimal, layout-tolerant selector philosophy (a small fallback chain of
.class / [class*='fragment'] / bare-tag selectors over HtmlAgilityPack) to pull:
| Field | Source shape on the page |
|---|---|
Name |
The profile header <h1> (.text-heading-xlarge, falling back to any h1). |
Headline |
The line under the name (.text-body-medium). |
Location |
The location line under the headline (.text-body-small). |
CurrentCompany |
Parsed from the first experience-section entry ("Title at Company" shape). |
PastCompanies |
Parsed from every experience-section entry, de-duplicated case-insensitively. |
Schools |
Parsed from every education-section entry's first segment. |
Degrees |
Parsed from every education-section entry's second segment (after ·/newline). |
Never throws on malformed or missing markup — a blank/empty result means "degrade to manual entry for this field," never a parse failure surfacing as an error.
Overlap: CommonGroundFinder¶
CommonGroundFinder.cs
(ICommonGroundFinder.Find(interviewer, intake) -> CommonGround) is a pure, local
computation: no constructor dependencies (CommonGroundFinderTests.CommonGroundFinder_HasNoNetworkOrPersistenceDependency
asserts every constructor takes zero parameters), no I/O anywhere in the method body.
Matching rules, all case-insensitive and whitespace-tolerant:
- Schools / past companies — exact-token overlap after trimming and lower-casing (
"Globex Inc"matches" globex inc "). - Degrees / fields of study — substring overlap after expanding a small synonym table
(
CS↔Computer Science,BS/B.S.↔Bachelor of Science,MBA↔Master of Business Administration, etc.), so"BS CS"(interviewer) matches"Computer Science"(user'sFieldOfStudy) in either direction. - Location — only the leading city token of
InterviewerProfile.Locationis compared againstIntakeProfile.Contact.Address; a match setsNearbyLocationto the interviewer's own location text. Blank on either side never fabricates a match.
CommonGround.Notes holds ready-to-display talking points ("You both studied at X.", "You both
worked at Y.") — one per genuine overlap found. HasAnyOverlap is false and Notes is empty
when nothing genuine matches; the UI shows an explicit "no overlap found" message rather than an
empty box, never inventing a connection.
Storage: InterviewerStoreService¶
InterviewerStoreService.cs
uses the same LocalJsonStore
every other local collection in this app uses, under its own "interviewers" collection key —
nothing shared with any other feature, nothing synced anywhere off the machine (no Drive mirror,
unlike applications/settings). GetForJobAsync filters and sorts by AddedAt; AddAsync /
RemoveAsync persist the full collection back atomically (LocalJsonStore.SaveAsync's
stage-then-move pattern).
API surface¶
GET /api/interviewers/job/{jobId} -> InterviewerProfile[]
POST /api/interviewers -> InterviewerProfile (manual entry)
POST /api/interviewers/job/{jobId}/parse -> InterviewerProfile (parses request.Html)
DELETE /api/interviewers/{interviewerId}
GET /api/interviewers/{interviewerId}/common-ground -> CommonGround
The parse endpoint's request body (ParseProfileRequest { Html, Role, LinkedInUrl }) carries
only the HTML the user's own browser already rendered — the API never fetches this itself;
there is no HttpClient call to linkedin.com anywhere in this feature. The common-ground
endpoint reads the stored interviewer plus IProfileService.GetIntakeAsync and returns the
computed CommonGround — it does not write or transmit anything.
sequenceDiagram
participant U as User
participant UI as Angular (Applied — interviewers panel)
participant Extract as InterviewerProfileExtractor
participant Store as InterviewerStoreService
participant CG as CommonGroundFinder
U->>U: Opens interviewer's PUBLIC LinkedIn profile themselves
U->>UI: Pastes the page HTML (or types details manually)
UI->>Extract: ExtractFromPageHtml(html) [read-only, one page, no crawl]
Extract-->>UI: InterviewerProfile (blank fields where not found)
UI->>Store: AddAsync(interviewer)
Store-->>UI: saved InterviewerProfile
UI->>CG: Find(interviewer, user's own IntakeProfile) [pure, local]
CG-->>UI: CommonGround { SharedSchools, SharedPastCompanies, SimilarDegrees, NearbyLocation, Notes }
UI-->>U: Talking points shown for rapport prep — nothing sent, no message, no connect
Frontend¶
The "Your interviewers" panel lives on the Applied feature
(applied.ts /
applied.html), expandable per
application row (keyed by jobId). Every panel instance renders a persistent guardrail line:
"Rapport prep from public info only — WorkWingman never messages or connects with anyone here."
Two add flows: "+ Add manually" (plain form fields) and "+ Paste their public profile"
(HTML textarea, with copy explaining exactly what the paste is used for and that nothing is
crawled or logged into on the user's behalf). Each interviewer card shows its
InterviewerProfileSource as a chip so the user always knows whether a field was typed or parsed.
DI registration¶
builder.Services.AddSingleton<IInterviewerStoreService, InterviewerStoreService>();
builder.Services.AddSingleton<ICommonGroundFinder, CommonGroundFinder>();
registered in Program.cs alongside every other domain
service — no special-case DI.
Tests¶
InterviewerProfileExtractorTests.cs— fixture-HTML parse tests (full profile, minimal markup, unrecognized/malformed markup, empty/ whitespace input never throws), plus direct selector-matching tests (SelectBySelectorLikeClass) mirroringLinkedInPostingSignalExtractorTests's white-box style, and a structuralNoNetworkCall_ExtractorHasNoHttpDependencytest.CommonGroundFinderTests.cs— exact overlap, case/whitespace tolerance, degree synonym expansion (BS CS↔Computer Science,MBA↔ full form), substring-without-synonym matching, no-overlap/empty-interviewer cases, location matching (same city / different city / blank either side), duplicate-collapsing, null-argument guards, and a structuralCommonGroundFinder_HasNoNetworkOrPersistenceDependencytest (every constructor takes zero parameters).InterviewerStoreServiceTests.cs— real (temp-directory)LocalJsonStorepersistence: add/get/remove, per-job filtering, ordering, cross-instance persistence, unknown-id no-ops.- Frontend:
applied.spec.tscovers panel expand/collapse, load-once-per-job caching, manual-add and paste-add flows (including the "does nothing without required input" guards), common-ground rendering (including the no-overlap footnote path), removal, and the guardrail note's presence in the rendered DOM. - All new backend files are added to the mutation-testing scope in
stryker-config.json(**/Automation/InterviewerProfileExtractor.cs, plus**/Services/*.csalready coveringCommonGroundFinder.csandInterviewerStoreService.cs).
Related docs¶
- Plain-language version: ../plain/interviewer-research.md
- job-signals.md — the same read-only, one-page, no-bulk-scrape pattern applied to job postings
- outreach-drafting.md — the same "draft/prep only, user acts themselves" boundary applied to messaging
- architecture.md · security.md
- Doc index: ../README.md