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(['
', '
', '
', '

', '', ''], "\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); } }