'...', 'percentage' => 1-100, 'reason' => '...'] 或 null (不可用) */ public function predict(array $context): ?array { // 获取所有已启用厂商,PREFERRED_MODEL 对应的排在最前面 $providers = AiProviderConfig::getEnabledProviders(); if ($providers->isEmpty()) { Log::warning('百家乐 AI 预测:无任何已启用的 AI 厂商配置'); return null; } // 将首选模型提到队列最前(若存在) $preferred = $providers->firstWhere('model', self::PREFERRED_MODEL); if ($preferred) { $providers = $providers->reject(fn (AiProviderConfig $p) => $p->model === self::PREFERRED_MODEL) ->prepend($preferred) ->values(); // 重置索引,消除 IDE 类型推断警告 } $prompt = $this->buildPredictionPrompt($context); // 依次尝试每个厂商,失败则切换下一个 foreach ($providers as $provider) { try { $result = $this->callProvider($provider, $prompt); Log::info('百家乐 AI 预测成功', [ 'provider' => $provider->name, 'model' => $provider->model, 'result' => $result, ]); return $result; } catch (\Exception $e) { Log::warning('百家乐 AI 预测厂商调用失败,尝试下一个', [ 'provider' => $provider->name, 'model' => $provider->model, 'error' => $e->getMessage(), ]); continue; } } // 所有厂商均失败 Log::error('百家乐 AI 预测:所有 AI 厂商均不可用,将使用本地决策兜底'); return null; } /** * 构建发送给 AI 的预测策略提示词 * * 将路单数据转为人类可读格式,要求 AI 必须以特定 JSON 输出策略。 * * @param array $context 上下文数据 */ private function buildPredictionPrompt(array $context): string { $recentResults = $context['recent_results'] ?? []; $availableGold = $context['available_gold'] ?? 0; $history = $context['historical_bets'] ?? []; $lossCoverActive = (bool) ($context['loss_cover_active'] ?? false); // 将英文 key 转为中文展示,方便 AI 理解 $labelMap = ['big' => '大', 'small' => '小', 'triple' => '豹子']; $roadmap = array_map( fn (string $result) => $labelMap[$result] ?? $result, $recentResults ); // 路单从旧到新排列(最后一条是最近一局) $roadmapText = implode(' → ', array_reverse($roadmap)); $total = count($recentResults); $historyText = ''; if (empty($history)) { $historyText = '暂无推测历史记录。'; } else { foreach ($history as $bet) { $betAction = $labelMap[$bet['bet_type']] ?? $bet['bet_type']; $profitText = $bet['profit'] > 0 ? "赢 +{$bet['profit']}" : "负 {$bet['profit']}"; $historyText .= "- 第 {$bet['round_id']} 局:投入 {$betAction} {$bet['amount']} 积分,结果:{$profitText}\n"; } } $lossCoverText = $lossCoverActive ? '当前处于“你玩游戏我买单”活动时间窗口内。你本局必须参与下注,不能返回 pass;金额已由系统固定为单局最高限额,你只需要判断下注方向。' : '当前不在“你玩游戏我买单”活动时间窗口内。你可以按常规风控策略决定是否观望。'; return <<getDecryptedApiKey(); // 智能拼接端点 URL(与 AiChatService 保持一致) $base = rtrim($config->api_endpoint, '/'); $endpoint = str_ends_with($base, '/v1') ? $base.'/chat/completions' : $base.'/v1/chat/completions'; try { $response = Http::withToken($apiKey) ->timeout(self::REQUEST_TIMEOUT) ->post($endpoint, [ 'model' => $config->model, 'temperature' => 0.6, // 预测任务需要生成推理和灵活评估,适当提高温度 'max_tokens' => 300, // 输出 JSON 包含 reason,加大 token 限制 // 不硬性强制 response_format (经测试某些平台代理对于非原生支持 JSON Mode 的模型,配置此参数会返回空白内容) 'messages' => [ [ 'role' => 'system', 'content' => '你是百家乐资金策略专家,严格遵守只返回合格格式的 JSON 字符串。', ], [ 'role' => 'user', 'content' => $prompt, ], ], ]); $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); if (! $response->successful()) { $this->logUsage($config, $responseTimeMs, false, $response->body()); throw new \Exception("HTTP {$response->status()}: {$response->body()}"); } $data = $response->json(); $message = $data['choices'][0]['message'] ?? []; // 兼容推理模型(如 DeepSeek-R1、StepFun):content 可能为 null, // 此时从 reasoning 字段中正则匹配最后出现的预测关键词作为兜底。 $reply = trim($message['content'] ?? ''); if ($reply === '') { $reasoning = $message['reasoning'] ?? ''; if (preg_match('/[大小豹子]+(?=[^大小豹子]*$)/u', $reasoning, $m)) { $reply = $m[0]; } } $promptTokens = $data['usage']['prompt_tokens'] ?? 0; $completionTokens = $data['usage']['completion_tokens'] ?? 0; $this->logUsage($config, $responseTimeMs, true, null, $promptTokens, $completionTokens); // 将 AI 回复的中文词解析为系统内部 key return $this->parseReply($reply); } catch (\Illuminate\Http\Client\ConnectionException $e) { $responseTimeMs = (int) ((microtime(true) - $startTime) * 1000); $this->logUsage($config, $responseTimeMs, false, $e->getMessage()); throw new \Exception("连接超时: {$e->getMessage()}"); } } /** * 将 AI 回复的纯文本解析为内部的结构化策略数组 * * @param string $reply AI 返回的原始文本 (可能带 Markdown 或推理过程) * @return array ['action', 'percentage', 'reason'] * * @throws \Exception 无法识别或格式错误时抛出 */ private function parseReply(string $reply): array { // 尝试剔除 Markdown JSON 块标签 $jsonStr = preg_replace('/```json\s*(.*?)\s*```/s', '$1', $reply); $jsonStr = preg_replace('/```\s*(.*?)\s*```/s', '$1', $jsonStr); $jsonStr = trim($jsonStr); // 如果 AI 有不可预测的开头结尾文字,可以尝试用正则仅抓取首尾的大括号 if (preg_match('/\{.*\}/s', $jsonStr, $matches)) { $jsonStr = $matches[0]; } $decoded = json_decode($jsonStr, true); if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) { // 尝试通过正则表达式进行文本兜底解析(从后往前找最可能的结果) if (preg_match('/(大|小|豹子|pass|观望)[^大小豹子]*$/iu', $reply, $matches)) { $match = mb_strtolower($matches[1]); $fallbackAction = match ($match) { '大' => 'big', '小' => 'small', '豹子' => 'triple', '观望', 'pass' => 'pass', default => null, }; if ($fallbackAction) { return [ 'action' => $fallbackAction, 'percentage' => $fallbackAction === 'pass' ? 0 : 5, 'reason' => '(触发文本降级解析)'.mb_substr($reply, 0, 50), ]; } } throw new \Exception("AI 返回的 JSON 格式非法无法解析:{$reply}"); } $action = $decoded['action'] ?? null; if (! in_array($action, ['big', 'small', 'triple', 'pass'], true)) { throw new \Exception("AI 返回了不合法的 action 指令:{$action}"); } return [ 'action' => $action, 'percentage' => max(0, min(10, (int) ($decoded['percentage'] ?? 0))), 'reason' => $decoded['reason'] ?? '局势迷离,默默操作', ]; } /** * 记录 AI 调用日志到 ai_usage_logs 表 * * @param AiProviderConfig $config AI 厂商配置 * @param int $responseTimeMs 响应时间(毫秒) * @param bool $success 是否成功 * @param string|null $errorMessage 错误信息 * @param int $promptTokens 输入 token 数 * @param int $completionTokens 输出 token 数 */ private function logUsage( AiProviderConfig $config, int $responseTimeMs, bool $success, ?string $errorMessage = null, int $promptTokens = 0, int $completionTokens = 0, ): void { try { AiUsageLog::create([ 'user_id' => $this->resolveAiUserId(), 'provider' => $config->provider, 'model' => $config->model, 'action' => 'baccarat_predict', 'prompt_tokens' => $promptTokens, 'completion_tokens' => $completionTokens, 'total_tokens' => $promptTokens + $completionTokens, 'response_time_ms' => $responseTimeMs, 'success' => $success, 'error_message' => $errorMessage ? mb_substr($errorMessage, 0, 500) : null, ]); } catch (\Exception $e) { Log::error('百家乐 AI 预测日志记录失败', ['error' => $e->getMessage()]); } } /** * 查询 AI小班长的用户 ID * * 加载结果以属性形式缓存,同一个服务实例内只查一次。 * 查不到时返回 null,避免破坏外键约束。 */ private function resolveAiUserId(): ?int { if ($this->aiUserId === null) { $this->aiUserId = \App\Models\User::where('username', 'AI小班长')->value('id'); } return $this->aiUserId; } }