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
3 changes: 2 additions & 1 deletion app/api/interview/[id]/evaluate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
const reasoningResults = await evaluateReasoning(
session.question,
session.canvasSnapshot,
structuralResults.details
structuralResults.details,
session.constraintChanges || []
);

// 3. Combine into final evaluation document
Expand Down
161 changes: 160 additions & 1 deletion app/api/interview/[id]/hint/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getAuthenticatedUser } from '@/src/lib/firebase/firebaseAdmin';
import { evaluateStructure } from '@/src/lib/evaluation/structuralRules';
import { generateJSON } from '@/src/lib/ai/geminiClient';
import { ICanvasNode, IConnection } from '@/src/lib/db/models/Design';
import { IConstraintChange } from '@/src/lib/db/models/InterviewSession';

interface RouteParams {
params: Promise<{ id: string }>;
Expand All @@ -16,6 +17,117 @@ interface HintResponse {
severity: 'question' | 'nudge' | 'praise';
}

interface ConstraintTriggerSession {
difficulty: 'easy' | 'medium' | 'hard';
constraintChanges?: IConstraintChange[];
startedAt: Date | string;
timeLimit: number;
}

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
): 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({
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({
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'],
});
}

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 {
id: `cc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
...selected,
introducedAt: new Date(),
introducedAtMinute,
status: 'active',
interviewerMessage,
};
}

function shouldTriggerConstraintChange(
session: ConstraintTriggerSession,
nodeCount: number
): { shouldTrigger: boolean; introducedAtMinute: number } {
if (!['medium', 'hard'].includes(session.difficulty)) {
return { shouldTrigger: false, introducedAtMinute: 0 };
}

if ((session.constraintChanges || []).length > 0) {
return { shouldTrigger: false, introducedAtMinute: 0 };
}

if (nodeCount < LIVE_CHANGE_MIN_NODES) {
return { shouldTrigger: false, introducedAtMinute: 0 };
}

const startedAt = new Date(session.startedAt).getTime();
if (isNaN(startedAt)) {
return { shouldTrigger: false, introducedAtMinute: 0 };
}

const elapsedMinutes = Math.max(0, Math.floor((Date.now() - startedAt) / (1000 * 60)));
const progress = session.timeLimit > 0 ? elapsedMinutes / session.timeLimit : 0;

if (progress < LIVE_CHANGE_MIN_PROGRESS || progress > LIVE_CHANGE_MAX_PROGRESS) {
return { shouldTrigger: false, introducedAtMinute: elapsedMinutes };
}

return { shouldTrigger: true, introducedAtMinute: elapsedMinutes };
}

export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const { id } = await params;
Expand Down Expand Up @@ -60,6 +172,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
if (!session.aiMessages) {
session.aiMessages = [];
}
if (!session.constraintChanges) {
session.constraintChanges = [];
}

const sanitizeMessage = (msg: string) => {
return msg
Expand All @@ -71,6 +186,44 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
? sanitizeMessage(candidateReply.trim())
: null;

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

if (liveChangeDecision.shouldTrigger) {
const constraintChange = generateConstraintChange(
session.question.prompt,
session.difficulty,
liveChangeDecision.introducedAtMinute
);

const newMessage = {
role: 'interviewer' as const,
content: constraintChange.interviewerMessage,
timestamp: new Date()
};

if (sanitizedCandidateReply) {
session.aiMessages.push({
role: 'candidate',
content: sanitizedCandidateReply,
timestamp: new Date()
});
}

session.constraintChanges.push(constraintChange);
session.aiMessages.push(newMessage);
await session.save();

return NextResponse.json({
success: true,
hint: {
message: newMessage.content,
severity: 'question'
},
message: newMessage,
constraintChange
});
}

const structuralResults = evaluateStructure(
nodes,
connections,
Expand Down Expand Up @@ -102,6 +255,11 @@ ${failedRules.length > 0 ? failedRules.join('\n') : 'None! Architecture looks st

Time Remaining: ${timeRemaining || 'unknown'} minutes.

Live Constraint Changes:
${session.constraintChanges.length > 0
? session.constraintChanges.map(change => `- [${change.type}] ${change.title}: ${change.description}`).join('\n')
: 'None yet.'}

Conversation History (oldest to newest):
${session.aiMessages.length > 0
? session.aiMessages.map(m => `<${m.role === 'interviewer' ? 'INTERVIEWER' : 'CANDIDATE'}> ${sanitizeMessage(m.content)} </${m.role === 'interviewer' ? 'INTERVIEWER' : 'CANDIDATE'}>`).join('\n')
Expand Down Expand Up @@ -141,7 +299,8 @@ Respond strictly in JSON:
return NextResponse.json({
success: true,
hint: response,
message: newMessage
message: newMessage,
constraintChange: null
});
} catch (error) {
console.error('Error generating AI hint:', error);
Expand Down
1 change: 1 addition & 0 deletions app/api/interview/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
status: session.status,
canvasSnapshot: session.canvasSnapshot,
aiMessages: session.aiMessages || [],
constraintChanges: session.constraintChanges || [],
evaluation: session.evaluation ?? null,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
Expand Down
18 changes: 17 additions & 1 deletion app/interview/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DesignCanvas, CanvasNode, Connection, CanvasStateRef } from '@/componen
import { PropertiesPanel } from '@/components/canvas/PropertiesPanel';
import { useInterviewAI, AIMessage } from '@/src/hooks/useInterviewAI';
import { InterviewerPanel } from '@/components/interview/InterviewerPanel';
import { IConstraintChange } from '@/src/lib/db/models/InterviewSession';

interface InterviewSessionData {
id: string;
Expand All @@ -33,6 +34,7 @@ interface InterviewSessionData {
connections: Connection[];
};
aiMessages?: AIMessage[];
constraintChanges?: IConstraintChange[];
evaluation?: unknown;
}

Expand Down Expand Up @@ -80,7 +82,20 @@ export default function InterviewCanvasPage({ params }: PageProps) {
sessionId: id,
stateRef: canvasStateRef,
timeRemaining: timer.minutes,
initialMessages: session?.aiMessages || []
initialMessages: session?.aiMessages || [],
onConstraintChange: (change) => {
setSession(prev => {
if (!prev) return prev;
const existing = prev.constraintChanges || [];
if (existing.some(item => item.id === change.id)) {
return prev;
}
return {
...prev,
constraintChanges: [...existing, change]
};
});
}
});

// Fetch session data
Expand Down Expand Up @@ -342,6 +357,7 @@ export default function InterviewCanvasPage({ params }: PageProps) {
<QuestionPanel
question={session.question}
difficulty={session.difficulty}
constraintChanges={session.constraintChanges || []}
showHints={showHints}
onToggleHints={() => setShowHints(prev => !prev)}
/>
Expand Down
74 changes: 73 additions & 1 deletion app/interview/[id]/result/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useRequireAuth } from '@/src/hooks/useRequireAuth';
import { authFetch } from '@/src/lib/firebase/authClient';
import { IInterviewQuestion, IEvaluation, IRuleResult } from '@/src/lib/db/models/InterviewSession';
import { IInterviewQuestion, IEvaluation, IRuleResult, IConstraintChange } from '@/src/lib/db/models/InterviewSession';
import { DesignCanvas, CanvasNode, Connection } from '@/components/canvas/DesignCanvas';

interface InterviewSessionData {
Expand All @@ -23,6 +23,7 @@ interface InterviewSessionData {
content: string;
timestamp: string;
}[];
constraintChanges?: IConstraintChange[];
startedAt: string;
timeLimit: number;
}
Expand Down Expand Up @@ -100,6 +101,7 @@ export default function InterviewResultPage({ params }: PageProps) {

const { evaluation, question } = session;
const { structural, reasoning, finalScore } = evaluation;
const constraintChanges = session.constraintChanges || [];

return (
<div className="min-h-screen bg-background-dark text-white p-6 md:p-10 font-display selection:bg-primary/30">
Expand Down Expand Up @@ -236,6 +238,34 @@ export default function InterviewResultPage({ params }: PageProps) {

{/* Left Column: Structural Analysis */}
<div className="lg:col-span-2 space-y-8">
{constraintChanges.length > 0 && (
<section className="bg-sidebar-bg-dark border border-border-dark rounded-3xl p-6">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-amber-400 text-[22px]">bolt</span>
<h3 className="text-lg font-bold">Live Constraint Changes</h3>
</div>
<div className="space-y-3">
{constraintChanges.map((change) => (
<div key={change.id} className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
<div className="flex items-center justify-between gap-3 mb-1.5">
<h4 className="font-semibold text-white">{change.title}</h4>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${change.severity === 'high'
? 'bg-red-500/15 text-red-400 border border-red-500/20'
: 'bg-amber-500/15 text-amber-300 border border-amber-500/20'
}`}>
{change.severity}
</span>
</div>
<p className="text-sm text-slate-300">{change.description}</p>
<p className="mt-2 text-[11px] text-slate-500">
Introduced {change.introducedAtMinute} minutes into the interview
</p>
</div>
))}
</div>
</section>
)}

