diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 24e5751..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 62174da..28851dc 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,5 @@ vite.config.ts.timestamp-* # Python virtual environment venv/ .venv/ +.DS_Store +docs/.DS_Store diff --git a/README.md b/README.md index ca59283..50d73f0 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ cd frontend pnpm install ``` -This also installs the pre-push git hooks via lefthook. Hooks run ESLint, Prettier, and TypeScript checks before any push reaches GitHub. +This also installs git hooks via lefthook. **Pre-commit** auto-formats staged frontend/backend files; **pre-push** runs ESLint, Prettier check, and TypeScript on the frontend (plus Ruff on the backend). ## Running the project diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index 70733fc..0000000 Binary files a/docs/.DS_Store and /dev/null differ diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 0646fac..dcee3d6 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -3,4 +3,5 @@ out/ build/ node_modules/ pnpm-lock.yaml +package-lock.json next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8f44869..2568218 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -131,7 +131,10 @@ Run from `frontend/` unless noted. Install hooks: `pnpm install` (runs `lefthook install` via `prepare`). -Pre-push hooks (repo root `lefthook.yml`): ESLint, Prettier check, and TypeScript on the frontend; Ruff on the backend. Set `LEFTHOOK=0` to skip in CI if needed. +Git hooks (repo root `lefthook.yml`): + +- **pre-commit:** Prettier and Ruff format **staged** files and re-stage fixes (`stage_fixed: true`) — formatting is applied before the commit lands. +- **pre-push:** ESLint, Prettier check, and TypeScript on the frontend; Ruff check/format on the backend (catches `--no-verify` commits). Set `LEFTHOOK=0` to skip in CI if needed. Script reference: [frontend/README.md](./README.md). @@ -240,7 +243,7 @@ External links may use `` with `target="_blank"` and `rel="noopener noreferre ## Git conventions - Ask before committing or pushing. -- Pre-push: lefthook runs frontend lint, format check, and typecheck (see `lefthook.yml` at repo root). +- Hooks: pre-commit auto-formats staged files; pre-push runs lint, format check, and typecheck (see `lefthook.yml` at repo root). - Keep commits focused; match existing message style on the branch when one exists. --- diff --git a/frontend/app/dev/flow/FlowDevClient.module.css b/frontend/app/dev/flow/FlowDevClient.module.css new file mode 100644 index 0000000..caa0d2d --- /dev/null +++ b/frontend/app/dev/flow/FlowDevClient.module.css @@ -0,0 +1,66 @@ +.layout { + display: flex; + flex-direction: column; + gap: 2rem; + max-width: 56rem; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.button { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent); + background: var(--foreground); + color: var(--background); + cursor: pointer; +} + +.buttonSecondary { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 600; + border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent); + background: transparent; + color: var(--foreground); + cursor: pointer; +} + +.button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.stepHint { + margin: 0; + font-size: 0.8125rem; + opacity: 0.65; +} + +.stages { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.scorecardSection { + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 0.5rem; + border-top: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent); +} + +.scorecardTitle { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} diff --git a/frontend/app/dev/flow/FlowDevClient.tsx b/frontend/app/dev/flow/FlowDevClient.tsx new file mode 100644 index 0000000..a8ed1b8 --- /dev/null +++ b/frontend/app/dev/flow/FlowDevClient.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; + +import { ScorecardPanel } from "@/app/scorecard/ScorecardPanel"; +import { FlowStageCard } from "@/app/dev/flow/components/FlowStageCard/FlowStageCard"; +import { aggregateReviewPayload } from "@/lib/interview-coach/aggregateReviewPayload"; +import { + mockDeliveryScores, + mockModelAnswer, + mockQualitativeFeedback, + mockQuestion, + mockTranscript, + mockTranscriptScores, +} from "@/lib/interview-coach/mocks"; +import { INITIAL_PIPELINE_STAGES } from "@/lib/interview-coach/pipelineStages"; +import type { PipelineStage, SessionReviewResult } from "@/lib/interview-coach/types"; + +import styles from "./FlowDevClient.module.css"; + +const MOCK_RECORDING = { + questionId: mockQuestion.id, + audioMimeType: "audio/webm", + durationSeconds: 15.1, + blobRef: "mock-audio-blob", +}; + +function cloneStages(): PipelineStage[] { + return INITIAL_PIPELINE_STAGES.map((s) => ({ ...s })); +} + +export function FlowDevClient() { + const [stages, setStages] = useState(cloneStages); + const [stepIndex, setStepIndex] = useState(-1); + const [sessionResult, setSessionResult] = useState(null); + + const canAdvance = stepIndex < stages.length - 1; + const isComplete = stepIndex >= stages.length - 1; + + const advance = useCallback(() => { + const nextIndex = stepIndex + 1; + if (nextIndex >= stages.length) return; + + setStages((prev) => { + const next = prev.map((s) => ({ ...s })); + const stage = next[nextIndex]; + if (!stage) return prev; + + stage.status = "done"; + + switch (stage.id) { + case "record": + stage.input = { note: "User action in browser" }; + stage.output = MOCK_RECORDING; + break; + case "transcribe": + stage.input = { audio: MOCK_RECORDING }; + stage.output = mockTranscript; + break; + case "audioScores": + stage.input = { audio: MOCK_RECORDING }; + stage.output = mockDeliveryScores; + break; + case "transcriptScores": + stage.input = { transcript: mockTranscript }; + stage.output = mockTranscriptScores; + break; + case "aggregate": { + const payload = aggregateReviewPayload({ + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + }); + stage.input = { + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + }; + stage.output = payload; + break; + } + case "llmFeedback": { + const payload = aggregateReviewPayload({ + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + }); + stage.input = payload; + stage.output = { + feedback: mockQualitativeFeedback, + modelAnswer: mockModelAnswer, + }; + break; + } + case "scorecard": { + const context = aggregateReviewPayload({ + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + }); + const result: SessionReviewResult = { + context, + feedback: mockQualitativeFeedback, + modelAnswer: mockModelAnswer, + }; + stage.input = result; + stage.output = { rendered: "ScorecardPanel" }; + setSessionResult(result); + break; + } + default: + break; + } + + if (nextIndex + 1 < next.length) { + const upcoming = next[nextIndex + 1]; + if (upcoming && upcoming.status === "idle") { + upcoming.status = "pending"; + } + } + + return next; + }); + + setStepIndex(nextIndex); + }, [stepIndex, stages.length]); + + const reset = useCallback(() => { + setStages(cloneStages()); + setStepIndex(-1); + setSessionResult(null); + }, []); + + const loadingFlags = useMemo(() => { + const done = (id: PipelineStage["id"]) => stages.find((s) => s.id === id)?.status === "done"; + return { + loadingDelivery: !done("audioScores"), + loadingTranscriptScores: !done("transcriptScores"), + loadingFeedback: !done("llmFeedback"), + }; + }, [stages]); + + return ( +
+
+ + +

