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

737 lines
36 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\AiConfig;
class KbRagService
{
private const LOTTERY_POST_ANALYSIS_TIMEOUT = 25;
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 DeepSeekClient::defaultModelOptions([
'max_tokens' => 1200,
'temperature' => 0.35,
'timeout' => self::LOTTERY_POST_ANALYSIS_TIMEOUT,
'reasoning_effort' => 'low',
'retry_times' => 0,
'retry_sleep_ms' => 0,
]);
}
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),
};
}
}