Skip to content

WorkWingman — Interview Coaching (Technical)

A local-only coaching layer that helps the user turn a draft interview answer into a more WE-focused answer, markets their own self-assessment results as factual interview talking points, reflects on team/environment fit as a two-way street, and folds in learnings from their own past interview retros. Every piece of this feature runs entirely on-device, with no external API/LLM/NLP call anywhere in the path — it is curated content plus deterministic heuristics, not a model.

Framing discipline, load-bearing throughout this feature: this is a supportive, optional practice tool, not a clinical or psychological assessment. It never diagnoses the user, never invents a trait they didn't self-report, and never tells them what kind of person they are. Every self-marketing talking point and team-fit reflection is grounded in — and only in — what the user's own prior self-assessment results say. Absence of data is handled as "not enough information yet," never backfilled with a guess.

Where this fits

flowchart LR
    U[User pastes a draft answer] --> C[InterviewCoachingController]
    C --> S[InterviewCoachingService]
    S --> W[WeFocusAnalyzer]
    S --> M[Self-marketing builder]
    S --> F[Team-fit builder]
    S --> R[Retro-informed builder]
    AI[AssessmentInsights] -.stub input.-> S
    IR[InterviewRetro list] -.stub input.-> S
    W --> Out[InterviewCoachingResult]
    M --> Out
    F --> Out
    R --> Out
    Out --> FE[Angular: Interview coaching screen]

Consumed seams (both parallel branches, stubbed locally)

This feature was built on its own branch (feature-interview-coaching) concurrently with two other in-flight branches it depends on. Rather than block on those branches landing, this feature defines small local input-contract stubs so it compiles and tests standalone:

  • WorkWingman.Core.Models.AssessmentInsights (src/WorkWingman.Core/Models/AssessmentInsights.cs) — a local stand-in for whatever feature-self-assessment ships as its equivalent shape: Big Five (OCEAN) trait scores (nullable ints, 0-100 — null means "not assessed," distinct from a real score of 0), a self-reported MBTI string, work/communication/learning style strings, and a list of free-text stated preferences. Marked explicitly as a stub in its doc comment, with the integration expectation spelled out: align or adapter-wrap this type against the real one when feature-self-assessment lands.
  • WorkWingman.Core.Models.InterviewRetro (src/WorkWingman.Core/Models/InterviewRetro.cs) — a local stand-in for feature-interview-retro's retro + asked-question-bank + common-ground shape: per-interview job/company/stage, questions asked, the user's own self-noted learnings, and rapport notes. Same integration note applies.
  • StoryProfile / IntakeProfile already exist on master and are unchanged by this feature — they're referenced conceptually (as sources for a user's "voice" and background) but not consumed directly by the coaching logic itself, since none of the coaching outputs need to quote resume/story content back to the user.

Both stub files are additive-only and namespaced identically to what the real branches would use (WorkWingman.Core.Models), so integration is either a straight replace of the stub file or a thin adapter — never a rename across the codebase.

Null-guarding a JSON-deserialized contract. Because the API endpoint accepts AssessmentInsights and List<InterviewRetro> as request-body JSON, a caller can send an explicit null for a nested object or list ("bigFive": null, "statedPreferences": null, "selfNotedLearnings": null, a null element inside a retros array) even though the C# property initializers only cover the "field omitted entirely" case, not "field present with a JSON null." AssessmentInsights.HasData and every place InterviewCoachingService reads BigFive/StatedPreferences/a retro's nested lists normalizes with ?? new BigFiveTraits() / ?? [] (or skips a null retro element outright) before touching them, so a partially-null payload degrades to "less data available," never a 500. This was a real gap caught by the codex-review gate before commit — see the "Testing" section below.

WeFocusAnalyzer — the we-vs-I heuristic

