Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
eaa1548
Add standalone quiz and interview practice modes
Sid3548 Mar 20, 2026
13cbb91
Merge pull request #1 from Sid3548/codex/add-hindi-language-support-a…
Sid3548 Mar 20, 2026
6528301
Refine quiz and interview color palette
Sid3548 Mar 20, 2026
f69ddb7
feat(interview): add /api/interview/turn route via orchestration system
claude Mar 20, 2026
c4d12fc
Fix coding quiz scoring
Sid3548 Mar 20, 2026
ea70624
Harden interview and quiz flows
Sid3548 Mar 20, 2026
ce97bcb
fix(quiz): coding assessment no longer always awards full marks
claude Mar 20, 2026
5914dff
Resolve PR #3 conflicts and validation issues
Sid3548 Mar 20, 2026
eb29187
Merge codex/add-hindi-language-support-and-quiz-features-kdu066: hard…
claude Mar 20, 2026
350c009
Merge claude/code-review-WGKtD: add interview orchestration turn rout…
claude Mar 20, 2026
eff9eaf
Merge codex/add-hindi-language-support-and-quiz-features: keep refine…
claude Mar 20, 2026
d4a73a8
feat: unified API key — one key powers LLM, TTS, and ASR
claude Mar 20, 2026
ceca910
Merge pull request #5 from Sid3548/claude/merge-all-branches-beZ0B
Sid3548 Mar 20, 2026
44db632
feat: fill canvas TODOs, table editing, link dialog, student-friendly UI
claude Mar 20, 2026
3bd8a33
fix: switch OpenAI TTS model from gpt-4o-mini-tts to tts-1 for broade…
claude Mar 20, 2026
9292e5c
fix: resolve all P0/P1/P2/P3 build and correctness issues
claude Mar 20, 2026
db3166b
Merge branch 'main' into claude/merge-all-branches-beZ0B
Sid3548 Mar 20, 2026
7241f56
Merge pull request #6 from Sid3548/claude/merge-all-branches-beZ0B
Sid3548 Mar 20, 2026
91bbcfa
fix: auto-select OpenAI TTS model based on voice
claude Mar 20, 2026
9fae097
fix: restrict openai-tts to tts-1 voices only
claude Mar 20, 2026
bc00910
feat: add dynamic voice fetching for TTS providers
claude Mar 20, 2026
9c299b7
feat: auto-expand/collapse chat panel with discussion lifecycle
claude Mar 20, 2026
fdc3b66
feat: improve landing page with universal student-focused title
claude Mar 20, 2026
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
2 changes: 1 addition & 1 deletion app/api/generate/scene-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function POST(req: NextRequest) {
// Ensure outline has language from stageInfo (fallback for older outlines)
const outline: SceneOutline = {
...rawOutline,
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US') || 'zh-CN',
language: rawOutline.language || (stageInfo?.language as 'zh-CN' | 'en-US' | 'hi-IN') || 'zh-CN',
};

// ── Model resolution from request headers ──
Expand Down
15 changes: 11 additions & 4 deletions app/api/generate/scene-outlines-stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import type { AgentInfo } from '@/lib/generation/generation-pipeline';
import { MAX_PDF_CONTENT_CHARS, MAX_VISION_IMAGES } from '@/lib/constants/generation';
import { nanoid } from 'nanoid';
import { isChineseLanguage, isHindiLanguage } from '@/lib/utils/language';
import type {
UserRequirements,
PdfImage,
Expand Down Expand Up @@ -121,7 +122,11 @@ export async function POST(req: NextRequest) {

// Build prompt (same logic as generateSceneOutlinesFromRequirements)
let availableImagesText =
requirements.language === 'zh-CN' ? '无可用图片' : 'No images available';
isChineseLanguage(requirements.language)
? '无可用图片'
: isHindiLanguage(requirements.language)
? 'कोई image उपलब्ध नहीं है'
: 'No images available';
let visionImages: Array<{ id: string; src: string }> | undefined;

if (pdfImages && pdfImages.length > 0) {
Expand Down Expand Up @@ -177,11 +182,13 @@ export async function POST(req: NextRequest) {
language: requirements.language,
pdfContent: pdfText
? pdfText.substring(0, MAX_PDF_CONTENT_CHARS)
: requirements.language === 'zh-CN'
: isChineseLanguage(requirements.language)
? '无'
: 'None',
: isHindiLanguage(requirements.language)
? 'कोई नहीं'
: 'None',
availableImages: availableImagesText,
researchContext: researchContext || (requirements.language === 'zh-CN' ? '' : 'None'),
researchContext: researchContext || (isChineseLanguage(requirements.language) ? '无' : isHindiLanguage(requirements.language) ? 'कोई नहीं' : 'None'),
mediaGenerationPolicy,
teacherContext,
});
Expand Down
59 changes: 59 additions & 0 deletions app/api/interview/debrief/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextRequest } from 'next/server';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
import { callLLM } from '@/lib/ai/llm';
import { buildInterviewDebriefPrompt } from '@/lib/interview/prompts';
import type { InterviewConfig } from '@/lib/interview/types';
import { parseFirstJsonObject } from '@/lib/server/json-parser';

function clampTenPointScore(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(10, Math.round(numeric)));
}

function clampHundredPointScore(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(100, Math.round(numeric)));
}

