no message
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user