['BTC', '比特币', 'Bitcoin'], 'ETH' => ['ETH', '以太坊', 'Ethereum'], 'BNB' => ['BNB'], 'SOL' => ['SOL', 'Solana'], 'XRP' => ['XRP', 'Ripple'], 'DOGE' => ['DOGE', '狗狗币', 'Dogecoin'], 'TON' => ['TON', 'Toncoin'], 'TRX' => ['TRX', 'TRON', '波场'], 'ADA' => ['ADA', 'Cardano'], 'AVAX' => ['AVAX', 'Avalanche'], ]; private const QUERY_STOP_WORDS = [ '最新', '最近', '比赛结果', '比赛', '结果', '比分', '赛果', '赛程', '成绩', '战绩', '球队', '赛事', '今天', '今日', '昨天', '昨日', '明天', '分析', '什么', '一下', '查询', '查看', '看看', '给我', '有没有', '内容', '的', ]; public static function build(string $message, int $userId, int $sessionId, array $plan = []): array { $topK = max(1, min(12, (int) AiConfig::getVal('assistant_kb_topk', '6'))); $hits = self::retrieveKnowledge($message, $userId, $sessionId, $topK, $plan); $matchItems = self::searchMatches($message, $plan); $cryptoItems = self::resolveCryptoContext($message, $plan); $payload = self::composePayload($hits, $matchItems, $cryptoItems, [], $plan); return array_merge($payload, [ 'knowledge_hits' => $hits, 'match_items' => $matchItems, 'crypto_items' => $cryptoItems, ]); } public static function composePayload( array $hits, array $matchItems, array $cryptoItems, array $webNewsItems = [], array $plan = [] ): array { $sources = []; foreach ($webNewsItems as $item) { $sources[] = $item['source']; } foreach ($hits as $hit) { $sources[] = self::sourceFromKbHit($hit); } foreach ($matchItems as $item) { $sources[] = $item['source']; } foreach ($cryptoItems as $item) { $sources[] = $item['source']; } $sources = self::uniqueSources($sources); $context = [ 'query_plan' => self::compactPlan($plan), 'web_news_context' => array_column($webNewsItems, 'context'), 'knowledge_hits' => array_map([self::class, 'compactHit'], $hits), 'match_context' => array_column($matchItems, 'context'), 'crypto_realtime' => array_column($cryptoItems, 'context'), ]; return [ 'context_text' => json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), 'sources' => array_values($sources), 'has_context' => !empty($webNewsItems) || !empty($hits) || !empty($matchItems) || !empty($cryptoItems), ]; } private static function retrieveKnowledge(string $message, int $userId, int $sessionId, int $topK, array $plan = []): array { $queries = self::knowledgeQueries($message, $plan); $filters = [ 'user_id' => $userId, 'ref_id' => $sessionId, 'domains' => self::knowledgeDomains($plan), ]; if (!empty($plan['preferred_domain']) && in_array($plan['preferred_domain'], $filters['domains'], true)) { $filters['preferred_domain'] = $plan['preferred_domain']; } $entities = is_array($plan['entities'] ?? null) ? $plan['entities'] : []; $structuredContext = [ 'title' => (string) ($plan['rewritten_query'] ?? $message), 'league' => (string) (($entities['leagues'][0] ?? '') ?: ''), 'teams' => is_array($entities['teams'] ?? null) ? $entities['teams'] : [], ]; $mergedHits = []; foreach (array_slice($queries, 0, 3) as $index => $queryText) { $result = KbService::retrieve('assistant_chat', $queryText, $filters, $structuredContext, max($topK, 6)); if (empty($result['success']) || empty($result['hits']) || !is_array($result['hits'])) { continue; } foreach ($result['hits'] as $hit) { $key = implode(':', [ (string) ($hit['domain'] ?? ''), (string) ($hit['subtype'] ?? ''), (int) ($hit['source_id'] ?? 0), (int) ($hit['document_id'] ?? 0), md5((string) ($hit['chunk_text'] ?? '')), ]); $hit['score'] = round((float) ($hit['score'] ?? 0) - ($index * 0.01), 6); if (!isset($mergedHits[$key]) || (float) ($hit['score'] ?? 0) > (float) ($mergedHits[$key]['score'] ?? 0)) { $mergedHits[$key] = $hit; } } } $hits = array_values($mergedHits); usort($hits, static fn(array $left, array $right) => ((float) ($right['score'] ?? 0)) <=> ((float) ($left['score'] ?? 0))); return array_slice($hits, 0, $topK); } private static function searchMatches(string $message, array $plan = []): array { if (!self::shouldRetrieveMatchContext($message, $plan)) { return []; } $terms = self::matchTerms($message, $plan); if (empty($terms)) { return []; } $matchDateRange = self::matchDateRange($message, $plan); $preferSchedule = self::preferScheduleMatchContext($message, $plan); try { if ($preferSchedule) { $rows = self::matchRowsByTerms($terms, 6, null, null, $matchDateRange, 'asc'); } elseif (self::preferCompletedMatchContext($message, $plan)) { $rows = self::matchRowsByTerms($terms, 3, 2, null, $matchDateRange, 'desc'); if (count($rows) < 3) { $rows = self::mergeMatchRows( $rows, self::matchRowsByTerms($terms, 3 - count($rows), null, 2, $matchDateRange, 'desc') ); } } else { $rows = self::matchRowsByTerms($terms, 3, null, null, $matchDateRange, 'desc'); } } catch (\Throwable $e) { return []; } return array_map(static function (array $row) { $title = trim(($row['home_team'] ?? '') . ' vs ' . ($row['away_team'] ?? '')); $matchTime = !empty($row['match_time']) && is_numeric($row['match_time']) ? date('Y-m-d H:i', (int) $row['match_time']) : (string) ($row['match_time'] ?? ''); $summary = sprintf( '%s,%s,比分 %s-%s,赔率 主胜%s 平%s 客胜%s。', $row['league_name'] ?? '', $matchTime, $row['home_score'] ?? 0, $row['away_score'] ?? 0, $row['home_odds'] ?? '-', $row['draw_odds'] ?? '-', $row['away_odds'] ?? '-' ); return [ 'context' => [ 'domain' => 'match', 'title' => $title, 'summary' => $summary, ], 'source' => [ 'type' => 'match', 'title' => $title, 'summary' => $summary, 'path' => '/pages/match_detail/match_detail?id=' . (int) $row['id'], 'source_id' => (int) $row['id'], ], ]; }, $rows); } private static function matchRowsByTerms( array $terms, int $limit, ?int $status = null, ?int $excludeStatus = null, ?array $matchDateRange = null, string $orderBy = 'desc' ): array { $query = MatchEvent::where('is_show', 1); $query->where(function ($q) use ($terms) { foreach (array_slice($terms, 0, 5) as $term) { $like = '%' . $term . '%'; $q->whereOr('home_team', 'like', $like) ->whereOr('away_team', 'like', $like) ->whereOr('league_name', 'like', $like); } }); if ($status !== null) { $query->where('status', $status); } if ($excludeStatus !== null) { $query->where('status', '<>', $excludeStatus); } if (is_array($matchDateRange) && count($matchDateRange) === 2) { $query->whereBetween('match_time', [(int) $matchDateRange[0], (int) $matchDateRange[1]]); } $orderBy = strtolower($orderBy) === 'asc' ? 'asc' : 'desc'; return $query->field('id,league_name,home_team,away_team,match_time,status,home_score,away_score,home_odds,draw_odds,away_odds') ->order('match_time', $orderBy) ->limit($limit) ->select() ->toArray(); } private static function preferScheduleMatchContext(string $message, array $plan = []): bool { if (($plan['intent'] ?? '') === 'match_schedule') { return true; } return (bool) preg_match('/下一场|下场|赛程|未来|即将|未开赛|今天|今日|明天/u', $message); } private static function preferCompletedMatchContext(string $message, array $plan = []): bool { if (($plan['intent'] ?? '') === 'match_result') { return true; } if (preg_match('/下一场|下场|赛程|未来|即将|未开赛|今天|今日/u', $message)) { return false; } return (bool) preg_match('/最新|最近|结果|比分|赛果|成绩|战绩|上一场|上场/u', $message); } private static function matchDateRange(string $message, array $plan = []): ?array { $plannedRange = self::plannedDateRange($plan); if ($plannedRange !== null) { return $plannedRange; } $baseDate = strtotime(date('Y-m-d')); if ($baseDate === false) { return null; } if (preg_match('/今天|今日/u', $message)) { return [$baseDate, $baseDate + 86400 - 1]; } if (preg_match('/明天/u', $message)) { $start = $baseDate + 86400; return [$start, $start + 86400 - 1]; } if (preg_match('/昨天|昨日/u', $message)) { $start = $baseDate - 86400; return [$start, $start + 86400 - 1]; } return null; } private static function mergeMatchRows(array $primary, array $secondary): array { $rows = []; $seen = []; foreach (array_merge($primary, $secondary) as $row) { $id = (int) ($row['id'] ?? 0); if ($id <= 0 || isset($seen[$id])) { continue; } $seen[$id] = true; $rows[] = $row; } return $rows; } private static function resolveCryptoContext(string $message, array $plan = []): array { if (!self::shouldRetrieveCryptoContext($message, $plan)) { return []; } if ((int) AiConfig::getVal('assistant_enable_crypto_realtime', '1') !== 1) { return []; } $symbols = self::detectCryptoSymbols($message, $plan); if (empty($symbols)) { return []; } $items = []; foreach (array_slice($symbols, 0, 3) as $symbol) { $ticker = self::fetchBinanceTicker($symbol); if (empty($ticker)) { continue; } $price = (float) ($ticker['lastPrice'] ?? 0); $change = (float) ($ticker['priceChangePercent'] ?? 0); $high = (float) ($ticker['highPrice'] ?? 0); $low = (float) ($ticker['lowPrice'] ?? 0); $summary = sprintf( '%s/USDT 当前价格 %.8g,24小时涨跌 %.2f%%,24小时高低 %.8g / %.8g。', $symbol, $price, $change, $high, $low ); $items[] = [ 'context' => [ 'domain' => 'crypto', 'symbol' => $symbol, 'summary' => $summary, 'raw' => [ 'price' => $price, 'change_percent' => $change, 'high' => $high, 'low' => $low, ], ], 'source' => [ 'type' => 'crypto', 'title' => $symbol . '/USDT 行情', 'summary' => $summary, 'path' => '/pages/crypto/crypto_detail?symbol=' . $symbol, 'source_id' => 0, ], ]; } return $items; } private static function detectCryptoSymbols(string $message, array $plan = []): array { $plannedSymbols = []; foreach ((array) ($plan['entities']['symbols'] ?? []) as $symbol) { $symbol = strtoupper(trim((string) $symbol)); if ($symbol !== '') { $plannedSymbols[] = $symbol; } } if (!empty($plannedSymbols)) { return array_values(array_unique($plannedSymbols)); } $haystack = mb_strtolower($message); $symbols = []; foreach (self::CRYPTO_SYMBOLS as $symbol => $aliases) { foreach ($aliases as $alias) { if (mb_strpos($haystack, mb_strtolower($alias)) !== false) { $symbols[] = $symbol; break; } } } return array_values(array_unique($symbols)); } private static function fetchBinanceTicker(string $symbol): array { $cacheKey = 'assistant_crypto_ticker_' . $symbol; $cached = Cache::get($cacheKey); if (is_array($cached)) { return $cached; } $url = 'https://api.binance.com/api/v3/ticker/24hr?symbol=' . rawurlencode($symbol . 'USDT'); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $body = curl_exec($ch); $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $data = $httpCode === 200 ? json_decode((string) $body, true) : []; if (is_array($data) && !empty($data['symbol'])) { Cache::set($cacheKey, $data, 10); return $data; } return []; } private static function sourceFromKbHit(array $hit): array { $domain = (string) ($hit['domain'] ?? ''); $sourceId = (int) ($hit['source_id'] ?? 0); return [ 'type' => $domain, 'title' => (string) ($hit['title'] ?? ''), 'summary' => self::clipText((string) ($hit['summary'] ?? $hit['chunk_text'] ?? ''), 120), 'path' => self::sourcePath($domain, $sourceId), 'source_id' => $sourceId, ]; } private static function sourcePath(string $domain, int $sourceId): string { return match ($domain) { 'article' => '/pages/news_detail/news_detail?id=' . $sourceId, 'post' => '/packages_community/pages/post_detail?id=' . $sourceId, 'match' => '/pages/match_detail/match_detail?id=' . $sourceId, 'lottery' => '/pages/lottery_analysis/lottery_analysis', default => '', }; } private static function compactHit(array $hit): array { return [ 'domain' => $hit['domain'] ?? '', 'subtype' => $hit['subtype'] ?? '', 'source_id' => (int) ($hit['source_id'] ?? 0), 'title' => $hit['title'] ?? '', 'summary' => $hit['summary'] ?? '', 'content' => self::clipText((string) ($hit['chunk_text'] ?? ''), 360), 'score' => $hit['score'] ?? 0, ]; } private static function uniqueSources(array $sources): array { $map = []; foreach ($sources as $source) { $key = ($source['type'] ?? '') . ':' . ($source['source_id'] ?? 0) . ':' . ($source['title'] ?? ''); if (!isset($map[$key]) && trim((string) ($source['title'] ?? '')) !== '') { $map[$key] = $source; } } return array_slice(array_values($map), 0, 8); } private static function queryTerms(string $message): array { $parts = preg_split('/[\s,,。!?;、::\/\\\\]+/u', trim($message)) ?: []; $terms = []; foreach ($parts as $part) { $term = trim($part); if ($term === '') { continue; } if (self::isUsefulQueryTerm($term)) { $terms[] = $term; } $normalized = str_replace(self::QUERY_STOP_WORDS, '', $term); if ($normalized !== $term && self::isUsefulQueryTerm($normalized)) { $terms[] = $normalized; } } return array_values(array_unique($terms)); } private static function isUsefulQueryTerm(string $term): bool { return mb_strlen($term) >= 2 && !in_array($term, self::QUERY_STOP_WORDS, true); } private static function clipText(string $text, int $length): string { $text = trim(preg_replace('/\s+/u', ' ', $text) ?: ''); if (mb_strlen($text) <= $length) { return $text; } return mb_substr($text, 0, $length) . '...'; } private static function knowledgeQueries(string $message, array $plan): array { $queries = []; $rewritten = trim((string) ($plan['rewritten_query'] ?? '')); if ($rewritten !== '') { $queries[] = $rewritten; } foreach ((array) ($plan['alternate_queries'] ?? []) as $query) { $query = trim((string) $query); if ($query !== '') { $queries[] = $query; } } $queries[] = $message; return array_values(array_unique(array_filter($queries))); } private static function knowledgeDomains(array $plan): array { $domains = array_values(array_intersect( (array) ($plan['domains'] ?? []), ['article', 'post', 'lottery', 'match'] )); return !empty($domains) ? $domains : ['article', 'post', 'lottery', 'match']; } private static function shouldRetrieveMatchContext(string $message, array $plan): bool { $domains = (array) ($plan['domains'] ?? []); if (in_array('match', $domains, true) || ($plan['preferred_domain'] ?? '') === 'match') { return true; } $entities = is_array($plan['entities'] ?? null) ? $plan['entities'] : []; if (!empty($entities['teams']) || !empty($entities['leagues'])) { return true; } return (bool) preg_match('/比赛|赛程|比分|赛果|战绩|世界杯|NBA|足球|篮球/u', $message); } private static function shouldRetrieveCryptoContext(string $message, array $plan): bool { $domains = (array) ($plan['domains'] ?? []); if (in_array('crypto', $domains, true) || ($plan['preferred_domain'] ?? '') === 'crypto') { return true; } if (!empty($plan['entities']['symbols'])) { return true; } return (bool) preg_match('/BTC|ETH|BNB|SOL|XRP|DOGE|TON|TRX|ADA|AVAX|比特币|以太坊|行情/u', $message); } private static function matchTerms(string $message, array $plan): array { $terms = self::queryTerms($message); $rewritten = trim((string) ($plan['rewritten_query'] ?? '')); if ($rewritten !== '' && $rewritten !== $message) { $terms = array_merge($terms, self::queryTerms($rewritten)); } $entities = is_array($plan['entities'] ?? null) ? $plan['entities'] : []; foreach (['teams', 'leagues', 'keywords'] as $key) { foreach ((array) ($entities[$key] ?? []) as $term) { $term = trim((string) $term); if ($term !== '') { $terms[] = $term; } } } return array_values(array_unique(array_filter($terms))); } private static function plannedDateRange(array $plan): ?array { $dateRange = is_array($plan['date_range'] ?? null) ? $plan['date_range'] : []; if (empty($dateRange)) { return null; } $preset = (string) ($dateRange['preset'] ?? ''); if ($preset === 'today') { return self::dayRange(date('Y-m-d')); } if ($preset === 'tomorrow') { return self::dayRange(date('Y-m-d', strtotime('+1 day'))); } if ($preset === 'yesterday') { return self::dayRange(date('Y-m-d', strtotime('-1 day'))); } if (!empty($dateRange['date'])) { return self::dayRange((string) $dateRange['date']); } if (!empty($dateRange['start_date']) && !empty($dateRange['end_date'])) { $start = strtotime((string) $dateRange['start_date'] . ' 00:00:00'); $end = strtotime((string) $dateRange['end_date'] . ' 23:59:59'); if ($start !== false && $end !== false) { return [$start, $end]; } } return null; } private static function dayRange(string $date): ?array { $start = strtotime($date . ' 00:00:00'); if ($start === false) { return null; } return [$start, $start + 86400 - 1]; } private static function compactPlan(array $plan): array { if (empty($plan)) { return []; } return [ 'intent' => $plan['intent'] ?? '', 'rewritten_query' => $plan['rewritten_query'] ?? '', 'alternate_queries' => $plan['alternate_queries'] ?? [], 'domains' => $plan['domains'] ?? [], 'preferred_domain' => $plan['preferred_domain'] ?? '', 'entities' => $plan['entities'] ?? [], 'date_range' => $plan['date_range'] ?? [], 'needs_realtime' => $plan['needs_realtime'] ?? false, 'confidence' => $plan['confidence'] ?? 0, 'meta' => $plan['_meta'] ?? [], ]; } }