deploy: auto commit server changes 2026-06-12 14:57:05

This commit is contained in:
hajimi
2026-06-12 14:57:05 +08:00
parent dabde9abaf
commit de2a36107a
95 changed files with 1039 additions and 137 deletions
@@ -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'] ?? [],
];
}
}