From d24a1a1f9243e5d0f952f66709bd16fb14e463f6 Mon Sep 17 00:00:00 2001 From: handsomezhuzhu <2658601135@qq.com> Date: Mon, 1 Dec 2025 22:43:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Gemini=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=92=8C=20AI=20=E5=8F=82=E8=80=83=E7=AD=94=E6=A1=88?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要功能: - 🎯 新增 Google Gemini AI 提供商支持 - 原生 PDF 理解能力(最多1000页) - 完整保留图片、表格、公式等内容 - 支持自定义 Base URL(用于代理/中转服务) - 🤖 实现 AI 参考答案自动生成 - 当题目缺少答案时自动调用 AI 生成参考答案 - 支持单选、多选、判断、简答等所有题型 - 答案标记为"AI参考答案:"便于识别 - 🔧 优化文档解析功能 - 改进中文 Prompt 提高识别准确度 - 自动修复 JSON 中的控制字符(换行符等) - 智能题目类型验证和自动转换(proof→short等) - 增加超时时间和重试机制 - 🎨 完善管理后台配置界面 - 新增 Gemini 配置区域 - 突出显示 PDF 原生支持特性 - 为其他提供商添加"仅文本"警告 - 支持 Gemini Base URL 自定义 技术改进: - 添加 google-genai 依赖 - 实现异步 API 调用适配 - 完善错误处理和日志输出 - 统一配置管理和数据库存储 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- backend/requirements.txt | 1 + backend/routers/admin.py | 7 +- backend/routers/exam.py | 167 ++++++++- backend/schemas.py | 6 + backend/services/config_service.py | 8 +- backend/services/llm_service.py | 513 +++++++++++++++++++++++++-- frontend/src/pages/AdminSettings.jsx | 185 ++++++++-- 7 files changed, 824 insertions(+), 63 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 04442ac..55d383c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,6 +15,7 @@ aiofiles==23.2.1 httpx==0.26.0 openai==1.10.0 anthropic==0.8.1 +google-genai==1.0.0 python-docx==1.1.0 PyPDF2==3.0.1 openpyxl==3.1.2 diff --git a/backend/routers/admin.py b/backend/routers/admin.py index e1ca233..b1b881e 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -34,7 +34,7 @@ async def get_system_config( "allow_registration": configs.get("allow_registration", "true").lower() == "true", "max_upload_size_mb": int(configs.get("max_upload_size_mb", "10")), "max_daily_uploads": int(configs.get("max_daily_uploads", "20")), - "ai_provider": configs.get("ai_provider", "openai"), + "ai_provider": configs.get("ai_provider", "gemini"), # API Configuration "openai_api_key": mask_api_key(configs.get("openai_api_key")), "openai_base_url": configs.get("openai_base_url", "https://api.openai.com/v1"), @@ -43,7 +43,10 @@ async def get_system_config( "anthropic_model": configs.get("anthropic_model", "claude-3-haiku-20240307"), "qwen_api_key": mask_api_key(configs.get("qwen_api_key")), "qwen_base_url": configs.get("qwen_base_url", "https://dashscope.aliyuncs.com/compatible-mode/v1"), - "qwen_model": configs.get("qwen_model", "qwen-plus") + "qwen_model": configs.get("qwen_model", "qwen-plus"), + "gemini_api_key": mask_api_key(configs.get("gemini_api_key")), + "gemini_base_url": configs.get("gemini_base_url", ""), + "gemini_model": configs.get("gemini_model", "gemini-2.0-flash-exp") } diff --git a/backend/routers/exam.py b/backend/routers/exam.py index 2d56165..ac58711 100644 --- a/backend/routers/exam.py +++ b/backend/routers/exam.py @@ -67,10 +67,87 @@ async def check_upload_limits(user_id: int, file_size: int, db: AsyncSession): ) +async def generate_ai_reference_answer( + llm_service, + question_content: str, + question_type: str, + options: Optional[List[str]] = None +) -> str: + """ + Generate an AI reference answer for a question without a provided answer. + + Args: + llm_service: LLM service instance + question_content: The question text + question_type: Type of question (single, multiple, judge, short) + options: Question options (for choice questions) + + Returns: + Generated answer text + """ + # Build prompt based on question type + if question_type in ["single", "multiple"] and options: + options_text = "\n".join(options) + prompt = f"""这是一道{ + '单选题' if question_type == 'single' else '多选题' + },但文档中没有提供答案。请根据题目内容,推理出最可能的正确答案。 + +题目:{question_content} + +选项: +{options_text} + +请只返回你认为正确的选项字母(如 A 或 AB),不要有其他解释。如果无法确定,请返回"无法确定"。""" + elif question_type == "judge": + prompt = f"""这是一道判断题,但文档中没有提供答案。请根据题目内容,判断正误。 + +题目:{question_content} + +请只返回"对"或"错",不要有其他解释。如果无法确定,请返回"无法确定"。""" + else: # short answer + prompt = f"""这是一道简答题,但文档中没有提供答案。请根据题目内容,给出一个简洁的参考答案(50字以内)。 + +题目:{question_content} + +请直接返回答案内容,不要有"答案:"等前缀。如果无法回答,请返回"无法确定"。""" + + # Generate answer using LLM + if llm_service.provider == "gemini": + import asyncio + + def _generate(): + return llm_service.client.models.generate_content( + model=llm_service.model, + contents=prompt + ) + + response = await asyncio.to_thread(_generate) + return response.text.strip() + elif llm_service.provider == "anthropic": + response = await llm_service.client.messages.create( + model=llm_service.model, + max_tokens=256, + messages=[{"role": "user", "content": prompt}] + ) + return response.content[0].text.strip() + else: # OpenAI or Qwen + response = await llm_service.client.chat.completions.create( + model=llm_service.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant that provides concise answers."}, + {"role": "user", "content": prompt} + ], + temperature=0.7, + max_tokens=256 + ) + return response.choices[0].message.content.strip() + + async def process_questions_with_dedup( exam_id: int, questions_data: List[dict], - db: AsyncSession + db: AsyncSession, + llm_service=None ) -> ParseResult: """ Process parsed questions with deduplication logic. @@ -79,6 +156,7 @@ async def process_questions_with_dedup( exam_id: Target exam ID questions_data: List of question dicts from LLM parsing db: Database session + llm_service: LLM service instance for generating AI answers Returns: ParseResult with statistics @@ -86,6 +164,7 @@ async def process_questions_with_dedup( total_parsed = len(questions_data) duplicates_removed = 0 new_added = 0 + ai_answers_generated = 0 # Get existing content hashes for this exam result = await db.execute( @@ -101,13 +180,40 @@ async def process_questions_with_dedup( duplicates_removed += 1 continue + # Handle missing answers - generate AI reference answer + answer = q_data.get("answer") + if (answer is None or answer == "null" or answer == "") and llm_service: + print(f"[Question] Generating AI reference answer for: {q_data['content'][:50]}...", flush=True) + try: + # Convert question type to string if it's not already + q_type = q_data["type"] + if hasattr(q_type, 'value'): + q_type = q_type.value + elif isinstance(q_type, str): + q_type = q_type.lower() + + ai_answer = await generate_ai_reference_answer( + llm_service, + q_data["content"], + q_type, + q_data.get("options") + ) + answer = f"AI参考答案:{ai_answer}" + ai_answers_generated += 1 + print(f"[Question] ✅ AI answer generated: {ai_answer[:50]}...", flush=True) + except Exception as e: + print(f"[Question] ⚠️ Failed to generate AI answer: {e}", flush=True) + answer = "(答案未提供)" + elif answer is None or answer == "null" or answer == "": + answer = "(答案未提供)" + # Create new question new_question = Question( exam_id=exam_id, content=q_data["content"], type=q_data["type"], options=q_data.get("options"), - answer=q_data["answer"], + answer=answer, analysis=q_data.get("analysis"), content_hash=content_hash ) @@ -117,11 +223,15 @@ async def process_questions_with_dedup( await db.commit() + message = f"Parsed {total_parsed} questions, removed {duplicates_removed} duplicates, added {new_added} new questions" + if ai_answers_generated > 0: + message += f", generated {ai_answers_generated} AI reference answers" + return ParseResult( total_parsed=total_parsed, duplicates_removed=duplicates_removed, new_added=new_added, - message=f"Parsed {total_parsed} questions, removed {duplicates_removed} duplicates, added {new_added} new questions" + message=message ) @@ -145,27 +255,54 @@ async def async_parse_and_save( exam.status = ExamStatus.PROCESSING await db.commit() - # Parse document - print(f"[Exam {exam_id}] Parsing document: {filename}") - text_content = await document_parser.parse_file(file_content, filename) - - if not text_content or len(text_content.strip()) < 10: - raise Exception("Document appears to be empty or too short") - # Load LLM configuration from database llm_config = await load_llm_config(db) llm_service = LLMService(config=llm_config) - # Parse questions using LLM - print(f"[Exam {exam_id}] Calling LLM to extract questions...") - questions_data = await llm_service.parse_document(text_content) + # Check if file is PDF and provider is Gemini + is_pdf = filename.lower().endswith('.pdf') + is_gemini = llm_config.get('ai_provider') == 'gemini' + + print(f"[Exam {exam_id}] Parsing document: {filename}") + print(f"[Exam {exam_id}] File type: {'PDF' if is_pdf else 'Text-based'}", flush=True) + print(f"[Exam {exam_id}] AI Provider: {llm_config.get('ai_provider')}", flush=True) + + try: + if is_pdf and is_gemini: + # Use Gemini's native PDF processing + print(f"[Exam {exam_id}] Using Gemini native PDF processing", flush=True) + print(f"[Exam {exam_id}] PDF file size: {len(file_content)} bytes", flush=True) + questions_data = await llm_service.parse_document_with_pdf(file_content, filename) + else: + # Extract text first, then parse + if is_pdf: + print(f"[Exam {exam_id}] ⚠️ Warning: Using text extraction for PDF (provider does not support native PDF)", flush=True) + + print(f"[Exam {exam_id}] Extracting text from document...", flush=True) + text_content = await document_parser.parse_file(file_content, filename) + + if not text_content or len(text_content.strip()) < 10: + raise Exception("Document appears to be empty or too short") + + print(f"[Exam {exam_id}] Text content length: {len(text_content)} chars", flush=True) + print(f"[Exam {exam_id}] Document content preview:\n{text_content[:500]}\n{'...' if len(text_content) > 500 else ''}", flush=True) + print(f"[Exam {exam_id}] Calling LLM to extract questions...", flush=True) + + questions_data = await llm_service.parse_document(text_content) + + except Exception as parse_error: + print(f"[Exam {exam_id}] ⚠️ Parse error details: {type(parse_error).__name__}", flush=True) + print(f"[Exam {exam_id}] ⚠️ Parse error message: {str(parse_error)}", flush=True) + import traceback + print(f"[Exam {exam_id}] ⚠️ Full traceback:\n{traceback.format_exc()}", flush=True) + raise if not questions_data: raise Exception("No questions found in document") - # Process questions with deduplication + # Process questions with deduplication and AI answer generation print(f"[Exam {exam_id}] Processing questions with deduplication...") - parse_result = await process_questions_with_dedup(exam_id, questions_data, db) + parse_result = await process_questions_with_dedup(exam_id, questions_data, db, llm_service) # Update exam status and total questions result = await db.execute(select(Exam).where(Exam.id == exam_id)) diff --git a/backend/schemas.py b/backend/schemas.py index d1c6134..2f7d9cc 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -54,6 +54,9 @@ class SystemConfigUpdate(BaseModel): qwen_api_key: Optional[str] = None qwen_base_url: Optional[str] = None qwen_model: Optional[str] = None + gemini_api_key: Optional[str] = None + gemini_base_url: Optional[str] = None + gemini_model: Optional[str] = None class SystemConfigResponse(BaseModel): @@ -70,6 +73,9 @@ class SystemConfigResponse(BaseModel): qwen_api_key: Optional[str] = None qwen_base_url: Optional[str] = None qwen_model: Optional[str] = None + gemini_api_key: Optional[str] = None + gemini_base_url: Optional[str] = None + gemini_model: Optional[str] = None # ============ Exam Schemas ============ diff --git a/backend/services/config_service.py b/backend/services/config_service.py index 69b0787..f42bb5e 100644 --- a/backend/services/config_service.py +++ b/backend/services/config_service.py @@ -26,7 +26,7 @@ async def load_llm_config(db: AsyncSession) -> Dict[str, str]: # Build configuration dictionary config = { - 'ai_provider': db_configs.get('ai_provider', 'openai'), + 'ai_provider': db_configs.get('ai_provider', 'gemini'), # OpenAI 'openai_api_key': db_configs.get('openai_api_key'), 'openai_base_url': db_configs.get('openai_base_url', 'https://api.openai.com/v1'), @@ -37,7 +37,11 @@ async def load_llm_config(db: AsyncSession) -> Dict[str, str]: # Qwen 'qwen_api_key': db_configs.get('qwen_api_key'), 'qwen_base_url': db_configs.get('qwen_base_url', 'https://dashscope.aliyuncs.com/compatible-mode/v1'), - 'qwen_model': db_configs.get('qwen_model', 'qwen-plus') + 'qwen_model': db_configs.get('qwen_model', 'qwen-plus'), + # Gemini + 'gemini_api_key': db_configs.get('gemini_api_key'), + 'gemini_base_url': db_configs.get('gemini_base_url'), # Optional, defaults to Google's API + 'gemini_model': db_configs.get('gemini_model', 'gemini-2.0-flash-exp') } return config diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py index 9b77624..dfcfae2 100644 --- a/backend/services/llm_service.py +++ b/backend/services/llm_service.py @@ -6,6 +6,8 @@ import json from typing import List, Dict, Any, Optional from openai import AsyncOpenAI from anthropic import AsyncAnthropic +from google import genai +from google.genai import types import httpx from models import QuestionType @@ -36,9 +38,17 @@ class LLMService: self.client = AsyncOpenAI( api_key=api_key, - base_url=base_url + base_url=base_url, + timeout=120.0, # 增加超时时间到 120 秒 + max_retries=3 # 自动重试 3 次 ) + # Log configuration for debugging + print(f"[LLM Config] Provider: OpenAI", flush=True) + print(f"[LLM Config] Base URL: {base_url}", flush=True) + print(f"[LLM Config] Model: {self.model}", flush=True) + print(f"[LLM Config] API Key: {api_key[:10]}...{api_key[-4:] if len(api_key) > 14 else 'xxxx'}", flush=True) + elif self.provider == "anthropic": api_key = (config or {}).get("anthropic_api_key") or os.getenv("ANTHROPIC_API_KEY") self.model = (config or {}).get("anthropic_model") or os.getenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307") @@ -60,9 +70,58 @@ class LLMService: self.client = AsyncOpenAI( api_key=api_key, - base_url=base_url + base_url=base_url, + timeout=120.0, # 增加超时时间到 120 秒 + max_retries=3 # 自动重试 3 次 ) + elif self.provider == "gemini": + api_key = (config or {}).get("gemini_api_key") or os.getenv("GEMINI_API_KEY") + base_url = (config or {}).get("gemini_base_url") or os.getenv("GEMINI_BASE_URL") + self.model = (config or {}).get("gemini_model") or os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp") + + if not api_key: + raise ValueError("Gemini API key not configured") + + # Create client with optional custom base URL + if base_url: + # Use custom base URL (for proxy/relay services) + print(f"[LLM Config] Using custom Gemini base URL: {base_url}", flush=True) + + # Try different methods to set custom base URL + try: + # Method 1: Try http_options parameter + self.client = genai.Client( + api_key=api_key, + http_options={'api_endpoint': base_url} + ) + print(f"[LLM Config] ✓ Set base URL via http_options", flush=True) + except TypeError: + try: + # Method 2: Try vertexai parameter (some versions) + self.client = genai.Client( + api_key=api_key, + vertexai=False, + client_options={'api_endpoint': base_url} + ) + print(f"[LLM Config] ✓ Set base URL via client_options", flush=True) + except: + # Method 3: Set environment variable and create client + print(f"[LLM Config] ⚠️ SDK doesn't support custom URL parameter, using environment variable", flush=True) + os.environ['GOOGLE_API_BASE'] = base_url + self.client = genai.Client(api_key=api_key) + print(f"[LLM Config] ✓ Set base URL via environment variable", flush=True) + else: + # Use default Google API + self.client = genai.Client(api_key=api_key) + + # Log configuration for debugging + print(f"[LLM Config] Provider: Gemini", flush=True) + print(f"[LLM Config] Model: {self.model}", flush=True) + if base_url: + print(f"[LLM Config] Base URL: {base_url}", flush=True) + print(f"[LLM Config] API Key: {api_key[:10]}...{api_key[-4:] if len(api_key) > 14 else 'xxxx'}", flush=True) + else: raise ValueError(f"Unsupported AI provider: {self.provider}") @@ -82,33 +141,49 @@ class LLMService: ... ] """ - prompt = """You are a professional question parser. Parse the given document and extract all questions. + prompt = """你是一个专业的试题解析专家。请仔细分析下面的文档内容,提取其中的所有试题。 -For each question, identify: -1. Question content (the question text) -2. Question type: single (单选), multiple (多选), judge (判断), short (简答) -3. Options (for choice questions only, format: ["A. Option1", "B. Option2", ...]) -4. Correct answer -5. Analysis/Explanation (if available) +请注意: +- 文档中可能包含中文或英文题目 +- 题目可能有多种格式,请灵活识别 +- 即使格式不标准,也请尽量提取题目内容 +- 如果文档只是普通文章而没有题目,请返回空数组 [] -Return ONLY a JSON array of questions, with no additional text: +对于每道题目,请识别: +1. 题目内容 (完整的题目文字) +2. 题目类型(**只能**使用以下4种类型之一): + - single:单选题 + - multiple:多选题 + - judge:判断题 + - short:简答题(包括问答题、计算题、证明题、填空题等所有非选择题) +3. 选项 (仅针对选择题,格式: ["A. 选项1", "B. 选项2", ...]) +4. 正确答案 (请仔细查找文档中的答案。如果确实没有答案,可以填 null) +5. 解析/说明 (如果有的话) + +**重要**:题目类型必须是 single、multiple、judge、short 之一,不要使用其他类型名称! + +返回格式:请**只返回** JSON 数组,不要有任何其他文字或 markdown 代码块: [ - { - "content": "question text", + {{ + "content": "题目内容", "type": "single", - "options": ["A. Option1", "B. Option2", "C. Option3", "D. Option4"], + "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], "answer": "A", - "analysis": "explanation" - }, + "analysis": "解析说明" + }}, ... ] -Document content: +文档内容: --- {content} --- -IMPORTANT: Return ONLY the JSON array, no markdown code blocks or explanations.""" +重要提示: +- 仔细阅读文档内容 +- 识别所有看起来像试题的内容 +- 如果文档中没有题目(比如只是普通文章),返回 [] +- **只返回 JSON 数组**,不要包含 ```json 或其他标记""" try: if self.provider == "anthropic": @@ -120,6 +195,20 @@ IMPORTANT: Return ONLY the JSON array, no markdown code blocks or explanations." ] ) result = response.content[0].text + elif self.provider == "gemini": + # Gemini uses different API + import asyncio + + def _generate_content(): + return self.client.models.generate_content( + model=self.model, + contents=prompt.format(content=content) + ) + + print(f"[Gemini Text] Calling Gemini API with model: {self.model}", flush=True) + response = await asyncio.to_thread(_generate_content) + result = response.text + print(f"[Gemini Text] API call completed", flush=True) else: # OpenAI or Qwen response = await self.client.chat.completions.create( model=self.model, @@ -131,28 +220,398 @@ IMPORTANT: Return ONLY the JSON array, no markdown code blocks or explanations." ) result = response.choices[0].message.content + # Log original response for debugging + import sys + print(f"[LLM Raw Response] Length: {len(result)} chars", flush=True) + print(f"[LLM Raw Response] First 300 chars:\n{result[:300]}", flush=True) + print(f"[LLM Raw Response] Last 200 chars:\n{result[-200:]}", flush=True) + sys.stdout.flush() + # Clean result and parse JSON result = result.strip() + + # Remove markdown code blocks if result.startswith("```json"): result = result[7:] - if result.startswith("```"): + elif result.startswith("```"): result = result[3:] + if result.endswith("```"): result = result[:-3] + result = result.strip() - questions = json.loads(result) + # Try to find JSON array if there's extra text + if not result.startswith('['): + # Find the first '[' character + start_idx = result.find('[') + if start_idx != -1: + print(f"[JSON Cleanup] Found '[' at position {start_idx}, extracting array...") + result = result[start_idx:] + else: + print(f"[JSON Error] No '[' found in response!") + raise Exception("LLM response does not contain a JSON array") - # Add content hash to each question + if not result.endswith(']'): + # Find the last ']' character + end_idx = result.rfind(']') + if end_idx != -1: + print(f"[JSON Cleanup] Found last ']' at position {end_idx}") + result = result[:end_idx + 1] + + result = result.strip() + + # Log the cleaned result for debugging + print(f"[LLM Cleaned JSON] Length: {len(result)} chars") + print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}") + + try: + questions = json.loads(result) + except json.JSONDecodeError as je: + print(f"[JSON Error] Failed to parse JSON at line {je.lineno}, column {je.colno}") + print(f"[JSON Error] Error: {je.msg}") + + # If error is about control characters, try to fix them + if "control character" in je.msg.lower() or "invalid \\escape" in je.msg.lower(): + print(f"[JSON Cleanup] Attempting to fix control characters...", flush=True) + + # Fix unescaped control characters in JSON string values + import re + + def fix_string_value(match): + """Fix control characters inside a JSON string value""" + string_content = match.group(1) + # Escape control characters + string_content = string_content.replace('\n', '\\n') + string_content = string_content.replace('\r', '\\r') + string_content = string_content.replace('\t', '\\t') + string_content = string_content.replace('\b', '\\b') + string_content = string_content.replace('\f', '\\f') + return f'"{string_content}"' + + # Match string values in JSON + # Pattern matches: "..." (handles escaped quotes and backslashes) + # (?:[^"\\]|\\.)* means: either non-quote-non-backslash OR backslash-followed-by-anything, repeated + fixed_result = re.sub(r'"((?:[^"\\]|\\.)*)"', fix_string_value, result) + + print(f"[JSON Cleanup] Retrying with fixed control characters...", flush=True) + try: + questions = json.loads(fixed_result) + print(f"[JSON Cleanup] ✅ Successfully parsed after fixing control characters!", flush=True) + except json.JSONDecodeError as je2: + print(f"[JSON Error] Still failed after fix: {je2.msg}", flush=True) + # Print context around the error + lines = result.split('\n') + if je.lineno <= len(lines): + start = max(0, je.lineno - 3) + end = min(len(lines), je.lineno + 2) + print(f"[JSON Error] Context (lines {start+1}-{end}):") + for i in range(start, end): + marker = " >>> " if i == je.lineno - 1 else " " + print(f"{marker}{i+1}: {lines[i]}") + raise Exception(f"Invalid JSON format from LLM: {je.msg} at line {je.lineno}") + else: + # Print context around the error + lines = result.split('\n') + if je.lineno <= len(lines): + start = max(0, je.lineno - 3) + end = min(len(lines), je.lineno + 2) + print(f"[JSON Error] Context (lines {start+1}-{end}):") + for i in range(start, end): + marker = " >>> " if i == je.lineno - 1 else " " + print(f"{marker}{i+1}: {lines[i]}") + raise Exception(f"Invalid JSON format from LLM: {je.msg} at line {je.lineno}") + + # Validate that we got a list + if not isinstance(questions, list): + raise Exception(f"Expected a list of questions, got {type(questions)}") + + if len(questions) == 0: + raise Exception("No questions found in the parsed result") + + # Validate and fix question types + valid_types = {"single", "multiple", "judge", "short"} + type_mapping = { + "proof": "short", + "essay": "short", + "calculation": "short", + "fill": "short", + "填空": "short", + "证明": "short", + "计算": "short", + "问答": "short", + "单选": "single", + "多选": "multiple", + "判断": "judge", + "简答": "short" + } + + # Add content hash and validate types for q in questions: + if "content" not in q: + print(f"[Warning] Question missing 'content' field: {q}") + continue + + # Validate and fix question type + q_type = q.get("type", "short") + if isinstance(q_type, str): + q_type_lower = q_type.lower() + if q_type_lower not in valid_types: + # Try to map to valid type + if q_type_lower in type_mapping: + old_type = q_type + q["type"] = type_mapping[q_type_lower] + print(f"[Type Fix] Changed '{old_type}' to '{q['type']}' for question: {q['content'][:50]}...", flush=True) + else: + # Default to short answer + print(f"[Type Fix] Unknown type '{q_type}', defaulting to 'short' for question: {q['content'][:50]}...", flush=True) + q["type"] = "short" + else: + q["type"] = q_type_lower + else: + q["type"] = "short" + q["content_hash"] = calculate_content_hash(q["content"]) return questions except Exception as e: - print(f"Error parsing document: {e}") + print(f"[Error] Document parsing failed: {str(e)}") raise Exception(f"Failed to parse document: {str(e)}") + async def parse_document_with_pdf(self, pdf_bytes: bytes, filename: str) -> List[Dict[str, Any]]: + """ + Parse PDF document using Gemini's native PDF understanding. + Only works with Gemini provider. + + Args: + pdf_bytes: PDF file content as bytes + filename: Original filename for logging + + Returns: + List of question dictionaries + """ + if self.provider != "gemini": + raise ValueError("PDF parsing is only supported with Gemini provider") + + prompt = """你是一个专业的试题解析专家。请仔细分析这个 PDF 文档,提取其中的所有试题。 + +请注意: +- PDF 中可能包含中文或英文题目 +- 题目可能有多种格式,请灵活识别 +- 即使格式不标准,也请尽量提取题目内容 +- 题目内容如果包含代码或换行,请将换行符替换为空格或\\n + +对于每道题目,请识别: +1. 题目内容 (完整的题目文字,如果有代码请保持在一行或用\\n表示换行) +2. 题目类型(**只能**使用以下4种类型之一): + - single:单选题 + - multiple:多选题 + - judge:判断题 + - short:简答题(包括问答题、计算题、证明题、填空题等所有非选择题) +3. 选项 (仅针对选择题,格式: ["A. 选项1", "B. 选项2", ...]) +4. 正确答案 (请仔细查找文档中的答案。如果确实没有答案,可以填 null) +5. 解析/说明 (如果有的话) + +**重要**:题目类型必须是 single、multiple、judge、short 之一,不要使用其他类型名称! + +返回格式要求: +1. **必须**返回一个完整的 JSON 数组(以 [ 开始,以 ] 结束) +2. **不要**返回 JSONL 格式(每行一个 JSON 对象) +3. **不要**包含 markdown 代码块标记(```json 或 ```) +4. **不要**包含任何解释性文字 + +正确的格式示例: +[ + {{ + "content": "题目内容", + "type": "single", + "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], + "answer": "A", + "analysis": "解析说明" + }}, + {{ + "content": "第二道题", + "type": "judge", + "options": [], + "answer": "对", + "analysis": null + }} +] + +重要提示: +- 请仔细查看 PDF 的每一页 +- 识别所有看起来像试题的内容 +- 如果找不到明确的选项,可以根据上下文推断题目类型 +- 题目内容中的换行请用\\n或空格替换,确保 JSON 格式正确 +- **只返回一个 JSON 数组**,不要包含其他任何内容""" + + try: + print(f"[Gemini PDF] Processing PDF: {filename}", flush=True) + print(f"[Gemini PDF] File size: {len(pdf_bytes)} bytes", flush=True) + + # Use Gemini's native PDF processing + # Run sync API in thread pool to avoid blocking + import asyncio + + def _generate_content(): + return self.client.models.generate_content( + model=self.model, + contents=[ + types.Part.from_bytes( + data=pdf_bytes, + mime_type='application/pdf', + ), + prompt + ] + ) + + print(f"[Gemini PDF] Calling Gemini API with model: {self.model}", flush=True) + response = await asyncio.to_thread(_generate_content) + print(f"[Gemini PDF] API call completed", flush=True) + + result = response.text + print(f"[Gemini PDF] Response retrieved, checking content...", flush=True) + + # Log original response for debugging + import sys + print(f"[LLM Raw Response] Length: {len(result)} chars", flush=True) + print(f"[LLM Raw Response] First 300 chars:\n{result[:300]}", flush=True) + print(f"[LLM Raw Response] Last 200 chars:\n{result[-200:]}", flush=True) + sys.stdout.flush() + + # Clean result and parse JSON (same as text method) + result = result.strip() + + # Remove markdown code blocks + if result.startswith("```json"): + result = result[7:] + elif result.startswith("```"): + result = result[3:] + + if result.endswith("```"): + result = result[:-3] + + result = result.strip() + + # Try to find JSON array if there's extra text + if not result.startswith('['): + start_idx = result.find('[') + if start_idx != -1: + print(f"[JSON Cleanup] Found '[' at position {start_idx}, extracting array...", flush=True) + result = result[start_idx:] + else: + print(f"[JSON Error] No '[' found in response!", flush=True) + raise Exception("LLM response does not contain a JSON array") + + if not result.endswith(']'): + end_idx = result.rfind(']') + if end_idx != -1: + print(f"[JSON Cleanup] Found last ']' at position {end_idx}", flush=True) + result = result[:end_idx + 1] + + result = result.strip() + + # Log the cleaned result for debugging + print(f"[LLM Cleaned JSON] Length: {len(result)} chars", flush=True) + print(f"[LLM Cleaned JSON] First 300 chars:\n{result[:300]}", flush=True) + + try: + questions = json.loads(result) + except json.JSONDecodeError as je: + print(f"[JSON Error] Failed to parse JSON at line {je.lineno}, column {je.colno}", flush=True) + print(f"[JSON Error] Error: {je.msg}", flush=True) + # Print context around the error + lines = result.split('\n') + if je.lineno <= len(lines): + start = max(0, je.lineno - 3) + end = min(len(lines), je.lineno + 2) + print(f"[JSON Error] Context (lines {start+1}-{end}):", flush=True) + for i in range(start, end): + marker = " >>> " if i == je.lineno - 1 else " " + print(f"{marker}{i+1}: {lines[i]}", flush=True) + raise Exception(f"Invalid JSON format from LLM: {je.msg} at line {je.lineno}") + + # Validate that we got a list + if not isinstance(questions, list): + raise Exception(f"Expected a list of questions, got {type(questions)}") + + if len(questions) == 0: + # Provide more helpful error message + print(f"[Gemini PDF] ⚠️ Gemini returned empty array - PDF may not contain recognizable questions", flush=True) + print(f"[Gemini PDF] 💡 Trying to get Gemini's explanation...", flush=True) + + # Ask Gemini what it saw in the PDF + def _ask_what_gemini_sees(): + return self.client.models.generate_content( + model=self.model, + contents=[ + types.Part.from_bytes( + data=pdf_bytes, + mime_type='application/pdf', + ), + "Please describe what you see in this PDF document. What is the main content? Are there any questions, exercises, or test items? Respond in Chinese." + ] + ) + + import asyncio + explanation_response = await asyncio.to_thread(_ask_what_gemini_sees) + explanation = explanation_response.text + print(f"[Gemini PDF] 📄 Gemini sees: {explanation[:500]}...", flush=True) + + raise Exception(f"No questions found in PDF. Gemini's description: {explanation[:200]}...") + + # Validate and fix question types + valid_types = {"single", "multiple", "judge", "short"} + type_mapping = { + "proof": "short", + "essay": "short", + "calculation": "short", + "fill": "short", + "填空": "short", + "证明": "short", + "计算": "short", + "问答": "short", + "单选": "single", + "多选": "multiple", + "判断": "judge", + "简答": "short" + } + + # Add content hash and validate types + for q in questions: + if "content" not in q: + print(f"[Warning] Question missing 'content' field: {q}", flush=True) + continue + + # Validate and fix question type + q_type = q.get("type", "short") + if isinstance(q_type, str): + q_type_lower = q_type.lower() + if q_type_lower not in valid_types: + # Try to map to valid type + if q_type_lower in type_mapping: + old_type = q_type + q["type"] = type_mapping[q_type_lower] + print(f"[Type Fix] Changed '{old_type}' to '{q['type']}' for question: {q['content'][:50]}...", flush=True) + else: + # Default to short answer + print(f"[Type Fix] Unknown type '{q_type}', defaulting to 'short' for question: {q['content'][:50]}...", flush=True) + q["type"] = "short" + else: + q["type"] = q_type_lower + else: + q["type"] = "short" + + q["content_hash"] = calculate_content_hash(q["content"]) + + print(f"[Gemini PDF] Successfully extracted {len(questions)} questions", flush=True) + return questions + + except Exception as e: + print(f"[Error] PDF parsing failed: {str(e)}", flush=True) + raise Exception(f"Failed to parse PDF document: {str(e)}") + async def grade_short_answer( self, question: str, @@ -201,6 +660,18 @@ Return ONLY the JSON object, no markdown or explanations.""" ] ) result = response.content[0].text + elif self.provider == "gemini": + # Gemini uses different API + import asyncio + + def _generate_content(): + return self.client.models.generate_content( + model=self.model, + contents=prompt + ) + + response = await asyncio.to_thread(_generate_content) + result = response.text else: # OpenAI or Qwen response = await self.client.chat.completions.create( model=self.model, diff --git a/frontend/src/pages/AdminSettings.jsx b/frontend/src/pages/AdminSettings.jsx index 89d17c1..5b484b4 100644 --- a/frontend/src/pages/AdminSettings.jsx +++ b/frontend/src/pages/AdminSettings.jsx @@ -2,26 +2,29 @@ * Admin Settings Page - Enhanced with API Configuration */ import React, { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' import { adminAPI } from '../api/client' import { useAuth } from '../context/AuthContext' -import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff } from 'lucide-react' +import { Settings, Save, Loader, Key, Link as LinkIcon, Eye, EyeOff, ArrowLeft } from 'lucide-react' import toast from 'react-hot-toast' export const AdminSettings = () => { const { user } = useAuth() + const navigate = useNavigate() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [showApiKeys, setShowApiKeys] = useState({ openai: false, anthropic: false, - qwen: false + qwen: false, + gemini: false }) const [config, setConfig] = useState({ allow_registration: true, max_upload_size_mb: 10, max_daily_uploads: 20, - ai_provider: 'openai', + ai_provider: 'gemini', // OpenAI openai_api_key: '', openai_base_url: 'https://api.openai.com/v1', @@ -32,7 +35,11 @@ export const AdminSettings = () => { // Qwen qwen_api_key: '', qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - qwen_model: 'qwen-plus' + qwen_model: 'qwen-plus', + // Gemini + gemini_api_key: '', + gemini_base_url: '', + gemini_model: 'gemini-2.0-flash-exp' }) useEffect(() => { @@ -114,11 +121,20 @@ export const AdminSettings = () => { {/* Header */}
-
- -
-

系统设置

-

管理员:{user?.username}

+
+
+ + +
+

系统设置

+

管理员:{user?.username}

+
@@ -189,12 +205,13 @@ export const AdminSettings = () => { onChange={(e) => handleChange('ai_provider', e.target.value)} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" > +

- 选择后在下方配置对应的 API 密钥 + 选择后在下方配置对应的 API 密钥。Gemini 支持原生 PDF 解析

@@ -209,6 +226,13 @@ export const AdminSettings = () => { )}
+ {/* Text-only warning */} +
+

+ ⚠️ OpenAI 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。 +

+
+ {/* API Key */}
@@ -281,6 +310,13 @@ export const AdminSettings = () => { )} + {/* Text-only warning */} +
+