src/WorkWingman.Infrastructure/Services/WeFocusAnalyzer.cs is a static, pure, deterministic class — no DI, no I/O, no async. It scans a draft answer text with three regexes (System.Text.RegularExpressions.GeneratedRegex, source-generated at compile time for speed):

  1. Singular pronoun regex\b(I|me|my|mine|myself)\b, case-insensitive, word-bounded so it never matches inside words like "Immediately" or "myriad".
  2. Team pronoun matching — split into two regexes. HasTeamCue/CountTeamCues combine:
  3. TeamPhraseCaseInsensitive\b(we|our|ours|ourselves|the team|my team|teammates?| together|collaborat\w*|partnered?|paired)\b, matched fully case-insensitively. Safe to blanket-match because none of these words or phrases collide with a real acronym — "The team", "My team", "TOGETHER" in any capitalization all mean the same thing.
  4. UsPronoun\b(us|Us)\b, matched case-sensitively and deliberately excluding the bare all-caps form "US". An earlier single-regex version ran the whole team-pronoun list under RegexOptions.IgnoreCase, which meant the country acronym "US" in a sentence like "I shipped the rollout in the US market" matched the pronoun "us" and inflated TeamCount — a real false positive caught by codex-review. Splitting "us" out into its own narrowly-scoped regex fixes the acronym collision without sacrificing case-insensitive matching for every other word/phrase (an interim fix that instead special-cased every word's capitalization was itself caught missing "The team" at sentence start — this two-regex split is the version that survived review).
  5. Solo-ownership-verb regex\bI\s+(built|led|designed|created|architected|drove| delivered|decided|solved|fixed|shipped|owned|wrote|implemented)\b — the exact verbs a collaborative STAR answer should pair with a "who else" beat.

Scoring: WeFocusScore is team / (team + singular) * 100, rounded, with 50 as the neutral default when both counts are zero (an answer with no first-person language at all is treated as balanced, not penalized — there's nothing to flag).

False-positive guard (the important part): a sentence is only added to OwnershipWithoutCollaborationCues when it matches the solo-ownership-verb regex and the same sentence does not also match the team-pronoun regex. This means "I led the team's redesign" or "I partnered with design, and I drove the rollout" are never flagged — the ownership verb has a collaboration cue right there in the same sentence. Flagging is per-sentence (split on . ! ? \n), not per-answer, so a collaborative closing line elsewhere in the answer does not excuse a genuinely solo-sounding sentence earlier in it.

Suggestions are specific, not generic: each flagged sentence gets its own suggestion quoting the sentence back (truncated past 120 chars) plus a concrete prompt ("name who you partnered with and what the team's outcome was"). Additional heuristic suggestions fire when there's singular language with zero team language at all, or when singular outweighs team by more than 3:1.

Curated STAR guidance is always returned, regardless of input, reinforcing Situation → Task → our Action → team Result as the target shape for a collaborative answer.

Self-marketing — factual only

InterviewCoachingService.BuildSelfMarketing(AssessmentInsights) (src/WorkWingman.Infrastructure/Services/InterviewCoachingService.cs) walks each Big Five trait, the MBTI self-report, work style, and learning style only when that field is actually populated — an unset (null/empty) field produces zero talking points for that dimension, never a fabricated one. Each SelfMarketingTalkingPoint carries a GroundedIn string naming exactly which self-reported value it came from (e.g. "Conscientiousness: 82"), so the UI (and any future audit) can always trace a talking point back to the input that produced it — nothing is generated speculatively.

High vs. low phrasing per trait is a simple score >= 60 branch — there is no attempt to interpret mid-range scores as anything beyond what they are. MBTI is deliberately framed as "a conversation starter... not a label to recite," paired with an instruction to bring a concrete example — self-marketing content never suggests reciting a personality type as a substitute for evidence.

PhrasingTipFor(communicationStyle) maps a handful of common self-reported communication styles (direct / narrative / data-driven / collaborative) to a matching phrasing nudge, with a generic fallback for anything else — again, mapped only from what the user actually typed into their CommunicationStyle field.

Team-fit — the two-way street

BuildTeamFit(AssessmentInsights) produces a TeamFitReflection with two halves:

  • EnvironmentReflections — "you may thrive in..." statements, each conditioned on a specific Big Five threshold (e.g. Conscientiousness >= 70 → clear ownership/process; Extraversion <= 30 → async-first, protected focus time) or a verbatim stated preference. Every line traces back to a specific score or preference; there is no environment inference drawn from a trait the user never provided.
  • QuestionsToAsk — a fixed, curated list of questions the user should ask the interviewer (management style, decision-making on disagreement, pace, autonomy, feedback norms), always returned regardless of assessment data, because the two-way-street framing (the user is evaluating the company, not just being evaluated) holds even with zero self-assessment data. This list is the natural cross-link point to the interview-prep-bank's own "questions to ask" content (see feature-interview-prep-bank) — same category of content, this feature's version is scoped to fit-evaluation questions specifically.

When there's no assessment data at all, IsGenericFallback is set and the reflections/questions still return non-empty, generic-but-useful content — the screen is never blank.

Retro-informed coaching

BuildRetroInformedCoaching(IReadOnlyList<InterviewRetro>) does two things with the user's own past retros:

  1. Turns every SelfNotedLearnings entry into an "adapted tip" that quotes the user's own note back to them alongside a concrete structural fix (e.g. "you noted rambling on system design — here's a tighter structure: headline, then STAR, then stop").
  2. Builds a lightweight asked-question frequency count across all retros' QuestionsAsked (case-insensitive dictionary keyed on trimmed question text) and surfaces any question asked 2 or more times as RecurringQuestionsToPrepare, so prep time goes to what's statistically likely to recur, not everything ever asked once.

API surface

InterviewCoachingController (src/WorkWingman.Api/Controllers/InterviewCoachingController.cs), registered as IInterviewCoachingServiceInterviewCoachingService in Program.cs:

Route Method Body Returns
/api/interview-coaching POST { answerText, insights?, retros? } InterviewCoachingResult (full bundle)
/api/interview-coaching/we-focus POST { answerText } WeFocusReport only

Both insights and retros are optional on the request; the service treats null as "no data yet" and returns the same empty-safe shapes described above rather than erroring.

Frontend

InterviewCoaching (frontend/src/app/features/interview-coaching/interview-coaching.ts) is a standalone Angular component at route /interview-coaching (linked from the sidebar next to "Applied & interviews"). The flow is: paste an answer → "Get coaching" → four cards render (we-focus feedback + STAR guidance, self-marketing talking points, team-fit reflection + questions to ask, retro-informed notes). Every section has an explicit empty state ("No self-assessment results yet...", "No interview retros logged yet...") so the screen is useful and honest with zero upstream data. The API call is wrapped in catchError(() => of(null)) exactly like the rest of the app's resilience pattern, so a stopped backend degrades to a toast, not a crash.

Stale-response race guard. A private requestToken counter is bumped on every getCoaching() call, clear(), and every edit to the draft via onAnswerTextChange() (bound from the textarea's (ngModelChange), not a bare answerText.set($event)). Each in-flight request captures the token value at the moment it was sent; when the response arrives, it's applied only if the token still matches. This closes two related races, both caught by codex-review: clearing the form (or starting a newer request) while an older request is still in flight must not let that older response repopulate the page, and — the case direct clearing doesn't cover — editing the draft text itself while a request for the pre-edit text is still in flight must not let that stale response show coaching for text the user has already changed. The Clear button is also disabled while loading() is true as a visible reinforcement of the same guarantee.

No network, by construction

IInterviewCoachingService's methods are all synchronous (no Task-returning members) — this is enforced by a structural xUnit test (IInterviewCoachingService_MethodsAreSynchronous_NoAsyncNoNetworkSurface) that reflects over the interface and fails if any method ever starts returning a Task. A synchronous local method cannot await an HttpClient call, so this is a real (not just documented) guarantee that the coaching path stays offline.

Testing

  • Backend: WeFocusAnalyzerTests.cs and InterviewCoachingServiceTests.cs in tests/WorkWingman.Tests/, using Bogus-seeded fixtures from TestData.cs (AssessmentInsightsFaker, InterviewRetroFaker). Covers we-vs-I detection on curated sample texts including explicit false-positive guards (ownership verb + same-sentence collaboration cue; cross-sentence non-excuse; the "US"-acronym and "my team" regressions described above) and exact-boundary regressions (truncation length, score thresholds), self-marketing generation from stub traits (exact talking-point/grounded-in text per trait, "never invents an unscored trait", the communication-style-only case), team-fit output (exact reflection text per threshold, the no-strong-signal fallback), retro incorporation (adapted tips + recurring question detection + ordering, null question elements), full empty-input degradation, the JSON-null-nested-field robustness cases described above, and the no-Task structural guard.
  • Frontend: interview-coaching.spec.ts covers initial state, hasAnswer computed, the get/clear flow, graceful degradation on an erroring API call, the weFocusLabel threshold mapping (including exact boundaries), rendering of both empty states (no data at all vs. retros logged but no tips yet), a DOM-level test that types into the textarea and asserts the signal updates (this is what caught a real [(ngModel)]-on-a-signal binding bug during review), the stale-response race guard (clearing, starting a newer request, or editing the draft while a prior request is still in flight must not let that prior response repopulate the page), and the "edit after a result already rendered" case (no request in flight at all — a distinct staleness path from the race guard).
  • Both projects' existing Stryker configs (tests/WorkWingman.Tests/stryker-config.json's **/Services/*.cs glob, frontend/stryker.conf.json's src/app/**/*.ts glob) already cover the new files without any config change. Final scores: backend 98.05% (251/255 mutants killed), frontend 92.06% (58/63; 92.06% because it was measured on a slightly earlier snapshot of the same file — see below). Remaining survivors on both sides are documented equivalent mutants (++/-- on counters where only "changed" matters, not direction; conditions whose "always true" branch is provably a no-op given the surrounding guard). Note: this backend test project shares a single Playwright-referencing assembly with the rest of the app's services, so Stryker.NET's parallel test-runner concurrency was unreliable in this sandbox (mutants spuriously "survived" under concurrent/parallel runs that an isolated single-threaded (-c 1) rerun confirmed were killed); the numbers above are from isolated runs.
  • Ten rounds of codex review --uncommitted were run against this feature before commit, each fixing real findings: a [(ngModel)] bound directly to a signal instead of its value (broke the textarea entirely); the we-focus endpoint accepting raw text instead of the documented JSON body; a stale-response race when clearing or starting a newer request mid-flight; JSON-null nested fields (bigFive: null, a null retro, a null element inside a list) throwing instead of degrading; a missing empty-state for "retros logged but nothing to show yet"; the "US" country-acronym false positive in we-focus detection; a missed "The team" capitalization after the acronym fix; editing the draft mid-flight (and, separately, after a result already rendered) not invalidating stale coaching; "my team" being double-counted as both a solo and a team cue; a silently-dropped communication-style-only self-assessment; and a null element inside a retro's questionsAsked list crashing the endpoint. Every fix above is paired with a regression test.