function normalizeInterviewDebriefResult(input: Record<string, unknown>) {
const topImprovements = Array.isArray(input.topImprovements)
? input.topImprovements.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: [];
const summary = typeof input.summary === 'string' ? input.summary.trim() : '';

if (!summary) {
throw new Error('Interview debrief response did not include a summary');
}

return {
overallScore: clampHundredPointScore(input.overallScore),
communicationRating: clampTenPointScore(input.communicationRating),
technicalAccuracyRating: clampTenPointScore(input.technicalAccuracyRating),
topImprovements,
summary,
};
}

export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as {
config: InterviewConfig;
turns: Array<{ question: string; answer?: string }>;
};
const { model } = resolveModelFromHeaders(req);
const result = await callLLM(
{
model,
system: 'You are an interview coach. Return JSON only.',
prompt: buildInterviewDebriefPrompt(body),
},
'interview-debrief',
);
return apiSuccess(normalizeInterviewDebriefResult(parseFirstJsonObject<Record<string, unknown>>(result.text)));
} catch (error) {
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed');
}
}
33 changes: 33 additions & 0 deletions app/api/interview/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest } from 'next/server';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
import { callLLM } from '@/lib/ai/llm';
import { buildInterviewSessionPrompt } from '@/lib/interview/prompts';
import type { InterviewConfig } from '@/lib/interview/types';
import { parseFirstJsonObject } from '@/lib/server/json-parser';

function normalizeInterviewSessionResult(input: Record<string, unknown>) {
const question = typeof input.question === 'string' ? input.question.trim() : '';
if (!question) {
throw new Error('Interview session response did not include a valid question');
}
return { question };
}

export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as InterviewConfig;
const { model } = resolveModelFromHeaders(req);
const result = await callLLM(
{
model,
system: 'You are a realistic interviewer. Return JSON only.',
prompt: buildInterviewSessionPrompt(body),
},
'interview-session',
);
return apiSuccess(normalizeInterviewSessionResult(parseFirstJsonObject<Record<string, unknown>>(result.text)));
} catch (error) {
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed');
}
}
189 changes: 189 additions & 0 deletions app/api/interview/turn/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Interview Turn API Endpoint
*
* POST /api/interview/turn
*
* Replaces the direct callLLM() path with two statelessGenerate() calls,
* using the same orchestration entrypoint as /api/chat.
*
* Turn sequence per submission:
* 1. Coach agent → feedback { good, missing, strongAnswer }
* 2. Interviewer agent → nextQuestion string
*
* Request shape (unchanged from previous callLLM version):
* { config: InterviewConfig, history: [...], answer: string }
* Model credentials supplied via x-model / x-api-key / x-base-url headers.
*
* Response shape (unchanged — frontend reads payload.feedback + payload.nextQuestion):
* { feedback: { good, missing, strongAnswer }, nextQuestion }
*/

import { NextRequest } from 'next/server';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
import { statelessGenerate } from '@/lib/orchestration/stateless-generate';
import { buildInterviewTurnPrompt } from '@/lib/interview/prompts';
import type { InterviewConfig } from '@/lib/interview/types';
import type { StatelessChatRequest, StatelessEvent } from '@/lib/types/chat';
import type { ThinkingConfig } from '@/lib/types/provider';
import { createLogger } from '@/lib/logger';

const log = createLogger('Interview Turn API');

export const maxDuration = 60;

// ── Agent IDs ─────────────────────────────────────────────────────────────────

const COACH_ID = 'interview-coach';
const INTERVIEWER_ID = 'interview-interviewer';

// ── Helpers ───────────────────────────────────────────────────────────────────

