no message
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
<?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 当前价格 %.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) . '...';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user