Skip to content

WorkWingman — Company Research (Technical)

An interview-prep briefing surfaced on a job posting: what the company does, their public engineering tech stack, and recent news — plus factual talking points woven into the cover letter. Built to the same hard rule as job-signals.md: every source must be free, official, and public — no paid API keys, no scraping, no fabricated content. This document covers each source's endpoint and legality verdict, the CompanyBriefing shape, the cover-letter integration seam, and how everything degrades when data isn't available.

Architecture

Mirrors Enrichment/ (layoff history):

src/WorkWingman.Infrastructure/Research/
├── GitHubOrgSource.cs              (ITechStackSourceAdapter)
├── NewsSource.cs                   (INewsSourceAdapter)
├── WikidataSource.cs               (ICompanyBackgroundSourceAdapter)
├── JobDescriptionTechExtractor.cs  (pure, no network)
├── CompanyResearchService.cs       (ICompanyResearchService — aggregator)
└── CompanyTalkingPointSelector.cs  (cover-letter seam)

Three source-adapter interfaces (not one shared interface) because the three sources return heterogeneous shapes — a tech-stack list, a news list, and a background string — so each gets its own single-purpose interface rather than forcing a lowest-common-denominator contract:

public interface ITechStackSourceAdapter
{
    string Name { get; }
    Task<IReadOnlyList<string>> LookupAsync(string companyName, CancellationToken ct = default);
}

public interface INewsSourceAdapter
{
    string Name { get; }
    Task<IReadOnlyList<NewsItem>> LookupAsync(string companyName, CancellationToken ct = default);
}

public interface ICompanyBackgroundSourceAdapter
{
    string Name { get; }
    Task<string?> LookupAsync(string companyName, CancellationToken ct = default);
}

CompanyResearchService (source) takes IEnumerable<T> of each adapter interface via constructor injection (DI resolves every registered implementation, same as CompanyLayoffService), calls each with try/catch-and-continue, and combines whatever came back into one CompanyBriefing:

foreach (var source in techStackSources)
{
    ct.ThrowIfCancellationRequested();
    try { /* lookup, append, record attribution */ }
    catch (OperationCanceledException) { throw; }
    catch { /* this source is unavailable this run; skip it, keep the others */ }
}

One misbehaving source never blocks the others; cancellation still propagates via the explicit per-iteration ThrowIfCancellationRequested() check (the same pattern CompanyLayoffService uses, verified by an equivalent cancellation test).

Sources evaluated and the decision

Source Verdict Why
GitHub REST API (GitHubOrgSource) Used Official, free, unauthenticated (60 req/hour/IP). Fetches GET /orgs/{org}/repos across up to 3 pages of 100 (see "pagination" note below), aggregates the language field across public repos into a top-8 list. An optional GITHUB_TOKEN environment variable is read once at construction (Environment.GetEnvironmentVariable("GITHUB_TOKEN")) for a much higher 5,000 req/hour ceiling — never required, every call still works unauthenticated at the lower limit when unset.
GDELT DOC 2.0 (NewsSource) Used Free, open research project, no signup, no key. GET https://api.gdeltproject.org/api/v2/doc/doc?query="{company}"&mode=artlist&format=json&maxrecords=10&sort=datedesc. Verified live shape: { "articles": [{ "url", "title", "seendate", "domain", "sourcecountry", ... }] }. seendate is a compact UTC stamp (20260703T140000Z, no separators) parsed with an explicit format string; falls back to DateTimeOffset.UnixEpoch rather than throwing on a malformed date. GDELT exposes no distinct "publisher display name," so NewsItem.SourceName is populated from domain (e.g. "reuters.com") — real attribution, just not a prettified brand name.
Google News RSS Documented alternative, not wired https://news.google.com/rss/search?q=... is a public, legal-to-fetch RSS feed and would work as a drop-in second INewsSourceAdapter if GDELT's coverage proves thin for a given company. Not implemented today because GDELT alone satisfied the "recent news" signal during verification; the interface seam is ready for it.
NewsAPI.org / Bing News Search Out of scope — paid/keyed Both require an API key and a paid tier beyond a small free quota — inconsistent with this app's no-account, local-first, zero-cost-to-run design. No adapter code, no config stub beyond this note.
Wikipedia REST summary API (WikidataSource) Used GET https://en.wikipedia.org/api/rest_v1/page/summary/{title} — free, official, no key. Verified live shape includes type, title, description, extract. extract (a full prose paragraph) is used as WhatTheyDo; a type: "disambiguation" response is treated as "not found" (returns null), never guessed at. Named WikidataSource per the original research-source naming, though it calls Wikipedia's summary endpoint rather than Wikidata's SPARQL/entity API — see the class doc for the full reasoning (Wikipedia's summary endpoint already returns ready-to-read prose with no entity-resolution step; querying Wikidata's structured triple store directly was judged over-engineering for this scope).
Wikidata SPARQL Documented alternative, not wired Would supply structured facts (founding year, employee count, industry) as a supplement to the prose summary. Could be added later as a second ICompanyBackgroundSourceAdapter without changing the interface or CompanyResearchService.
SEC EDGAR (business-description supplement) Not wired, optional/secondary The existing SecEdgarSource (layoffs) full-text search pattern could supply a 10-K business-description excerpt for public companies. Judged secondary to the Wikipedia summary, which already covers "what they do" for both public and many private companies; left as a documented extension point rather than built now.
BuiltWith / Wappalyzer / StackShare Out of scope — paid/restricted All three either require a paid plan or restrict programmatic/bulk access under their ToS. No code, no config stub.
Crunchbase Out of scope — paid Crunchbase's API requires a paid subscription. No code, no config stub.

