feat: end-to-end choice question support in assessment pipeline

- Data pathway: flow options through questions, answerKey in graph state
- Interviewer: format MULTIPLE_CHOICE with A/B/C/D options
- Grader: instant choice scoring (zero LLM), compare correctAnswer
- AssessmentView: render choice buttons vs textarea based on questionType
- Security: sanitizeStateForClient strips correctAnswer/judgment/answerKey
- Bank detection: check PUBLISHED items (not PUBLISHED bank status)
- Batch UI: select all / batch approve / batch reject on detail view
This commit is contained in:
Developer
2026-05-21 10:06:33 +08:00
parent 57898f939c
commit 3993099907
7 changed files with 228 additions and 24 deletions
+47 -13
View File
@@ -27,7 +27,7 @@ import { AssessmentAnswer } from './entities/assessment-answer.entity';
import { AssessmentTemplate } from './entities/assessment-template.entity'; import { AssessmentTemplate } from './entities/assessment-template.entity';
import { AssessmentCertificate } from './entities/assessment-certificate.entity'; import { AssessmentCertificate } from './entities/assessment-certificate.entity';
import { QuestionBank, QuestionBankStatus } from './entities/question-bank.entity'; import { QuestionBank, QuestionBankStatus } from './entities/question-bank.entity';
import { QuestionBankItem } from './entities/question-bank-item.entity'; import { QuestionBankItem, QuestionBankItemStatus } from './entities/question-bank-item.entity';
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service'; import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
import { ModelConfigService } from '../model-config/model-config.service'; import { ModelConfigService } from '../model-config/model-config.service';
@@ -453,7 +453,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
} }
this.logger.debug(`[startSession] isKb: ${isKb}`); this.logger.debug(`[startSession] isKb: ${isKb}`);
const templateData = template const templateData: any = template
? { ? {
name: template.name, name: template.name,
keywords: template.keywords, keywords: template.keywords,
@@ -475,22 +475,22 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
if (templateId) { if (templateId) {
try { try {
const targetCount = template?.questionCount || 5; const targetCount = template?.questionCount || 5;
const publishedBanks = await this.questionBankRepository.find({ const linkedBanks = await this.questionBankRepository.find({
where: { templateId, status: QuestionBankStatus.PUBLISHED }, where: { templateId },
}); });
if (publishedBanks.length > 0) { if (linkedBanks.length > 0) {
const bankIds = publishedBanks.map(b => b.id); const bankIds = linkedBanks.map(b => b.id);
const questionCount = await this.questionBankItemRepository.count({ const questionCount = await this.questionBankItemRepository.count({
where: { bankId: In(bankIds) }, where: { bankId: In(bankIds), status: QuestionBankItemStatus.PUBLISHED },
}); });
this.logger.log( this.logger.log(
`[startSession] Found ${publishedBanks.length} published banks with ${questionCount} questions, target: ${targetCount}`, `[startSession] Found ${linkedBanks.length} banks with ${questionCount} published questions, target: ${targetCount}`,
); );
if (questionCount >= targetCount) { if (questionCount >= targetCount) {
const bankId = publishedBanks[0].id; const bankId = linkedBanks[0].id;
const selectedItems = await this.questionBankService.selectQuestions( const selectedItems = await this.questionBankService.selectQuestions(
bankId, bankId,
targetCount, targetCount,
@@ -500,12 +500,27 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
id: item.id, id: item.id,
questionText: item.questionText, questionText: item.questionText,
questionType: item.questionType, questionType: item.questionType,
options: item.options,
keyPoints: item.keyPoints, keyPoints: item.keyPoints,
difficulty: item.difficulty, difficulty: item.difficulty,
dimension: item.dimension, dimension: item.dimension,
basis: item.basis, basis: item.basis,
})); }));
const answerKey: Record<string, { correctAnswer?: string | null; judgment?: string | null; followupHints?: string[] | null }> = {};
selectedItems.forEach(item => {
if (item.correctAnswer || item.judgment || item.followupHints) {
answerKey[item.id] = {
correctAnswer: item.correctAnswer,
judgment: item.judgment,
followupHints: item.followupHints,
};
}
});
if (Object.keys(answerKey).length > 0 && templateData) {
templateData.questionAnswerKey = answerKey;
}
questionSource = 'bank'; questionSource = 'bank';
this.logger.log( this.logger.log(
`[startSession] Selected ${questionsFromBank.length} questions from question bank`, `[startSession] Selected ${questionsFromBank.length} questions from question bank`,
@@ -609,7 +624,7 @@ private async getModel(tenantId: string): Promise<ChatOpenAI> {
this.logger.log( this.logger.log(
`Session ${sessionId} already has state, skipping generation.`, `Session ${sessionId} already has state, skipping generation.`,
); );
const mappedData = { ...existingState.values }; const mappedData = this.sanitizeStateForClient({ ...existingState.values });
mappedData.messages = this.mapMessages(mappedData.messages || []); mappedData.messages = this.mapMessages(mappedData.messages || []);
mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory = this.mapMessages(
mappedData.feedbackHistory || [], mappedData.feedbackHistory || [],
@@ -631,6 +646,7 @@ const initialState: Partial<EvaluationState> = {
style: session.templateJson?.style, style: session.templateJson?.style,
keywords: session.templateJson?.keywords, keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
currentQuestionIndex: 0, currentQuestionIndex: 0,
}; };
@@ -752,7 +768,7 @@ const initialState: Partial<EvaluationState> = {
} }
await this.sessionRepository.save(session); await this.sessionRepository.save(session);
const mappedData: any = { ...finalData }; const mappedData: any = this.sanitizeStateForClient({ ...finalData });
mappedData.messages = this.mapMessages(finalData.messages); mappedData.messages = this.mapMessages(finalData.messages);
mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [], finalData.feedbackHistory || [],
@@ -1123,7 +1139,7 @@ const initialState: Partial<EvaluationState> = {
} }
await this.sessionRepository.save(session); await this.sessionRepository.save(session);
const mappedData: any = { ...finalData }; const mappedData: any = this.sanitizeStateForClient({ ...finalData });
mappedData.messages = this.mapMessages(finalData.messages); mappedData.messages = this.mapMessages(finalData.messages);
mappedData.feedbackHistory = this.mapMessages( mappedData.feedbackHistory = this.mapMessages(
finalData.feedbackHistory || [], finalData.feedbackHistory || [],
@@ -1169,7 +1185,7 @@ const initialState: Partial<EvaluationState> = {
values.feedbackHistory = this.mapMessages(values.feedbackHistory); values.feedbackHistory = this.mapMessages(values.feedbackHistory);
} }
return values; return this.sanitizeStateForClient(values);
} }
/** /**
@@ -1280,6 +1296,7 @@ const initialState: Partial<EvaluationState> = {
session.templateJson?.difficultyDistribution, session.templateJson?.difficultyDistribution,
style: session.templateJson?.style, style: session.templateJson?.style,
keywords: session.templateJson?.keywords, keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
language: session.language || 'zh', language: session.language || 'zh',
report: session.finalReport || undefined, report: session.finalReport || undefined,
}; };
@@ -1309,6 +1326,7 @@ const initialState: Partial<EvaluationState> = {
difficultyDistribution: session.templateJson?.difficultyDistribution, difficultyDistribution: session.templateJson?.difficultyDistribution,
style: session.templateJson?.style, style: session.templateJson?.style,
keywords: session.templateJson?.keywords, keywords: session.templateJson?.keywords,
questionAnswerKey: session.templateJson?.questionAnswerKey,
language: session.language || 'en', language: session.language || 'en',
}; };
@@ -1373,6 +1391,22 @@ const initialState: Partial<EvaluationState> = {
}); });
} }
/**
* Strips sensitive fields before sending state to frontend.
*/
private sanitizeStateForClient(data: any): any {
if (!data) return data;
const sanitized = { ...data };
delete sanitized.questionAnswerKey;
if (Array.isArray(sanitized.questions)) {
sanitized.questions = sanitized.questions.map((q: any) => {
const { correctAnswer, judgment, followupHints, ...rest } = q;
return rest;
});
}
return sanitized;
}
/** /**
* Maps LangChain messages to a simple format for the frontend and storage. * Maps LangChain messages to a simple format for the frontend and storage.
*/ */
@@ -230,6 +230,7 @@ ${existingQuestionsText ? `- Repeating previous questions: ${existingQuestionsTe
return { return {
id: (existingQuestions.length + 1).toString(), id: (existingQuestions.length + 1).toString(),
questionText: q.question_text, questionText: q.question_text,
questionType: 'SHORT_ANSWER',
keyPoints: q.key_points, keyPoints: q.key_points,
difficulty: q.difficulty, difficulty: q.difficulty,
basis: q.basis, basis: q.basis,
@@ -67,6 +67,31 @@ export const graderNode = async (
return { currentQuestionIndex: currentQuestionIndex + 1 }; return { currentQuestionIndex: currentQuestionIndex + 1 };
} }
const isChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE';
const answerKey = (state.questionAnswerKey as any)?.[currentQuestion.id];
if (isChoice || answerKey?.correctAnswer) {
const expectedAnswer = answerKey?.correctAnswer || currentQuestion.correctAnswer;
const userAnswer = (lastUserMessage.content as string).trim();
const isCorrect = userAnswer.toUpperCase() === expectedAnswer?.toUpperCase();
console.log('[GraderNode] Choice grading:', { userAnswer, expectedAnswer, isCorrect });
const feedback = isCorrect ? '✅ 正确' : `❌ 错误,正确答案是 ${expectedAnswer}`;
const feedbackMessage = new AIMessage(
{ content: `Score: ${isCorrect ? 10 : 0}\nFeedback: ${feedback}` } as any,
);
return {
messages: [feedbackMessage],
feedbackHistory: [feedbackMessage],
scores: { [currentQuestion.id || currentQuestionIndex.toString()]: isCorrect ? 10 : 0 },
shouldFollowUp: false,
followUpCount: 0,
currentQuestionIndex: currentQuestionIndex + 1,
};
}
const systemPromptZh = `你是一位专业的考官。 const systemPromptZh = `你是一位专业的考官。
请根据以下问题和关键点对用户的回答进行评分。 请根据以下问题和关键点对用户的回答进行评分。
@@ -33,8 +33,6 @@ export const interviewerNode = async (
const currentQuestion = questions[currentQuestionIndex]; const currentQuestion = questions[currentQuestionIndex];
// If it's a follow-up, we add a prefix to the label later.
// If we've run out of questions and no follow-up requested, we shouldn't be here, but let's be safe.
if (currentQuestionIndex >= questions.length) { if (currentQuestionIndex >= questions.length) {
return { shouldFollowUp: false }; return { shouldFollowUp: false };
} }
@@ -49,12 +47,10 @@ export const interviewerNode = async (
state.feedbackHistory && state.feedbackHistory &&
state.feedbackHistory.length > 0 state.feedbackHistory.length > 0
) { ) {
// Construct a follow-up prompt based on last feedback
const lastFeedbackMsg = const lastFeedbackMsg =
state.feedbackHistory[state.feedbackHistory.length - 1]; state.feedbackHistory[state.feedbackHistory.length - 1];
const feedbackText = lastFeedbackMsg.content.toString(); const feedbackText = lastFeedbackMsg.content.toString();
// Extract the "Feedback: ..." part if possible, otherwise use whole text
const feedbackMatch = feedbackText.match( const feedbackMatch = feedbackText.match(
/(?:Feedback|反馈|フィードバック): ([\s\S]*)/i, /(?:Feedback|反馈|フィードバック): ([\s\S]*)/i,
); );
@@ -74,8 +70,22 @@ export const interviewerNode = async (
: 'Based on the feedback above, please provide more specific details:'; : 'Based on the feedback above, please provide more specific details:';
prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`; prompt = `${followUpLabel}\n\n${specificFeedback}\n\n${followUpInstruction}`;
} else if (currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0) {
const label = isZh
? `问题 ${currentQuestionIndex + 1}`
: isJa
? `質問 ${currentQuestionIndex + 1}`
: `Question ${currentQuestionIndex + 1}`;
const optionsText = currentQuestion.options.join('\n');
const instruction = isZh
? '请选择一个选项(输入字母 A/B/C/D)'
: isJa
? '選択肢から1つ選んでください(A/B/C/Dを入力)'
: 'Please select one option (enter A, B, C, or D)';
prompt = `${label}: ${currentQuestion.questionText}\n\n${optionsText}\n\n${instruction}`;
} else { } else {
// Standard question presentation
const label = isZh const label = isZh
? `问题 ${currentQuestionIndex + 1}` ? `问题 ${currentQuestionIndex + 1}`
: isJa : isJa
+9
View File
@@ -119,6 +119,15 @@ export const EvaluationAnnotation = Annotation.Root({
keywords: Annotation<string[] | undefined>({ keywords: Annotation<string[] | undefined>({
reducer: (prev, next) => next ?? prev, reducer: (prev, next) => next ?? prev,
}), }),
/**
* Answer key for bank questions: id → { correctAnswer, judgment, followupHints }.
* Used by grader for instant choice scoring and open-question anchoring.
* NOT sent to frontend.
*/
questionAnswerKey: Annotation<Record<string, any> | undefined>({
reducer: (prev, next) => next ?? prev,
}),
}); });
export type EvaluationState = typeof EvaluationAnnotation.State; export type EvaluationState = typeof EvaluationAnnotation.State;
+62 -2
View File
@@ -51,6 +51,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
const [templates, setTemplates] = useState<AssessmentTemplate[]>([]); const [templates, setTemplates] = useState<AssessmentTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null); const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null);
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout; const isTimedOut = timeCheck?.isTotalTimeout || timeCheck?.isQuestionTimeout;
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -232,10 +233,18 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
}; };
const handleSubmitAnswer = async () => { const handleSubmitAnswer = async () => {
if (!session || !inputValue.trim() || isLoading || isTimedOut) return; const currentQuestion = state?.questions?.[state.currentQuestionIndex || 0] as any;
const isChoice = currentQuestion?.questionType === 'MULTIPLE_CHOICE' && currentQuestion?.options?.length > 0;
const answer = inputValue.trim(); if (isChoice) {
if (!selectedChoice || isLoading || isTimedOut) return;
} else {
if (!inputValue.trim() || isLoading || isTimedOut) return;
}
const answer = isChoice ? selectedChoice! : inputValue.trim();
setInputValue(''); setInputValue('');
setSelectedChoice(null);
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...'); setProcessStep(isZh ? '正在准备发送...' : isJa ? '送信準備中...' : 'Preparing to send...');
@@ -507,6 +516,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
!(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:'))) !(m.role === 'assistant' && (m.content?.toString().startsWith('Score:') || m.content?.toString().startsWith('得分:')))
); );
const currentQuestion = (state?.questions?.[state.currentQuestionIndex || 0] || {}) as any;
const isCurrentChoice = currentQuestion.questionType === 'MULTIPLE_CHOICE' && currentQuestion.options?.length > 0;
const optionLabels = ['A', 'B', 'C', 'D'];
const feedbackHistory = state?.feedbackHistory || []; const feedbackHistory = state?.feedbackHistory || [];
const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1]; const lastFeedbackMessage = feedbackHistory[feedbackHistory.length - 1];
@@ -586,6 +599,52 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
{t('timeLimitExceeded')} {t('timeLimitExceeded')}
</div> </div>
)} )}
{isCurrentChoice ? (
<div className="max-w-3xl mx-auto space-y-3">
<div className="flex items-center gap-2 text-xs text-slate-500 font-bold uppercase tracking-wider mb-1">
<span className="w-1 h-1 bg-indigo-400 rounded-full" />
</div>
<div className="grid gap-2">
{currentQuestion.options.map((opt: string, i: number) => {
const letter = optionLabels[i];
const isSelected = selectedChoice === letter;
return (
<button
key={letter}
onClick={() => !isTimedOut && setSelectedChoice(letter)}
disabled={isTimedOut}
className={cn(
"w-full text-left px-5 py-4 rounded-2xl border-2 transition-all text-sm font-medium",
isSelected
? "border-indigo-500 bg-indigo-50 text-indigo-700 shadow-md"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50",
isTimedOut && "opacity-50 cursor-not-allowed"
)}
>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-xl text-xs font-black mr-3 shrink-0 border-2 border-current">
{letter}
</span>
{opt}
</button>
);
})}
</div>
<button
onClick={handleSubmitAnswer}
disabled={!selectedChoice || isLoading || isTimedOut}
className={cn(
"w-full mt-3 h-14 flex items-center justify-center gap-2 rounded-2xl transition-all shadow-lg text-white font-bold",
!selectedChoice || isLoading || isTimedOut
? "bg-slate-300 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-700 active:scale-[0.97]"
)}
>
{isLoading ? <Loader2 size={20} className="animate-spin" /> : <Send size={20} />}
<span className="text-sm"></span>
</button>
</div>
) : (
<div className="max-w-3xl mx-auto flex items-end gap-3"> <div className="max-w-3xl mx-auto flex items-end gap-3">
<textarea <textarea
value={inputValue} value={inputValue}
@@ -614,6 +673,7 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
<Send size={22} className={isLoading ? "animate-pulse" : ""} /> <Send size={22} className={isLoading ? "animate-pulse" : ""} />
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
@@ -76,6 +76,48 @@ export default function QuestionBankDetailView() {
const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' }); const [generateForm, setGenerateForm] = useState({ count: 5, knowledgeBaseContent: '' });
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const selectableItems = items.filter(i => i.status === 'PENDING_REVIEW');
const allSelected = selectableItems.length > 0 && selectableItems.every(i => selectedItemIds.has(i.id));
const toggleSelectAll = () => {
if (allSelected) {
setSelectedItemIds(new Set());
} else {
setSelectedItemIds(new Set(selectableItems.map(i => i.id)));
}
};
const toggleSelectItem = (itemId: string) => {
setSelectedItemIds(prev => {
const next = new Set(prev);
if (next.has(itemId)) next.delete(itemId); else next.add(itemId);
return next;
});
};
const handleBatchApprove = async () => {
const ids = Array.from(selectedItemIds);
if (ids.length === 0) return;
try {
await questionBankService.batchReviewItems(bankId, ids, true);
showSuccess(`已通过 ${ids.length} 道题目`);
setSelectedItemIds(new Set());
fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
const handleBatchReject = async () => {
const ids = Array.from(selectedItemIds);
if (ids.length === 0) return;
try {
await questionBankService.batchReviewItems(bankId, ids, false);
showSuccess(`已驳回 ${ids.length} 道题目`);
setSelectedItemIds(new Set());
fetchData();
} catch (err: any) { showError(err.message || t('actionFailed')); }
};
useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]); useEffect(() => { fetchData(); fetchTemplates(); }, [bankId]);
@@ -304,10 +346,28 @@ export default function QuestionBankDetailView() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2> <h2 className="text-lg font-black text-slate-900">{t('questionList')}</h2>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: (dimensionOptions[0]?.value as any) || 'WORK_CAPABILITY' }); }} <div className="flex items-center gap-2">
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95"> {selectedItemIds.size > 0 && (
<Plus size={16} /> {t('addQuestion')} <>
</button> <button onClick={handleBatchApprove}
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-emerald-100 hover:bg-emerald-700 transition-all active:scale-95">
<Check size={14} /> ({selectedItemIds.size})
</button>
<button onClick={handleBatchReject}
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-red-100 hover:bg-red-600 transition-all active:scale-95">
<X size={14} />
</button>
</>
)}
<button onClick={toggleSelectAll}
className="px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 hover:bg-slate-200 transition-all">
{allSelected ? '取消全选' : '全选'}
</button>
<button onClick={() => { setShowAddItem(true); setEditingItem(null); setKeyPointsInput(''); setItemForm({ questionText: '', questionType: 'SHORT_ANSWER', keyPoints: [], difficulty: 'STANDARD', dimension: (dimensionOptions[0]?.value as any) || 'WORK_CAPABILITY' }); }}
className="px-5 py-3 bg-blue-600 text-white rounded-xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg shadow-blue-100 hover:bg-blue-700 transition-all active:scale-95">
<Plus size={16} /> {t('addQuestion')}
</button>
</div>
</div> </div>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -327,6 +387,11 @@ export default function QuestionBankDetailView() {
className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"> className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden">
<div className={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} /> <div className={`absolute top-0 right-0 w-40 h-40 rounded-full blur-3xl -mr-20 -mt-20 ${itemStat.blur}`} />
<div className="relative z-10 flex items-start justify-between"> <div className="relative z-10 flex items-start justify-between">
{item.status === 'PENDING_REVIEW' && (
<input type="checkbox" checked={selectedItemIds.has(item.id)}
onChange={() => toggleSelectItem(item.id)}
className="mt-1.5 mr-3 w-4 h-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 shrink-0 cursor-pointer" />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2.5 flex-wrap"> <div className="flex items-center gap-2 mb-2.5 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}</span> <span className="inline-flex items-center gap-1 px-2.5 py-1 bg-slate-50 text-slate-600 text-[10px] font-bold rounded-lg border border-slate-100">{typeIcons[item.questionType]}{t(QUESTION_TYPES.find(qt => qt.value === item.questionType)?.labelKey || 'shortAnswer')}</span>