Skip to content

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:

  1. 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.
  2. 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.ExtractFromPageHtml parses 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 like LinkedInPostingSignalExtractor'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 (CSComputer Science, BS/B.S.Bachelor of Science, MBAMaster of Business Administration, etc.), so "BS CS" (interviewer) matches "Computer Science" (user's FieldOfStudy) in either direction.
  • Location — only the leading city token of InterviewerProfile.Location is compared against IntakeProfile.Contact.Address; a match sets NearbyLocation to 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

InterviewersController.cs:

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) mirroring LinkedInPostingSignalExtractorTests's white-box style, and a structural NoNetworkCall_ExtractorHasNoHttpDependency test.
  • CommonGroundFinderTests.cs — exact overlap, case/whitespace tolerance, degree synonym expansion (BS CSComputer 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 structural CommonGroundFinder_HasNoNetworkOrPersistenceDependency test (every constructor takes zero parameters).
  • InterviewerStoreServiceTests.cs — real (temp-directory) LocalJsonStore persistence: add/get/remove, per-job filtering, ordering, cross-instance persistence, unknown-id no-ops.
  • Frontend: applied.spec.ts covers 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/*.cs already covering CommonGroundFinder.cs and InterviewerStoreService.cs).