From 0b20207df640e500b37f5b46345c18df19169f16 Mon Sep 17 00:00:00 2001 From: Shashank Date: Thu, 26 Mar 2026 18:10:40 +0530 Subject: [PATCH] feat: Real time constraints - Real time interview constraint changes - Interviewer changes the constraints after when 30-50% of the interview is done --- app/api/interview/[id]/evaluate/route.ts | 3 +- app/api/interview/[id]/hint/route.ts | 161 ++++++++++++++++++++++- app/api/interview/[id]/route.ts | 1 + app/interview/[id]/page.tsx | 18 ++- app/interview/[id]/result/page.tsx | 74 ++++++++++- components/interview/QuestionPanel.tsx | 44 ++++++- src/hooks/useInterviewAI.ts | 23 +++- src/lib/db/models/InterviewSession.ts | 52 ++++++++ src/lib/evaluation/reasoningEvaluator.ts | 31 ++++- 9 files changed, 396 insertions(+), 11 deletions(-) diff --git a/app/api/interview/[id]/evaluate/route.ts b/app/api/interview/[id]/evaluate/route.ts index 4681d52..676c900 100644 --- a/app/api/interview/[id]/evaluate/route.ts +++ b/app/api/interview/[id]/evaluate/route.ts @@ -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 diff --git a/app/api/interview/[id]/hint/route.ts b/app/api/interview/[id]/hint/route.ts index 44f5d27..bff1ed9 100644 --- a/app/api/interview/[id]/hint/route.ts +++ b/app/api/interview/[id]/hint/route.ts @@ -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 }>; @@ -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> = []; + + 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; @@ -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 @@ -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, @@ -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)} `).join('\n') @@ -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); diff --git a/app/api/interview/[id]/route.ts b/app/api/interview/[id]/route.ts index 61ef252..5694eab 100644 --- a/app/api/interview/[id]/route.ts +++ b/app/api/interview/[id]/route.ts @@ -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, diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 4f17f81..89ae8af 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -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; @@ -33,6 +34,7 @@ interface InterviewSessionData { connections: Connection[]; }; aiMessages?: AIMessage[]; + constraintChanges?: IConstraintChange[]; evaluation?: unknown; } @@ -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 @@ -342,6 +357,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { setShowHints(prev => !prev)} /> diff --git a/app/interview/[id]/result/page.tsx b/app/interview/[id]/result/page.tsx index 22fbf68..f3d98bb 100644 --- a/app/interview/[id]/result/page.tsx +++ b/app/interview/[id]/result/page.tsx @@ -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 { @@ -23,6 +23,7 @@ interface InterviewSessionData { content: string; timestamp: string; }[]; + constraintChanges?: IConstraintChange[]; startedAt: string; timeLimit: number; } @@ -100,6 +101,7 @@ export default function InterviewResultPage({ params }: PageProps) { const { evaluation, question } = session; const { structural, reasoning, finalScore } = evaluation; + const constraintChanges = session.constraintChanges || []; return (
@@ -236,6 +238,34 @@ export default function InterviewResultPage({ params }: PageProps) { {/* Left Column: Structural Analysis */}
+ {constraintChanges.length > 0 && ( +
+
+ bolt +

Live Constraint Changes

+
+
+ {constraintChanges.map((change) => ( +
+
+

{change.title}

+ + {change.severity} + +
+

{change.description}

+

+ Introduced {change.introducedAtMinute} minutes into the interview +

+
+ ))} +
+
+ )} +

@@ -292,6 +322,48 @@ export default function InterviewResultPage({ params }: PageProps) {

+ {constraintChanges.length > 0 && ( +
+

+ bolt + Adaptability +

+
+ {reasoning.adaptationSummary || 'The interview included live requirement changes, but no adaptation summary was generated.'} +
+ {(reasoning.addressedConstraintChanges?.length || reasoning.missedConstraintChanges?.length) ? ( +
+ {reasoning.addressedConstraintChanges && reasoning.addressedConstraintChanges.length > 0 && ( +
+

Addressed

+
    + {reasoning.addressedConstraintChanges.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ )} + {reasoning.missedConstraintChanges && reasoning.missedConstraintChanges.length > 0 && ( +
+

Missed

+
    + {reasoning.missedConstraintChanges.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ )} +
+ ) : null} +
+ )} + {/* Strengths */}

diff --git a/components/interview/QuestionPanel.tsx b/components/interview/QuestionPanel.tsx index 017514b..50c9431 100644 --- a/components/interview/QuestionPanel.tsx +++ b/components/interview/QuestionPanel.tsx @@ -1,10 +1,11 @@ 'use client'; -import { IInterviewQuestion } from '@/src/lib/db/models/InterviewSession'; +import { IConstraintChange, IInterviewQuestion } from '@/src/lib/db/models/InterviewSession'; interface QuestionPanelProps { question: IInterviewQuestion; difficulty: 'easy' | 'medium' | 'hard'; + constraintChanges?: IConstraintChange[]; /** Whether to reveal hints */ showHints?: boolean; onToggleHints?: () => void; @@ -16,7 +17,13 @@ const DIFFICULTY_COLORS = { hard: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/20', dot: 'bg-red-500' }, }; -export function QuestionPanel({ question, difficulty, showHints = false, onToggleHints }: QuestionPanelProps) { +export function QuestionPanel({ + question, + difficulty, + constraintChanges = [], + showHints = false, + onToggleHints +}: QuestionPanelProps) { const colors = DIFFICULTY_COLORS[difficulty]; return ( @@ -79,6 +86,39 @@ export function QuestionPanel({ question, difficulty, showHints = false, onToggl

)} + {/* Live Changes */} + {constraintChanges.length > 0 && ( +
+

+ bolt + Live Changes +

+
+ {constraintChanges.map((change) => ( +
+
+

{change.title}

+ + {change.severity} + +
+

{change.description}

+
+ schedule + Added {change.introducedAtMinute} min into interview +
+
+ ))} +
+
+ )} + {/* Traffic Profile */} {(question.trafficProfile?.users || question.trafficProfile?.rps || question.trafficProfile?.storage) && (
diff --git a/src/hooks/useInterviewAI.ts b/src/hooks/useInterviewAI.ts index 8b63537..86e3249 100644 --- a/src/hooks/useInterviewAI.ts +++ b/src/hooks/useInterviewAI.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { ICanvasNode, IConnection } from '../lib/db/models/Design'; +import { IConstraintChange } from '../lib/db/models/InterviewSession'; import { authFetch } from '../lib/firebase/authClient'; export interface AIMessage { @@ -17,6 +18,7 @@ interface HintEndpointResponse { success: boolean; hint: HintResponse; message: AIMessage; + constraintChange?: IConstraintChange | null; } interface UseInterviewAIProps { @@ -24,12 +26,25 @@ interface UseInterviewAIProps { stateRef: React.MutableRefObject<{ nodes: ICanvasNode[]; connections: IConnection[] } | null>; timeRemaining: number; initialMessages?: AIMessage[]; + onConstraintChange?: (change: IConstraintChange) => void; } -export function useInterviewAI({ sessionId, stateRef, timeRemaining, initialMessages = [] }: UseInterviewAIProps) { +export function useInterviewAI({ + sessionId, + stateRef, + timeRemaining, + initialMessages = [], + onConstraintChange +}: UseInterviewAIProps) { const [messages, setMessages] = useState(() => initialMessages); const [isThinking, setIsThinking] = useState(false); + useEffect(() => { + if (initialMessages.length > 0) { + setMessages(initialMessages); + } + }, [initialMessages]); + const isThinkingRef = useRef(isThinking); useEffect(() => { isThinkingRef.current = isThinking; @@ -85,6 +100,10 @@ export function useInterviewAI({ sessionId, stateRef, timeRemaining, initialMess return next; }); + if (data.constraintChange) { + onConstraintChange?.(data.constraintChange); + } + lastHintTimeRef.current = Date.now(); } catch (error) { console.error('Failed to request AI hint:', error); @@ -92,7 +111,7 @@ export function useInterviewAI({ sessionId, stateRef, timeRemaining, initialMess isThinkingRef.current = false; setIsThinking(false); } - }, [sessionId, stateRef]); + }, [sessionId, stateRef, onConstraintChange]); // Periodic polling - e.g., every 5 minutes in ms (300,000 ms) useEffect(() => { diff --git a/src/lib/db/models/InterviewSession.ts b/src/lib/db/models/InterviewSession.ts index 91909ce..ba513a1 100644 --- a/src/lib/db/models/InterviewSession.ts +++ b/src/lib/db/models/InterviewSession.ts @@ -22,6 +22,23 @@ export interface IRuleResult { severity: 'critical' | 'warning' | 'info'; } +export type ConstraintChangeType = 'traffic' | 'reliability' | 'latency' | 'compliance' | 'product' | 'cost'; +export type ConstraintChangeSeverity = 'moderate' | 'high'; +export type ConstraintChangeStatus = 'active' | 'acknowledged' | 'addressed'; + +export interface IConstraintChange { + id: string; + type: ConstraintChangeType; + title: string; + description: string; + severity: ConstraintChangeSeverity; + introducedAt: Date; + introducedAtMinute: number; + status: ConstraintChangeStatus; + impactAreas: string[]; + interviewerMessage: string; +} + // Full evaluation result export interface IEvaluation { structural: { @@ -35,6 +52,9 @@ export interface IEvaluation { strengths: string[]; weaknesses: string[]; suggestions: string[]; + adaptationSummary?: string; + addressedConstraintChanges?: string[]; + missedConstraintChanges?: string[]; }; finalScore: number; weights: { @@ -63,6 +83,7 @@ export interface IInterviewSession extends Document { content: string; timestamp: Date; }[]; + constraintChanges?: IConstraintChange[]; evaluation?: IEvaluation; createdAt: Date; updatedAt: Date; @@ -100,6 +121,30 @@ const RuleResultSchema = new Schema( { _id: false } ); +const ConstraintChangeSchema = new Schema( + { + id: { type: String, required: true }, + type: { + type: String, + enum: ['traffic', 'reliability', 'latency', 'compliance', 'product', 'cost'], + required: true, + }, + title: { type: String, required: true }, + description: { type: String, required: true }, + severity: { type: String, enum: ['moderate', 'high'], required: true }, + introducedAt: { type: Date, required: true }, + introducedAtMinute: { type: Number, required: true }, + status: { + type: String, + enum: ['active', 'acknowledged', 'addressed'], + default: 'active', + }, + impactAreas: { type: [String], default: [] }, + interviewerMessage: { type: String, required: true }, + }, + { _id: false } +); + const StructuralEvalSchema = new Schema( { score: { type: Number, required: true }, @@ -116,6 +161,9 @@ const ReasoningEvalSchema = new Schema( strengths: { type: [String], default: [] }, weaknesses: { type: [String], default: [] }, suggestions: { type: [String], default: [] }, + adaptationSummary: { type: String, default: null }, + addressedConstraintChanges: { type: [String], default: [] }, + missedConstraintChanges: { type: [String], default: [] }, }, { _id: false } ); @@ -208,6 +256,10 @@ const InterviewSessionSchema = new Schema( type: [AiMessageSchema], default: [] }, + constraintChanges: { + type: [ConstraintChangeSchema], + default: [] + }, evaluation: { type: EvaluationSchema, default: null, diff --git a/src/lib/evaluation/reasoningEvaluator.ts b/src/lib/evaluation/reasoningEvaluator.ts index fbb9982..02bbe72 100644 --- a/src/lib/evaluation/reasoningEvaluator.ts +++ b/src/lib/evaluation/reasoningEvaluator.ts @@ -1,5 +1,5 @@ import { generateJSON } from '../ai/geminiClient'; -import { IInterviewQuestion, IRuleResult } from '../db/models/InterviewSession'; +import { IInterviewQuestion, IRuleResult, IConstraintChange } from '../db/models/InterviewSession'; import { ICanvasNode, IConnection } from '../db/models/Design'; export interface ReasoningEvaluation { @@ -7,6 +7,9 @@ export interface ReasoningEvaluation { strengths: string[]; weaknesses: string[]; suggestions: string[]; + adaptationSummary?: string; + addressedConstraintChanges?: string[]; + missedConstraintChanges?: string[]; } /** @@ -15,8 +18,15 @@ export interface ReasoningEvaluation { export async function evaluateReasoning( question: IInterviewQuestion, canvasSnapshot: { nodes: ICanvasNode[]; connections: IConnection[] }, - structuralResults: IRuleResult[] + structuralResults: IRuleResult[], + constraintChanges: IConstraintChange[] = [] ): Promise { + const activeConstraintText = constraintChanges.length > 0 + ? constraintChanges.map((change) => + `- [${change.type}] ${change.title}: ${change.description} (introduced at minute ${change.introducedAtMinute})` + ).join('\n') + : 'None'; + const prompt = ` You are a senior system design interviewer at a top-tier tech company. Evaluate the following candidate design based on the provided question and architectural constraints. @@ -27,6 +37,9 @@ Requirements: ${question.requirements.join(', ')} Constraints: ${question.constraints.join(', ')} Traffic Profile: ${JSON.stringify(question.trafficProfile)} +### LIVE CONSTRAINT CHANGES +${activeConstraintText} + ### CANDIDATE DESIGN (JSON Structure) Nodes: ${JSON.stringify(canvasSnapshot.nodes.map(n => ({ id: n.id, type: n.type, label: n.label })))} Connections: ${JSON.stringify(canvasSnapshot.connections.map(c => ({ from: c.from, to: c.to })))} @@ -39,6 +52,7 @@ ${structuralResults.map(r => `- ${r.rule}: ${r.status.toUpperCase()} (${r.messag 2. Are trade-offs appropriate for the specific scale constraints? 3. Are there hidden bottlenecks that the deterministic checks missed? 4. Is the overall architecture coherent and justified? +5. If live constraint changes were introduced, did the candidate adapt the design appropriately? ### OUTPUT FORMAT Return your evaluation as a structured JSON object with the following exact keys: @@ -46,6 +60,9 @@ Return your evaluation as a structured JSON object with the following exact keys - "strengths": string[] (list of 2-3 specific architectural strengths) - "weaknesses": string[] (list of 2-3 specific architectural gaps or weaknesses) - "suggestions": string[] (list of 2-3 actionable steps to improve the design) +- "adaptationSummary": string (1-2 sentences describing how well the candidate handled any live changes) +- "addressedConstraintChanges": string[] (titles of live changes the design addressed well) +- "missedConstraintChanges": string[] (titles of live changes the design failed to address) Ensure the JSON is well-formatted and strictly valid. `; @@ -61,6 +78,9 @@ Ensure the JSON is well-formatted and strictly valid. evaluation.strengths = evaluation.strengths || []; evaluation.weaknesses = evaluation.weaknesses || []; evaluation.suggestions = evaluation.suggestions || []; + evaluation.adaptationSummary = evaluation.adaptationSummary || undefined; + evaluation.addressedConstraintChanges = evaluation.addressedConstraintChanges || []; + evaluation.missedConstraintChanges = evaluation.missedConstraintChanges || []; return evaluation; } catch (error) { @@ -70,7 +90,12 @@ Ensure the JSON is well-formatted and strictly valid. score: 50, strengths: ['Basic structure present'], weaknesses: ['AI feedback unavailable at this time'], - suggestions: ['Please review your design against functional requirements manually'] + suggestions: ['Please review your design against functional requirements manually'], + adaptationSummary: constraintChanges.length > 0 + ? 'Live constraint changes were introduced, but adaptation analysis was unavailable.' + : 'No live constraint changes were introduced.', + addressedConstraintChanges: [], + missedConstraintChanges: constraintChanges.map((change) => change.title) }; } }