['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'], ]; public static function build(string $message, int $userId, int $sessionId): array { $topK = max(1, min(12, (int) AiConfig::getVal('assistant_kb_topk', '6'))); $hits = self::retrieveKnowledge($message, $userId, $sessionId, $topK); $matchItems = self::searchMatches($message); $cryptoItems = self::resolveCryptoContext($message); $sources = []; 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 = [ '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($hits) || !empty($matchItems) || !empty($cryptoItems), ]; } private static function retrieveKnowledge(string $message, int $userId, int $sessionId, int $topK): array { $result = KbService::retrieve( 'assistant_chat', $message, [ 'user_id' => $userId, 'ref_id' => $sessionId, 'domains' => ['article', 'post', 'lottery', 'match'], ], ['title' => $message], $topK ); if (empty($result['success']) || empty($result['hits']) || !is_array($result['hits'])) { return []; } return $result['hits']; } private static function searchMatches(string $message): array { $terms = self::queryTerms($message); if (empty($terms)) { return []; } try { $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); } }); $rows = $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 desc') ->limit(3) ->select() ->toArray(); } 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 resolveCryptoContext(string $message): array { if ((int) AiConfig::getVal('assistant_enable_crypto_realtime', '1') !== 1) { return []; } $symbols = self::detectCryptoSymbols($message); 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 { $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 { $terms = preg_split('/[\s,,。!?;、::\/\\\\]+/u', trim($message)) ?: []; $terms = array_values(array_filter(array_map('trim', $terms), static function (string $term) { return mb_strlen($term) >= 2 && !in_array($term, ['今天', '最近', '分析', '什么', '一下'], true); })); return array_unique($terms); } 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) . '...'; } }