Skip to content
Merged
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
Binary file removed .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,5 @@ vite.config.ts.timestamp-*
# Python virtual environment
venv/
.venv/
.DS_Store
docs/.DS_Store
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file removed docs/.DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ out/
build/
node_modules/
pnpm-lock.yaml
package-lock.json
next-env.d.ts
7 changes: 5 additions & 2 deletions frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -240,7 +243,7 @@ External links may use `<a>` 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.

---
Expand Down
66 changes: 66 additions & 0 deletions frontend/app/dev/flow/FlowDevClient.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
181 changes: 181 additions & 0 deletions frontend/app/dev/flow/FlowDevClient.tsx
Original file line number Diff line number Diff line change
@@ -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<PipelineStage[]>(cloneStages);
const [stepIndex, setStepIndex] = useState(-1);
const [sessionResult, setSessionResult] = useState<SessionReviewResult | null>(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 (
<div className={styles.layout}>
<div className={styles.controls}>
<button type="button" className={styles.button} onClick={advance} disabled={!canAdvance}>
Advance one stage
</button>
<button type="button" className={styles.buttonSecondary} onClick={reset}>
Reset
</button>
<p className={styles.stepHint}>
Step {Math.max(0, stepIndex + 1)} / {stages.length}
{isComplete ? " — pipeline complete" : ""}
</p>
</div>

<div className={styles.stages}>
{stages.map((stage) => (
<FlowStageCard key={stage.id} stage={stage} />
))}
</div>

<section className={styles.scorecardSection} aria-labelledby="live-scorecard">
<h2 id="live-scorecard" className={styles.scorecardTitle}>
Live scorecard preview
</h2>
<ScorecardPanel
result={sessionResult}
loadingDelivery={loadingFlags.loadingDelivery && stepIndex >= 0}
loadingTranscriptScores={loadingFlags.loadingTranscriptScores && stepIndex >= 0}
loadingFeedback={loadingFlags.loadingFeedback && stepIndex >= 0}
/>
</section>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading