Tasks: 16 — Deep Company Research
Status legend:
[ ]pending •[~]in-progress •[x]done •[-]dropped
Phase 1 — Structured contract + cache table
-
T01 — Define the
companyResearchartifact + summary schema (6 axes, per-field provenance)- Files:
packages/ai/src/structured/schemas/company-research.ts(NEW),packages/ai/src/structured/index.ts(add exports) - Acceptance:
companyResearchSummarySchemadefines 6 axes (firmographics, funding, tech, reputation, hiringTrend, news); every field is nullable and pairs with aprovenancerecord ({ source: string; fetchedAt: string; confidence: number (0–1) }ornullwhen unknown).- All string/array fields carry
.max()bounds (Article 8 §3);companyName.max(200). companyResearchArtifact = defineArtifact("company_research", 1, companyResearchSummarySchema), exported fromstructured/index.ts, following theevaluationArtifactpattern.- Inferred
CompanyResearchSummarytype exported.
- Estimate: 1 day
- Files:
-
T02 — Unit-test the artifact schema (valid/invalid axis + provenance shapes)
- Files:
packages/ai/src/structured/schemas/company-research.test.ts(NEW) - Acceptance:
- Valid full-card object parses; an unknown field is rejected (strict); a field with a value but no provenance is rejected; confidence outside 0–1 is rejected.
companyResearchArtifact.build(summary, prose)returns{ kind, schemaVersion: 1, summary }.pnpm test -- --selectProjects aigreen for this file.
- Estimate: 0.5 day
- Files:
-
T03 — Add the
company_researchcache table + export- Files:
packages/db/src/schema/company-research.ts(NEW),packages/db/src/schema/index.ts(add export) - Acceptance:
pgTable("company_research", …)withid integer().primaryKey().generatedAlwaysAsIdentity(),companyKey text().notNull().unique()(normalized name), onejsonbcolumn per axis$type<…>().default(null), aprovenance jsonbmap,fetchedAt timestampper-axis or a table-leveltimestamp("created_at").notNull().defaultNow()+updatedAt.index("company_research_company_key_idx").on(table.companyKey).- jsonb axis types mirror
CompanyResearchSummary(DX-only types, likeevaluations.ts). - No
userIdcolumn (corpus-level cache — Partition Rule, Article 6). - Exported from
packages/db/src/schema/index.ts.
- Estimate: 0.5 day
- Files:
-
T04 — Push the schema + smoke-test the table
- Files:
packages/db/drizzle.config.ts(no change — verify schema glob),packages/db/src/schema/company-research.test.ts(NEW — type/shape test) - Acceptance:
pnpm db:pushappliescompany_researchwith no errors and no destructive diff to existing tables.- Row-type test asserts the table's inferred insert/select types compile.
pnpm check-typesclean.
- Estimate: 0.5 day
- Files:
Phase 2 — Multi-source enricher core (plugin + merge-with-provenance)
-
T05 — Define the
CompanyResearchSourceplugin interface- Files:
packages/ai/src/company-research/types.ts(NEW) - Acceptance:
CompanyResearchSourceexposesname: stringandfetch(input): Promise<Partial<CompanyResearchSummary>>returning only the axes it can fill, best-effort (never throws — returns{}on failure).- Types import the summary type from
@ever-hust/aistructured module (no DB import — avoid the circular dep noted inevaluations.ts).
- Estimate: 0.5 day
- Files:
-
T06 — Implement
mergeWithProvenance+ unit tests- Files:
packages/ai/src/company-research/merge.ts(NEW),packages/ai/src/company-research/merge.test.ts(NEW) - Acceptance:
- Merges N partial source results into one
CompanyResearchSummary; per field, picks the highest-confidence then most-recent value and records its provenance. - Unknown fields remain
nullwithnullprovenance (no invention — Article 7). - Tests cover: conflict resolution by confidence, tie-break by recency, all-empty input ⇒ all
null, single-source passthrough.
- Merges N partial source results into one
- Estimate: 1 day
- Files:
-
T07 — Implement the cache read/write helpers with TTL + tests
- Files:
packages/ai/src/company-research/cache.ts(NEW),packages/ai/src/company-research/cache.test.ts(NEW) - Acceptance:
getCachedResearch(companyKey)returns the row's fresh axes and the set of stale/missing axes (per-axis TTL from constants);upsertResearch(companyKey, summary)writes viadb(@ever-hust/db) and bumpsupdatedAt.- Company-name normalization helper (
normalizeCompanyKey) case-folds + trims; tested. - Tests (mocked
db) cover: fresh hit, expired axis flagged stale, miss returns all axes stale.
- Estimate: 1 day
- Files:
-
T08 — Refactor the jobs-table aggregator into the first source plugin + tests
- Files:
packages/ai/src/company-research/sources/jobs-table.ts(NEW),packages/ai/src/company-research/sources/jobs-table.test.ts(NEW) - Acceptance:
jobsTableSourcelifts the existing logic fromtools/company-research.ts(ilike onjobs.companyName, roll up industry/size/description/locations/departments/levels viaescapeIlike) and maps it onto the firmographics + hiringTrend axes withsource:"jobs-table"provenance.- Behaviour preserved: for a fixture company it reproduces today's firmographic output (Article 9 — additive, no regression).
- Returns
{}(not a throw) when no matching jobs exist.
- Estimate: 1 day
- Files:
Phase 3 — Tool upgrade + registration + system prompt
-
T09 — Upgrade
companyResearchTool.executeto cache→sources→merge→upsert→artifact- Files:
packages/ai/src/tools/company-research.ts(UPGRADE) - Acceptance:
execute({ companyName, jobId })normalizes the key (resolvingcompanyNamefromjobswhenjobIdis given, as today), reads the cache, runs registered sources only for stale/missing axes, merges with provenance, upserts, and returnscompanyResearchArtifact.build(summary, prose)validated viaassertArtifact.inputSchemaunchanged (companyName.max(200), optionaljobId);userIdNOT an LLM param.- On total failure returns
{ error, companyName }(existing graceful shape); partial source failure still returns the axes that succeeded.
- Estimate: 1 day
- Files:
-
T10 — Add the per-source rate-limit guard + tests
- Files:
packages/ai/src/rate-limit.ts(addcheckCompanyResearchSourceLimit),packages/ai/src/rate-limit.test.ts(extend) - Acceptance:
- A per-source limiter caps source calls (mirrors
checkSearchLimit/checkRateLimitstyle); sources past the limit are skipped, not errored. - Test asserts the limiter blocks after the cap and that a blocked source yields
{}(card still returns cached/other-source axes).
- A per-source limiter caps source calls (mirrors
- Estimate: 0.5 day
- Files:
-
T11 — Confirm orchestrator registration of the upgraded tool
- Files:
packages/ai/src/agents/orchestrator.ts(verify/adjust thetoolsobject) - Acceptance:
companyResearchremains in thestreamTexttoolsobject; if any server context is now needed it is injected server-side (never an LLM param), matching thegetUserProfilepattern.stopWhen: stepCountIs(5)unchanged;orchestrator.test.tsstill green.
- Estimate: 0.5 day
- Files:
-
T12 — Update the system prompt (local + Langfuse) to document the 6-axis card
- Files:
packages/ai/src/prompts.ts(update thecompanyResearchdescription),packages/ai/src/prompts.test.ts(extend) - Acceptance:
- The
companyResearchbullet inDEFAULT_ORCHESTRATOR_PROMPTdescribes the 6 axes + per-field provenance and states it is read-only research (no submit/apply — Article 4). - A
## Company Researchusage section explains progressive presentation and the "unknown ⇒ say so, never invent" rule (Article 7). prompts.test.tsasserts the new description text; a code comment notes the Langfuseorchestrator-systemprompt must be updated in parallel.
- The
- Estimate: 0.5 day
- Files:
-
T13 — Unit-test the upgraded tool end-to-end (cache hit / miss / partial failure)
- Files:
packages/ai/src/tools/company-research.test.ts(NEW) - Acceptance:
- Cache-hit (all axes fresh) short-circuits and runs no sources.
- Cache-miss runs sources, merges, upserts, and returns a validated
company_researchartifact (assertArtifactpasses). - One source throwing/returning
{}still yields the other source's axes; result conforms tocompanyResearchSummarySchema. pnpm test -- --selectProjects aigreen.
- Estimate: 1 day
- Files:
Phase 4 — Progressive research card UI + read-only API
-
T14 — Build the progressive
CompanyResearchCardcomponent + render test- Files:
apps/web/components/canvas/company-research-card.tsx(NEW),apps/web/components/canvas/company-research-card.test.tsx(NEW) - Acceptance:
- Card uses
@ever-hust/uiCard/Badge+cn(), modelled onsalary-insights-card.tsx; renders a skeleton when axes are absent and fills each axis independently as data arrives (progressive). - Each populated field shows a provenance chip (source + relative age); unknown fields render an explicit "not available" state (never blank-as-known).
- Render test asserts skeleton-first, then field + provenance chip on data;
errorstate renders a friendly message.
- Card uses
- Estimate: 1 day
- Files:
-
T15 — Wire the canvas-sync case + canvas state
- Files:
apps/web/hooks/use-canvas-sync.ts(addcase "companyResearch",companyResearchstate,clearCompanyResearch),apps/web/app/(dashboard)/chat/page.tsx(render the card) - Acceptance:
handleToolResult("companyResearch", result)setscompanyResearchcanvas state when the result has axes or anerror;clearCompanyResearchresets it (mirrorssalaryInsights).chat/page.tsxrendersCompanyResearchCardfrom canvas state as an overlay card.- Existing canvas-sync tests still pass; a new case test asserts state update.
- Estimate: 0.5 day
- Files:
-
T16 — Read-only research hydration API route + tests
- Files:
apps/web/app/api/companies/[company]/research/route.ts(NEW),apps/web/lib/api-schemas.ts(add param schema),apps/web/app/api/companies/[company]/research/route.test.ts(NEW) - Acceptance:
- GET handler calls
requireSessionUser()thenapplyRateLimit(userId, "authenticated"), validates the[company]param with a Zod schema fromapi-schemas.ts, reads the cache (getCachedResearch) and returns the validated artifact; errors viaapiBadRequest()/apiError()fromapi-response.ts. - No mutation, no LLM call — pure cache read (Article 4 trivially satisfied).
- Test covers: unauthenticated ⇒ 401-style response, invalid param ⇒ 400, cache hit ⇒ artifact.
- GET handler calls
- Estimate: 1 day
- Files:
Phase 5 — Optional Ever Jobs firmographics source + E2E
-
T17 — Add the optional Ever Jobs firmographics source (best-effort, graceful fallback)
- Files:
packages/ai/src/company-research/sources/ever-jobs.ts(NEW),packages/ai/src/company-research/sources/ever-jobs.test.ts(NEW) - Acceptance:
everJobsSourceuseseverJobsClient(@ever-hust/jobs-api) to fill firmographics axes where the API provides them, withsource:"ever-jobs"provenance.- On API unavailability / missing fields it returns
{}(no throw) so the merger falls back tojobsTableSource(graceful degradation — Article 2). - Tests (mocked client) cover the happy path and the unavailable-API fallback path.
- Estimate: 1 day
- Files:
-
T18 — Register both sources + integrate the optional source behind the enricher
- Files:
packages/ai/src/company-research/index.ts(NEW — source registry),packages/ai/src/company-research/index.test.ts(NEW) - Acceptance:
- The enricher runs
[jobsTableSource, everJobsSource](Ever Jobs optional/best-effort) and the tool consumes this registry; jobs-table source always present. - Test asserts the registry order and that removing
everJobsSourceleaves a working jobs-table-only card (Article 9 — independently disableable).
- The enricher runs
- Estimate: 0.5 day
- Files:
-
T19 — Playwright E2E: card renders progressively with provenance
- Files:
tests/e2e/company-research.spec.ts(NEW) - Acceptance:
- Against
http://localhost:8443, asking the assistant to research a company surfaces the research card: skeleton appears first, then axes fill in, and at least one provenance chip is visible. - An unknown field renders its explicit "not available" state (no fabricated value).
pnpm test:e2egreen for this spec.
- Against
- Estimate: 1 day
- Files:
-
T20 — Full CI pass + zero-competitor self-check
- Files: (whole epic diff)
- Acceptance:
pnpm lint,pnpm check-types,pnpm test,pnpm test:e2eall green locally; CI green ondevelop(Article 10 §4).git diffgrepped for competitor names ⇒ empty (Article 11); only Ever brands appear.docs/specs/ROADMAP.mdepic-16 progress updated.
- Estimate: 0.5 day
Notes
- Write tests alongside each implementation task; do not batch testing into a final task.
- Verify zero competitor references before every commit (see constitution Article 11). Sources
are named by data class only (
jobs-table,ever-jobs). - Every AI artifact emits a Zod-validated summary alongside prose (Article 5); the
company_researchtable jsonb mirrors that summary. userIdis server-injected by the orchestrator — never an LLM-supplied param.- Update
docs/specs/ROADMAP.mdprogress when an epic's tasks complete.