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:
- 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.
- Up to two most-recent news headlines (e.g. "Recently in the news: \"Acme wins award\" (reuters.com, 2026-07-01).").
- 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¶
GitHubOrgSourceTests.cs,NewsSourceTests.cs,WikidataSourceTests.cs— FlurlHttpTeststubs built from the actual response shapes observed during live research fetches; zero real network in any test run.JobDescriptionTechExtractorTests.cs— pure, no-network JD-scan tests.CompanyTalkingPointSelectorTests.cs— pure seam tests (null/empty briefing,maxbounding, never-fabricates guarantee).CompanyResearchServiceTests.cs— aggregation/graceful-degradation/talking-point-derivation logic against mocked adapters.stryker-config.jsonadds**/Research/*.csto themutatelist.company-research-card.spec.ts— every conditional section (present/absent), empty-state, chip count, news-linkhref/target/rel, and thehasAnyData/sourceAttributioncomputed signals;documents.spec.tscoversDocuments' own derived-state methods and that the card only renders when research data exists.- Frontend Stryker (
frontend/stryker.conf.json) mutatessrc/app/**/*.tsby glob, socompany-research-card.tsis covered automatically with no config change needed.
Related docs¶
- Plain-language version: ../plain/company-research.md
- The layoff-history enrichment this mirrors: job-signals.md
- Testing philosophy and tooling: testing.md