Files
sbnews/server/app/common/service/ai/AiAssistantContextService.php
T
2026-06-11 12:15:29 +08:00

303 lines
10 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;
use app\common\model\match\MatchEvent;
use think\facade\Cache;
class AiAssistantContextService
{
private const CRYPTO_SYMBOLS = [
'BTC' => ['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 当前价格 %.8g24小时涨跌 %.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) . '...';
}
}