Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ As usage grows, the platform needs stronger derived data pipelines, performance
- [x] Onboarding recommendations
- [x] Shared behavior pattern discovery with approach comparison
- [x] [P1] Bright spot detection: explicitly surface high performers and cross-pollinate their patterns
- [ ] [P1] Exemplar-session-to-learning-path pipeline
- [x] [P1] Exemplar-session-to-learning-path pipeline
- [ ] [P1] Team skill gap mapping by workflow, tool category, and project context
- [ ] [P2] Coaching program measurement: which onboarding or training changes improved outcomes

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"

import { LearningPathCards } from "@/components/growth/learning-path-cards"

describe("LearningPathCards", () => {
it("renders an empty state", () => {
render(
<MemoryRouter>
<LearningPathCards paths={[]} />
</MemoryRouter>,
)

expect(screen.getByText("No learning paths available.")).toBeInTheDocument()
})

it("renders exemplar study links for recommendations", () => {
render(
<MemoryRouter>
<LearningPathCards
paths={[
{
engineer_id: "eng-1",
name: "Alice Example",
total_sessions: 4,
coverage_score: 0.5,
complexity_trend: "flat",
recommendations: [
{
category: "tool_gap",
skill_area: "Edit",
title: "Learn the Edit tool",
description: "2/3 teammates use Edit, but you haven't used it yet.",
priority: "high",
evidence: {},
exemplars: [
{
session_id: "sess-1",
title: "debugging: read -> edit -> execute -> fix",
engineer_name: "Bob Example",
project_name: "primer",
summary: "Resolved an auth regression.",
relevance_reason: "Shows Edit in a successful peer workflow.",
workflow_archetype: "debugging",
workflow_fingerprint: "debugging: read -> edit -> execute -> fix",
duration_seconds: 75,
estimated_cost: 0.5,
tools_used: ["Read", "Edit", "Bash"],
},
],
},
],
},
]}
/>
</MemoryRouter>,
)

expect(screen.getByText("Study Exemplars")).toBeInTheDocument()
expect(screen.getByText("Bob Example • primer")).toBeInTheDocument()
expect(screen.getByText("Shows Edit in a successful peer workflow.")).toBeInTheDocument()
expect(screen.getByText("Debugging")).toBeInTheDocument()
expect(screen.getByRole("link")).toHaveAttribute("href", "/sessions/sess-1")
})
})
47 changes: 46 additions & 1 deletion frontend/src/components/growth/learning-path-cards.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Link } from "react-router-dom"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { cn, formatCost, formatDuration, formatLabel } from "@/lib/utils"
import type { EngineerLearningPath } from "@/types/api"

