feat: 添加 Gemini 支持和 AI 参考答案生成功能

主要功能:
- 🎯 新增 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 <noreply@anthropic.com>
This commit is contained in:
2025-12-01 22:43:08 +08:00
parent a01f3540c5
commit d24a1a1f92
7 changed files with 824 additions and 63 deletions

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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))

View File

@@ -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 ============

View File

@@ -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

View File

@@ -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,

View File

@@ -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 */}
<div className="bg-white shadow">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3">
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
title="返回"
>
<ArrowLeft className="h-6 w-6 text-gray-600" />
</button>
<Settings className="h-8 w-8 text-primary-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
<p className="text-gray-600">管理员{user?.username}</p>
</div>
</div>
</div>
</div>
@@ -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"
>
<option value="gemini">Google Gemini (推荐)</option>
<option value="openai">OpenAI (GPT)</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="qwen">Qwen (通义千问)</option>
</select>
<p className="text-sm text-gray-500 mt-1">
选择后在下方配置对应的 API 密钥
选择后在下方配置对应的 API 密钥Gemini 支持原生 PDF 解析
</p>
</div>
</div>
@@ -209,6 +226,13 @@ export const AdminSettings = () => {
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
OpenAI 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
@@ -258,16 +282,21 @@ export const AdminSettings = () => {
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<select
<input
type="text"
list="openai-models"
value={config.openai_model}
onChange={(e) => handleChange('openai_model', 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"
>
placeholder="gpt-4o-mini"
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"
/>
<datalist id="openai-models">
<option value="gpt-4o">gpt-4o (最强)</option>
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo</option>
</select>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
@@ -281,6 +310,13 @@ export const AdminSettings = () => {
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
Anthropic 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
@@ -329,15 +365,21 @@ export const AdminSettings = () => {
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<select
<input
type="text"
list="anthropic-models"
value={config.anthropic_model}
onChange={(e) => handleChange('anthropic_model', 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"
>
placeholder="claude-3-haiku-20240307"
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"
/>
<datalist id="anthropic-models">
<option value="claude-3-5-sonnet-20241022">claude-3-5-sonnet (最强)</option>
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
<option value="claude-3-opus-20240229">claude-3-opus</option>
</select>
<option value="claude-3-sonnet-20240229">claude-3-sonnet</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
@@ -351,6 +393,13 @@ export const AdminSettings = () => {
)}
</div>
{/* Text-only warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
通义千问 仅支持文本解析不支持 PDF 原生理解PDF 文件将通过文本提取处理可能丢失格式和图片信息
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
@@ -400,15 +449,105 @@ export const AdminSettings = () => {
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<select
<input
type="text"
list="qwen-models"
value={config.qwen_model}
onChange={(e) => handleChange('qwen_model', 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"
>
placeholder="qwen-plus"
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"
/>
<datalist id="qwen-models">
<option value="qwen-max">qwen-max (最强)</option>
<option value="qwen-plus">qwen-plus (推荐)</option>
<option value="qwen-turbo">qwen-turbo</option>
</select>
<option value="qwen-turbo">qwen-turbo (快速)</option>
<option value="qwen-long">qwen-long (长文本)</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>
{/* Gemini Configuration */}
<div className={`bg-white rounded-xl shadow-md p-6 space-y-4 ${config.ai_provider === 'gemini' ? 'ring-2 ring-primary-500' : ''}`}>
<div className="flex items-center gap-2">
<Key className="h-5 w-5 text-purple-600" />
<h2 className="text-xl font-bold text-gray-900">Google Gemini 配置</h2>
{config.ai_provider === 'gemini' && (
<span className="px-2 py-1 text-xs font-medium bg-primary-100 text-primary-700 rounded-full">当前使用</span>
)}
</div>
{/* PDF support highlight */}
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
Gemini 支持原生 PDF 理解可直接处理 PDF 文件最多 1000 完整保留图片表格公式等内容
</p>
</div>
{/* API Key */}
<div>
<label className="block font-medium text-gray-900 mb-2">
API Key
</label>
<div className="relative">
<input
type={showApiKeys.gemini ? 'text' : 'password'}
value={config.gemini_api_key || ''}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('gemini')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showApiKeys.gemini ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
<p className="text-sm text-gray-500 mt-1"> https://aistudio.google.com/apikey 获取</p>
</div>
{/* Base URL (optional) */}
<div>
<label className="block font-medium text-gray-900 mb-2">
Base URL (可选)
</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={config.gemini_base_url}
onChange={(e) => 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"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
可配置自定义代理或中转服务支持 Key 轮训等留空则使用 Google 官方 API
</p>
</div>
{/* Model */}
<div>
<label className="block font-medium text-gray-900 mb-2">
模型
</label>
<input
type="text"
list="gemini-models"
value={config.gemini_model}
onChange={(e) => 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"
/>
<datalist id="gemini-models">
<option value="gemini-2.0-flash-exp">gemini-2.0-flash-exp (最新推荐)</option>
<option value="gemini-1.5-pro">gemini-1.5-pro (最强)</option>
<option value="gemini-1.5-flash">gemini-1.5-flash (快速)</option>
<option value="gemini-1.0-pro">gemini-1.0-pro</option>
</datalist>
<p className="text-xs text-gray-500 mt-1">可输入自定义模型名称或从建议中选择</p>
</div>
</div>