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

633 lines
22 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'],
];
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 当前价格 %.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 $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'] ?? [],
];
}
}