Files
sbnews/server/app/common/service/ai/AiService.php
T
2026-06-11 12:15:29 +08:00

616 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiAnalysis;
use app\common\model\ai\AiConfig;
use app\common\model\ai\AiLog;
use app\common\model\lottery\LotteryAiAnalysis;
use app\common\service\FileService;
use app\common\service\MTranServerService;
class AiService
{
// 按当前业务要求固定走 GPT 中转,不再依赖后台模型配置。
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
private const GPT_PROXY_MODEL = 'gpt-5.5';
/**
* 赛事预测(完整,保留兼容)
*/
public static function predictMatch(int $matchId, array $matchData, int $userId = 0): array
{
// 检查缓存
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_MATCH_PREDICT, $matchId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && self::isKbEnhancedMatchPayload($cacheData)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
}
}
$result = KbRagService::analyzeMatch($matchId, $matchData, $userId);
self::saveResult(AiAnalysis::TYPE_MATCH_PREDICT, $matchId, '', $result, $userId);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? self::buildFallbackMatch($matchData);
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
// ── 分段赛事预测 ──
protected static array $sectionPrompts = [
'prediction' => '你是专业体育赛事分析师。请基于数据预测胜负和比分。
要求输出严格JSON
{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"简述理由"},"analysis":{"home_strength":78,"away_strength":72}}',
'factors' => '你是专业体育赛事分析师。请分析影响比赛的关键因素和风险。
要求输出严格JSON
{"key_factors":["利好因素1","利好因素2","利好因素3"],"risk_factors":["风险1","风险2"]}',
'odds' => '你是专业体育赛事分析师。请分析赔率并给出投注建议。
要求输出严格JSON
{"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"赔率分析说明"}}',
'summary' => '你是专业体育赛事分析师。请给出简洁的分析总结。
要求输出严格JSON
{"summary":"完整的分析总结,200字左右"}',
];
public static function predictMatchSection(int $matchId, string $section, array $matchData, int $userId = 0): array
{
$validSections = ['prediction', 'factors', 'odds', 'summary'];
if (!in_array($section, $validSections)) {
return ['success' => false, 'error' => '无效的分析模块'];
}
$cacheKey = AiAnalysis::TYPE_MATCH_PREDICT * 100 + array_search($section, $validSections);
$cache = AiAnalysis::getCache($cacheKey, $matchId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && self::isKbEnhancedMatchPayload($cacheData)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true, 'section' => $section];
}
}
$result = KbRagService::analyzeMatchSection($matchId, $section, $matchData, $userId);
self::saveResult($cacheKey, $matchId, '', $result, $userId, 6);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error'], 'section' => $section];
}
$data = $result['parsed'] ?? [];
return ['success' => true, 'data' => $data, 'from_cache' => false, 'section' => $section];
}
/**
* 资讯AI分析
*/
public static function analyzeArticle(int $articleId, array $articleData, int $userId = 0): array
{
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_ARTICLE_ANALYSIS, $articleId);
if ($cache) {
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
}
$result = KbRagService::analyzeArticle($articleId, $articleData, $userId);
$cacheHours = (int) AiConfig::getVal('article_analysis_cache_hours', '24');
self::saveResult(AiAnalysis::TYPE_ARTICLE_ANALYSIS, $articleId, '', $result, $userId, $cacheHours);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? self::buildFallbackArticle($articleData);
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
/**
* 帖子AI分析
*/
public static function analyzePost(int $postId, array $postData, int $userId = 0, bool $isLotteryPost = false, bool $force = false): array
{
$cache = $force ? null : AiAnalysis::getCache(AiAnalysis::TYPE_POST_ANALYSIS, $postId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && self::isValidPostAnalysisCache($cacheData, $isLotteryPost)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
}
}
$result = KbRagService::analyzePost($postId, $postData, $userId, $isLotteryPost);
self::saveResult(AiAnalysis::TYPE_POST_ANALYSIS, $postId, '', $result, $userId, 12);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error'] ?? 'AI分析失败'];
}
return [
'success' => true,
'data' => $result['parsed'] ?? self::buildFallbackPost($postData),
'from_cache' => false,
];
}
/**
* 用户可信度分析
*/
public static function analyzeCredibility(int $targetUserId, array $userData, int $requestUserId = 0): array
{
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_USER_CREDIBILITY, $targetUserId);
if ($cache) {
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
}
$defaultPrompt = '你是体育社区内容审核AI。请根据用户的历史数据评估其可信度。
要求输出严格JSON格式:
{"credibility_score":75,"risk_level":"low/medium/high","is_suspicious":false,"analysis":"分析说明,100字左右","dimension_scores":{"hit_rate":80,"stability":85,"rationality":65,"activity":70},"suggestions":["建议1","建议2"]}';
$systemPrompt = AiConfig::getVal('credibility_analysis_prompt', $defaultPrompt);
$userMsg = "请分析以下用户数据并评估可信度:\n" . json_encode($userData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$client = new DeepSeekClient();
$result = $client->chatJson($systemPrompt, $userMsg);
self::saveResult(AiAnalysis::TYPE_USER_CREDIBILITY, $targetUserId, '', $result, $requestUserId, 24);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? self::buildFallbackCredibility();
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
/**
* 彩票概率分析
*/
public static function analyzeLottery(array $lotteryData, int $userId = 0): array
{
$refId = crc32(json_encode($lotteryData));
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId);
if ($cache) {
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
}
$result = KbRagService::analyzeLotteryOverview($lotteryData, $userId);
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? ['disclaimer' => '分析暂时不可用,请稍后重试'];
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
public static function translateCommunityPost(string $content): array
{
if ($content === '') {
return ['success' => false, 'error' => '帖子内容为空'];
}
return MTranServerService::translate($content, 'zh-Hans', 'auto');
}
/**
* 资讯翻译
*/
public static function translateArticle(string $title, string $abstract, string $content): array
{
$title = trim($title);
$abstract = trim($abstract);
$content = trim($content);
if ($title === '' && $abstract === '' && $content === '') {
return ['success' => false, 'error' => '文章内容为空'];
}
if (mb_strlen($content) > 5000) {
$content = mb_substr($content, 0, 5000) . '...';
}
$parts = [];
if ($title !== '') {
$translatedTitle = MTranServerService::translate($title, 'zh-Hans', 'auto');
if (!$translatedTitle['success']) {
return $translatedTitle;
}
$parts[] = '标题:' . trim((string) $translatedTitle['content']);
}
if ($abstract !== '') {
$translatedAbstract = MTranServerService::translate($abstract, 'zh-Hans', 'auto');
if (!$translatedAbstract['success']) {
return $translatedAbstract;
}
$parts[] = '摘要:' . trim((string) $translatedAbstract['content']);
}
if ($content !== '') {
$translatedContentLines = [];
foreach (self::splitTextForTranslation($content) as $contentLine) {
$translatedContent = MTranServerService::translate($contentLine, 'zh-Hans', 'auto');
if (!$translatedContent['success']) {
return $translatedContent;
}
$translatedContentLines[] = trim((string) $translatedContent['content']);
}
$translatedContentLines = array_values(array_filter($translatedContentLines, static fn($line) => $line !== ''));
if (!empty($translatedContentLines)) {
$firstLine = array_shift($translatedContentLines);
$parts[] = '正文:' . $firstLine;
foreach ($translatedContentLines as $line) {
$parts[] = $line;
}
}
}
$finalText = trim(implode("\n", $parts));
if ($finalText === '') {
return ['success' => false, 'error' => '翻译结果为空'];
}
return [
'success' => true,
'content' => $finalText,
'tokens' => 0,
'cost_ms' => 0,
];
}
protected static function splitTextForTranslation(string $text): array
{
$normalized = trim(preg_replace('/\r\n|\r/u', "\n", $text));
if ($normalized === '') {
return [];
}
$manualLines = array_values(array_filter(array_map(static function ($line) {
$line = preg_replace('/\s+/u', ' ', trim($line));
return $line === '' ? '' : $line;
}, explode("\n", $normalized))));
if (count($manualLines) > 1) {
$segments = [];
foreach ($manualLines as $line) {
$segments = array_merge($segments, self::splitTextForTranslation($line));
}
return $segments;
}
$flatText = preg_replace('/\s+/u', ' ', $normalized);
preg_match_all('/[^。!?.!?]+[。!?.!?]?/u', $flatText, $matches);
$segments = array_values(array_filter(array_map('trim', $matches[0] ?? [])));
return !empty($segments) ? $segments : [$flatText];
}
/**
* 资讯评论生成
*/
public static function generateArticleComment(string $title, string $abstract, string $content, string $persona = ''): array
{
$title = trim($title);
$abstract = trim($abstract);
$content = trim($content);
if ($title === '' && $abstract === '' && $content === '') {
return ['success' => false, 'error' => '文章内容为空'];
}
if (mb_strlen($content) > 3500) {
$content = mb_substr($content, 0, 3500) . '...';
}
$systemPrompt = AiConfig::getVal(
'article_comment_prompt',
'你是一名专业、克制、真实的体育资讯评论员。请根据给定资讯内容生成1条中文评论,要求自然、具体、像真实用户发言,评论长度控制在20到60字之间。不要输出标题、前缀、解释、表情、标签、项目符号,不要提及自己是AI,不要复述全文。'
);
$personaText = $persona !== '' ? "评论视角:{$persona}\n" : '';
$userMsg = $personaText . "标题:{$title}\n摘要:{$abstract}\n正文:{$content}\n\n请输出1条适合发布在评论区的中文评论。";
$client = new DeepSeekClient();
return $client->chat($systemPrompt, $userMsg, ['max_tokens' => 200, 'temperature' => 0.75]);
}
public static function analyzeLotteryPostImages(string $content, array $images): array
{
if (empty($images)) {
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
}
$imageUrls = array_values(array_filter(array_map(function ($image) {
if (!$image) {
return '';
}
return self::prepareGptImageUrl(FileService::getFileUrl($image));
}, $images)));
if (empty($imageUrls)) {
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
}
$systemPrompt = AiConfig::getVal(
'community_lottery_image_analysis_prompt',
'你是彩票图片内容分析助手。只允许依据当前帖子图片和帖子文案中直接给出的当期信息进行分析。'
. '不要引用往期内容、历史期数、知识库信息或你自己的记忆。'
. '如果图片里没有明确展示某项信息,就直接说明无法判断。'
. '请先对图片内容做去噪过滤,只保留与当期分析直接相关的信息。'
. '有效信息包括:期号、号码、波色、单双、大小、生肖、五行、推荐组合、胆码拖码、走势箭头、统计表、图表结论、明确的标题结论。'
. '无关噪音包括:装饰背景、边框花纹、吉祥话、宣传口号、重复标题、水印、无关广告、联系方式、模板化祝福语、与分析无关的排版符号。'
. '对于重复出现的同一信息,只保留一次并合并描述;对于模糊、遮挡、低置信度识别内容,不要强行分析,可直接标记为“无法可靠识别”。'
. '请先完整识别图片中的号码、期号、版面文字、波色/单双/大小/生肖/五行、图表结论、胆码拖码、推荐组合或其他可见资料。'
. '识别出来的每一项内容,都要单独给出对应分析描述,不能只做整体概括。'
. '如果有多张图片,按图片1、图片2逐张拆开写;每张图片内再按“识别内容1/分析1、识别内容2/分析2”的方式一一对应展开。'
. '输出格式尽量清晰,推荐使用:图片X、过滤后识别内容、逐项分析、综合结论、风险提示。'
. '不要承诺中奖,不要编造图片中不存在的数据,最后给出理性购彩风险提示。'
);
$options = [
'max_tokens' => 2000,
'temperature' => 0.3,
'model' => self::GPT_PROXY_MODEL,
'base_url' => self::GPT_PROXY_BASE_URL,
'api_key' => self::GPT_PROXY_API_KEY,
'wire_api' => 'responses',
'provider_label' => 'OpenAI(GPT中转)',
];
$client = new DeepSeekClient();
$userMessage = "帖子内容:\n" . ($content ?: '无文字内容') . "\n\n请只依据当前帖子图片和上面的帖子文案输出分析。先过滤掉杂乱和无关内容,再提取图片里的有效信息,最后对提取出的每一项内容一一分析描述,不要补充任何往期推断。";
$result = $client->chatWithImages(
$systemPrompt,
$userMessage,
$imageUrls,
$options
);
return $result;
}
protected static function prepareGptImageUrl(string $imageUrl): string
{
if (!preg_match('/^https?:\/\//i', $imageUrl)) {
return $imageUrl;
}
$dataUrl = self::downloadImageAsDataUrl($imageUrl);
return $dataUrl !== '' ? $dataUrl : $imageUrl;
}
protected static function downloadImageAsDataUrl(string $imageUrl): string
{
$ch = curl_init($imageUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 25,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_USERAGENT => 'Mozilla/5.0 SportEraBot/1.0',
]);
$body = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if ($httpCode !== 200 || !is_string($body) || $body === '' || strlen($body) > 8 * 1024 * 1024) {
return '';
}
$mime = strtolower(trim(explode(';', $contentType)[0] ?? ''));
if ($mime === '' || !str_starts_with($mime, 'image/')) {
$path = parse_url($imageUrl, PHP_URL_PATH) ?: '';
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mime = match ($ext) {
'png' => 'image/png',
'webp' => 'image/webp',
'gif' => 'image/gif',
default => 'image/jpeg',
};
}
return 'data:' . $mime . ';base64,' . base64_encode($body);
}
/**
* 保存分析结果
*/
protected static function saveResult(int $type, int $refId, string $prompt, array $result, int $userId, int $cacheHours = 6): void
{
$status = $result['success'] ? AiAnalysis::STATUS_SUCCESS : AiAnalysis::STATUS_FAILED;
$resultJson = $result['success'] ? json_encode($result['parsed'] ?? $result['content'], JSON_UNESCAPED_UNICODE) : '';
AiAnalysis::create([
'type' => $type,
'ref_id' => $refId,
'prompt' => mb_substr($prompt, 0, 5000),
'result' => $resultJson,
'model' => (string) ($result['model'] ?? self::GPT_PROXY_MODEL),
'tokens_used' => $result['tokens'] ?? 0,
'status' => $status,
'error_msg' => $result['error'] ?? '',
'expire_time' => time() + $cacheHours * 3600,
]);
AiLog::create([
'user_id' => $userId,
'type' => $type,
'ref_id' => $refId,
'tokens_used' => $result['tokens'] ?? 0,
'cost_ms' => $result['cost_ms'] ?? 0,
'status' => $status,
]);
}
/**
* 彩票分模块分析(热冷号/趋势/统计)
*/
public static function analyzeLotteryModule(array $lotteryData, string $module, int $userId = 0): array
{
$latestIssue = $lotteryData['recent_draws'][0]['issue'] ?? '';
$cacheKey = $lotteryData['lottery_type'] . ':' . $module . ':' . $latestIssue;
$refId = crc32($cacheKey);
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId);
if ($cache) {
$cacheData = json_decode($cache['result'], true);
if (is_array($cacheData) && !empty($cacheData)) {
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
}
}
$result = KbRagService::analyzeLotteryModule($lotteryData, $module, $userId);
if (!$result['success']) {
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
return ['success' => false, 'error' => $result['error']];
}
$data = $result['parsed'] ?? null;
if (!is_array($data) || empty($data)) {
$result['success'] = false;
$result['error'] = 'AI返回格式异常';
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
return ['success' => false, 'error' => $result['error']];
}
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
// 持久化到彩票AI分析历史表
if ($latestIssue) {
$history = LotteryAiAnalysis::create([
'code' => $lotteryData['lottery_type'],
'issue' => $latestIssue,
'module' => $module,
'result' => json_encode($data, JSON_UNESCAPED_UNICODE),
]);
KbSyncService::enqueue('lottery', 'ai_history', (int) $history->id, 'upsert', 90);
}
return ['success' => true, 'data' => $data, 'from_cache' => false];
}
/**
* 赛事预测降级数据
*/
protected static function buildFallbackMatch(array $matchData): array
{
return [
'prediction' => [
'result' => '待分析',
'confidence' => 50,
'score_predict' => '-',
'reasoning' => 'AI分析暂时不可用,请稍后重试',
],
'analysis' => [
'home_strength' => 50,
'away_strength' => 50,
'key_factors' => ['数据加载中'],
'risk_factors' => ['AI服务响应异常'],
],
'odds_opinion' => ['value_bet' => '无', 'explanation' => '暂无数据'],
'summary' => '当前AI分析服务暂时不可用,请稍后重试。',
];
}
/**
* 资讯分析降级数据
*/
protected static function buildFallbackArticle(array $articleData): array
{
return [
'overall_score' => 60,
'summary' => $articleData['desc'] ?? '暂无摘要',
'leagues_and_teams' => ['leagues' => [], 'teams' => [], 'match_context' => ''],
'upcoming_matches' => [],
'head_to_head' => ['available' => false, 'records' => [], 'summary' => ''],
'betting_analysis' => [
'prediction' => '谨慎观望',
'confidence' => 50,
'reasoning' => 'AI分析暂时不可用,请稍后重试。',
'risk_level' => 'medium',
'key_factors' => [],
'value_bet' => '无明显价值',
],
'key_points' => ['详细分析正在生成中'],
'risk_warnings' => [],
'opposite_views' => [],
'sentiment' => ['label' => 'neutral', 'confidence' => 50],
'tags' => [],
'evidence_list' => [],
'similar_history' => [],
'from_kb' => false,
'retrieval_meta' => ['hit_count' => 0, 'domains' => [], 'top_scores' => []],
];
}
protected static function buildFallbackPost(array $postData): array
{
return [
'overall_score' => 60,
'summary' => self::truncatePlainText((string) ($postData['content'] ?? '当前帖子信息不足,暂时无法给出稳定结论。'), 120),
'credibility_signals' => [],
'risk_warning' => ['AI分析暂时不可用,请稍后重试。'],
'evidence_list' => [],
'similar_history' => [],
'from_kb' => false,
'retrieval_meta' => ['hit_count' => 0, 'domains' => [], 'top_scores' => []],
];
}
/**
* 可信度分析降级数据
*/
protected static function buildFallbackCredibility(): array
{
return [
'credibility_score' => 50,
'risk_level' => 'medium',
'is_suspicious' => false,
'analysis' => '可信度分析服务暂时不可用',
'dimension_scores' => ['hit_rate' => 50, 'stability' => 50, 'rationality' => 50, 'activity' => 50],
'suggestions' => [],
];
}
protected static function truncatePlainText(string $text, int $length): string
{
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
}
protected static function isKbEnhancedMatchPayload(array $payload): bool
{
return isset($payload['retrieval_meta']) || isset($payload['evidence_list']) || isset($payload['from_kb']);
}
protected static function isValidPostAnalysisCache(array $payload, bool $isLotteryPost): bool
{
if (empty($payload)) {
return false;
}
if (!$isLotteryPost) {
return true;
}
return self::isKbEnhancedMatchPayload($payload)
&& isset($payload['next_prediction'])
&& is_array($payload['next_prediction'])
&& ($payload['analysis_source'] ?? '') === 'image_free_v1';
}
}