Tasks: 08 — Funnel & Rejection-Pattern Analytics
Status legend:
[ ]pending •[~]in-progress •[x]done •[-]dropped
Phase 1 — Pure aggregation engine
-
T01 — Define the funnel-insights Zod contract & row types
- Files:
packages/ai/src/insights/types.ts,packages/ai/src/insights/types.test.ts - Acceptance:
- Exports
FunnelInsightsSchema(Zod) covering: ordered stages (applied → responded → interview → offer), per-stage counts + conversion rates,timeInStageDays,bySegmentkeyed by dimension, andscoreFloor: { value: number | null; sampleSize: number; sufficient: boolean }. - Exports inferred
FunnelInsightstype and aSegmentDimensionenum (scoreBand | jobFamily | source | compRange | remote). - Defines an internal
FunnelRowtype (one application joined to its evaluation: stage,stageChangedAt,score,band,family,source,compRange,isRemote) as the typed seam from #2/#3. - Test asserts a sample object parses; a malformed object fails.
- Exports
- Estimate: 0.5 day
- Files:
-
T02 — Implement stage conversion + time-in-stage
- Files:
packages/ai/src/insights/funnel.ts,packages/ai/src/insights/funnel.test.ts - Acceptance:
computeFunnel(rows: FunnelRow[])returns per-stage counts and conversion rates (each stage / the prior reachable stage), validating againstFunnelInsightsSchema.timeInStage(rows)derives median days per transition fromstageChangedAt; null when a stage is never reached.- Pure (no DB import); deterministic on fixtures.
- Tests cover: empty input, all-stalled-at-applied, a full applied→offer path, and a row missing
stageChangedAt.
- Estimate: 1 day
- Files:
-
T03 — Implement conversion-by-segment
- Files:
packages/ai/src/insights/funnel.ts,packages/ai/src/insights/funnel.test.ts - Acceptance:
conversionBySegment(rows, dim: SegmentDimension)buckets rows by the dimension and returns applied→responded+ conversion per bucket, sorted, with a per-bucketsampleSize.- Comp range and score band use fixed buckets (not raw values) to bound cardinality.
- Tests cover each dimension and a buckets-below-min-sample case.
- Estimate: 1 day
- Files:
-
T04 — Implement the empirical score floor
- Files:
packages/ai/src/insights/funnel.ts,packages/ai/src/insights/funnel.test.ts - Acceptance:
computeScoreFloor(rows, minSample)= minscoreamong rows that reachedresponded+; returns{ value, sampleSize, sufficient }wheresufficientis false (andvaluenull) belowminSample— never fabricates a floor (Article 7).- Tests cover: no
responded+ rows, ties at the floor, single qualifying row, and thesufficientboundary.
- Estimate: 0.5 day
- Files:
Phase 2 — Read API, tool & optional cache
-
T05 — Add the
insights_cachetable and push it- Files:
packages/db/src/schema/insights-cache.ts,packages/db/src/schema/index.ts,packages/db/src/schema/insights-cache.test.ts - Acceptance:
- Table
insights_cache:id integer generatedAlwaysAsIdentity(),userId text notNull references users.id onDelete cascade,kind textenum (funnel),payload jsonb $type<FunnelInsights>(),computedAt timestamp notNull defaultNow(), index on(userId, kind). - Exported from
schema/index.ts; house style matchesapplications.ts. pnpm db:pushapplies cleanly against a dev DB.- Unit test asserts the column/enum shape compiles and the row type matches
FunnelInsights.
- Table
- Estimate: 0.5 day
- Files:
-
T06 —
loadFunnelRows(userId)join with cache read-through- Files:
packages/ai/src/insights/query.ts,packages/ai/src/insights/query.test.ts - Acceptance:
- Joins
applications↔evaluationson(userId, jobId), scoped to the passeduserId, returningFunnelRow[]. - Cache read-through: a fresh
insights_cacherow short-circuits recompute; a miss recomputes and writes the cache. dbimported from@ever-hust/db;userIdis a function param, never LLM-supplied.- Test mocks
dband asserts the join filter, the cache-hit short-circuit, and the cache-miss write.
- Joins
- Estimate: 1 day
- Files:
-
T07 —
getFunnelInsightstool + export + orchestrator registration- Files:
packages/ai/src/tools/get-funnel-insights.ts,packages/ai/src/tools/index.ts,packages/ai/src/agents/orchestrator.ts,packages/ai/src/tools/get-funnel-insights.test.ts - Acceptance:
tool({ description, inputSchema: z.object({ ... }).strict(), execute }); input has only optional display hints (e.g.segmentDimension) with.max()bounds — nouserIdin the schema.executecallsloadFunnelRows+computeFunnel/segment/floor and returns aFunnelInsightsSchema-valid plain object.- Exported from
tools/index.ts; registered in thetools: { ... }object inorchestrator.tswithuserIdinjected server-side (same wrapper pattern asfavoriteJob/savePreferences); stays withinstepCountIs(5). - Test: tool returns a valid object on a fixture; calling it never reads a
userIdfrom tool input.
- Estimate: 1 day
- Files:
-
T08 —
GET /api/insights/funnelroute- Files:
apps/web/app/api/insights/funnel/route.ts,apps/web/lib/__tests__orapps/web/app/api/insights/funnel/route.test.ts - Acceptance:
requireSessionUser()resolves the user;applyRateLimit(userId, "authenticated")applied; returnsFunnelInsightsJSON.- Errors via
apiError()/apiBadRequest()fromapps/web/lib/api-response.ts; defaultCache-Control: private, no-cacheheaders preserved. userIdcomes only from the session — never the query string.- Test (web-lib project) asserts 401 without session, 200 + valid shape with a mocked session.
- Estimate: 0.5 day
- Files:
-
T09 — Document
getFunnelInsightsin the system prompt- Files:
packages/ai/src/prompts.ts,packages/ai/src/prompts.test.ts - Acceptance:
DEFAULT_ORCHESTRATOR_PROMPTlistsgetFunnelInsightsunder "Your Capabilities" with a one-line description and a usage note (use when the user asks how their search is performing / where they stall / what score floor to target).- Note that the Langfuse
orchestrator-systemprompt must be updated to match (comment in file). prompts.test.tsasserts the prompt text mentionsgetFunnelInsights.
- Estimate: 0.5 day
- Files:
Phase 3 — Insights page & canvas surfacing
-
T10 — Funnel insights canvas card
- Files:
apps/web/components/canvas/funnel-insights-card.tsx - Acceptance:
- Renders the funnel (stage counts + conversion), by-segment breakdown, and a score-floor callout, reusing the in-house bar/range/
Sectionprimitives and@ever-hust/ui/{card,badge}+cn()(modelled onsalary-insights-card.tsx). - Props typed from the exported
FunnelInsightstype (single source of truth). - Empty-history and "floor: not enough data" states render gracefully (no crash, no fabricated number).
- Renders the funnel (stage counts + conversion), by-segment breakdown, and a score-floor callout, reusing the in-house bar/range/
- Estimate: 1 day
- Files:
-
T11 — Wire the tool result into canvas sync
- Files:
apps/web/hooks/use-canvas-sync.ts - Acceptance:
- Adds
case "getFunnelInsights"tohandleToolResultthat stores the funnel object in canvas state and surfacesFunnelInsightsCard(alongside the existingsalaryInsightsoverlay pattern, with a matchingclearFunnelInsightscallback). - Unknown-tool default branch unchanged.
- Adds
- Estimate: 0.5 day
- Files:
-
T12 — Insights dashboard page
- Files:
apps/web/app/(dashboard)/insights/page.tsx - Acceptance:
- Client page fetches
/api/insights/funnel, rendersFunnelInsightsCardwith loading/error/empty states using the sharedPageHeader/EmptyState/ErrorStatecomponents. - Reachable from dashboard navigation; respects the
(dashboard)auth proxy.
- Client page fetches
- Estimate: 0.5 day
- Files:
Phase 4 — Opt-in auto-tune (human-in-the-loop)
-
T13 — Apply-floor API route + schema
- Files:
apps/web/app/api/insights/apply-floor/route.ts,apps/web/lib/api-schemas.ts,apps/web/app/api/insights/apply-floor/route.test.ts - Acceptance:
- POST,
requireSessionUser(), body validated by a new Zod schema inapi-schemas.ts({ floor: number }, bounded range);applyRateLimit(userId, "authenticated"). - Writes
users.preferences.evaluationFloorfor the session user only (merges into existingpreferencesJSON, does not clobber other keys). - Rejects an out-of-range or absent floor via
apiBadRequest(); no write on validation failure. - Test asserts: 401 without session, 400 on bad body, 200 + persisted preference on valid body.
- POST,
- Estimate: 1 day
- Files:
-
T14 — "Apply this threshold" explicit-confirm action
- Files:
apps/web/components/canvas/funnel-insights-card.tsx - Acceptance:
- Score-floor callout shows an "Apply this threshold" button that opens a confirm
@ever-hust/ui/dialogshowing current → new floor; only on explicit confirm does it POST/api/insights/apply-floor(Article 4 — never auto-applied). - Button is disabled when
scoreFloor.sufficientis false (insufficient history). - Success toast / state reflects the saved floor.
- Score-floor callout shows an "Apply this threshold" button that opens a confirm
- Estimate: 0.5 day
- Files:
Phase 5 — E2E & hardening
-
T15 — Playwright E2E for the Insights flow
- Files:
tests/e2e/insights.spec.ts - Acceptance:
- Against a seeded fixture-history user (
pnpm db:seed), the/insightspage renders the funnel, a by-segment breakdown, and the score-floor callout. - Exercises the apply-floor confirm dialog and asserts the preference persists (and that nothing is written without confirming).
pnpm test:e2egreen athttp://localhost:8443.
- Against a seeded fixture-history user (
- Estimate: 1 day
- Files:
-
T16 — Lint, type-check & competitor-clean gate
- Files: (repo-wide; no new source)
- Acceptance:
pnpm lint,pnpm check-types,pnpm test,pnpm test:e2eall green.- Grep the diff for competitor names → empty result (Article 11).
docs/specs/ROADMAP.mdprogress updated for epic 08.
- 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).
- Update
docs/specs/ROADMAP.mdprogress when an epic's tasks complete. - Upstream contracts assumed present: application stages +
stageChangedAt(#2),evaluationsscore/band/family (#3), the shared Zod-artifact contract (#5), grounded/no-invent helpers (#6). userIdis always server-injected (orchestrator wrapper orrequireSessionUser()) — never a tool-input or query-string field.