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) . '...';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiChatMessage;
|
||||
use app\common\model\ai\AiChatSession;
|
||||
use app\common\model\ai\AiConfig;
|
||||
use app\common\model\ai\AiLog;
|
||||
use think\facade\Cache;
|
||||
|
||||
class AiAssistantService
|
||||
{
|
||||
public const AI_LOG_TYPE_ASSISTANT = 7;
|
||||
private const QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode';
|
||||
private const QWEN_MODEL = 'qwen3.7-plus';
|
||||
|
||||
public static function chat(string $message, int $userId, string $clientId, int $sessionId = 0, string $ip = ''): array
|
||||
{
|
||||
$message = trim(mb_substr($message, 0, 1000));
|
||||
if ($message === '') {
|
||||
return ['success' => false, 'error' => '请输入要咨询的问题'];
|
||||
}
|
||||
if ($userId <= 0 && $clientId === '') {
|
||||
return ['success' => false, 'error' => '缺少会话标识'];
|
||||
}
|
||||
if (!self::checkRateLimit($userId, $clientId, $ip)) {
|
||||
return ['success' => false, 'error' => '提问太频繁,请稍后再试'];
|
||||
}
|
||||
|
||||
$session = self::resolveSession($sessionId, $userId, $clientId, $message);
|
||||
if (!$session) {
|
||||
return ['success' => false, 'error' => '会话不存在或无权访问'];
|
||||
}
|
||||
|
||||
$history = self::historyMessages((int) $session->id);
|
||||
$context = AiAssistantContextService::build($message, $userId, (int) $session->id);
|
||||
|
||||
AiChatMessage::create([
|
||||
'session_id' => (int) $session->id,
|
||||
'user_id' => $userId,
|
||||
'client_id' => $clientId,
|
||||
'role' => AiChatMessage::ROLE_USER,
|
||||
'content' => $message,
|
||||
'sources_json' => '[]',
|
||||
'status' => AiChatMessage::STATUS_SUCCESS,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
$messages = self::buildModelMessages($history, $message, $context);
|
||||
$result = self::callAssistantModel($messages);
|
||||
|
||||
$status = !empty($result['success']) ? AiChatMessage::STATUS_SUCCESS : AiChatMessage::STATUS_FAILED;
|
||||
$answer = !empty($result['success'])
|
||||
? trim((string) ($result['content'] ?? ''))
|
||||
: 'AI助手暂时没有生成成功,请稍后再试。';
|
||||
if ($answer === '') {
|
||||
$answer = '我暂时没有找到足够的站内依据,请换个关键词再问一次。';
|
||||
}
|
||||
|
||||
$assistantMessage = AiChatMessage::create([
|
||||
'session_id' => (int) $session->id,
|
||||
'user_id' => $userId,
|
||||
'client_id' => $clientId,
|
||||
'role' => AiChatMessage::ROLE_ASSISTANT,
|
||||
'content' => $answer,
|
||||
'sources_json' => json_encode($context['sources'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'model' => (string) ($result['model'] ?? 'gpt-5.5'),
|
||||
'tokens_used' => (int) ($result['tokens'] ?? 0),
|
||||
'cost_ms' => (int) ($result['cost_ms'] ?? 0),
|
||||
'status' => $status,
|
||||
'error_msg' => mb_substr((string) ($result['error'] ?? ''), 0, 500),
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
self::touchSession((int) $session->id, $message, $answer);
|
||||
self::recordAiLog($userId, (int) $session->id, $result, $status);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return ['success' => false, 'error' => 'AI助手暂时不可用,请稍后再试'];
|
||||
}
|
||||
|
||||
$session = AiChatSession::findOrEmpty((int) $session->id);
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'session' => self::formatSession($session->toArray()),
|
||||
'message' => self::formatMessage($assistantMessage->toArray()),
|
||||
'sources' => $context['sources'] ?? [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function sessions(int $userId, string $clientId): array
|
||||
{
|
||||
if ($userId <= 0 && $clientId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = AiChatSession::order('last_active_time desc')->order('id desc')->limit(30);
|
||||
self::applyOwnerWhere($query, $userId, $clientId);
|
||||
return array_map([self::class, 'formatSession'], $query->select()->toArray());
|
||||
}
|
||||
|
||||
public static function messages(int $sessionId, int $userId, string $clientId): array
|
||||
{
|
||||
$session = self::getOwnedSession($sessionId, $userId, $clientId);
|
||||
if (!$session) {
|
||||
return ['success' => false, 'error' => '会话不存在或无权访问'];
|
||||
}
|
||||
|
||||
$messages = AiChatMessage::where('session_id', $sessionId)
|
||||
->order('id', 'asc')
|
||||
->limit(200)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'session' => self::formatSession($session->toArray()),
|
||||
'messages' => array_map([self::class, 'formatMessage'], $messages),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function clear(int $sessionId, int $userId, string $clientId): array
|
||||
{
|
||||
if ($sessionId > 0) {
|
||||
$session = self::getOwnedSession($sessionId, $userId, $clientId);
|
||||
if (!$session) {
|
||||
return ['success' => false, 'error' => '会话不存在或无权访问'];
|
||||
}
|
||||
AiChatMessage::where('session_id', $sessionId)->delete();
|
||||
$session->delete();
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
if ($userId <= 0 && $clientId === '') {
|
||||
return ['success' => false, 'error' => '缺少会话标识'];
|
||||
}
|
||||
|
||||
$query = AiChatSession::field('id');
|
||||
self::applyOwnerWhere($query, $userId, $clientId);
|
||||
$ids = $query->column('id');
|
||||
if (!empty($ids)) {
|
||||
AiChatMessage::whereIn('session_id', $ids)->delete();
|
||||
AiChatSession::whereIn('id', $ids)->delete();
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
}
|
||||
|
||||
private static function resolveSession(int $sessionId, int $userId, string $clientId, string $message): ?AiChatSession
|
||||
{
|
||||
if ($sessionId > 0) {
|
||||
return self::getOwnedSession($sessionId, $userId, $clientId);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
return AiChatSession::create([
|
||||
'user_id' => $userId,
|
||||
'client_id' => $clientId,
|
||||
'title' => self::makeTitle($message),
|
||||
'last_message' => $message,
|
||||
'last_active_time' => $now,
|
||||
'message_count' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function getOwnedSession(int $sessionId, int $userId, string $clientId): ?AiChatSession
|
||||
{
|
||||
$query = AiChatSession::where('id', $sessionId);
|
||||
self::applyOwnerWhere($query, $userId, $clientId);
|
||||
$session = $query->findOrEmpty();
|
||||
return $session->isEmpty() ? null : $session;
|
||||
}
|
||||
|
||||
private static function applyOwnerWhere($query, int $userId, string $clientId): void
|
||||
{
|
||||
if ($userId > 0) {
|
||||
$query->where('user_id', $userId);
|
||||
return;
|
||||
}
|
||||
$query->where('user_id', 0)->where('client_id', $clientId);
|
||||
}
|
||||
|
||||
private static function historyMessages(int $sessionId): array
|
||||
{
|
||||
$limit = max(2, min(20, (int) AiConfig::getVal('assistant_history_limit', '8')));
|
||||
$rows = AiChatMessage::where('session_id', $sessionId)
|
||||
->where('status', AiChatMessage::STATUS_SUCCESS)
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
return array_reverse($rows);
|
||||
}
|
||||
|
||||
private static function buildModelMessages(array $history, string $message, array $context): array
|
||||
{
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => AiConfig::getVal('assistant_system_prompt', self::defaultSystemPrompt()),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($history as $row) {
|
||||
$role = $row['role'] === AiChatMessage::ROLE_ASSISTANT ? 'assistant' : 'user';
|
||||
$content = trim((string) ($row['content'] ?? ''));
|
||||
if ($content !== '') {
|
||||
$messages[] = ['role' => $role, 'content' => mb_substr($content, 0, 1000)];
|
||||
}
|
||||
}
|
||||
|
||||
$messages[] = [
|
||||
'role' => 'user',
|
||||
'content' => "用户问题:\n{$message}\n\n站内资料与实时上下文:\n"
|
||||
. ($context['context_text'] ?? '{}')
|
||||
. "\n\n请按照“直接结论、依据摘要、相关内容、风险提示”的结构回答。"
|
||||
. "如果站内资料不足,请明确说明,不要编造。彩票、赛事预测、加密行情必须提示不构成投资或购彩建议。",
|
||||
];
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
private static function callAssistantModel(array $messages): array
|
||||
{
|
||||
$client = new DeepSeekClient();
|
||||
$optionsList = [];
|
||||
|
||||
$qwenOptions = self::qwenOptions();
|
||||
if (!empty($qwenOptions['api_key'])) {
|
||||
$optionsList[] = $qwenOptions;
|
||||
}
|
||||
$optionsList[] = self::defaultModelOptions();
|
||||
|
||||
$failedErrors = [];
|
||||
$lastResult = ['success' => false, 'error' => 'AI助手模型服务暂时不可用', 'tokens' => 0];
|
||||
foreach ($optionsList as $options) {
|
||||
$result = $client->chatMessages($messages, $options);
|
||||
if (!empty($result['success'])) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$lastResult = $result;
|
||||
$provider = (string) ($result['provider_label'] ?? ($options['provider_label'] ?? 'AI模型'));
|
||||
$failedErrors[] = $provider . ': ' . (string) ($result['error'] ?? '调用失败');
|
||||
}
|
||||
|
||||
if (!empty($failedErrors)) {
|
||||
$lastResult['error'] = implode(' | ', $failedErrors);
|
||||
}
|
||||
return $lastResult;
|
||||
}
|
||||
|
||||
private static function qwenOptions(): array
|
||||
{
|
||||
$apiKey = trim((string) AiConfig::getVal('qwen_api_key', ''));
|
||||
if ($apiKey === '') {
|
||||
$apiKey = trim((string) env('DASHSCOPE_API_KEY', ''));
|
||||
}
|
||||
if ($apiKey === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) AiConfig::getVal('qwen_base_url', ''));
|
||||
if ($baseUrl === '') {
|
||||
$baseUrl = self::QWEN_BASE_URL;
|
||||
}
|
||||
$model = trim((string) AiConfig::getVal('qwen_model', ''));
|
||||
if ($model === '') {
|
||||
$model = self::QWEN_MODEL;
|
||||
}
|
||||
|
||||
return self::defaultModelOptions() + [
|
||||
'api_key' => $apiKey,
|
||||
'base_url' => $baseUrl,
|
||||
'model' => $model,
|
||||
'wire_api' => 'chat',
|
||||
'provider_label' => 'DashScope(Qwen)',
|
||||
];
|
||||
}
|
||||
|
||||
private static function defaultModelOptions(): array
|
||||
{
|
||||
return [
|
||||
'max_tokens' => 1600,
|
||||
'temperature' => 0.45,
|
||||
];
|
||||
}
|
||||
|
||||
private static function checkRateLimit(int $userId, string $clientId, string $ip): bool
|
||||
{
|
||||
$limit = max(1, (int) AiConfig::getVal('assistant_rate_limit_per_minute', '10'));
|
||||
$identity = $userId > 0 ? 'u' . $userId : 'c' . md5($clientId . '|' . $ip);
|
||||
$key = 'assistant_rate_' . $identity . '_' . date('YmdHi');
|
||||
$count = (int) Cache::get($key);
|
||||
if ($count >= $limit) {
|
||||
return false;
|
||||
}
|
||||
Cache::set($key, $count + 1, 70);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function touchSession(int $sessionId, string $question, string $answer): void
|
||||
{
|
||||
AiChatSession::where('id', $sessionId)->update([
|
||||
'last_message' => mb_substr($question, 0, 500),
|
||||
'last_answer' => mb_substr($answer, 0, 500),
|
||||
'last_active_time' => time(),
|
||||
'message_count' => AiChatMessage::where('session_id', $sessionId)->count(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function recordAiLog(int $userId, int $sessionId, array $result, int $status): void
|
||||
{
|
||||
AiLog::create([
|
||||
'user_id' => $userId,
|
||||
'type' => self::AI_LOG_TYPE_ASSISTANT,
|
||||
'ref_id' => $sessionId,
|
||||
'tokens_used' => (int) ($result['tokens'] ?? 0),
|
||||
'cost_ms' => (int) ($result['cost_ms'] ?? 0),
|
||||
'status' => $status === AiChatMessage::STATUS_SUCCESS ? 1 : 2,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
private static function formatSession(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => (string) ($row['title'] ?? ''),
|
||||
'last_message' => (string) ($row['last_message'] ?? ''),
|
||||
'last_answer' => (string) ($row['last_answer'] ?? ''),
|
||||
'message_count' => (int) ($row['message_count'] ?? 0),
|
||||
'last_active_time' => self::formatTime($row['last_active_time'] ?? 0),
|
||||
'create_time' => self::formatTime($row['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatMessage(array $row): array
|
||||
{
|
||||
$sources = json_decode((string) ($row['sources_json'] ?? '[]'), true);
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'session_id' => (int) ($row['session_id'] ?? 0),
|
||||
'role' => (string) ($row['role'] ?? ''),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'sources' => is_array($sources) ? $sources : [],
|
||||
'status' => (int) ($row['status'] ?? 1),
|
||||
'error_msg' => (string) ($row['error_msg'] ?? ''),
|
||||
'create_time' => self::formatTime($row['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function formatTime($value): string
|
||||
{
|
||||
if (is_numeric($value) && (int) $value > 0) {
|
||||
return date('Y-m-d H:i:s', (int) $value);
|
||||
}
|
||||
return (string) ($value ?: '');
|
||||
}
|
||||
|
||||
private static function makeTitle(string $message): string
|
||||
{
|
||||
$title = trim(preg_replace('/\s+/u', ' ', $message) ?: '');
|
||||
return mb_substr($title ?: '新的对话', 0, 24);
|
||||
}
|
||||
|
||||
private static function defaultSystemPrompt(): string
|
||||
{
|
||||
return '你是世博头条 AI 助手,面向普通用户回答站内体育资讯、赛事、社区、彩票和加密行情相关问题。'
|
||||
. '必须优先依据提供的站内资料和实时上下文回答;资料不足时要直接说明不足并建议用户换关键词。'
|
||||
. '不要声称可以访问后台、源码、服务器或未提供的内部数据。'
|
||||
. '回答使用中文,结构清晰,语气专业克制。涉及赛事预测、彩票或加密行情时,必须说明仅供参考,不构成投资或购彩建议。';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiAnalysis;
|
||||
use app\common\model\ai\AiConfig;
|
||||
use app\common\model\ai\AiLog;
|
||||
use app\common\model\lottery\LotteryAiAnalysis;
|
||||
use app\common\service\FileService;
|
||||
use app\common\service\MTranServerService;
|
||||
|
||||
class AiService
|
||||
{
|
||||
|
||||
|
||||
// 按当前业务要求固定走 GPT 中转,不再依赖后台模型配置。
|
||||
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
|
||||
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
|
||||
private const GPT_PROXY_MODEL = 'gpt-5.5';
|
||||
|
||||
/**
|
||||
* 赛事预测(完整,保留兼容)
|
||||
*/
|
||||
public static function predictMatch(int $matchId, array $matchData, int $userId = 0): array
|
||||
{
|
||||
// 检查缓存
|
||||
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_MATCH_PREDICT, $matchId);
|
||||
if ($cache) {
|
||||
$cacheData = json_decode($cache['result'], true);
|
||||
if (is_array($cacheData) && self::isKbEnhancedMatchPayload($cacheData)) {
|
||||
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
|
||||
}
|
||||
}
|
||||
|
||||
$result = KbRagService::analyzeMatch($matchId, $matchData, $userId);
|
||||
|
||||
self::saveResult(AiAnalysis::TYPE_MATCH_PREDICT, $matchId, '', $result, $userId);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$data = $result['parsed'] ?? self::buildFallbackMatch($matchData);
|
||||
return ['success' => true, 'data' => $data, 'from_cache' => false];
|
||||
}
|
||||
|
||||
// ── 分段赛事预测 ──
|
||||
|
||||
protected static array $sectionPrompts = [
|
||||
'prediction' => '你是专业体育赛事分析师。请基于数据预测胜负和比分。
|
||||
要求输出严格JSON:
|
||||
{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"简述理由"},"analysis":{"home_strength":78,"away_strength":72}}',
|
||||
'factors' => '你是专业体育赛事分析师。请分析影响比赛的关键因素和风险。
|
||||
要求输出严格JSON:
|
||||
{"key_factors":["利好因素1","利好因素2","利好因素3"],"risk_factors":["风险1","风险2"]}',
|
||||
'odds' => '你是专业体育赛事分析师。请分析赔率并给出投注建议。
|
||||
要求输出严格JSON:
|
||||
{"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"赔率分析说明"}}',
|
||||
'summary' => '你是专业体育赛事分析师。请给出简洁的分析总结。
|
||||
要求输出严格JSON:
|
||||
{"summary":"完整的分析总结,200字左右"}',
|
||||
];
|
||||
|
||||
public static function predictMatchSection(int $matchId, string $section, array $matchData, int $userId = 0): array
|
||||
{
|
||||
$validSections = ['prediction', 'factors', 'odds', 'summary'];
|
||||
if (!in_array($section, $validSections)) {
|
||||
return ['success' => false, 'error' => '无效的分析模块'];
|
||||
}
|
||||
|
||||
$cacheKey = AiAnalysis::TYPE_MATCH_PREDICT * 100 + array_search($section, $validSections);
|
||||
$cache = AiAnalysis::getCache($cacheKey, $matchId);
|
||||
if ($cache) {
|
||||
$cacheData = json_decode($cache['result'], true);
|
||||
if (is_array($cacheData) && self::isKbEnhancedMatchPayload($cacheData)) {
|
||||
return ['success' => true, 'data' => $cacheData, 'from_cache' => true, 'section' => $section];
|
||||
}
|
||||
}
|
||||
|
||||
$result = KbRagService::analyzeMatchSection($matchId, $section, $matchData, $userId);
|
||||
|
||||
self::saveResult($cacheKey, $matchId, '', $result, $userId, 6);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error'], 'section' => $section];
|
||||
}
|
||||
|
||||
$data = $result['parsed'] ?? [];
|
||||
return ['success' => true, 'data' => $data, 'from_cache' => false, 'section' => $section];
|
||||
}
|
||||
|
||||
/**
|
||||
* 资讯AI分析
|
||||
*/
|
||||
public static function analyzeArticle(int $articleId, array $articleData, int $userId = 0): array
|
||||
{
|
||||
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_ARTICLE_ANALYSIS, $articleId);
|
||||
if ($cache) {
|
||||
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
|
||||
}
|
||||
|
||||
$result = KbRagService::analyzeArticle($articleId, $articleData, $userId);
|
||||
|
||||
$cacheHours = (int) AiConfig::getVal('article_analysis_cache_hours', '24');
|
||||
self::saveResult(AiAnalysis::TYPE_ARTICLE_ANALYSIS, $articleId, '', $result, $userId, $cacheHours);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$data = $result['parsed'] ?? self::buildFallbackArticle($articleData);
|
||||
return ['success' => true, 'data' => $data, 'from_cache' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子AI分析
|
||||
*/
|
||||
public static function analyzePost(int $postId, array $postData, int $userId = 0, bool $isLotteryPost = false, bool $force = false): array
|
||||
{
|
||||
$cache = $force ? null : AiAnalysis::getCache(AiAnalysis::TYPE_POST_ANALYSIS, $postId);
|
||||
if ($cache) {
|
||||
$cacheData = json_decode($cache['result'], true);
|
||||
if (is_array($cacheData) && self::isValidPostAnalysisCache($cacheData, $isLotteryPost)) {
|
||||
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
|
||||
}
|
||||
}
|
||||
|
||||
$result = KbRagService::analyzePost($postId, $postData, $userId, $isLotteryPost);
|
||||
self::saveResult(AiAnalysis::TYPE_POST_ANALYSIS, $postId, '', $result, $userId, 12);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error'] ?? 'AI分析失败'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => $result['parsed'] ?? self::buildFallbackPost($postData),
|
||||
'from_cache' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户可信度分析
|
||||
*/
|
||||
public static function analyzeCredibility(int $targetUserId, array $userData, int $requestUserId = 0): array
|
||||
{
|
||||
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_USER_CREDIBILITY, $targetUserId);
|
||||
if ($cache) {
|
||||
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
|
||||
}
|
||||
|
||||
$defaultPrompt = '你是体育社区内容审核AI。请根据用户的历史数据评估其可信度。
|
||||
|
||||
要求输出严格JSON格式:
|
||||
{"credibility_score":75,"risk_level":"low/medium/high","is_suspicious":false,"analysis":"分析说明,100字左右","dimension_scores":{"hit_rate":80,"stability":85,"rationality":65,"activity":70},"suggestions":["建议1","建议2"]}';
|
||||
$systemPrompt = AiConfig::getVal('credibility_analysis_prompt', $defaultPrompt);
|
||||
|
||||
$userMsg = "请分析以下用户数据并评估可信度:\n" . json_encode($userData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
$client = new DeepSeekClient();
|
||||
$result = $client->chatJson($systemPrompt, $userMsg);
|
||||
|
||||
self::saveResult(AiAnalysis::TYPE_USER_CREDIBILITY, $targetUserId, '', $result, $requestUserId, 24);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$data = $result['parsed'] ?? self::buildFallbackCredibility();
|
||||
return ['success' => true, 'data' => $data, 'from_cache' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* 彩票概率分析
|
||||
*/
|
||||
public static function analyzeLottery(array $lotteryData, int $userId = 0): array
|
||||
{
|
||||
$refId = crc32(json_encode($lotteryData));
|
||||
|
||||
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId);
|
||||
if ($cache) {
|
||||
return ['success' => true, 'data' => json_decode($cache['result'], true), 'from_cache' => true];
|
||||
}
|
||||
|
||||
$result = KbRagService::analyzeLotteryOverview($lotteryData, $userId);
|
||||
|
||||
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
|
||||
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$data = $result['parsed'] ?? ['disclaimer' => '分析暂时不可用,请稍后重试'];
|
||||
return ['success' => true, 'data' => $data, 'from_cache' => false];
|
||||
}
|
||||
|
||||
public static function translateCommunityPost(string $content): array
|
||||
{
|
||||
if ($content === '') {
|
||||
return ['success' => false, 'error' => '帖子内容为空'];
|
||||
}
|
||||
|
||||
return MTranServerService::translate($content, 'zh-Hans', 'auto');
|
||||
}
|
||||
|
||||
/**
|
||||
* 资讯翻译
|
||||
*/
|
||||
public static function translateArticle(string $title, string $abstract, string $content): array
|
||||
{
|
||||
$title = trim($title);
|
||||
$abstract = trim($abstract);
|
||||
$content = trim($content);
|
||||
|
||||
if ($title === '' && $abstract === '' && $content === '') {
|
||||
return ['success' => false, 'error' => '文章内容为空'];
|
||||
}
|
||||
|
||||
if (mb_strlen($content) > 5000) {
|
||||
$content = mb_substr($content, 0, 5000) . '...';
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
if ($title !== '') {
|
||||
$translatedTitle = MTranServerService::translate($title, 'zh-Hans', 'auto');
|
||||
if (!$translatedTitle['success']) {
|
||||
return $translatedTitle;
|
||||
}
|
||||
$parts[] = '标题:' . trim((string) $translatedTitle['content']);
|
||||
}
|
||||
|
||||
if ($abstract !== '') {
|
||||
$translatedAbstract = MTranServerService::translate($abstract, 'zh-Hans', 'auto');
|
||||
if (!$translatedAbstract['success']) {
|
||||
return $translatedAbstract;
|
||||
}
|
||||
$parts[] = '摘要:' . trim((string) $translatedAbstract['content']);
|
||||
}
|
||||
|
||||
if ($content !== '') {
|
||||
$translatedContentLines = [];
|
||||
foreach (self::splitTextForTranslation($content) as $contentLine) {
|
||||
$translatedContent = MTranServerService::translate($contentLine, 'zh-Hans', 'auto');
|
||||
if (!$translatedContent['success']) {
|
||||
return $translatedContent;
|
||||
}
|
||||
$translatedContentLines[] = trim((string) $translatedContent['content']);
|
||||
}
|
||||
$translatedContentLines = array_values(array_filter($translatedContentLines, static fn($line) => $line !== ''));
|
||||
if (!empty($translatedContentLines)) {
|
||||
$firstLine = array_shift($translatedContentLines);
|
||||
$parts[] = '正文:' . $firstLine;
|
||||
foreach ($translatedContentLines as $line) {
|
||||
$parts[] = $line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$finalText = trim(implode("\n", $parts));
|
||||
if ($finalText === '') {
|
||||
return ['success' => false, 'error' => '翻译结果为空'];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $finalText,
|
||||
'tokens' => 0,
|
||||
'cost_ms' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
protected static function splitTextForTranslation(string $text): array
|
||||
{
|
||||
$normalized = trim(preg_replace('/\r\n|\r/u', "\n", $text));
|
||||
if ($normalized === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$manualLines = array_values(array_filter(array_map(static function ($line) {
|
||||
$line = preg_replace('/\s+/u', ' ', trim($line));
|
||||
return $line === '' ? '' : $line;
|
||||
}, explode("\n", $normalized))));
|
||||
|
||||
if (count($manualLines) > 1) {
|
||||
$segments = [];
|
||||
foreach ($manualLines as $line) {
|
||||
$segments = array_merge($segments, self::splitTextForTranslation($line));
|
||||
}
|
||||
return $segments;
|
||||
}
|
||||
|
||||
$flatText = preg_replace('/\s+/u', ' ', $normalized);
|
||||
preg_match_all('/[^。!?.!?]+[。!?.!?]?/u', $flatText, $matches);
|
||||
$segments = array_values(array_filter(array_map('trim', $matches[0] ?? [])));
|
||||
|
||||
return !empty($segments) ? $segments : [$flatText];
|
||||
}
|
||||
|
||||
/**
|
||||
* 资讯评论生成
|
||||
*/
|
||||
public static function generateArticleComment(string $title, string $abstract, string $content, string $persona = ''): array
|
||||
{
|
||||
$title = trim($title);
|
||||
$abstract = trim($abstract);
|
||||
$content = trim($content);
|
||||
|
||||
if ($title === '' && $abstract === '' && $content === '') {
|
||||
return ['success' => false, 'error' => '文章内容为空'];
|
||||
}
|
||||
|
||||
if (mb_strlen($content) > 3500) {
|
||||
$content = mb_substr($content, 0, 3500) . '...';
|
||||
}
|
||||
|
||||
$systemPrompt = AiConfig::getVal(
|
||||
'article_comment_prompt',
|
||||
'你是一名专业、克制、真实的体育资讯评论员。请根据给定资讯内容生成1条中文评论,要求自然、具体、像真实用户发言,评论长度控制在20到60字之间。不要输出标题、前缀、解释、表情、标签、项目符号,不要提及自己是AI,不要复述全文。'
|
||||
);
|
||||
|
||||
$personaText = $persona !== '' ? "评论视角:{$persona}\n" : '';
|
||||
$userMsg = $personaText . "标题:{$title}\n摘要:{$abstract}\n正文:{$content}\n\n请输出1条适合发布在评论区的中文评论。";
|
||||
|
||||
$client = new DeepSeekClient();
|
||||
return $client->chat($systemPrompt, $userMsg, ['max_tokens' => 200, 'temperature' => 0.75]);
|
||||
}
|
||||
|
||||
public static function analyzeLotteryPostImages(string $content, array $images): array
|
||||
{
|
||||
if (empty($images)) {
|
||||
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
|
||||
}
|
||||
|
||||
$imageUrls = array_values(array_filter(array_map(function ($image) {
|
||||
if (!$image) {
|
||||
return '';
|
||||
}
|
||||
return self::prepareGptImageUrl(FileService::getFileUrl($image));
|
||||
}, $images)));
|
||||
|
||||
if (empty($imageUrls)) {
|
||||
return ['success' => false, 'error' => '帖子图片为空,无法分析'];
|
||||
}
|
||||
|
||||
$systemPrompt = AiConfig::getVal(
|
||||
'community_lottery_image_analysis_prompt',
|
||||
'你是彩票图片内容分析助手。只允许依据当前帖子图片和帖子文案中直接给出的当期信息进行分析。'
|
||||
. '不要引用往期内容、历史期数、知识库信息或你自己的记忆。'
|
||||
. '如果图片里没有明确展示某项信息,就直接说明无法判断。'
|
||||
. '请先对图片内容做去噪过滤,只保留与当期分析直接相关的信息。'
|
||||
. '有效信息包括:期号、号码、波色、单双、大小、生肖、五行、推荐组合、胆码拖码、走势箭头、统计表、图表结论、明确的标题结论。'
|
||||
. '无关噪音包括:装饰背景、边框花纹、吉祥话、宣传口号、重复标题、水印、无关广告、联系方式、模板化祝福语、与分析无关的排版符号。'
|
||||
. '对于重复出现的同一信息,只保留一次并合并描述;对于模糊、遮挡、低置信度识别内容,不要强行分析,可直接标记为“无法可靠识别”。'
|
||||
. '请先完整识别图片中的号码、期号、版面文字、波色/单双/大小/生肖/五行、图表结论、胆码拖码、推荐组合或其他可见资料。'
|
||||
. '识别出来的每一项内容,都要单独给出对应分析描述,不能只做整体概括。'
|
||||
. '如果有多张图片,按图片1、图片2逐张拆开写;每张图片内再按“识别内容1/分析1、识别内容2/分析2”的方式一一对应展开。'
|
||||
. '输出格式尽量清晰,推荐使用:图片X、过滤后识别内容、逐项分析、综合结论、风险提示。'
|
||||
. '不要承诺中奖,不要编造图片中不存在的数据,最后给出理性购彩风险提示。'
|
||||
);
|
||||
$options = [
|
||||
'max_tokens' => 2000,
|
||||
'temperature' => 0.3,
|
||||
'model' => self::GPT_PROXY_MODEL,
|
||||
'base_url' => self::GPT_PROXY_BASE_URL,
|
||||
'api_key' => self::GPT_PROXY_API_KEY,
|
||||
'wire_api' => 'responses',
|
||||
'provider_label' => 'OpenAI(GPT中转)',
|
||||
];
|
||||
|
||||
$client = new DeepSeekClient();
|
||||
$userMessage = "帖子内容:\n" . ($content ?: '无文字内容') . "\n\n请只依据当前帖子图片和上面的帖子文案输出分析。先过滤掉杂乱和无关内容,再提取图片里的有效信息,最后对提取出的每一项内容一一分析描述,不要补充任何往期推断。";
|
||||
$result = $client->chatWithImages(
|
||||
$systemPrompt,
|
||||
$userMessage,
|
||||
$imageUrls,
|
||||
$options
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
protected static function prepareGptImageUrl(string $imageUrl): string
|
||||
{
|
||||
if (!preg_match('/^https?:\/\//i', $imageUrl)) {
|
||||
return $imageUrl;
|
||||
}
|
||||
|
||||
$dataUrl = self::downloadImageAsDataUrl($imageUrl);
|
||||
return $dataUrl !== '' ? $dataUrl : $imageUrl;
|
||||
}
|
||||
|
||||
protected static function downloadImageAsDataUrl(string $imageUrl): string
|
||||
{
|
||||
$ch = curl_init($imageUrl);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 10,
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 SportEraBot/1.0',
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$contentType = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || !is_string($body) || $body === '' || strlen($body) > 8 * 1024 * 1024) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$mime = strtolower(trim(explode(';', $contentType)[0] ?? ''));
|
||||
if ($mime === '' || !str_starts_with($mime, 'image/')) {
|
||||
$path = parse_url($imageUrl, PHP_URL_PATH) ?: '';
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$mime = match ($ext) {
|
||||
'png' => 'image/png',
|
||||
'webp' => 'image/webp',
|
||||
'gif' => 'image/gif',
|
||||
default => 'image/jpeg',
|
||||
};
|
||||
}
|
||||
|
||||
return 'data:' . $mime . ';base64,' . base64_encode($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存分析结果
|
||||
*/
|
||||
protected static function saveResult(int $type, int $refId, string $prompt, array $result, int $userId, int $cacheHours = 6): void
|
||||
{
|
||||
$status = $result['success'] ? AiAnalysis::STATUS_SUCCESS : AiAnalysis::STATUS_FAILED;
|
||||
$resultJson = $result['success'] ? json_encode($result['parsed'] ?? $result['content'], JSON_UNESCAPED_UNICODE) : '';
|
||||
|
||||
AiAnalysis::create([
|
||||
'type' => $type,
|
||||
'ref_id' => $refId,
|
||||
'prompt' => mb_substr($prompt, 0, 5000),
|
||||
'result' => $resultJson,
|
||||
'model' => (string) ($result['model'] ?? self::GPT_PROXY_MODEL),
|
||||
'tokens_used' => $result['tokens'] ?? 0,
|
||||
'status' => $status,
|
||||
'error_msg' => $result['error'] ?? '',
|
||||
'expire_time' => time() + $cacheHours * 3600,
|
||||
]);
|
||||
|
||||
AiLog::create([
|
||||
'user_id' => $userId,
|
||||
'type' => $type,
|
||||
'ref_id' => $refId,
|
||||
'tokens_used' => $result['tokens'] ?? 0,
|
||||
'cost_ms' => $result['cost_ms'] ?? 0,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 彩票分模块分析(热冷号/趋势/统计)
|
||||
*/
|
||||
public static function analyzeLotteryModule(array $lotteryData, string $module, int $userId = 0): array
|
||||
{
|
||||
$latestIssue = $lotteryData['recent_draws'][0]['issue'] ?? '';
|
||||
$cacheKey = $lotteryData['lottery_type'] . ':' . $module . ':' . $latestIssue;
|
||||
$refId = crc32($cacheKey);
|
||||
|
||||
$cache = AiAnalysis::getCache(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId);
|
||||
if ($cache) {
|
||||
$cacheData = json_decode($cache['result'], true);
|
||||
if (is_array($cacheData) && !empty($cacheData)) {
|
||||
return ['success' => true, 'data' => $cacheData, 'from_cache' => true];
|
||||
}
|
||||
}
|
||||
|
||||
$result = KbRagService::analyzeLotteryModule($lotteryData, $module, $userId);
|
||||
|
||||
if (!$result['success']) {
|
||||
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
$data = $result['parsed'] ?? null;
|
||||
if (!is_array($data) || empty($data)) {
|
||||
$result['success'] = false;
|
||||
$result['error'] = 'AI返回格式异常';
|
||||
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
self::saveResult(AiAnalysis::TYPE_LOTTERY_ANALYSIS, $refId, '', $result, $userId, 12);
|
||||
|
||||
// 持久化到彩票AI分析历史表
|
||||
if ($latestIssue) {
|
||||
$history = LotteryAiAnalysis::create([
|
||||
'code' => $lotteryData['lottery_type'],
|
||||
'issue' => $latestIssue,
|
||||
'module' => $module,
|
||||
'result' => json_encode($data, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
KbSyncService::enqueue('lottery', 'ai_history', (int) $history->id, 'upsert', 90);
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $data, 'from_cache' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* 赛事预测降级数据
|
||||
*/
|
||||
protected static function buildFallbackMatch(array $matchData): array
|
||||
{
|
||||
return [
|
||||
'prediction' => [
|
||||
'result' => '待分析',
|
||||
'confidence' => 50,
|
||||
'score_predict' => '-',
|
||||
'reasoning' => 'AI分析暂时不可用,请稍后重试',
|
||||
],
|
||||
'analysis' => [
|
||||
'home_strength' => 50,
|
||||
'away_strength' => 50,
|
||||
'key_factors' => ['数据加载中'],
|
||||
'risk_factors' => ['AI服务响应异常'],
|
||||
],
|
||||
'odds_opinion' => ['value_bet' => '无', 'explanation' => '暂无数据'],
|
||||
'summary' => '当前AI分析服务暂时不可用,请稍后重试。',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 资讯分析降级数据
|
||||
*/
|
||||
protected static function buildFallbackArticle(array $articleData): array
|
||||
{
|
||||
return [
|
||||
'overall_score' => 60,
|
||||
'summary' => $articleData['desc'] ?? '暂无摘要',
|
||||
'leagues_and_teams' => ['leagues' => [], 'teams' => [], 'match_context' => ''],
|
||||
'upcoming_matches' => [],
|
||||
'head_to_head' => ['available' => false, 'records' => [], 'summary' => ''],
|
||||
'betting_analysis' => [
|
||||
'prediction' => '谨慎观望',
|
||||
'confidence' => 50,
|
||||
'reasoning' => 'AI分析暂时不可用,请稍后重试。',
|
||||
'risk_level' => 'medium',
|
||||
'key_factors' => [],
|
||||
'value_bet' => '无明显价值',
|
||||
],
|
||||
'key_points' => ['详细分析正在生成中'],
|
||||
'risk_warnings' => [],
|
||||
'opposite_views' => [],
|
||||
'sentiment' => ['label' => 'neutral', 'confidence' => 50],
|
||||
'tags' => [],
|
||||
'evidence_list' => [],
|
||||
'similar_history' => [],
|
||||
'from_kb' => false,
|
||||
'retrieval_meta' => ['hit_count' => 0, 'domains' => [], 'top_scores' => []],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function buildFallbackPost(array $postData): array
|
||||
{
|
||||
return [
|
||||
'overall_score' => 60,
|
||||
'summary' => self::truncatePlainText((string) ($postData['content'] ?? '当前帖子信息不足,暂时无法给出稳定结论。'), 120),
|
||||
'credibility_signals' => [],
|
||||
'risk_warning' => ['AI分析暂时不可用,请稍后重试。'],
|
||||
'evidence_list' => [],
|
||||
'similar_history' => [],
|
||||
'from_kb' => false,
|
||||
'retrieval_meta' => ['hit_count' => 0, 'domains' => [], 'top_scores' => []],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 可信度分析降级数据
|
||||
*/
|
||||
protected static function buildFallbackCredibility(): array
|
||||
{
|
||||
return [
|
||||
'credibility_score' => 50,
|
||||
'risk_level' => 'medium',
|
||||
'is_suspicious' => false,
|
||||
'analysis' => '可信度分析服务暂时不可用',
|
||||
'dimension_scores' => ['hit_rate' => 50, 'stability' => 50, 'rationality' => 50, 'activity' => 50],
|
||||
'suggestions' => [],
|
||||
];
|
||||
}
|
||||
|
||||
protected static function truncatePlainText(string $text, int $length): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
|
||||
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
|
||||
}
|
||||
|
||||
protected static function isKbEnhancedMatchPayload(array $payload): bool
|
||||
{
|
||||
return isset($payload['retrieval_meta']) || isset($payload['evidence_list']) || isset($payload['from_kb']);
|
||||
}
|
||||
|
||||
protected static function isValidPostAnalysisCache(array $payload, bool $isLotteryPost): bool
|
||||
{
|
||||
if (empty($payload)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$isLotteryPost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::isKbEnhancedMatchPayload($payload)
|
||||
&& isset($payload['next_prediction'])
|
||||
&& is_array($payload['next_prediction'])
|
||||
&& ($payload['analysis_source'] ?? '') === 'image_free_v1';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiConfig;
|
||||
|
||||
class DeepSeekClient
|
||||
{
|
||||
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
|
||||
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
|
||||
private const GPT_PROXY_MODEL = 'gpt-5.5';
|
||||
private const GPT_PROXY_WIRE_API = 'responses';
|
||||
|
||||
protected string $apiKey;
|
||||
protected string $baseUrl;
|
||||
protected string $model;
|
||||
protected string $providerLabel;
|
||||
protected string $wireApi;
|
||||
protected int $maxTokens;
|
||||
protected float $temperature;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = self::GPT_PROXY_API_KEY;
|
||||
$this->baseUrl = self::GPT_PROXY_BASE_URL;
|
||||
$this->model = self::GPT_PROXY_MODEL;
|
||||
$this->wireApi = self::GPT_PROXY_WIRE_API;
|
||||
$this->providerLabel = 'OpenAI(GPT中转)';
|
||||
$this->maxTokens = (int) AiConfig::getVal('max_tokens', '2000');
|
||||
$this->temperature = (float) AiConfig::getVal('temperature', '0.7');
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天请求
|
||||
*/
|
||||
public function chat(string $systemPrompt, string $userMessage, array $options = []): array
|
||||
{
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userMessage],
|
||||
];
|
||||
|
||||
return $this->request($messages, $options);
|
||||
}
|
||||
|
||||
public function chatMessages(array $messages, array $options = []): array
|
||||
{
|
||||
if (empty($messages)) {
|
||||
return ['success' => false, 'error' => 'messages不能为空', 'tokens' => 0];
|
||||
}
|
||||
|
||||
return $this->request($messages, $options);
|
||||
}
|
||||
|
||||
public function chatWithImages(string $systemPrompt, string $userMessage, array $imageUrls, array $options = []): array
|
||||
{
|
||||
$content = [
|
||||
['type' => 'text', 'text' => $userMessage],
|
||||
];
|
||||
|
||||
foreach ($imageUrls as $imageUrl) {
|
||||
if ($imageUrl === '') {
|
||||
continue;
|
||||
}
|
||||
$content[] = [
|
||||
'type' => 'image_url',
|
||||
'image_url' => ['url' => $imageUrl],
|
||||
];
|
||||
}
|
||||
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $content],
|
||||
];
|
||||
|
||||
return $this->request($messages, $options);
|
||||
}
|
||||
|
||||
protected function request(array $messages, array $options = []): array
|
||||
{
|
||||
$apiKey = $options['api_key'] ?? $this->apiKey;
|
||||
$providerLabel = $options['provider_label'] ?? $this->providerLabel;
|
||||
if (empty($apiKey)) {
|
||||
return ['success' => false, 'error' => $providerLabel . ' API Key未配置', 'tokens' => 0, 'model' => $options['model'] ?? $this->model, 'provider_label' => $providerLabel];
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
$baseUrl = rtrim($options['base_url'] ?? $this->baseUrl, '/');
|
||||
$wireApi = strtolower((string) ($options['wire_api'] ?? $this->wireApi));
|
||||
$payload = $wireApi === 'responses'
|
||||
? $this->buildResponsesPayload($messages, $options)
|
||||
: $this->buildChatPayload($messages, $options);
|
||||
$endpoint = $wireApi === 'responses'
|
||||
? $this->buildResponsesEndpoint($baseUrl)
|
||||
: $this->buildChatEndpoint($baseUrl);
|
||||
$timeout = $this->resolveTimeout($options['timeout'] ?? null);
|
||||
|
||||
$ch = curl_init($endpoint);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => min(15, $timeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$costMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
|
||||
if ($error) {
|
||||
return ['success' => false, 'error' => 'cURL错误: ' . $error, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
$content = $wireApi === 'responses' ? $this->extractResponsesContent($data) : ($data['choices'][0]['message']['content'] ?? '');
|
||||
if ($httpCode !== 200 || $content === '') {
|
||||
$errMsg = $data['error']['message'] ?? ('HTTP ' . $httpCode);
|
||||
return ['success' => false, 'error' => $providerLabel . ' 返回异常: ' . $errMsg, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
||||
}
|
||||
|
||||
$tokens = $data['usage']['total_tokens'] ?? (($data['usage']['input_tokens'] ?? 0) + ($data['usage']['output_tokens'] ?? 0));
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $content,
|
||||
'tokens' => $tokens,
|
||||
'cost_ms' => $costMs,
|
||||
'model' => $payload['model'],
|
||||
'provider_label' => $providerLabel,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildChatPayload(array $messages, array $options): array
|
||||
{
|
||||
return [
|
||||
'model' => $options['model'] ?? $this->model,
|
||||
'messages' => $messages,
|
||||
'max_tokens' => $options['max_tokens'] ?? $this->maxTokens,
|
||||
'temperature' => $options['temperature'] ?? $this->temperature,
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildResponsesPayload(array $messages, array $options): array
|
||||
{
|
||||
$instructions = [];
|
||||
$input = [];
|
||||
foreach ($messages as $message) {
|
||||
$role = (string) ($message['role'] ?? 'user');
|
||||
$content = $message['content'] ?? '';
|
||||
if ($role === 'system') {
|
||||
$text = $this->contentToText($content);
|
||||
if ($text !== '') {
|
||||
$instructions[] = $text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$input[] = [
|
||||
'role' => $role === 'assistant' ? 'assistant' : 'user',
|
||||
'content' => $this->convertResponsesContent($content),
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $options['model'] ?? $this->model,
|
||||
'input' => $input,
|
||||
'max_output_tokens' => $options['max_tokens'] ?? $this->maxTokens,
|
||||
'temperature' => $options['temperature'] ?? $this->temperature,
|
||||
];
|
||||
if (!empty($instructions)) {
|
||||
$payload['instructions'] = implode("\n\n", $instructions);
|
||||
}
|
||||
return $payload;
|
||||
}
|
||||
|
||||
protected function convertResponsesContent($content): array
|
||||
{
|
||||
if (is_string($content)) {
|
||||
return [['type' => 'input_text', 'text' => $content]];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ((array) $content as $item) {
|
||||
$type = (string) ($item['type'] ?? '');
|
||||
if ($type === 'text') {
|
||||
$rows[] = ['type' => 'input_text', 'text' => (string) ($item['text'] ?? '')];
|
||||
} elseif ($type === 'image_url') {
|
||||
$imageUrl = $item['image_url']['url'] ?? '';
|
||||
if ($imageUrl !== '') {
|
||||
$rows[] = ['type' => 'input_image', 'image_url' => (string) $imageUrl];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rows ?: [['type' => 'input_text', 'text' => '']];
|
||||
}
|
||||
|
||||
protected function contentToText($content): string
|
||||
{
|
||||
if (is_string($content)) {
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
$texts = [];
|
||||
foreach ((array) $content as $item) {
|
||||
if (($item['type'] ?? '') === 'text') {
|
||||
$texts[] = (string) ($item['text'] ?? '');
|
||||
}
|
||||
}
|
||||
return trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
protected function extractResponsesContent(array $data): string
|
||||
{
|
||||
if (!empty($data['output_text'])) {
|
||||
return (string) $data['output_text'];
|
||||
}
|
||||
|
||||
$texts = [];
|
||||
foreach (($data['output'] ?? []) as $output) {
|
||||
foreach (($output['content'] ?? []) as $content) {
|
||||
if (isset($content['text'])) {
|
||||
$texts[] = (string) $content['text'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return trim(implode("\n", $texts));
|
||||
}
|
||||
|
||||
protected function buildChatEndpoint(string $baseUrl): string
|
||||
{
|
||||
if (str_ends_with($baseUrl, '/chat/completions')) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
if (str_ends_with($baseUrl, '/v1')) {
|
||||
return $baseUrl . '/chat/completions';
|
||||
}
|
||||
|
||||
return $baseUrl . '/v1/chat/completions';
|
||||
}
|
||||
|
||||
protected function buildResponsesEndpoint(string $baseUrl): string
|
||||
{
|
||||
if (str_ends_with($baseUrl, '/responses')) {
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
if (str_ends_with($baseUrl, '/v1')) {
|
||||
return $baseUrl . '/responses';
|
||||
}
|
||||
|
||||
return $baseUrl . '/v1/responses';
|
||||
}
|
||||
|
||||
protected function resolveTimeout($timeout): int
|
||||
{
|
||||
$value = (int) ($timeout ?: AiConfig::getVal('ai_request_timeout', '180'));
|
||||
return max(30, min($value, 300));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送请求并解析JSON结果
|
||||
*/
|
||||
public function chatJson(string $systemPrompt, string $userMessage, array $options = []): array
|
||||
{
|
||||
$result = $this->chat($systemPrompt, $userMessage, $options);
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$content = $result['content'];
|
||||
// 尝试提取JSON块
|
||||
if (preg_match('/```json\s*(.*?)\s*```/s', $content, $m)) {
|
||||
$content = $m[1];
|
||||
} elseif (preg_match('/\{.*\}/s', $content, $m)) {
|
||||
$content = $m[0];
|
||||
}
|
||||
|
||||
$parsed = json_decode($content, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$result['parsed'] = null;
|
||||
$result['raw_content'] = $result['content'];
|
||||
} else {
|
||||
$result['parsed'] = $parsed;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiConfig;
|
||||
|
||||
class EmbeddingClient
|
||||
{
|
||||
private const DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode';
|
||||
private const DASHSCOPE_EMBEDDING_MODEL = 'text-embedding-v4';
|
||||
|
||||
protected string $apiKey;
|
||||
protected string $baseUrl;
|
||||
protected string $model;
|
||||
protected int $defaultDim;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = (string) (AiConfig::getVal('embedding_api_key', '') ?: env('DASHSCOPE_API_KEY', ''));
|
||||
$this->baseUrl = (string) (AiConfig::getVal('embedding_base_url', '') ?: self::DASHSCOPE_BASE_URL);
|
||||
$this->model = (string) (AiConfig::getVal('embedding_model', '') ?: self::DASHSCOPE_EMBEDDING_MODEL);
|
||||
$this->defaultDim = (int) (AiConfig::getVal('embedding_dim', '') ?: '1024');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array<int, string> $input
|
||||
*/
|
||||
public function embed($input, array $options = []): array
|
||||
{
|
||||
$apiKey = $options['api_key'] ?? $this->apiKey;
|
||||
$baseUrl = rtrim($options['base_url'] ?? $this->baseUrl, '/');
|
||||
$model = $options['model'] ?? $this->model;
|
||||
|
||||
if ($apiKey === '' || $baseUrl === '' || $model === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Embedding API配置未完成',
|
||||
'embeddings' => [],
|
||||
'cost_ms' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'input' => $input,
|
||||
];
|
||||
|
||||
if (!empty($options['dimensions'])) {
|
||||
$payload['dimensions'] = (int) $options['dimensions'];
|
||||
} elseif ($this->defaultDim > 0) {
|
||||
$payload['dimensions'] = $this->defaultDim;
|
||||
}
|
||||
|
||||
$startTime = microtime(true);
|
||||
$ch = curl_init($baseUrl . '/v1/embeddings');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$costMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||
if ($error) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Embedding cURL错误: ' . $error,
|
||||
'embeddings' => [],
|
||||
'cost_ms' => $costMs,
|
||||
];
|
||||
}
|
||||
|
||||
$data = json_decode((string) $response, true);
|
||||
if ($httpCode !== 200 || empty($data['data']) || !is_array($data['data'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $data['error']['message'] ?? ('HTTP ' . $httpCode),
|
||||
'embeddings' => [],
|
||||
'cost_ms' => $costMs,
|
||||
];
|
||||
}
|
||||
|
||||
$embeddings = [];
|
||||
foreach ($data['data'] as $row) {
|
||||
$embedding = $row['embedding'] ?? [];
|
||||
if (is_array($embedding) && !empty($embedding)) {
|
||||
$embeddings[] = array_map('floatval', $embedding);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => !empty($embeddings),
|
||||
'error' => !empty($embeddings) ? '' : 'Embedding结果为空',
|
||||
'embeddings' => $embeddings,
|
||||
'model' => $model,
|
||||
'dimension' => count($embeddings[0] ?? []),
|
||||
'tokens' => (int) ($data['usage']['total_tokens'] ?? 0),
|
||||
'cost_ms' => $costMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiConfig;
|
||||
|
||||
class KbRagService
|
||||
{
|
||||
private const GPT_PROXY_API_KEY = 'sk-fbbad0b884aa6bd16da234723c1f1a4a9e7cc97c23d1ab8137a00dc4d9fa0ec2';
|
||||
private const GPT_PROXY_BASE_URL = 'https://sub2.congmingai.com';
|
||||
private const GPT_PROXY_MODEL = 'gpt-5.5';
|
||||
|
||||
public static function analyzeMatch(int $matchId, array $matchData, int $userId = 0): array
|
||||
{
|
||||
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_analysis');
|
||||
$systemPrompt = AiConfig::getVal('match_predict_prompt', self::defaultMatchPrompt());
|
||||
$userMessage = self::buildMatchUserMessage($matchData, $retrieval, true);
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
|
||||
return self::normalizeMatchResult($parsed, $retrieval);
|
||||
}, ['max_tokens' => 1400]);
|
||||
}
|
||||
|
||||
public static function analyzeMatchSection(int $matchId, string $section, array $matchData, int $userId = 0): array
|
||||
{
|
||||
$validSections = ['prediction', 'factors', 'odds', 'summary'];
|
||||
if (!in_array($section, $validSections, true)) {
|
||||
return ['success' => false, 'error' => '无效的分析模块'];
|
||||
}
|
||||
|
||||
$retrieval = self::retrieveMatchEvidence($matchId, $matchData, $userId, 'match_' . $section);
|
||||
$systemPrompt = AiConfig::getVal('match_predict_' . $section . '_prompt', self::matchSectionPrompt($section));
|
||||
$userMessage = self::buildMatchUserMessage($matchData, $retrieval, false, $section);
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($section, $retrieval) {
|
||||
return self::normalizeMatchSectionResult($section, $parsed, $retrieval);
|
||||
}, ['max_tokens' => 1000]);
|
||||
}
|
||||
|
||||
public static function analyzeArticle(int $articleId, array $articleData, int $userId = 0): array
|
||||
{
|
||||
$retrieval = KbService::retrieve(
|
||||
'article_analysis',
|
||||
self::buildArticleQuery($articleData),
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ref_id' => $articleId,
|
||||
'domains' => ['article', 'post'],
|
||||
'preferred_domain' => 'article',
|
||||
'cid' => (int) ($articleData['cid'] ?? 0),
|
||||
'exclude_source' => ['domain' => 'article', 'source_id' => $articleId],
|
||||
],
|
||||
[
|
||||
'title' => (string) ($articleData['title'] ?? ''),
|
||||
'summary' => (string) ($articleData['abstract'] ?? $articleData['desc'] ?? ''),
|
||||
],
|
||||
self::intConfig('kb_final_topk', 8)
|
||||
);
|
||||
|
||||
$systemPrompt = AiConfig::getVal('article_analysis_prompt', self::defaultArticlePrompt());
|
||||
$userMessage = "当前文章:\n" . json_encode([
|
||||
'id' => $articleId,
|
||||
'title' => $articleData['title'] ?? '',
|
||||
'abstract' => $articleData['abstract'] ?? '',
|
||||
'author' => $articleData['author'] ?? '',
|
||||
'category' => $articleData['category'] ?? '',
|
||||
'content' => self::clipText(strip_tags((string) ($articleData['content'] ?? '')), 2500),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n知识库证据:\n"
|
||||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n请严格依据当前文章和知识库证据输出JSON。";
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
|
||||
return self::normalizeArticleResult($parsed, $retrieval);
|
||||
});
|
||||
}
|
||||
|
||||
public static function analyzePost(int $postId, array $postData, int $userId = 0, bool $isLotteryPost = false): array
|
||||
{
|
||||
$tags = $postData['tags'] ?? [];
|
||||
if ($isLotteryPost) {
|
||||
$retrieval = self::emptyRetrieval();
|
||||
$systemPrompt = self::lotteryPostImagePrompt();
|
||||
$userMessage = "当前六合彩帖子与图片识别内容:\n" . json_encode([
|
||||
'id' => $postId,
|
||||
'content' => self::clipText((string) ($postData['content'] ?? ''), 1800),
|
||||
'tags' => $tags,
|
||||
'lottery_key' => (string) (($postData['ext']['lottery_key'] ?? '') ?: ''),
|
||||
'lottery_analysis_content' => self::clipText((string) ($postData['lottery_analysis_content'] ?? ''), 2200),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n请只依据当前帖子图片识别内容和帖子文案自由解读,不需要知识库证据,也不要输出“知识库证据不足”。";
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval, $postData) {
|
||||
return self::normalizePostResult($parsed, $retrieval, $postData);
|
||||
}, self::gptPostOptions());
|
||||
}
|
||||
|
||||
$domains = ['post', 'article'];
|
||||
$retrieval = KbService::retrieve(
|
||||
'post_analysis',
|
||||
self::buildPostQuery($postData),
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ref_id' => $postId,
|
||||
'domains' => $domains,
|
||||
'preferred_domain' => 'post',
|
||||
'tags' => is_array($tags) ? $tags : [],
|
||||
'exclude_source' => ['domain' => 'post', 'source_id' => $postId],
|
||||
],
|
||||
[
|
||||
'title' => (string) ($postData['content'] ?? ''),
|
||||
],
|
||||
self::intConfig('kb_final_topk', 8)
|
||||
);
|
||||
|
||||
$systemPrompt = AiConfig::getVal('post_analysis_prompt', self::defaultPostPrompt());
|
||||
$userMessage = "当前帖子:\n" . json_encode([
|
||||
'id' => $postId,
|
||||
'content' => self::clipText((string) ($postData['content'] ?? ''), 1800),
|
||||
'tags' => $tags,
|
||||
'post_type' => $postData['post_type'] ?? 0,
|
||||
'is_paid' => $postData['is_paid'] ?? 0,
|
||||
'lottery_key' => (string) (($postData['ext']['lottery_key'] ?? '') ?: ''),
|
||||
'lottery_analysis_content' => self::clipText((string) ($postData['lottery_analysis_content'] ?? ''), 1000),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n知识库证据:\n"
|
||||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n请输出JSON,不允许编造知识库里不存在的历史事实。";
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval, $postData) {
|
||||
return self::normalizePostResult($parsed, $retrieval, $postData);
|
||||
}, self::gptPostOptions());
|
||||
}
|
||||
|
||||
public static function analyzeLotteryOverview(array $lotteryData, int $userId = 0): array
|
||||
{
|
||||
$code = (string) ($lotteryData['lottery_type'] ?? '');
|
||||
$latestIssue = (string) ($lotteryData['recent_draws'][0]['issue'] ?? '');
|
||||
$retrieval = KbService::retrieve(
|
||||
'lottery_analysis',
|
||||
self::buildLotteryQuery($lotteryData),
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ref_id' => 0,
|
||||
'domains' => ['lottery', 'post'],
|
||||
'preferred_domain' => 'lottery',
|
||||
'code' => $code,
|
||||
'issue' => $latestIssue,
|
||||
],
|
||||
[
|
||||
'title' => (string) ($lotteryData['lottery_name'] ?? $code),
|
||||
],
|
||||
self::intConfig('kb_final_topk', 8)
|
||||
);
|
||||
|
||||
$systemPrompt = AiConfig::getVal('lottery_analysis_prompt', self::defaultLotteryOverviewPrompt());
|
||||
$userMessage = "当前彩种结构化数据:\n" . json_encode($lotteryData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n知识库证据:\n"
|
||||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n请结合历史知识库证据和当前结构化数据输出JSON。";
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($retrieval) {
|
||||
return self::normalizeLotteryOverview($parsed, $retrieval);
|
||||
}, ['max_tokens' => 1800]);
|
||||
}
|
||||
|
||||
public static function analyzeLotteryModule(array $lotteryData, string $module, int $userId = 0): array
|
||||
{
|
||||
$code = (string) ($lotteryData['lottery_type'] ?? '');
|
||||
$latestIssue = (string) ($lotteryData['recent_draws'][0]['issue'] ?? '');
|
||||
$retrieval = KbService::retrieve(
|
||||
'lottery_' . $module,
|
||||
self::buildLotteryQuery($lotteryData),
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ref_id' => 0,
|
||||
'domains' => ['lottery', 'post'],
|
||||
'preferred_domain' => 'lottery',
|
||||
'code' => $code,
|
||||
'issue' => $latestIssue,
|
||||
],
|
||||
[
|
||||
'title' => (string) ($lotteryData['lottery_name'] ?? $code),
|
||||
],
|
||||
self::intConfig('kb_final_topk', 8)
|
||||
);
|
||||
|
||||
$systemPrompt = self::modulePrompt($module);
|
||||
$userMessage = "当前彩种结构化数据:\n" . json_encode($lotteryData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n知识库证据:\n"
|
||||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n请结合结构化数据和知识库证据输出JSON。";
|
||||
|
||||
return self::runJsonPrompt($systemPrompt, $userMessage, static function (array $parsed) use ($module, $retrieval) {
|
||||
return self::normalizeLotteryModule($module, $parsed, $retrieval);
|
||||
}, ['max_tokens' => 1200]);
|
||||
}
|
||||
|
||||
private static function runJsonPrompt(string $systemPrompt, string $userMessage, callable $normalizer, array $options = []): array
|
||||
{
|
||||
$client = new DeepSeekClient();
|
||||
$result = $client->chatJson($systemPrompt, $userMessage, $options);
|
||||
if (!$result['success']) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$parsed = is_array($result['parsed'] ?? null) ? $result['parsed'] : [];
|
||||
$normalized = $normalizer($parsed);
|
||||
$result['parsed'] = $normalized;
|
||||
$result['content'] = json_encode($normalized, JSON_UNESCAPED_UNICODE);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function gptPostOptions(): array
|
||||
{
|
||||
return [
|
||||
'model' => self::GPT_PROXY_MODEL,
|
||||
'base_url' => self::GPT_PROXY_BASE_URL,
|
||||
'api_key' => self::GPT_PROXY_API_KEY,
|
||||
'wire_api' => 'responses',
|
||||
'provider_label' => 'OpenAI(GPT中转)',
|
||||
'max_tokens' => 1200,
|
||||
'temperature' => 0.35,
|
||||
];
|
||||
}
|
||||
|
||||
private static function compactHits(array $hits): array
|
||||
{
|
||||
return array_map(static function (array $hit) {
|
||||
return [
|
||||
'domain' => $hit['domain'] ?? '',
|
||||
'subtype' => $hit['subtype'] ?? '',
|
||||
'source_id' => $hit['source_id'] ?? 0,
|
||||
'title' => $hit['title'] ?? '',
|
||||
'summary' => $hit['summary'] ?? '',
|
||||
'content' => self::clipText((string) ($hit['chunk_text'] ?? ''), 260),
|
||||
'score' => $hit['score'] ?? 0,
|
||||
'metadata' => $hit['metadata'] ?? [],
|
||||
];
|
||||
}, $hits);
|
||||
}
|
||||
|
||||
private static function normalizeArticleResult(array $parsed, array $retrieval): array
|
||||
{
|
||||
$bettingAnalysis = $parsed['betting_analysis'] ?? [
|
||||
'prediction' => '谨慎观望',
|
||||
'confidence' => 55,
|
||||
'reasoning' => '当前证据不足,建议谨慎观望。',
|
||||
'risk_level' => 'medium',
|
||||
'key_factors' => [],
|
||||
'value_bet' => '无明显价值',
|
||||
];
|
||||
$bettingAnalysis['risk_level'] = self::normalizeRiskLevel((string) ($bettingAnalysis['risk_level'] ?? 'medium'));
|
||||
|
||||
return [
|
||||
'overall_score' => (int) ($parsed['overall_score'] ?? 68),
|
||||
'summary' => (string) ($parsed['summary'] ?? '暂无明确结论,请结合原文谨慎判断。'),
|
||||
'leagues_and_teams' => $parsed['leagues_and_teams'] ?? ['leagues' => [], 'teams' => [], 'match_context' => ''],
|
||||
'upcoming_matches' => $parsed['upcoming_matches'] ?? [],
|
||||
'head_to_head' => $parsed['head_to_head'] ?? ['available' => false, 'records' => [], 'summary' => ''],
|
||||
'betting_analysis' => $bettingAnalysis,
|
||||
'risk_warnings' => $parsed['risk_warnings'] ?? ['以上内容仅供参考,请理性判断。'],
|
||||
'key_points' => $parsed['key_points'] ?? [],
|
||||
'opposite_views' => $parsed['opposite_views'] ?? [],
|
||||
'sentiment' => $parsed['sentiment'] ?? ['label' => 'neutral', 'confidence' => 55],
|
||||
'tags' => $parsed['tags'] ?? [],
|
||||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||||
'from_kb' => !empty($retrieval['hits']),
|
||||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizePostResult(array $parsed, array $retrieval, array $postData): array
|
||||
{
|
||||
$result = [
|
||||
'overall_score' => (int) ($parsed['overall_score'] ?? 66),
|
||||
'summary' => (string) ($parsed['summary'] ?? '当前帖子可参考信息有限,请谨慎判断。'),
|
||||
'credibility_signals' => $parsed['credibility_signals'] ?? [],
|
||||
'risk_warning' => $parsed['risk_warning'] ?? ['历史样本有限,不宜过度放大单条帖子观点。'],
|
||||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||||
'from_kb' => !empty($retrieval['hits']),
|
||||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||||
];
|
||||
|
||||
if (self::isLiuhePost($postData, $retrieval['hits'] ?? [])) {
|
||||
$result['summary'] = self::normalizeLiuheImageText(
|
||||
$result['summary'],
|
||||
'本次分析仅依据当前图片和帖子文案进行自由解读,可重点关注图片中出现的诗句、生肖、波色、尾数、胆码和旺弱号码等线索;结果仅供内容解读。'
|
||||
);
|
||||
$result['credibility_signals'] = self::normalizeLiuheImageList(
|
||||
$result['credibility_signals'],
|
||||
['当前分析基于图片识别出的可见文字、号码和图表线索。']
|
||||
);
|
||||
$result['risk_warning'] = self::normalizeLiuheImageList(
|
||||
$result['risk_warning'],
|
||||
['彩票结果具有随机性,图片解读仅供参考,不构成投注建议。']
|
||||
);
|
||||
$result['next_prediction'] = self::normalizeLiuhePrediction($parsed['next_prediction'] ?? []);
|
||||
$result['next_prediction']['reasons'] = self::normalizeLiuheImageList(
|
||||
$result['next_prediction']['reasons'],
|
||||
['推荐仅来自当前图片中的诗句、胆码、波色、尾数或生肖等线索。']
|
||||
);
|
||||
$result['image_interpretation'] = self::normalizeStringList($parsed['image_interpretation'] ?? []);
|
||||
$result['poem_analysis'] = (string) ($parsed['poem_analysis'] ?? '');
|
||||
$result['analysis_source'] = (string) ($parsed['analysis_source'] ?? 'image_free_v1');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function emptyRetrieval(): array
|
||||
{
|
||||
return [
|
||||
'hits' => [],
|
||||
'hit_count' => 0,
|
||||
'domains' => [],
|
||||
'top_scores' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeLotteryOverview(array $parsed, array $retrieval): array
|
||||
{
|
||||
return [
|
||||
'overall_score' => (int) ($parsed['overall_score'] ?? 62),
|
||||
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于历史样本的统计参考。'),
|
||||
'hot_numbers' => $parsed['hot_numbers'] ?? [],
|
||||
'cold_numbers' => $parsed['cold_numbers'] ?? [],
|
||||
'trend_analysis' => $parsed['trend_analysis'] ?? '',
|
||||
'recommended_combinations' => $parsed['recommended_combinations'] ?? [],
|
||||
'statistics' => $parsed['statistics'] ?? [],
|
||||
'trend_direction' => $parsed['trend_direction'] ?? '震荡',
|
||||
'risk_level' => $parsed['risk_level'] ?? '中',
|
||||
'key_evidence' => $parsed['key_evidence'] ?? [],
|
||||
'similar_issues' => $parsed['similar_issues'] ?? self::similarHistory($retrieval['hits'] ?? []),
|
||||
'disclaimer' => $parsed['disclaimer'] ?? '本分析仅基于历史数据与知识库证据,不保证准确性,请理性参考。',
|
||||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||||
'from_kb' => !empty($retrieval['hits']),
|
||||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeLotteryModule(string $module, array $parsed, array $retrieval): array
|
||||
{
|
||||
$base = [
|
||||
'overall_score' => (int) ($parsed['overall_score'] ?? 60),
|
||||
'risk_warning' => $parsed['risk_warning'] ?? ['本结果只反映历史统计规律,不保证未来表现。'],
|
||||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||||
'from_kb' => !empty($retrieval['hits']),
|
||||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||||
];
|
||||
|
||||
return match ($module) {
|
||||
'hot_cold' => array_merge($base, [
|
||||
'hot_numbers' => $parsed['hot_numbers'] ?? [],
|
||||
'cold_numbers' => $parsed['cold_numbers'] ?? [],
|
||||
'hot_analysis' => $parsed['hot_analysis'] ?? '',
|
||||
'cold_analysis' => $parsed['cold_analysis'] ?? '',
|
||||
]),
|
||||
'trend' => array_merge($base, [
|
||||
'trend_analysis' => $parsed['trend_analysis'] ?? '',
|
||||
'recommended_combinations' => $parsed['recommended_combinations'] ?? [],
|
||||
'disclaimer' => $parsed['disclaimer'] ?? '趋势分析仅供参考。',
|
||||
'trend_direction' => $parsed['trend_direction'] ?? '震荡',
|
||||
]),
|
||||
'stats' => array_merge($base, [
|
||||
'odd_even_ratio' => $parsed['odd_even_ratio'] ?? '',
|
||||
'big_small_ratio' => $parsed['big_small_ratio'] ?? '',
|
||||
'sum_range' => $parsed['sum_range'] ?? '',
|
||||
'consecutive_analysis' => $parsed['consecutive_analysis'] ?? '',
|
||||
'span_analysis' => $parsed['span_analysis'] ?? '',
|
||||
'frequency_table' => $parsed['frequency_table'] ?? [],
|
||||
'summary' => $parsed['summary'] ?? '',
|
||||
]),
|
||||
'color_zodiac' => array_merge($base, [
|
||||
'color_analysis' => $parsed['color_analysis'] ?? [],
|
||||
'zodiac_analysis' => $parsed['zodiac_analysis'] ?? [],
|
||||
'recommended' => $parsed['recommended'] ?? [],
|
||||
]),
|
||||
default => array_merge($base, $parsed),
|
||||
};
|
||||
}
|
||||
|
||||
private static function retrievalMeta(array $retrieval): array
|
||||
{
|
||||
return [
|
||||
'hit_count' => (int) ($retrieval['hit_count'] ?? 0),
|
||||
'domains' => $retrieval['domains'] ?? [],
|
||||
'top_scores' => $retrieval['top_scores'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
private static function similarHistory(array $hits): array
|
||||
{
|
||||
$rows = [];
|
||||
foreach ($hits as $hit) {
|
||||
$metadata = $hit['metadata'] ?? [];
|
||||
$rows[] = [
|
||||
'domain' => $hit['domain'] ?? '',
|
||||
'source_id' => $hit['source_id'] ?? 0,
|
||||
'title' => $hit['title'] ?? '',
|
||||
'issue' => $metadata['issue'] ?? '',
|
||||
'code' => $metadata['code'] ?? '',
|
||||
'score' => $hit['score'] ?? 0,
|
||||
];
|
||||
}
|
||||
return array_slice($rows, 0, 5);
|
||||
}
|
||||
|
||||
private static function buildArticleQuery(array $articleData): string
|
||||
{
|
||||
return trim(implode(' ', array_filter([
|
||||
$articleData['title'] ?? '',
|
||||
$articleData['abstract'] ?? '',
|
||||
$articleData['category'] ?? '',
|
||||
$articleData['desc'] ?? '',
|
||||
])));
|
||||
}
|
||||
|
||||
private static function buildPostQuery(array $postData): string
|
||||
{
|
||||
$tags = is_array($postData['tags'] ?? null) ? implode(' ', $postData['tags']) : '';
|
||||
$lotteryKey = (string) (($postData['ext']['lottery_key'] ?? '') ?: '');
|
||||
return trim(implode(' ', array_filter([
|
||||
self::clipText((string) ($postData['content'] ?? ''), 300),
|
||||
$tags,
|
||||
$lotteryKey,
|
||||
])));
|
||||
}
|
||||
|
||||
private static function buildLotteryQuery(array $lotteryData): string
|
||||
{
|
||||
$recentIssues = array_column($lotteryData['recent_draws'] ?? [], 'issue');
|
||||
return trim(implode(' ', array_filter([
|
||||
$lotteryData['lottery_type'] ?? '',
|
||||
$lotteryData['lottery_name'] ?? '',
|
||||
implode(' ', array_slice($recentIssues, 0, 5)),
|
||||
])));
|
||||
}
|
||||
|
||||
private static function buildMatchQuery(array $matchData): string
|
||||
{
|
||||
$matchInfo = $matchData['match_info'] ?? [];
|
||||
$headToHead = $matchData['head_to_head'] ?? [];
|
||||
$keywords = [
|
||||
$matchInfo['home_team'] ?? '',
|
||||
$matchInfo['away_team'] ?? '',
|
||||
$matchInfo['league'] ?? '',
|
||||
];
|
||||
|
||||
foreach (array_slice($headToHead, 0, 3) as $row) {
|
||||
$keywords[] = $row['home_team'] ?? '';
|
||||
$keywords[] = $row['away_team'] ?? '';
|
||||
$keywords[] = $row['league_name'] ?? ($row['league'] ?? '');
|
||||
}
|
||||
|
||||
return trim(implode(' ', array_filter($keywords)));
|
||||
}
|
||||
|
||||
private static function retrieveMatchEvidence(int $matchId, array $matchData, int $userId, string $scene): array
|
||||
{
|
||||
$matchInfo = $matchData['match_info'] ?? [];
|
||||
return KbService::retrieve(
|
||||
$scene,
|
||||
self::buildMatchQuery($matchData),
|
||||
[
|
||||
'user_id' => $userId,
|
||||
'ref_id' => $matchId,
|
||||
'domains' => ['article', 'post'],
|
||||
'preferred_domain' => 'article',
|
||||
],
|
||||
[
|
||||
'title' => trim(($matchInfo['home_team'] ?? '') . ' vs ' . ($matchInfo['away_team'] ?? '')),
|
||||
'league' => (string) ($matchInfo['league'] ?? ''),
|
||||
'teams' => array_values(array_filter([
|
||||
$matchInfo['home_team'] ?? '',
|
||||
$matchInfo['away_team'] ?? '',
|
||||
])),
|
||||
],
|
||||
self::intConfig('kb_final_topk', 8)
|
||||
);
|
||||
}
|
||||
|
||||
private static function buildMatchUserMessage(
|
||||
array $matchData,
|
||||
array $retrieval,
|
||||
bool $full = false,
|
||||
string $section = ''
|
||||
): string {
|
||||
$label = $full ? '完整赛事分析' : ('赛事分段分析-' . $section);
|
||||
return $label . ":\n当前赛事结构化数据:\n"
|
||||
. json_encode([
|
||||
'match_info' => $matchData['match_info'] ?? [],
|
||||
'head_to_head' => self::compactMatchRows($matchData['head_to_head'] ?? [], 5),
|
||||
'home_recent' => self::compactMatchRows($matchData['home_recent'] ?? [], 5),
|
||||
'away_recent' => self::compactMatchRows($matchData['away_recent'] ?? [], 5),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n知识库证据(资讯/帖子):\n"
|
||||
. json_encode(self::compactHits($retrieval['hits'] ?? []), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
|
||||
. "\n\n请优先依据当前赛事结构化数据与知识库证据输出,不允许编造未命中的历史事实。";
|
||||
}
|
||||
|
||||
private static function compactMatchRows(array $rows, int $limit = 5): array
|
||||
{
|
||||
$compact = [];
|
||||
foreach (array_slice($rows, 0, $limit) as $row) {
|
||||
$compact[] = [
|
||||
'match_time' => $row['match_time'] ?? '',
|
||||
'league' => $row['league_name'] ?? ($row['league'] ?? ''),
|
||||
'home_team' => $row['home_team'] ?? ($row['team_name'] ?? ''),
|
||||
'away_team' => $row['away_team'] ?? ($row['opponent'] ?? ''),
|
||||
'score' => [
|
||||
'home' => $row['home_score'] ?? ($row['team_score'] ?? null),
|
||||
'away' => $row['away_score'] ?? ($row['opponent_score'] ?? null),
|
||||
],
|
||||
'result' => $row['result'] ?? '',
|
||||
];
|
||||
}
|
||||
return $compact;
|
||||
}
|
||||
|
||||
private static function clipText(string $text, int $length): string
|
||||
{
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?: '');
|
||||
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
|
||||
}
|
||||
|
||||
private static function intConfig(string $name, int $default): int
|
||||
{
|
||||
return (int) AiConfig::getVal($name, (string) $default);
|
||||
}
|
||||
|
||||
private static function defaultArticlePrompt(): string
|
||||
{
|
||||
return '你是一位体育资讯AI研报助手。请严格根据当前文章和知识库证据生成分析,不允许虚构未命中的历史事实。'
|
||||
. '必须输出JSON,字段包含:'
|
||||
. '{"overall_score":72,"summary":"...","leagues_and_teams":{"leagues":[],"teams":[],"match_context":""},"upcoming_matches":[],"head_to_head":{"available":false,"records":[],"summary":""},"betting_analysis":{"prediction":"谨慎观望","confidence":55,"reasoning":"...","risk_level":"medium","key_factors":[],"value_bet":"无明显价值"},"risk_warnings":["..."],"key_points":["..."],"opposite_views":["..."],"sentiment":{"label":"neutral","confidence":60},"tags":["..."],"key_evidence":["..."]}';
|
||||
}
|
||||
|
||||
private static function defaultMatchPrompt(): string
|
||||
{
|
||||
return '你是一位体育赛事AI分析师。请结合当前赛事结构化数据(交手记录、双方近期战绩、赔率)与知识库中召回的相关资讯/帖子,生成完整比赛分析。'
|
||||
. '若知识库证据不足,要明确说明证据不足。'
|
||||
. '必须输出JSON:{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"..."},"analysis":{"home_strength":78,"away_strength":72,"key_factors":["..."],"risk_factors":["..."]},"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"..."},"summary":"...","evidence_list":[],"risk_warning":["..."]}';
|
||||
}
|
||||
|
||||
private static function defaultPostPrompt(): string
|
||||
{
|
||||
return '你是一位社区内容分析助手。请根据当前帖子和知识库证据,判断帖子的核心观点、可信线索与风险。'
|
||||
. '如果知识库证据不足,必须明确说明证据不足。'
|
||||
. '如果帖子属于六合彩(例如旧澳六合/新澳六合)资料或开奖类帖子,请额外给出基于当前帖子内容与知识库历史样本的下一期预测,字段包含推荐号码、信心和依据。'
|
||||
. '输出JSON:{"overall_score":66,"summary":"...","credibility_signals":["..."],"risk_warning":["..."],"next_prediction":{"recommended_numbers":["..."],"confidence":68,"reasons":["..."]}}';
|
||||
}
|
||||
|
||||
private static function isLiuhePost(array $postData, array $hits): bool
|
||||
{
|
||||
$tags = is_array($postData['tags'] ?? null) ? $postData['tags'] : [];
|
||||
if (in_array('旧澳六合', $tags, true) || in_array('新澳六合', $tags, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ext = is_array($postData['ext'] ?? null) ? $postData['ext'] : [];
|
||||
$lotteryKey = (string) ($ext['lottery_key'] ?? '');
|
||||
if (in_array($lotteryKey, ['a6', 'xa6'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($hits as $hit) {
|
||||
$title = (string) ($hit['title'] ?? '');
|
||||
$metadata = $hit['metadata'] ?? [];
|
||||
$hitLotteryKey = (string) ($metadata['lottery_key'] ?? '');
|
||||
if (str_contains($title, '旧澳六合') || str_contains($title, '新澳六合') || in_array($hitLotteryKey, ['a6', 'xa6'], true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function normalizeLiuhePrediction(array $prediction): array
|
||||
{
|
||||
$numbers = self::normalizeStringList($prediction['recommended_numbers'] ?? []);
|
||||
$reasons = self::normalizeStringList($prediction['reasons'] ?? []);
|
||||
|
||||
return [
|
||||
'recommended_numbers' => $numbers,
|
||||
'confidence' => max(0, min(100, (int) ($prediction['confidence'] ?? 60))),
|
||||
'reasons' => $reasons,
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeStringList($items): array
|
||||
{
|
||||
if (is_string($items)) {
|
||||
$items = preg_split('/[\r\n;;]+/u', $items) ?: [];
|
||||
}
|
||||
if (!is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(static function ($item) {
|
||||
$text = trim((string) $item);
|
||||
return $text === '' ? null : $text;
|
||||
}, $items)));
|
||||
}
|
||||
|
||||
private static function normalizeLiuheImageText(string $text, string $fallback): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || str_contains($text, '知识库')) {
|
||||
return $fallback;
|
||||
}
|
||||
return $text;
|
||||
}
|
||||
|
||||
private static function normalizeLiuheImageList($items, array $fallback): array
|
||||
{
|
||||
$rows = array_values(array_filter(self::normalizeStringList($items), static function (string $item) {
|
||||
return !str_contains($item, '知识库');
|
||||
}));
|
||||
return $rows ?: $fallback;
|
||||
}
|
||||
|
||||
private static function lotteryPostImagePrompt(): string
|
||||
{
|
||||
return '你是一位六合彩图片资料解读助手。请只依据当前帖子文案与图片识别内容进行分析,不需要知识库证据。'
|
||||
. '如果识别内容里包含诗句、字谜、藏宝图、玄机图、生肖、波色、尾数、胆码、旺弱号码、推荐组合,请逐项自由解读它们可能指向的生肖或号码。'
|
||||
. '不要因为没有历史知识库证据而给出“证据不足”的主结论;可以说明彩票随机性和不保证命中,但主体要围绕图片内容展开。'
|
||||
. '不要承诺必中,不要编造图片中不存在的文字或号码。'
|
||||
. '输出JSON:{"overall_score":66,"summary":"基于图片内容的综合解读","image_interpretation":["逐项解读"],"poem_analysis":"诗句/字谜解读,没有则为空","credibility_signals":["来自图片的可信线索"],"risk_warning":["理性提示"],"next_prediction":{"recommended_numbers":["..."],"confidence":60,"reasons":["..."]},"analysis_source":"image_free_v1"}';
|
||||
}
|
||||
|
||||
private static function defaultLotteryOverviewPrompt(): string
|
||||
{
|
||||
return '你是一位彩票数据分析助手。请结合当前结构化开奖数据与知识库中的历史开奖、历史AI分析、相关帖子证据,输出兼容旧接口的JSON。'
|
||||
. '必须包含字段:{"overall_score":62,"summary":"...","hot_numbers":[],"cold_numbers":[],"trend_analysis":"...","recommended_combinations":[],"statistics":{},"trend_direction":"震荡","risk_level":"中","key_evidence":["..."],"similar_issues":[],"disclaimer":"..."}';
|
||||
}
|
||||
|
||||
private static function matchSectionPrompt(string $module): string
|
||||
{
|
||||
return match ($module) {
|
||||
'prediction' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,预测比赛结果。输出JSON:{"prediction":{"result":"主胜/平局/客胜","confidence":75,"score_predict":"2-1","reasoning":"..."},"analysis":{"home_strength":78,"away_strength":72},"evidence_list":[],"risk_warning":["..."]}',
|
||||
'factors' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,分析关键因素与风险。输出JSON:{"key_factors":["..."],"risk_factors":["..."],"evidence_list":[],"risk_warning":["..."]}',
|
||||
'odds' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,分析赔率与投注方向。输出JSON:{"odds_opinion":{"value_bet":"主胜/平局/客胜/无","explanation":"..."},"evidence_list":[],"risk_warning":["..."]}',
|
||||
'summary' => '你是一位体育赛事AI分析师。请结合当前赛事结构化数据和知识库资讯/帖子证据,给出简洁结论。输出JSON:{"summary":"...","evidence_list":[],"risk_warning":["..."]}',
|
||||
default => '请输出JSON。',
|
||||
};
|
||||
}
|
||||
|
||||
private static function modulePrompt(string $module): string
|
||||
{
|
||||
return match ($module) {
|
||||
'hot_cold' => '你是一位彩票热冷号分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"hot_numbers":[],"cold_numbers":[],"hot_analysis":"...","cold_analysis":"...","risk_warning":["..."]}',
|
||||
'trend' => '你是一位彩票趋势分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"trend_analysis":"...","recommended_combinations":[],"trend_direction":"震荡","disclaimer":"...","risk_warning":["..."]}',
|
||||
'stats' => '你是一位彩票统计分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"odd_even_ratio":"","big_small_ratio":"","sum_range":"","consecutive_analysis":"...","span_analysis":"...","frequency_table":[],"summary":"...","risk_warning":["..."]}',
|
||||
'color_zodiac' => '你是一位六合彩颜色生肖分析助手。请结合当前结构化开奖数据和知识库证据输出JSON:{"overall_score":60,"color_analysis":{},"zodiac_analysis":{},"recommended":{},"risk_warning":["..."]}',
|
||||
default => '请输出JSON。',
|
||||
};
|
||||
}
|
||||
|
||||
private static function normalizeRiskLevel(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
return match ($value) {
|
||||
'低', 'low' => 'low',
|
||||
'高', 'high' => 'high',
|
||||
default => 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
private static function normalizeMatchResult(array $parsed, array $retrieval): array
|
||||
{
|
||||
return [
|
||||
'prediction' => $parsed['prediction'] ?? [
|
||||
'result' => '谨慎观望',
|
||||
'confidence' => 55,
|
||||
'score_predict' => '-',
|
||||
'reasoning' => '当前证据不足,建议谨慎观望。',
|
||||
],
|
||||
'analysis' => array_merge([
|
||||
'home_strength' => 50,
|
||||
'away_strength' => 50,
|
||||
'key_factors' => [],
|
||||
'risk_factors' => [],
|
||||
], is_array($parsed['analysis'] ?? null) ? $parsed['analysis'] : []),
|
||||
'odds_opinion' => $parsed['odds_opinion'] ?? [
|
||||
'value_bet' => '无',
|
||||
'explanation' => '赔率证据不足,建议谨慎观望。',
|
||||
],
|
||||
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于结构化数据和有限知识库证据的保守结论。'),
|
||||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||||
'from_kb' => !empty($retrieval['hits']),
|
||||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||||
];
|
||||
}
|
||||
|
||||
private static function normalizeMatchSectionResult(string $section, array $parsed, array $retrieval): array
|
||||
{
|
||||
$base = [
|
||||
'evidence_list' => self::compactHits($retrieval['hits'] ?? []),
|
||||
'similar_history' => self::similarHistory($retrieval['hits'] ?? []),
|
||||
'from_kb' => !empty($retrieval['hits']),
|
||||
'retrieval_meta' => self::retrievalMeta($retrieval),
|
||||
'risk_warning' => $parsed['risk_warning'] ?? ['以上分析仅供参考,请结合比赛临场信息谨慎判断。'],
|
||||
];
|
||||
|
||||
return match ($section) {
|
||||
'prediction' => array_merge($base, [
|
||||
'prediction' => $parsed['prediction'] ?? [
|
||||
'result' => '谨慎观望',
|
||||
'confidence' => 55,
|
||||
'score_predict' => '-',
|
||||
'reasoning' => '当前证据不足,建议谨慎观望。',
|
||||
],
|
||||
'analysis' => array_merge([
|
||||
'home_strength' => 50,
|
||||
'away_strength' => 50,
|
||||
], is_array($parsed['analysis'] ?? null) ? $parsed['analysis'] : []),
|
||||
]),
|
||||
'factors' => array_merge($base, [
|
||||
'key_factors' => $parsed['key_factors'] ?? [],
|
||||
'risk_factors' => $parsed['risk_factors'] ?? [],
|
||||
]),
|
||||
'odds' => array_merge($base, [
|
||||
'odds_opinion' => $parsed['odds_opinion'] ?? [
|
||||
'value_bet' => '无',
|
||||
'explanation' => '赔率证据不足,建议谨慎观望。',
|
||||
],
|
||||
]),
|
||||
'summary' => array_merge($base, [
|
||||
'summary' => (string) ($parsed['summary'] ?? '当前仅能给出基于结构化数据和有限知识库证据的保守结论。'),
|
||||
]),
|
||||
default => array_merge($base, $parsed),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiConfig;
|
||||
use app\common\model\ai\AiKbChunk;
|
||||
use app\common\model\ai\AiKbDocument;
|
||||
use app\common\model\ai\AiKbQueryLog;
|
||||
use app\common\model\ai\AiLog;
|
||||
use app\common\model\article\Article;
|
||||
use app\common\model\article\ArticleCate;
|
||||
use app\common\model\community\CommunityPost;
|
||||
use app\common\model\community\CommunityTag;
|
||||
use app\common\model\lottery\LotteryAiAnalysis;
|
||||
use app\common\model\lottery\LotteryDrawResult;
|
||||
use app\common\model\lottery\LotteryGame;
|
||||
use app\common\model\match\MatchEvent;
|
||||
use app\common\model\match\MatchHistory;
|
||||
use app\common\model\match\MatchRecent;
|
||||
use think\facade\Db;
|
||||
|
||||
class KbService
|
||||
{
|
||||
public const COLLECTION_GLOBAL = 'global';
|
||||
|
||||
public static function upsertDocument(string $domain, string $subtype, int $sourceId): array
|
||||
{
|
||||
$transactionStarted = false;
|
||||
try {
|
||||
$payload = self::buildDocumentPayload($domain, $subtype, $sourceId);
|
||||
if (empty($payload)) {
|
||||
self::deleteDocument($domain, $subtype, $sourceId);
|
||||
return ['success' => false, 'error' => '源内容不存在或不可用'];
|
||||
}
|
||||
|
||||
$contentHash = md5(json_encode([
|
||||
$payload['title'],
|
||||
$payload['summary'],
|
||||
$payload['canonical_text'],
|
||||
$payload['keywords'],
|
||||
$payload['metadata_json'],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$document = AiKbDocument::where([
|
||||
'domain' => $domain,
|
||||
'subtype' => $subtype,
|
||||
'source_id' => $sourceId,
|
||||
])->findOrEmpty();
|
||||
|
||||
if (
|
||||
!$document->isEmpty()
|
||||
&& (string) $document->content_hash === $contentHash
|
||||
&& (int) $document->is_active === 1
|
||||
) {
|
||||
$document->save([
|
||||
'title' => $payload['title'],
|
||||
'summary' => $payload['summary'],
|
||||
'keywords' => $payload['keywords'],
|
||||
'metadata_json' => $payload['metadata_json'],
|
||||
'source_created_at' => $payload['source_created_at'],
|
||||
'source_updated_at' => $payload['source_updated_at'],
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'document_id' => (int) $document->id,
|
||||
'chunk_count' => (int) AiKbChunk::where('document_id', $document->id)->count(),
|
||||
'skipped' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$chunks = self::splitText(
|
||||
$payload['canonical_text'],
|
||||
self::intConfig('kb_chunk_size', 600),
|
||||
self::intConfig('kb_chunk_overlap', 80)
|
||||
);
|
||||
if (empty($chunks)) {
|
||||
$chunks = [$payload['summary'] ?: $payload['title'] ?: 'empty'];
|
||||
}
|
||||
|
||||
$embeddingResult = (new EmbeddingClient())->embed($chunks);
|
||||
if (!$embeddingResult['success']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $embeddingResult['error'] ?? 'embedding失败',
|
||||
];
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
$transactionStarted = true;
|
||||
if ($document->isEmpty()) {
|
||||
$document = AiKbDocument::create([
|
||||
'domain' => $domain,
|
||||
'subtype' => $subtype,
|
||||
'source_id' => $sourceId,
|
||||
'title' => $payload['title'],
|
||||
'summary' => $payload['summary'],
|
||||
'canonical_text' => $payload['canonical_text'],
|
||||
'keywords' => $payload['keywords'],
|
||||
'metadata_json' => $payload['metadata_json'],
|
||||
'source_created_at' => $payload['source_created_at'],
|
||||
'source_updated_at' => $payload['source_updated_at'],
|
||||
'content_hash' => $contentHash,
|
||||
'is_active' => 1,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
} else {
|
||||
$document->save([
|
||||
'title' => $payload['title'],
|
||||
'summary' => $payload['summary'],
|
||||
'canonical_text' => $payload['canonical_text'],
|
||||
'keywords' => $payload['keywords'],
|
||||
'metadata_json' => $payload['metadata_json'],
|
||||
'source_created_at' => $payload['source_created_at'],
|
||||
'source_updated_at' => $payload['source_updated_at'],
|
||||
'content_hash' => $contentHash,
|
||||
'is_active' => 1,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
AiKbChunk::where('document_id', $document->id)->delete();
|
||||
$dimension = (int) ($embeddingResult['dimension'] ?? 0);
|
||||
foreach ($chunks as $index => $chunkText) {
|
||||
$embedding = $embeddingResult['embeddings'][$index] ?? [];
|
||||
AiKbChunk::create([
|
||||
'document_id' => $document->id,
|
||||
'collection_name' => self::COLLECTION_GLOBAL,
|
||||
'chunk_index' => $index,
|
||||
'chunk_text' => $chunkText,
|
||||
'embedding_json' => $embedding,
|
||||
'embedding_model' => $embeddingResult['model'] ?? '',
|
||||
'embedding_dim' => $dimension,
|
||||
'vector_norm' => self::vectorNorm($embedding),
|
||||
'keyword_text' => self::buildKeywordText($payload),
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'document_id' => (int) $document->id,
|
||||
'chunk_count' => count($chunks),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
if ($transactionStarted) {
|
||||
Db::rollback();
|
||||
}
|
||||
return ['success' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public static function deleteDocument(string $domain, string $subtype, int $sourceId): void
|
||||
{
|
||||
AiKbDocument::where([
|
||||
'domain' => $domain,
|
||||
'subtype' => $subtype,
|
||||
'source_id' => $sourceId,
|
||||
])->update([
|
||||
'is_active' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function retrieve(
|
||||
string $scene,
|
||||
string $queryText,
|
||||
array $filters = [],
|
||||
array $structuredContext = [],
|
||||
int $topK = 8
|
||||
): array {
|
||||
try {
|
||||
$topK = $topK > 0 ? $topK : self::intConfig('kb_final_topk', 8);
|
||||
$fulltextTopN = self::intConfig('kb_fulltext_topn', 200);
|
||||
$vectorTopN = self::intConfig('kb_vector_topn', 30);
|
||||
|
||||
$candidates = self::loadCandidates($queryText, $filters, $fulltextTopN);
|
||||
if (empty($candidates)) {
|
||||
self::recordQueryLog($filters, $scene, $queryText, [], 0, 0, AiKbQueryLog::STATUS_SUCCESS);
|
||||
return [
|
||||
'success' => true,
|
||||
'hits' => [],
|
||||
'hit_count' => 0,
|
||||
'domains' => [],
|
||||
'top_scores' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$queryEmbedding = [];
|
||||
$embeddingResult = (new EmbeddingClient())->embed($queryText);
|
||||
if (!empty($embeddingResult['success'])) {
|
||||
$queryEmbedding = $embeddingResult['embeddings'][0] ?? [];
|
||||
}
|
||||
|
||||
foreach ($candidates as &$candidate) {
|
||||
$candidate['metadata_json'] = is_array($candidate['metadata_json'])
|
||||
? $candidate['metadata_json']
|
||||
: (json_decode((string) $candidate['metadata_json'], true) ?: []);
|
||||
$candidate['embedding_json'] = is_array($candidate['embedding_json'])
|
||||
? $candidate['embedding_json']
|
||||
: (json_decode((string) $candidate['embedding_json'], true) ?: []);
|
||||
|
||||
$candidate['vector_score'] = !empty($queryEmbedding)
|
||||
? self::cosineSimilarity($queryEmbedding, $candidate['embedding_json'])
|
||||
: max(0.0, min(1.0, (float) ($candidate['text_score'] ?? 0)));
|
||||
$candidate['freshness_score'] = self::freshnessScore((int) ($candidate['source_updated_at'] ?? 0));
|
||||
$candidate['domain_score'] = self::domainScore($candidate, $filters);
|
||||
$candidate['match_score'] = self::matchScore($candidate, $filters, $structuredContext);
|
||||
$candidate['score'] = round(
|
||||
$candidate['vector_score'] * 0.65
|
||||
+ $candidate['freshness_score'] * 0.15
|
||||
+ $candidate['domain_score'] * 0.10
|
||||
+ $candidate['match_score'] * 0.10,
|
||||
6
|
||||
);
|
||||
}
|
||||
unset($candidate);
|
||||
|
||||
usort($candidates, static fn(array $a, array $b) => $b['score'] <=> $a['score']);
|
||||
$candidates = array_slice($candidates, 0, $vectorTopN);
|
||||
|
||||
$hits = array_map(static function (array $candidate) {
|
||||
return [
|
||||
'document_id' => (int) $candidate['document_id'],
|
||||
'domain' => $candidate['domain'],
|
||||
'subtype' => $candidate['subtype'],
|
||||
'source_id' => (int) $candidate['source_id'],
|
||||
'title' => $candidate['title'],
|
||||
'summary' => $candidate['summary'],
|
||||
'chunk_text' => $candidate['chunk_text'],
|
||||
'score' => (float) $candidate['score'],
|
||||
'vector_score' => (float) $candidate['vector_score'],
|
||||
'freshness_score' => (float) $candidate['freshness_score'],
|
||||
'match_score' => (float) $candidate['match_score'],
|
||||
'source_updated_at' => (int) $candidate['source_updated_at'],
|
||||
'metadata' => $candidate['metadata_json'],
|
||||
];
|
||||
}, array_slice($candidates, 0, $topK));
|
||||
|
||||
$recordStatus = AiKbQueryLog::STATUS_SUCCESS;
|
||||
self::recordQueryLog(
|
||||
$filters,
|
||||
$scene,
|
||||
$queryText,
|
||||
$hits,
|
||||
(int) ($embeddingResult['tokens'] ?? 0),
|
||||
(int) ($embeddingResult['cost_ms'] ?? 0),
|
||||
$recordStatus
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'hits' => $hits,
|
||||
'hit_count' => count($hits),
|
||||
'domains' => array_values(array_unique(array_column($hits, 'domain'))),
|
||||
'top_scores' => array_values(array_map(static fn(array $hit) => $hit['score'], $hits)),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
self::recordQueryLog($filters, $scene, $queryText, [], 0, 0, AiKbQueryLog::STATUS_FAILED);
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $e->getMessage(),
|
||||
'hits' => [],
|
||||
'hit_count' => 0,
|
||||
'domains' => [],
|
||||
'top_scores' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private static function loadCandidates(string $queryText, array $filters, int $limit): array
|
||||
{
|
||||
$queryText = trim($queryText);
|
||||
$query = Db::name('ai_kb_chunk')->alias('c')
|
||||
->join('ai_kb_document d', 'd.id = c.document_id')
|
||||
->where('d.is_active', 1)
|
||||
->field([
|
||||
'c.document_id',
|
||||
'c.chunk_text',
|
||||
'c.embedding_json',
|
||||
'c.embedding_dim',
|
||||
'c.vector_norm',
|
||||
'd.domain',
|
||||
'd.subtype',
|
||||
'd.source_id',
|
||||
'd.title',
|
||||
'd.summary',
|
||||
'd.metadata_json',
|
||||
'd.source_created_at',
|
||||
'd.source_updated_at',
|
||||
]);
|
||||
|
||||
if (!empty($filters['domains']) && is_array($filters['domains'])) {
|
||||
$query->whereIn('d.domain', $filters['domains']);
|
||||
}
|
||||
if (!empty($filters['subtypes']) && is_array($filters['subtypes'])) {
|
||||
$query->whereIn('d.subtype', $filters['subtypes']);
|
||||
}
|
||||
|
||||
$candidates = [];
|
||||
if ($queryText !== '') {
|
||||
try {
|
||||
$ftQuery = clone $query;
|
||||
$ftQuery->fieldRaw(
|
||||
"MATCH(c.chunk_text, c.keyword_text) AGAINST ('" . addslashes($queryText) . "' IN NATURAL LANGUAGE MODE) AS text_score"
|
||||
);
|
||||
$ftQuery->whereRaw(
|
||||
"MATCH(c.chunk_text, c.keyword_text) AGAINST ('" . addslashes($queryText) . "' IN NATURAL LANGUAGE MODE)"
|
||||
);
|
||||
$candidates = $ftQuery->order('text_score', 'desc')->limit($limit)->select()->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$candidates = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
$keywordParts = self::splitQueryTerms($queryText);
|
||||
$fallback = clone $query;
|
||||
foreach (array_slice($keywordParts, 0, 3) as $term) {
|
||||
$fallback->where(function ($subQuery) use ($term) {
|
||||
$subQuery->whereLike('c.chunk_text', '%' . $term . '%')
|
||||
->whereOrLike('d.title', '%' . $term . '%')
|
||||
->whereOrLike('d.keywords', '%' . $term . '%');
|
||||
});
|
||||
}
|
||||
$candidates = $fallback->order('d.source_updated_at', 'desc')->limit($limit)->select()->toArray();
|
||||
foreach ($candidates as &$candidate) {
|
||||
$candidate['text_score'] = 0.2;
|
||||
}
|
||||
unset($candidate);
|
||||
}
|
||||
|
||||
if (!empty($filters['exclude_source']['domain']) && !empty($filters['exclude_source']['source_id'])) {
|
||||
$excludeDomain = $filters['exclude_source']['domain'];
|
||||
$excludeSourceId = (int) $filters['exclude_source']['source_id'];
|
||||
$candidates = array_values(array_filter($candidates, static function (array $candidate) use ($excludeDomain, $excludeSourceId) {
|
||||
return !($candidate['domain'] === $excludeDomain && (int) $candidate['source_id'] === $excludeSourceId);
|
||||
}));
|
||||
}
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
private static function buildDocumentPayload(string $domain, string $subtype, int $sourceId): array
|
||||
{
|
||||
return match ($domain . ':' . $subtype) {
|
||||
'article:article' => self::buildArticlePayload($sourceId),
|
||||
'post:post' => self::buildPostPayload($sourceId),
|
||||
'lottery:draw_result' => self::buildLotteryDrawResultPayload($sourceId),
|
||||
'lottery:ai_history' => self::buildLotteryAiHistoryPayload($sourceId),
|
||||
'match:event' => self::buildMatchEventPayload($sourceId),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private static function buildArticlePayload(int $sourceId): array
|
||||
{
|
||||
$article = Article::where('id', $sourceId)->findOrEmpty();
|
||||
if ($article->isEmpty() || (int) $article->is_show !== 1 || !empty($article->delete_time)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $article->toArray();
|
||||
$ext = is_array($data['ext'] ?? null) ? $data['ext'] : (json_decode((string) ($data['ext'] ?? ''), true) ?: []);
|
||||
$translated = trim((string) ($ext['translated_content'] ?? ''));
|
||||
$content = self::normalizeHtmlText($translated !== '' ? $translated : (string) ($data['content'] ?? ''));
|
||||
$summary = trim((string) ($data['abstract'] ?: $data['desc'] ?: ''));
|
||||
$cateName = ArticleCate::where('id', $data['cid'] ?? 0)->value('name') ?: '';
|
||||
$keywords = array_filter([
|
||||
$data['title'] ?? '',
|
||||
$cateName,
|
||||
$data['author'] ?? '',
|
||||
$data['category'] ?? '',
|
||||
]);
|
||||
|
||||
return [
|
||||
'title' => (string) ($data['title'] ?? ''),
|
||||
'summary' => self::normalizePlainText($summary),
|
||||
'canonical_text' => trim(implode("\n\n", array_filter([
|
||||
$data['title'] ?? '',
|
||||
$summary,
|
||||
$content,
|
||||
]))),
|
||||
'keywords' => implode(',', array_unique($keywords)),
|
||||
'metadata_json' => [
|
||||
'cid' => (int) ($data['cid'] ?? 0),
|
||||
'cate_name' => $cateName,
|
||||
'author' => (string) ($data['author'] ?? ''),
|
||||
'category' => (string) ($data['category'] ?? ''),
|
||||
'article_id' => (int) ($data['article_id'] ?? 0),
|
||||
'published_at' => (string) ($data['published_at'] ?? ''),
|
||||
'is_video' => (int) ($data['is_video'] ?? 0),
|
||||
],
|
||||
'source_created_at' => (int) ($data['create_time'] ?? 0),
|
||||
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildPostPayload(int $sourceId): array
|
||||
{
|
||||
$post = CommunityPost::where('id', $sourceId)->findOrEmpty();
|
||||
if ($post->isEmpty() || (int) $post->status !== 1 || !empty($post->delete_time)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $post->toArray();
|
||||
$ext = is_array($data['ext'] ?? null) ? $data['ext'] : (json_decode((string) ($data['ext'] ?? ''), true) ?: []);
|
||||
$tagIds = Db::name('community_post_tag')->where('post_id', $sourceId)->column('tag_id');
|
||||
$tags = !empty($tagIds)
|
||||
? CommunityTag::whereIn('id', $tagIds)->column('name')
|
||||
: [];
|
||||
|
||||
$segments = array_filter([
|
||||
(string) ($data['content'] ?? ''),
|
||||
(string) ($ext['translated_content'] ?? ''),
|
||||
(string) ($ext['lottery_analysis_content'] ?? ''),
|
||||
implode(' ', $tags),
|
||||
]);
|
||||
$canonicalText = self::normalizePlainText(implode("\n\n", $segments));
|
||||
$summary = self::truncateText($canonicalText, 200);
|
||||
|
||||
return [
|
||||
'title' => self::truncateText($canonicalText, 40) ?: '社区帖子#' . $sourceId,
|
||||
'summary' => $summary,
|
||||
'canonical_text' => $canonicalText,
|
||||
'keywords' => implode(',', array_unique($tags)),
|
||||
'metadata_json' => [
|
||||
'post_type' => (int) ($data['post_type'] ?? 0),
|
||||
'match_id' => (int) ($data['match_id'] ?? 0),
|
||||
'is_paid' => (int) ($data['is_paid'] ?? 0),
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
'tags' => array_values($tags),
|
||||
],
|
||||
'source_created_at' => (int) ($data['create_time'] ?? 0),
|
||||
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildLotteryDrawResultPayload(int $sourceId): array
|
||||
{
|
||||
$row = LotteryDrawResult::where('id', $sourceId)->findOrEmpty();
|
||||
if ($row->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $row->toArray();
|
||||
$game = LotteryGame::where('code', $data['code'])->field('name,template')->findOrEmpty()->toArray();
|
||||
$numbers = array_filter(explode(',', (string) ($data['draw_code'] ?? '')));
|
||||
$trend = is_array($data['trend'] ?? null) ? $data['trend'] : (json_decode((string) ($data['trend'] ?? ''), true) ?: []);
|
||||
$trendText = self::normalizePlainText(json_encode($trend, JSON_UNESCAPED_UNICODE));
|
||||
$title = ($game['name'] ?? $data['code']) . ' 第' . $data['issue'] . '期开奖';
|
||||
$summary = sprintf(
|
||||
'%s 于 %s 开奖,开奖号码为 %s。',
|
||||
$title,
|
||||
(string) ($data['draw_time'] ?? ''),
|
||||
implode('、', $numbers)
|
||||
);
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'summary' => $summary,
|
||||
'canonical_text' => trim(implode("\n", array_filter([
|
||||
$summary,
|
||||
!empty($data['next_issue']) ? '下一期期号:' . $data['next_issue'] : '',
|
||||
!empty($data['next_time']) ? '下期开奖时间:' . $data['next_time'] : '',
|
||||
$trendText !== 'null' ? '走势数据:' . $trendText : '',
|
||||
]))),
|
||||
'keywords' => implode(',', array_filter([
|
||||
$data['code'],
|
||||
$game['name'] ?? '',
|
||||
$game['template'] ?? '',
|
||||
$data['issue'] ?? '',
|
||||
])),
|
||||
'metadata_json' => [
|
||||
'code' => (string) ($data['code'] ?? ''),
|
||||
'issue' => (string) ($data['issue'] ?? ''),
|
||||
'template' => (string) ($game['template'] ?? ''),
|
||||
'game_name' => (string) ($game['name'] ?? ''),
|
||||
'draw_time' => (string) ($data['draw_time'] ?? ''),
|
||||
],
|
||||
'source_created_at' => (int) ($data['create_time'] ?? 0),
|
||||
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildMatchEventPayload(int $sourceId): array
|
||||
{
|
||||
$match = MatchEvent::where('id', $sourceId)->findOrEmpty();
|
||||
if ($match->isEmpty() || (int) ($match->is_show ?? 1) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $match->toArray();
|
||||
$homeTeam = (string) ($data['home_team'] ?? '');
|
||||
$awayTeam = (string) ($data['away_team'] ?? '');
|
||||
$league = (string) ($data['league_name'] ?? '');
|
||||
$matchTime = !empty($data['match_time']) && is_numeric($data['match_time'])
|
||||
? date('Y-m-d H:i', (int) $data['match_time'])
|
||||
: (string) ($data['match_time'] ?? '');
|
||||
|
||||
$history = MatchHistory::where(function ($query) use ($homeTeam, $awayTeam) {
|
||||
$query->where([['home_team', '=', $homeTeam], ['away_team', '=', $awayTeam]])
|
||||
->whereOr([['home_team', '=', $awayTeam], ['away_team', '=', $homeTeam]]);
|
||||
})->order('match_time desc')->limit(5)->select()->toArray();
|
||||
|
||||
$homeRecent = MatchRecent::where('team_name', $homeTeam)
|
||||
->order('match_time desc')->limit(5)->select()->toArray();
|
||||
$awayRecent = MatchRecent::where('team_name', $awayTeam)
|
||||
->order('match_time desc')->limit(5)->select()->toArray();
|
||||
|
||||
$title = trim($homeTeam . ' vs ' . $awayTeam);
|
||||
$summary = sprintf(
|
||||
'%s %s,%s 对阵 %s,比赛时间 %s,当前比分 %s-%s。',
|
||||
$league,
|
||||
$title,
|
||||
$homeTeam,
|
||||
$awayTeam,
|
||||
$matchTime,
|
||||
$data['home_score'] ?? 0,
|
||||
$data['away_score'] ?? 0
|
||||
);
|
||||
|
||||
$canonical = [
|
||||
$summary,
|
||||
'赔率:主胜' . ($data['home_odds'] ?? '-') . ',平局' . ($data['draw_odds'] ?? '-') . ',客胜' . ($data['away_odds'] ?? '-') . '。',
|
||||
'历史交锋:' . self::normalizePlainText(json_encode($history, JSON_UNESCAPED_UNICODE)),
|
||||
$homeTeam . '近期战绩:' . self::normalizePlainText(json_encode($homeRecent, JSON_UNESCAPED_UNICODE)),
|
||||
$awayTeam . '近期战绩:' . self::normalizePlainText(json_encode($awayRecent, JSON_UNESCAPED_UNICODE)),
|
||||
];
|
||||
|
||||
return [
|
||||
'title' => $title ?: '赛事#' . $sourceId,
|
||||
'summary' => self::normalizePlainText($summary),
|
||||
'canonical_text' => trim(implode("\n", array_filter($canonical))),
|
||||
'keywords' => implode(',', array_filter([$league, $homeTeam, $awayTeam])),
|
||||
'metadata_json' => [
|
||||
'league' => $league,
|
||||
'teams' => array_values(array_filter([$homeTeam, $awayTeam])),
|
||||
'match_time' => $matchTime,
|
||||
'status' => (int) ($data['status'] ?? 0),
|
||||
],
|
||||
'source_created_at' => (int) ($data['create_time'] ?? $data['match_time'] ?? 0),
|
||||
'source_updated_at' => (int) ($data['update_time'] ?? $data['match_time'] ?? $data['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildLotteryAiHistoryPayload(int $sourceId): array
|
||||
{
|
||||
$row = LotteryAiAnalysis::where('id', $sourceId)->findOrEmpty();
|
||||
if ($row->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $row->toArray();
|
||||
$resultText = is_string($data['result'] ?? '')
|
||||
? $data['result']
|
||||
: json_encode($data['result'] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
$canonicalText = self::normalizePlainText($resultText);
|
||||
$title = sprintf('%s 第%s期 %s 历史AI分析', $data['code'] ?? '彩票', $data['issue'] ?? '-', $data['module'] ?? 'module');
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'summary' => self::truncateText($canonicalText, 220),
|
||||
'canonical_text' => $canonicalText,
|
||||
'keywords' => implode(',', array_filter([
|
||||
$data['code'] ?? '',
|
||||
$data['issue'] ?? '',
|
||||
$data['module'] ?? '',
|
||||
])),
|
||||
'metadata_json' => [
|
||||
'code' => (string) ($data['code'] ?? ''),
|
||||
'issue' => (string) ($data['issue'] ?? ''),
|
||||
'module' => (string) ($data['module'] ?? ''),
|
||||
],
|
||||
'source_created_at' => (int) ($data['create_time'] ?? 0),
|
||||
'source_updated_at' => (int) ($data['update_time'] ?? $data['create_time'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildKeywordText(array $payload): string
|
||||
{
|
||||
return implode(' ', array_filter([
|
||||
$payload['title'] ?? '',
|
||||
$payload['summary'] ?? '',
|
||||
$payload['keywords'] ?? '',
|
||||
json_encode($payload['metadata_json'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
]));
|
||||
}
|
||||
|
||||
private static function splitText(string $text, int $chunkSize, int $overlap): array
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$length = mb_strlen($text);
|
||||
if ($length <= $chunkSize) {
|
||||
return [$text];
|
||||
}
|
||||
|
||||
$chunks = [];
|
||||
$start = 0;
|
||||
while ($start < $length) {
|
||||
$chunks[] = mb_substr($text, $start, $chunkSize);
|
||||
if ($start + $chunkSize >= $length) {
|
||||
break;
|
||||
}
|
||||
$start += max(1, $chunkSize - $overlap);
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('trim', $chunks)));
|
||||
}
|
||||
|
||||
private static function cosineSimilarity(array $a, array $b): float
|
||||
{
|
||||
if (empty($a) || empty($b) || count($a) !== count($b)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$dot = 0.0;
|
||||
$normA = 0.0;
|
||||
$normB = 0.0;
|
||||
foreach ($a as $i => $value) {
|
||||
$value = (float) $value;
|
||||
$other = (float) ($b[$i] ?? 0.0);
|
||||
$dot += $value * $other;
|
||||
$normA += $value * $value;
|
||||
$normB += $other * $other;
|
||||
}
|
||||
|
||||
if ($normA <= 0.0 || $normB <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return max(0.0, min(1.0, $dot / (sqrt($normA) * sqrt($normB))));
|
||||
}
|
||||
|
||||
private static function vectorNorm(array $vector): float
|
||||
{
|
||||
$sum = 0.0;
|
||||
foreach ($vector as $value) {
|
||||
$value = (float) $value;
|
||||
$sum += $value * $value;
|
||||
}
|
||||
return $sum > 0 ? sqrt($sum) : 0.0;
|
||||
}
|
||||
|
||||
private static function freshnessScore(int $timestamp): float
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return 0.1;
|
||||
}
|
||||
|
||||
$days = max(0, (time() - $timestamp) / 86400);
|
||||
if ($days <= 1) {
|
||||
return 1.0;
|
||||
}
|
||||
if ($days <= 7) {
|
||||
return 0.85;
|
||||
}
|
||||
if ($days <= 30) {
|
||||
return 0.65;
|
||||
}
|
||||
if ($days <= 180) {
|
||||
return 0.4;
|
||||
}
|
||||
return 0.2;
|
||||
}
|
||||
|
||||
private static function domainScore(array $candidate, array $filters): float
|
||||
{
|
||||
$preferred = $filters['preferred_domain'] ?? '';
|
||||
if ($preferred !== '' && ($candidate['domain'] ?? '') === $preferred) {
|
||||
return 1.0;
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
private static function matchScore(array $candidate, array $filters, array $structuredContext): float
|
||||
{
|
||||
$score = 0.2;
|
||||
$metadata = $candidate['metadata_json'] ?? [];
|
||||
$title = self::normalizePlainText((string) ($candidate['title'] ?? ''));
|
||||
$summary = self::normalizePlainText((string) ($candidate['summary'] ?? ''));
|
||||
$chunkText = self::normalizePlainText((string) ($candidate['chunk_text'] ?? ''));
|
||||
$haystack = $title . ' ' . $summary . ' ' . $chunkText;
|
||||
|
||||
if (!empty($filters['cid']) && !empty($metadata['cid']) && (int) $filters['cid'] === (int) $metadata['cid']) {
|
||||
$score += 0.4;
|
||||
}
|
||||
if (!empty($filters['code']) && !empty($metadata['code']) && $filters['code'] === $metadata['code']) {
|
||||
$score += 0.4;
|
||||
}
|
||||
if (!empty($filters['issue']) && !empty($metadata['issue']) && $filters['issue'] === $metadata['issue']) {
|
||||
$score += 0.2;
|
||||
}
|
||||
if (!empty($filters['tags']) && !empty($metadata['tags']) && is_array($metadata['tags'])) {
|
||||
$common = array_intersect($filters['tags'], $metadata['tags']);
|
||||
if (!empty($common)) {
|
||||
$score += 0.3;
|
||||
}
|
||||
}
|
||||
if (!empty($structuredContext['title'])) {
|
||||
$sourceTitle = self::normalizePlainText((string) $structuredContext['title']);
|
||||
if ($title !== '' && $sourceTitle !== '' && mb_strpos($title, mb_substr($sourceTitle, 0, 8)) !== false) {
|
||||
$score += 0.2;
|
||||
}
|
||||
}
|
||||
if (!empty($structuredContext['league'])) {
|
||||
$league = self::normalizePlainText((string) $structuredContext['league']);
|
||||
if ($league !== '' && mb_strpos($haystack, $league) !== false) {
|
||||
$score += 0.15;
|
||||
}
|
||||
}
|
||||
if (!empty($structuredContext['teams']) && is_array($structuredContext['teams'])) {
|
||||
$teamHits = 0;
|
||||
foreach ($structuredContext['teams'] as $team) {
|
||||
$team = self::normalizePlainText((string) $team);
|
||||
if ($team !== '' && mb_strpos($haystack, $team) !== false) {
|
||||
$teamHits++;
|
||||
}
|
||||
}
|
||||
if ($teamHits > 0) {
|
||||
$score += min(0.3, 0.12 * $teamHits);
|
||||
}
|
||||
}
|
||||
|
||||
return max(0.0, min(1.0, $score));
|
||||
}
|
||||
|
||||
private static function recordQueryLog(
|
||||
array $filters,
|
||||
string $scene,
|
||||
string $queryText,
|
||||
array $hits,
|
||||
int $tokens,
|
||||
int $costMs,
|
||||
int $status
|
||||
): void {
|
||||
try {
|
||||
$userId = (int) ($filters['user_id'] ?? 0);
|
||||
$refId = (int) ($filters['ref_id'] ?? 0);
|
||||
AiKbQueryLog::create([
|
||||
'user_id' => $userId,
|
||||
'scene' => $scene,
|
||||
'ref_id' => $refId,
|
||||
'query_text' => $queryText,
|
||||
'filters_json' => $filters,
|
||||
'hit_docs_json' => $hits,
|
||||
'tokens_used' => $tokens,
|
||||
'cost_ms' => $costMs,
|
||||
'status' => $status,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
|
||||
AiLog::create([
|
||||
'user_id' => $userId,
|
||||
'type' => 6,
|
||||
'ref_id' => $refId,
|
||||
'tokens_used' => $tokens,
|
||||
'cost_ms' => $costMs,
|
||||
'status' => $status === AiKbQueryLog::STATUS_SUCCESS ? 1 : 2,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore logging failures to avoid affecting main flow.
|
||||
}
|
||||
}
|
||||
|
||||
private static function splitQueryTerms(string $queryText): array
|
||||
{
|
||||
$queryText = trim(self::normalizePlainText($queryText));
|
||||
if ($queryText === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$terms = preg_split('/[\s,,。!?;、::\/\\\\]+/u', $queryText) ?: [];
|
||||
$terms = array_values(array_filter(array_map('trim', $terms), static fn(string $term) => mb_strlen($term) >= 2));
|
||||
return array_unique($terms);
|
||||
}
|
||||
|
||||
private static function normalizeHtmlText(string $html): string
|
||||
{
|
||||
$html = str_replace(['<br>', '<br/>', '<br />', '</p>', '</div>', '</li>'], "\n", $html);
|
||||
return self::normalizePlainText(strip_tags($html));
|
||||
}
|
||||
|
||||
private static function normalizePlainText(string $text): string
|
||||
{
|
||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = preg_replace('/\s+/u', ' ', trim($text)) ?: '';
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
private static function truncateText(string $text, int $length): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
return mb_strlen($text) > $length ? mb_substr($text, 0, $length) . '...' : $text;
|
||||
}
|
||||
|
||||
private static function intConfig(string $name, int $default): int
|
||||
{
|
||||
return (int) AiConfig::getVal($name, (string) $default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service\ai;
|
||||
|
||||
use app\common\model\ai\AiKbDocument;
|
||||
use app\common\model\ai\AiKbSyncJob;
|
||||
use think\facade\Db;
|
||||
|
||||
class KbSyncService
|
||||
{
|
||||
public static function enqueue(
|
||||
string $domain,
|
||||
string $subtype,
|
||||
int $sourceId,
|
||||
string $action = AiKbSyncJob::ACTION_UPSERT,
|
||||
int $priority = 100,
|
||||
?int $scheduledAt = null
|
||||
): void {
|
||||
if ($domain === '' || $subtype === '' || $sourceId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scheduledAt = $scheduledAt ?: time();
|
||||
$pending = AiKbSyncJob::where([
|
||||
'domain' => $domain,
|
||||
'subtype' => $subtype,
|
||||
'source_id' => $sourceId,
|
||||
])->whereIn('status', [AiKbSyncJob::STATUS_PENDING, AiKbSyncJob::STATUS_PROCESSING])
|
||||
->order('id', 'desc')
|
||||
->findOrEmpty();
|
||||
|
||||
if (!$pending->isEmpty() && (int) $pending->status === AiKbSyncJob::STATUS_PENDING) {
|
||||
$pending->action = $action;
|
||||
$pending->priority = $priority;
|
||||
$pending->scheduled_at = $scheduledAt;
|
||||
$pending->error_msg = '';
|
||||
$pending->update_time = time();
|
||||
$pending->save();
|
||||
return;
|
||||
}
|
||||
|
||||
AiKbSyncJob::create([
|
||||
'domain' => $domain,
|
||||
'subtype' => $subtype,
|
||||
'source_id' => $sourceId,
|
||||
'action' => $action,
|
||||
'priority' => $priority,
|
||||
'status' => AiKbSyncJob::STATUS_PENDING,
|
||||
'retry_count' => 0,
|
||||
'error_msg' => '',
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'processed_at' => 0,
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function markLotteryBackfillJobs(int $limit = 200): void
|
||||
{
|
||||
self::enqueueMissingLotteryRows('draw_result', 'lottery_draw_result', $limit);
|
||||
self::enqueueMissingLotteryRows('ai_history', 'lottery_ai_analysis', $limit);
|
||||
}
|
||||
|
||||
public static function markMatchBackfillJobs(int $limit = 200): void
|
||||
{
|
||||
self::enqueueMissingRows('match', 'event', 'match', $limit);
|
||||
}
|
||||
|
||||
private static function enqueueMissingLotteryRows(string $subtype, string $table, int $limit): void
|
||||
{
|
||||
self::enqueueMissingRows('lottery', $subtype, $table, $limit);
|
||||
}
|
||||
|
||||
private static function enqueueMissingRows(string $domain, string $subtype, string $table, int $limit): void
|
||||
{
|
||||
try {
|
||||
$rows = Db::name($table)->alias('s')
|
||||
->leftJoin('ai_kb_document d', "d.domain = '{$domain}' AND d.subtype = '{$subtype}' AND d.source_id = s.id")
|
||||
->whereNull('d.id')
|
||||
->where('s.id', '>', 0)
|
||||
->order('s.id', 'desc')
|
||||
->limit($limit)
|
||||
->column('s.id');
|
||||
|
||||
foreach ($rows as $sourceId) {
|
||||
self::enqueue($domain, $subtype, (int) $sourceId, AiKbSyncJob::ACTION_UPSERT, 80);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore in environments where KB tables are not ready yet.
|
||||
}
|
||||
}
|
||||
|
||||
public static function markDocumentInactive(string $domain, string $subtype, int $sourceId): void
|
||||
{
|
||||
AiKbDocument::where([
|
||||
'domain' => $domain,
|
||||
'subtype' => $subtype,
|
||||
'source_id' => $sourceId,
|
||||
])->update([
|
||||
'is_active' => 0,
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user