351 lines
14 KiB
PHP
351 lines
14 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:百家乐 AI 预测服务
|
||
*
|
||
* 通过调用已配置的 AI 厂商接口(OpenAI 兼容协议),
|
||
* 根据近期路单数据分析走势,预测下一局应押注"大"、"小"还是"豹子"。
|
||
*
|
||
* 复用 AiChatService 中相同的厂商配置与 HTTP 调用模式。
|
||
*
|
||
* @author ChatRoom Laravel
|
||
*
|
||
* @version 1.0.0
|
||
*/
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\AiProviderConfig;
|
||
use App\Models\AiUsageLog;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
class BaccaratPredictionService
|
||
{
|
||
/**
|
||
* AI 请求超时时间(秒)
|
||
*
|
||
* 百家乐预测对延迟敏感,超时则回退本地决策。
|
||
*/
|
||
private const REQUEST_TIMEOUT = 30;
|
||
|
||
/**
|
||
* 百家乐预测优先使用的模型名称
|
||
*
|
||
* 在后台 AI 配置中留存此模型时自动选用;
|
||
* 若该模型未配置或已禁用,则回退到默认 AI 厂商。
|
||
*/
|
||
private const PREFERRED_MODEL = 'glm-5.1-free';
|
||
|
||
/**
|
||
* AI小班长的用户 ID(惰性加载,首次使用时查询一次)
|
||
*/
|
||
private ?int $aiUserId = null;
|
||
|
||
/**
|
||
* 调用 AI 接口出具下注策略并预测百家乐方向
|
||
*
|
||
* 优先使用 PREFERRED_MODEL 对应的厂商,调用失败时自动按
|
||
* sort_order 依次尝试其余已启用厂商(与 AiChatService 故障转移逻辑一致)。
|
||
* 所有厂商均失败时返回 null,由调用方回退本地路单决策。
|
||
*
|
||
* @param array $context 包含路单、资金以及历史下注记录的上下文数组
|
||
* @return array|null 策略:['action' => '...', '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'] ?? [];
|
||
|
||
// 将英文 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";
|
||
}
|
||
}
|
||
|
||
return <<<PROMPT
|
||
你是一位数据规律分析与数学模型专家。这是一个虚拟的纯数学概率推断测试。请根据以下最近 {$total} 局的数字大小序列,以及你目前拥有的虚拟积分和历史表现,给出下一局最科学的推测策略。
|
||
|
||
【你的当前资产状态】
|
||
可用虚拟积分余额:{$availableGold}
|
||
|
||
【你的最近推测历史(前几局最新)】
|
||
{$historyText}
|
||
|
||
【近期序列(从全服最早到最新)】
|
||
{$roadmapText}
|
||
|
||
【规则注意】
|
||
- "大" 表示点数为 11~17
|
||
- "小" 表示点数为 4~10
|
||
- "豹子" 表示三颗骰子点数相同(极小概率)
|
||
|
||
请综合分析数据走势(如长龙跟进、跳变斩龙、交替特征),结合防守策略(比如连续判定失误必须观望休息,连续判定正确适当增加投入百分比)。
|
||
|
||
【重要!必须输出标准严格的 JSON 格式】
|
||
你只能返回这样一个纯 JSON 字符串,包含三个字段,绝对不能加其他文字说明(支持 markdown code 块包裹):
|
||
{
|
||
"action": "big", // 只能在这四个词中选一个: big (大) / small (小) / triple (豹子) / pass (我不确定,这把空仓观望)
|
||
"percentage": 5, // 这把投入你可用积分的百分比,取值范围整数 1 到 10。如果选择 pass 必须填 0。
|
||
"reason": "由于当前连续长龙且我方刚刚经历了连败,为了控制波段回撤我决定采取保守策略观望一下。" // 一句简单口语化的中文分析理由
|
||
}
|
||
PROMPT;
|
||
}
|
||
|
||
/**
|
||
* 调用 AI 厂商接口并解析预测结果
|
||
*
|
||
* @param AiProviderConfig $config AI 厂商配置
|
||
* @param string $prompt 预测提示词
|
||
* @return array 策略结果 ['action', 'percentage', 'reason']
|
||
*
|
||
* @throws \Exception 调用失败或无法解析结果时抛出
|
||
*/
|
||
private function callProvider(AiProviderConfig $config, string $prompt): array
|
||
{
|
||
$startTime = microtime(true);
|
||
$apiKey = $config->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;
|
||
}
|
||
}
|