|
|
@@ -15,6 +15,9 @@ class MarkdownQuestionParser
|
|
|
private string $openAiBaseUrl;
|
|
|
private string $openAiModel;
|
|
|
private int $openAiTimeout;
|
|
|
+ private array $deepseekApiKeys;
|
|
|
+ private array $openAiApiKeys;
|
|
|
+ private bool $rateLimited = false;
|
|
|
|
|
|
public function __construct()
|
|
|
{
|
|
|
@@ -23,10 +26,16 @@ class MarkdownQuestionParser
|
|
|
$this->deepseekBaseUrl = rtrim((string) config('ai.deepseek.base_url', 'https://api.deepseek.com/v1'), '/');
|
|
|
$this->deepseekModel = (string) config('ai.deepseek.model', 'deepseek-chat');
|
|
|
$this->deepseekTimeout = (int) config('ai.deepseek.timeout', 30);
|
|
|
+ $this->deepseekApiKeys = $this->splitApiKeys(
|
|
|
+ (string) config('ai.deepseek.api_keys', env('DEEPSEEK_API_KEYS', ''))
|
|
|
+ );
|
|
|
|
|
|
$this->openAiBaseUrl = rtrim((string) config('ai.openai.base_url', 'https://api.openai.com/v1'), '/');
|
|
|
$this->openAiModel = (string) config('ai.openai.model', 'gpt-3.5-turbo');
|
|
|
$this->openAiTimeout = (int) config('ai.openai.timeout', 30);
|
|
|
+ $this->openAiApiKeys = $this->splitApiKeys(
|
|
|
+ (string) config('ai.openai.api_keys', env('OPENAI_API_KEYS', ''))
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -83,7 +92,9 @@ class MarkdownQuestionParser
|
|
|
return array_merge($candidate, $aiStructured);
|
|
|
}
|
|
|
|
|
|
- $this->enhanceWithAi($candidate);
|
|
|
+ if (!$this->rateLimited) {
|
|
|
+ $this->enhanceWithAi($candidate);
|
|
|
+ }
|
|
|
|
|
|
Log::debug('Parse raw_markdown done (heuristic+detect)', [
|
|
|
'index' => $index,
|
|
|
@@ -195,6 +206,9 @@ class MarkdownQuestionParser
|
|
|
|
|
|
return $normalized;
|
|
|
} catch (\Throwable $e) {
|
|
|
+ if ($this->isRateLimited($e)) {
|
|
|
+ $this->rateLimited = true;
|
|
|
+ }
|
|
|
Log::warning('AI structured parse failed, fallback to heuristic', [
|
|
|
'index' => $index,
|
|
|
'error' => $e->getMessage(),
|
|
|
@@ -297,6 +311,9 @@ class MarkdownQuestionParser
|
|
|
$candidate['ai_confidence'] = $result['confidence'] ?? null;
|
|
|
}
|
|
|
} catch (\Exception $e) {
|
|
|
+ if ($this->isRateLimited($e)) {
|
|
|
+ $this->rateLimited = true;
|
|
|
+ }
|
|
|
Log::error('AI question detection failed', [
|
|
|
'error' => $e->getMessage(),
|
|
|
'block' => substr($candidate['raw_markdown'], 0, 200),
|
|
|
@@ -340,7 +357,10 @@ class MarkdownQuestionParser
|
|
|
*/
|
|
|
private function callDeepSeek(string $prompt): array
|
|
|
{
|
|
|
- $apiKey = config('ai.deepseek.api_key', env('DEEPSEEK_API_KEY'));
|
|
|
+ $apiKey = $this->resolveApiKey(
|
|
|
+ $this->deepseekApiKeys,
|
|
|
+ (string) config('ai.deepseek.api_key', env('DEEPSEEK_API_KEY'))
|
|
|
+ );
|
|
|
|
|
|
$response = Http::withHeaders([
|
|
|
'Authorization' => "Bearer {$apiKey}",
|
|
|
@@ -354,7 +374,7 @@ class MarkdownQuestionParser
|
|
|
]);
|
|
|
|
|
|
if (!$response->successful()) {
|
|
|
- throw new \Exception('DeepSeek API error: ' . $response->body());
|
|
|
+ throw new \Exception('DeepSeek API error: HTTP ' . $response->status() . ' ' . $response->body());
|
|
|
}
|
|
|
|
|
|
$content = $response->json('choices.0.message.content');
|
|
|
@@ -367,7 +387,10 @@ class MarkdownQuestionParser
|
|
|
*/
|
|
|
private function callOpenAI(string $prompt): array
|
|
|
{
|
|
|
- $apiKey = config('ai.openai.api_key', env('OPENAI_API_KEY'));
|
|
|
+ $apiKey = $this->resolveApiKey(
|
|
|
+ $this->openAiApiKeys,
|
|
|
+ (string) config('ai.openai.api_key', env('OPENAI_API_KEY'))
|
|
|
+ );
|
|
|
|
|
|
$response = Http::withHeaders([
|
|
|
'Authorization' => "Bearer {$apiKey}",
|
|
|
@@ -381,7 +404,7 @@ class MarkdownQuestionParser
|
|
|
]);
|
|
|
|
|
|
if (!$response->successful()) {
|
|
|
- throw new \Exception('OpenAI API error: ' . $response->body());
|
|
|
+ throw new \Exception('OpenAI API error: HTTP ' . $response->status() . ' ' . $response->body());
|
|
|
}
|
|
|
|
|
|
$content = $response->json('choices.0.message.content');
|
|
|
@@ -389,6 +412,23 @@ class MarkdownQuestionParser
|
|
|
return $this->parseJsonResponse($content);
|
|
|
}
|
|
|
|
|
|
+ private function resolveApiKey(array $keys, string $fallback): string
|
|
|
+ {
|
|
|
+ if (empty($keys)) {
|
|
|
+ return $fallback;
|
|
|
+ }
|
|
|
+
|
|
|
+ $index = (int) (crc32((string) getmypid()) % count($keys));
|
|
|
+ return $keys[$index];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function splitApiKeys(string $raw): array
|
|
|
+ {
|
|
|
+ $items = array_map('trim', explode(',', $raw));
|
|
|
+ $items = array_filter($items, static fn (string $key) => $key !== '');
|
|
|
+ return array_values(array_unique($items));
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 精修简答题的解题步骤
|
|
|
*/
|
|
|
@@ -426,4 +466,19 @@ class MarkdownQuestionParser
|
|
|
|
|
|
return $json;
|
|
|
}
|
|
|
+
|
|
|
+ private function isRateLimited(\Throwable $e): bool
|
|
|
+ {
|
|
|
+ $message = $e->getMessage();
|
|
|
+ if (stripos($message, 'HTTP 429') !== false) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (stripos($message, 'rate limit') !== false) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (stripos($message, 'too many requests') !== false) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
}
|