Tech stack has a second, no-network half: JobDescriptionTechExtractor (source) is a pure static scan of JobPosting.Description against a curated keyword list (.NET, C#, Angular, React, Python, AWS, Azure, Kubernetes, Docker, Terraform, Snowflake, Databricks, LLM, RAG, MCP, GraphQL, Kafka, PostgreSQL, and more — see JobDescriptionTechExtractor.Keywords), matched case-insensitively with a word-boundary check (so "Go" doesn't match inside "Google" — "Google Cloud" is its own multi-word keyword instead). CompanyResearchService cross-references these JD-mentioned terms against GitHubOrgSource's org-wide language signal to produce the strongest, most specific talking point when both agree.

GitHubOrgSource org resolution — a documented heuristic, not a lookup

GitHub has no "find the org for company X" API. GitHubOrgSource normalizes the company name (lowercase, strip all non-alphanumeric characters — "Booz Allen Hamilton""boozallenhamilton") and tries it directly as an org slug. This will miss companies whose GitHub org uses a different slug (abbreviations, -inc suffixes, etc.) — a known, honest gap: a miss degrades to an empty list (404 caught, never thrown), never a wrong org's data.

Pagination is bounded, not unlimited. A single GET /orgs/{org}/repos?per_page=100 page would silently bias the language ranking toward whatever repos GitHub's default ordering returns first — a real problem for large orgs (Microsoft, Google, etc.) with thousands of public repos. GitHubOrgSource follows up to 3 pages of 100 (300 repos total), stopping early the moment a page comes back with fewer than 100 entries (the last page). This keeps a single company lookup's API usage bounded and predictable — never unbounded pagination that could exhaust the 60/hour unauthenticated rate limit on one lookup — while covering enough of even a large org's repos for the top-8 language ranking to be representative rather than an arbitrary 100-repo slice.

CompanyBriefing shape

public class CompanyBriefing
{
    public string? WhatTheyDo { get; set; }
    public List<string> TechStack { get; set; } = [];
    public List<NewsItem> RecentNews { get; set; } = [];
    public List<string> TalkingPoints { get; set; } = [];
    public List<string> Sources { get; set; } = [];       // e.g. "GitHub", "GDELT", "Wikipedia"
    public DateTimeOffset AsOf { get; set; }
}

public class NewsItem
{
    public string Title { get; set; } = "";
    public DateTimeOffset PublishedAt { get; set; }
    public string Url { get; set; } = "";
    public string SourceName { get; set; } = "";
}

Every field degrades to null/empty when its source(s) had nothing for this company — matches this codebase's existing LayoffHistory/PostingSignal degradation convention.

TalkingPoints are derived, factual, never fabricated. CompanyResearchService builds them strictly from fields that were actually populated, in this priority order:

  1. JD/GitHub tech-stack overlap (e.g. "The job description emphasizes C#, and their public GitHub org also uses C# — a genuine stack match.") — falls back to "Their public engineering org is heavily {topLanguage}." when there's tech-stack data but no JD overlap.
  2. Up to two most-recent news headlines (e.g. "Recently in the news: \"Acme wins award\" (reuters.com, 2026-07-01).").
  3. The Wikipedia "what they do" summary, verbatim, as its own talking point.

If none of the above have data, TalkingPoints is empty — never a placeholder, never guessed.

Wiring

JobPosting (source) carries the briefing directly, mirroring LayoffHistory:

public CompanyBriefing? Research { get; set; }

Populated inside JobQueueService.ResyncFromLinkedInAsync for each newly-added job, right after the existing layoff-history enrichment loop:

foreach (var job in added)
{
    ct.ThrowIfCancellationRequested();
    job.Research = await research.GetBriefingAsync(job.Company, job, ct);
}

The job itself is passed (not just the company name) so JobDescriptionTechExtractor can read job.Description. A failure never blocks the sync — same graceful-degradation contract as the layoff-history loop.

DI registration in Program.cs, the same plain-singleton pattern as every other service:

builder.Services.AddSingleton<ITechStackSourceAdapter, GitHubOrgSource>();
builder.Services.AddSingleton<INewsSourceAdapter, NewsSource>();
builder.Services.AddSingleton<ICompanyBackgroundSourceAdapter, WikidataSource>();
builder.Services.AddSingleton<ICompanyResearchService, CompanyResearchService>();

The cover-letter seam

CompanyTalkingPointSelector.SelectHooks (source) is the integration point between company research and any document drafter:

public static class CompanyTalkingPointSelector
{
    public static IReadOnlyList<string> SelectHooks(CompanyBriefing? briefing, int max = 2);
}

It is pure (no I/O, no randomness), returns at most max of briefing.TalkingPoints in their existing priority order, and returns an empty list when briefing is null or has no talking points — so a job with no company data produces an unchanged cover letter.

TemplateDrafter.DraftDocumentsAsync (source) calls it directly:

var hooks = CompanyTalkingPointSelector.SelectHooks(job.Research);

and appends the hooks as a short paragraph in the cover letter body when non-empty. This is the reusable seam for a future Claude-backed IClaudeDrafter: since every IClaudeDrafter implementation already receives the full JobPosting (which carries Research), a real Claude-backed drafter can call CompanyTalkingPointSelector.SelectHooks(job.Research) the exact same way — either to get the same pre-vetted, factual hooks as prompt context/inspiration, or to weave them in verbatim like TemplateDrafter does. No new interface parameter was needed; the seam is a static, independently-testable function shared by both callers.

Frontend

CompanyResearchCard is a self-contained, standalone Angular component under shared/ — there's no job-detail page yet, so it's built the way a future one would consume it: two input() signals, briefing: CompanyBriefing | null | undefined and companyName: string, no API call and no route dependency of its own.

briefing = input<CompanyBriefing | null | undefined>(null);
companyName = input<string>('');

It renders four sections — "What they do", "Tech stack" (chips), "Recent news" (linked headlines with source + date), and "Talking points for your cover letter" — each conditionally, only when its underlying field is non-empty, plus a sourceAttribution computed signal listing which sources actually contributed (e.g. "via GitHub, GDELT, Wikipedia"). When hasAnyData() is false (no briefing, or every field empty), it shows the same empty-state idiom as the rest of the app (queue.html's @empty block, documents.html's "no documents yet" card) rather than a broken-looking blank section.

Today's only consumer is Documents, which drops it in once a job's research field is populated:

@if (hasResearch()) {
  <app-company-research-card [briefing]="job()?.research" [companyName]="job()?.company ?? ''" />
}

Documents keeps its own hasResearch()/techStack()/recentNews()/talkingPoints()/sources() helper methods (used by its own tests and available for any other template logic on that page); the card component does its own independent derivation from the briefing input so it stays fully reusable on a future job-detail page without depending on Documents at all.

Privacy

Every fetch goes to a public, free, official API (GitHub, GDELT, Wikipedia); no user data leaves the machine beyond the company-name string itself, which is the same information already visible in the job posting. There is no bulk/scheduled crawl — a company is only researched once, when its job is newly added to the queue during a LinkedIn resync. No accounts, no API keys required for the default (unauthenticated) path; GITHUB_TOKEN is opt-in and read from an environment variable the user controls, never stored by the app.

The only real fetched data, never fabricated, guarantee: every field on CompanyBriefing is either null/empty or built from text a live API actually returned. TalkingPoints are template sentences filled in with real values from the other fields — never invented facts, never guessed numbers, never a placeholder company description. When every source comes back empty (private company, no GitHub org, no Wikipedia page, no news coverage), the entire briefing is an empty shell and the UI shows nothing rather than a broken/fabricated-looking section.

Tests