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
133 changes: 78 additions & 55 deletions app/api/interview/[id]/hint/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,67 +24,83 @@ interface ConstraintTriggerSession {
timeLimit: number;
}

interface ConstraintCandidateContext {
prompt: string;
difficulty: 'easy' | 'medium' | 'hard';
introducedAtMinute: number;
nodes: ICanvasNode[];
connections: IConnection[];
failedRuleMessages: string[];
}

const LIVE_CHANGE_MIN_PROGRESS = 0.25;
const LIVE_CHANGE_MAX_PROGRESS = 0.75;
const LIVE_CHANGE_MIN_NODES = 3;

function generateConstraintChange(
prompt: string,
difficulty: 'easy' | 'medium' | 'hard',
introducedAtMinute: number
{
prompt,
difficulty,
introducedAtMinute,
nodes,
connections,
failedRuleMessages
}: ConstraintCandidateContext
): IConstraintChange {
const lowerPrompt = prompt.toLowerCase();

const templates: Array<Omit<IConstraintChange, 'id' | 'introducedAt' | 'introducedAtMinute' | 'status' | 'interviewerMessage'>> = [];

if (lowerPrompt.includes('chat') || lowerPrompt.includes('notification') || lowerPrompt.includes('feed') || lowerPrompt.includes('stream')) {
templates.push({
const nodeTypes = new Set(nodes.map((node) => node.type));
const connectionCount = connections.length;
const hasCache = nodeTypes.has('Cache') || nodeTypes.has('CDN');
const hasQueue = nodeTypes.has('Queue') || nodeTypes.has('Kafka');
const databaseCount = nodes.filter((node) => node.type === 'SQL' || node.type === 'Blob').length;
const hasRealtimePrompt = ['chat', 'notification', 'feed', 'stream', 'collaborative'].some((keyword) => lowerPrompt.includes(keyword));
const hasFinancialPrompt = ['payment', 'trade', 'order'].some((keyword) => lowerPrompt.includes(keyword));
const mentionsDisconnected = failedRuleMessages.some((message) => message.toLowerCase().includes('not connected') || message.toLowerCase().includes('floating'));

let selected: Omit<IConstraintChange, 'id' | 'introducedAt' | 'introducedAtMinute' | 'status' | 'interviewerMessage'>;

if (!hasCache && (hasRealtimePrompt || nodeTypes.has('LB') || connectionCount >= 3)) {
selected = {
type: 'traffic',
title: 'Traffic Spike',
description: 'Peak traffic is now expected to spike to roughly 10x the original estimate during major live events.',
severity: 'high',
impactAreas: ['scalability', 'caching', 'load balancing'],
});
}

if (lowerPrompt.includes('payment') || lowerPrompt.includes('trade') || lowerPrompt.includes('order')) {
templates.push({
};
} else if ((databaseCount <= 1 || mentionsDisconnected) && difficulty !== 'easy') {
selected = {
type: 'reliability',
title: 'Regional Failover',
description: 'The system must continue serving users during a full regional outage with minimal disruption.',
severity: difficulty === 'hard' ? 'high' : 'moderate',
impactAreas: ['availability', 'replication', 'disaster recovery'],
};
} else if (!hasQueue && (hasRealtimePrompt || nodeTypes.has('Server') || nodeTypes.has('Function'))) {
selected = {
type: 'product',
title: 'Real-Time Updates',
description: 'Users now expect live updates in the product instead of relying on manual refreshes or long polling.',
severity: 'moderate',
impactAreas: ['realtime', 'messaging', 'fan-out'],
};
} else if (hasFinancialPrompt) {
selected = {
type: 'latency',
title: 'Stricter Write Latency',
description: 'Critical write operations now need to complete within 150ms at p95 while preserving correctness.',
severity: 'high',
impactAreas: ['latency', 'consistency', 'storage'],
});
};
} else {
selected = {
type: 'compliance',
title: 'Regional Data Residency',
description: 'Data for EU users must remain in-region and cannot be freely replicated across all geographies.',
severity: 'moderate',
impactAreas: ['compliance', 'storage', 'multi-region'],
};
}

templates.push({
type: 'reliability',
title: 'Regional Failover',
description: 'The system must continue serving users during a full regional outage with minimal disruption.',
severity: difficulty === 'hard' ? 'high' : 'moderate',
impactAreas: ['availability', 'replication', 'disaster recovery'],
});

templates.push({
type: 'compliance',
title: 'Regional Data Residency',
description: 'Data for EU users must remain in-region and cannot be freely replicated across all geographies.',
severity: 'moderate',
impactAreas: ['compliance', 'storage', 'multi-region'],
});

templates.push({
type: 'product',
title: 'Real-Time Updates',
description: 'Users now expect live updates in the product instead of relying on manual refreshes or long polling.',
severity: 'moderate',
impactAreas: ['realtime', 'messaging', 'fan-out'],
});

const selected = difficulty === 'hard'
? templates[0]
: templates.find((template) => template.type !== 'compliance') || templates[0];

const interviewerMessage = `Let's add a new constraint: ${selected.description} How would you adjust your design to handle that?`;

return {
Expand Down Expand Up @@ -186,14 +202,28 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
? sanitizeMessage(candidateReply.trim())
: null;

const structuralResults = evaluateStructure(
nodes,
connections,
session.question.requirements || [],
session.question.constraints || []
);

const failedRuleMessages = structuralResults.details
.filter((detail) => detail.status === 'fail')
.map((detail) => detail.message);

const liveChangeDecision = shouldTriggerConstraintChange(session, nodes.length);

if (liveChangeDecision.shouldTrigger) {
const constraintChange = generateConstraintChange(
session.question.prompt,
session.difficulty,
liveChangeDecision.introducedAtMinute
);
const constraintChange = generateConstraintChange({
prompt: session.question.prompt,
difficulty: session.difficulty,
introducedAtMinute: liveChangeDecision.introducedAtMinute,
nodes,
connections,
failedRuleMessages
});

const newMessage = {
role: 'interviewer' as const,
Expand Down Expand Up @@ -224,21 +254,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
}

const structuralResults = evaluateStructure(
nodes,
connections,
session.question.requirements || [],
session.question.constraints || []
);

const formatNode = (n: ICanvasNode) => ({ type: n.type, label: n.label });
const formatConn = (c: IConnection) => {
const from = nodes.find((n: ICanvasNode) => n.id === c.from);
const to = nodes.find((n: ICanvasNode) => n.id === c.to);
return { fromType: from?.type, fromLabel: from?.label, toType: to?.type, toLabel: to?.label, label: c.label };
};

const failedRules = structuralResults.details.filter(d => d.status === 'fail').map(d => d.message);
const failedRules = failedRuleMessages;

const prompt = `You are an expert systems design interviewer evaluating a candidate in a real-time system design interview.

Expand Down
2 changes: 2 additions & 0 deletions app/interview/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ export default function InterviewCanvasPage({ params }: PageProps) {
<div className="relative flex flex-col h-screen overflow-hidden bg-background-dark text-white font-display">
<InterviewHeader
difficulty={session.difficulty}
constraintChangeCount={session.constraintChanges?.length || 0}
latestConstraintTitle={session.constraintChanges?.at(-1)?.title}
saveStatus={saveStatus}
timer={timer}
status={session.status}
Expand Down
23 changes: 19 additions & 4 deletions components/interview/InterviewHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { InterviewTimer } from './InterviewTimer';

interface InterviewHeaderProps {
difficulty: 'easy' | 'medium' | 'hard';
constraintChangeCount?: number;
latestConstraintTitle?: string;
/** Save status */
saveStatus: 'idle' | 'saving' | 'saved' | 'error';
/** Timer state */
Expand Down Expand Up @@ -32,6 +34,8 @@ const DIFFICULTY_LABELS: Record<string, { color: string; label: string }> = {

export function InterviewHeader({
difficulty,
constraintChangeCount = 0,
latestConstraintTitle,
saveStatus,
timer,
status,
Expand All @@ -42,7 +46,6 @@ export function InterviewHeader({
const diffConfig = DIFFICULTY_LABELS[difficulty] || DIFFICULTY_LABELS.medium;
const isInProgress = status === 'in_progress';


const renderSaveStatus = () => {
switch (saveStatus) {
case 'saving':
Expand Down Expand Up @@ -73,7 +76,6 @@ export function InterviewHeader({

return (
<header className="relative h-14 flex items-center justify-between px-4 border-b border-border-dark bg-sidebar-bg-dark shrink-0 z-20">
{/* Left: Logo & Interview badge */}
<div className="flex items-center gap-4">
<Link href="/interview" className="flex items-center gap-2 text-white group">
<div className="p-1.5 bg-primary/10 rounded-lg group-hover:bg-primary/20 transition-colors">
Expand All @@ -92,15 +94,28 @@ export function InterviewHeader({
</span>
</div>

{constraintChangeCount > 0 && (
<div
className="hidden lg:flex items-center gap-2 rounded-full border border-amber-500/20 bg-amber-500/10 px-3 py-1"
title={latestConstraintTitle || 'Interview requirements updated'}
>
<span className="material-symbols-outlined text-[14px] text-amber-400">bolt</span>
<span className="text-[11px] font-bold uppercase tracking-wide text-amber-300">
Updated Requirements
</span>
<span className="rounded-full bg-amber-400/15 px-1.5 py-0.5 text-[10px] font-black text-amber-200">
{constraintChangeCount}
</span>
</div>
)}

{renderSaveStatus()}
</div>

{/* Center: Timer */}
<div className="absolute left-1/2 -translate-x-1/2">
<InterviewTimer {...timer} />
</div>

{/* Right: Actions */}
<div className="flex items-center gap-3">
{isInProgress && (
<button
Expand Down
Loading