740 lines
37 KiB
PHP
740 lines
37 KiB
PHP
<?php
|
||
|
||
namespace app\common\service\ai;
|
||
|
||
use app\common\model\ai\AiConfig;
|
||
|
||
class KbRagService
|
||
{
|
||
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 analyzeMatch(int $matchId, array $matchData, int $userId = 0): array
|
||
{
|
||
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_analysis');
|
||
$systemPrompt = AiConfig::getVal('match_predict_prompt', self::defaultMatchPrompt());
|
||
$userMessage = self::buildMatchUserMessage($matchData, $retrieval, true);
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
|
||
return self::normalizeMatchResult($parsed, $retrieval);
|
||
}, ['max_tokens' => 1400]);
|
||
}
|
||
|
||
public static function analyzeMatchSection(int $matchId, string $section, array $matchData, int $userId = 0): array
|
||
{
|
||
$validSections = ['prediction', 'factors', 'odds', 'summary'];
|
||
if (!in_array($section, $validSections, true)) {
|
||
return ['success' => false, 'error' => '无效的分析模块'];
|
||
}
|
||
|
||
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_' . $section);
|
||
$systemPrompt = AiConfig::getVal('match_predict_' . $section . '_prompt', self::matchSectionPrompt($section));
|
||
$userMessage = self::buildMatchUserMessage($matchData, $retrieval, false, $section);
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($section, $retrieval) {
|
||
return self::normalizeMatchSectionResult($section, $parsed, $retrieval);
|
||
}, ['max_tokens' => 1000]);
|
||
}
|
||
|
||
public static function analyzeArticle(int $articleId, array $articleData, int $userId = 0): array
|
||
{
|
||
$retrieval = KbService::retrieve(
|
||
'article_analysis',
|
||
self::buildArticleQuery($articleData),
|
||
[
|
||
'user_id' => $userId,
|
||
'ref_id' => $articleId,
|
||
'domains' => ['article', 'post'],
|
||
'preferred_domain' => 'article',
|
||
'cid' => (int) ($articleData['cid'] ?? 0),
|
||
'exclude_source' => ['domain' => 'article', 'source_id' => $articleId],
|
||
],
|
||
[
|
||
'title' => (string) ($articleData['title'] ?? ''),
|
||
'summary' => (string) ($articleData['abstract'] ?? $articleData['desc'] ?? ''),
|
||
],
|
||
self::intConfig('kb_final_topk', 8)
|
||
);
|
||
|
||
$systemPrompt = AiConfig::getVal('article_analysis_prompt', self::defaultArticlePrompt());
|
||
$userMessage = "当前文章:\n" . json_encode([
|
||
'id' => $articleId,
|
||
'title' => $articleData['title'] ?? '',
|
||
'abstract' => $articleData['abstract'] ?? '',
|
||
'author' => $articleData['author'] ?? '',
|
||
'category' => $articleData['category'] ?? '',
|
||
'content' => self::clipText(strip_tags((string) ($articleData['content'] ?? '')), 2500),
|
||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n知识库证据:\n"
|
||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n请严格依据当前文章和知识库证据输出JSON。";
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
|
||
return self::normalizeArticleResult($parsed, $retrieval);
|
||
});
|
||
}
|
||
|
||
public static function analyzePost(int $postId, array $postData, int $userId = 0, bool $isLotteryPost = false): array
|
||
{
|
||
$tags = $postData['tags'] ?? [];
|
||
if ($isLotteryPost) {
|
||
$retrieval = self::emptyRetrieval();
|
||
$systemPrompt = self::lotteryPostImagePrompt();
|
||
$userMessage = "当前六合彩帖子与图片识别内容:\n" . json_encode([
|
||
'id' => $postId,
|
||
'content' => self::clipText((string) ($postData['content'] ?? ''), 1800),
|
||
'tags' => $tags,
|
||
'lottery_key' => (string) (($postData['ext']['lottery_key'] ?? '') ?: ''),
|
||
'lottery_analysis_content' => self::clipText((string) ($postData['lottery_analysis_content'] ?? ''), 2200),
|
||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n请只依据当前帖子图片识别内容和帖子文案自由解读,不需要知识库证据,也不要输出“知识库证据不足”。";
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval, $postData) {
|
||
return self::normalizePostResult($parsed, $retrieval, $postData);
|
||
}, self::gptPostOptions());
|
||
}
|
||
|
||
$domains = ['post', 'article'];
|
||
$retrieval = KbService::retrieve(
|
||
'post_analysis',
|
||
self::buildPostQuery($postData),
|
||
[
|
||
'user_id' => $userId,
|
||
'ref_id' => $postId,
|
||
'domains' => $domains,
|
||
'preferred_domain' => 'post',
|
||
'tags' => is_array($tags) ? $tags : [],
|
||
'exclude_source' => ['domain' => 'post', 'source_id' => $postId],
|
||
],
|
||
[
|
||
'title' => (string) ($postData['content'] ?? ''),
|
||
],
|
||
self::intConfig('kb_final_topk', 8)
|
||
);
|
||
|
||
$systemPrompt = AiConfig::getVal('post_analysis_prompt', self::defaultPostPrompt());
|
||
$userMessage = "当前帖子:\n" . json_encode([
|
||
'id' => $postId,
|
||
'content' => self::clipText((string) ($postData['content'] ?? ''), 1800),
|
||
'tags' => $tags,
|
||
'post_type' => $postData['post_type'] ?? 0,
|
||
'is_paid' => $postData['is_paid'] ?? 0,
|
||
'lottery_key' => (string) (($postData['ext']['lottery_key'] ?? '') ?: ''),
|
||
'lottery_analysis_content' => self::clipText((string) ($postData['lottery_analysis_content'] ?? ''), 1000),
|
||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n知识库证据:\n"
|
||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n请输出JSON,不允许编造知识库里不存在的历史事实。";
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval, $postData) {
|
||
return self::normalizePostResult($parsed, $retrieval, $postData);
|
||
}, self::gptPostOptions());
|
||
}
|
||
|
||
public static function analyzeLotteryOverview(array $lotteryData, int $userId = 0): array
|
||
{
|
||
$code = (string) ($lotteryData['lottery_type'] ?? '');
|
||
$latestIssue = (string) ($lotteryData['recent_draws'][0]['issue'] ?? '');
|
||
$retrieval = KbService::retrieve(
|
||
'lottery_analysis',
|
||
self::buildLotteryQuery($lotteryData),
|
||
[
|
||
'user_id' => $userId,
|
||
'ref_id' => 0,
|
||
'domains' => ['lottery', 'post'],
|
||
'preferred_domain' => 'lottery',
|
||
'code' => $code,
|
||
'issue' => $latestIssue,
|
||
],
|
||
[
|
||
'title' => (string) ($lotteryData['lottery_name'] ?? $code),
|
||
],
|
||
self::intConfig('kb_final_topk', 8)
|
||
);
|
||
|
||
$systemPrompt = AiConfig::getVal('lottery_analysis_prompt', self::defaultLotteryOverviewPrompt());
|
||
$userMessage = "当前彩种结构化数据:\n" . json_encode($lotteryData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n知识库证据:\n"
|
||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n请结合历史知识库证据和当前结构化数据输出JSON。";
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
|
||
return self::normalizeLotteryOverview($parsed, $retrieval);
|
||
}, ['max_tokens' => 1800]);
|
||
}
|
||
|
||
public static function analyzeLotteryModule(array $lotteryData, string $module, int $userId = 0): array
|
||
{
|
||
$code = (string) ($lotteryData['lottery_type'] ?? '');
|
||
$latestIssue = (string) ($lotteryData['recent_draws'][0]['issue'] ?? '');
|
||
$retrieval = KbService::retrieve(
|
||
'lottery_' . $module,
|
||
self::buildLotteryQuery($lotteryData),
|
||
[
|
||
'user_id' => $userId,
|
||
'ref_id' => 0,
|
||
'domains' => ['lottery', 'post'],
|
||
'preferred_domain' => 'lottery',
|
||
'code' => $code,
|
||
'issue' => $latestIssue,
|
||
],
|
||
[
|
||
'title' => (string) ($lotteryData['lottery_name'] ?? $code),
|
||
],
|
||
self::intConfig('kb_final_topk', 8)
|
||
);
|
||
|
||
$systemPrompt = self::modulePrompt($module);
|
||
$userMessage = "当前彩种结构化数据:\n" . json_encode($lotteryData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n知识库证据:\n"
|
||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n请结合结构化数据和知识库证据输出JSON。";
|
||
|
||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($module, $retrieval) {
|
||
return self::normalizeLotteryModule($module, $parsed, $retrieval);
|
||
}, ['max_tokens' => 1200]);
|
||
}
|
||
|
||
private static function runJsonPrompt(string $systemPrompt, string $userMessage, callable $normalizer, array $options = []): array
|
||
{
|
||
$client = new DeepSeekClient();
|
||
$result = $client->chatJson($systemPrompt, $userMessage, $options);
|
||
if (!$result['success']) {
|
||
return $result;
|
||
}
|
||
|
||
$parsed = is_array($result['parsed'] ?? null) ? $result['parsed'] : [];
|
||
$normalized = $normalizer($parsed);
|
||
$result['parsed'] = $normalized;
|
||
$result['content'] = json_encode($normalized, JSON_UNESCAPED_UNICODE);
|
||
return $result;
|
||
}
|
||
|
||
private static function gptPostOptions(): array
|
||
{
|
||
return [
|
||
'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中转)',
|
||
'max_tokens' => 1200,
|
||
'temperature' => 0.35,
|
||
];
|
||
}
|
||
|
||
private static function compactHits(array $hits): array
|
||
{
|
||
return array_map(static function (array $hit) {
|
||
return [
|
||
'domain' => $hit['domain'] ?? '',
|
||
'subtype' => $hit['subtype'] ?? '',
|
||
'source_id' => $hit['source_id'] ?? 0,
|
||
'title' => $hit['title'] ?? '',
|
||
'summary' => $hit['summary'] ?? '',
|
||
'content' => self::clipText((string) ($hit['chunk_text'] ?? ''), 260),
|
||
'score' => $hit['score'] ?? 0,
|
||
'metadata' => $hit['metadata'] ?? [],
|
||
];
|
||
}, $hits);
|
||
}
|
||
|
||
private static function normalizeArticleResult(array $parsed, array $retrieval): array
|
||
{
|
||
$bettingAnalysis = $parsed['betting_analysis'] ?? [
|
||
'prediction' => '谨慎观望',
|
||
'confidence' => 55,
|
||
'reasoning' => '当前证据不足,建议谨慎观望。',
|
||
'risk_level' => 'medium',
|
||
'key_factors' => [],
|
||
'value_bet' => '无明显价值',
|
||
];
|
||
$bettingAnalysis['risk_level'] = self::normalizeRiskLevel((string) ($bettingAnalysis['risk_level'] ?? 'medium'));
|
||
|
||
return [
|
||
'overall_score' => (int) ($parsed['overall_score'] ?? 68),
|
||
'summary' => (string) ($parsed['summary'] ?? '暂无明确结论,请结合原文谨慎判断。'),
|
||
'leagues_and_teams' => $parsed['leagues_and_teams'] ?? ['leagues' => [], 'teams' => [], 'match_context' => ''],
|
||
'upcoming_matches' => $parsed['upcoming_matches'] ?? [],
|
||
'head_to_head' => $parsed['head_to_head'] ?? ['available' => false, 'records' => [], 'summary' => ''],
|
||
'betting_analysis' => $bettingAnalysis,
|
||
'risk_warnings' => $parsed['risk_warnings'] ?? ['以上内容仅供参考,请理性判断。'],
|
||
'key_points' => $parsed['key_points'] ?? [],
|
||
'opposite_views' => $parsed['opposite_views'] ?? [],
|
||
'sentiment' => $parsed['sentiment'] ?? ['label' => 'neutral', 'confidence' => 55],
|
||
'tags' => $parsed['tags'] ?? [],
|
||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||
'from_kb' => !empty($retrieval['hits']),
|
||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||
];
|
||
}
|
||
|
||
private static function normalizePostResult(array $parsed, array $retrieval, array $postData): array
|
||
{
|
||
$result = [
|
||
'overall_score' => (int) ($parsed['overall_score'] ?? 66),
|
||
'summary' => (string) ($parsed['summary'] ?? '当前帖子可参考信息有限,请谨慎判断。'),
|
||
'credibility_signals' => $parsed['credibility_signals'] ?? [],
|
||
'risk_warning' => $parsed['risk_warning'] ?? ['历史样本有限,不宜过度放大单条帖子观点。'],
|
||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||
'from_kb' => !empty($retrieval['hits']),
|
||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||
];
|
||
|
||
if (self::isLiuhePost($postData, $retrieval['hits'] ?? [])) {
|
||
$result['summary'] = self::normalizeLiuheImageText(
|
||
$result['summary'],
|
||
'本次分析仅依据当前图片和帖子文案进行自由解读,可重点关注图片中出现的诗句、生肖、波色、尾数、胆码和旺弱号码等线索;结果仅供内容解读。'
|
||
);
|
||
$result['credibility_signals'] = self::normalizeLiuheImageList(
|
||
$result['credibility_signals'],
|
||
['当前分析基于图片识别出的可见文字、号码和图表线索。']
|
||
);
|
||
$result['risk_warning'] = self::normalizeLiuheImageList(
|
||
$result['risk_warning'],
|
||
['彩票结果具有随机性,图片解读仅供参考,不构成投注建议。']
|
||
);
|
||
$result['next_prediction'] = self::normalizeLiuhePrediction($parsed['next_prediction'] ?? []);
|
||
$result['next_prediction']['reasons'] = self::normalizeLiuheImageList(
|
||
$result['next_prediction']['reasons'],
|
||
['推荐仅来自当前图片中的诗句、胆码、波色、尾数或生肖等线索。']
|
||
);
|
||
$result['image_interpretation'] = self::normalizeStringList($parsed['image_interpretation'] ?? []);
|
||
$result['poem_analysis'] = (string) ($parsed['poem_analysis'] ?? '');
|
||
$result['analysis_source'] = (string) ($parsed['analysis_source'] ?? 'image_free_v1');
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private static function emptyRetrieval(): array
|
||
{
|
||
return [
|
||
'hits' => [],
|
||
'hit_count' => 0,
|
||
'domains' => [],
|
||
'top_scores' => [],
|
||
];
|
||
}
|
||
|
||
private static function normalizeLotteryOverview(array $parsed, array $retrieval): array
|
||
{
|
||
return [
|
||
'overall_score' => (int) ($parsed['overall_score'] ?? 62),
|
||
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于历史样本的统计参考。'),
|
||
'hot_numbers' => $parsed['hot_numbers'] ?? [],
|
||
'cold_numbers' => $parsed['cold_numbers'] ?? [],
|
||
'trend_analysis' => $parsed['trend_analysis'] ?? '',
|
||
'recommended_combinations' => $parsed['recommended_combinations'] ?? [],
|
||
'statistics' => $parsed['statistics'] ?? [],
|
||
'trend_direction' => $parsed['trend_direction'] ?? '震荡',
|
||
'risk_level' => $parsed['risk_level'] ?? '中',
|
||
'key_evidence' => $parsed['key_evidence'] ?? [],
|
||
'similar_issues' => $parsed['similar_issues'] ?? self::similarHistory($retrieval['hits'] ?? []),
|
||
'disclaimer' => $parsed['disclaimer'] ?? '本分析仅基于历史数据与知识库证据,不保证准确性,请理性参考。',
|
||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||
'from_kb' => !empty($retrieval['hits']),
|
||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||
];
|
||
}
|
||
|
||
private static function normalizeLotteryModule(string $module, array $parsed, array $retrieval): array
|
||
{
|
||
$base = [
|
||
'overall_score' => (int) ($parsed['overall_score'] ?? 60),
|
||
'risk_warning' => $parsed['risk_warning'] ?? ['本结果只反映历史统计规律,不保证未来表现。'],
|
||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||
'from_kb' => !empty($retrieval['hits']),
|
||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||
];
|
||
|
||
return match ($module) {
|
||
'hot_cold' => array_merge($base, [
|
||
'hot_numbers' => $parsed['hot_numbers'] ?? [],
|
||
'cold_numbers' => $parsed['cold_numbers'] ?? [],
|
||
'hot_analysis' => $parsed['hot_analysis'] ?? '',
|
||
'cold_analysis' => $parsed['cold_analysis'] ?? '',
|
||
]),
|
||
'trend' => array_merge($base, [
|
||
'trend_analysis' => $parsed['trend_analysis'] ?? '',
|
||
'recommended_combinations' => $parsed['recommended_combinations'] ?? [],
|
||
'disclaimer' => $parsed['disclaimer'] ?? '趋势分析仅供参考。',
|
||
'trend_direction' => $parsed['trend_direction'] ?? '震荡',
|
||
]),
|
||
'stats' => array_merge($base, [
|
||
'odd_even_ratio' => $parsed['odd_even_ratio'] ?? '',
|
||
'big_small_ratio' => $parsed['big_small_ratio'] ?? '',
|
||
'sum_range' => $parsed['sum_range'] ?? '',
|
||
'consecutive_analysis' => $parsed['consecutive_analysis'] ?? '',
|
||
'span_analysis' => $parsed['span_analysis'] ?? '',
|
||
'frequency_table' => $parsed['frequency_table'] ?? [],
|
||
'summary' => $parsed['summary'] ?? '',
|
||
]),
|
||
'color_zodiac' => array_merge($base, [
|
||
'color_analysis' => $parsed['color_analysis'] ?? [],
|
||
'zodiac_analysis' => $parsed['zodiac_analysis'] ?? [],
|
||
'recommended' => $parsed['recommended'] ?? [],
|
||
]),
|
||
default => array_merge($base, $parsed),
|
||
};
|
||
}
|
||
|
||
private static function retrievalMeta(array $retrieval): array
|
||
{
|
||
return [
|
||
'hit_count' => (int) ($retrieval['hit_count'] ?? 0),
|
||
'domains' => $retrieval['domains'] ?? [],
|
||
'top_scores' => $retrieval['top_scores'] ?? [],
|
||
];
|
||
}
|
||
|
||
private static function similarHistory(array $hits): array
|
||
{
|
||
$rows = [];
|
||
foreach ($hits as $hit) {
|
||
$metadata = $hit['metadata'] ?? [];
|
||
$rows[] = [
|
||
'domain' => $hit['domain'] ?? '',
|
||
'source_id' => $hit['source_id'] ?? 0,
|
||
'title' => $hit['title'] ?? '',
|
||
'issue' => $metadata['issue'] ?? '',
|
||
'code' => $metadata['code'] ?? '',
|
||
'score' => $hit['score'] ?? 0,
|
||
];
|
||
}
|
||
return array_slice($rows, 0, 5);
|
||
}
|
||
|
||
private static function buildArticleQuery(array $articleData): string
|
||
{
|
||
return trim(implode(' ', array_filter([
|
||
$articleData['title'] ?? '',
|
||
$articleData['abstract'] ?? '',
|
||
$articleData['category'] ?? '',
|
||
$articleData['desc'] ?? '',
|
||
])));
|
||
}
|
||
|
||
private static function buildPostQuery(array $postData): string
|
||
{
|
||
$tags = is_array($postData['tags'] ?? null) ? implode(' ', $postData['tags']) : '';
|
||
$lotteryKey = (string) (($postData['ext']['lottery_key'] ?? '') ?: '');
|
||
return trim(implode(' ', array_filter([
|
||
self::clipText((string) ($postData['content'] ?? ''), 300),
|
||
$tags,
|
||
$lotteryKey,
|
||
])));
|
||
}
|
||
|
||
private static function buildLotteryQuery(array $lotteryData): string
|
||
{
|
||
$recentIssues = array_column($lotteryData['recent_draws'] ?? [], 'issue');
|
||
return trim(implode(' ', array_filter([
|
||
$lotteryData['lottery_type'] ?? '',
|
||
$lotteryData['lottery_name'] ?? '',
|
||
implode(' ', array_slice($recentIssues, 0, 5)),
|
||
])));
|
||
}
|
||
|
||
private static function buildMatchQuery(array $matchData): string
|
||
{
|
||
$matchInfo = $matchData['match_info'] ?? [];
|
||
$headToHead = $matchData['head_to_head'] ?? [];
|
||
$keywords = [
|
||
$matchInfo['home_team'] ?? '',
|
||
$matchInfo['away_team'] ?? '',
|
||
$matchInfo['league'] ?? '',
|
||
];
|
||
|
||
foreach (array_slice($headToHead, 0, 3) as $row) {
|
||
$keywords[] = $row['home_team'] ?? '';
|
||
$keywords[] = $row['away_team'] ?? '';
|
||
$keywords[] = $row['league_name'] ?? ($row['league'] ?? '');
|
||
}
|
||
|
||
return trim(implode(' ', array_filter($keywords)));
|
||
}
|
||
|
||
private static function retrieveMatchEvidence(int $matchId, array $matchData, int $userId, string $scene): array
|
||
{
|
||
$matchInfo = $matchData['match_info'] ?? [];
|
||
return KbService::retrieve(
|
||
$scene,
|
||
self::buildMatchQuery($matchData),
|
||
[
|
||
'user_id' => $userId,
|
||
'ref_id' => $matchId,
|
||
'domains' => ['article', 'post'],
|
||
'preferred_domain' => 'article',
|
||
],
|
||
[
|
||
'title' => trim(($matchInfo['home_team'] ?? '') . ' vs ' . ($matchInfo['away_team'] ?? '')),
|
||
'league' => (string) ($matchInfo['league'] ?? ''),
|
||
'teams' => array_values(array_filter([
|
||
$matchInfo['home_team'] ?? '',
|
||
$matchInfo['away_team'] ?? '',
|
||
])),
|
||
],
|
||
self::intConfig('kb_final_topk', 8)
|
||
);
|
||
}
|
||
|
||
private static function buildMatchUserMessage(
|
||
array $matchData,
|
||
array $retrieval,
|
||
bool $full = false,
|
||
string $section = ''
|
||
): string {
|
||
$label = $full ? '完整赛事分析' : ('赛事分段分析-' . $section);
|
||
return $label . ":\n当前赛事结构化数据:\n"
|
||
. json_encode([
|
||
'match_info' => $matchData['match_info'] ?? [],
|
||
'head_to_head' => self::compactMatchRows($matchData['head_to_head'] ?? [], 5),
|
||
'home_recent' => self::compactMatchRows($matchData['home_recent'] ?? [], 5),
|
||
'away_recent' => self::compactMatchRows($matchData['away_recent'] ?? [], 5),
|
||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n知识库证据(资讯/帖子):\n"
|
||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||
. "\n\n请优先依据当前赛事结构化数据与知识库证据输出,不允许编造未命中的历史事实。";
|
||
}
|
||
|
||
private static function compactMatchRows(array $rows, int $limit = 5): array
|
||
{
|
||
$compact = [];
|
||
foreach (array_slice($rows, 0, $limit) as $row) {
|
||
$compact[] = [
|
||
'match_time' => $row['match_time'] ?? '',
|
||
'league' => $row['league_name'] ?? ($row['league'] ?? ''),
|
||
'home_team' => $row['home_team'] ?? ($row['team_name'] ?? ''),
|
||
'away_team' => $row['away_team'] ?? ($row['opponent'] ?? ''),
|
||
'score' => [
|
||
'home' => $row['home_score'] ?? ($row['team_score'] ?? null),
|
||
'away' => $row['away_score'] ?? ($row['opponent_score'] ?? null),
|
||
],
|
||
'result' => $row['result'] ?? '',
|
||
];
|
||
}
|
||
return $compact;
|
||
}
|
||
|
||
private static function clipText(string $text, int $length): string
|
||
{
|
||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
|
||
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
|
||
}
|
||
|
||
private static function intConfig(string $name, int $default): int
|
||
{
|
||
return (int) AiConfig::getVal($name, (string) $default);
|
||
}
|
||
|
||
private static function defaultArticlePrompt(): string
|
||
{
|
||
return '你是一位体育资讯AI研报助手。请严格根据当前文章和知识库证据生成分析,不允许虚构未命中的历史事实。'
|
||
. '必须输出JSON,字段包含:'
|
||
. '{"overall_score":72,"summary":"...","leagues_and_teams":{"leagues":[],"teams":[],"match_context":""},"upcoming_matches":[],"head_to_head":{"available":false,"records":[],"summary":""},"betting_analysis":{"prediction":"谨慎观望","confidence":55,"reasoning":"...","risk_level":"medium","key_factors":[],"value_bet":"无明显价值"},"risk_warnings":["..."],"key_points":["..."],"opposite_views":["..."],"sentiment":{"label":"neutral","confidence":60},"tags":["..."],"key_evidence":["..."]}';
|
||
}
|
||
|
||
private static function defaultMatchPrompt(): string
|
||
{
|
||
return '你是一位体育赛事AI分析师。请结合当前赛事结构化数据(交手记录、双方近期战绩、赔率)与知识库中召回的相关资讯/帖子,生成完整比赛分析。'
|
||
. '若知识库证据不足,要明确说明证据不足。'
|
||
. '必须输出JSON:{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"..."},"analysis":{"home_strength":78,"away_strength":72,"key_factors":["..."],"risk_factors":["..."]},"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"..."},"summary":"...","evidence_list":[],"risk_warning":["..."]}';
|
||
}
|
||
|
||
private static function defaultPostPrompt(): string
|
||
{
|
||
return '你是一位社区内容分析助手。请根据当前帖子和知识库证据,判断帖子的核心观点、可信线索与风险。'
|
||
. '如果知识库证据不足,必须明确说明证据不足。'
|
||
. '如果帖子属于六合彩(例如旧澳六合/新澳六合)资料或开奖类帖子,请额外给出基于当前帖子内容与知识库历史样本的下一期预测,字段包含推荐号码、信心和依据。'
|
||
. '输出JSON:{"overall_score":66,"summary":"...","credibility_signals":["..."],"risk_warning":["..."],"next_prediction":{"recommended_numbers":["..."],"confidence":68,"reasons":["..."]}}';
|
||
}
|
||
|
||
private static function isLiuhePost(array $postData, array $hits): bool
|
||
{
|
||
$tags = is_array($postData['tags'] ?? null) ? $postData['tags'] : [];
|
||
if (in_array('旧澳六合', $tags, true) || in_array('新澳六合', $tags, true)) {
|
||
return true;
|
||
}
|
||
|
||
$ext = is_array($postData['ext'] ?? null) ? $postData['ext'] : [];
|
||
$lotteryKey = (string) ($ext['lottery_key'] ?? '');
|
||
if (in_array($lotteryKey, ['a6', 'xa6'], true)) {
|
||
return true;
|
||
}
|
||
|
||
foreach ($hits as $hit) {
|
||
$title = (string) ($hit['title'] ?? '');
|
||
$metadata = $hit['metadata'] ?? [];
|
||
$hitLotteryKey = (string) ($metadata['lottery_key'] ?? '');
|
||
if (str_contains($title, '旧澳六合') || str_contains($title, '新澳六合') || in_array($hitLotteryKey, ['a6', 'xa6'], true)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private static function normalizeLiuhePrediction(array $prediction): array
|
||
{
|
||
$numbers = self::normalizeStringList($prediction['recommended_numbers'] ?? []);
|
||
$reasons = self::normalizeStringList($prediction['reasons'] ?? []);
|
||
|
||
return [
|
||
'recommended_numbers' => $numbers,
|
||
'confidence' => max(0, min(100, (int) ($prediction['confidence'] ?? 60))),
|
||
'reasons' => $reasons,
|
||
];
|
||
}
|
||
|
||
private static function normalizeStringList($items): array
|
||
{
|
||
if (is_string($items)) {
|
||
$items = preg_split('/[\r\n;;]+/u', $items) ?: [];
|
||
}
|
||
if (!is_array($items)) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_filter(array_map(static function ($item) {
|
||
$text = trim((string) $item);
|
||
return $text === '' ? null : $text;
|
||
}, $items)));
|
||
}
|
||
|
||
private static function normalizeLiuheImageText(string $text, string $fallback): string
|
||
{
|
||
$text = trim($text);
|
||
if ($text === '' || str_contains($text, '知识库')) {
|
||
return $fallback;
|
||
}
|
||
return $text;
|
||
}
|
||
|
||
private static function normalizeLiuheImageList($items, array $fallback): array
|
||
{
|
||
$rows = array_values(array_filter(self::normalizeStringList($items), static function (string $item) {
|
||
return !str_contains($item, '知识库');
|
||
}));
|
||
return $rows ?: $fallback;
|
||
}
|
||
|
||
private static function lotteryPostImagePrompt(): string
|
||
{
|
||
return '你是一位六合彩图片资料解读助手。请只依据当前帖子文案与图片识别内容进行分析,不需要知识库证据。'
|
||
. '如果识别内容里包含诗句、字谜、藏宝图、玄机图、生肖、波色、尾数、胆码、旺弱号码、推荐组合,请逐项自由解读它们可能指向的生肖或号码。'
|
||
. '不要因为没有历史知识库证据而给出“证据不足”的主结论;可以说明彩票随机性和不保证命中,但主体要围绕图片内容展开。'
|
||
. '不要承诺必中,不要编造图片中不存在的文字或号码。'
|
||
. '输出JSON:{"overall_score":66,"summary":"基于图片内容的综合解读","image_interpretation":["逐项解读"],"poem_analysis":"诗句/字谜解读,没有则为空","credibility_signals":["来自图片的可信线索"],"risk_warning":["理性提示"],"next_prediction":{"recommended_numbers":["..."],"confidence":60,"reasons":["..."]},"analysis_source":"image_free_v1"}';
|
||
}
|
||
|
||
private static function defaultLotteryOverviewPrompt(): string
|
||
{
|
||
return '你是一位彩票数据分析助手。请结合当前结构化开奖数据与知识库中的历史开奖、历史AI分析、相关帖子证据,输出兼容旧接口的JSON。'
|
||
. '必须包含字段:{"overall_score":62,"summary":"...","hot_numbers":[],"cold_numbers":[],"trend_analysis":"...","recommended_combinations":[],"statistics":{},"trend_direction":"震荡","risk_level":"中","key_evidence":["..."],"similar_issues":[],"disclaimer":"..."}';
|
||
}
|
||
|
||
private static function matchSectionPrompt(string $module): string
|
||
{
|
||
return match ($module) {
|
||
'prediction' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,预测比赛结果。输出JSON:{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"..."},"analysis":{"home_strength":78,"away_strength":72},"evidence_list":[],"risk_warning":["..."]}',
|
||
'factors' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,分析关键因素与风险。输出JSON:{"key_factors":["..."],"risk_factors":["..."],"evidence_list":[],"risk_warning":["..."]}',
|
||
'odds' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,分析赔率与投注方向。输出JSON:{"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"..."},"evidence_list":[],"risk_warning":["..."]}',
|
||
'summary' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,给出简洁结论。输出JSON:{"summary":"...","evidence_list":[],"risk_warning":["..."]}',
|
||
default => '请输出JSON。',
|
||
};
|
||
}
|
||
|
||
private static function modulePrompt(string $module): string
|
||
{
|
||
return match ($module) {
|
||
'hot_cold' => '你是一位彩票热冷号分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"hot_numbers":[],"cold_numbers":[],"hot_analysis":"...","cold_analysis":"...","risk_warning":["..."]}',
|
||
'trend' => '你是一位彩票趋势分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"trend_analysis":"...","recommended_combinations":[],"trend_direction":"震荡","disclaimer":"...","risk_warning":["..."]}',
|
||
'stats' => '你是一位彩票统计分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"odd_even_ratio":"","big_small_ratio":"","sum_range":"","consecutive_analysis":"...","span_analysis":"...","frequency_table":[],"summary":"...","risk_warning":["..."]}',
|
||
'color_zodiac' => '你是一位六合彩颜色生肖分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"color_analysis":{},"zodiac_analysis":{},"recommended":{},"risk_warning":["..."]}',
|
||
default => '请输出JSON。',
|
||
};
|
||
}
|
||
|
||
private static function normalizeRiskLevel(string $value): string
|
||
{
|
||
$value = strtolower(trim($value));
|
||
return match ($value) {
|
||
'低', 'low' => 'low',
|
||
'高', 'high' => 'high',
|
||
default => 'medium',
|
||
};
|
||
}
|
||
|
||
private static function normalizeMatchResult(array $parsed, array $retrieval): array
|
||
{
|
||
return [
|
||
'prediction' => $parsed['prediction'] ?? [
|
||
'result' => '谨慎观望',
|
||
'confidence' => 55,
|
||
'score_predict' => '-',
|
||
'reasoning' => '当前证据不足,建议谨慎观望。',
|
||
],
|
||
'analysis' => array_merge([
|
||
'home_strength' => 50,
|
||
'away_strength' => 50,
|
||
'key_factors' => [],
|
||
'risk_factors' => [],
|
||
], is_array($parsed['analysis'] ?? null) ? $parsed['analysis'] : []),
|
||
'odds_opinion' => $parsed['odds_opinion'] ?? [
|
||
'value_bet' => '无',
|
||
'explanation' => '赔率证据不足,建议谨慎观望。',
|
||
],
|
||
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于结构化数据和有限知识库证据的保守结论。'),
|
||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||
'from_kb' => !empty($retrieval['hits']),
|
||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||
];
|
||
}
|
||
|
||
private static function normalizeMatchSectionResult(string $section, array $parsed, array $retrieval): array
|
||
{
|
||
$base = [
|
||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||
'from_kb' => !empty($retrieval['hits']),
|
||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||
'risk_warning' => $parsed['risk_warning'] ?? ['以上分析仅供参考,请结合比赛临场信息谨慎判断。'],
|
||
];
|
||
|
||
return match ($section) {
|
||
'prediction' => array_merge($base, [
|
||
'prediction' => $parsed['prediction'] ?? [
|
||
'result' => '谨慎观望',
|
||
'confidence' => 55,
|
||
'score_predict' => '-',
|
||
'reasoning' => '当前证据不足,建议谨慎观望。',
|
||
],
|
||
'analysis' => array_merge([
|
||
'home_strength' => 50,
|
||
'away_strength' => 50,
|
||
], is_array($parsed['analysis'] ?? null) ? $parsed['analysis'] : []),
|
||
]),
|
||
'factors' => array_merge($base, [
|
||
'key_factors' => $parsed['key_factors'] ?? [],
|
||
'risk_factors' => $parsed['risk_factors'] ?? [],
|
||
]),
|
||
'odds' => array_merge($base, [
|
||
'odds_opinion' => $parsed['odds_opinion'] ?? [
|
||
'value_bet' => '无',
|
||
'explanation' => '赔率证据不足,建议谨慎观望。',
|
||
],
|
||
]),
|
||
'summary' => array_merge($base, [
|
||
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于结构化数据和有限知识库证据的保守结论。'),
|
||
]),
|
||
default => array_merge($base, $parsed),
|
||
};
|
||
}
|
||
}
|