+ ⚠️ Anthropic 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。 +

+
+ {/* API Key */}
@@ -351,6 +393,13 @@ export const AdminSettings = () => { )} + {/* Text-only warning */} +
+

+ ⚠️ 通义千问 仅支持文本解析,不支持 PDF 原生理解。PDF 文件将通过文本提取处理,可能丢失格式和图片信息。 +

+
+ {/* API Key */}
+ + + {/* Gemini Configuration */} +
+
+ +

Google Gemini 配置

+ {config.ai_provider === 'gemini' && ( + 当前使用 + )} +
+ + {/* PDF support highlight */} +
+

+ ✅ Gemini 支持原生 PDF 理解,可直接处理 PDF 文件(最多 1000 页),完整保留图片、表格、公式等内容。 +

+
+ + {/* API Key */} +
+ +
+ handleChange('gemini_api_key', e.target.value)} + placeholder="AIza..." + className="w-full px-4 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm" + /> + +
+

从 https://aistudio.google.com/apikey 获取

+
+ + {/* Base URL (optional) */} +
+ +
+ + handleChange('gemini_base_url', e.target.value)} + placeholder="https://generativelanguage.googleapis.com(留空使用默认)" + className="w-full pl-10 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm" + /> +
+

+ 可配置自定义代理或中转服务(支持 Key 轮训等)。留空则使用 Google 官方 API +

+
+ + {/* Model */} +
+ + handleChange('gemini_model', e.target.value)} + placeholder="gemini-2.0-flash-exp" + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent font-mono text-sm" + /> + + + + + + +

可输入自定义模型名称,或从建议中选择