Files
sbnews/server/app/common/service/ai/AiService.php
T

568 lines
22 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
{
private const LOTTERY_POST_IMAGE_TIMEOUT = 25;
// 生成类 AI 统一走 DeepSeekClient 的 OpenAI Responses 配置。
private static function gptModelOptions(array $overrides = []): array
{
return DeepSeekClient::defaultModelOptions($overrides);
}
private static function defaultModelName(): string
{
return (string) self::gptModelOptions()['model'];
}
/**
* 赛事预测(完整,保留兼容)
*/
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 FileService::getFileUrl($image);
}, $images)));
if (empty($imageUrls)) {
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
}
$systemPrompt = AiConfig::getVal(
'community_lottery_image_analysis_prompt',
'你是彩票图片内容快速抽取助手。只提取当前图片中清晰可见的文字、期号、号码、生肖、波色、单双、大小、五行、推荐组合、标题结论等有效信息。'
. '不要做长篇分析,不要引用历史或记忆,不要承诺中奖。'
. '看不清或图片里没有的信息直接写“未识别”。'
. '请按“图片X:有效信息:...;不确定:...”的格式输出,总字数控制在300字以内。'
);
$options = self::gptModelOptions([
'max_tokens' => 600,
'temperature' => 0.3,
'timeout' => self::LOTTERY_POST_IMAGE_TIMEOUT,
'reasoning_effort' => 'low',
'retry_times' => 0,
'retry_sleep_ms' => 0,
]);
$client = new DeepSeekClient();
$userMessage = "帖子内容:\n" . ($content ?: '无文字内容') . "\n\n请快速抽取图片里的有效信息,只输出可见事实和不确定项,不要展开分析。";
$result = $client->chatWithImages(
$systemPrompt,
$userMessage,
$imageUrls,
$options
);
return $result;
}
/**
* 保存分析结果
*/
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::defaultModelName()),
'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';
}
}