Skip to content
Closed
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
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
27 changes: 27 additions & 0 deletions app/api/interview/debrief/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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';

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(JSON.parse(result.text.trim().match(/\{[\s\S]*\}/)?.[0] || '{}'));
} catch (error) {
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed');
}
}
24 changes: 24 additions & 0 deletions app/api/interview/session/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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';

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(JSON.parse(result.text.trim().match(/\{[\s\S]*\}/)?.[0] || '{}'));
} 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', content: contextPrompt, createdAt: new Date() }],
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', content: contextPrompt, createdAt: new Date() }],
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),
);
}
}
17 changes: 14 additions & 3 deletions app/api/quiz-grade/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,29 @@ export async function POST(req: NextRequest) {
const { model: languageModel } = resolveModelFromHeaders(req);

const isZh = language === 'zh-CN';
const isHi = language === 'hi-IN';

const systemPrompt = isZh
? `你是一位专业的教育评估专家。请根据题目和学生答案进行评分并给出简短评语。
必须以如下 JSON 格式回复(不要包含其他内容):
{"score": <0到${points}的整数>, "comment": "<一两句评语>"}`
: `You are a professional educational assessor. Grade the student's answer and provide brief feedback.
: isHi
? `आप एक अनुभवी educational assessor हैं। प्रश्न और छात्र के उत्तर के आधार पर score दें और छोटा feedback दें।
केवल इसी JSON format में उत्तर दें:
{"score": <0 से ${points} तक integer>, "comment": "<एक या दो वाक्यों का feedback>"}`
: `You are a professional educational assessor. Grade the student's answer and provide brief feedback.
You must reply in the following JSON format only (no other content):
{"score": <integer from 0 to ${points}>, "comment": "<one or two sentences of feedback>"}`;

const userPrompt = isZh
? `题目:${question}
满分:${points}分
${commentPrompt ? `评分要点:${commentPrompt}\n` : ''}学生答案:${userAnswer}`
: `Question: ${question}
: isHi
? `प्रश्न: ${question}
पूर्ण अंक: ${points}
${commentPrompt ? `ग्रेडिंग गाइडेंस: ${commentPrompt}\n` : ''}छात्र का उत्तर: ${userAnswer}`
: `Question: ${question}
Full marks: ${points} points
${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${userAnswer}`;

Expand Down Expand Up @@ -83,7 +92,9 @@ ${commentPrompt ? `Grading guidance: ${commentPrompt}\n` : ''}Student answer: ${
score: Math.round(points * 0.5),
comment: isZh
? '已作答,请参考标准答案。'
: 'Answer received. Please refer to the standard answer.',
: isHi
? 'उत्तर प्राप्त हुआ। कृपया standard answer देखें।'
: 'Answer received. Please refer to the standard answer.',
};
}

Expand Down
23 changes: 23 additions & 0 deletions app/api/quiz/debrief/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextRequest } from 'next/server';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { buildPlacementDebriefPrompt } from '@/lib/quiz/prompts';
import { callQuizLLM } from '@/lib/quiz/llm';

export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as { summary: string };
const result = await callQuizLLM(
req,
'You are a supportive coach. Return strict JSON only.',
buildPlacementDebriefPrompt(body.summary),
'quiz-debrief',
);
return apiSuccess(JSON.parse(result.text.trim().match(/\{[\s\S]*\}/)?.[0] || '{}'));
} catch (error) {
return apiError(
'INTERNAL_ERROR',
500,
error instanceof Error ? error.message : 'Failed to generate debrief',
);
}
}
18 changes: 18 additions & 0 deletions app/api/quiz/explain/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextRequest } from 'next/server';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { callQuizLLM } from '@/lib/quiz/llm';

export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as { prompt: string };
const result = await callQuizLLM(
req,
'You explain wrong quiz answers clearly and briefly. Return plain text only.',
body.prompt,
'quiz-explain',
);
return apiSuccess({ explanation: result.text.trim() });
} catch (error) {
return apiError('INTERNAL_ERROR', 500, error instanceof Error ? error.message : 'Failed');
}
}
Loading