mirror of
https://github.com/handsomezhuzhu/QQuiz.git
synced 2026-02-20 20:10:14 +00:00
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:
@@ -15,6 +15,7 @@ aiofiles==23.2.1
|
|||||||
httpx==0.26.0
|
httpx==0.26.0
|
||||||
openai==1.10.0
|
openai==1.10.0
|
||||||
anthropic==0.8.1
|
anthropic==0.8.1
|
||||||
|
google-genai==1.0.0
|
||||||
python-docx==1.1.0
|
python-docx==1.1.0
|
||||||
PyPDF2==3.0.1
|
PyPDF2==3.0.1
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async def get_system_config(
|
|||||||
"allow_registration": configs.get("allow_registration", "true").lower() == "true",
|
"allow_registration": configs.get("allow_registration", "true").lower() == "true",
|
||||||
"max_upload_size_mb": int(configs.get("max_upload_size_mb", "10")),
|
"max_upload_size_mb": int(configs.get("max_upload_size_mb", "10")),
|
||||||
"max_daily_uploads": int(configs.get("max_daily_uploads", "20")),
|
"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
|
# API Configuration
|
||||||
"openai_api_key": mask_api_key(configs.get("openai_api_key")),
|
"openai_api_key": mask_api_key(configs.get("openai_api_key")),
|
||||||
"openai_base_url": configs.get("openai_base_url", "https://api.openai.com/v1"),
|
"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"),
|
"anthropic_model": configs.get("anthropic_model", "claude-3-haiku-20240307"),
|
||||||
"qwen_api_key": mask_api_key(configs.get("qwen_api_key")),
|
"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_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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
async def process_questions_with_dedup(
|
||||||
exam_id: int,
|
exam_id: int,
|
||||||
questions_data: List[dict],
|
questions_data: List[dict],
|
||||||
db: AsyncSession
|
db: AsyncSession,
|
||||||
|
llm_service=None
|
||||||
) -> ParseResult:
|
) -> ParseResult:
|
||||||
"""
|
"""
|
||||||
Process parsed questions with deduplication logic.
|
Process parsed questions with deduplication logic.
|
||||||
@@ -79,6 +156,7 @@ async def process_questions_with_dedup(
|
|||||||
exam_id: Target exam ID
|
exam_id: Target exam ID
|
||||||
questions_data: List of question dicts from LLM parsing
|
questions_data: List of question dicts from LLM parsing
|
||||||
db: Database session
|
db: Database session
|
||||||
|
llm_service: LLM service instance for generating AI answers
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ParseResult with statistics
|
ParseResult with statistics
|
||||||
@@ -86,6 +164,7 @@ async def process_questions_with_dedup(
|
|||||||
total_parsed = len(questions_data)
|
total_parsed = len(questions_data)
|
||||||
duplicates_removed = 0
|
duplicates_removed = 0
|
||||||
new_added = 0
|
new_added = 0
|
||||||
|
ai_answers_generated = 0
|
||||||
|
|
||||||
# Get existing content hashes for this exam
|
# Get existing content hashes for this exam
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -101,13 +180,40 @@ async def process_questions_with_dedup(
|
|||||||
duplicates_removed += 1
|
duplicates_removed += 1
|
||||||
continue
|
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
|
# Create new question
|
||||||
new_question = Question(
|
new_question = Question(
|
||||||
exam_id=exam_id,
|
exam_id=exam_id,
|
||||||
content=q_data["content"],
|
content=q_data["content"],
|
||||||
type=q_data["type"],
|
type=q_data["type"],
|
||||||
options=q_data.get("options"),
|
options=q_data.get("options"),
|
||||||
answer=q_data["answer"],
|
answer=answer,
|
||||||
analysis=q_data.get("analysis"),
|
analysis=q_data.get("analysis"),
|
||||||
content_hash=content_hash
|
content_hash=content_hash
|
||||||
)
|
)
|
||||||
@@ -117,11 +223,15 @@ async def process_questions_with_dedup(
|
|||||||
|
|
||||||
await db.commit()
|
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(
|
return ParseResult(
|
||||||
total_parsed=total_parsed,
|
total_parsed=total_parsed,
|
||||||
duplicates_removed=duplicates_removed,
|
duplicates_removed=duplicates_removed,
|
||||||
new_added=new_added,
|
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
|
exam.status = ExamStatus.PROCESSING
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Parse document
|
# Load LLM configuration from database
|
||||||
|
llm_config = await load_llm_config(db)
|
||||||
|
llm_service = LLMService(config=llm_config)
|
||||||
|
|
||||||
|
# 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}] 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)
|
text_content = await document_parser.parse_file(file_content, filename)
|
||||||
|
|
||||||
if not text_content or len(text_content.strip()) < 10:
|
if not text_content or len(text_content.strip()) < 10:
|
||||||
raise Exception("Document appears to be empty or too short")
|
raise Exception("Document appears to be empty or too short")
|
||||||
|
|
||||||
# Load LLM configuration from database
|
print(f"[Exam {exam_id}] Text content length: {len(text_content)} chars", flush=True)
|
||||||
llm_config = await load_llm_config(db)
|
print(f"[Exam {exam_id}] Document content preview:\n{text_content[:500]}\n{'...' if len(text_content) > 500 else ''}", flush=True)
|
||||||
llm_service = LLMService(config=llm_config)
|
print(f"[Exam {exam_id}] Calling LLM to extract questions...", flush=True)
|
||||||
|
|
||||||
# Parse questions using LLM
|
|
||||||
print(f"[Exam {exam_id}] Calling LLM to extract questions...")
|
|
||||||
questions_data = await llm_service.parse_document(text_content)
|
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:
|
if not questions_data:
|
||||||
raise Exception("No questions found in document")
|
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...")
|
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
|
# Update exam status and total questions
|
||||||
result = await db.execute(select(Exam).where(Exam.id == exam_id))
|
result = await db.execute(select(Exam).where(Exam.id == exam_id))
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class SystemConfigUpdate(BaseModel):
|
|||||||
qwen_api_key: Optional[str] = None
|
qwen_api_key: Optional[str] = None
|
||||||
qwen_base_url: Optional[str] = None
|
qwen_base_url: Optional[str] = None
|
||||||
qwen_model: 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):
|
class SystemConfigResponse(BaseModel):
|
||||||
@@ -70,6 +73,9 @@ class SystemConfigResponse(BaseModel):
|
|||||||
qwen_api_key: Optional[str] = None
|
qwen_api_key: Optional[str] = None
|
||||||
qwen_base_url: Optional[str] = None
|
qwen_base_url: Optional[str] = None
|
||||||
qwen_model: 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 ============
|
# ============ Exam Schemas ============
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async def load_llm_config(db: AsyncSession) -> Dict[str, str]:
|
|||||||
|
|
||||||
# Build configuration dictionary
|
# Build configuration dictionary
|
||||||
config = {
|
config = {
|
||||||
'ai_provider': db_configs.get('ai_provider', 'openai'),
|
'ai_provider': db_configs.get('ai_provider', 'gemini'),
|
||||||
# OpenAI
|
# OpenAI
|
||||||
'openai_api_key': db_configs.get('openai_api_key'),
|
'openai_api_key': db_configs.get('openai_api_key'),
|
||||||
'openai_base_url': db_configs.get('openai_base_url', 'https://api.openai.com/v1'),
|
'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
|
||||||
'qwen_api_key': db_configs.get('qwen_api_key'),
|
'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_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
|
return config
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import json
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from anthropic import AsyncAnthropic
|
from anthropic import AsyncAnthropic
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from models import QuestionType
|
from models import QuestionType
|
||||||
@@ -36,9 +38,17 @@ class LLMService:
|
|||||||
|
|
||||||
self.client = AsyncOpenAI(
|
self.client = AsyncOpenAI(
|
||||||
api_key=api_key,
|
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":
|
elif self.provider == "anthropic":
|
||||||
api_key = (config or {}).get("anthropic_api_key") or os.getenv("ANTHROPIC_API_KEY")
|
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")
|
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(
|
self.client = AsyncOpenAI(
|
||||||
api_key=api_key,
|
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:
|
else:
|
||||||
raise ValueError(f"Unsupported AI provider: {self.provider}")
|
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",
|
"type": "single",
|
||||||
"options": ["A. Option1", "B. Option2", "C. Option3", "D. Option4"],
|
"options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"],
|
||||||
"answer": "A",
|
"answer": "A",
|
||||||
"analysis": "explanation"
|
"analysis": "解析说明"
|
||||||
},
|
}},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
|
|
||||||
Document content:
|
文档内容:
|
||||||
---
|
---
|
||||||
{content}
|
{content}
|
||||||
---
|
---
|
||||||
|
|
||||||
IMPORTANT: Return ONLY the JSON array, no markdown code blocks or explanations."""
|
重要提示:
|
||||||
|
- 仔细阅读文档内容
|
||||||
|
- 识别所有看起来像试题的内容
|
||||||
|
- 如果文档中没有题目(比如只是普通文章),返回 []
|
||||||
|
- **只返回 JSON 数组**,不要包含 ```json 或其他标记"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.provider == "anthropic":
|
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
|
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
|
else: # OpenAI or Qwen
|
||||||
response = await self.client.chat.completions.create(
|
response = await self.client.chat.completions.create(
|
||||||
model=self.model,
|
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
|
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
|
# Clean result and parse JSON
|
||||||
result = result.strip()
|
result = result.strip()
|
||||||
|
|
||||||
|
# Remove markdown code blocks
|
||||||
if result.startswith("```json"):
|
if result.startswith("```json"):
|
||||||
result = result[7:]
|
result = result[7:]
|
||||||
if result.startswith("```"):
|
elif result.startswith("```"):
|
||||||
result = result[3:]
|
result = result[3:]
|
||||||
|
|
||||||
if result.endswith("```"):
|
if result.endswith("```"):
|
||||||
result = result[:-3]
|
result = result[:-3]
|
||||||
|
|
||||||
result = result.strip()
|
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:
|
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"])
|
q["content_hash"] = calculate_content_hash(q["content"])
|
||||||
|
|
||||||
return questions
|
return questions
|
||||||
|
|
||||||
except Exception as e:
|
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)}")
|
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(
|
async def grade_short_answer(
|
||||||
self,
|
self,
|
||||||
question: str,
|
question: str,
|
||||||
@@ -201,6 +660,18 @@ Return ONLY the JSON object, no markdown or explanations."""
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
result = response.content[0].text
|
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
|
else: # OpenAI or Qwen
|
||||||
response = await self.client.chat.completions.create(
|
response = await self.client.chat.completions.create(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
|
|||||||
@@ -2,26 +2,29 @@
|
|||||||
* Admin Settings Page - Enhanced with API Configuration
|
* Admin Settings Page - Enhanced with API Configuration
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { adminAPI } from '../api/client'
|
import { adminAPI } from '../api/client'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
export const AdminSettings = () => {
|
export const AdminSettings = () => {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showApiKeys, setShowApiKeys] = useState({
|
const [showApiKeys, setShowApiKeys] = useState({
|
||||||
openai: false,
|
openai: false,
|
||||||
anthropic: false,
|
anthropic: false,
|
||||||
qwen: false
|
qwen: false,
|
||||||
|
gemini: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const [config, setConfig] = useState({
|
const [config, setConfig] = useState({
|
||||||
allow_registration: true,
|
allow_registration: true,
|
||||||
max_upload_size_mb: 10,
|
max_upload_size_mb: 10,
|
||||||
max_daily_uploads: 20,
|
max_daily_uploads: 20,
|
||||||
ai_provider: 'openai',
|
ai_provider: 'gemini',
|
||||||
// OpenAI
|
// OpenAI
|
||||||
openai_api_key: '',
|
openai_api_key: '',
|
||||||
openai_base_url: 'https://api.openai.com/v1',
|
openai_base_url: 'https://api.openai.com/v1',
|
||||||
@@ -32,7 +35,11 @@ export const AdminSettings = () => {
|
|||||||
// Qwen
|
// Qwen
|
||||||
qwen_api_key: '',
|
qwen_api_key: '',
|
||||||
qwen_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -114,7 +121,15 @@ export const AdminSettings = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white shadow">
|
<div className="bg-white shadow">
|
||||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Settings className="h-8 w-8 text-primary-600" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
|
<h1 className="text-2xl font-bold text-gray-900">系统设置</h1>
|
||||||
@@ -123,6 +138,7 @@ export const AdminSettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
<div className="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||||
@@ -189,12 +205,13 @@ export const AdminSettings = () => {
|
|||||||
onChange={(e) => handleChange('ai_provider', e.target.value)}
|
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"
|
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="openai">OpenAI (GPT)</option>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
<option value="qwen">Qwen (通义千问)</option>
|
<option value="qwen">Qwen (通义千问)</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
选择后在下方配置对应的 API 密钥
|
选择后在下方配置对应的 API 密钥。Gemini 支持原生 PDF 解析
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,6 +226,13 @@ export const AdminSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
<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 className="block font-medium text-gray-900 mb-2">
|
||||||
模型
|
模型
|
||||||
</label>
|
</label>
|
||||||
<select
|
<input
|
||||||
|
type="text"
|
||||||
|
list="openai-models"
|
||||||
value={config.openai_model}
|
value={config.openai_model}
|
||||||
onChange={(e) => handleChange('openai_model', e.target.value)}
|
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">gpt-4o (最强)</option>
|
||||||
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
|
<option value="gpt-4o-mini">gpt-4o-mini (推荐)</option>
|
||||||
<option value="gpt-4-turbo">gpt-4-turbo</option>
|
<option value="gpt-4-turbo">gpt-4-turbo</option>
|
||||||
<option value="gpt-3.5-turbo">gpt-3.5-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,6 +310,13 @@ export const AdminSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
<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 className="block font-medium text-gray-900 mb-2">
|
||||||
模型
|
模型
|
||||||
</label>
|
</label>
|
||||||
<select
|
<input
|
||||||
|
type="text"
|
||||||
|
list="anthropic-models"
|
||||||
value={config.anthropic_model}
|
value={config.anthropic_model}
|
||||||
onChange={(e) => handleChange('anthropic_model', e.target.value)}
|
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-5-sonnet-20241022">claude-3-5-sonnet (最强)</option>
|
||||||
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
|
<option value="claude-3-haiku-20240307">claude-3-haiku (推荐)</option>
|
||||||
<option value="claude-3-opus-20240229">claude-3-opus</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -351,6 +393,13 @@ export const AdminSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* API Key */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-medium text-gray-900 mb-2">
|
<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 className="block font-medium text-gray-900 mb-2">
|
||||||
模型
|
模型
|
||||||
</label>
|
</label>
|
||||||
<select
|
<input
|
||||||
|
type="text"
|
||||||
|
list="qwen-models"
|
||||||
value={config.qwen_model}
|
value={config.qwen_model}
|
||||||
onChange={(e) => handleChange('qwen_model', e.target.value)}
|
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-max">qwen-max (最强)</option>
|
||||||
<option value="qwen-plus">qwen-plus (推荐)</option>
|
<option value="qwen-plus">qwen-plus (推荐)</option>
|
||||||
<option value="qwen-turbo">qwen-turbo</option>
|
<option value="qwen-turbo">qwen-turbo (快速)</option>
|
||||||
</select>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user