+ Step {Math.max(0, stepIndex + 1)} / {stages.length} + {isComplete ? " — pipeline complete" : ""} +

+
+ +
+ {stages.map((stage) => ( + + ))} +
+ +
+

+ Live scorecard preview +

+ = 0} + loadingTranscriptScores={loadingFlags.loadingTranscriptScores && stepIndex >= 0} + loadingFeedback={loadingFlags.loadingFeedback && stepIndex >= 0} + /> +
+
+ ); +} diff --git a/frontend/app/dev/flow/components/FlowStageCard/FlowStageCard.module.css b/frontend/app/dev/flow/components/FlowStageCard/FlowStageCard.module.css new file mode 100644 index 0000000..ddae336 --- /dev/null +++ b/frontend/app/dev/flow/components/FlowStageCard/FlowStageCard.module.css @@ -0,0 +1,92 @@ +.root { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-radius: 0.5rem; + border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent); + background: color-mix(in srgb, var(--foreground) 3%, transparent); +} + +.rootIdle { + opacity: 0.55; +} + +.rootPending { + border-color: color-mix(in srgb, #3b82f6 50%, transparent); +} + +.rootDone { + border-color: color-mix(in srgb, #22c55e 45%, transparent); +} + +.rootError { + border-color: color-mix(in srgb, #ef4444 50%, transparent); +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.description { + margin: 0.25rem 0 0; + font-size: 0.8125rem; + opacity: 0.65; + line-height: 1.4; +} + +.status { + padding: 0.15rem 0.45rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + font-family: var(--font-mono), monospace; +} + +.statusIdle { + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} + +.statusPending { + background: color-mix(in srgb, #3b82f6 20%, transparent); + color: #2563eb; +} + +.statusDone { + background: color-mix(in srgb, #22c55e 20%, transparent); + color: #15803d; +} + +.statusError { + background: color-mix(in srgb, #ef4444 20%, transparent); + color: #b91c1c; +} + +.payloads { + display: grid; + gap: 0.75rem; +} + +@media (min-width: 768px) { + .payloads { + grid-template-columns: 1fr 1fr; + } +} + +.error { + margin: 0; + font-size: 0.8125rem; + color: #b91c1c; +} diff --git a/frontend/app/dev/flow/components/FlowStageCard/FlowStageCard.tsx b/frontend/app/dev/flow/components/FlowStageCard/FlowStageCard.tsx new file mode 100644 index 0000000..ac78513 --- /dev/null +++ b/frontend/app/dev/flow/components/FlowStageCard/FlowStageCard.tsx @@ -0,0 +1,46 @@ +import { JsonPayloadBlock } from "@/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock"; +import type { PipelineStage } from "@/lib/interview-coach/types"; + +import styles from "./FlowStageCard.module.css"; + +export type FlowStageCardProps = { + stage: PipelineStage; +}; + +const STATUS_CLASS: Record = { + idle: styles.statusIdle, + pending: styles.statusPending, + done: styles.statusDone, + error: styles.statusError, +}; + +const ROOT_STATUS_CLASS: Record = { + idle: styles.rootIdle, + pending: styles.rootPending, + done: styles.rootDone, + error: styles.rootError, +}; + +export function FlowStageCard({ stage }: FlowStageCardProps) { + return ( +
+
+
+

+ {stage.label} +

+

{stage.description}

+
+ {stage.status} +
+
+ + +
+ {stage.error ?

{stage.error}

: null} +
+ ); +} diff --git a/frontend/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock.module.css b/frontend/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock.module.css new file mode 100644 index 0000000..94937f9 --- /dev/null +++ b/frontend/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock.module.css @@ -0,0 +1,28 @@ +.root { + margin: 0; + padding: 0.75rem; + border-radius: 0.375rem; + font-family: var(--font-mono), monospace; + font-size: 0.6875rem; + line-height: 1.45; + overflow-x: auto; + background: color-mix(in srgb, var(--foreground) 8%, transparent); + white-space: pre-wrap; + word-break: break-word; +} + +.empty { + margin: 0; + font-size: 0.75rem; + font-style: italic; + opacity: 0.5; +} + +.label { + margin: 0 0 0.35rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; +} diff --git a/frontend/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock.tsx b/frontend/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock.tsx new file mode 100644 index 0000000..136f197 --- /dev/null +++ b/frontend/app/dev/flow/components/JsonPayloadBlock/JsonPayloadBlock.tsx @@ -0,0 +1,19 @@ +import styles from "./JsonPayloadBlock.module.css"; + +export type JsonPayloadBlockProps = { + label: string; + data?: unknown; +}; + +export function JsonPayloadBlock({ label, data }: JsonPayloadBlockProps) { + return ( +
+

{label}

+ {data === undefined ? ( +

+ ) : ( +
{JSON.stringify(data, null, 2)}
+ )} +
+ ); +} diff --git a/frontend/app/dev/flow/page.tsx b/frontend/app/dev/flow/page.tsx new file mode 100644 index 0000000..dd05f98 --- /dev/null +++ b/frontend/app/dev/flow/page.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; + +import { FlowDevClient } from "@/app/dev/flow/FlowDevClient"; + +export default function FlowDevPage() { + return ( +
+
+ + ← Home + +

Pipeline flow (dev)

+

+ Step through mocked pipeline stages. Each card shows input/output JSON as data would move + between browser, Whisper, emotion model, transcript classifier, aggregator, LLM, and + scorecard UI. +

+
+ +
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index e3c0158..2bb12d5 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Interview Coach", + description: "AI-powered interview coaching with delivery and answer feedback", }; export default function RootLayout({ diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 3f36f7c..0d2813c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,65 +1,34 @@ -import Image from "next/image"; +import Link from "next/link"; export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
+
+
+

+ Interview Coach +

+

+ Practice answers. Get structured feedback. +

+

+ Frontend shells for the scorecard and pipeline are ready for integration. Use the dev flow + page to watch mock data move through each stage. +

+
+
); } diff --git a/frontend/app/scorecard/ScorecardPanel.tsx b/frontend/app/scorecard/ScorecardPanel.tsx new file mode 100644 index 0000000..25ad4bf --- /dev/null +++ b/frontend/app/scorecard/ScorecardPanel.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Scorecard } from "@/app/scorecard/components/Scorecard/Scorecard"; +import type { SessionReviewResult } from "@/lib/interview-coach/types"; + +export type ScorecardPanelProps = { + result?: SessionReviewResult | null; + loadingDelivery?: boolean; + loadingTranscriptScores?: boolean; + loadingFeedback?: boolean; +}; + +/** + * Container shell: will own fetch + session state later. + * For now passes through partial or full SessionReviewResult. + */ +export function ScorecardPanel({ + result = null, + loadingDelivery = false, + loadingTranscriptScores = false, + loadingFeedback = false, +}: ScorecardPanelProps) { + const context = result?.context; + + return ( + + ); +} diff --git a/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.module.css b/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.module.css new file mode 100644 index 0000000..1aa8873 --- /dev/null +++ b/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.module.css @@ -0,0 +1,64 @@ +.root { + border: 1px dashed var(--foreground); + border-opacity: 0.2; + border-radius: 0.5rem; + padding: 1rem 1.25rem; + background: color-mix(in srgb, var(--foreground) 4%, transparent); +} + +.title { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + opacity: 0.7; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.metric { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.label { + font-size: 0.75rem; + opacity: 0.65; +} + +.value { + font-family: var(--font-mono), monospace; + font-size: 1.125rem; + font-weight: 600; +} + +.placeholder { + margin: 0; + font-size: 0.875rem; + opacity: 0.55; + font-style: italic; +} + +.skeletonBar { + height: 1.125rem; + width: 3rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--foreground) 12%, transparent); + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.45; + } + 50% { + opacity: 0.9; + } +} diff --git a/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.stories.tsx b/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.stories.tsx new file mode 100644 index 0000000..48957b5 --- /dev/null +++ b/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { mockDeliveryScores } from "@/lib/interview-coach/mocks"; + +import { DeliveryScores } from "./DeliveryScores"; + +const meta: Meta = { + title: "Scorecard/DeliveryScores", + component: DeliveryScores, +}; + +export default meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { loading: true }, +}; + +export const Populated: Story = { + args: { scores: mockDeliveryScores }, +}; + +export const Empty: Story = { + args: { scores: null }, +}; diff --git a/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.tsx b/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.tsx new file mode 100644 index 0000000..75f42f9 --- /dev/null +++ b/frontend/app/scorecard/components/DeliveryScores/DeliveryScores.tsx @@ -0,0 +1,49 @@ +import type { DeliveryScores as DeliveryScoresType } from "@/lib/interview-coach/types"; + +import styles from "./DeliveryScores.module.css"; + +export type DeliveryScoresProps = { + loading?: boolean; + scores?: DeliveryScoresType | null; +}; + +const METRICS: { key: keyof DeliveryScoresType; label: string }[] = [ + { key: "arousal", label: "Arousal" }, + { key: "dominance", label: "Dominance" }, + { key: "valence", label: "Valence" }, +]; + +function formatScore(value: number): string { + return value.toFixed(2); +} + +export function DeliveryScores({ loading = false, scores = null }: DeliveryScoresProps) { + return ( +
+

+ Audio delivery (wav2vec2) +

+ {loading ? ( +
+ {METRICS.map(({ key }) => ( +
+ {key} +
+
+ ))} +
+ ) : scores ? ( +
+ {METRICS.map(({ key, label }) => ( +
+
{label}
+
{formatScore(scores[key])}
+
+ ))} +
+ ) : ( +

Awaiting POST /emotion/analyze

+ )} +
+ ); +} diff --git a/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.module.css b/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.module.css new file mode 100644 index 0000000..1645340 --- /dev/null +++ b/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.module.css @@ -0,0 +1,34 @@ +.root { + border: 1px dashed var(--foreground); + border-opacity: 0.2; + border-radius: 0.5rem; + padding: 1rem 1.25rem; + background: color-mix(in srgb, var(--foreground) 3%, transparent); +} + +.title { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + opacity: 0.7; +} + +.text { + margin: 0; + line-height: 1.55; +} + +.placeholder { + margin: 0; + font-size: 0.875rem; + opacity: 0.55; + font-style: italic; +} + +.skeletonBlock { + height: 4rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} diff --git a/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.stories.tsx b/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.stories.tsx new file mode 100644 index 0000000..a5c77a7 --- /dev/null +++ b/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { mockModelAnswer } from "@/lib/interview-coach/mocks"; + +import { ModelAnswer } from "./ModelAnswer"; + +const meta: Meta = { + title: "Scorecard/ModelAnswer", + component: ModelAnswer, +}; + +export default meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { loading: true }, +}; + +export const Populated: Story = { + args: { modelAnswer: mockModelAnswer }, +}; + +export const Empty: Story = { + args: { modelAnswer: null }, +}; diff --git a/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.tsx b/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.tsx new file mode 100644 index 0000000..323858b --- /dev/null +++ b/frontend/app/scorecard/components/ModelAnswer/ModelAnswer.tsx @@ -0,0 +1,25 @@ +import type { ModelAnswer as ModelAnswerType } from "@/lib/interview-coach/types"; + +import styles from "./ModelAnswer.module.css"; + +export type ModelAnswerProps = { + loading?: boolean; + modelAnswer?: ModelAnswerType | null; +}; + +export function ModelAnswer({ loading = false, modelAnswer = null }: ModelAnswerProps) { + return ( +
+

+ Model answer (shell) +

+ {loading ? ( +
+ ) : modelAnswer ? ( +

{modelAnswer.text}

+ ) : ( +

Included in LLM feedback response

+ )} +
+ ); +} diff --git a/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.module.css b/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.module.css new file mode 100644 index 0000000..6fc43ea --- /dev/null +++ b/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.module.css @@ -0,0 +1,62 @@ +.root { + border: 1px dashed var(--foreground); + border-opacity: 0.2; + border-radius: 0.5rem; + padding: 1rem 1.25rem; +} + +.title { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + opacity: 0.7; +} + +.summary { + margin: 0 0 1rem; + line-height: 1.5; +} + +.sectionTitle { + margin: 0 0 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + opacity: 0.75; +} + +.list { + margin: 0 0 1rem; + padding-left: 1.25rem; +} + +.list:last-child { + margin-bottom: 0; +} + +.placeholder { + margin: 0; + font-size: 0.875rem; + opacity: 0.55; + font-style: italic; +} + +.skeletonLine { + height: 0.875rem; + margin-bottom: 0.5rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} + +.skeletonLineShort { + height: 0.875rem; + width: 70%; + margin-bottom: 0.5rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--foreground) 10%, transparent); +} + +.skeletonLine:last-child { + margin-bottom: 0; +} diff --git a/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.stories.tsx b/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.stories.tsx new file mode 100644 index 0000000..9e34e4f --- /dev/null +++ b/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { mockQualitativeFeedback } from "@/lib/interview-coach/mocks"; + +import { QualitativeFeedback } from "./QualitativeFeedback"; + +const meta: Meta = { + title: "Scorecard/QualitativeFeedback", + component: QualitativeFeedback, +}; + +export default meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { loading: true }, +}; + +export const Populated: Story = { + args: { feedback: mockQualitativeFeedback }, +}; + +export const Empty: Story = { + args: { feedback: null }, +}; diff --git a/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.tsx b/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.tsx new file mode 100644 index 0000000..9deae25 --- /dev/null +++ b/frontend/app/scorecard/components/QualitativeFeedback/QualitativeFeedback.tsx @@ -0,0 +1,48 @@ +import type { QualitativeFeedback as QualitativeFeedbackType } from "@/lib/interview-coach/types"; + +import styles from "./QualitativeFeedback.module.css"; + +export type QualitativeFeedbackProps = { + loading?: boolean; + feedback?: QualitativeFeedbackType | null; +}; + +export function QualitativeFeedback({ + loading = false, + feedback = null, +}: QualitativeFeedbackProps) { + return ( +
+

+ LLM feedback (shell) +

+ {loading ? ( +
+
+
+
+
+ ) : feedback ? ( + <> +

{feedback.summary}

+

Strengths

+
    + {feedback.strengths.map((item) => ( +
  • {item}
  • + ))} +
+

Improvements

+
    + {feedback.improvements.map((item) => ( +
  • {item}
  • + ))} +
+

Delivery notes

+

{feedback.deliveryNotes}

+ + ) : ( +

Awaiting POST /feedback/generate

+ )} +
+ ); +} diff --git a/frontend/app/scorecard/components/Scorecard/Scorecard.module.css b/frontend/app/scorecard/components/Scorecard/Scorecard.module.css new file mode 100644 index 0000000..99c89f2 --- /dev/null +++ b/frontend/app/scorecard/components/Scorecard/Scorecard.module.css @@ -0,0 +1,62 @@ +.root { + display: flex; + flex-direction: column; + gap: 1.25rem; + max-width: 48rem; +} + +.header { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.eyebrow { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.55; +} + +.question { + margin: 0; + font-size: 1.125rem; + line-height: 1.45; + font-weight: 600; +} + +.transcript { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + line-height: 1.5; + background: color-mix(in srgb, var(--foreground) 6%, transparent); + opacity: 0.9; +} + +.scoresRow { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.feedbackColumn { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.shellBadge { + align-self: flex-start; + padding: 0.2rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent); + opacity: 0.7; +} diff --git a/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx b/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx new file mode 100644 index 0000000..14ee2a6 --- /dev/null +++ b/frontend/app/scorecard/components/Scorecard/Scorecard.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { + mockDeliveryScores, + mockModelAnswer, + mockQualitativeFeedback, + mockQuestion, + mockTranscript, + mockTranscriptScores, +} from "@/lib/interview-coach/mocks"; + +import { Scorecard } from "./Scorecard"; + +const meta: Meta = { + title: "Scorecard/Scorecard", + component: Scorecard, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: {}, +}; + +export const PartialScoresOnly: Story = { + args: { + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + }, +}; + +export const Full: Story = { + args: { + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + feedback: mockQualitativeFeedback, + modelAnswer: mockModelAnswer, + }, +}; + +export const LoadingFeedback: Story = { + args: { + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, + loadingFeedback: true, + }, +}; diff --git a/frontend/app/scorecard/components/Scorecard/Scorecard.tsx b/frontend/app/scorecard/components/Scorecard/Scorecard.tsx new file mode 100644 index 0000000..5c447fc --- /dev/null +++ b/frontend/app/scorecard/components/Scorecard/Scorecard.tsx @@ -0,0 +1,59 @@ +import { DeliveryScores } from "@/app/scorecard/components/DeliveryScores/DeliveryScores"; +import { ModelAnswer } from "@/app/scorecard/components/ModelAnswer/ModelAnswer"; +import { QualitativeFeedback } from "@/app/scorecard/components/QualitativeFeedback/QualitativeFeedback"; +import { TranscriptFeedbackScores } from "@/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores"; +import type { + DeliveryScores as DeliveryScoresType, + InterviewQuestion, + ModelAnswer as ModelAnswerType, + QualitativeFeedback as QualitativeFeedbackType, + Transcript, + TranscriptFeedbackScores as TranscriptFeedbackScoresType, +} from "@/lib/interview-coach/types"; + +import styles from "./Scorecard.module.css"; + +export type ScorecardProps = { + question?: InterviewQuestion | null; + transcript?: Transcript | null; + deliveryScores?: DeliveryScoresType | null; + transcriptScores?: TranscriptFeedbackScoresType | null; + feedback?: QualitativeFeedbackType | null; + modelAnswer?: ModelAnswerType | null; + loadingDelivery?: boolean; + loadingTranscriptScores?: boolean; + loadingFeedback?: boolean; + showShellBadge?: boolean; +}; + +export function Scorecard({ + question = null, + transcript = null, + deliveryScores = null, + transcriptScores = null, + feedback = null, + modelAnswer = null, + loadingDelivery = false, + loadingTranscriptScores = false, + loadingFeedback = false, + showShellBadge = true, +}: ScorecardProps) { + return ( +
+ {showShellBadge ? UI shell : null} +
+

Question

+

{question?.prompt ?? "No question selected"}

+ {transcript ?

{transcript.text}

: null} +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.module.css b/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.module.css new file mode 100644 index 0000000..43f6f4a --- /dev/null +++ b/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.module.css @@ -0,0 +1,70 @@ +.root { + border: 1px dashed var(--foreground); + border-opacity: 0.2; + border-radius: 0.5rem; + padding: 1rem 1.25rem; + background: color-mix(in srgb, var(--foreground) 4%, transparent); +} + +.title { + margin: 0 0 0.75rem; + font-size: 0.875rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + opacity: 0.7; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +@media (min-width: 640px) { + .grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.metric { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.label { + font-size: 0.75rem; + opacity: 0.65; +} + +.value { + font-family: var(--font-mono), monospace; + font-size: 1.125rem; + font-weight: 600; +} + +.placeholder { + margin: 0; + font-size: 0.875rem; + opacity: 0.55; + font-style: italic; +} + +.skeletonBar { + height: 1.125rem; + width: 3rem; + border-radius: 0.25rem; + background: color-mix(in srgb, var(--foreground) 12%, transparent); + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.45; + } + 50% { + opacity: 0.9; + } +} diff --git a/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.stories.tsx b/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.stories.tsx new file mode 100644 index 0000000..c0ac91d --- /dev/null +++ b/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { mockTranscriptScores } from "@/lib/interview-coach/mocks"; + +import { TranscriptFeedbackScores } from "./TranscriptFeedbackScores"; + +const meta: Meta = { + title: "Scorecard/TranscriptFeedbackScores", + component: TranscriptFeedbackScores, +}; + +export default meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { loading: true }, +}; + +export const Populated: Story = { + args: { scores: mockTranscriptScores }, +}; + +export const Empty: Story = { + args: { scores: null }, +}; diff --git a/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.tsx b/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.tsx new file mode 100644 index 0000000..c1e924f --- /dev/null +++ b/frontend/app/scorecard/components/TranscriptFeedbackScores/TranscriptFeedbackScores.tsx @@ -0,0 +1,53 @@ +import type { TranscriptFeedbackScores as TranscriptFeedbackScoresType } from "@/lib/interview-coach/types"; + +import styles from "./TranscriptFeedbackScores.module.css"; + +export type TranscriptFeedbackScoresProps = { + loading?: boolean; + scores?: TranscriptFeedbackScoresType | null; +}; + +const METRICS: { key: keyof TranscriptFeedbackScoresType; label: string }[] = [ + { key: "clarity", label: "Clarity" }, + { key: "structure", label: "Structure" }, + { key: "relevance", label: "Relevance" }, + { key: "conciseness", label: "Conciseness" }, +]; + +function formatScore(value: number): string { + return value.toFixed(2); +} + +export function TranscriptFeedbackScores({ + loading = false, + scores = null, +}: TranscriptFeedbackScoresProps) { + return ( +
+

+ Transcript classifier (shell) +

+ {loading ? ( +
+ {METRICS.map(({ key }) => ( +
+ {key} +
+
+ ))} +
+ ) : scores ? ( +
+ {METRICS.map(({ key, label }) => ( +
+
{label}
+
{formatScore(scores[key])}
+
+ ))} +
+ ) : ( +

Awaiting transcript classifier

+ )} +
+ ); +} diff --git a/frontend/app/scorecard/page.tsx b/frontend/app/scorecard/page.tsx new file mode 100644 index 0000000..4f93427 --- /dev/null +++ b/frontend/app/scorecard/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +import { ScorecardPanel } from "@/app/scorecard/ScorecardPanel"; +import { mockSessionReview } from "@/lib/interview-coach/mocks"; + +export default function ScorecardPage() { + return ( +
+
+ + ← Home + +

Scorecard shell

+

+ Static mock data. Use{" "} + + /dev/flow + {" "} + to step through the pipeline and watch payloads update. +

+
+ +
+ ); +} diff --git a/frontend/lib/interview-coach/aggregateReviewPayload.ts b/frontend/lib/interview-coach/aggregateReviewPayload.ts new file mode 100644 index 0000000..262001e --- /dev/null +++ b/frontend/lib/interview-coach/aggregateReviewPayload.ts @@ -0,0 +1,26 @@ +import type { + DeliveryScores, + InterviewQuestion, + ReviewContextPayload, + Transcript, + TranscriptFeedbackScores, +} from "@/lib/interview-coach/types"; + +/** + * Merges audio delivery scores and transcript classifier scores into one + * payload for POST /feedback/generate. Pure function — safe to call from + * client containers or server route handlers. + */ +export function aggregateReviewPayload(input: { + question: InterviewQuestion; + transcript: Transcript; + deliveryScores: DeliveryScores; + transcriptScores: TranscriptFeedbackScores; +}): ReviewContextPayload { + return { + question: input.question, + transcript: input.transcript, + deliveryScores: input.deliveryScores, + transcriptScores: input.transcriptScores, + }; +} diff --git a/frontend/lib/interview-coach/mocks.ts b/frontend/lib/interview-coach/mocks.ts new file mode 100644 index 0000000..d61fedb --- /dev/null +++ b/frontend/lib/interview-coach/mocks.ts @@ -0,0 +1,78 @@ +import type { + DeliveryScores, + InterviewQuestion, + ModelAnswer, + QualitativeFeedback, + ReviewContextPayload, + SessionReviewResult, + Transcript, + TranscriptFeedbackScores, +} from "@/lib/interview-coach/types"; + +export const mockQuestion: InterviewQuestion = { + id: "q-behavioral-01", + prompt: "Tell me about a time you had to influence a team without direct authority.", +}; + +export const mockTranscript: Transcript = { + text: "Last year our design and engineering leads disagreed on scope for a launch. I set up a short working session, mapped tradeoffs on a whiteboard, and proposed a phased rollout. We shipped on time and both teams felt heard.", + segments: [ + { + start: 0, + end: 4.2, + text: "Last year our design and engineering leads disagreed on scope for a launch.", + }, + { + start: 4.2, + end: 9.8, + text: "I set up a short working session, mapped tradeoffs on a whiteboard,", + }, + { + start: 9.8, + end: 15.1, + text: "and proposed a phased rollout. We shipped on time and both teams felt heard.", + }, + ], +}; + +export const mockDeliveryScores: DeliveryScores = { + arousal: 0.61, + dominance: 0.55, + valence: 0.32, +}; + +export const mockTranscriptScores: TranscriptFeedbackScores = { + clarity: 0.78, + structure: 0.72, + relevance: 0.85, + conciseness: 0.68, +}; + +export const mockQualitativeFeedback: QualitativeFeedback = { + summary: + "Strong STAR-style story with a clear outcome. Delivery reads as measured but engaged; tighten the opening hook.", + strengths: ["Concrete situation and actions", "Credible outcome with team alignment"], + improvements: [ + "Lead with your role in one sentence", + "Quantify impact if possible (timeline, metrics)", + ], + deliveryNotes: + "Valence is slightly low — consider warmer phrasing when describing collaboration.", +}; + +export const mockModelAnswer: ModelAnswer = { + text: "When two senior stakeholders disagreed on launch scope, I facilitated a 45-minute working session, documented three options with tradeoffs, and recommended a phased rollout. We hit the date with both teams aligned on phase two priorities.", +}; + +export const mockReviewContext: ReviewContextPayload = { + question: mockQuestion, + transcript: mockTranscript, + deliveryScores: mockDeliveryScores, + transcriptScores: mockTranscriptScores, +}; + +export const mockSessionReview: SessionReviewResult = { + context: mockReviewContext, + feedback: mockQualitativeFeedback, + modelAnswer: mockModelAnswer, +}; diff --git a/frontend/lib/interview-coach/pipelineStages.ts b/frontend/lib/interview-coach/pipelineStages.ts new file mode 100644 index 0000000..71bd244 --- /dev/null +++ b/frontend/lib/interview-coach/pipelineStages.ts @@ -0,0 +1,46 @@ +import type { PipelineStage } from "@/lib/interview-coach/types"; + +export const INITIAL_PIPELINE_STAGES: PipelineStage[] = [ + { + id: "record", + label: "1. Record", + description: "User selects a question and records answer in the browser.", + status: "idle", + }, + { + id: "transcribe", + label: "2. Transcribe", + description: "Audio blob → Whisper API → timestamped transcript.", + status: "idle", + }, + { + id: "audioScores", + label: "3. Audio delivery scores", + description: "Audio → POST /emotion/analyze → arousal, dominance, valence.", + status: "idle", + }, + { + id: "transcriptScores", + label: "4. Transcript classifier", + description: "Transcript → classifier → content/delivery scores (shell schema).", + status: "idle", + }, + { + id: "aggregate", + label: "5. Aggregate for LLM", + description: "Merge question, transcript, and both score sets → ReviewContextPayload.", + status: "idle", + }, + { + id: "llmFeedback", + label: "6. LLM feedback", + description: "ReviewContextPayload → POST /feedback/generate → feedback + model answer.", + status: "idle", + }, + { + id: "scorecard", + label: "7. Scorecard UI", + description: "Frontend renders delivery metrics, qualitative feedback, model answer.", + status: "idle", + }, +]; diff --git a/frontend/lib/interview-coach/types.ts b/frontend/lib/interview-coach/types.ts new file mode 100644 index 0000000..0cacf48 --- /dev/null +++ b/frontend/lib/interview-coach/types.ts @@ -0,0 +1,82 @@ +/** Arousal / dominance / valence from POST /emotion/analyze */ +export type DeliveryScores = { + arousal: number; + dominance: number; + valence: number; +}; + +export type TranscriptSegment = { + start: number; + end: number; + text: string; +}; + +/** Timestamped transcript from POST /speech/transcribe (contract TBD) */ +export type Transcript = { + text: string; + segments: TranscriptSegment[]; +}; + +/** + * Delivery / content signals from transcript classifier (contract TBD). + * Shell dimensions until backend defines the schema. + */ +export type TranscriptFeedbackScores = { + clarity: number; + structure: number; + relevance: number; + conciseness: number; +}; + +export type InterviewQuestion = { + id: string; + prompt: string; +}; + +/** Combined context sent to POST /feedback/generate */ +export type ReviewContextPayload = { + question: InterviewQuestion; + transcript: Transcript; + deliveryScores: DeliveryScores; + transcriptScores: TranscriptFeedbackScores; +}; + +/** Structured LLM feedback (contract TBD) */ +export type QualitativeFeedback = { + summary: string; + strengths: string[]; + improvements: string[]; + deliveryNotes: string; +}; + +export type ModelAnswer = { + text: string; +}; + +/** Full result rendered on the scorecard */ +export type SessionReviewResult = { + context: ReviewContextPayload; + feedback: QualitativeFeedback; + modelAnswer: ModelAnswer; +}; + +export type PipelineStageStatus = "idle" | "pending" | "done" | "error"; + +export type PipelineStageId = + | "record" + | "transcribe" + | "audioScores" + | "transcriptScores" + | "aggregate" + | "llmFeedback" + | "scorecard"; + +export type PipelineStage = { + id: PipelineStageId; + label: string; + description: string; + status: PipelineStageStatus; + input?: TInput; + output?: TOutput; + error?: string; +}; diff --git a/frontend/types/storybook.d.ts b/frontend/types/storybook.d.ts new file mode 100644 index 0000000..42dc6e3 --- /dev/null +++ b/frontend/types/storybook.d.ts @@ -0,0 +1,10 @@ +declare module "@storybook/react" { + export type Meta = { + title?: string; + component?: T; + }; + + export type StoryObj = { + args?: Partial; + }; +} diff --git a/lefthook.yml b/lefthook.yml index 0688893..0cfcc26 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,3 +1,19 @@ +# Auto-format on commit (re-stages fixes). Pre-push still verifies so skipped hooks are caught. +pre-commit: + parallel: true + commands: + frontend-format: + root: frontend/ + glob: "**/*.{js,ts,jsx,tsx,mjs,cjs,json,css,md}" + exclude: "(package-lock\\.json|pnpm-lock\\.yaml)" + run: pnpm exec prettier --write {staged_files} + stage_fixed: true + backend-format: + root: backend/ + glob: "**/*.py" + run: ruff format {staged_files} + stage_fixed: true + pre-push: commands: frontend-lint: