Files
sbnews/server/app/common/service/ai/KbService.php
T
2026-06-11 12:15:29 +08:00

814 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common\service\ai;
use app\common\model\ai\AiConfig;
use app\common\model\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);
}
}