deploy: auto commit server changes 2026-06-12 14:57:05
This commit is contained in:
@@ -21,13 +21,29 @@ class AiAssistantContextService
|
||||
'AVAX' => ['AVAX', 'Avalanche'],
|
||||
];
|
||||
|
||||
public static function build(string $message, int $userId, int $sessionId): array
|
||||
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);
|
||||
$matchItems = self::searchMatches($message);
|
||||
$cryptoItems = self::resolveCryptoContext($message);
|
||||
$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 $plan = []): array
|
||||
{
|
||||
$sources = [];
|
||||
foreach ($hits as $hit) {
|
||||
$sources[] = self::sourceFromKbHit($hit);
|
||||
@@ -41,6 +57,7 @@ class AiAssistantContextService
|
||||
|
||||
$sources = self::uniqueSources($sources);
|
||||
$context = [
|
||||
'query_plan' => self::compactPlan($plan),
|
||||
'knowledge_hits' => array_map([self::class, 'compactHit'], $hits),
|
||||
'match_context' => array_column($matchItems, 'context'),
|
||||
'crypto_realtime' => array_column($cryptoItems, 'context'),
|
||||
@@ -53,50 +70,80 @@ class AiAssistantContextService
|
||||
];
|
||||
}
|
||||
|
||||
private static function retrieveKnowledge(string $message, int $userId, int $sessionId, int $topK): array
|
||||
private static function retrieveKnowledge(string $message, int $userId, int $sessionId, int $topK, array $plan = []): array
|
||||
{
|
||||
$result = KbService::retrieve(
|
||||
'assistant_chat',
|
||||
$message,
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ref_id' => $sessionId,
|
||||
'domains' => ['article', 'post', 'lottery', 'match'],
|
||||
],
|
||||
['title' => $message],
|
||||
$topK
|
||||
);
|
||||
$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'];
|
||||
}
|
||||
|
||||
if (empty($result['success']) || empty($result['hits']) || !is_array($result['hits'])) {
|
||||
$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 [];
|
||||
}
|
||||
|
||||
return $result['hits'];
|
||||
}
|
||||
|
||||
private static function searchMatches(string $message): array
|
||||
{
|
||||
$terms = self::queryTerms($message);
|
||||
$terms = self::matchTerms($message, $plan);
|
||||
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);
|
||||
}
|
||||
});
|
||||
$matchDateRange = self::matchDateRange($message, $plan);
|
||||
$preferSchedule = self::preferScheduleMatchContext($message, $plan);
|
||||
|
||||
$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();
|
||||
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 [];
|
||||
}
|
||||
@@ -134,13 +181,116 @@ class AiAssistantContextService
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
private static function resolveCryptoContext(string $message): array
|
||||
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);
|
||||
$symbols = self::detectCryptoSymbols($message, $plan);
|
||||
if (empty($symbols)) {
|
||||
return [];
|
||||
}
|
||||
@@ -188,8 +338,19 @@ class AiAssistantContextService
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function detectCryptoSymbols(string $message): array
|
||||
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) {
|
||||
@@ -284,11 +445,29 @@ class AiAssistantContextService
|
||||
|
||||
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);
|
||||
$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
|
||||
@@ -299,4 +478,145 @@ class AiAssistantContextService
|
||||
}
|
||||
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'] ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user