const categoryColors: Record<string, string> = {
Expand Down Expand Up @@ -82,6 +84,49 @@ export function LearningPathCards({ paths }: LearningPathCardsProps) {
</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">{rec.description}</p>
{rec.exemplars.length > 0 && (
<div className="mt-3 space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Study Exemplars
</p>
<div className="grid gap-2 lg:grid-cols-2">
{rec.exemplars.map((exemplar) => (
<Link
key={`${rec.category}-${rec.skill_area}-${exemplar.session_id}`}
to={`/sessions/${exemplar.session_id}`}
className="rounded-md border border-border/70 bg-muted/20 p-3 transition-colors hover:bg-muted/40"
>
<div className="space-y-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">{exemplar.title}</span>
{exemplar.workflow_archetype && (
<Badge variant="outline">
{formatLabel(exemplar.workflow_archetype)}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{exemplar.engineer_name}
{exemplar.project_name ? ` • ${exemplar.project_name}` : ""}
</p>
<p className="text-xs text-muted-foreground">
{exemplar.relevance_reason}
</p>
<div className="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<span>{formatDuration(exemplar.duration_seconds)}</span>
{exemplar.estimated_cost != null && (
<span>{formatCost(exemplar.estimated_cost)}</span>
)}
{exemplar.tools_used.slice(0, 3).map((tool) => (
<span key={tool}>{tool}</span>
))}
</div>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
))}
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/pages/__tests__/growth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ vi.mock("@/components/insights/team-skill-gaps", () => ({
vi.mock("@/components/growth/skill-universe-chart", () => ({
SkillUniverseChart: () => <div>skill universe</div>,
}))
vi.mock("@/components/growth/learning-path-cards", () => ({
LearningPathCards: () => <div>learning paths</div>,
}))
vi.mock("@/components/insights/engineer-skill-table", () => ({
EngineerSkillTable: () => <div>engineer skill table</div>,
}))
Expand Down Expand Up @@ -192,6 +195,46 @@ describe("GrowthPage", () => {
expect(screen.getByText("exemplar session library")).toBeInTheDocument()
})

it("shows learning paths on the skills tab", () => {
mockUseSkillInventory.mockReturnValue({
data: {
engineer_profiles: [],
team_skill_gaps: [],
total_engineers: 1,
total_session_types: 1,
total_tools_used: 1,
},
isLoading: false,
} as unknown as ReturnType<typeof useSkillInventory>)
mockUseLearningPaths.mockReturnValue({
data: {
engineer_paths: [
{
engineer_id: "eng-1",
name: "Alice Example",
total_sessions: 4,
recommendations: [],
coverage_score: 0.5,
complexity_trend: "flat",
},
],
team_skill_universe: {},
sessions_analyzed: 4,
},
isLoading: false,
} as unknown as ReturnType<typeof useLearningPaths>)
mockUseEngineerProfile.mockReturnValue({
data: null,
isLoading: false,
} as unknown as ReturnType<typeof useEngineerProfile>)

renderPage()
fireEvent.click(screen.getByRole("button", { name: "Skills" }))

expect(screen.getByText("Learning Paths")).toBeInTheDocument()
expect(screen.getByText("learning paths")).toBeInTheDocument()
})

it("shows an engineer chooser for API-key admins without a selected context", () => {
mockGetApiKey.mockReturnValue("test-key")
mockUseAuth.mockReturnValue({ user: null } as ReturnType<typeof useAuth>)
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/pages/growth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { SkillInventorySummary } from "@/components/insights/skill-inventory-sum
import { CoverageSummary } from "@/components/growth/coverage-summary"
import { TeamSkillGaps } from "@/components/insights/team-skill-gaps"
import { SkillUniverseChart } from "@/components/growth/skill-universe-chart"
import { LearningPathCards } from "@/components/growth/learning-path-cards"
import { WorkflowPlaybookCards } from "@/components/growth/workflow-playbook-cards"
import { EngineerSkillTable } from "@/components/insights/engineer-skill-table"
import { Card, CardContent } from "@/components/ui/card"
Expand Down Expand Up @@ -102,6 +103,17 @@ function SkillsTab({ teamId, startDate, endDate }: TabProps) {
<SkillUniverseChart universe={learning.team_skill_universe} />
</div>
)}
{learning && learning.engineer_paths.length > 0 && (
<div className="space-y-3">
<div className="space-y-1">
<h3 className="text-sm font-medium">Learning Paths</h3>
<p className="text-sm text-muted-foreground">
Personalized recommendations with exemplar peer sessions to study.
</p>
</div>
<LearningPathCards paths={learning.engineer_paths} />
</div>
)}
{skills && <EngineerSkillTable data={skills.engineer_profiles} />}
</div>
)
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,13 +761,28 @@ export interface SkillInventoryResponse {

// --- Learning Paths ---

export interface LearningRecommendationExemplar {
session_id: string
title: string
engineer_name: string
project_name: string | null
summary: string | null
relevance_reason: string
workflow_archetype: string | null
workflow_fingerprint: string | null
duration_seconds: number | null
estimated_cost: number | null
tools_used: string[]
}

export interface LearningRecommendation {
category: string
skill_area: string
title: string
description: string
priority: string
evidence: Record<string, unknown>
exemplars: LearningRecommendationExemplar[]
}

export interface EngineerLearningPath {
Expand Down
15 changes: 15 additions & 0 deletions src/primer/common/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1098,13 +1098,28 @@ class SkillInventoryResponse(BaseModel):
# --- Learning Paths ---


class LearningRecommendationExemplar(BaseModel):
session_id: str
title: str
engineer_name: str
project_name: str | None = None
summary: str | None = None
relevance_reason: str
workflow_archetype: str | None = None
workflow_fingerprint: str | None = None
duration_seconds: float | None
estimated_cost: float | None = None
tools_used: list[str]


class LearningRecommendation(BaseModel):
category: str # "session_type_gap", "tool_gap", "complexity", "goal_gap"
skill_area: str
title: str
description: str
priority: str # "high", "medium", "low"
evidence: dict
exemplars: list[LearningRecommendationExemplar] = []


class EngineerLearningPath(BaseModel):
Expand Down
Loading
Loading