/** Drain a statelessGenerate() generator and return accumulated text deltas. */
async function collectText(gen: AsyncGenerator<StatelessEvent>): Promise<string> {
let text = '';
for await (const event of gen) {
if (event.type === 'text_delta') {
text += event.data.content;
}
}
return text.trim();
}

/**
* Try to parse the first JSON object found in a string.
* Falls back to an empty object so the caller never throws.
*/
function extractJson(raw: string): Record<string, unknown> {
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return {};
try {
return JSON.parse(match[0]) as Record<string, unknown>;
} catch {
return {};
}
}

// ── Route handler ─────────────────────────────────────────────────────────────

export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as {
config: InterviewConfig;
history: Array<{ question: string; answer?: string }>;
answer: string;
};

const { config, history, answer } = body;

if (!answer || !config) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'answer and config are required');
}

const { model: languageModel, modelString } = resolveModelFromHeaders(req);

const signal = req.signal;
const thinking: ThinkingConfig = { enabled: false };

// Build the full interview context as the user message — same text that
// callLLM() used as its `prompt`, so context quality is unchanged.
const contextPrompt = buildInterviewTurnPrompt({ config, history, answer });

const storeState: StatelessChatRequest['storeState'] = {
stage: null,
scenes: [],
currentSceneId: null,
mode: 'autonomous',
whiteboardOpen: false,
};

// ── Coach turn ────────────────────────────────────────────────────────────
// The coach persona tells the LLM to embed JSON in the text content so we
// can extract { good, missing, strongAnswer } from the text_delta stream.

const coachPersona = [
`You are a professional interview coach reviewing a ${config.role} interview answer.`,
`Difficulty: ${config.difficulty}. Interview type: ${config.interviewType}.`,
'Analyse the candidate\'s answer and output ONLY a JSON object (no markdown, no prose):',
'{"good":["..."],"missing":["..."],"strongAnswer":"..."}',
'Each array should contain 1-3 concise bullet points. strongAnswer is one sentence.',
].join(' ');

const coachRequest: StatelessChatRequest = {
messages: [{ id: 'ctx-1', role: 'user', parts: [{ type: 'text', text: contextPrompt }] }],
storeState,
config: {
agentIds: [COACH_ID],
agentConfigs: [
{
id: COACH_ID,
name: 'Interview Coach',
role: 'coach',
persona: coachPersona,
avatar: '🎓',
color: '#4CAF50',
allowedActions: [],
priority: 1,
isGenerated: true,
},
],
},
apiKey: req.headers.get('x-api-key') || '',
baseUrl: req.headers.get('x-base-url') || undefined,
model: modelString,
};

log.info(`Coach turn — role: ${config.role}, difficulty: ${config.difficulty}`);
const coachText = await collectText(statelessGenerate(coachRequest, signal, languageModel, thinking));
const feedback = extractJson(coachText);

// ── Interviewer turn ──────────────────────────────────────────────────────
const interviewerPersona = [
`You are an experienced ${config.role} interviewer conducting a ${config.difficulty} ${config.interviewType} interview.`,
'Based on the conversation so far, ask exactly one natural follow-up question.',
'Do NOT repeat a question already asked. Output the question only — no preamble, no JSON.',
].join(' ');

const interviewerRequest: StatelessChatRequest = {
messages: [{ id: 'ctx-1', role: 'user', parts: [{ type: 'text', text: contextPrompt }] }],
storeState,
config: {
agentIds: [INTERVIEWER_ID],
agentConfigs: [
{
id: INTERVIEWER_ID,
name: 'Interviewer',
role: 'interviewer',
persona: interviewerPersona,
avatar: '👔',
color: '#2196F3',
allowedActions: [],
priority: 1,
isGenerated: true,
},
],
},
apiKey: req.headers.get('x-api-key') || '',
baseUrl: req.headers.get('x-base-url') || undefined,
model: modelString,
};

log.info(`Interviewer turn — role: ${config.role}`);
const nextQuestion = await collectText(
statelessGenerate(interviewerRequest, signal, languageModel, thinking),
);

log.info(
`Interview turn complete — feedback keys: ${Object.keys(feedback).join(',')}, question length: ${nextQuestion.length}`,
);

return apiSuccess({ feedback, nextQuestion });
} catch (error) {
log.error('Error:', error);
return apiError(
'INTERNAL_ERROR',
500,
error instanceof Error ? error.message : String(error),
);
}
}
Loading