<section>
<div className="flex items-center justify-between mb-4 px-2">
<h3 className="text-lg font-bold flex items-center gap-2">
Expand Down Expand Up @@ -292,6 +322,48 @@ export default function InterviewResultPage({ params }: PageProps) {
</div>

<div className="space-y-8">
{constraintChanges.length > 0 && (
<div>
<h4 className="text-xs font-black text-amber-400 uppercase tracking-widest mb-3 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">bolt</span>
Adaptability
</h4>
<div className="p-4 bg-white/5 border border-white/5 rounded-2xl text-sm text-slate-300 leading-relaxed">
{reasoning.adaptationSummary || 'The interview included live requirement changes, but no adaptation summary was generated.'}
</div>
{(reasoning.addressedConstraintChanges?.length || reasoning.missedConstraintChanges?.length) ? (
<div className="mt-3 grid grid-cols-1 gap-3">
{reasoning.addressedConstraintChanges && reasoning.addressedConstraintChanges.length > 0 && (
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-3">
<p className="text-[11px] font-black uppercase tracking-widest text-emerald-400 mb-2">Addressed</p>
<ul className="space-y-2">
{reasoning.addressedConstraintChanges.map((item, i) => (
<li key={`${item}-${i}`} className="text-sm text-slate-300 flex items-start gap-2">
<span className="mt-1.5 size-1.5 rounded-full bg-emerald-500 shrink-0" />
{item}
</li>
))}
</ul>
</div>
)}
{reasoning.missedConstraintChanges && reasoning.missedConstraintChanges.length > 0 && (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-3">
<p className="text-[11px] font-black uppercase tracking-widest text-red-400 mb-2">Missed</p>
<ul className="space-y-2">
{reasoning.missedConstraintChanges.map((item, i) => (
<li key={`${item}-${i}`} className="text-sm text-slate-300 flex items-start gap-2">
<span className="mt-1.5 size-1.5 rounded-full bg-red-500 shrink-0" />
{item}
</li>
))}
</ul>
</div>
)}
</div>
) : null}
</div>
)}

{/* Strengths */}
<div>
<h4 className="text-xs font-black text-emerald-500 uppercase tracking-widest mb-3 flex items-center gap-2">
Expand Down
Loading
Loading